From 6e65d84a1ccbac1fba3e4d368772c7adbed02c75 Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Thu, 24 Sep 2026 11:02:24 +0200 Subject: [PATCH] :) --- README.md | 8 +- docs/ARCHITECTURE.md | 6 +- docs/FRONTEND.md | 5 + docs/OPERATIONS.md | 64 ++- frontend/src/App.tsx | 6 +- frontend/src/components/AdminCenter.tsx | 88 +++- frontend/src/components/ModelsPanel.tsx | 148 +----- frontend/src/components/OllamaCatalog.tsx | 237 ++++++++++ frontend/src/components/OllamaNodes.tsx | 520 ++++++++++++++++++++++ frontend/src/lib/api.ts | 95 +++- frontend/src/lib/i18n.ts | 360 +++++++++++++++ frontend/src/lib/route.ts | 8 + prompt_v2 | 25 ++ src/auth.ts | 37 ++ src/drivers/ollama.ts | 122 ++++- src/index.ts | 266 ++++++++++- src/ollamaNodes.ts | 337 ++++++++++++++ 17 files changed, 2141 insertions(+), 191 deletions(-) create mode 100644 frontend/src/components/OllamaCatalog.tsx create mode 100644 frontend/src/components/OllamaNodes.tsx create mode 100644 src/ollamaNodes.ts diff --git a/README.md b/README.md index f4a4557..9e73e88 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,12 @@ from the **Accounts** panel (each gets a one-time access key to hand over). | GET/PATCH | `/api/admin/settings` | admin | feature flags: registrationEnabled, ocrEnabled | | GET/POST | `/api/conversations` | Bearer | server-persisted chats (title, topic, model) | | GET | `/api/conversations/search?q=` | Bearer | search titles, topics + old prompts (with snippet) | -| POST | `/api/admin/ollama/pull` | admin | pull a model from the Ollama library (NDJSON progress) | -| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model | +| POST | `/api/admin/ollama/pull` | admin | pull a model (library tag or `hf.co/…`, `{name, nodeId?}`, NDJSON progress) | +| DELETE | `/api/admin/ollama/models/:name` | admin | remove a local Ollama model (`?node=` for a pool node) | +| GET/POST | `/api/admin/ollama/nodes` | admin | Ollama pool: list (+effective order) / register a host | +| PATCH/DELETE | `/api/admin/ollama/nodes/:id` | admin | rename / reweight / enable / delete a node | +| GET | `/api/admin/ollama/nodes/:id/status`, `…/stats?window=` | admin | live probe (version, running, VRAM) / load charts 1h–30d | +| GET | `/api/admin/ollama/probe?host=` | admin | test a host before registering it | | GET | `/api/admin/ollama/status` | admin | Ollama connectivity probe: reachability, version, on-disk models | | POST | `/api/access/request` | public | reserve username+email with a motivation → ticket (`/review/:id`) | | GET/POST | `/api/access/ticket/:id` (+`/message`) | ticket bearer | follow + reply on an access request | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8e94ddc..ed06203 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -121,7 +121,11 @@ only ever sees reviewed text blocks, never raw files. Models are addressed as `"driver:model"` (split on the first `:`). - `OllamaDriver`: model discovery via `GET {OLLAMA_HOST}/api/tags`, chat via - `POST /api/chat` with NDJSON streaming. + `POST /api/chat` with NDJSON streaming. Multi-node pool (`src/ollamaNodes.ts`: + `ollama_nodes` table + `effectiveOllamaNodes()`): each generation picks a + node by weighted random and fails over pre-stream on error/timeout + (`ollama_timeout_s` setting); per-attempt telemetry (ok, latency, token + counts) lands in `ollama_stats` for the admin charts. - `GET /api/models` is served from an in-memory cache in `DriverRegistry` (`MODEL_CACHE_TTL_MS` = 60 s) so logins don't hit Ollama every time. Concurrent misses share one in-flight upstream fetch. Admins pass diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 1dc0256..dd61c4c 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -81,6 +81,11 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`, - `Markdown.tsx` — `react-markdown` + GFM; code blocks get a language label, a copy button and dependency-free syntax highlighting (`lib/highlight.tsx` — regex tokenizer, no highlight.js, so the Nix offline lockfile is untouched). +- Admin Center tabs are deep-linkable (`/admin`, `/admin/models`, + `/admin/access`, … — `lib/route.ts` `adminTabFromPath`, SPA fallback serves + them all). Models tab: `OllamaNodes.tsx` (pool with weights, per-node live + status + 1h–30d load charts, generation timeout) and `OllamaCatalog.tsx` + (live Hugging Face GGUF search + one-click `hf.co/…` pull to a node). ## Theming diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 41d2d86..94e9054 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -84,11 +84,14 @@ Notes: ## Admin Center (`/admin`) & self-registration The sidebar **Admin Center** button (admin only) opens the `/admin` page with -four tabs: **Accounts** (same manager as before, incl. the Ollama model -catalog), **Access** (access-request wishlist triage, see below), -**Features** (server-side switches), **Mail** (SMTP credentials viewer + -connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`, -`POST /api/admin/mail/test`, admin Bearer only). +six deep-linkable tabs (`/admin` = accounts, `/admin/models`, `/admin/access`, +`/admin/reports`, `/admin/features`, `/admin/mail`): **Accounts** (same +manager as before), **Access** (access-request wishlist triage, see below), +**Reports**, **Models** (node pool, per-model policy, Ollama library + HF +catalog), **Features** (server-side switches), **Mail** (SMTP credentials +viewer + connectivity tester: `GET /api/admin/mail`, +`POST /api/admin/mail/verify`, `POST /api/admin/mail/test`, admin Bearer +only — plus the password-recovery rate limits below). - `registration_enabled` (default off): when on, the login page shows a working **Register** button — anyone can create an account (key shown once) @@ -108,7 +111,20 @@ connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`, Admins bypass it (`PATCH /api/admin/users/:id` still works). Surfaced to clients via `GET /api/tools`. - Flags live in the `settings` SQLite table, edited via - `GET/PATCH /api/admin/settings` (admin Bearer only). + `GET/PATCH /api/admin/settings` (admin Bearer only). The same endpoint + carries the Ollama pool tuning (`ollama_timeout_s` 5–3600, default 300; + `ollama_default_weight` 0–1000, default 100) and the recovery envelope + (`recovery_limit_max` 1–100, default 5; `recovery_limit_window_min` + 1–1440, default 60). + +## Password recovery rate limits + +`POST /api/auth/recover` and the `/api/auth/reset/:token` endpoints share a +dedicated per-IP envelope (separate from login brute-force protection): +every hit counts, successes included (recovery answers are always-ok by +design, so enumeration is impossible). Over-limit answers 429 with +`retryAfterSec` + `Retry-After`. The limits are admin-configurable live in +Admin Center → Mail → Password recovery limits, no restart. ## Model access policy (kill-switch + rate limits) @@ -124,10 +140,38 @@ an **Enabled** switch and **hourly / daily** per-user request caps 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?}`). - Ollama library: `POST /api/admin/ollama/pull` (NDJSON progress), - `DELETE /api/admin/ollama/models/:name`, and the connectivity probe - `GET /api/admin/ollama/status` (reachability, version, on-disk models — - surfaced in Admin Center → Models). + Ollama library: `POST /api/admin/ollama/pull` (`{name, nodeId?}`, NDJSON + progress — library tags AND `hf.co/user/repo:quant` references), + `DELETE /api/admin/ollama/models/:name[?node=]`, and the connectivity probe + `GET /api/admin/ollama/status` (default node — surfaced in the node pool). + +## Ollama node pool (multi-host, weighted) + +Admin Center → Models → **Ollama nodes**: register extra Ollama hosts +(modal with name, host, weight + a Test probe before saving). The +env-configured `OLLAMA_HOST` stays as the implicit `default` node (read-only +row, `env` badge). Each generation picks a node by **weighted random** +among enabled nodes with weight > 0 (share shown as % — firewall-like +priority, heaviest first) and fails over to the next node on error or +timeout, but only before any token was produced (a started reply never +restarts elsewhere). Weight 0 or disabled = no traffic. Pool changes +invalidate the model list cache; `listModels` merges tags across nodes. + +- `ollama_timeout_s` (default 300 s, Admin Center → Models): max time for + one generation per node attempt (caller aborts propagate instantly and + never fail over). +- Node detail view: live probe (version, latency, on-disk models, running + models with VRAM via `/api/ps`) + load charts over 1h / 6h / 24h / 7d / + 30d (requests, errors, avg latency, tokens/s from app-side generation + telemetry in `ollama_stats`, 35-day retention — Ollama exposes no CPU/RAM + API, so host CPU/RAM meters are intentionally absent). +- HF catalog (same tab): live Hugging Face search (`filter=gguf`, sorted by + downloads), per-repo quantization picker, one-click pull to the selected + node. Needs Ollama with `hf.co/…` support + internet on that host. +- Routes: `GET/POST /api/admin/ollama/nodes`, `PATCH/DELETE + /api/admin/ollama/nodes/:id`, `GET …/nodes/:id/status`, + `GET …/nodes/:id/stats?window=1h|6h|24h|7d|30d`, + `GET /api/admin/ollama/probe?host=…`. ## Personal providers (BYOK) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f3bc19..b0b4fca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,7 @@ import type { FilePreview, User, } from "./lib/types.ts"; -import { chatIdFromPath, isChatPath, navigate, normalizePath, useRoute } from "./lib/route.ts"; +import { adminTabFromPath, chatIdFromPath, isChatPath, navigate, normalizePath, useRoute } from "./lib/route.ts"; import { Login } from "./components/Login.tsx"; import { RegisterPage } from "./components/RegisterPage.tsx"; import { Sidebar } from "./components/Sidebar.tsx"; @@ -44,7 +44,7 @@ const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null; const REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null; const IS_ADMIN_PAGE = typeof window !== "undefined" && - /^\/admin\/?$/.test(window.location.pathname); + (/^\/admin\/?$/.test(window.location.pathname) || adminTabFromPath(window.location.pathname) !== null); function resetTokenFromPath(): string | null { const m = window.location.pathname.match( @@ -139,7 +139,7 @@ export function App() { // Public routes: no auth needed. IDs are constant for the page lifetime. if (IS_ADMIN_PAGE) { - return ; + return ; } if (RESET_TOKEN) { return ; diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index fcfc4a0..0357c33 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -1,20 +1,27 @@ import { useEffect, useRef, useState } from "react"; import { ArrowLeft, Boxes, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react"; -import { api, getToken, type AccessStatus, type ReportStatus } from "../lib/api.ts"; +import { api, getToken, type AccessStatus, type AdminSettings, type AdminSettingsPatch, type ReportStatus } from "../lib/api.ts"; import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts"; +import { navigate } from "../lib/route.ts"; import { AdminPanel } from "./AdminPanel.tsx"; import { AccessRequestsPanel } from "./AccessRequests.tsx"; import { ReportsPanel } from "./ReportsPanel.tsx"; import { ModelsPanel } from "./ModelsPanel.tsx"; import { Toasts } from "./Toasts.tsx"; import { pushToast } from "../lib/toasts.ts"; -import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; +import { Button, CopyButton, Field, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; import { cn } from "../lib/cn.ts"; import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts"; import { useT } from "../lib/i18n.ts"; type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail"; +const VALID_TABS: readonly string[] = ["accounts", "access", "reports", "models", "features", "mail"]; + +function validTab(v: string | null | undefined): Tab { + return v !== null && v !== undefined && (VALID_TABS as readonly string[]).includes(v) ? (v as Tab) : "accounts"; +} + function applyTheme(theme: string) { const root = document.documentElement; const dark = resolveThemeDark(theme); @@ -22,15 +29,28 @@ function applyTheme(theme: string) { root.style.colorScheme = dark ? "dark" : "light"; } -export function AdminCenter() { +export function AdminCenter({ initialTab }: { initialTab?: string | null }) { const { t } = useT(); const [allowed, setAllowed] = useState(null); - const [tab, setTab] = useState("accounts"); - const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } | null>(null); + const [tab, setTab] = useState(() => validTab(initialTab)); + const [settings, setSettings] = useState(null); const [error, setError] = useState(""); const [saving, setSaving] = useState(false); const [openAccessCount, setOpenAccessCount] = useState(0); const [openReportCount, setOpenReportCount] = useState(0); + // Deep links (/admin, /admin/models, …): unknown sections bounce to /admin. + useEffect(() => { + if (initialTab !== null && initialTab !== undefined && !VALID_TABS.includes(initialTab)) { + navigate("/admin", true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function selectTab(id: Tab): void { + setTab(id); + navigate(id === "accounts" ? "/admin" : `/admin/${id}`); + } + // Full badge resync (HTTP) — only for mount, reconnect gaps and rare // actions with no WS event (reporter shadow-ban). Hot path is incremental. const resyncBadgesRef = useRef<() => void>(() => {}); @@ -139,7 +159,7 @@ export function AdminCenter() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [allowed]); - async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) { + async function toggle(patch: AdminSettingsPatch) { setSaving(true); setError(""); try { @@ -187,7 +207,7 @@ export function AdminCenter() { {TABS.map((t) => ( + + {recMsg && ( +

+ {recMsg.text} +

+ )} + ); } diff --git a/frontend/src/components/ModelsPanel.tsx b/frontend/src/components/ModelsPanel.tsx index ef45dbb..0c70f63 100644 --- a/frontend/src/components/ModelsPanel.tsx +++ b/frontend/src/components/ModelsPanel.tsx @@ -1,12 +1,14 @@ -import { useCallback, useEffect, useState } from "react"; -import { Activity, Bot, Undo2 } from "lucide-react"; -import { api, type OllamaStatus, type PolicyModel } from "../lib/api.ts"; +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 { NodesSection } from "./OllamaNodes.tsx"; +import { CatalogSection } from "./OllamaCatalog.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. */ +/** Admin models section: node pool, per-model policy, Ollama library + HF catalog. */ export function ModelsPanel() { const [models, setModels] = useState([]); const [loading, setLoading] = useState(true); @@ -48,7 +50,7 @@ export function ModelsPanel() { return (
- +
@@ -103,143 +105,13 @@ export function ModelsPanel() { refresh(); }} /> + +
); } -/* ---------- Ollama connectivity probe (admin) ---------- */ - -function fmtSize(bytes: number): string { - if (!bytes || bytes < 0) return "—"; - const units = ["B", "KB", "MB", "GB", "TB"]; - let v = bytes; - let i = 0; - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } - return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; -} - -/** Live Ollama host status: reachability, version, latency, on-disk models. */ -function OllamaConnectivity() { - const [status, setStatus] = useState(null); - const [checking, setChecking] = useState(true); - const { t } = useT(); - - const check = useCallback(async () => { - setChecking(true); - try { - setStatus(await api.ollamaStatus()); - } catch { - setStatus(null); - } finally { - setChecking(false); - } - }, []); - - useEffect(() => { - void check(); - }, [check]); - - const state = !status ? "unknown" : !status.enabled ? "disabled" : status.reachable ? "online" : "offline"; - const dot = - state === "online" - ? "bg-emerald-500" - : state === "offline" - ? "bg-red-500" - : "bg-stone-400 dark:bg-zinc-500"; - const stateLabel = - state === "online" - ? t("models.connOnline") - : state === "offline" - ? t("models.connOffline") - : state === "disabled" - ? t("models.connDisabledState") - : t("common.loading"); - - return ( -
-
-

- {t("models.connTitle")} -

- - - {checking && !status ? t("common.loading") : stateLabel} - - -
-

{t("models.connDesc")}

- - {status && ( -
-
-
{t("models.connHost")}
-
{status.host}
-
-
-
{t("models.connVersion")}
-
{status.version ?? "—"}
-
-
-
{t("models.connLatency")}
-
{status.latencyMs === null ? "—" : `${status.latencyMs} ms`}
-
-
-
{t("models.connModels")}
-
{status.models.length}
-
-
- )} - - {!checking && !status && ( -

- {t("admin.loadFailed")} -

- )} - {status && status.error && ( -

- {status.error} - {t("models.connHint")} -

- )} - - {status && status.reachable && ( - status.models.length === 0 ? ( -

- {t("models.connEmpty")} -

- ) : ( -
    - {status.models.map((m) => ( -
  • - {m.name} - {fmtSize(m.size)} -
  • - ))} -
- ) - )} -
- ); -} +/* ---------- per-model policy rows ---------- */ function ModelRow({ model, onChanged, onError }: { model: PolicyModel; diff --git a/frontend/src/components/OllamaCatalog.tsx b/frontend/src/components/OllamaCatalog.tsx new file mode 100644 index 0000000..969b778 --- /dev/null +++ b/frontend/src/components/OllamaCatalog.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef, useState } from "react"; +import { Download, Globe, Server } from "lucide-react"; +import { api, type EffectiveOllamaNode } from "../lib/api.ts"; +import { Field, Input, Picker, Spinner } from "./ui.tsx"; +import { useT } from "../lib/i18n.ts"; +import { cn } from "../lib/cn.ts"; + +interface HfHit { + id: string; + downloads: number; + likes: number; +} + +interface HfFile { + name: string; + quant: string; + size: number | null; +} + +/** Extract the quantization tag from a GGUF filename (…-Q4_K_M.gguf). */ +function quantOf(filename: string): string | null { + const base = filename.split("/").pop() ?? filename; + const m = base.match(/[-_.](Q\d(?:_\w+)?|IQ\d_\w+|BF16|F16|F32)(?:\.gguf)$/i); + return m ? m[1]!.toUpperCase() : null; +} + +function fmtNum(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + +/** Hugging Face GGUF catalog: search, pick a quantization, pull hf.co/… */ +export function CatalogSection() { + const { t } = useT(); + const [q, setQ] = useState(""); + const [hits, setHits] = useState([]); + const [searching, setSearching] = useState(false); + const [searched, setSearched] = useState(false); + const [files, setFiles] = useState>({}); + const [nodes, setNodes] = useState([{ id: "default", name: "default", host: "", weight: 0 }]); + const [nodeId, setNodeId] = useState("default"); + const [pulling, setPulling] = useState(null); + const [pullStatus, setPullStatus] = useState(""); + const [pullPct, setPullPct] = useState(null); + const [error, setError] = useState(""); + const timer = useRef | null>(null); + + useEffect(() => { + api.ollamaNodes().then((r) => { + setNodes([{ id: "default", name: "default", host: "", weight: 0 }, ...r.effective.filter((n) => n.id !== "default")]); + }).catch(() => {}); + }, []); + + useEffect(() => { + if (timer.current) clearTimeout(timer.current); + const query = q.trim(); + if (query.length < 2) { + setHits([]); + setSearched(false); + return; + } + setSearching(true); + timer.current = setTimeout(async () => { + try { + const res = await fetch( + `https://huggingface.co/api/models?search=${encodeURIComponent(query)}&filter=gguf&sort=downloads&direction=-1&limit=15`, + ); + if (!res.ok) throw new Error(`Hugging Face: ${res.status}`); + const data = (await res.json()) as { id?: string; downloads?: number; likes?: number }[]; + setHits( + (Array.isArray(data) ? data : []) + .filter((m) => typeof m.id === "string") + .map((m) => ({ id: m.id as string, downloads: m.downloads ?? 0, likes: m.likes ?? 0 })), + ); + setSearched(true); + } catch (err) { + setError(err instanceof Error ? err.message : "search failed"); + } finally { + setSearching(false); + } + }, 450); + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, [q]); + + async function loadFiles(id: string) { + if (files[id] !== undefined) return; + setFiles((prev) => ({ ...prev, [id]: null })); + try { + const res = await fetch(`https://huggingface.co/api/models/${encodeURIComponent(id)}`); + if (!res.ok) throw new Error(`Hugging Face: ${res.status}`); + const data = (await res.json()) as { siblings?: { rfilename?: string }[] }; + const list: HfFile[] = []; + for (const s of data.siblings ?? []) { + const name = s.rfilename ?? ""; + if (!name.toLowerCase().endsWith(".gguf")) continue; + const quant = quantOf(name); + if (quant) list.push({ name, quant, size: null }); + } + // Biggest quants last (heuristic on the tag, not the file size). + setFiles((prev) => ({ ...prev, [id]: list })); + } catch { + setFiles((prev) => ({ ...prev, [id]: [] })); + } + } + + async function pull(id: string, quant: string) { + const ref = `hf.co/${id}:${quant}`; + if (pulling) return; + setPulling(ref); + setError(""); + setPullStatus(t("catalog.pulling", { name: ref })); + setPullPct(null); + try { + await api.ollamaPull(ref, (p) => { + if (p.error) { + setError(p.error); + return; + } + setPullStatus(p.status || ref); + if (p.total && p.completed) setPullPct(Math.min(100, Math.round((p.completed / p.total) * 100))); + else setPullPct(null); + }, { nodeId }); + setPullStatus(ref); + setPullPct(100); + } catch (err) { + setError(err instanceof Error ? err.message : "pull failed"); + } finally { + setPulling(null); + } + } + + return ( +
+

+ {t("catalog.title")} +

+

{t("catalog.desc")}

+ +
+
+ setQ(e.target.value)} + placeholder={t("catalog.searchPh")} + aria-label={t("catalog.searchPh")} + /> +
+
+ + ({ value: n.id, label: n.id === "default" ? "default" : `${n.name} (w:${n.weight})` }))} + /> + +
+
+ + {error &&

{error}

} + {pulling && ( +
+

+ {t("catalog.pulling", { name: pulling })} +

+

{pullStatus}

+ {pullPct !== null && ( +
+
+
+ )} +
+ )} + + {searching &&

{t("common.loading")}

} + {!searching && searched && hits.length === 0 && ( +

+ {t("catalog.noRes")} +

+ )} + +
    + {hits.map((h) => ( +
  • + + {files[h.id] !== undefined && ( +
    + {files[h.id] === null ? ( +

    {t("common.loading")}

    + ) : files[h.id]!.length === 0 ? ( +

    {t("catalog.noRes")}

    + ) : ( +
      + {files[h.id]!.map((f) => { + const ref = `hf.co/${h.id}:${f.quant}`; + return ( +
    • + +
    • + ); + })} +
    + )} +

    {t("catalog.hint")}

    +
    + )} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/OllamaNodes.tsx b/frontend/src/components/OllamaNodes.tsx new file mode 100644 index 0000000..a795ba3 --- /dev/null +++ b/frontend/src/components/OllamaNodes.tsx @@ -0,0 +1,520 @@ +import { useEffect, useState } from "react"; +import { Activity, Pencil, Plus, Server, Trash2, Undo2 } from "lucide-react"; +import { + api, + type EffectiveOllamaNode, + type OllamaNode, + type OllamaNodeStats, + type OllamaNodeStatus, +} from "../lib/api.ts"; +import { Button, ConfirmDialog, Field, Input, Modal, Picker, Spinner, Switch } from "./ui.tsx"; +import { useT } from "../lib/i18n.ts"; +import { cn } from "../lib/cn.ts"; + +export function fmtSize(bytes: number): string { + if (!bytes || bytes < 0) return "—"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +type StatsWindowId = "1h" | "6h" | "24h" | "7d" | "30d"; +const WINDOWS: StatsWindowId[] = ["1h", "6h", "24h", "7d", "30d"]; + +/** Admin Ollama pool: nodes with firewall-like weights + generation timeout. */ +export function NodesSection() { + const { t } = useT(); + const [nodes, setNodes] = useState([]); + const [effective, setEffective] = useState([]); + const [statuses, setStatuses] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [formNode, setFormNode] = useState(null); + const [detailId, setDetailId] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [timeoutS, setTimeoutS] = useState("300"); + const [defWeight, setDefWeight] = useState("100"); + const [saving, setSaving] = useState(false); + + async function refresh() { + setError(""); + try { + const [list, settings] = await Promise.all([api.ollamaNodes(), api.adminGetSettings()]); + setNodes(list.nodes); + setEffective(list.effective); + setTimeoutS(String(settings.settings.ollamaTimeoutS)); + setDefWeight(String(settings.settings.ollamaDefaultWeight)); + // Live probes in parallel; a dead node never blocks the list. + const ids = ["default", ...list.nodes.map((n) => n.id)]; + const results = await Promise.all( + ids.map(async (id) => { + try { + return [id, await api.ollamaNodeStatus(id)] as const; + } catch { + return [id, null] as const; + } + }), + ); + const map: Record = {}; + for (const [id, st] of results) map[id] = st; + setStatuses(map); + } catch (err) { + setError(err instanceof Error ? err.message : t("admin.loadFailed")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const totalWeight = effective.reduce((s, n) => s + n.weight, 0); + + async function savePool(patch: { ollamaTimeoutS?: number; ollamaDefaultWeight?: number }) { + setSaving(true); + try { + const res = await api.adminPatchSettings(patch); + setTimeoutS(String(res.settings.ollamaTimeoutS)); + setDefWeight(String(res.settings.ollamaDefaultWeight)); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.saveFailed")); + } finally { + setSaving(false); + } + } + + function commitTimeout(raw: string) { + const v = Number(raw); + if (!Number.isInteger(v) || v < 5 || v > 3600) { + void refresh(); + return; + } + void savePool({ ollamaTimeoutS: v }); + } + + function commitDefWeight(raw: string) { + const v = Number(raw); + if (!Number.isInteger(v) || v < 0 || v > 1000) { + void refresh(); + return; + } + void savePool({ ollamaDefaultWeight: v }); + } + + async function toggleEnabled(node: OllamaNode, enabled: boolean) { + try { + const res = await api.ollamaNodePatch(node.id, { enabled }); + setNodes((prev) => prev.map((n) => (n.id === res.node.id ? res.node : n))); + const list = await api.ollamaNodes(); + setEffective(list.effective); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.saveFailed")); + } + } + + async function remove(id: string) { + try { + await api.ollamaNodeDelete(id); + setDeleteId(null); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : t("admin.deleteFailed")); + } + } + + const rows: { id: string; name: string; host: string; weight: number; enabled: boolean; builtin: boolean }[] = [ + { + id: "default", + name: "default", + host: statuses["default"]?.host ?? "…", + weight: effective.find((n) => n.id === "default")?.weight ?? 0, + enabled: effective.some((n) => n.id === "default"), + builtin: true, + }, + ...nodes.map((n) => ({ id: n.id, name: n.name, host: n.host, weight: n.weight, enabled: n.enabled, builtin: false })), + ]; + + return ( +
+
+

+ {t("nodes.title")} +

+

{t("nodes.desc")}

+ +
+
+ + setTimeoutS(e.target.value)} + onBlur={(e) => commitTimeout(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + /> + +

{t("nodes.timeoutDesc")}

+
+
+ + setDefWeight(e.target.value)} + onBlur={(e) => commitDefWeight(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} + /> + +

{t("nodes.defWeightDesc")}

+
+
+ + {error &&

{error}

} + {loading &&

{t("common.loading")}

} + +
    + {rows.map((r) => { + const st = statuses[r.id] ?? null; + const online = st?.reachable === true; + const share = totalWeight > 0 && r.enabled && r.weight > 0 ? Math.round((r.weight / totalWeight) * 100) : 0; + return ( +
  • + + + + {r.name} + {r.builtin && env} + + {r.host} + + + w:{r.weight} · {r.enabled ? t("nodes.share", { pct: share }) : t("nodes.off")} + + {!r.builtin && ( + { + const full = nodes.find((n) => n.id === r.id); + if (full) void toggleEnabled(full, v); + }} + /> + )} + + + {!r.builtin && ( + <> + + + + )} + +
  • + ); + })} +
+ +
+ + +
+
+ + { + setFormNode(null); + refresh(); + }} + /> + {detailId && setDetailId(null)} />} + setDeleteId(null)} + title={t("nodes.deleteTitle")} + message={t("nodes.deleteMsg")} + onConfirm={() => deleteId && remove(deleteId)} + /> +
+ ); +} + +/* ---------- add / edit modal ---------- */ + +function NodeFormModal({ node, open, onClose }: { node: OllamaNode | null; open: boolean; onClose: () => void }) { + const { t } = useT(); + const [name, setName] = useState(""); + const [host, setHost] = useState(""); + const [weight, setWeight] = useState("100"); + const [enabled, setEnabled] = useState(true); + const [busy, setBusy] = useState(false); + const [probing, setProbing] = useState(false); + const [probe, setProbe] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + if (open) { + setName(node?.name ?? ""); + setHost(node?.host ?? ""); + setWeight(String(node?.weight ?? 100)); + setEnabled(node?.enabled ?? true); + setProbe(null); + setError(""); + setBusy(false); + } + }, [open, node]); + + async function test() { + setProbing(true); + setProbe(null); + setError(""); + try { + setProbe(await api.ollamaNodeProbe(host.trim())); + } catch (err) { + setError(err instanceof Error ? err.message : t("nodes.testFail")); + } finally { + setProbing(false); + } + } + + async function save(e: React.FormEvent) { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(""); + try { + const w = Number(weight); + const payload = { name: name.trim(), host: host.trim(), weight: w, enabled }; + if (node) await api.ollamaNodePatch(node.id, payload); + else await api.ollamaNodeCreate(payload); + onClose(); + } catch (err) { + const msg = err instanceof Error ? err.message : t("nodes.invalid"); + setError(/already registered/i.test(msg) ? t("nodes.dupHost") : msg); + } finally { + setBusy(false); + } + } + + return ( + +
+ + setName(e.target.value)} placeholder={t("nodes.namePh")} required maxLength={60} /> + + + setHost(e.target.value)} placeholder={t("nodes.hostPh")} required inputMode="url" /> + +
+
+ + setWeight(e.target.value)} required /> + +
+
+ +
+
+ +
+
+ {probe && ( +

+ {probe.reachable + ? `${t("nodes.testOk")}${probe.version ? ` · v${probe.version}` : ""} · ${probe.models.length} models` + : `${t("nodes.testFail")}${probe.error ? ` — ${probe.error}` : ""}`} +

+ )} +

{t("nodes.weightHint")}

+ {error &&

{error}

} +
+ + +
+
+
+ ); +} + +/* ---------- node detail: live status + load charts ---------- */ + +function NodeDetailModal({ nodeId, onClose }: { nodeId: string; onClose: () => void }) { + const { t } = useT(); + const [status, setStatus] = useState(null); + const [stats, setStats] = useState(null); + const [window, setWindow] = useState("24h"); + + useEffect(() => { + let alive = true; + api.ollamaNodeStatus(nodeId).then((s) => { if (alive) setStatus(s); }).catch(() => { if (alive) setStatus(null); }); + return () => { alive = false; }; + }, [nodeId]); + + useEffect(() => { + let alive = true; + api.ollamaNodeStats(nodeId, window).then((r) => { if (alive) setStats(r.stats); }).catch(() => {}); + return () => { alive = false; }; + }, [nodeId, window]); + + const maxReq = Math.max(1, ...((stats?.buckets ?? []).map((b) => b.req))); + + return ( + + {!status ? ( +

{t("common.loading")}

+ ) : ( +
+
+
+
{t("models.connHost")}
+
{status.host}
+
+
+
{t("nodes.version")}
+
{status.version ?? "—"}
+
+
+
{t("nodes.latency")}
+
{status.latencyMs === null ? "—" : `${status.latencyMs} ms`}
+
+
+
{t("nodes.diskModels", { n: status.models.length })}
+
{status.running.length} running
+
+
+ + {status.running.length > 0 && ( +
+

{t("nodes.running")}

+
    + {status.running.map((m) => ( +
  • + {m.name} + {t("nodes.vram")}: {fmtSize(m.sizeVram)} +
  • + ))} +
+
+ )} + +
+
+

{t("nodes.stats")}

+ + setWindow(v as StatsWindowId)} + align="right" + options={WINDOWS.map((w) => ({ value: w, label: w }))} + /> + +
+ {stats && ( + <> +
+ {stats.totals.req} {t("nodes.req")} + 0 ? "text-red-500" : undefined}>{stats.totals.err} {t("nodes.err")} + {t("nodes.avg")}: {stats.totals.avgMs === null ? "—" : `${Math.round(stats.totals.avgMs)} ms`} + {t("nodes.tps")}: {stats.totals.tokPerSec === null ? "—" : stats.totals.tokPerSec.toFixed(1)} + {stats.totals.promptTok + stats.totals.evalTok} {t("nodes.tok")} +
+ {stats.totals.req === 0 ? ( +

+ {t("nodes.emptyStats")} +

+ ) : ( +
+ {stats.buckets.map((b, i) => { + const h = Math.max(2, Math.round((b.req / maxReq) * 100)); + const errH = b.req > 0 ? Math.round(h * (b.err / b.req)) : 0; + return ( +
+ {errH > 0 &&
} +
+ ); + })} +
+ )} + + )} +
+ + {status.models.length > 0 && ( +
+

{t("nodes.diskModels", { n: status.models.length })}

+
    + {status.models.map((m) => ( +
  • + {m.name} + {fmtSize(m.size)} +
  • + ))} +
+
+ )} + +

{t("nodes.cpuNote")}

+
+ )} + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5fde6b9..6f68202 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -68,6 +68,74 @@ export interface OllamaHostModel { modifiedAt: string | null; } +export interface AdminSettings { + registrationEnabled: boolean; + accessRequestEnabled: boolean; + ocrEnabled: boolean; + reportsEnabled: boolean; + usernameChangeEnabled: boolean; + ollamaTimeoutS: number; + ollamaDefaultWeight: number; + recoveryLimitMax: number; + recoveryLimitWindowMin: number; +} + +export interface AdminSettingsPatch { + registrationEnabled?: boolean; + accessRequestEnabled?: boolean; + ocrEnabled?: boolean; + reportsEnabled?: boolean; + usernameChangeEnabled?: boolean; + ollamaTimeoutS?: number; + ollamaDefaultWeight?: number; + recoveryLimitMax?: number; + recoveryLimitWindowMin?: number; +} + +export interface OllamaNode { + id: string; + name: string; + host: string; + weight: number; + enabled: boolean; + created_at: number; + updated_at: number; +} + +export interface EffectiveOllamaNode { + id: string; + name: string; + host: string; + weight: number; +} + +export interface OllamaNodeStatus { + reachable: boolean; + host: string; + version: string | null; + latencyMs: number | null; + models: OllamaHostModel[]; + running: { name: string; sizeVram: number; size: number; expiresAt: string | null }[]; + error: string | null; +} + +export interface OllamaStatsBucket { + t: number; + req: number; + err: number; + avgMs: number | null; + promptTok: number; + evalTok: number; +} + +export interface OllamaNodeStats { + window: string; + from: number; + to: number; + totals: { req: number; err: number; avgMs: number | null; promptTok: number; evalTok: number; tokPerSec: number | null }; + buckets: OllamaStatsBucket[]; +} + export interface OllamaStatus { enabled: boolean; host: string; @@ -188,9 +256,9 @@ export const api = { adminPatch: (id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) => req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), - adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings"), - adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) => - req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }), + adminGetSettings: () => req<{ settings: AdminSettings }>("/api/admin/settings"), + adminPatchSettings: (patch: AdminSettingsPatch) => + req<{ settings: AdminSettings }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }), /** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */ adminMailStatus: () => @@ -281,11 +349,12 @@ export const api = { ollamaPull: async ( name: string, onProgress: (p: { status?: string; digest?: string; total?: number; completed?: number; error?: string }) => void, + opts: { nodeId?: string } = {}, ): Promise => { const res = await fetch("/api/admin/ollama/pull", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, ...(opts.nodeId ? { nodeId: opts.nodeId } : {}) }), }); if (res.status === 401) { clearToken(); @@ -313,7 +382,23 @@ 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" }), + ollamaDelete: (name: string, nodeId?: string) => + req<{ ok: boolean }>(`/api/admin/ollama/models/${encodeURIComponent(name)}${nodeId ? `?node=${encodeURIComponent(nodeId)}` : ""}`, { method: "DELETE" }), + + /** Admin: Ollama node pool (extra hosts with firewall-like weights). */ + ollamaNodes: () => req<{ nodes: OllamaNode[]; effective: EffectiveOllamaNode[] }>("/api/admin/ollama/nodes"), + ollamaNodeCreate: (n: { name: string; host: string; weight: number; enabled: boolean }) => + req<{ node: OllamaNode }>("/api/admin/ollama/nodes", { method: "POST", body: JSON.stringify(n) }), + ollamaNodePatch: (id: string, patch: { name?: string; host?: string; weight?: number; enabled?: boolean }) => + req<{ node: OllamaNode }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(patch) }), + ollamaNodeDelete: (id: string) => + req<{ ok: boolean }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}`, { method: "DELETE" }), + ollamaNodeStatus: (id: string) => + req(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/status`), + ollamaNodeProbe: (host: string) => + req(`/api/admin/ollama/probe?host=${encodeURIComponent(host)}`), + ollamaNodeStats: (id: string, window: string) => + req<{ stats: OllamaNodeStats }>(`/api/admin/ollama/nodes/${encodeURIComponent(id)}/stats?window=${encodeURIComponent(window)}`), /** Admin: Ollama connectivity probe (reachability, version, on-disk models). */ ollamaStatus: () => req("/api/admin/ollama/status"), diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 86b361c..659b5f0 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -491,6 +491,66 @@ const en = { "live.newReport": "New content report ({reason})", "live.adminReply": "Admin replied to your ticket", "live.ticketStatus": "Ticket {status}", + "nodes.title": "Ollama nodes", + "nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.", + "nodes.add": "Add node", + "nodes.edit": "Edit node", + "nodes.name": "Name", + "nodes.namePh": "gpu-box-2", + "nodes.host": "Host", + "nodes.hostPh": "http://10.0.0.5:11434", + "nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.", + "nodes.weight": "Weight", + "nodes.weightHint": "0 disables traffic, higher gets more generations.", + "nodes.enabled": "Enabled", + "nodes.share": "{pct}%", + "nodes.off": "off", + "nodes.test": "Test", + "nodes.probing": "Probing…", + "nodes.testOk": "Reachable", + "nodes.testFail": "Unreachable", + "nodes.deleteTitle": "Delete this node?", + "nodes.deleteMsg": "Generations will no longer be routed to this host.", + "nodes.invalid": "Check the name, host and weight.", + "nodes.dupHost": "This host is already registered.", + "nodes.detail": "Node details", + "nodes.running": "Running now", + "nodes.vram": "VRAM", + "nodes.diskModels": "On disk ({n})", + "nodes.version": "Version", + "nodes.latency": "Latency", + "nodes.timeout": "Generation timeout", + "nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.", + "nodes.timeoutUnit": "seconds (5–3600)", + "nodes.defWeight": "Default node weight", + "nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.", + "nodes.stats": "Load", + "nodes.req": "requests", + "nodes.err": "errors", + "nodes.avg": "avg latency", + "nodes.tps": "tokens/s", + "nodes.tok": "tokens", + "nodes.emptyStats": "No generations in this window yet.", + "nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.", + "catalog.title": "Model catalog (Hugging Face)", + "catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.", + "catalog.searchPh": "Search GGUF models…", + "catalog.pull": "Pull", + "catalog.pulling": "Pulling {name}…", + "catalog.node": "Target node", + "catalog.downloads": "downloads", + "catalog.files": "Quantizations", + "catalog.noRes": "No results.", + "catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.", + "rec.title": "Password recovery limits", + "rec.desc": "Dedicated envelope for key-recovery requests (separate from login).", + "rec.max": "Max attempts", + "rec.maxHint": "per window, per IP", + "rec.window": "Window", + "rec.windowHint": "minutes", + "rec.save": "Save limits", + "rec.saved": "Limits saved", + "rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).", "msg.copy": "Copy message", "msg.search": "Search the web", @@ -1068,6 +1128,66 @@ const es: Dict = { "live.newReport": "New content report ({reason})", "live.adminReply": "Admin replied to your ticket", "live.ticketStatus": "Ticket {status}", + "nodes.title": "Ollama nodes", + "nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.", + "nodes.add": "Add node", + "nodes.edit": "Edit node", + "nodes.name": "Name", + "nodes.namePh": "gpu-box-2", + "nodes.host": "Host", + "nodes.hostPh": "http://10.0.0.5:11434", + "nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.", + "nodes.weight": "Weight", + "nodes.weightHint": "0 disables traffic, higher gets more generations.", + "nodes.enabled": "Enabled", + "nodes.share": "{pct}%", + "nodes.off": "off", + "nodes.test": "Test", + "nodes.probing": "Probing…", + "nodes.testOk": "Reachable", + "nodes.testFail": "Unreachable", + "nodes.deleteTitle": "Delete this node?", + "nodes.deleteMsg": "Generations will no longer be routed to this host.", + "nodes.invalid": "Check the name, host and weight.", + "nodes.dupHost": "This host is already registered.", + "nodes.detail": "Node details", + "nodes.running": "Running now", + "nodes.vram": "VRAM", + "nodes.diskModels": "On disk ({n})", + "nodes.version": "Version", + "nodes.latency": "Latency", + "nodes.timeout": "Generation timeout", + "nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.", + "nodes.timeoutUnit": "seconds (5–3600)", + "nodes.defWeight": "Default node weight", + "nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.", + "nodes.stats": "Load", + "nodes.req": "requests", + "nodes.err": "errors", + "nodes.avg": "avg latency", + "nodes.tps": "tokens/s", + "nodes.tok": "tokens", + "nodes.emptyStats": "No generations in this window yet.", + "nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.", + "catalog.title": "Model catalog (Hugging Face)", + "catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.", + "catalog.searchPh": "Search GGUF models…", + "catalog.pull": "Pull", + "catalog.pulling": "Pulling {name}…", + "catalog.node": "Target node", + "catalog.downloads": "downloads", + "catalog.files": "Quantizations", + "catalog.noRes": "No results.", + "catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.", + "rec.title": "Password recovery limits", + "rec.desc": "Dedicated envelope for key-recovery requests (separate from login).", + "rec.max": "Max attempts", + "rec.maxHint": "per window, per IP", + "rec.window": "Window", + "rec.windowHint": "minutes", + "rec.save": "Save limits", + "rec.saved": "Limits saved", + "rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).", "msg.copy": "Copiar mensaje", "msg.search": "Buscar en la web", @@ -1640,6 +1760,66 @@ const fr: Dict = { "live.newReport": "New content report ({reason})", "live.adminReply": "Admin replied to your ticket", "live.ticketStatus": "Ticket {status}", + "nodes.title": "Ollama nodes", + "nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.", + "nodes.add": "Add node", + "nodes.edit": "Edit node", + "nodes.name": "Name", + "nodes.namePh": "gpu-box-2", + "nodes.host": "Host", + "nodes.hostPh": "http://10.0.0.5:11434", + "nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.", + "nodes.weight": "Weight", + "nodes.weightHint": "0 disables traffic, higher gets more generations.", + "nodes.enabled": "Enabled", + "nodes.share": "{pct}%", + "nodes.off": "off", + "nodes.test": "Test", + "nodes.probing": "Probing…", + "nodes.testOk": "Reachable", + "nodes.testFail": "Unreachable", + "nodes.deleteTitle": "Delete this node?", + "nodes.deleteMsg": "Generations will no longer be routed to this host.", + "nodes.invalid": "Check the name, host and weight.", + "nodes.dupHost": "This host is already registered.", + "nodes.detail": "Node details", + "nodes.running": "Running now", + "nodes.vram": "VRAM", + "nodes.diskModels": "On disk ({n})", + "nodes.version": "Version", + "nodes.latency": "Latency", + "nodes.timeout": "Generation timeout", + "nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.", + "nodes.timeoutUnit": "seconds (5–3600)", + "nodes.defWeight": "Default node weight", + "nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.", + "nodes.stats": "Load", + "nodes.req": "requests", + "nodes.err": "errors", + "nodes.avg": "avg latency", + "nodes.tps": "tokens/s", + "nodes.tok": "tokens", + "nodes.emptyStats": "No generations in this window yet.", + "nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.", + "catalog.title": "Model catalog (Hugging Face)", + "catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.", + "catalog.searchPh": "Search GGUF models…", + "catalog.pull": "Pull", + "catalog.pulling": "Pulling {name}…", + "catalog.node": "Target node", + "catalog.downloads": "downloads", + "catalog.files": "Quantizations", + "catalog.noRes": "No results.", + "catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.", + "rec.title": "Password recovery limits", + "rec.desc": "Dedicated envelope for key-recovery requests (separate from login).", + "rec.max": "Max attempts", + "rec.maxHint": "per window, per IP", + "rec.window": "Window", + "rec.windowHint": "minutes", + "rec.save": "Save limits", + "rec.saved": "Limits saved", + "rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).", "msg.copy": "Copier le message", "msg.search": "Rechercher sur le web", @@ -2200,6 +2380,66 @@ const ru: Dict = { "live.newReport": "New content report ({reason})", "live.adminReply": "Admin replied to your ticket", "live.ticketStatus": "Ticket {status}", + "nodes.title": "Ollama nodes", + "nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.", + "nodes.add": "Add node", + "nodes.edit": "Edit node", + "nodes.name": "Name", + "nodes.namePh": "gpu-box-2", + "nodes.host": "Host", + "nodes.hostPh": "http://10.0.0.5:11434", + "nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.", + "nodes.weight": "Weight", + "nodes.weightHint": "0 disables traffic, higher gets more generations.", + "nodes.enabled": "Enabled", + "nodes.share": "{pct}%", + "nodes.off": "off", + "nodes.test": "Test", + "nodes.probing": "Probing…", + "nodes.testOk": "Reachable", + "nodes.testFail": "Unreachable", + "nodes.deleteTitle": "Delete this node?", + "nodes.deleteMsg": "Generations will no longer be routed to this host.", + "nodes.invalid": "Check the name, host and weight.", + "nodes.dupHost": "This host is already registered.", + "nodes.detail": "Node details", + "nodes.running": "Running now", + "nodes.vram": "VRAM", + "nodes.diskModels": "On disk ({n})", + "nodes.version": "Version", + "nodes.latency": "Latency", + "nodes.timeout": "Generation timeout", + "nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.", + "nodes.timeoutUnit": "seconds (5–3600)", + "nodes.defWeight": "Default node weight", + "nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.", + "nodes.stats": "Load", + "nodes.req": "requests", + "nodes.err": "errors", + "nodes.avg": "avg latency", + "nodes.tps": "tokens/s", + "nodes.tok": "tokens", + "nodes.emptyStats": "No generations in this window yet.", + "nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.", + "catalog.title": "Model catalog (Hugging Face)", + "catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.", + "catalog.searchPh": "Search GGUF models…", + "catalog.pull": "Pull", + "catalog.pulling": "Pulling {name}…", + "catalog.node": "Target node", + "catalog.downloads": "downloads", + "catalog.files": "Quantizations", + "catalog.noRes": "No results.", + "catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.", + "rec.title": "Password recovery limits", + "rec.desc": "Dedicated envelope for key-recovery requests (separate from login).", + "rec.max": "Max attempts", + "rec.maxHint": "per window, per IP", + "rec.window": "Window", + "rec.windowHint": "minutes", + "rec.save": "Save limits", + "rec.saved": "Limits saved", + "rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).", "msg.copy": "Скопировать сообщение", "msg.search": "Искать в интернете", @@ -2765,6 +3005,66 @@ const it: Dict = { "live.newReport": "New content report ({reason})", "live.adminReply": "Admin replied to your ticket", "live.ticketStatus": "Ticket {status}", + "nodes.title": "Ollama nodes", + "nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.", + "nodes.add": "Add node", + "nodes.edit": "Edit node", + "nodes.name": "Name", + "nodes.namePh": "gpu-box-2", + "nodes.host": "Host", + "nodes.hostPh": "http://10.0.0.5:11434", + "nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.", + "nodes.weight": "Weight", + "nodes.weightHint": "0 disables traffic, higher gets more generations.", + "nodes.enabled": "Enabled", + "nodes.share": "{pct}%", + "nodes.off": "off", + "nodes.test": "Test", + "nodes.probing": "Probing…", + "nodes.testOk": "Reachable", + "nodes.testFail": "Unreachable", + "nodes.deleteTitle": "Delete this node?", + "nodes.deleteMsg": "Generations will no longer be routed to this host.", + "nodes.invalid": "Check the name, host and weight.", + "nodes.dupHost": "This host is already registered.", + "nodes.detail": "Node details", + "nodes.running": "Running now", + "nodes.vram": "VRAM", + "nodes.diskModels": "On disk ({n})", + "nodes.version": "Version", + "nodes.latency": "Latency", + "nodes.timeout": "Generation timeout", + "nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.", + "nodes.timeoutUnit": "seconds (5–3600)", + "nodes.defWeight": "Default node weight", + "nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.", + "nodes.stats": "Load", + "nodes.req": "requests", + "nodes.err": "errors", + "nodes.avg": "avg latency", + "nodes.tps": "tokens/s", + "nodes.tok": "tokens", + "nodes.emptyStats": "No generations in this window yet.", + "nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.", + "catalog.title": "Model catalog (Hugging Face)", + "catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.", + "catalog.searchPh": "Search GGUF models…", + "catalog.pull": "Pull", + "catalog.pulling": "Pulling {name}…", + "catalog.node": "Target node", + "catalog.downloads": "downloads", + "catalog.files": "Quantizations", + "catalog.noRes": "No results.", + "catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.", + "rec.title": "Password recovery limits", + "rec.desc": "Dedicated envelope for key-recovery requests (separate from login).", + "rec.max": "Max attempts", + "rec.maxHint": "per window, per IP", + "rec.window": "Window", + "rec.windowHint": "minutes", + "rec.save": "Save limits", + "rec.saved": "Limits saved", + "rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).", "msg.copy": "Copia messaggio", "msg.search": "Cerca nel web", @@ -3321,6 +3621,66 @@ const pt: Dict = { "live.newReport": "New content report ({reason})", "live.adminReply": "Admin replied to your ticket", "live.ticketStatus": "Ticket {status}", + "nodes.title": "Ollama nodes", + "nodes.desc": "Extra Ollama hosts. Each generation picks a node by weight (firewall-like priority, heaviest first) and fails over to the next one on error. The default node comes from OLLAMA_HOST.", + "nodes.add": "Add node", + "nodes.edit": "Edit node", + "nodes.name": "Name", + "nodes.namePh": "gpu-box-2", + "nodes.host": "Host", + "nodes.hostPh": "http://10.0.0.5:11434", + "nodes.hostHint": "http(s) base URL, no path. Must be reachable from this server.", + "nodes.weight": "Weight", + "nodes.weightHint": "0 disables traffic, higher gets more generations.", + "nodes.enabled": "Enabled", + "nodes.share": "{pct}%", + "nodes.off": "off", + "nodes.test": "Test", + "nodes.probing": "Probing…", + "nodes.testOk": "Reachable", + "nodes.testFail": "Unreachable", + "nodes.deleteTitle": "Delete this node?", + "nodes.deleteMsg": "Generations will no longer be routed to this host.", + "nodes.invalid": "Check the name, host and weight.", + "nodes.dupHost": "This host is already registered.", + "nodes.detail": "Node details", + "nodes.running": "Running now", + "nodes.vram": "VRAM", + "nodes.diskModels": "On disk ({n})", + "nodes.version": "Version", + "nodes.latency": "Latency", + "nodes.timeout": "Generation timeout", + "nodes.timeoutDesc": "Max time for one Ollama generation before failing over. Applies to every node.", + "nodes.timeoutUnit": "seconds (5–3600)", + "nodes.defWeight": "Default node weight", + "nodes.defWeightDesc": "Traffic share of the OLLAMA_HOST node. 0 excludes it when custom nodes exist.", + "nodes.stats": "Load", + "nodes.req": "requests", + "nodes.err": "errors", + "nodes.avg": "avg latency", + "nodes.tps": "tokens/s", + "nodes.tok": "tokens", + "nodes.emptyStats": "No generations in this window yet.", + "nodes.cpuNote": "Ollama exposes no CPU/RAM API — charts come from app-side generation telemetry; per-model VRAM via /api/ps.", + "catalog.title": "Model catalog (Hugging Face)", + "catalog.desc": "GGUF models installable with one click via ollama pull hf.co/…. Needs Ollama with Hugging Face support and internet on the Ollama host.", + "catalog.searchPh": "Search GGUF models…", + "catalog.pull": "Pull", + "catalog.pulling": "Pulling {name}…", + "catalog.node": "Target node", + "catalog.downloads": "downloads", + "catalog.files": "Quantizations", + "catalog.noRes": "No results.", + "catalog.hint": "Pick a quantization, it pulls straight from Hugging Face.", + "rec.title": "Password recovery limits", + "rec.desc": "Dedicated envelope for key-recovery requests (separate from login).", + "rec.max": "Max attempts", + "rec.maxHint": "per window, per IP", + "rec.window": "Window", + "rec.windowHint": "minutes", + "rec.save": "Save limits", + "rec.saved": "Limits saved", + "rec.invalid": "Check the values (attempts 1–100, window 1–1440 min).", "msg.copy": "Copiar mensagem", "msg.search": "Pesquisar na web", diff --git a/frontend/src/lib/route.ts b/frontend/src/lib/route.ts index 58aece1..f0cfbbd 100644 --- a/frontend/src/lib/route.ts +++ b/frontend/src/lib/route.ts @@ -23,6 +23,14 @@ export function chatIdFromPath(pathname: string): string | null { return m ? decodeURIComponent(m[1]!) : null; } +/** Admin section from /admin itself (null) or /admin/. */ +export function adminTabFromPath(pathname: string): string | null { + const p = normalizePath(pathname); + if (p === "/admin") return null; + const m = p.match(/^\/admin\/([^/]+)$/); + return m ? decodeURIComponent(m[1]!) : null; +} + /** Client-side navigation without reload (same-origin path only). */ export function navigate(to: string, replace = false): void { const dest = normalizePath(to); diff --git a/prompt_v2 b/prompt_v2 index a66985a..0e23e1d 100644 --- a/prompt_v2 +++ b/prompt_v2 @@ -15,3 +15,28 @@ fait un timeout sur les requete envoyez au ollama , configurable dans l'admin ce Faire un catalog des modèles ollama via internet /hugging face compatible ollama. voir les détails des nodes ollama (charge cpu, mémoire, vram..., sur le temps (1h,6h,12h,24h,7w,14w,1m)) leur composants,... + +--- + +Changer le favicon en fonction de la couleur accentique. +Revoir la plate-forme + +---- + +Proposer un Text-to-speech dans le composer de message via un Modèle comme whisper ou autre (voir tts) +le composer message qui fait les message vocaux, doit respecter la DA du site, un beau truc, avec les waves form jolie du message vocal en real time, proposer de stoper la vocal, transcribe en texte, proposer l'édition avant envoie + +---- pour les telemtry des nodes ollama +Vraiment rendre le truc super clean: telemtry avec très beau graphe, dock bar, pour changer le type de graphique, faire une vrai sous-page dans le admin center, avec de magnifique graphe, mangifique tooltip vraiment sympathique, voir le host, kernel, uptime, network, ip, etc + +--- pour les waves formes du message vocal, rendre le tout vraiment vraiment beau, un beau composer unique. +--- Nouvel outil, l'ia en temps réel: +Faire un Speech-To-Speech, soit avec whisper + LLM ,etc proposer à l'utilisateur sont combo modèle + très belle animation quand le modèle parle, un peu style siri ai. (Apple Inteligence) , outil potentiel à inclur: FlowerlyTTS local ou bien modèle whisper , speech2speech. Proposer dans l'admin center sa configuration , avec les outils, etc... +Le modèle doit détecter la langue par le texte/etc. l'user peut customiser l'accent sur sa langue configuré(accent féminin, accent masculin, avec preview audio sur un text déjà pré-encoder: Exemple: +"Salut, euhm, comment je peux t'aider aujourd'hui ?"> voix grave héistante +"5 sur 5 je recois! Que-veux-tu ?" voix grave ton serieux +"Owwww, ce chat est trop mignon, oops, pardon. Je me concentre, que puis-je faire pour toi " voix fémine, mignonne +"Hey , que puis-je faire pour toi ? " voix féminine médium par exemple + +Ces exemples sont les default voix preview . dans l'admin center , l'utilisateur peut les modifier /revenir à défaut +L'utilisateur doit pouvoir configurée ces ai,vocaux,voix,langue dans une sous-section dédier dans ces paramètres. diff --git a/src/auth.ts b/src/auth.ts index 836c9e1..1f128cf 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,6 +4,7 @@ import { createSession, deleteSession, getSession, + getSetting, getUserById, getUserByUsername, toPublicUser, @@ -109,3 +110,39 @@ export function recordAttempt(ip: string): void { export function clearAttempts(ip: string): void { attempts.delete(ip); } + +// --- dedicated rate limiter for password recovery (admin-configurable) --- +// Separate bucket from login: recovery is anonymous-by-design (always-ok +// answers) so it needs its own, usually stricter, envelope. + +const recoveryAttempts = new Map(); + +function clampSettingInt(raw: string, min: number, max: number, fallback: number): number { + const v = Number.parseInt(raw, 10); + if (!Number.isInteger(v)) return fallback; + return Math.min(max, Math.max(min, v)); +} + +export async function recoveryLimitMax(): Promise { + return clampSettingInt(await getSetting("recovery_limit_max", "5"), 1, 100, 5); +} + +export async function recoveryLimitWindowMs(): Promise { + return clampSettingInt(await getSetting("recovery_limit_window_min", "60"), 1, 1440, 60) * 60_000; +} + +export async function isRecoveryLimited(ip: string): Promise<{ limited: boolean; retryAfterSec: number }> { + const windowMs = await recoveryLimitWindowMs(); + const max = await recoveryLimitMax(); + const now = Date.now(); + const list = (recoveryAttempts.get(ip) ?? []).filter((t) => t > now - windowMs); + recoveryAttempts.set(ip, list); + if (list.length < max) return { limited: false, retryAfterSec: 0 }; + return { limited: true, retryAfterSec: Math.max(1, Math.ceil((list[0]! + windowMs - now) / 1000)) }; +} + +export function recordRecoveryAttempt(ip: string): void { + const list = recoveryAttempts.get(ip) ?? []; + list.push(Date.now()); + recoveryAttempts.set(ip, list); +} diff --git a/src/drivers/ollama.ts b/src/drivers/ollama.ts index a068376..ca5bf2c 100644 --- a/src/drivers/ollama.ts +++ b/src/drivers/ollama.ts @@ -1,12 +1,28 @@ import { config } from "../config.ts"; import { driverLog, fmtBytes, fmtMs } from "./log.ts"; +import { + effectiveOllamaNodes, + getOllamaTimeoutMs, + pickOllamaRoute, + recordOllamaStat, + type EffectiveNode, +} from "../ollamaNodes.ts"; import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts"; interface OllamaTag { name: string; } -/** Local LLM via Ollama. Priority #1. */ +interface OllamaDone { + done?: boolean; + error?: string; + message?: { content?: string }; + prompt_eval_count?: number; + eval_count?: number; + eval_duration?: number; +} + +/** Local LLM via Ollama (multi-node pool). Priority #1. */ export class OllamaDriver implements LLMDriver { readonly name = "ollama"; readonly enabled: boolean; @@ -16,22 +32,31 @@ export class OllamaDriver implements LLMDriver { this.enabled = enabled; } + /** Model list merged across every enabled node (deduped by name). */ async listModels(): Promise { const t0 = Date.now(); - const res = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(8000) }); - if (!res.ok) { - driverLog(this.name, `listModels ERROR: /api/tags -> ${res.status} (${fmtMs(Date.now() - t0)})`); - throw new Error(`ollama /api/tags failed: ${res.status}`); - } - const data = (await res.json()) as { models?: OllamaTag[] }; - const models = data.models ?? []; - driverLog(this.name, `listModels -> ${models.length} model(s) [${models.map((m) => m.name).join(", ")}] (${fmtMs(Date.now() - t0)})`); - return models.map((m) => ({ - id: `ollama:${m.name}`, - name: m.name, - driver: "ollama", - label: m.name, + const nodes = await effectiveOllamaNodes(); + if (nodes.length === 0) throw new Error("no ollama node available (all disabled or weight 0)"); + const seen = new Map(); + const errors: string[] = []; + await Promise.all(nodes.map(async (n) => { + try { + const res = await fetch(`${n.host}/api/tags`, { signal: AbortSignal.timeout(8000) }); + if (!res.ok) throw new Error(`status ${res.status}`); + const data = (await res.json()) as { models?: OllamaTag[] }; + for (const m of data.models ?? []) { + if (typeof m.name === "string" && !seen.has(m.name)) { + seen.set(m.name, { id: `ollama:${m.name}`, name: m.name, driver: "ollama", label: m.name }); + } + } + } catch (e) { + errors.push(`${n.name}: ${(e as Error).message}`); + } })); + const models = [...seen.values()]; + driverLog(this.name, `listModels -> ${models.length} model(s) across ${nodes.length} node(s) (${fmtMs(Date.now() - t0)})${errors.length > 0 ? ` [skipped: ${errors.join("; ")}]` : ""}`); + if (models.length === 0 && errors.length > 0) throw new Error(`ollama listModels failed: ${errors.join("; ")}`); + return models; } async chat(messages: ChatMessage[], opts: ChatOptions): Promise { @@ -41,17 +66,56 @@ export class OllamaDriver implements LLMDriver { } async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator { + const timeoutMs = await getOllamaTimeoutMs(); + const { order } = await pickOllamaRoute(); + let lastError: Error | null = null; + for (const node of order) { + // Failover only while nothing was produced yet: once tokens reached + // the client, restarting on another node would duplicate the reply. + let produced = false; + const counting: ChatOptions = { + ...opts, + onToken: (t) => { + produced = true; + opts.onToken?.(t); + }, + }; + try { + yield* this.streamFromNode(node, messages, counting, timeoutMs); + return; + } catch (e) { + // Caller hangup: never fail over (nobody is listening anymore). + if (opts.signal?.aborted) throw e; + if (produced) throw e; + lastError = e as Error; + driverLog(this.name, `chat model=${opts.model} node ${node.name} failed, failing over: ${lastError.message}`); + } + } + throw lastError ?? new Error("ollama chat failed on every node"); + } + + /** One streaming attempt against a single node (throws on any failure). */ + private async *streamFromNode( + node: EffectiveNode, + messages: ChatMessage[], + opts: ChatOptions, + timeoutMs: number, + ): AsyncGenerator { const t0 = Date.now(); - driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`); - const res = await fetch(`${this.host}/api/chat`, { + driverLog(this.name, `chat model=${opts.model} node=${node.name} stream=open`); + const signals = [AbortSignal.timeout(timeoutMs)]; + if (opts.signal) signals.push(opts.signal); + const signal = typeof AbortSignal.any === "function" ? AbortSignal.any(signals) : signals[0]!; + const res = await fetch(`${node.host}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: opts.model, messages, stream: true }), - signal: opts.signal, + signal, }); if (!res.ok || !res.body) { const text = await res.text().catch(() => ""); - driverLog(this.name, `chat model=${opts.model} ERROR: upstream ${res.status} ${text.slice(0, 120)}`); + driverLog(this.name, `chat model=${opts.model} node=${node.name} ERROR: upstream ${res.status} ${text.slice(0, 120)}`); + await recordOllamaStat({ node_id: node.id, ok: false, latency_ms: Date.now() - t0, prompt_tokens: 0, eval_tokens: 0, model: opts.model }); throw new Error(`ollama chat failed (${res.status}): ${text.slice(0, 300)}`); } const reader = res.body.getReader(); @@ -59,6 +123,8 @@ export class OllamaDriver implements LLMDriver { let buf = ""; let chunks = 0; let chars = 0; + let promptTokens = 0; + let evalTokens = 0; let status = "done"; try { while (true) { @@ -71,7 +137,7 @@ export class OllamaDriver implements LLMDriver { const trimmed = line.trim(); if (!trimmed) continue; try { - const obj = JSON.parse(trimmed) as { message?: { content?: string }; error?: string; done?: boolean }; + const obj = JSON.parse(trimmed) as OllamaDone; if (obj.error) throw new Error(`ollama error: ${obj.error}`); const token = obj.message?.content ?? ""; if (token) { @@ -80,8 +146,11 @@ export class OllamaDriver implements LLMDriver { opts.onToken?.(token); yield token; } + if (typeof obj.prompt_eval_count === "number") promptTokens = obj.prompt_eval_count; + if (typeof obj.eval_count === "number") evalTokens = obj.eval_count; if (obj.done) { - driverLog(this.name, `chat model=${opts.model} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`); + driverLog(this.name, `chat model=${opts.model} node=${node.name} done: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`); + await recordOllamaStat({ node_id: node.id, ok: true, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model }); return; } } catch (e) { @@ -90,9 +159,16 @@ export class OllamaDriver implements LLMDriver { } } } - driverLog(this.name, `chat model=${opts.model} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`); + driverLog(this.name, `chat model=${opts.model} node=${node.name} upstream closed: ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`); + // Truncated stream (no done:true): still a usable partial reply. + await recordOllamaStat({ node_id: node.id, ok: true, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model }); } catch (e) { - status = (e as Error).name === "AbortError" ? "aborted-by-caller" : `ERROR: ${(e as Error).message}`; + const name = (e as Error).name; + status = name === "AbortError" ? "aborted-by-caller" : name === "TimeoutError" ? "timeout" : `ERROR: ${(e as Error).message}`; + // Caller aborts are not node failures — don't pollute telemetry. + if (name !== "AbortError" || !opts.signal?.aborted) { + await recordOllamaStat({ node_id: node.id, ok: false, latency_ms: Date.now() - t0, prompt_tokens: promptTokens, eval_tokens: evalTokens, model: opts.model }); + } throw e; } finally { // Stop the upstream generation when the consumer goes away @@ -104,7 +180,7 @@ export class OllamaDriver implements LLMDriver { } reader.releaseLock(); if (status !== "done") { - driverLog(this.name, `chat model=${opts.model} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`); + driverLog(this.name, `chat model=${opts.model} node=${node.name} ${status} after ${chunks} chunks, ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`); } } } diff --git a/src/index.ts b/src/index.ts index 5a83dd9..acc3dc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,14 +5,35 @@ import { extractBearer, hashSecret, isRateLimited, + isRecoveryLimited, issueToken, login, newAccessKey, recordAttempt, + recordRecoveryAttempt, + recoveryLimitMax, + recoveryLimitWindowMs, revokeToken, verifyToken, type PublicUser, } from "./auth.ts"; +import { + createOllamaNode, + DEFAULT_NODE_ID, + deleteOllamaNode, + effectiveOllamaNodes, + getOllamaDefaultWeight, + getOllamaNode, + getOllamaTimeoutMs, + listOllamaNodes, + nodeStats, + resolveOllamaHost, + sanitizeNodeHost, + sanitizeNodeName, + sanitizeWeight, + updateOllamaNode, + type StatsWindow, +} from "./ollamaNodes.ts"; import { addMessage, consumeReset, @@ -123,6 +144,7 @@ import { } from "./cdn.ts"; import { OCR_MAX_BYTES, ocrAllowed, recordOcrJob } from "./tools/ocr.ts"; import { tools } from "./tools/registry.ts"; +import { initOllamaNodes } from "./ollamaNodes.ts"; assertConfig(); await initDb(); @@ -134,6 +156,7 @@ await initAccessTables(); await initReportsTables(); await initModelUsageTables(); await initTotpTables(); +await initOllamaNodes(); function json(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { @@ -142,6 +165,64 @@ function json(data: unknown, status = 200): Response { }); } +/** + * Live probe of one Ollama host: version, latency, on-disk models and + * running models (/api/ps — the only load signal Ollama exposes, with + * per-model VRAM usage). Always resolves (reachability is data). + */ +async function probeOllamaHost(host: string): Promise<{ + reachable: boolean; + host: string; + version: string | null; + latencyMs: number | null; + models: { name: string; size: number; modifiedAt: string | null }[]; + running: { name: string; sizeVram: number; size: number; expiresAt: string | null }[]; + error: string | null; +}> { + const t0 = Date.now(); + try { + const verRes = await fetch(`${host}/api/version`, { signal: AbortSignal.timeout(8000) }); + if (!verRes.ok) { + return { reachable: false, host, version: null, latencyMs: Date.now() - t0, models: [], running: [], error: `ollama /api/version failed: ${verRes.status}` }; + } + const ver = (await verRes.json().catch(() => ({}))) as { version?: unknown }; + const tagsRes = await fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(8000) }); + const tags = (await tagsRes.json().catch(() => ({}))) as { models?: { name?: unknown; size?: unknown; modified_at?: unknown }[] }; + const models = tagsRes.ok && Array.isArray(tags.models) + ? tags.models + .filter((m) => typeof m.name === "string") + .map((m) => ({ + name: m.name as string, + size: typeof m.size === "number" ? m.size : 0, + modifiedAt: typeof m.modified_at === "string" ? (m.modified_at as string) : null, + })) + : []; + const psRes = await fetch(`${host}/api/ps`, { signal: AbortSignal.timeout(8000) }); + const ps = (await psRes.json().catch(() => ({}))) as { models?: { name?: unknown; size_vram?: unknown; size?: unknown; expires_at?: unknown }[] }; + const running = psRes.ok && Array.isArray(ps.models) + ? ps.models + .filter((m) => typeof m.name === "string") + .map((m) => ({ + name: m.name as string, + sizeVram: typeof m.size_vram === "number" ? m.size_vram : 0, + size: typeof m.size === "number" ? m.size : 0, + expiresAt: typeof m.expires_at === "string" ? (m.expires_at as string) : null, + })) + : []; + return { + reachable: true, + host, + version: typeof ver.version === "string" ? ver.version : null, + latencyMs: Date.now() - t0, + models, + running, + error: null, + }; + } catch (e) { + return { reachable: false, host, version: null, latencyMs: Date.now() - t0, models: [], running: [], error: (e as Error).message }; + } +} + function clientIp(req: Request, server: { requestIP?: (r: Request) => { address: string } | null }): string { try { const info = server.requestIP?.(req); @@ -487,6 +568,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ if (path === "/api/auth/recover" && req.method === "POST") { const ip = clientIp(req, server); if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429); + // Dedicated recovery envelope (admin-configurable, stricter than + // login): every hit counts, successes included (anonymous by design). + const rl = await isRecoveryLimited(ip); + if (rl.limited) { + return new Response( + JSON.stringify({ error: "too many recovery attempts, try again later", retryAfterSec: rl.retryAfterSec }), + { status: 429, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": String(rl.retryAfterSec) } }, + ); + } + recordRecoveryAttempt(ip); const { ok, body } = await readJson(req); // Never enumerate accounts: always answer ok. if (ok && typeof body.username === "string") { @@ -507,6 +598,15 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ const resetMatch = path.match(/^\/api\/auth\/reset\/([A-Za-z0-9_-]{6,80})$/); if (resetMatch) { + const ip = clientIp(req, server); + const rl = await isRecoveryLimited(ip); + if (rl.limited) { + return new Response( + JSON.stringify({ error: "too many recovery attempts, try again later", retryAfterSec: rl.retryAfterSec }), + { status: 429, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": String(rl.retryAfterSec) } }, + ); + } + recordRecoveryAttempt(ip); const tokenHash = createHash("sha256").update(resetMatch[1]!).digest("hex"); if (req.method === "GET") { const r = await peekReset(tokenHash); @@ -866,6 +966,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1", reportsEnabled: await isReportsEnabled(), usernameChangeEnabled: await isUsernameChangeEnabled(), + ollamaTimeoutS: Math.round(await getOllamaTimeoutMs() / 1000), + ollamaDefaultWeight: await getOllamaDefaultWeight(), + recoveryLimitMax: await recoveryLimitMax(), + recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000), }, }); } @@ -901,6 +1005,30 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ if (typeof body.usernameChangeEnabled !== "boolean") return json({ error: "usernameChangeEnabled must be boolean" }, 400); await setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0"); } + if (body.ollamaTimeoutS !== undefined) { + if (!Number.isInteger(body.ollamaTimeoutS) || (body.ollamaTimeoutS as number) < 5 || (body.ollamaTimeoutS as number) > 3600) { + return json({ error: "ollamaTimeoutS must be an integer 5-3600 (seconds)" }, 400); + } + await setSetting("ollama_timeout_s", String(body.ollamaTimeoutS)); + } + if (body.ollamaDefaultWeight !== undefined) { + if (!Number.isInteger(body.ollamaDefaultWeight) || (body.ollamaDefaultWeight as number) < 0 || (body.ollamaDefaultWeight as number) > 1000) { + return json({ error: "ollamaDefaultWeight must be an integer 0-1000 (0 = never use the default node)" }, 400); + } + await setSetting("ollama_default_weight", String(body.ollamaDefaultWeight)); + } + if (body.recoveryLimitMax !== undefined) { + if (!Number.isInteger(body.recoveryLimitMax) || (body.recoveryLimitMax as number) < 1 || (body.recoveryLimitMax as number) > 100) { + return json({ error: "recoveryLimitMax must be an integer 1-100" }, 400); + } + await setSetting("recovery_limit_max", String(body.recoveryLimitMax)); + } + if (body.recoveryLimitWindowMin !== undefined) { + if (!Number.isInteger(body.recoveryLimitWindowMin) || (body.recoveryLimitWindowMin as number) < 1 || (body.recoveryLimitWindowMin as number) > 1440) { + return json({ error: "recoveryLimitWindowMin must be an integer 1-1440 (minutes)" }, 400); + } + await setSetting("recovery_limit_window_min", String(body.recoveryLimitWindowMin)); + } return json({ settings: { registrationEnabled: await isRegistrationEnabled(), @@ -908,6 +1036,10 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ ocrEnabled: (await getSetting("tools_ocr_enabled", "1")) === "1", reportsEnabled: await isReportsEnabled(), usernameChangeEnabled: await isUsernameChangeEnabled(), + ollamaTimeoutS: Math.round(await getOllamaTimeoutMs() / 1000), + ollamaDefaultWeight: await getOllamaDefaultWeight(), + recoveryLimitMax: await recoveryLimitMax(), + recoveryLimitWindowMin: Math.round(await recoveryLimitWindowMs() / 60_000), }, }); } @@ -965,6 +1097,16 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ const name = typeof body.name === "string" ? body.name.trim() : ""; if (!/^[a-z0-9._:\/-]{1,100}$/i.test(name)) return json({ error: "invalid model name" }, 400); if (!ollamaDriver.enabled) return json({ error: "ollama driver is disabled" }, 502); + // Target node for the pull (library tags AND hf.co/... references). + let pullHost = ollamaDriver.host; + if (body.nodeId !== undefined) { + if (typeof body.nodeId !== "string") return json({ error: "nodeId must be a string" }, 400); + try { + pullHost = (await resolveOllamaHost(body.nodeId === DEFAULT_NODE_ID ? undefined : body.nodeId)).host; + } catch { + return json({ error: "unknown ollama node" }, 404); + } + } const abortUpstream = new AbortController(); const onClientAbort = () => abortUpstream.abort(); req.signal.addEventListener("abort", onClientAbort); @@ -983,7 +1125,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ } }; try { - const res = await fetch(`${ollamaDriver.host}/api/pull`, { + const res = await fetch(`${pullHost}/api/pull`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: name, stream: true }), @@ -1038,8 +1180,17 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ if (ollamaDeleteMatch && req.method === "DELETE") { if (!admin) return json({ error: "forbidden" }, 403); if (!ollamaDriver.enabled) return json({ error: "ollama driver is disabled" }, 502); + const nodeParam = url.searchParams.get("node"); + let deleteHost = ollamaDriver.host; + if (nodeParam) { + try { + deleteHost = (await resolveOllamaHost(nodeParam === DEFAULT_NODE_ID ? undefined : nodeParam)).host; + } catch { + return json({ error: "unknown ollama node" }, 404); + } + } try { - const res = await fetch(`${ollamaDriver.host}/api/delete`, { + const res = await fetch(`${deleteHost}/api/delete`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: ollamaDeleteMatch[1] }), @@ -1168,6 +1319,117 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ } } + // --- admin: ollama nodes (multi-host pool, firewall-like weights) --- + if (path === "/api/admin/ollama/nodes" && req.method === "GET") { + if (!admin) return json({ error: "forbidden" }, 403); + return json({ nodes: await listOllamaNodes(), effective: await effectiveOllamaNodes() }); + } + + if (path === "/api/admin/ollama/nodes" && req.method === "POST") { + if (!admin) return json({ error: "forbidden" }, 403); + const { ok, body } = await readJson(req); + if (!ok) return json({ error: "invalid JSON" }, 400); + const name = sanitizeNodeName(body.name); + const host = sanitizeNodeHost(body.host); + const weight = sanitizeWeight(body.weight ?? 100); + if (!name) return json({ error: "name: 1-60 chars" }, 400); + if (!host) return json({ error: "host: http(s) base URL without path (e.g. http://10.0.0.5:11434)" }, 400); + if (weight === null) return json({ error: "weight: integer 0-1000" }, 400); + if (body.enabled !== undefined && typeof body.enabled !== "boolean") { + return json({ error: "enabled must be boolean" }, 400); + } + try { + const node = await createOllamaNode({ name, host, weight, enabled: body.enabled ?? true }); + registry.invalidateModelsCache(); + return json({ node }, 201); + } catch (e) { + if ((e as Error).message === "HOST_TAKEN") return json({ error: "this host is already registered" }, 409); + throw e; + } + } + + const nodeMatch = path.match(/^\/api\/admin\/ollama\/nodes\/([A-Za-z0-9_-]{1,64})$/); + if (nodeMatch && !path.endsWith("/status") && !path.endsWith("/stats")) { + if (!admin) return json({ error: "forbidden" }, 403); + const id = nodeMatch[1]!; + if (id === DEFAULT_NODE_ID) return json({ error: "the default node comes from OLLAMA_HOST and cannot be edited here" }, 400); + if (req.method === "DELETE") { + if (!await deleteOllamaNode(id)) return json({ error: "node not found" }, 404); + registry.invalidateModelsCache(); + return json({ ok: true }); + } + if (req.method === "PATCH") { + const { ok, body } = await readJson(req); + if (!ok) return json({ error: "invalid JSON" }, 400); + const patch: { name?: string; host?: string; weight?: number; enabled?: boolean } = {}; + if (body.name !== undefined) { + const name = sanitizeNodeName(body.name); + if (!name) return json({ error: "name: 1-60 chars" }, 400); + patch.name = name; + } + if (body.host !== undefined) { + const host = sanitizeNodeHost(body.host); + if (!host) return json({ error: "host: http(s) base URL without path" }, 400); + patch.host = host; + } + if (body.weight !== undefined) { + const weight = sanitizeWeight(body.weight); + if (weight === null) return json({ error: "weight: integer 0-1000" }, 400); + patch.weight = weight; + } + if (body.enabled !== undefined) { + if (typeof body.enabled !== "boolean") return json({ error: "enabled must be boolean" }, 400); + patch.enabled = body.enabled; + } + try { + const updated = await updateOllamaNode(id, patch); + if (!updated) return json({ error: "node not found" }, 404); + registry.invalidateModelsCache(); + return json({ node: updated }); + } catch (e) { + if ((e as Error).message === "HOST_TAKEN") return json({ error: "this host is already registered" }, 409); + throw e; + } + } + return json({ error: "not found" }, 404); + } + + // Per-node live probe: version, latency, on-disk models, running models + // (/api/ps — the only load signal Ollama exposes, with VRAM usage). + const nodeStatusMatch = path.match(/^\/api\/admin\/ollama\/nodes\/([A-Za-z0-9_-]{1,64})\/status$/); + if (nodeStatusMatch && req.method === "GET") { + if (!admin) return json({ error: "forbidden" }, 403); + let host: string; + try { + host = (await resolveOllamaHost(nodeStatusMatch[1] === DEFAULT_NODE_ID ? undefined : nodeStatusMatch[1])).host; + } catch { + return json({ error: "unknown ollama node" }, 404); + } + return json(await probeOllamaHost(host)); + } + + // Probe an arbitrary (not yet registered) host from the node form. + if (path === "/api/admin/ollama/probe" && req.method === "GET") { + if (!admin) return json({ error: "forbidden" }, 403); + const host = sanitizeNodeHost(url.searchParams.get("host")); + if (!host) return json({ error: "host: http(s) base URL without path" }, 400); + return json(await probeOllamaHost(host)); + } + + // Per-node generation telemetry aggregated over a window. + const nodeStatsMatch = path.match(/^\/api\/admin\/ollama\/nodes\/([A-Za-z0-9_-]{1,64})\/stats$/); + if (nodeStatsMatch && req.method === "GET") { + if (!admin) return json({ error: "forbidden" }, 403); + const raw = (url.searchParams.get("window") ?? "24h").toLowerCase(); + const win: StatsWindow = raw === "1h" || raw === "6h" || raw === "24h" || raw === "7d" || raw === "30d" ? raw : "24h"; + try { + await resolveOllamaHost(nodeStatsMatch[1] === DEFAULT_NODE_ID ? undefined : nodeStatsMatch[1]); + } catch { + return json({ error: "unknown ollama node" }, 404); + } + return json({ stats: await nodeStats(nodeStatsMatch[1]!, win) }); + } + // --- admin: access-request wishlist triage --- if (path === "/api/admin/access" && req.method === "GET") { if (!admin) return json({ error: "forbidden" }, 403); diff --git a/src/ollamaNodes.ts b/src/ollamaNodes.ts new file mode 100644 index 0000000..8e42a11 --- /dev/null +++ b/src/ollamaNodes.ts @@ -0,0 +1,337 @@ +import { randomBytes } from "node:crypto"; +import { db, dbKind, getSetting } from "./db.ts"; +import { config } from "./config.ts"; + +/** + * Multi-node Ollama pool + generation telemetry. + * + * - `ollama_nodes`: admin-managed extra Ollama hosts. The env-configured + * host (`OLLAMA_HOST`) always exists as the implicit "default" node; its + * traffic share is `ollama_default_weight` (0 = never use it). + * - Routing is firewall-like: nodes are ordered by weight (highest first) + * and each generation picks a node by weighted random among the enabled + * ones, failing over to the next heaviest node on error/timeout. + * - `ollama_stats`: one row per generation attempt (node, ok, latency, + * token counts from Ollama's final chunk) powering the per-node detail + * charts. Rows older than 35 days are pruned on write. + * + * NOTE on host metrics: Ollama exposes no CPU/RAM API — only per-model + * VRAM via /api/ps (surfaced in the node detail view). Load-over-time + * charts come from app-side telemetry (requests, errors, latency, + * tokens/s), which is also what drives routing decisions. + */ + +export interface OllamaNode { + id: string; + name: string; + host: string; + weight: number; + enabled: boolean; + created_at: number; + updated_at: number; +} + +export interface EffectiveNode { + /** "default" for the env host, otherwise the row id. */ + id: string; + name: string; + host: string; + weight: number; +} + +export const DEFAULT_NODE_ID = "default"; +const STATS_RETENTION_MS = 35 * 86400_000; + +export async function initOllamaNodes(): Promise { + const autoId = dbKind() === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT"; + await db().exec(`CREATE TABLE IF NOT EXISTS ollama_nodes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + host TEXT NOT NULL UNIQUE, + weight INTEGER NOT NULL DEFAULT 100, + enabled INTEGER NOT NULL DEFAULT 1, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + )`); + await db().exec(`CREATE TABLE IF NOT EXISTS ollama_stats ( + id ${autoId}, + node_id TEXT NOT NULL, + ts BIGINT NOT NULL, + ok INTEGER NOT NULL, + latency_ms INTEGER NOT NULL DEFAULT 0, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + eval_tokens INTEGER NOT NULL DEFAULT 0, + model TEXT NOT NULL DEFAULT '' + )`); + await db().exec(`CREATE INDEX IF NOT EXISTS idx_ollama_stats_node_ts ON ollama_stats(node_id, ts)`); +} + +function rowToNode(r: Record): OllamaNode { + return { + id: String(r.id), + name: String(r.name), + host: String(r.host), + weight: Number(r.weight), + enabled: Number(r.enabled) === 1, + created_at: Number(r.created_at), + updated_at: Number(r.updated_at), + }; +} + +export async function listOllamaNodes(): Promise { + const rows = await db().all>( + "SELECT * FROM ollama_nodes ORDER BY weight DESC, created_at ASC", + ); + return rows.map(rowToNode); +} + +export async function getOllamaNode(id: string): Promise { + const row = await db().get>("SELECT * FROM ollama_nodes WHERE id = ?", id); + return row ? rowToNode(row) : null; +} + +function newNodeId(): string { + return "node_" + randomBytes(9).toString("base64url"); +} + +/** Normalize + validate an Ollama base URL (scheme + host, no path). */ +export function sanitizeNodeHost(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const t = raw.trim().replace(/\/+$/, ""); + if (t.length < 10 || t.length > 200) return null; + let u: URL; + try { + u = new URL(t); + } catch { + return null; + } + if (u.protocol !== "http:" && u.protocol !== "https:") return null; + if (!u.hostname || u.username || u.password) return null; + // Drop default ports and any path/query/fragment for canonical form. + const port = u.port && !((u.protocol === "http:" && u.port === "80") || (u.protocol === "https:" && u.port === "443")) + ? `:${u.port}` + : ""; + if (u.pathname !== "/" && u.pathname !== "") return null; + return `${u.protocol}//${u.hostname}${port}`; +} + +export function sanitizeNodeName(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const t = raw.trim(); + if (t.length === 0 || t.length > 60) return null; + return t; +} + +export function sanitizeWeight(raw: unknown): number | null { + if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0 || raw > 1000) return null; + return raw; +} + +export async function createOllamaNode(opts: { name: string; host: string; weight: number; enabled: boolean }): Promise { + const now = Date.now(); + const row: OllamaNode = { id: newNodeId(), ...opts, created_at: now, updated_at: now }; + try { + await db().run( + "INSERT INTO ollama_nodes (id, name, host, weight, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + row.id, row.name, row.host, row.weight, row.enabled ? 1 : 0, now, now, + ); + } catch (e) { + if (/unique|duplicate/i.test((e as Error).message)) throw new Error("HOST_TAKEN"); + throw e; + } + return row; +} + +export async function updateOllamaNode( + id: string, + patch: { name?: string; host?: string; weight?: number; enabled?: boolean }, +): Promise { + const cur = await getOllamaNode(id); + if (!cur) return null; + const next: OllamaNode = { + ...cur, + name: patch.name ?? cur.name, + host: patch.host ?? cur.host, + weight: patch.weight ?? cur.weight, + enabled: patch.enabled ?? cur.enabled, + updated_at: Date.now(), + }; + try { + await db().run( + "UPDATE ollama_nodes SET name = ?, host = ?, weight = ?, enabled = ?, updated_at = ? WHERE id = ?", + next.name, next.host, next.weight, next.enabled ? 1 : 0, next.updated_at, id, + ); + } catch (e) { + if (/unique|duplicate/i.test((e as Error).message)) throw new Error("HOST_TAKEN"); + throw e; + } + return next; +} + +export async function deleteOllamaNode(id: string): Promise { + const r = await db().run("DELETE FROM ollama_nodes WHERE id = ?", id); + return r.changes > 0; +} + +// --- effective pool (env default + custom rows) --- + +function clampInt(raw: string, min: number, max: number, fallback: number): number { + const v = Number.parseInt(raw, 10); + if (!Number.isInteger(v)) return fallback; + return Math.min(max, Math.max(min, v)); +} + +/** Generation timeout for Ollama chat requests (admin setting, seconds). */ +export async function getOllamaTimeoutMs(): Promise { + return clampInt(await getSetting("ollama_timeout_s", "300"), 5, 3600, 300) * 1000; +} + +/** Traffic weight of the implicit env node (0 = never use it). */ +export async function getOllamaDefaultWeight(): Promise { + return clampInt(await getSetting("ollama_default_weight", "100"), 0, 1000, 100); +} + +/** All routable nodes, heaviest first (default env node included). */ +export async function effectiveOllamaNodes(): Promise { + const out: EffectiveNode[] = []; + if (config.ollamaEnabled) { + const w = await getOllamaDefaultWeight(); + if (w > 0) { + out.push({ id: DEFAULT_NODE_ID, name: "default", host: config.ollamaHost.replace(/\/$/, ""), weight: w }); + } + } + for (const n of await listOllamaNodes()) { + if (n.enabled && n.weight > 0) out.push({ id: n.id, name: n.name, host: n.host, weight: n.weight }); + } + out.sort((a, b) => b.weight - a.weight); + return out; +} + +/** + * Pick the node for one generation (weighted random) + the failover order + * (picked node first, then heaviest-first). Throws when nothing is routable. + */ +export async function pickOllamaRoute(): Promise<{ first: EffectiveNode; order: EffectiveNode[] }> { + const nodes = await effectiveOllamaNodes(); + if (nodes.length === 0) throw new Error('no ollama node available (all disabled or weight 0)'); + const total = nodes.reduce((s, n) => s + n.weight, 0); + let r = Math.random() * total; + let first = nodes[0]!; + for (const n of nodes) { + r -= n.weight; + if (r <= 0) { + first = n; + break; + } + } + const order = [first, ...nodes.filter((n) => n.id !== first.id)]; + return { first, order }; +} + +/** Resolve an admin-selected node id (pull/delete/status) to its host. */ +export async function resolveOllamaHost(nodeId: string | undefined): Promise { + if (!nodeId || nodeId === DEFAULT_NODE_ID) { + return { id: DEFAULT_NODE_ID, name: "default", host: config.ollamaHost.replace(/\/$/, ""), weight: 0 }; + } + const n = await getOllamaNode(nodeId); + if (!n) throw new Error("unknown ollama node"); + return { id: n.id, name: n.name, host: n.host, weight: n.weight }; +} + +// --- telemetry --- + +export async function recordOllamaStat(s: { + node_id: string; + ok: boolean; + latency_ms: number; + prompt_tokens: number; + eval_tokens: number; + model: string; +}): Promise { + const d = db(); + await d.run( + "INSERT INTO ollama_stats (node_id, ts, ok, latency_ms, prompt_tokens, eval_tokens, model) VALUES (?, ?, ?, ?, ?, ?, ?)", + s.node_id, Date.now(), s.ok ? 1 : 0, Math.max(0, Math.round(s.latency_ms)), + Math.max(0, Math.round(s.prompt_tokens)), Math.max(0, Math.round(s.eval_tokens)), s.model.slice(0, 160), + ); + // Best-effort retention (indexed range delete, cheap). + try { + await d.run("DELETE FROM ollama_stats WHERE ts < ?", Date.now() - STATS_RETENTION_MS); + } catch { + // stats must never break generations + } +} + +export type StatsWindow = "1h" | "6h" | "24h" | "7d" | "30d"; + +export function statsWindowMs(w: StatsWindow): number { + return { "1h": 3600_000, "6h": 6 * 3600_000, "24h": 86400_000, "7d": 7 * 86400_000, "30d": 30 * 86400_000 }[w]; +} + +export interface StatsBucket { + t: number; + req: number; + err: number; + avgMs: number | null; + promptTok: number; + evalTok: number; +} + +export interface NodeStats { + window: StatsWindow; + from: number; + to: number; + totals: { req: number; err: number; avgMs: number | null; promptTok: number; evalTok: number; tokPerSec: number | null }; + buckets: StatsBucket[]; +} + +const BUCKETS = 24; + +export async function nodeStats(nodeId: string, window: StatsWindow): Promise { + const to = Date.now(); + const span = statsWindowMs(window); + const from = to - span; + const bucketMs = Math.floor(span / BUCKETS); + // GROUP BY output alias works on both SQLite and Postgres; AVG may come + // back as text (pg numeric) so every aggregate is coerced in JS. + const rows = await db().all>( + `SELECT CAST((ts - ?) / ? AS INTEGER) AS b, COUNT(*) AS req, + COUNT(*) - COALESCE(SUM(ok), 0) AS err, + AVG(CASE WHEN ok = 1 THEN latency_ms END) AS avgms, + COALESCE(SUM(prompt_tokens), 0) AS pt, COALESCE(SUM(eval_tokens), 0) AS et + FROM ollama_stats WHERE node_id = ? AND ts >= ? GROUP BY b ORDER BY b ASC`, + from, bucketMs, nodeId, from, + ); + const buckets: StatsBucket[] = Array.from({ length: BUCKETS }, (_, i) => ({ + t: from + i * bucketMs, + req: 0, + err: 0, + avgMs: null, + promptTok: 0, + evalTok: 0, + })); + for (const r of rows) { + const b = Number(r.b); + if (!Number.isInteger(b) || b < 0 || b >= BUCKETS) continue; + const slot = buckets[b]!; + slot.req = Number(r.req); + slot.err = Number(r.err); + slot.avgMs = r.avgms === null ? null : Number(r.avgms); + slot.promptTok = Number(r.pt); + slot.evalTok = Number(r.et); + } + const totals = buckets.reduce( + (a, b) => ({ req: a.req + b.req, err: a.err + b.err, promptTok: a.promptTok + b.promptTok, evalTok: a.evalTok + b.evalTok }), + { req: 0, err: 0, promptTok: 0, evalTok: 0 }, + ); + const latRows = buckets.filter((b) => b.avgMs !== null && b.req - b.err > 0); + const latWeight = latRows.reduce((s, b) => s + (b.req - b.err), 0); + const avgMs = latWeight > 0 + ? latRows.reduce((s, b) => s + b.avgMs! * (b.req - b.err), 0) / latWeight + : null; + // Tokens/s over the window's active span (first to last non-empty bucket). + const active = buckets.filter((b) => b.req > 0); + const activeMs = active.length > 1 ? active[active.length - 1]!.t - active[0]!.t + bucketMs : active.length === 1 ? bucketMs : 0; + const tokPerSec = activeMs > 0 ? (totals.promptTok + totals.evalTok) / (activeMs / 1000) : null; + return { window, from, to, totals: { ...totals, avgMs, tokPerSec }, buckets }; +}