diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index 593b0f9..ddacf68 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -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() { )} + {tab === "models" && ( +
+ +
+ )} + {tab === "features" && (
diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 8812b44..8b66fd4 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -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(null); const [editTarget, setEditTarget] = useState(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: () -
) : (
@@ -415,7 +411,6 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: () /> setEditTarget(null)} onSaved={refresh} /> - setModelsOpen(false)} /> ); } diff --git a/frontend/src/components/ModelsPanel.tsx b/frontend/src/components/ModelsPanel.tsx new file mode 100644 index 0000000..703d518 --- /dev/null +++ b/frontend/src/components/ModelsPanel.tsx @@ -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([]); + 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(); + 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 ( +
+
+
+

{t("models.title")}

+ +
+

{t("models.desc")}

+ {error &&

{error}

} + {loading &&

{t("common.loading")}

} + {!loading && models.length === 0 && !error && ( +

+ {t("models.empty")} +

+ )} + {groups.map(([group, list]) => ( +
+

{group}

+
    + {list.map((m) => ( + + ))} +
+
+ ))} +
+ +
+

{t("models.ollamaTitle")}

+

{t("models.ollamaDesc")}

+
+ +
+
+ + { + setOllamaOpen(false); + refresh(); + }} + /> +
+ ); +} + +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 ( +
  • + + {model.label || model.id} + {model.id} + + + + void patch({ enabled: v })} + /> +
  • + ); +} diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index 21223b0..17e128e 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -634,8 +634,8 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v } return ( -
    -

    {t("archived.readonly")}

    +
    +

    {t("archived.readonly")}

    {error &&

    {error}

    } {(list ?? []).length === 0 ? (

    {t("archived.empty")}

    diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 86e0c27..60b1123 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -42,6 +42,18 @@ export interface Report { updated_at: number; } +export interface ModelPolicyEntry { + enabled: boolean; + hourly: number; + daily: number; +} + +export interface PolicyModel extends DriverModel { + enabled: boolean; + hourly: number; + daily: number; +} + const TOKEN_KEY = "kisassistant_token"; export function getToken(): string | null { @@ -140,6 +152,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) }), diff --git a/src/index.ts b/src/index.ts index 8139af1..af1f993 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,14 @@ import { type ReportReason, type ReportStatus, } from "./reports.ts"; +import { + checkModelAccess, + getModelPolicy, + initModelUsageTables, + policyFor, + recordModelUse, + setModelPolicyEntry, +} from "./modelPolicy.ts"; import type { ConversationRow } from "./db.ts"; import type { OllamaDriver } from "./drivers/ollama.ts"; import { DriverRegistry } from "./drivers/registry.ts"; @@ -109,6 +117,7 @@ const ollamaDriver = registry.get("ollama") as OllamaDriver; await tools.init(); initAccessTables(); initReportsTables(); +initModelUsageTables(); initTotpTables(); function json(data: unknown, status = 200): Response { @@ -980,6 +989,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 +1232,11 @@ 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 }); + 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) --- @@ -1237,6 +1287,13 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ } catch (e) { return json({ error: (e as Error).message }, 400); } + // 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). let conv = null; diff --git a/src/modelPolicy.ts b/src/modelPolicy.ts new file mode 100644 index 0000000..e217cab --- /dev/null +++ b/src/modelPolicy.ts @@ -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; + +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; + 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; + 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); +}