diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d451104..f12dd42 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -118,9 +118,11 @@ Models are addressed as `"driver:model"` (split on the first `:`). `openai` / `anthropic` / `deepseek` / `gemini` keys in settings (`GET/PUT/DELETE /api/me/providers`, keys stored in `user_provider_keys`, never returned — GET exposes presence + last4 only). `GET /api/models` - merges the caller's own-key models into their menu, and `POST /api/chat` - prefers the personal key for those four providers (also when the global - driver is disabled). The admin model policy still applies. Driver + merges the caller's own-key models into their menu (a personal entry + replaces the platform entry with the same id, flagged `personal: true` so + the picker lists it under its own "driver · personal key" section), and + `POST /api/chat` prefers the personal key for those four providers (also + when the global driver is disabled). The admin model policy still applies. Driver constructors accept an `apiKeyOverride` for these per-user instances. - `ArcaicSubDriver` ×3 (`arcaic-openai`, `arcaic-gemini`, `arcaic-qwen`, one model each: `chat`) + `browser.ts` engine: web-UI sessions driven by a diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index ae15c93..e4bfa92 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -241,9 +241,12 @@ export function Chat({ const modelGroups: PickerGroup[] = (() => { const byDriver = new Map(); for (const m of models) { - const group = m.group ?? m.driver; + // Own-key (BYOK) models get their own section so they are never + // confused with platform models: "openai · personal key". + const base = m.group ?? m.driver; + const group = m.personal ? `${base} · ${t("chat.personalTag")}` : base; const list = byDriver.get(group) ?? []; - list.push({ value: m.id, label: m.label, hint: m.id }); + list.push({ value: m.id, label: m.label, hint: m.personal ? `${m.id} · ${t("chat.personalTag")}` : m.id }); byDriver.set(group, list); } return [...byDriver.entries()].map(([group, options]) => ({ group, options })); diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index af91640..33ca5e3 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -93,6 +93,7 @@ const en = { "chat.modelAria": "Model", "chat.noModels": "No models", "chat.selectModel": "Select model…", + "chat.personalTag": "personal key", "chat.you": "You", "chat.greet": "How can I help, {name}?", "chat.greetSub": "Pick a model, then type below. Markdown is supported.", @@ -633,6 +634,7 @@ const es: Dict = { "chat.modelAria": "Modelo", "chat.noModels": "Sin modelos", "chat.selectModel": "Elegir modelo…", + "chat.personalTag": "clave personal", "chat.you": "Tú", "chat.greet": "¿En qué te ayudo, {name}?", "chat.greetSub": "Elige un modelo y escribe abajo. Markdown soportado.", @@ -1163,6 +1165,7 @@ const fr: Dict = { "chat.modelAria": "Modèle", "chat.noModels": "Aucun modèle", "chat.selectModel": "Choisir un modèle…", + "chat.personalTag": "clé personnelle", "chat.you": "Vous", "chat.greet": "Comment puis-je aider, {name} ?", "chat.greetSub": @@ -1693,6 +1696,7 @@ const ru: Dict = { "chat.modelAria": "Модель", "chat.noModels": "Нет моделей", "chat.selectModel": "Выбрать модель…", + "chat.personalTag": "свой ключ", "chat.you": "Вы", "chat.greet": "Чем помочь, {name}?", "chat.greetSub": "Выберите модель и пишите ниже. Поддерживается Markdown.", @@ -2210,6 +2214,7 @@ const it: Dict = { "chat.modelAria": "Modello", "chat.noModels": "Nessun modello", "chat.selectModel": "Scegli modello…", + "chat.personalTag": "chiave personale", "chat.you": "Tu", "chat.greet": "Come posso aiutare, {name}?", "chat.greetSub": "Scegli un modello e scrivi qui sotto. Markdown supportato.", @@ -2733,6 +2738,7 @@ const pt: Dict = { "chat.modelAria": "Modelo", "chat.noModels": "Sem modelos", "chat.selectModel": "Escolher modelo…", + "chat.personalTag": "chave pessoal", "chat.you": "Tu", "chat.greet": "Como posso ajudar, {name}?", "chat.greetSub": "Escolhe um modelo e escreve abaixo. Markdown suportado.", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index fa6d567..782d7ce 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -31,6 +31,8 @@ export interface DriverModel { driver: string; label: string; group?: string; + /** Served through your own provider key (BYOK) — shown in its own menu section. */ + personal?: boolean; } export interface Attachment { diff --git a/src/drivers/types.ts b/src/drivers/types.ts index c2addaf..f1fe9e7 100644 --- a/src/drivers/types.ts +++ b/src/drivers/types.ts @@ -13,6 +13,9 @@ export interface DriverModel { label: string; /** Display category in the model picker (defaults to `driver`). */ group?: string; + /** True when served through the caller's own provider key (BYOK) rather + * than the platform key — the UI lists these in their own section. */ + personal?: boolean; } export interface ChatOptions { diff --git a/src/index.ts b/src/index.ts index bd145f5..9793f8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1274,16 +1274,17 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ // mutated by the per-user merge below. const models = [...(await registry.listAllModelsCached(wantRefresh && admin))]; // Personal providers (BYOK): merge the caller's own-key models. - // Same ids as the global drivers, so already-listed ones are skipped - // and the model picker groups them with their driver. + // A personal entry replaces the platform entry with the same id so + // the menu shows one clearly-marked (personal: true) section per + // connected provider — matching /api/chat, which bills those ids + // to the personal key. try { const personal = await listUserProviderModels(user.id); - const seen = new Set(models.map((m) => m.id)); + const at = new Map(models.map((m, i) => [m.id, i])); for (const m of personal) { - if (!seen.has(m.id)) { - seen.add(m.id); - models.push(m); - } + const idx = at.get(m.id); + if (idx === undefined) models.push(m); + else models[idx] = m; } } catch { // personal listing is best-effort — global models still served diff --git a/src/userProviders.ts b/src/userProviders.ts index 58c591a..60133e6 100644 --- a/src/userProviders.ts +++ b/src/userProviders.ts @@ -99,9 +99,10 @@ export function buildUserProviderDriver(provider: UserProviderId, apiKey: string } /** - * List models reachable with the user's own keys. Never throws: a failing - * provider is skipped (its driver falls back to a static list, and only a - * total failure is swallowed here). + * List models reachable with the user's own keys. Every entry is marked + * `personal: true` so the UI can list them in their own menu section. + * Never throws: a failing provider is skipped (its driver falls back to a + * static list, and only a total failure is swallowed here). */ export async function listUserProviderModels(userId: string): Promise { const out: DriverModel[] = []; @@ -109,7 +110,9 @@ export async function listUserProviderModels(userId: string): Promise