:3
This commit is contained in:
@@ -1,15 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { api, getToken } from "../lib/api.ts";
|
||||||
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||||
import { AdminPanel } from "./AdminPanel.tsx";
|
import { AdminPanel } from "./AdminPanel.tsx";
|
||||||
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
||||||
import { ReportsPanel } from "./ReportsPanel.tsx";
|
import { ReportsPanel } from "./ReportsPanel.tsx";
|
||||||
|
import { ModelsPanel } from "./ModelsPanel.tsx";
|
||||||
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
|
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
|
||||||
import { cn } from "../lib/cn.ts";
|
import { cn } from "../lib/cn.ts";
|
||||||
import { useT } from "../lib/i18n.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) {
|
function applyTheme(theme: string) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -32,6 +33,7 @@ export function AdminCenter() {
|
|||||||
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
||||||
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
|
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
|
||||||
{ id: "reports", label: t("center.tabReports"), icon: Flag },
|
{ 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: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
|
||||||
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
||||||
];
|
];
|
||||||
@@ -189,6 +191,12 @@ export function AdminCenter() {
|
|||||||
</section>
|
</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" && (
|
{tab === "features" && (
|
||||||
<section className="space-y-3">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -190,7 +190,6 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
|
const [freshKey, setFreshKey] = useState<{ username: string; key: string } | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||||
const [editTarget, setEditTarget] = useState<User | null>(null);
|
const [editTarget, setEditTarget] = useState<User | null>(null);
|
||||||
const [modelsOpen, setModelsOpen] = useState(false);
|
|
||||||
// Listing: search + sort + filter + pagination (server-side).
|
// Listing: search + sort + filter + pagination (server-side).
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [debouncedQ, setDebouncedQ] = 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)}>
|
<Button variant="secondary" size="sm" onClick={() => setShowCreate(true)}>
|
||||||
<UserPlus size={15} /> {t("admin.newAccount")}
|
<UserPlus size={15} /> {t("admin.newAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setModelsOpen(true)}>
|
|
||||||
<Bot size={15} /> {t("ollama.models")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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">
|
<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">
|
||||||
@@ -415,7 +411,6 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
|
<EditUserDialog user={editTarget} onClose={() => setEditTarget(null)} onSaved={refresh} />
|
||||||
<OllamaModelsModal open={modelsOpen} onClose={() => setModelsOpen(false)} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -634,8 +634,8 @@ function ArchivedSection({ onView, onChanged }: { onView: (c: Conversation) => v
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<p className="-mb-2 text-xs opacity-60">{t("archived.readonly")}</p>
|
<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>}
|
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||||
{(list ?? []).length === 0 ? (
|
{(list ?? []).length === 0 ? (
|
||||||
<p className="py-4 text-center text-sm opacity-50">{t("archived.empty")}</p>
|
<p className="py-4 text-center text-sm opacity-50">{t("archived.empty")}</p>
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ export interface Report {
|
|||||||
updated_at: number;
|
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";
|
const TOKEN_KEY = "kisassistant_token";
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
@@ -140,6 +152,15 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ shadowbanned }),
|
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 }) =>
|
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) }),
|
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
|
|||||||
+58
-1
@@ -79,6 +79,14 @@ import {
|
|||||||
type ReportReason,
|
type ReportReason,
|
||||||
type ReportStatus,
|
type ReportStatus,
|
||||||
} from "./reports.ts";
|
} from "./reports.ts";
|
||||||
|
import {
|
||||||
|
checkModelAccess,
|
||||||
|
getModelPolicy,
|
||||||
|
initModelUsageTables,
|
||||||
|
policyFor,
|
||||||
|
recordModelUse,
|
||||||
|
setModelPolicyEntry,
|
||||||
|
} from "./modelPolicy.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";
|
||||||
@@ -109,6 +117,7 @@ const ollamaDriver = registry.get("ollama") as OllamaDriver;
|
|||||||
await tools.init();
|
await tools.init();
|
||||||
initAccessTables();
|
initAccessTables();
|
||||||
initReportsTables();
|
initReportsTables();
|
||||||
|
initModelUsageTables();
|
||||||
initTotpTables();
|
initTotpTables();
|
||||||
|
|
||||||
function json(data: unknown, status = 200): Response {
|
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 ---
|
// --- admin: access-request wishlist triage ---
|
||||||
if (path === "/api/admin/access" && req.method === "GET") {
|
if (path === "/api/admin/access" && req.method === "GET") {
|
||||||
if (!admin) return json({ error: "forbidden" }, 403);
|
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") ?? "",
|
url.searchParams.get("refresh") ?? url.searchParams.get("force") ?? "",
|
||||||
);
|
);
|
||||||
const models = await registry.listAllModelsCached(wantRefresh && admin);
|
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) ---
|
// --- platform tools (uploads always go through tools) ---
|
||||||
@@ -1237,6 +1287,13 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return json({ error: (e as Error).message }, 400);
|
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[];
|
const messages = body.messages as ChatMessage[];
|
||||||
// Optional persistence into a server-side conversation (must belong to the user).
|
// Optional persistence into a server-side conversation (must belong to the user).
|
||||||
let conv = null;
|
let conv = null;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user