diff --git a/.env.example b/.env.example index 3d64f8b..5462678 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,9 @@ ANTHROPIC_API_KEY= ANTHROPIC_ENABLED=false OPENAI_API_KEY= OPENAI_ENABLED=false +# Gemini (Google AI, OpenAI-compatible endpoint). Key from https://aistudio.google.com/apikey +GEMINI_API_KEY= +GEMINI_ENABLED=false # Arcaic backend (headless browser, throwaway profile per process). # Every process launch creates a throwaway Firefox profile (deleted on exit). ARCAIC_ENABLED=false diff --git a/README.md b/README.md index 59a903a..f1b1d61 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ from the **Accounts** panel (each gets a one-time access key to hand over). | GET | `/api/conversations/archived` | Bearer | archived chats (hidden from sidebar + search) | | POST | `/api/conversations/:id/archive`, `/api/conversations/:id/unarchive` | owner | archive (read-only) / restore to the list | | GET | `/api/share/:publicId` | **public** | read-only shared chat JSON | -| GET | `/api/models` | Bearer | `[{id: "driver:model", …}]` across enabled drivers | +| GET | `/api/models` | Bearer | `[{id: "driver:model", …}]` across enabled drivers + your own-key providers | +| GET/PUT/DELETE | `/api/me/providers`, `/api/me/providers/:id` | Bearer | personal providers (BYOK: openai/anthropic/deepseek/gemini) — keys stored server-side, never returned | | GET/PATCH | `/api/admin/model-policy` | admin | per-model kill-switch + hourly/daily limits (users filtered, enforced in chat) | | POST | `/api/chat` | Bearer | `{model, messages, conversationId?, stream?}` → JSON or SSE | @@ -92,7 +93,8 @@ reply into that conversation (ownership enforced) and auto-titles new chats. `$DATA_DIR/kisassistant.db` (WAL mode). Tables: `users` (id, username, display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens), `conversations` (id, user, title, topic, model, archived_at), `messages`, `shares`, -`resets` (recovery tokens), `settings` (feature flags) +`resets` (recovery tokens), `settings` (feature flags), +`user_provider_keys` (per-user BYOK API keys: user_id, provider, api_key) (conv ↔ public id). No migration system — schema is created idempotently at boot. ## Drivers (backend layout) @@ -101,10 +103,11 @@ display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tok src/drivers/types.ts LLMDriver interface + DriverModel + errors src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream src/drivers/apiBase.ts ApiDriverBase (OpenAI-compatible SSE) base class -src/drivers/apis.ts Mistral / DeepSeek / Anthropic / OpenAI drivers +src/drivers/apis.ts Mistral / DeepSeek / Anthropic / OpenAI / Gemini drivers (global key or per-user override) src/drivers/browser.ts ArcaicBrowserEngine + site configs (openai/qwen/gemini) — throwaway Firefox profile src/drivers/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED) src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing +src/userProviders.ts personal providers (BYOK): per-user keys, own-key driver factory src/db.ts SQLite store (users, sessions, convs, messages, shares) src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD) ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7fdd3bd..6a09104 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,6 +49,7 @@ Created idempotently at boot in `getDb()` (no migration framework; keep it so). | `resets` | `token_hash PK, user_id, created_at, expires_at`. One-time key-recovery tokens (single use, consumed on POST). | | `settings` | `key PK, value, updated_at`. Admin-controlled feature flags (`registration_enabled` default off, `tools_ocr_enabled` default on). | | `conversations` | `id, user_id, title, topic, model, archived_at, created_at, updated_at`. `archived_at = 0` means live; otherwise a unix-ms archive timestamp (added idempotently via `ALTER TABLE`, like `users.email`). | +| `user_provider_keys` | `user_id, provider PK, api_key, updated_at`. Personal LLM keys (BYOK) — stored reversibly (needed upstream), never returned to clients (GET exposes presence + last4 only). Wiped with the account in `deleteUser()`. | | `messages` | `id AUTOINCREMENT, conv_id → conversations ON DELETE CASCADE, role, content, created_at`. | | `shares` | `conv_id PK → conversations ON DELETE CASCADE, public_id UNIQUE, created_at`. | @@ -99,7 +100,7 @@ only ever sees reviewed text blocks, never raw files. ## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`), `DriverRegistry` with global priority -`ollama → mistral → glm → arcaic-openai → arcaic-gemini → arcaic-qwen → deepseek → anthropic → openai`. +`ollama → mistral → glm → gemini → arcaic-openai → arcaic-gemini → arcaic-qwen → deepseek → anthropic → openai`. Models are addressed as `"driver:model"` (split on the first `:`). - `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0550365..2bffdb5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -505,6 +505,23 @@ export function App() { await refreshConvs(); } + /** + * Settings "Providers" changed (key saved/removed): reload the model menu + * so connected providers appear (or disappear) at the top without a + * page refresh. The current pick survives when still available. + */ + async function handleProvidersChanged() { + try { + const m = await api.models(); + setModels(m.models); + setModel((cur) => + cur && m.models.some((x) => x.id === cur) ? cur : (m.models[0]?.id ?? ""), + ); + } catch { + // ignore (session errors reload the page via api layer) + } + } + // Auth pages render instantly (no splash); the platform boots behind one. if (path === "/login") { if (user) return null; // bounce to /chat imminent @@ -630,6 +647,7 @@ export function App() { }} onViewArchived={openArchived} onArchivedChanged={handleArchivedChanged} + onProvidersChanged={handleProvidersChanged} /> ); diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 8b66fd4..6a102ed 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -379,7 +379,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () ))} )} -
+
{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })} {pages > 1 && (
diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index 17e128e..63cfc3c 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; -import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react"; -import { api } from "../lib/api.ts"; +import { Archive, ArchiveRestore, Eye, Fingerprint, Globe, FileText, ImagePlus, KeyRound, Link2, Mail, Palette, Pencil, Plug, ScanText, Search, Settings2, ShieldCheck, Sparkles, Tag, Trash2, Unplug, User as UserIcon, X } from "lucide-react"; +import { api, type UserProvider } from "../lib/api.ts"; import { cn } from "../lib/cn.ts"; import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts"; import { FEATURES, setFeature, useFeatures } from "../lib/features.ts"; @@ -262,6 +262,7 @@ export function SettingsModal({ onKeyRotated, onViewArchived, onArchivedChanged, + onProvidersChanged, }: { user: User | null; onClose: () => void; @@ -269,6 +270,7 @@ export function SettingsModal({ onKeyRotated?: () => void; onViewArchived: (c: Conversation) => void; onArchivedChanged: (deletedId?: string) => void; + onProvidersChanged?: () => void; }) { const { t } = useT(); const [displayName, setDisplayName] = useState(""); @@ -301,6 +303,7 @@ export function SettingsModal({ { id: "appearance", title: t("settings.appearance"), icon: Palette, keywords: ["appearance", "theme", "dark", "light", "color", "colour", "accent", "palette", "language", "langue", "idioma", "lingua", "язык"] }, { id: "features", title: t("settings.features"), icon: Sparkles, keywords: ["features", "thinking", "attachments", "search", "deep", "upload", "ocr", "capabilities", "enable", "disable"] }, { id: "security", title: t("settings.security"), icon: ShieldCheck, keywords: ["security", "key", "rotate", "password", "totp", "2fa", "two", "factor", "authenticator", "passkey", "webauthn"] }, + { id: "providers", title: t("settings.providers"), icon: Plug, keywords: ["providers", "provider", "api", "keys", "openai", "anthropic", "deepseek", "gemini", "byok", "model", "fournisseur", "proveedor", "провайдер"] }, { id: "archived", title: t("archived.title"), icon: Archive, keywords: ["archived", "archive", "old", "hidden", "read-only", "readonly", "archiv"] }, ] as const; type CatId = (typeof CATS)[number]["id"]; @@ -546,6 +549,12 @@ export function SettingsModal({ )} + {activeCat === "providers" && ( + + + + )} + {activeCat === "archived" && ( @@ -996,3 +1005,169 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
); } + +/* ---------- Providers (settings): personal API keys (BYOK) ---------- */ + +const PROVIDER_META: { id: string; label: string; docs: string }[] = [ + { id: "openai", label: "OpenAI", docs: "https://platform.openai.com/api-keys" }, + { id: "anthropic", label: "Anthropic", docs: "https://console.anthropic.com/settings/keys" }, + { id: "deepseek", label: "DeepSeek", docs: "https://platform.deepseek.com/api_keys" }, + { id: "gemini", label: "Gemini", docs: "https://aistudio.google.com/apikey" }, +]; + +function ProvidersSection({ onChanged }: { onChanged?: () => void }) { + const { t } = useT(); + const [list, setList] = useState(null); + const [keys, setKeys] = useState>({}); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + async function refresh() { + try { + const res = await api.providers(); + setList(res.providers); + } catch (err) { + setError(err instanceof Error ? err.message : t("providers.loadFailed")); + } + } + + useEffect(() => { + refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function save(id: string) { + const v = (keys[id] ?? "").trim(); + if (v.length < 8 || v.length > 256 || /\s/.test(v)) { + setError(t("providers.invalidKey")); + return; + } + setBusyId(id); + setError(""); + setNotice(""); + try { + const res = await api.setProvider(id, v); + setList(res.providers); + setKeys((k) => ({ ...k, [id]: "" })); + setNotice(t("providers.keySaved")); + onChanged?.(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.saveFailed")); + } finally { + setBusyId(null); + } + } + + async function remove(id: string) { + setBusyId(id); + setError(""); + setNotice(""); + try { + const res = await api.deleteProvider(id); + setList(res.providers); + setNotice(t("providers.keyRemoved")); + onChanged?.(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.deleteFailed")); + } finally { + setBusyId(null); + } + } + + if (list === null && !error) { + return

{t("common.loading")}

; + } + + const byId = new Map((list ?? []).map((p) => [p.provider, p])); + + return ( +
+

{t("providers.intro")}

+ {error &&

{error}

} + {notice &&

{notice}

} +
    + {PROVIDER_META.map((m) => { + const st = byId.get(m.id); + const connected = !!st?.hasKey; + const busy = busyId === m.id; + return ( +
  • +
    + + {m.label} + + {connected ? t("providers.connected") : t("providers.notConnected")} + + {connected && st?.last4 && ( + + ••••{st.last4} + + )} + + {t("providers.getKey")} + +
    +
    + setKeys((k) => ({ ...k, [m.id]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + save(m.id); + } + }} + placeholder={connected ? `${t("providers.keyPh")} (${t("providers.connected").toLowerCase()})` : t("providers.keyPh")} + aria-label={`${m.label} API key`} + disabled={busy} + /> + +
    + {connected && ( +
    + +
    + )} +
  • + ); + })} +
+

{t("providers.hint")}

+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 60b1123..e8cbae8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -48,6 +48,14 @@ export interface ModelPolicyEntry { daily: number; } +export interface UserProvider { + provider: string; + label: string; + hasKey: boolean; + last4: string | null; + updatedAt: number; +} + export interface PolicyModel extends DriverModel { enabled: boolean; hourly: number; @@ -291,6 +299,16 @@ export const api = { /** Admin: delete a local Ollama model (frees disk on the Ollama host). */ ollamaDelete: (name: string) => req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}`, { method: "DELETE" }), + /** Personal LLM providers (BYOK): presence + last4 only, keys never leave the server. */ + providers: () => req<{ providers: UserProvider[] }>("/api/me/providers"), + setProvider: (provider: string, apiKey: string) => + req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { + method: "PUT", + body: JSON.stringify({ apiKey }), + }), + deleteProvider: (provider: string) => + req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }), + tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[] }>("/api/tools"), /** Send an image to the platform OCR tool. Returns editable text (never the raw image). */ diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index f003500..af91640 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -183,6 +183,22 @@ const en = { "settings.accentAria": "Accent: {label}", "settings.featHint": "Turn composer capabilities on or off. Applied instantly, saved on this device.", + "settings.providers": "Providers", + "providers.intro": + "Connect your own API keys. Models from connected providers show up in the model menu at the top.", + "providers.hint": + "Keys stay on this server and are never shown again — only the last 4 characters. Usage is billed to your own provider account, and the admin model policy still applies.", + "providers.connected": "Connected", + "providers.notConnected": "Not connected", + "providers.keyPh": "Paste API key…", + "providers.saveKey": "Save key", + "providers.removeKey": "Remove", + "providers.replaceKey": "Replace", + "providers.getKey": "Get a key", + "providers.keySaved": "Key saved — its models are now in the menu.", + "providers.keyRemoved": "Key removed.", + "providers.invalidKey": "That key looks invalid (8–256 characters, no spaces).", + "providers.loadFailed": "Could not load providers.", "accent.preset.emerald": "Emerald", "accent.preset.rose": "Rose", @@ -705,6 +721,22 @@ const es: Dict = { "settings.accentAria": "Acento: {label}", "settings.featHint": "Activa funciones del editor. Al instante, en este dispositivo.", + "settings.providers": "Proveedores", + "providers.intro": + "Conecta tus propias claves API. Los modelos de los proveedores conectados aparecen en el menú de modelos de arriba.", + "providers.hint": + "Las claves quedan en este servidor y no se muestran de nuevo — solo los últimos 4 caracteres. El uso se factura a tu propia cuenta y la política de modelos del admin sigue vigente.", + "providers.connected": "Conectado", + "providers.notConnected": "Sin conectar", + "providers.keyPh": "Pega la clave API…", + "providers.saveKey": "Guardar clave", + "providers.removeKey": "Quitar", + "providers.replaceKey": "Reemplazar", + "providers.getKey": "Obtener clave", + "providers.keySaved": "Clave guardada — sus modelos ya están en el menú.", + "providers.keyRemoved": "Clave eliminada.", + "providers.invalidKey": "Esa clave no parece válida (8–256 caracteres, sin espacios).", + "providers.loadFailed": "No se pudieron cargar los proveedores.", "accent.preset.emerald": "Esmeralda", "accent.preset.rose": "Rosa", @@ -1220,6 +1252,22 @@ const fr: Dict = { "settings.accentAria": "Accent : {label}", "settings.featHint": "Activez les fonctions du composer. Aussitôt, sur cet appareil.", + "settings.providers": "Fournisseurs", + "providers.intro": + "Connectez vos propres clés API. Les modèles des fournisseurs connectés apparaissent dans le menu des modèles en haut.", + "providers.hint": + "Les clés restent sur ce serveur et ne sont plus jamais affichées — seuls les 4 derniers caractères. L'usage est facturé sur votre propre compte, et la politique de modèles de l'admin reste en vigueur.", + "providers.connected": "Connecté", + "providers.notConnected": "Non connecté", + "providers.keyPh": "Collez la clé API…", + "providers.saveKey": "Enregistrer", + "providers.removeKey": "Retirer", + "providers.replaceKey": "Remplacer", + "providers.getKey": "Obtenir une clé", + "providers.keySaved": "Clé enregistrée — ses modèles sont dans le menu.", + "providers.keyRemoved": "Clé retirée.", + "providers.invalidKey": "Cette clé semble invalide (8–256 caractères, sans espaces).", + "providers.loadFailed": "Impossible de charger les fournisseurs.", "accent.preset.emerald": "Émeraude", "accent.preset.rose": "Rose", @@ -1730,6 +1778,22 @@ const ru: Dict = { "settings.accentAria": "Акцент: {label}", "settings.featHint": "Включайте возможности редактора. Сразу, на этом устройстве.", + "settings.providers": "Провайдеры", + "providers.intro": + "Подключите свои API-ключи. Модели подключённых провайдеров появятся в меню моделей сверху.", + "providers.hint": + "Ключи хранятся на этом сервере и больше не показываются — только последние 4 символа. Оплата идёт на ваш счёт у провайдера, политика моделей админа действует.", + "providers.connected": "Подключено", + "providers.notConnected": "Не подключено", + "providers.keyPh": "Вставьте API-ключ…", + "providers.saveKey": "Сохранить", + "providers.removeKey": "Удалить", + "providers.replaceKey": "Заменить", + "providers.getKey": "Получить ключ", + "providers.keySaved": "Ключ сохранён — модели уже в меню.", + "providers.keyRemoved": "Ключ удалён.", + "providers.invalidKey": "Похоже, ключ неверный (8–256 символов, без пробелов).", + "providers.loadFailed": "Не удалось загрузить провайдеров.", "accent.preset.emerald": "Изумруд", "accent.preset.rose": "Роза", @@ -2232,6 +2296,22 @@ const it: Dict = { "settings.accentAria": "Accento: {label}", "settings.featHint": "Attiva funzioni del composer. Subito, su questo dispositivo.", + "settings.providers": "Provider", + "providers.intro": + "Collega le tue chiavi API. I modelli dei provider collegati appaiono nel menu dei modelli in alto.", + "providers.hint": + "Le chiavi restano su questo server e non vengono più mostrate — solo gli ultimi 4 caratteri. L'uso è addebitato al tuo account e resta valida la policy modelli dell'admin.", + "providers.connected": "Collegato", + "providers.notConnected": "Non collegato", + "providers.keyPh": "Incolla la chiave API…", + "providers.saveKey": "Salva", + "providers.removeKey": "Rimuovi", + "providers.replaceKey": "Sostituisci", + "providers.getKey": "Ottieni chiave", + "providers.keySaved": "Chiave salvata — i modelli sono nel menu.", + "providers.keyRemoved": "Chiave rimossa.", + "providers.invalidKey": "Chiave non valida (8–256 caratteri, senza spazi).", + "providers.loadFailed": "Impossibile caricare i provider.", "accent.preset.emerald": "Smeraldo", "accent.preset.rose": "Rosa", @@ -2737,6 +2817,22 @@ const pt: Dict = { "settings.customAria": "Cor de destaque personalizada", "settings.accentAria": "Destaque: {label}", "settings.featHint": "Liga funções do editor. Na hora, neste dispositivo.", + "settings.providers": "Provedores", + "providers.intro": + "Ligue as suas chaves API. Os modelos dos provedores ligados aparecem no menu de modelos em cima.", + "providers.hint": + "As chaves ficam neste servidor e nunca mais são mostradas — só os últimos 4 caracteres. O uso é cobrado na sua conta e a política de modelos do admin continua válida.", + "providers.connected": "Ligado", + "providers.notConnected": "Desligado", + "providers.keyPh": "Cole a chave API…", + "providers.saveKey": "Guardar", + "providers.removeKey": "Remover", + "providers.replaceKey": "Substituir", + "providers.getKey": "Obter chave", + "providers.keySaved": "Chave guardada — os modelos já estão no menu.", + "providers.keyRemoved": "Chave removida.", + "providers.invalidKey": "Essa chave parece inválida (8–256 caracteres, sem espaços).", + "providers.loadFailed": "Não foi possível carregar os provedores.", "accent.preset.emerald": "Esmeralda", "accent.preset.rose": "Rosa", diff --git a/src/config.ts b/src/config.ts index 3a8d67f..d74aab9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,6 +42,9 @@ export const config = { openaiApiKey: env("OPENAI_API_KEY", ""), openaiEnabled: envBool("OPENAI_ENABLED", false), + geminiApiKey: env("GEMINI_API_KEY", ""), + geminiEnabled: envBool("GEMINI_ENABLED", false), + glmApiKey: env("GLM_API_KEY", ""), glmEnabled: envBool("GLM_ENABLED", false), // Zhipu has two platforms: international (api.z.ai, default) and diff --git a/src/db.ts b/src/db.ts index cbcd259..2165920 100644 --- a/src/db.ts +++ b/src/db.ts @@ -114,6 +114,13 @@ export function getDb(): Database { value TEXT NOT NULL, updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS user_provider_keys( + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + api_key TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, provider) + ); `); // Email column added after the initial schema — keep idempotent. const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[]; @@ -209,6 +216,7 @@ export function deleteUser(id: string): void { for (const c of convs) txn(c.id); d.query("DELETE FROM sessions WHERE user_id = ?").run(id); d.query("DELETE FROM resets WHERE user_id = ?").run(id); + d.query("DELETE FROM user_provider_keys WHERE user_id = ?").run(id); d.query("DELETE FROM users WHERE id = ?").run(id); } diff --git a/src/drivers/apis.ts b/src/drivers/apis.ts index c318569..c692214 100644 --- a/src/drivers/apis.ts +++ b/src/drivers/apis.ts @@ -1,14 +1,25 @@ import { config } from "../config.ts"; import { ApiDriverBase } from "./apiBase.ts"; +/** + * Concrete API drivers read their key from the server env by default. + * Pass `apiKeyOverride` to build a per-user (BYOK) instance instead: the + * driver is then enabled whenever the override key is non-empty, ignoring + * the global `*_ENABLED` flag. See `src/userProviders.ts`. + */ +function globalEnabled(flag: boolean, key: string): boolean { + return flag && key.length > 0; +} + /** Mistral API driver. Global priority #2 (after local ollama). */ export class MistralDriver extends ApiDriverBase { readonly name = "mistral"; - constructor() { + constructor(apiKeyOverride?: string) { + const apiKey = apiKeyOverride ?? config.mistralApiKey; super({ baseUrl: "https://api.mistral.ai/v1", - apiKey: config.mistralApiKey, - enabled: config.mistralEnabled, + apiKey, + enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.mistralEnabled, apiKey), defaultModels: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest"], }); } @@ -17,11 +28,12 @@ export class MistralDriver extends ApiDriverBase { /** DeepSeek API driver. Priority within APIs: DeepSeek first. */ export class DeepSeekDriver extends ApiDriverBase { readonly name = "deepseek"; - constructor() { + constructor(apiKeyOverride?: string) { + const apiKey = apiKeyOverride ?? config.deepseekApiKey; super({ baseUrl: "https://api.deepseek.com/v1", - apiKey: config.deepseekApiKey, - enabled: config.deepseekEnabled, + apiKey, + enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.deepseekEnabled, apiKey), defaultModels: ["deepseek-chat", "deepseek-reasoner"], }); } @@ -30,11 +42,12 @@ export class DeepSeekDriver extends ApiDriverBase { /** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */ export class AnthropicDriver extends ApiDriverBase { readonly name = "anthropic"; - constructor() { + constructor(apiKeyOverride?: string) { + const apiKey = apiKeyOverride ?? config.anthropicApiKey; super({ baseUrl: "https://api.anthropic.com/v1", - apiKey: config.anthropicApiKey, - enabled: config.anthropicEnabled, + apiKey, + enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.anthropicEnabled, apiKey), defaultModels: ["claude-sonnet-4-20250514", "claude-3-7-sonnet-latest"], }); } @@ -43,16 +56,36 @@ export class AnthropicDriver extends ApiDriverBase { /** OpenAI API driver. Priority: last among APIs. */ export class OpenAIDriver extends ApiDriverBase { readonly name = "openai"; - constructor() { + constructor(apiKeyOverride?: string) { + const apiKey = apiKeyOverride ?? config.openaiApiKey; super({ baseUrl: "https://api.openai.com/v1", - apiKey: config.openaiApiKey, - enabled: config.openaiEnabled, + apiKey, + enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.openaiEnabled, apiKey), defaultModels: ["gpt-4o", "gpt-4o-mini"], }); } } +/** + * Gemini driver (Google AI, OpenAI-compatible endpoint). Priority: after GLM. + * Docs: https://ai.google.dev/gemini-api/docs/openai — key from + * https://aistudio.google.com/apikey (server: GEMINI_API_KEY, or per-user + * BYOK in settings). + */ +export class GeminiDriver extends ApiDriverBase { + readonly name = "gemini"; + constructor(apiKeyOverride?: string) { + const apiKey = apiKeyOverride ?? config.geminiApiKey; + super({ + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + apiKey, + enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.geminiEnabled, apiKey), + defaultModels: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"], + }); + } +} + /** * GLM driver (Zhipu AI, OpenAI-compatible endpoint). Priority: after Mistral. * Docs: https://docs.z.ai — international base is api.z.ai, mainland China diff --git a/src/drivers/registry.ts b/src/drivers/registry.ts index e129cc8..26d9626 100644 --- a/src/drivers/registry.ts +++ b/src/drivers/registry.ts @@ -1,18 +1,19 @@ -import { AnthropicDriver, DeepSeekDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts"; +import { AnthropicDriver, DeepSeekDriver, GeminiDriver, GlmDriver, MistralDriver, OpenAIDriver } from "./apis.ts"; import { OllamaDriver } from "./ollama.ts"; import { createArcaicDrivers } from "./arcaic.ts"; import type { DriverModel, LLMDriver } from "./types.ts"; /** * Global driver priority (per spec): - * 1. ollama (local) -> 2. mistral -> 3. glm - * -> 4. arcaic-openai -> 5. arcaic-gemini -> 6. arcaic-qwen - * -> 7. deepseek -> 8. anthropic -> 9. openai + * 1. ollama (local) -> 2. mistral -> 3. glm -> 4. gemini + * -> 5. arcaic-openai -> 6. arcaic-gemini -> 7. arcaic-qwen + * -> 8. deepseek -> 9. anthropic -> 10. openai */ const PRIORITY = [ "ollama", "mistral", "glm", + "gemini", "arcaic-openai", "arcaic-gemini", "arcaic-qwen", @@ -37,6 +38,7 @@ export class DriverRegistry { new OllamaDriver(), new MistralDriver(), new GlmDriver(), + new GeminiDriver(), ...createArcaicDrivers(), new DeepSeekDriver(), new AnthropicDriver(), diff --git a/src/index.ts b/src/index.ts index af1f993..1511d6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,8 +65,7 @@ import { listAccessRequests, setAccessStatus, type AccessStatus, -} from "./access.ts"; -import { broadcastAccessLive, subscribeAccessLive } from "./accessLive.ts"; +} from "./access.ts";import { broadcastAccessLive, subscribeAccessLive } from "./accessLive.ts"; import { REPORT_REASONS, REPORT_STATUSES, @@ -87,6 +86,17 @@ import { recordModelUse, setModelPolicyEntry, } from "./modelPolicy.ts"; +import { + buildUserProviderDriver, + deleteUserProviderKey, + getUserProviderKey, + isUserProviderId, + listUserProviders, + listUserProviderModels, + setUserProviderKey, + validProviderKey, + type UserProviderId, +} from "./userProviders.ts"; import type { ConversationRow } from "./db.ts"; import type { OllamaDriver } from "./drivers/ollama.ts"; import { DriverRegistry } from "./drivers/registry.ts"; @@ -668,6 +678,35 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ return json({ enabled: false }); } + // --- self-service: personal LLM providers (BYOK) --- + // Keys are stored server-side and never returned: GET only exposes + // presence + last4. Connected providers add their models to this + // user's /api/models list and /api/chat honors the personal key. + if (path === "/api/me/providers" && req.method === "GET") { + return json({ providers: listUserProviders(user.id) }); + } + + const providerMatch = path.match(/^\/api\/me\/providers\/([a-z]{3,12})$/); + if (providerMatch) { + const pid = providerMatch[1]!; + if (!isUserProviderId(pid)) { + return json({ error: "unknown provider (openai|anthropic|deepseek|gemini)" }, 400); + } + const provider = pid as UserProviderId; + if (req.method === "PUT") { + const { ok, body } = await readJson(req); + const key = ok ? validProviderKey(body.apiKey) : null; + if (!key) return json({ error: "apiKey: 8-256 chars, no whitespace" }, 400); + setUserProviderKey(user.id, provider, key); + return json({ providers: listUserProviders(user.id) }); + } + if (req.method === "DELETE") { + deleteUserProviderKey(user.id, provider); + return json({ providers: listUserProviders(user.id) }); + } + return json({ error: "not found" }, 404); + } + // --- admin: user management (No-KYC accounts, keys issued by admin) --- if (path === "/api/admin/users" && req.method === "GET") { if (!admin) return json({ error: "forbidden" }, 403); @@ -1232,6 +1271,21 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ url.searchParams.get("refresh") ?? url.searchParams.get("force") ?? "", ); 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. + try { + const personal = await listUserProviderModels(user.id); + const seen = new Set(models.map((m) => m.id)); + for (const m of personal) { + if (!seen.has(m.id)) { + seen.add(m.id); + models.push(m); + } + } + } catch { + // personal listing is best-effort — global models still served + } if (admin) return json({ models }); // Users never see admin-disabled models (nor select them — /api/chat // enforces the same policy server-side). @@ -1284,8 +1338,24 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ let driver, model: string; try { ({ driver, model } = registry.splitId(body.model)); + // BYOK precedence: when the caller connected this provider with + // their own key, the request is billed to them, not the platform. + if (isUserProviderId(driver.name)) { + const personalKey = getUserProviderKey(user.id, driver.name); + if (personalKey) driver = buildUserProviderDriver(driver.name, personalKey); + } } catch (e) { - return json({ error: (e as Error).message }, 400); + // Unknown/disabled global driver — maybe the caller connected it + // personally (e.g. platform key unset, user key set). + const fullId = body.model as string; + const idx = fullId.indexOf(":"); + const pname = idx === -1 ? "" : fullId.slice(0, idx); + const sub = idx === -1 ? "" : fullId.slice(idx + 1); + if (!isUserProviderId(pname)) return json({ error: (e as Error).message }, 400); + const personalKey = getUserProviderKey(user.id, pname); + if (!personalKey || !sub) return json({ error: (e as Error).message }, 400); + driver = buildUserProviderDriver(pname, personalKey); + model = sub; } // Per-model access policy (kill-switch + per-user rate limits). // Admins bypass; attempts count even when generation later fails. diff --git a/src/userProviders.ts b/src/userProviders.ts new file mode 100644 index 0000000..58c591a --- /dev/null +++ b/src/userProviders.ts @@ -0,0 +1,119 @@ +import { getDb } from "./db.ts"; +import { AnthropicDriver, DeepSeekDriver, GeminiDriver, OpenAIDriver } from "./drivers/apis.ts"; +import type { DriverModel, LLMDriver } from "./drivers/types.ts"; + +/** + * Personal LLM providers (BYOK): users attach their own API keys in + * settings, and the matching models show up in their model menu. + * + * Security notes (do not weaken): + * - Keys are stored reversibly in SQLite (they must be sent upstream) and + * are NEVER returned to any client — GET only exposes presence + last4. + * - Never log or print keys. Key material only travels server -> provider. + * - Scoping is per account: every helper takes userId and all chat/model + * routes resolve keys for the caller only. + */ + +// Providers a user may connect themselves. Keep in sync with the settings +// UI (frontend PROVIDER_META) and the docs. +export const USER_PROVIDER_IDS = ["openai", "anthropic", "deepseek", "gemini"] as const; +export type UserProviderId = (typeof USER_PROVIDER_IDS)[number]; + +export const USER_PROVIDER_LABELS: Record = { + openai: "OpenAI", + anthropic: "Anthropic", + deepseek: "DeepSeek", + gemini: "Gemini", +}; + +export function isUserProviderId(v: string): v is UserProviderId { + return (USER_PROVIDER_IDS as readonly string[]).includes(v); +} + +/** User-supplied key validation: trimmed printable secret, 8–256 chars. */ +export function validProviderKey(v: unknown): string | null { + if (typeof v !== "string") return null; + const t = v.trim(); + if (t.length < 8 || t.length > 256) return null; + if (/[\s]/.test(t)) return null; + return t; +} + +export interface UserProviderStatus { + provider: UserProviderId; + label: string; + hasKey: boolean; + /** Last 4 chars of the stored key (for recognition), or null when unset. */ + last4: string | null; + updatedAt: number; +} + +export function getUserProviderKey(userId: string, provider: UserProviderId): string | null { + const r = getDb() + .query("SELECT api_key FROM user_provider_keys WHERE user_id = ? AND provider = ?") + .get(userId, provider) as { api_key: string } | null; + return r?.api_key ?? null; +} + +export function listUserProviders(userId: string): UserProviderStatus[] { + const rows = getDb() + .query("SELECT provider, api_key, updated_at FROM user_provider_keys WHERE user_id = ?") + .all(userId) as { provider: string; api_key: string; updated_at: number }[]; + const byId = new Map(rows.map((r) => [r.provider, r])); + return USER_PROVIDER_IDS.map((p) => { + const r = byId.get(p); + return { + provider: p, + label: USER_PROVIDER_LABELS[p], + hasKey: !!r, + last4: r ? r.api_key.slice(-4) : null, + updatedAt: r?.updated_at ?? 0, + }; + }); +} + +export function setUserProviderKey(userId: string, provider: UserProviderId, apiKey: string): void { + getDb() + .query( + "INSERT INTO user_provider_keys (user_id, provider, api_key, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, provider) DO UPDATE SET api_key = excluded.api_key, updated_at = excluded.updated_at", + ) + .run(userId, provider, apiKey, Date.now()); +} + +export function deleteUserProviderKey(userId: string, provider: UserProviderId): void { + getDb().query("DELETE FROM user_provider_keys WHERE user_id = ? AND provider = ?").run(userId, provider); +} + +/** Build a per-user driver instance authenticated with the user's own key. */ +export function buildUserProviderDriver(provider: UserProviderId, apiKey: string): LLMDriver { + switch (provider) { + case "openai": + return new OpenAIDriver(apiKey); + case "anthropic": + return new AnthropicDriver(apiKey); + case "deepseek": + return new DeepSeekDriver(apiKey); + case "gemini": + return new GeminiDriver(apiKey); + } +} + +/** + * 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). + */ +export async function listUserProviderModels(userId: string): Promise { + const out: DriverModel[] = []; + const jobs = USER_PROVIDER_IDS.map(async (p) => { + const key = getUserProviderKey(userId, p); + if (!key) return; + try { + out.push(...(await buildUserProviderDriver(p, key).listModels())); + } catch (e) { + console.error(`[providers] user ${p} listModels failed:`, (e as Error).message); + } + }); + await Promise.all(jobs); + return out; +}