own provider update + retry buggy response
This commit is contained in:
@@ -60,6 +60,9 @@ ANTHROPIC_API_KEY=
|
|||||||
ANTHROPIC_ENABLED=false
|
ANTHROPIC_ENABLED=false
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_ENABLED=false
|
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).
|
# Arcaic backend (headless browser, throwaway profile per process).
|
||||||
# Every process launch creates a throwaway Firefox profile (deleted on exit).
|
# Every process launch creates a throwaway Firefox profile (deleted on exit).
|
||||||
ARCAIC_ENABLED=false
|
ARCAIC_ENABLED=false
|
||||||
|
|||||||
@@ -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) |
|
| 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 |
|
| 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/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) |
|
| 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 |
|
| 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,
|
`$DATA_DIR/kisassistant.db` (WAL mode). Tables: `users` (id, username,
|
||||||
display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens),
|
display_name, avatar_url, theme, email, key_hash), `sessions` (hashed Bearer tokens),
|
||||||
`conversations` (id, user, title, topic, model, archived_at), `messages`, `shares`,
|
`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.
|
(conv ↔ public id). No migration system — schema is created idempotently at boot.
|
||||||
|
|
||||||
## Drivers (backend layout)
|
## 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/types.ts LLMDriver interface + DriverModel + errors
|
||||||
src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream
|
src/drivers/ollama.ts OllamaDriver (priority #1) — /api/tags discovery, NDJSON stream
|
||||||
src/drivers/apiBase.ts ApiDriverBase (OpenAI-compatible SSE) base class
|
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/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/arcaic.ts ArcaicSubDriver ×3 — arcaic-openai / arcaic-gemini / arcaic-qwen (ARCAIC_ENABLED)
|
||||||
src/drivers/registry.ts DriverRegistry — priority order + "driver:model" routing
|
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/db.ts SQLite store (users, sessions, convs, messages, shares)
|
||||||
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
|
src/auth.ts login/sessions/rate-limit (admin key = APP_PASSWORD)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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). |
|
| `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). |
|
| `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`). |
|
| `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`. |
|
| `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`. |
|
| `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`),
|
## Drivers`src/drivers/`: `LLMDriver` interface (`listModels`, `chat`, `chatStream`),
|
||||||
`DriverRegistry` with global priority
|
`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 `:`).
|
Models are addressed as `"driver:model"` (split on the first `:`).
|
||||||
|
|
||||||
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
- `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via
|
||||||
|
|||||||
@@ -505,6 +505,23 @@ export function App() {
|
|||||||
await refreshConvs();
|
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.
|
// Auth pages render instantly (no splash); the platform boots behind one.
|
||||||
if (path === "/login") {
|
if (path === "/login") {
|
||||||
if (user) return null; // bounce to /chat imminent
|
if (user) return null; // bounce to /chat imminent
|
||||||
@@ -630,6 +647,7 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
onViewArchived={openArchived}
|
onViewArchived={openArchived}
|
||||||
onArchivedChanged={handleArchivedChanged}
|
onArchivedChanged={handleArchivedChanged}
|
||||||
|
onProvidersChanged={handleProvidersChanged}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div className="mt-3 flex items-center justify-between gap-2 text-sm">
|
<div className="mt-4 flex items-center justify-between gap-2 pt-1 text-sm">
|
||||||
<span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
|
<span className="opacity-60">{total === 1 ? t("admin.countOne", { total, page, pages }) : t("admin.countMany", { total, page, pages })}</span>
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { 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 } from "../lib/api.ts";
|
import { api, type UserProvider } from "../lib/api.ts";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
|
import { ACCENT_PRESETS, applyAccent, currentAccent, previewAccent, type AccentState } from "../lib/accent.ts";
|
||||||
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
|
import { FEATURES, setFeature, useFeatures } from "../lib/features.ts";
|
||||||
@@ -262,6 +262,7 @@ export function SettingsModal({
|
|||||||
onKeyRotated,
|
onKeyRotated,
|
||||||
onViewArchived,
|
onViewArchived,
|
||||||
onArchivedChanged,
|
onArchivedChanged,
|
||||||
|
onProvidersChanged,
|
||||||
}: {
|
}: {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -269,6 +270,7 @@ export function SettingsModal({
|
|||||||
onKeyRotated?: () => void;
|
onKeyRotated?: () => void;
|
||||||
onViewArchived: (c: Conversation) => void;
|
onViewArchived: (c: Conversation) => void;
|
||||||
onArchivedChanged: (deletedId?: string) => void;
|
onArchivedChanged: (deletedId?: string) => void;
|
||||||
|
onProvidersChanged?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [displayName, setDisplayName] = useState("");
|
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: "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: "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: "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"] },
|
{ id: "archived", title: t("archived.title"), icon: Archive, keywords: ["archived", "archive", "old", "hidden", "read-only", "readonly", "archiv"] },
|
||||||
] as const;
|
] as const;
|
||||||
type CatId = (typeof CATS)[number]["id"];
|
type CatId = (typeof CATS)[number]["id"];
|
||||||
@@ -546,6 +549,12 @@ export function SettingsModal({
|
|||||||
</SettingsPane>
|
</SettingsPane>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeCat === "providers" && (
|
||||||
|
<SettingsPane>
|
||||||
|
<ProvidersSection onChanged={onProvidersChanged} />
|
||||||
|
</SettingsPane>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeCat === "archived" && (
|
{activeCat === "archived" && (
|
||||||
<SettingsPane>
|
<SettingsPane>
|
||||||
<ArchivedSection onView={onViewArchived} onChanged={onArchivedChanged} />
|
<ArchivedSection onView={onViewArchived} onChanged={onArchivedChanged} />
|
||||||
@@ -996,3 +1005,169 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 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<UserProvider[] | null>(null);
|
||||||
|
const [keys, setKeys] = useState<Record<string, string>>({});
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(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 <p className="flex items-center gap-2 py-4 text-sm opacity-60"><Spinner size={15} /> {t("common.loading")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = new Map((list ?? []).map((p) => [p.provider, p]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs leading-relaxed opacity-60">{t("providers.intro")}</p>
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
|
{notice && <p className="rounded-2xl bg-emerald-500/10 px-4 py-2.5 text-sm text-emerald-700 dark:text-emerald-300">{notice}</p>}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{PROVIDER_META.map((m) => {
|
||||||
|
const st = byId.get(m.id);
|
||||||
|
const connected = !!st?.hasKey;
|
||||||
|
const busy = busyId === m.id;
|
||||||
|
return (
|
||||||
|
<li key={m.id} className="space-y-2.5 rounded-2xl border border-stone-200/70 p-3.5 dark:border-zinc-800">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 w-2.5 shrink-0 rounded-full",
|
||||||
|
connected ? "bg-emerald-500" : "bg-stone-300 dark:bg-zinc-600",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{m.label}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"rounded-full px-2 py-0.5 text-xs",
|
||||||
|
connected
|
||||||
|
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "bg-stone-200/70 opacity-60 dark:bg-zinc-800",
|
||||||
|
)}>
|
||||||
|
{connected ? t("providers.connected") : t("providers.notConnected")}
|
||||||
|
</span>
|
||||||
|
{connected && st?.last4 && (
|
||||||
|
<code className="rounded-lg bg-stone-100 px-2 py-0.5 font-mono text-xs opacity-70 dark:bg-zinc-800">
|
||||||
|
••••{st.last4}
|
||||||
|
</code>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={m.docs}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="ml-auto shrink-0 text-xs text-accent-700 underline-offset-2 hover:underline dark:text-accent-400"
|
||||||
|
>
|
||||||
|
{t("providers.getKey")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={keys[m.id] ?? ""}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={busy || !(keys[m.id] ?? "").trim()}
|
||||||
|
onClick={() => save(m.id)}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{busy ? <Spinner size={15} /> : t("providers.saveKey")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{connected && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => remove(m.id)}
|
||||||
|
className="!text-red-600 dark:!text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> {t("providers.removeKey")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs leading-relaxed opacity-60">{t("providers.hint")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export interface ModelPolicyEntry {
|
|||||||
daily: number;
|
daily: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProvider {
|
||||||
|
provider: string;
|
||||||
|
label: string;
|
||||||
|
hasKey: boolean;
|
||||||
|
last4: string | null;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PolicyModel extends DriverModel {
|
export interface PolicyModel extends DriverModel {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
hourly: number;
|
hourly: number;
|
||||||
@@ -291,6 +299,16 @@ export const api = {
|
|||||||
/** Admin: delete a local Ollama model (frees disk on the Ollama host). */
|
/** 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" }),
|
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"),
|
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). */
|
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
|
||||||
|
|||||||
@@ -183,6 +183,22 @@ const en = {
|
|||||||
"settings.accentAria": "Accent: {label}",
|
"settings.accentAria": "Accent: {label}",
|
||||||
"settings.featHint":
|
"settings.featHint":
|
||||||
"Turn composer capabilities on or off. Applied instantly, saved on this device.",
|
"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.emerald": "Emerald",
|
||||||
"accent.preset.rose": "Rose",
|
"accent.preset.rose": "Rose",
|
||||||
@@ -705,6 +721,22 @@ const es: Dict = {
|
|||||||
"settings.accentAria": "Acento: {label}",
|
"settings.accentAria": "Acento: {label}",
|
||||||
"settings.featHint":
|
"settings.featHint":
|
||||||
"Activa funciones del editor. Al instante, en este dispositivo.",
|
"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.emerald": "Esmeralda",
|
||||||
"accent.preset.rose": "Rosa",
|
"accent.preset.rose": "Rosa",
|
||||||
@@ -1220,6 +1252,22 @@ const fr: Dict = {
|
|||||||
"settings.accentAria": "Accent : {label}",
|
"settings.accentAria": "Accent : {label}",
|
||||||
"settings.featHint":
|
"settings.featHint":
|
||||||
"Activez les fonctions du composer. Aussitôt, sur cet appareil.",
|
"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.emerald": "Émeraude",
|
||||||
"accent.preset.rose": "Rose",
|
"accent.preset.rose": "Rose",
|
||||||
@@ -1730,6 +1778,22 @@ const ru: Dict = {
|
|||||||
"settings.accentAria": "Акцент: {label}",
|
"settings.accentAria": "Акцент: {label}",
|
||||||
"settings.featHint":
|
"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.emerald": "Изумруд",
|
||||||
"accent.preset.rose": "Роза",
|
"accent.preset.rose": "Роза",
|
||||||
@@ -2232,6 +2296,22 @@ const it: Dict = {
|
|||||||
"settings.accentAria": "Accento: {label}",
|
"settings.accentAria": "Accento: {label}",
|
||||||
"settings.featHint":
|
"settings.featHint":
|
||||||
"Attiva funzioni del composer. Subito, su questo dispositivo.",
|
"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.emerald": "Smeraldo",
|
||||||
"accent.preset.rose": "Rosa",
|
"accent.preset.rose": "Rosa",
|
||||||
@@ -2737,6 +2817,22 @@ const pt: Dict = {
|
|||||||
"settings.customAria": "Cor de destaque personalizada",
|
"settings.customAria": "Cor de destaque personalizada",
|
||||||
"settings.accentAria": "Destaque: {label}",
|
"settings.accentAria": "Destaque: {label}",
|
||||||
"settings.featHint": "Liga funções do editor. Na hora, neste dispositivo.",
|
"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.emerald": "Esmeralda",
|
||||||
"accent.preset.rose": "Rosa",
|
"accent.preset.rose": "Rosa",
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export const config = {
|
|||||||
openaiApiKey: env("OPENAI_API_KEY", ""),
|
openaiApiKey: env("OPENAI_API_KEY", ""),
|
||||||
openaiEnabled: envBool("OPENAI_ENABLED", false),
|
openaiEnabled: envBool("OPENAI_ENABLED", false),
|
||||||
|
|
||||||
|
geminiApiKey: env("GEMINI_API_KEY", ""),
|
||||||
|
geminiEnabled: envBool("GEMINI_ENABLED", false),
|
||||||
|
|
||||||
glmApiKey: env("GLM_API_KEY", ""),
|
glmApiKey: env("GLM_API_KEY", ""),
|
||||||
glmEnabled: envBool("GLM_ENABLED", false),
|
glmEnabled: envBool("GLM_ENABLED", false),
|
||||||
// Zhipu has two platforms: international (api.z.ai, default) and
|
// Zhipu has two platforms: international (api.z.ai, default) and
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ export function getDb(): Database {
|
|||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
updated_at INTEGER 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.
|
// Email column added after the initial schema — keep idempotent.
|
||||||
const cols = db.query("PRAGMA table_info(users)").all() as { name: string }[];
|
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);
|
for (const c of convs) txn(c.id);
|
||||||
d.query("DELETE FROM sessions WHERE user_id = ?").run(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 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);
|
d.query("DELETE FROM users WHERE id = ?").run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-12
@@ -1,14 +1,25 @@
|
|||||||
import { config } from "../config.ts";
|
import { config } from "../config.ts";
|
||||||
import { ApiDriverBase } from "./apiBase.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). */
|
/** Mistral API driver. Global priority #2 (after local ollama). */
|
||||||
export class MistralDriver extends ApiDriverBase {
|
export class MistralDriver extends ApiDriverBase {
|
||||||
readonly name = "mistral";
|
readonly name = "mistral";
|
||||||
constructor() {
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.mistralApiKey;
|
||||||
super({
|
super({
|
||||||
baseUrl: "https://api.mistral.ai/v1",
|
baseUrl: "https://api.mistral.ai/v1",
|
||||||
apiKey: config.mistralApiKey,
|
apiKey,
|
||||||
enabled: config.mistralEnabled,
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.mistralEnabled, apiKey),
|
||||||
defaultModels: ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest"],
|
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. */
|
/** DeepSeek API driver. Priority within APIs: DeepSeek first. */
|
||||||
export class DeepSeekDriver extends ApiDriverBase {
|
export class DeepSeekDriver extends ApiDriverBase {
|
||||||
readonly name = "deepseek";
|
readonly name = "deepseek";
|
||||||
constructor() {
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.deepseekApiKey;
|
||||||
super({
|
super({
|
||||||
baseUrl: "https://api.deepseek.com/v1",
|
baseUrl: "https://api.deepseek.com/v1",
|
||||||
apiKey: config.deepseekApiKey,
|
apiKey,
|
||||||
enabled: config.deepseekEnabled,
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.deepseekEnabled, apiKey),
|
||||||
defaultModels: ["deepseek-chat", "deepseek-reasoner"],
|
defaultModels: ["deepseek-chat", "deepseek-reasoner"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -30,11 +42,12 @@ export class DeepSeekDriver extends ApiDriverBase {
|
|||||||
/** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */
|
/** Anthropic driver (OpenAI-compatible endpoint). Priority: after DeepSeek. */
|
||||||
export class AnthropicDriver extends ApiDriverBase {
|
export class AnthropicDriver extends ApiDriverBase {
|
||||||
readonly name = "anthropic";
|
readonly name = "anthropic";
|
||||||
constructor() {
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.anthropicApiKey;
|
||||||
super({
|
super({
|
||||||
baseUrl: "https://api.anthropic.com/v1",
|
baseUrl: "https://api.anthropic.com/v1",
|
||||||
apiKey: config.anthropicApiKey,
|
apiKey,
|
||||||
enabled: config.anthropicEnabled,
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.anthropicEnabled, apiKey),
|
||||||
defaultModels: ["claude-sonnet-4-20250514", "claude-3-7-sonnet-latest"],
|
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. */
|
/** OpenAI API driver. Priority: last among APIs. */
|
||||||
export class OpenAIDriver extends ApiDriverBase {
|
export class OpenAIDriver extends ApiDriverBase {
|
||||||
readonly name = "openai";
|
readonly name = "openai";
|
||||||
constructor() {
|
constructor(apiKeyOverride?: string) {
|
||||||
|
const apiKey = apiKeyOverride ?? config.openaiApiKey;
|
||||||
super({
|
super({
|
||||||
baseUrl: "https://api.openai.com/v1",
|
baseUrl: "https://api.openai.com/v1",
|
||||||
apiKey: config.openaiApiKey,
|
apiKey,
|
||||||
enabled: config.openaiEnabled,
|
enabled: apiKeyOverride !== undefined ? apiKey.length > 0 : globalEnabled(config.openaiEnabled, apiKey),
|
||||||
defaultModels: ["gpt-4o", "gpt-4o-mini"],
|
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.
|
* GLM driver (Zhipu AI, OpenAI-compatible endpoint). Priority: after Mistral.
|
||||||
* Docs: https://docs.z.ai — international base is api.z.ai, mainland China
|
* Docs: https://docs.z.ai — international base is api.z.ai, mainland China
|
||||||
|
|||||||
@@ -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 { OllamaDriver } from "./ollama.ts";
|
||||||
import { createArcaicDrivers } from "./arcaic.ts";
|
import { createArcaicDrivers } from "./arcaic.ts";
|
||||||
import type { DriverModel, LLMDriver } from "./types.ts";
|
import type { DriverModel, LLMDriver } from "./types.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global driver priority (per spec):
|
* Global driver priority (per spec):
|
||||||
* 1. ollama (local) -> 2. mistral -> 3. glm
|
* 1. ollama (local) -> 2. mistral -> 3. glm -> 4. gemini
|
||||||
* -> 4. arcaic-openai -> 5. arcaic-gemini -> 6. arcaic-qwen
|
* -> 5. arcaic-openai -> 6. arcaic-gemini -> 7. arcaic-qwen
|
||||||
* -> 7. deepseek -> 8. anthropic -> 9. openai
|
* -> 8. deepseek -> 9. anthropic -> 10. openai
|
||||||
*/
|
*/
|
||||||
const PRIORITY = [
|
const PRIORITY = [
|
||||||
"ollama",
|
"ollama",
|
||||||
"mistral",
|
"mistral",
|
||||||
"glm",
|
"glm",
|
||||||
|
"gemini",
|
||||||
"arcaic-openai",
|
"arcaic-openai",
|
||||||
"arcaic-gemini",
|
"arcaic-gemini",
|
||||||
"arcaic-qwen",
|
"arcaic-qwen",
|
||||||
@@ -37,6 +38,7 @@ export class DriverRegistry {
|
|||||||
new OllamaDriver(),
|
new OllamaDriver(),
|
||||||
new MistralDriver(),
|
new MistralDriver(),
|
||||||
new GlmDriver(),
|
new GlmDriver(),
|
||||||
|
new GeminiDriver(),
|
||||||
...createArcaicDrivers(),
|
...createArcaicDrivers(),
|
||||||
new DeepSeekDriver(),
|
new DeepSeekDriver(),
|
||||||
new AnthropicDriver(),
|
new AnthropicDriver(),
|
||||||
|
|||||||
+73
-3
@@ -65,8 +65,7 @@ import {
|
|||||||
listAccessRequests,
|
listAccessRequests,
|
||||||
setAccessStatus,
|
setAccessStatus,
|
||||||
type AccessStatus,
|
type AccessStatus,
|
||||||
} from "./access.ts";
|
} from "./access.ts";import { broadcastAccessLive, subscribeAccessLive } from "./accessLive.ts";
|
||||||
import { broadcastAccessLive, subscribeAccessLive } from "./accessLive.ts";
|
|
||||||
import {
|
import {
|
||||||
REPORT_REASONS,
|
REPORT_REASONS,
|
||||||
REPORT_STATUSES,
|
REPORT_STATUSES,
|
||||||
@@ -87,6 +86,17 @@ import {
|
|||||||
recordModelUse,
|
recordModelUse,
|
||||||
setModelPolicyEntry,
|
setModelPolicyEntry,
|
||||||
} from "./modelPolicy.ts";
|
} 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 { ConversationRow } from "./db.ts";
|
||||||
import type { OllamaDriver } from "./drivers/ollama.ts";
|
import type { OllamaDriver } from "./drivers/ollama.ts";
|
||||||
import { DriverRegistry } from "./drivers/registry.ts";
|
import { DriverRegistry } from "./drivers/registry.ts";
|
||||||
@@ -668,6 +678,35 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
return json({ enabled: false });
|
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) ---
|
// --- admin: user management (No-KYC accounts, keys issued by admin) ---
|
||||||
if (path === "/api/admin/users" && req.method === "GET") {
|
if (path === "/api/admin/users" && req.method === "GET") {
|
||||||
if (!admin) return json({ error: "forbidden" }, 403);
|
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") ?? "",
|
url.searchParams.get("refresh") ?? url.searchParams.get("force") ?? "",
|
||||||
);
|
);
|
||||||
const models = await registry.listAllModelsCached(wantRefresh && admin);
|
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 });
|
if (admin) return json({ models });
|
||||||
// Users never see admin-disabled models (nor select them — /api/chat
|
// Users never see admin-disabled models (nor select them — /api/chat
|
||||||
// enforces the same policy server-side).
|
// enforces the same policy server-side).
|
||||||
@@ -1284,8 +1338,24 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
let driver, model: string;
|
let driver, model: string;
|
||||||
try {
|
try {
|
||||||
({ driver, model } = registry.splitId(body.model));
|
({ 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) {
|
} 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).
|
// Per-model access policy (kill-switch + per-user rate limits).
|
||||||
// Admins bypass; attempts count even when generation later fails.
|
// Admins bypass; attempts count even when generation later fails.
|
||||||
|
|||||||
@@ -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<UserProviderId, string> = {
|
||||||
|
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<DriverModel[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user