Compare commits

...
3 Commits
Author SHA1 Message Date
k 0ca3e9d4ca own provider update + retry buggy response 2026-09-21 22:26:32 +02:00
k 0943cdf5f3 fixes 2026-09-21 22:17:05 +02:00
k 19b791493f :3 2026-09-21 22:12:37 +02:00
18 changed files with 1094 additions and 36 deletions
+3
View File
@@ -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
+7 -3
View File
@@ -80,7 +80,9 @@ 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 |
`POST /api/chat` with `conversationId` persists the user message + assistant
@@ -91,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)
@@ -100,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)
```
+2 -1
View File
@@ -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
+15
View File
@@ -74,6 +74,21 @@ connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
- Flags live in the `settings` SQLite table, edited via
`GET/PATCH /api/admin/settings` (admin Bearer only).
## Model access policy (kill-switch + rate limits)
The Admin Center **Models** tab lists every model across enabled drivers
(grouped, Ollama library moved there too). Per model id (`"driver:model"`):
an **Enabled** switch and **hourly / daily** per-user request caps
(0 = unlimited, admins always bypass). Policy lives in the `settings` table
(`model_policy` JSON blob, default entries pruned); usage counters in
`model_usage` (fixed UTC hour/day windows, old buckets pruned on write).
- Disabled models vanish from `GET /api/models` for users (admins still see
everything) and `POST /api/chat` rejects them (403) — attempts count
toward limits even when generation later fails; over-limit answers 429.
- Routes: `GET /api/admin/model-policy[?refresh=1]`,
`PATCH /api/admin/model-policy` (`{model, enabled?, hourly?, daily?}`).
## Access-request wishlist (registration approval queue)
When public registration is off, visitors can still reserve a username via
+18
View File
@@ -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}
/>
</div>
);
+10 -2
View File
@@ -1,15 +1,16 @@
import { useEffect, useState } from "react";
import { ArrowLeft, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
import { api, getToken } from "../lib/api.ts";
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
import { AdminPanel } from "./AdminPanel.tsx";
import { AccessRequestsPanel } from "./AccessRequests.tsx";
import { ReportsPanel } from "./ReportsPanel.tsx";
import { ModelsPanel } from "./ModelsPanel.tsx";
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
import { cn } from "../lib/cn.ts";
import { useT } from "../lib/i18n.ts";
type Tab = "accounts" | "access" | "reports" | "features" | "mail";
type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail";
function applyTheme(theme: string) {
const root = document.documentElement;
@@ -32,6 +33,7 @@ export function AdminCenter() {
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
{ id: "reports", label: t("center.tabReports"), icon: Flag },
{ id: "models", label: t("center.tabModels"), icon: Boxes },
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
{ id: "mail", label: t("center.tabMail"), icon: Mail },
];
@@ -189,6 +191,12 @@ export function AdminCenter() {
</section>
)}
{tab === "models" && (
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<ModelsPanel />
</section>
)}
{tab === "features" && (
<section className="space-y-3">
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
+1 -6
View File
@@ -190,7 +190,6 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const [editTarget, setEditTarget] = useState<User | null>(null);
const [modelsOpen, setModelsOpen] = useState(false);
// Listing: search + sort + filter + pagination (server-side).
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
@@ -309,9 +308,6 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
<UserPlus size={15} /> {t("admin.newAccount")}
</Button>
<Button variant="secondary" size="sm" onClick={() => setModelsOpen(true)}>
<Bot size={15} /> {t("ollama.models")}
</Button>
</div>
) : (
<form onSubmit={create} className="mb-4 flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 p-3 dark:border-zinc-700">
@@ -383,7 +379,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
))}
</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>
{pages > 1 && (
<div className="flex gap-2.5">
@@ -415,7 +411,6 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
/>
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
<OllamaModelsModal open={modelsOpen} onClose={() => setModelsOpen(false)} />
</>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import { Bot, Undo2 } from "lucide-react";
import { api, type PolicyModel } from "../lib/api.ts";
import { OllamaModelsModal } from "./AdminPanel.tsx";
import { Button, Input, Spinner, Switch } from "./ui.tsx";
import { useT } from "../lib/i18n.ts";
import { cn } from "../lib/cn.ts";
/** Admin models section: per-model kill-switch + rate limits, Ollama library. */
export function ModelsPanel() {
const [models, setModels] = useState<PolicyModel[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [ollamaOpen, setOllamaOpen] = useState(false);
const { t } = useT();
async function refresh(force = false) {
setError("");
try {
const res = await api.adminModelPolicy(force);
setModels(res.models);
} catch (err) {
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
} finally {
setLoading(false);
}
}
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function apply(updated: PolicyModel) {
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)));
}
const groups = (() => {
const byGroup = new Map<string, PolicyModel[]>();
for (const m of models) {
const g = m.group ?? m.driver;
const list = byGroup.get(g) ?? [];
list.push(m);
byGroup.set(g, list);
}
return [...byGroup.entries()];
})();
return (
<div className="space-y-5">
<section>
<div className="mb-2 flex items-center gap-2">
<p className="text-sm font-medium">{t("models.title")}</p>
<button
onClick={() => refresh(true)}
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
title={t("models.refresh")}
aria-label={t("models.refreshAria")}
>
<Undo2 size={15} />
</button>
</div>
<p className="mb-3 text-sm opacity-60">{t("models.desc")}</p>
{error && <p className="mb-3 rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
{!loading && models.length === 0 && !error && (
<p className="rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-center text-sm opacity-50 dark:border-zinc-700">
{t("models.empty")}
</p>
)}
{groups.map(([group, list]) => (
<div key={group} className="mb-4">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider opacity-50">{group}</p>
<ul className="space-y-2">
{list.map((m) => (
<ModelRow key={m.id} model={m} onChanged={apply} onError={setError} />
))}
</ul>
</div>
))}
</section>
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="text-sm font-medium">{t("models.ollamaTitle")}</p>
<p className="mt-0.5 text-sm opacity-60">{t("models.ollamaDesc")}</p>
<div className="mt-3">
<Button
variant="secondary"
size="sm"
onClick={() => setOllamaOpen(true)}
>
<Bot size={14} /> {t("ollama.models")}
</Button>
</div>
</section>
<OllamaModelsModal
open={ollamaOpen}
onClose={() => {
setOllamaOpen(false);
refresh();
}}
/>
</div>
);
}
function ModelRow({ model, onChanged, onError }: {
model: PolicyModel;
onChanged: (m: PolicyModel) => void;
onError: (msg: string) => void;
}) {
const { t } = useT();
const [hourly, setHourly] = useState(model.hourly === 0 ? "" : String(model.hourly));
const [daily, setDaily] = useState(model.daily === 0 ? "" : String(model.daily));
const [busy, setBusy] = useState(false);
useEffect(() => {
setHourly(model.hourly === 0 ? "" : String(model.hourly));
setDaily(model.daily === 0 ? "" : String(model.daily));
}, [model.hourly, model.daily]);
async function patch(p: { enabled?: boolean; hourly?: number; daily?: number }) {
setBusy(true);
try {
const res = await api.adminModelPolicyPatch(model.id, p);
onChanged(res.model);
onError("");
} catch (err) {
onError(err instanceof Error ? err.message : t("models.saveFailed"));
} finally {
setBusy(false);
}
}
function commitNumber(kind: "hourly" | "daily", raw: string) {
const v = raw.trim() === "" ? 0 : Number(raw);
if (!Number.isInteger(v) || v < 0 || v > 1000000) {
if (kind === "hourly") setHourly(model.hourly === 0 ? "" : String(model.hourly));
else setDaily(model.daily === 0 ? "" : String(model.daily));
return;
}
if (v === model[kind]) return;
void patch({ [kind]: v });
}
return (
<li
className={cn(
"flex flex-wrap items-center gap-x-4 gap-y-2 rounded-2xl border border-stone-200/70 bg-white px-3.5 py-2.5 dark:border-zinc-800 dark:bg-zinc-900",
!model.enabled && "opacity-60",
)}
>
<span className="min-w-0 flex-1 basis-44">
<span className="block truncate text-sm font-medium">{model.label || model.id}</span>
<span className="block truncate font-mono text-xs opacity-50">{model.id}</span>
</span>
<label className="flex items-center gap-1.5 text-xs opacity-80">
<Input
type="number"
min={0}
max={1000000}
value={hourly}
disabled={busy}
placeholder={t("models.unlimited")}
aria-label={t("models.hourly")}
onChange={(e) => setHourly(e.target.value)}
onBlur={(e) => commitNumber("hourly", e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
className="w-24"
/>
{t("models.hourly")}
</label>
<label className="flex items-center gap-1.5 text-xs opacity-80">
<Input
type="number"
min={0}
max={1000000}
value={daily}
disabled={busy}
placeholder={t("models.unlimited")}
aria-label={t("models.daily")}
onChange={(e) => setDaily(e.target.value)}
onBlur={(e) => commitNumber("daily", e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
className="w-24"
/>
{t("models.daily")}
</label>
<Switch
label={t("models.enabled")}
checked={model.enabled}
disabled={busy}
onChange={(v) => void patch({ enabled: v })}
/>
</li>
);
}
+179 -4
View File
@@ -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({
</SettingsPane>
)}
{activeCat === "providers" && (
<SettingsPane>
<ProvidersSection onChanged={onProvidersChanged} />
</SettingsPane>
)}
{activeCat === "archived" && (
<SettingsPane>
<ArchivedSection onView={onViewArchived} onChanged={onArchivedChanged} />
@@ -634,8 +643,8 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
}
return (
<div>
<p className="-mb-2 text-xs opacity-60">{t("archived.readonly")}</p>
<div className="space-y-3">
<p className="text-xs leading-relaxed opacity-60">{t("archived.readonly")}</p>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{(list ?? []).length === 0 ? (
<p className="py-4 text-center text-sm opacity-50">{t("archived.empty")}</p>
@@ -996,3 +1005,169 @@ function SecuritySection({ onKeyRotated }: { onKeyRotated?: () => void }) {
</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>
);
}
+39
View File
@@ -42,6 +42,26 @@ export interface Report {
updated_at: number;
}
export interface ModelPolicyEntry {
enabled: boolean;
hourly: number;
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;
daily: number;
}
const TOKEN_KEY = "kisassistant_token";
export function getToken(): string | null {
@@ -140,6 +160,15 @@ export const api = {
method: "POST",
body: JSON.stringify({ shadowbanned }),
}),
/** Admin: per-model access policy (kill-switch + rate limits). */
adminModelPolicy: (refresh = false) =>
req<{ models: PolicyModel[] }>(`/api/admin/model-policy${refresh ? "?refresh=1" : ""}`),
adminModelPolicyPatch: (model: string, patch: { enabled?: boolean; hourly?: number; daily?: number }) =>
req<{ model: PolicyModel }>(`/api/admin/model-policy`, {
method: "PATCH",
body: JSON.stringify({ model, ...patch }),
}),
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
@@ -270,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). */
+174
View File
@@ -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",
@@ -272,6 +288,19 @@ const en = {
"center.tabAccounts": "Accounts",
"center.tabFeatures": "Features",
"center.tabMail": "Mail",
"center.tabModels": "Models",
"models.title": "Models",
"models.desc": "Enable or limit each model. Disabled models disappear from the picker; limits apply per user (admins bypass them).",
"models.refresh": "Refresh",
"models.refreshAria": "Refresh models",
"models.enabled": "Enabled",
"models.hourly": "per hour",
"models.daily": "per day",
"models.unlimited": "Unlimited",
"models.ollamaTitle": "Ollama library",
"models.ollamaDesc": "Pull or remove local models.",
"models.empty": "No models available.",
"models.saveFailed": "Could not save",
"center.locked": "Admins only",
"center.lockedSub": "Log in with the admin account to open the Admin Center.",
"center.regTitle": "Public registration",
@@ -692,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",
@@ -780,6 +825,19 @@ const es: Dict = {
"center.tabAccounts": "Cuentas",
"center.tabFeatures": "Funciones",
"center.tabMail": "Correo",
"center.tabModels": "Modelos",
"models.title": "Modelos",
"models.desc": "Activa o limita cada modelo. Los modelos desactivados desaparecen del selector; los límites se aplican por usuario (los admins no tienen límite).",
"models.refresh": "Actualizar",
"models.refreshAria": "Actualizar modelos",
"models.enabled": "Activado",
"models.hourly": "por hora",
"models.daily": "por día",
"models.unlimited": "Ilimitado",
"models.ollamaTitle": "Biblioteca Ollama",
"models.ollamaDesc": "Descargar o eliminar modelos locales.",
"models.empty": "No hay modelos disponibles.",
"models.saveFailed": "No se pudo guardar",
"center.locked": "Solo admins",
"center.lockedSub": "Inicia sesión como admin para abrir el Centro de admin.",
"center.regTitle": "Registro público",
@@ -1194,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",
@@ -1282,6 +1356,19 @@ const fr: Dict = {
"center.tabAccounts": "Comptes",
"center.tabFeatures": "Fonctions",
"center.tabMail": "Mail",
"center.tabModels": "Modèles",
"models.title": "Modèles",
"models.desc": "Activez ou limitez chaque modèle. Les modèles désactivés disparaissent du sélecteur ; les limites s'appliquent par utilisateur (les admins ne sont pas limités).",
"models.refresh": "Actualiser",
"models.refreshAria": "Actualiser les modèles",
"models.enabled": "Activé",
"models.hourly": "par heure",
"models.daily": "par jour",
"models.unlimited": "Illimité",
"models.ollamaTitle": "Bibliothèque Ollama",
"models.ollamaDesc": "Télécharger ou supprimer des modèles locaux.",
"models.empty": "Aucun modèle disponible.",
"models.saveFailed": "Enregistrement impossible",
"center.locked": "Admins uniquement",
"center.lockedSub": "Connectez-vous en admin pour ouvrir le Centre admin.",
"center.regTitle": "Inscription publique",
@@ -1691,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": "Роза",
@@ -1775,6 +1878,19 @@ const ru: Dict = {
"center.tabAccounts": "Аккаунты",
"center.tabFeatures": "Функции",
"center.tabMail": "Почта",
"center.tabModels": "Модели",
"models.title": "Модели",
"models.desc": "Включайте или ограничивайте каждую модель. Отключённые модели исчезают из выбора; лимиты действуют на пользователя (админов не касаются).",
"models.refresh": "Обновить",
"models.refreshAria": "Обновить модели",
"models.enabled": "Включена",
"models.hourly": "в час",
"models.daily": "в день",
"models.unlimited": "Без лимита",
"models.ollamaTitle": "Библиотека Ollama",
"models.ollamaDesc": "Скачивание и удаление локальных моделей.",
"models.empty": "Нет доступных моделей.",
"models.saveFailed": "Не удалось сохранить",
"center.locked": "Только админы",
"center.lockedSub": "Войдите как админ, чтобы открыть центр.",
"center.regTitle": "Открытая регистрация",
@@ -2180,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",
@@ -2266,6 +2398,19 @@ const it: Dict = {
"center.tabAccounts": "Account",
"center.tabFeatures": "Funzioni",
"center.tabMail": "Mail",
"center.tabModels": "Modelli",
"models.title": "Modelli",
"models.desc": "Abilita o limita ogni modello. I modelli disabilitati spariscono dal selettore; i limiti valgono per utente (gli admin sono esenti).",
"models.refresh": "Aggiorna",
"models.refreshAria": "Aggiorna modelli",
"models.enabled": "Abilitato",
"models.hourly": "all'ora",
"models.daily": "al giorno",
"models.unlimited": "Illimitato",
"models.ollamaTitle": "Libreria Ollama",
"models.ollamaDesc": "Scarica o rimuovi modelli locali.",
"models.empty": "Nessun modello disponibile.",
"models.saveFailed": "Salvataggio fallito",
"center.locked": "Solo admin",
"center.lockedSub": "Accedi come admin per aprire il Centro.",
"center.regTitle": "Registrazione pubblica",
@@ -2672,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",
@@ -2755,6 +2916,19 @@ const pt: Dict = {
"center.tabAccounts": "Contas",
"center.tabFeatures": "Funções",
"center.tabMail": "Mail",
"center.tabModels": "Modelos",
"models.title": "Modelos",
"models.desc": "Ative ou limite cada modelo. Os desativados somem do seletor; os limites valem por utilizador (admins isentos).",
"models.refresh": "Atualizar",
"models.refreshAria": "Atualizar modelos",
"models.enabled": "Ativo",
"models.hourly": "por hora",
"models.daily": "por dia",
"models.unlimited": "Ilimitado",
"models.ollamaTitle": "Biblioteca Ollama",
"models.ollamaDesc": "Baixar ou remover modelos locais.",
"models.empty": "Nenhum modelo disponível.",
"models.saveFailed": "Falha ao guardar",
"center.locked": "Só admins",
"center.lockedSub": "Entra como admin para abrir o Centro.",
"center.regTitle": "Registo público",
+3
View File
@@ -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
+8
View File
@@ -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);
}
+45 -12
View File
@@ -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
+6 -4
View File
@@ -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(),
+131 -4
View File
@@ -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,
@@ -79,6 +78,25 @@ import {
type ReportReason,
type ReportStatus,
} from "./reports.ts";
import {
checkModelAccess,
getModelPolicy,
initModelUsageTables,
policyFor,
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";
@@ -109,6 +127,7 @@ const ollamaDriver = registry.get("ollama") as OllamaDriver;
await tools.init();
initAccessTables();
initReportsTables();
initModelUsageTables();
initTotpTables();
function json(data: unknown, status = 200): Response {
@@ -659,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);
@@ -980,6 +1028,43 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
}
}
// --- admin: per-model access policy (kill-switch + rate limits) ---
if (path === "/api/admin/model-policy" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
if (/^(1|true|yes)$/i.test(url.searchParams.get("refresh") ?? "")) {
registry.invalidateModelsCache();
}
// Full list (policy never hides models from admins) merged with policy.
const models = await registry.listAllModelsCached();
const policy = getModelPolicy();
return json({
models: models.map((m) => ({ ...m, ...policyFor(policy, m.id) })),
});
}
if (path === "/api/admin/model-policy" && req.method === "PATCH") {
if (!admin) return json({ error: "forbidden" }, 403);
const { ok, body } = await readJson(req);
if (!ok) return json({ error: "invalid JSON" }, 400);
const b = body as { model?: unknown; enabled?: unknown; hourly?: unknown; daily?: unknown };
if (typeof b.model !== "string" || b.model.length < 3 || b.model.length > 200 || !b.model.includes(":")) {
return json({ error: 'model must look like "driver:model"' }, 400);
}
if (b.enabled === undefined && b.hourly === undefined && b.daily === undefined) {
return json({ error: "nothing to update (enabled, hourly, daily)" }, 400);
}
try {
const entry = setModelPolicyEntry(b.model, {
enabled: b.enabled as boolean | undefined,
hourly: b.hourly as number | undefined,
daily: b.daily as number | undefined,
});
return json({ model: { id: b.model, ...entry } });
} catch {
return json({ error: "enabled must be boolean, hourly/daily integers 0-1000000" }, 400);
}
}
// --- admin: access-request wishlist triage ---
if (path === "/api/admin/access" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
@@ -1186,7 +1271,26 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
url.searchParams.get("refresh") ?? url.searchParams.get("force") ?? "",
);
const models = await registry.listAllModelsCached(wantRefresh && admin);
return json({ models });
// 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).
const policy = getModelPolicy();
return json({ models: models.filter((m) => policyFor(policy, m.id).enabled) });
}
// --- platform tools (uploads always go through tools) ---
@@ -1234,8 +1338,31 @@ 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.
if (!user.isAdmin) {
const access = checkModelAccess(user.id, body.model);
if (!access.ok) return json({ error: access.message }, access.code);
recordModelUse(user.id, body.model);
}
const messages = body.messages as ChatMessage[];
// Optional persistence into a server-side conversation (must belong to the user).
+132
View File
@@ -0,0 +1,132 @@
import { getDb, getSetting, setSetting } from "./db.ts";
/**
* Per-model access policy (admin kill-switch + per-user rate limits).
* Stored as one JSON blob in the `settings` table (`model_policy` key);
* usage counters live in `model_usage` (hourly + daily fixed windows).
*
* Policy is global, not per-user; admins always bypass checks.
* Limits of 0 mean "unlimited". Models without an entry use the default
* (enabled, no limits); entries equal to the default are pruned.
*/
export interface ModelPolicyEntry {
enabled: boolean;
/** Max requests per UTC hour (0 = unlimited). */
hourly: number;
/** Max requests per UTC day (0 = unlimited). */
daily: number;
}
export const DEFAULT_POLICY: ModelPolicyEntry = { enabled: true, hourly: 0, daily: 0 };
export type ModelPolicy = Record<string, ModelPolicyEntry>;
const SETTINGS_KEY = "model_policy";
export const MAX_LIMIT = 1_000_000;
function numOrZero(v: unknown): number {
return typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= MAX_LIMIT ? v : 0;
}
function sanitizeEntry(v: unknown): ModelPolicyEntry {
const o = (v ?? {}) as Record<string, unknown>;
return {
enabled: typeof o.enabled === "boolean" ? o.enabled : true,
hourly: numOrZero(o.hourly),
daily: numOrZero(o.daily),
};
}
function isDefault(e: ModelPolicyEntry): boolean {
return e.enabled && e.hourly === 0 && e.daily === 0;
}
export function getModelPolicy(): ModelPolicy {
const raw = getSetting(SETTINGS_KEY, "");
if (!raw) return {};
try {
const obj = JSON.parse(raw) as Record<string, unknown>;
if (!obj || typeof obj !== "object") return {};
const out: ModelPolicy = {};
for (const [k, v] of Object.entries(obj)) {
if (k && k.length <= 200) out[k] = sanitizeEntry(v);
}
return out;
} catch {
return {};
}
}
export function policyFor(policy: ModelPolicy, modelId: string): ModelPolicyEntry {
return policy[modelId] ?? { ...DEFAULT_POLICY };
}
export function setModelPolicyEntry(
modelId: string,
patch: { enabled?: boolean; hourly?: number; daily?: number },
): ModelPolicyEntry {
if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") throw new Error("BAD_POLICY");
if (patch.hourly !== undefined && !Number.isInteger(patch.hourly)) throw new Error("BAD_POLICY");
if (patch.daily !== undefined && !Number.isInteger(patch.daily)) throw new Error("BAD_POLICY");
const policy = getModelPolicy();
const cur = policy[modelId] ?? { ...DEFAULT_POLICY };
const next: ModelPolicyEntry = {
enabled: patch.enabled ?? cur.enabled,
hourly: patch.hourly ?? cur.hourly,
daily: patch.daily ?? cur.daily,
};
if (next.hourly < 0 || next.hourly > MAX_LIMIT || next.daily < 0 || next.daily > MAX_LIMIT) {
throw new Error("BAD_POLICY");
}
if (isDefault(next)) delete policy[modelId];
else policy[modelId] = next;
setSetting(SETTINGS_KEY, JSON.stringify(policy));
return { ...DEFAULT_POLICY, ...policy[modelId] };
}
export function initModelUsageTables(): void {
getDb().query(`CREATE TABLE IF NOT EXISTS model_usage (
user_id TEXT NOT NULL,
model TEXT NOT NULL,
window TEXT NOT NULL,
bucket INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, model, window, bucket)
)`).run();
}
function buckets(now = Date.now()): { h: number; d: number } {
return { h: Math.floor(now / 3600_000), d: Math.floor(now / 86400_000) };
}
function windowCount(userId: string, modelId: string, window: "h" | "d", bucket: number): number {
const row = getDb().query(
"SELECT count AS n FROM model_usage WHERE user_id = ? AND model = ? AND window = ? AND bucket = ?",
).get(userId, modelId, window, bucket) as { n: number } | null;
return row?.n ?? 0;
}
export type ModelAccess = { ok: true } | { ok: false; code: 403 | 429; message: string };
export function checkModelAccess(userId: string, modelId: string): ModelAccess {
const p = policyFor(getModelPolicy(), modelId);
if (!p.enabled) return { ok: false, code: 403, message: "model disabled by administrator" };
const { h, d } = buckets();
if (p.hourly > 0 && windowCount(userId, modelId, "h", h) >= p.hourly) {
return { ok: false, code: 429, message: "hourly limit reached for this model, try again later" };
}
if (p.daily > 0 && windowCount(userId, modelId, "d", d) >= p.daily) {
return { ok: false, code: 429, message: "daily limit reached for this model, try again tomorrow" };
}
return { ok: true };
}
export function recordModelUse(userId: string, modelId: string): void {
const d = getDb();
const { h, d: day } = buckets();
d.query(`INSERT INTO model_usage (user_id, model, window, bucket, count) VALUES (?, ?, 'h', ?, 1)
ON CONFLICT(user_id, model, window, bucket) DO UPDATE SET count = count + 1`).run(userId, modelId, h);
d.query(`INSERT INTO model_usage (user_id, model, window, bucket, count) VALUES (?, ?, 'd', ?, 1)
ON CONFLICT(user_id, model, window, bucket) DO UPDATE SET count = count + 1`).run(userId, modelId, day);
d.query("DELETE FROM model_usage WHERE (window = 'h' AND bucket < ?) OR (window = 'd' AND bucket < ?)").run(h - 1, day - 1);
}
+119
View File
@@ -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;
}