diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6a09104..d451104 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,6 +114,14 @@ Models are addressed as `"driver:model"` (split on the first `:`). (SSE) implementation; concrete drivers only set base URL, key, and fallback model list. A driver is enabled only when its `*_ENABLED` flag is set **and** its key is non-empty; failing drivers are skipped (not fatal) in listings. +- Personal providers (BYOK, `src/userProviders.ts`): users attach their own + `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 + 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 headless browser (WebDriver BiDi) with a **throwaway profile** (mkdtemp diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 62a321d..c788c64 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -24,7 +24,7 @@ Copy `.env.example` to `.env`. Full list: | `RESET_TTL_MIN` | `60` | reset-link lifetime (single use) | | `RATE_LIMIT_MAX` / `RATE_LIMIT_WINDOW_MIN` | `5` / `10` | login rate limit per IP | | `OLLAMA_HOST` / `OLLAMA_ENABLED` | `http://10.66.66.4:11434` / `true` | local-model driver | -| `MISTRAL_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` | +| `MISTRAL_*`, `GLM_*`, `DEEPSEEK_*`, `ANTHROPIC_*`, `OPENAI_*`, `GEMINI_*` | disabled | API drivers: set `*_ENABLED=true` **and** `*_API_KEY` | | `GLM_BASE_URL` | `https://api.z.ai/api/paas/v4` | Zhipu platform override (mainland China: `https://open.bigmodel.cn/api/paas/v4`) | ## Local development @@ -89,6 +89,23 @@ an **Enabled** switch and **hourly / daily** per-user request caps - Routes: `GET /api/admin/model-policy[?refresh=1]`, `PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`). +## Personal providers (BYOK) + +Users can connect their own `OpenAI` / `Anthropic` / `DeepSeek` / `Gemini` +API keys in **Settings → Providers** (per account, no admin involved). +Connected providers add their models to that user's model menu at the top +(no page refresh needed); usage is billed to the user's own provider +account, and the admin model policy (kill-switch + rate limits) still +applies. + +- Keys are stored server-side in `user_provider_keys` and **never returned** + to any client (`GET /api/me/providers` exposes presence + last 4 chars + only; never logged). A stored key greys out its field (Replace to rotate, + Remove to disconnect). Deleting the account wipes its keys. +- Routes: `GET /api/me/providers`, `PUT /api/me/providers/:id` + (`{apiKey}`, 8–256 chars), `DELETE /api/me/providers/:id` (all Bearer, + owner-scoped). + ## Access-request wishlist (registration approval queue) When public registration is off, visitors can still reserve a username via diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index 63cfc3c..db44c08 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -1019,6 +1019,9 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) { const { t } = useT(); const [list, setList] = useState(null); const [keys, setKeys] = useState>({}); + // Connected providers show a greyed-out (disabled) key field; "Replace" + // unlocks it for rotation. + const [editing, setEditing] = useState>({}); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); @@ -1050,6 +1053,7 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) { const res = await api.setProvider(id, v); setList(res.providers); setKeys((k) => ({ ...k, [id]: "" })); + setEditing((e) => ({ ...e, [id]: false })); setNotice(t("providers.keySaved")); onChanged?.(); } catch (err) { @@ -1066,6 +1070,7 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) { try { const res = await api.deleteProvider(id); setList(res.providers); + setEditing((e) => ({ ...e, [id]: false })); setNotice(t("providers.keyRemoved")); onChanged?.(); } catch (err) { @@ -1091,6 +1096,8 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) { const st = byId.get(m.id); const connected = !!st?.hasKey; const busy = busyId === m.id; + // A stored key locks the field (greyed out) until "Replace". + const locked = connected && !editing[m.id]; return (
  • @@ -1129,29 +1136,54 @@ function ProvidersSection({ onChanged }: { onChanged?: () => void }) { type="password" autoComplete="off" spellCheck={false} - value={keys[m.id] ?? ""} + value={locked ? "" : (keys[m.id] ?? "")} onChange={(e) => setKeys((k) => ({ ...k, [m.id]: e.target.value }))} onKeyDown={(e) => { - if (e.key === "Enter") { + if (e.key === "Enter" && !locked) { e.preventDefault(); save(m.id); } }} - placeholder={connected ? `${t("providers.keyPh")} (${t("providers.connected").toLowerCase()})` : t("providers.keyPh")} + placeholder={locked && st?.last4 ? `••••${st.last4}` : t("providers.keyPh")} aria-label={`${m.label} API key`} - disabled={busy} + disabled={busy || locked} + className="disabled:cursor-not-allowed disabled:bg-stone-100 disabled:opacity-60 dark:disabled:bg-zinc-800" /> - + {!locked && ( + + )}
    {connected && ( -
    +
    + {locked ? ( + + ) : ( + + )}