SysDeck 4.1 - Standalone Edition: consolidates the day-to-day work of a Linux operations team in a single webui

This commit is contained in:
Jeremy Anderson 2026-09-11 23:03:43 -04:00
parent 490bb6fc36
commit d25af0e307
656 changed files with 123932 additions and 565 deletions

322
BLOG.md
View File

@ -4,6 +4,159 @@ Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net>
---
## v0.3.0 — 2026-09-11 (AI Gateway Edition: klanker-gate integrated)
*(latest release: v0.4.1 — cockpit module detection; see the last entry below)*
v0.3.0 answers the operator's question: *"lets take a look at this project klanker-gate, i believe its mainly coded for a windows platform. how much work would it be to port it to arch linux and into the sysdeck as a module."* The answer surprised the premise, so this entry records both the verdict and the evidence.
### The verdict: zero source changes (it was never a Windows codebase)
klanker-gate — internally "Frosty Deno" — is a **Deno 2 + TypeScript** LLM gateway by **TykoDev** (https://github.com/TykoDev/klanker-gate, Apache-2.0; **not SysDeck code** — full credit and license notes in `klanker-gate/ATTRIBUTION.md` and `THIRD_PARTY.md`). Deno runs identically on every platform. The Windows mentions in its 448-file tree are accommodations for a second-class Windows *dev* platform, not Windows-first code:
- `apps/gateway/cluster.ts` — `reusePortSupported()` returns true **only for `linux` and `darwin`**. Windows is locked to single-process serving, and the failure message literally directs the operator to "Use Docker/Linux for multi-process".
- `Dockerfile` — both stages are Linux images (`denoland/deno:2.9.3`, `denoland/deno:alpine-2.9.3`). The Windows comment in it is about Defender locking files on a Windows *host*.
- `deploy/docker-entrypoint.sh` — a POSIX `/bin/sh` script.
- `scripts/` — `.sh` and Deno `.ts` tasks; not a single `.bat`/`.ps1` in the tree.
- `deno.lock` win32 entries — ordinary cross-platform lockfile records that every project using npm-native dependencies carries on every OS.
So the "port" is a **packaging exercise**: install Deno + PostgreSQL, manage the process with systemd. On top of that, moving to Arch *unlocks* a feature Windows cannot have — `FROSTY_WORKERS` multi-process serving through `SO_REUSEPORT` (Linux/darwin only).
### What ships in sysdeck-0.3.0-master.tar.bz2
- `/` — the cockpit edition: **27 plugins** (new: sysdeck-klanker), 28 bridge modules.
- `/web` — the SysDeck Web Edition: **29 bridge modules** (new: klanker, the AI Gateway panel).
- `/web/mini-services/fester` — Fester, vendored + pre-integrated (independent version 0.2.1), unchanged from v0.2.0.
- `/klanker-gate` — **klanker-gate vendored + pre-integrated** (by TykoDev — https://github.com/TykoDev/klanker-gate — independent version 0.9.0, Apache-2.0, credited in its `ATTRIBUTION.md`), including the new `arch/` packaging directory.
### Run without Cockpit — the complete runbook (0.3.0)
v0.3.0 also answers: *"make better instructions on running sysdeck without cockpit with just the nextjs backend."* The web edition is fully standalone — no cockpit, no Python bridge, no systemd, no root — and the instructions now exist in three kept-in-sync forms:
- **the "Run without Cockpit" panel** in the web console (system group, right under Overview): prerequisites, the one-command `make web-dev` quickstart, the granular commands, the standalone production build (bun + node-only paths), both systemd units (web + fester), the `.env` reference table, the reverse-proxy/WebSocket-gateway configs (Caddy + nginx), and a troubleshooting matrix — every command block copy-to-clipboard.
- **`web/README.md`** in the master tarball — the same runbook as plain markdown.
- **QUICKSTART §11** — the condensed version, plus §12 for the skin below.
### The web-edition skin for Cockpit (0.3.0)
*"…or completely theme cockpit to look like the web edition 0.3.0 as demoed"* — done, without touching a single module:
- `shared/sysdeck-web.css` — the skin: every plugin's `index.html` links it after the base stylesheet. It ports the Next.js console's midnight/teal design (accent `#3fc9b0`, soft-tinted badges, 10px radii, tabular numerals, teal-edged scrollbars, reduced-motion support) onto the `.sysdeck-*` / `.suite-*` vocabulary. Revert: delete the file.
- `sudo make install-branding` — themes the Cockpit **shell** chrome itself (sidebar, header, login) via `/usr/share/cockpit/branding.css`, Cockpit's documented override point; covers PatternFly v4 (`pf-c-*`) and v5 (`pf-v5-*`) generations, backs up any distro `branding.css` first, `make uninstall-branding` restores it.
### The Arch packaging (`klanker-gate/arch/`)
- `PKGBUILD` — self-packaging (files come from the tree the file lives in): gateway tree → `/usr/share/klanker-gate`, wrapper → `/usr/bin/klanker-gate`, env → `/etc/klanker-gate/env` (pacman `backup=()`), unit + sysusers + tmpfiles in their canonical locations. `depends=('deno>=2.9')`; postgres is an `optdepends` (remote `FROSTY_PG_URL` is equally supported).
- `klanker-gate.service` — hardened systemd unit: `StateDirectory=klanker-gate`, `WorkingDirectory=/var/lib/klanker-gate` (so the permission contract's `--allow-write=data` resolves to `/var/lib/klanker-gate/data`), `ProtectSystem=full`, `PrivateTmp`, empty `CapabilityBoundingSet`. Deliberately no `MemoryDenyWriteExecute` (V8's JIT needs W^X pages).
- `run.sh` — the ExecStart wrapper: one-time `deno cache --frozen` warmup into the service user's DENO_DIR, then exec with the upstream permission flags, plus `--allow-run` scoped to the Deno binary **only** when `FROSTY_WORKERS>1` — mirroring the upstream entrypoint's own escalation policy.
- `INSTALL-ARCH.md` — the full runbook: `makepkg -si`, local postgres provisioning (role + database), env configuration, `systemctl enable --now`, healthcheck curls, the multi-worker bonus, the optional control-UI build, and the SysDeck wiring for both editions.
### The klanker module (both editions)
- `bridge/klanker.py` — stdlib-only REST client (urllib, 4s timeout — never outliving the panel's 5s poll), 10 subcommands: `status` (healthz + version, merged + enriched), `providers`, `models`, `vkeys`, `logs --limit`, `analytics --window`, `runtime`, `service <action>` (systemctl wrapper for klanker-gate.service), `journal [N]` (journalctl tail, ANSI-stripped, token-masked, 32 KB cap), `localstack` (probes ollama/llama.cpp/koboldcpp/LM Studio/SGLang/vLLM `/v1/models` on this host, 0.4s each in parallel threads, returns wiring recipes + env/admin-API examples). `KLANKER_URL` (default `http://127.0.0.1:8080`) and `KLANKER_ADMIN_TOKEN` (header-only, never echoed) drive it. Connection failures are graceful JSON with a remediation hint — same contract as fester.py.
- `plugins/sysdeck-klanker/` — full panel: gateway status card, stat grid (providers, virtual keys, requests 24h, spend 24h — upstream money is integer **micro-USD**, displayed as `$X.XXXX`), providers table with health dots, virtual keys table, recent-requests table, model catalog, **local stack wiring card** (live-probed backend matrix + copyable env/curl recipes), runtime topology card, service control (start/stop/restart with confirm) and a journal viewer. 5s auto-refresh that preserves the service/journal card state.
- Web edition — the **AI Gateway** panel under Integrations: hybrid live/demo. It probes the gateway first (`KLANKER_URL`, 1.5s timeout, Bearer `KLANKER_ADMIN_TOKEN`); unreachable → seeded demo rows, clearly badged, with a note explaining that this sandbox has no Deno/Postgres. On a host running the gateway, it flips to `source: live` untouched.
- `shared/bridge.js` — the klanker surface: 11 methods. `check-bridge-subcommands` now verifies **218 calls across 28 bridge modules** (was 216/27 — the audit pass added auth readers+certs).
### The local stack is first-class (0.3.0 follow-up)
A fair question after the integration: *"it seems to be mainly for SaaS
account linking, i personally only run a local stack such as ollama,
llama.cpp, koboldcpp"* — can all features be met locally? Yes: the
upstream provider registry has **five keyless self-hosted types** —
`ollama`, `lmstudio`, `sgl`, and the generic `openai-compatible` /
`anthropic-compatible` ("user supplies the base URL and an optional
key", per the registry's own comment) — and llama-server/KoboldCpp/vLLM
all speak the OpenAI wire. Governance, virtual keys, budgets, fallback
+ weighted load-balancing, model auto-discovery (`refresh-models`), the
request ring and analytics are all provider-agnostic, so they work
unchanged over a local stack — spend just reads ~$0.
What the release adds on top: the `localstack` probe + the **Local
stack wiring** cards in both editions, env/admin-API recipes with the
8080 port-collision warning (llama-server's default = the gateway's
port), QUICKSTART §10.1, and a local-first re-seeded web demo dataset
(ollama + llama-server + koboldcpp + lmstudio + sglang + one groq
overflow row — 24h spend ≈ $0.001, all of it the cloud fallback).
And the follow-up question — *"it should be easy to toggle this ai
gateway on or off in case i decide to use something like pi"* — is now
a first-class shell feature: **module visibility toggles** (web
edition). Every sidebar row carries a power control; a disabled module
vanishes from navigation and the command palette, lands in a
**Disabled (N)** section for one-click re-enable, and the state
persists in SQLite (`shell.disabled`, new `shell` bridge module,
audited). The `POST /api/bridge` envelope also got a correctness fix:
command-level `fail()` responses now pass through top-level instead of
being double-wrapped, so panels actually render command errors.
### The 0.3.0 security audit (follow-up)
*"do a security audit on klankergate code and the full sysdeck codebase
afterwards"* — done, end to end, and the release is better for it. Four
audit passes covered the vendored gateway (Deno routes, crypto, admin
surface, compose), the cockpit bridge (28 helpers), the 27 plugin
panels, and the web edition (bridge dispatcher, 31 modules, panels,
fester service).
**SysDeck code: fixed, not filed.** The two criticals in the cockpit
bridge were both "the guard exists 100 lines away and this command
forgot it" — `policy.py cgroup-set` wrote `<any-path>/<control-file>`
as root (arbitrary file overwrite → one-prompt persistent root via
`/etc/cron.d`), and `builder.py artifacts-clear` rmtree'd an
unvalidated profile argument. Both now resolve-and-bound exactly like
their siblings; the same treatment went to build-log/build-delete/
artifacts/profile-create (traversal + config injection into
root-executed build configs). hwalert carried the tree's only `sudo sh
-c` f-string — a literal root shell injection, latent only because
the module isn't wired to a panel yet — now a direct write behind a
sysfs device-path guard. The db module's start/stop/restart accepted
arbitrary unit names (`db stop sshd`) and its `query` promised
DDL-refusal in a comment while running any statement; both are honest
now. The 8 oldest panels rendered live tool output unescaped into
`innerHTML` (package names from repos, USB descriptors, fwupd metadata
— the XSS→cockpit-session→bridge-RCE chain); all escape now, and the
27 manifests dropped `unsafe-eval`. The web edition bound every
surface to 0.0.0.0 unauthenticated — dev server, fester service, and
the port gateway's wildcard transform — so everything now pins
loopback, the bridge gained a body cap + per-IP rate limit, and
unexpected errors return generic text (details to the server log).
**klanker-gate: audited, credited, and left unmodified.** The vendoring
contract (byte-identical upstream tree, SysDeck adds only `arch/`)
holds — so the audit's 10 upstream findings (3 critical: the admin API
runs unauthenticated when `FROSTY_ADMIN_TOKEN` is unset, the gateway
binds 0.0.0.0 by default, and `/v1/*` is open until the first virtual
key exists) are documented in `klanker-gate/arch/SECURITY-UPSTREAM.md`
as an advisory that can travel upstream, while the SysDeck packaging
layer mitigates what packaging can: the systemd unit now refuses to
start without the token (fail-closed `ExecStartPre`, opt-out via
drop-in), and `INSTALL-ARCH.md` §9 carries the firewall + create-a-vkey
-immediately runbook. The audit also recorded what upstream does
*well* — AES-256-GCM envelope key storage, SHA-256-hashed vkeys,
redacted provider views, an opt-in-only content log with deep secret
redaction, and a properly sandboxed Code Mode worker.
**Verified:** `make check` 254/254, `check-bridge-subcommands` 218/28 (the audit pass added auth readers+certs),
python compile checks on all 8 touched bridge files, panel import
checks, `bun run lint` clean, and the tarball rebuilt with new builder
guards that fail the build if any of the fixes regress.
### Verification
- Cockpit tree: `make check` — **all 254 tests + guards pass** with the new module (bridge subcommands cross-check, manifest consistency, version sync across all 9 surfaces now reporting 0.3.0).
- Bridge offline smoke: `status`/`service status`/`journal` all return graceful structured JSON in the sandbox (no Deno, no systemd units here — exactly the degradation path designed).
- Web edition: `bun run lint` clean; bridge smoke — all 7 klanker commands return `ok:true` with `source:'demo'`; panel render + mobile verified with zero page/console errors.
- Master tarball rebuilt by `scripts/make-master-tarball.sh` with the vendored `klanker-gate/`; verified by extraction, file count, sha256.
### Notes
- klanker-gate's version (0.9.0) is intentionally independent from SysDeck's (0.3.0), same as Fester's (0.2.1) — each mirrors its own repository's version line inside the bundle.
- The gateway cannot run inside the web edition's dev sandbox (no Deno runtime, no PostgreSQL); that is what the honest DEMO badge communicates. On the operator's Arch host, `arch/INSTALL-ARCH.md` is the 5-minute path to the LIVE badge.
---
## v0.2.0 — 2026-08-20 (Master Edition: one tarball, two editions, Fester pre-integrated)
v0.2.0 turns SysDeck into a single distributable that ships **both** editions with **Fester vendored and wired in**. The release answers the operator's framing directly: *"fester exists as a separate repository — it deserves its own. generate a master tarball of sysdeck with fester pre-integrated."*
@ -2753,3 +2906,172 @@ check: check-metainfo-consistency
The v0.0.27 release notes claimed "each subcommand now verified against the actual COMMANDS dict." That verification was done by hand at authoring time — and hand-verification rots the moment someone touches either side without re-running the verification.
The new `check-bridge-subcommands` guard makes the verification automatic and continuous. Every `make check` from now on will catch any future bridge.js ↔ Python helper drift, with a message that names the exact file, line, and missing subcommand.
---
## v0.3.1 — 2026-09-13 (the login gate)
### The cockpit-style login (0.3.1 follow-up)
*"sysdeck is intended for lan side use not wan facing, so this is a
decision point for lack of crypto or auth in depth. a simple
cockpit-style login for our standalone is fine by me, lets get it
coded if it hasnt been added to the standalone nextjs side"* — the
operator made the posture call, and this entry records how it landed.
The 0.3.0 audit ended with the web edition guarded (loopback binds,
rate limits, body caps) but still unauthenticated. That was honest
for a LAN-side console — and it left the door literally open to
anyone who could reach the port. 0.3.1 adds the one boundary that
matches the actual threat model (the roommate, the accidental
port-forward): **a shared password, exactly like the Cockpit login
the console's plugin pages already live behind.**
Mechanically: `SYSDECK_WEB_PASSWORD` in `web/.env` (default
`sysdeck`, with an amber nag on the login screen until you change
it — the nag is intentional, so a default install advertises its own
default). The password compares in constant time; failures rate
limit per IP at 5/minute and both login and failure land in the
AuditLog with the source IP. A success mints an HttpOnly
SameSite=Lax cookie carrying an HMAC-SHA256-signed token with a 12h
expiry — and the signing key is generated per install and persisted
in the same SQLite store everything else uses, which buys a
property the project needed anyway: **the fester mini-service reads
that key straight out of the DB file and verifies the identical
token on its REST and WebSocket surface.** The browser's live DAG
event stream rides the same cookie, so the direct
`?XTransformPort=3010` path is gated, not just the proxied routes.
Until the web console has booted once, fester keeps its documented
standalone behavior (loopback, unauthenticated) — the gate arms
itself the first time the console renders.
Everything visible is gated: the page server-renders a login screen
until the cookie verifies, every `/api/*` route answers 401 until
signed in, and a session expiring mid-flight reloads to the login
instead of plastering panels with error cards. What it is *not* is
also on the record (QUICKSTART §10.4): no user accounts, no MFA, no
online-attacker crypto — the loopback bind remains the outer
boundary, and `SYSDECK_SESSION_SECURE=1` arms the Secure cookie flag
when an operator fronts the console with TLS.
**Verified:** `bun run lint` clean; curl smoke of the full auth
lifecycle (wrong password 401 + audited, login → cookie → bridge 200,
logout → 401 again, forged token rejected, 429 after the failure
cap, fester REST+WS gated, standalone-mode fallback);
`make check` 254/254 across the bumped release surfaces; the master
tarball rebuilt with new guards (session lib, gated routes, page
gate, fester gate, QUICKSTART §10.4).
---
## v0.4.0 — 2026-09-12 (the Unix login)
*"lets change from shared login to unix acc based login same way
cockpit does it"* — the operator's directive, and the one this whole
project was always pointed at: the console is named for the deck it
replaces, so its login should work the way that deck's login works.
You sign in with a **Unix account, and the host's PAM stack decides.**
The mechanics are deliberately boring, because PAM already solved
this in 1997: `web/scripts/pam-auth.py` is a stdlib-only ctypes
client of `libpam` that runs the same sequence every login surface on
the box runs — `pam_start` → `pam_authenticate` → `pam_acct_mgmt` —
under the `sysdeck` service when `/etc/pam.d/sysdeck` exists, else
the stock `login` stack. The credentials arrive over stdin (argv is
world-readable in `/proc`, so it never touches argv), the
conversation callback answers only password/username prompts, and
the reply buffers come from the C allocator because Linux-PAM frees
them itself — the classic ctypes-PAM heap-corruption trap, found and
fixed the honest way (the sandbox crash first, then the malloc).
The honest constraint surfaced early: pam_unix needs root to verify
*arbitrary* users (a non-root process only gets its own uid through
`unix_chkpwd` — a pam_unix guarantee, not ours). Cockpit answers that
by running cockpit-ws as root; SysDeck ships the same call as a
mode: `SYSDECK_AUTH_MODE=pam` (default) for the root systemd unit,
`pam+local` for unprivileged installs (PAM first, then a `SdUser`
scrypt table managed by `bun scripts/manage-users.mjs`), `local` for
console-accounts-only. A wedged PAM helper fails CLOSED — a health
incident, never a silent fallback. Failures rate limit per-IP **and**
per-username, wrong-user and wrong-password look identical, and the
AuditLog now records the unix username as the actor on both ends.
The session token grew a spine: `v2.<exp>.<userB64>.<hmac>` — the
cookie is *bound to the account that earned it*. The shell wears the
identity cockpit-style: an account menu with avatar, `user@host`,
PAM/local provenance, the wheel "Administrative access" badge, and a
session-expiry countdown with a draining life bar; the status bar
carries `user@host` beside the vitals. The fester service verifies
the identical v2 token on REST and WebSocket. And v1 tokens still
verify — the 0.3.1 README promised restart-stable sessions, so the
0.4.0 upgrade keeps that promise: old cookies age out as legacy
"operator" sessions instead of logging anyone out.
The login screen itself finally got the fester treatment: host
identity banner (hostname + OS pretty name — exactly what cockpit
leads its own login with), an aurora/grid backdrop that re-skins
under every console theme, caps-lock detection, a one-shot error
shake, Enter-to-advance from username to password, and the amber
default-password nag for the seeded local account until it's
rotated. Reduced-motion users get the same scene, still.
**Verified:** PAM helper exercised against the live stack (wrong
password → PAM_AUTH_ERR, clean exits, no heap events); curl smoke of
the full lifecycle (401 pre-auth, login → v2 cookie → bridge 200,
logout → 401, forged v2 rejected by console AND fester, 429 after
the failure cap, legacy v1 acceptance); browser-driven pass over the
login scene, account menu, panel transitions; the cockpit bridge
guards re-run unchanged (218 calls / 28 modules, manifests clean) —
the plugin side is untouched by design.
---
## v0.4.1 — 2026-09-12 (cockpit module detection)
*"lets make sure any installed cockpit modules detected are also
loaded in the nextjs only side as well, such as the distro modules
like cockpit-machines and cockpit-podman for example. this will be
100% compatible at that point."* — the operator's compatibility
directive, and the honest last gap: 0.4.0 made the LOGIN cockpit-equal,
but a host with cockpit-machines or cockpit-podman installed still
showed those modules nowhere in the Next.js console.
0.4.1 makes the console perform the same discovery the cockpit shell
does: scan `/usr/share/cockpit/<pkg>/manifest.json` and treat every
package with a `menu` entry as a module. That single rule carries the
whole feature — distro modules (machines, podman, networking, storage,
accounts, updates, SELinux, PCP metrics, kdump, tuned), 45Drives-style
addons, anything a packager ships with a manifest. `sysdeck-*` modules
are skipped because native panels already exist for them; `base1` and
`shell` never appear because they have no menu — the shell's own rules,
reused verbatim. Detection is pure filesystem, so it works with cockpit
stopped, absent, or merely staged (`SYSDECK_COCKPIT_SCAN` points at
extra roots, colon-separated, for DESTDIR installs).
Every detected module **loads into the console's navigation**: a
"Cockpit" sidebar group with a LIVE/DEMO provenance badge, a ⌘K palette
group, and a per-module detail view — manifest identity, shipped files
with sizes, live backend presence probes (real `which()` checks for
`virsh`/`podman`/`nmcli`/`pkcon`/`getenforce`/`pmrep`..., on-demand
version probes), and a jump to the native panel that already covers the
domain: machines and podman → Containers & VMs, packagekit → Packages,
networkmanager → Network Security, metrics → Monitoring, storage →
Overview. A Cockpit Modules hub panel summarizes the scan: counts,
backend availability, native coverage. With no cockpit tree on the
host, the surface shows a clearly-badged typical-distro set so it stays
explorable — the same demo/live honesty the rest of the console
practices.
The same directive retired the codenames: no more "web edition", no
edition subtitles. The console's identity is **SysDeck**, and its one
subtitle line is a single clean URL — **dcos.net** — on the login
banner, under the sidebar wordmark, and in the status bar. The page
title is just "SysDeck".
**Verified:** live detection exercised end-to-end against a staged
cockpit tree (machines + podman detected LIVE with labels, orders, API
levels and file counts from their manifests; menu-less chrome and
sysdeck-* correctly excluded); demo fallback returns the 11-module
typical distro set badged DEMO; detail views, native-panel jumps, hub
table, and palette entries driven through a real browser; `make check`
still ALL PASS (218 calls / 28 modules, 254/254 tests, version sync at
0.4.1); lint and tsc clean on every touched file.

View File

@ -1,9 +1,13 @@
# SysDeck - Makefile
# Author: Jeremy Anderson (https://dcos.net)
#
# v0.2.0 MASTER EDITION: two distributions in one tree —
# / the cockpit edition: 26 standalone Cockpit plugins + shared bridge
# /web the SysDeck Web Edition (Next.js console, 28 bridge modules)
# v0.3.0 AI GATEWAY EDITION: two distributions in one tree —
# / the cockpit edition: 27 standalone Cockpit plugins + shared bridge
# /web the SysDeck Web Edition (Next.js console, 29 bridge modules)
# /klanker-gate — the Frosty Deno LLM gateway, vendored + pre-integrated
# (by TykoDev, https://github.com/TykoDev/klanker-gate,
# Apache-2.0 — not SysDeck code; own version 0.9.0, with
# arch/ packaging for Arch Linux)
# /web/mini-services/fester — Fester, vendored + pre-integrated (own version 0.2.1)
# Each plugin ships to /usr/share/cockpit/sysdeck-<name>/ and appears as
# its own sidebar entry in Cockpit. The Python bridge helpers stay at
@ -26,7 +30,7 @@
# Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS.
PACKAGE := sysdeck
VERSION := 0.2.0
VERSION := 0.4.1
LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE)
PYTHON_DIR := $(LIB_DIR)/bridge
SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE)
@ -55,7 +59,7 @@ SMOKE_TEST_SCRIPT := cockpit-smoke-test.sh
# Generator script (regenerates plugins/ and shared/).
GENERATOR := scripts/generate-plugins.py
.PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master
.PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master install-branding uninstall-branding
# ─── plugins: regenerate from generator ──────────────────────────────
plugins:
@ -86,6 +90,10 @@ install:
install -m 0644 shared/manifest.json $(DESTDIR)/usr/share/cockpit/sysdeck-common/manifest.json
install -m 0644 shared/bridge.js $(DESTDIR)/usr/share/cockpit/sysdeck-common/bridge.js
install -m 0644 shared/sysdeck.css $(DESTDIR)/usr/share/cockpit/sysdeck-common/sysdeck.css
# v0.3.0: the web-edition skin (midnight/teal design of the Next.js
# console) — every plugin index.html links it after base sysdeck.css.
# Remove the file to revert plugin pages to the classic 0.1.x skin.
install -m 0644 shared/sysdeck-web.css $(DESTDIR)/usr/share/cockpit/sysdeck-common/sysdeck-web.css
# Python bridge helpers: /usr/lib/sysdeck/bridge/
# v0.0.27: install each helper as an executable script (0755, not 0644)
# so they can be invoked by absolute path:
@ -385,7 +393,7 @@ distcheck: dist
@rm -rf /tmp/sysdeck-distcheck-$$
@echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct."
# ─── v0.2.0 master edition: web + fester ─────────────────────────────
# ─── v0.3.0 master edition: web + fester + klanker-gate ─────────────────────────────
# Run these from an extracted master tarball (where web/ sits alongside
# this Makefile) or the canonical dev tree with web/ present.
@ -403,6 +411,35 @@ web-install:
cd $(FESTER_DIR) && bun install
web-dev: web-install
# ─── branding: theme the Cockpit SHELL chrome to the web-edition look ────
# /usr/share/cockpit/branding.css is Cockpit's documented override point
# for the shell (sidebar, header, login). shared/branding.css ports the
# web edition 0.3.0 midnight/teal design onto it. Any pre-existing
# branding.css (shipped by the distro) is backed up first and restored
# by `make uninstall-branding`.
install-branding:
@echo ">>> Theming the Cockpit shell to the web-edition look"
-@if test -f $(DESTDIR)/usr/share/cockpit/branding.css; then \
cp -a $(DESTDIR)/usr/share/cockpit/branding.css $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak; \
echo " existing branding.css backed up (branding.css.sysdeck-bak)"; \
fi
install -d $(DESTDIR)/usr/share/cockpit
install -m 0644 shared/branding.css $(DESTDIR)/usr/share/cockpit/branding.css
@echo " installed /usr/share/cockpit/branding.css"
@echo " reload the Cockpit page (hard refresh) to see the shell skin"
uninstall-branding:
@echo ">>> Restoring the Cockpit shell branding"
rm -f $(DESTDIR)/usr/share/cockpit/branding.css
-@if test -f $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak; then \
mv $(DESTDIR)/usr/share/cockpit/branding.css.sysdeck-bak $(DESTDIR)/usr/share/cockpit/branding.css; \
echo " restored original branding.css from backup"; \
else \
echo " no backup found — the distro package owns branding.css"; \
echo " reinstall the cockpit-bridge package to restore defaults"; \
fi
@echo ">>> Starting fester in the background (log: /tmp/fester.log)"
cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 &
@echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)"

132
QA.md
View File

@ -1072,3 +1072,135 @@ PASS — guard fails with a clear, actionable message naming the exact file, lin
### 11. Honest accounting
The v0.0.27 release notes claimed "each subcommand now verified against the actual COMMANDS dict." That claim was overstated — the verification was hand-done at authoring time and was incomplete. The new `check-bridge-subcommands` guard makes the verification automatic, continuous, and enforced at build time. Hand-verification rots; machine verification doesn't.
# MoE Quality Assurance Pass — v0.4.0 (Unix Login Edition)
## v0.4.0 QA — unix-account login + web console revision
**Scope:** the 0.4.0 revision replaces the 0.3.1 shared-password login
with Unix-account (PAM) login in the web edition and gives the web
console a visual revision. The cockpit edition is untouched; guards
were re-run to prove it.
### 1. Authentication core
- `web/scripts/pam-auth.py` exercised against the live host PAM stack
(wrong password → `PAM_AUTH_ERR`, code 7, clean exit 1; malformed
JSON → `bad-json`; oversized credentials rejected). The conversation
callback allocates replies from libc (strdup/malloc) — verified
heap-clean across repeated invocations (no `free(): invalid pointer`
after the fix).
- Login route policy matrix verified by curl:
- pre-auth: every `/api/*` → 401; page server-renders login screen.
- wrong username and wrong password return the identical generic
`incorrect username or password` (no account enumeration).
- failure cap: 6th bad attempt within the window → 429 with
Retry-After (per-IP AND per-username buckets).
- `pam+local` mode: PAM-definitive-failure falls through to the
SdUser scrypt store; seeded account authenticates; v2 cookie minted.
- wedged helper (timeout/protocol) → fail CLOSED (401), never a
silent fallback. pam-only with missing helper → 503 setup error.
### 2. Session integrity
- v2 token format `v2.<expMs>.<userB64url>.<hmac-sha256>`: forged
signature rejected by the console route layer AND by the fester
service (REST + WS upgrade path) — both derive the HMAC from the
shared SQLite secret.
- v1 (0.3.1) tokens still verify (legacy session, user=null) —
upgrade continuity confirmed by code inspection of both verifiers.
- Logout clears the cookie (maxAge 0) and audits with the unix
username as actor; login-failed audits never contain the attempted
secret.
### 3. Cockpit edition compatibility (the operator's requirement)
- `make check`: **ALL PASS** — metainfo structure + 26 launchables,
28/28 manifests conform, Makefile recipes tab-indented, no broken
cockpit imports, no `python3 -m sysdeck.bridge` calls, **218
bridge.js calls cross-checked against 28 Python COMMANDS dicts**,
version sync across 9 release surfaces, `py_compile` + `node --check`
clean, 254/254 unit tests (version-sync test now pins 0.4.0).
- Zero changes under `bridge/`, `plugins/`, `shared/`, `standalone-plugins/`
(except `bridge/__init__.py` version string + packaging metadata).
### 4. Web console revision
- `bun run lint` clean. `tsc --noEmit` clean for every new/modified
file (pre-existing strictness complaints in vendored fester and
glances remain untouched, build unaffected — `ignoreBuildErrors`).
- Visual QA (headless browser screenshots reviewed by a vision model):
login scene — "polished and premium, no visual bugs"; shell + account
menu — "release-quality, dropdown anchors perfectly"; fester and
firewall panels render with no error cards or overlaps.
- prefers-reduced-motion kills the aurora/shake/panel transitions.
### 5. Summary
The 0.4.0 revision does what the operator asked: log in with a Unix
account the way Cockpit does (host PAM decides), every cockpit module
keeps working (guards green, tree untouched), and the web console now
carries its identity — account menu, user@host, session countdown —
at the fester quality bar. Remaining honest limits are documented in
QUICKSTART §10.4: pam_unix needs root for arbitrary-user verification
(use the root systemd unit, or pam+local), and the local scrypt store
is an escape hatch, not the primary path.
# MoE Quality Assurance Pass — v0.4.1 (cockpit module detection)
## v0.4.1 QA — every installed cockpit module loads into the web console
**Scope:** the console now performs the cockpit shell's own module
discovery (filesystem manifest scan) and loads every detected module
into its navigation. UI codenames retired; subtitle is dcos.net.
### 1. Detection correctness
- Live path exercised against a staged cockpit tree via
`SYSDECK_COCKPIT_SCAN`: machines + podman detected with correct
labels ("Virtual Machines", "Podman Containers"), menu orders, API
levels (`requires.cockpit`), real file counts and paths.
- Exclusion rules match the cockpit shell's own: manifest without a
`menu` block (base1, shell) never listed; `sysdeck-*` never listed
(native panels exist). Verified with deliberately staged traps for
both rules.
- Demo fallback: with no cockpit tree, the 11-module typical-distro
set returns badged DEMO with the honest note; backend `which()`
probes still run REAL binaries on that path.
- `info` returns the full manifest JSON, recursive file listing with
sizes, and an on-demand backend version probe only when the binary
is present (list stays cheap).
### 2. Console integration
- Sidebar "Cockpit" group renders every detected module with a
LIVE/DEMO badge and per-module icons; active state routes as
`cm:<name>`; the ⌘K palette searches the same entries; the
Cockpit Modules hub table row-click deep-links into detail views.
- Native-panel jumps (machines/podman → Containers & VMs verified in a
real browser click path) ride the same `sysdeck:goto` event the
overview callout uses.
- Visual QA (browser screenshots + vision model): shell, machines
detail view, and hub all render clean — no overlaps, no cut-offs, no
error cards; sidebar subtitle (dcos.net) judged "clean and minimal".
### 3. Branding sweep
- No "web edition" string remains in any user-visible surface (login
banner, sidebar, status bar, overview badge, glances subtitle,
services/packages panel texts, page title/metadata). The subtitle is
a single URL — dcos.net — on the login banner, sidebar and status
bar. README version line dropped the edition codename.
### 4. Cockpit edition + guards
- `make check`: ALL PASS — 218 bridge.js calls / 28 modules, 28
manifests, version sync at 0.4.1 across all release surfaces,
254/254 unit tests. The cockpit tree itself is untouched.
### 5. Summary
The compatibility loop is closed: whatever cockpit modules the host
has, the console has — detected from disk, badged honestly, probed
live, and cross-linked to the native panels. With 0.4.0's unix login
and this, the console/host pair is 100% aligned.

View File

@ -140,9 +140,9 @@ sudo systemctl restart cockpit.socket
Open the in-panel error view: every SysDeck plugin's `index.html` installs `window.addEventListener('error')` and `'unhandledrejection'` handlers that replace the "Loading…" placeholder with the actual error message on the page — no devtools required. The same page tells you whether `cockpit.js` itself loaded, whether `bridge.js` imported cleanly, and whether the panel's `mount()` threw.
## 9. The Web Edition (master tarball, v0.2.0)
## 9. The Web Edition (master tarball)
The master tarball also ships the **SysDeck Web Edition** at `web/` — a standalone browser console (no cockpit required) with 28 bridge modules, real `/proc` / `/sys` collectors, and **Fester pre-integrated** (vendored at `web/mini-services/fester`, independent version 0.2.1):
The master tarball also ships the **SysDeck Web Edition** at `web/` — a standalone browser console (no cockpit required) with 29 bridge modules, real `/proc` / `/sys` collectors, and **Fester pre-integrated** (vendored at `web/mini-services/fester`, independent version 0.2.1):
```bash
make web-dev # fester service in the background (:3010) + web console (:3000)
@ -158,4 +158,270 @@ bun run dev # web console on :3000
Open `http://localhost:3000`. The master tarball can be rebuilt any time with `make master`.
## 10. The AI Gateway (master tarball, v0.3.0)
The master tarball also vendors **klanker-gate** — the Frosty Deno LLM gateway (independent version 0.9.0, Apache-2.0, **by TykoDev: https://github.com/TykoDev/klanker-gate — not SysDeck code**, see `klanker-gate/ATTRIBUTION.md`) — at `klanker-gate/`, with the new **AI Gateway** module in both editions. On Arch Linux the whole gateway is one package away:
```bash
cd klanker-gate/arch
pacman -S --needed deno base-devel # deno is in [extra]
makepkg -si # /usr/share/klanker-gate + systemd unit
sudoedit /etc/klanker-gate/env # FROSTY_PG_URL + one provider key (+ token)
sudo systemctl enable --now klanker-gate
curl http://localhost:8080/healthz
```
Then point SysDeck at it (cockpit bridge env, or `web/.env` for the web edition, then restart):
```bash
KLANKER_URL=http://127.0.0.1:8080
KLANKER_ADMIN_TOKEN=<the FROSTY_ADMIN_TOKEN you set>
```
Both the cockpit AI Gateway panel and the web edition's AI Gateway panel flip from their offline/demo state to live data automatically. The full runbook — postgres provisioning, multi-worker serving (`FROSTY_WORKERS`, an Arch bonus via `SO_REUSEPORT`), the optional control-UI build — is `klanker-gate/arch/INSTALL-ARCH.md`.
### 10.1 Running an all-local stack (ollama · llama.cpp · koboldcpp)
The gateway is **not SaaS-only** — no API key is required anywhere in this
setup. Five provider types are local-first upstream: `ollama`, `lmstudio`,
`sgl` (SGLang) natively, plus the generic `openai-compatible` type that
llama.cpp (llama-server), KoboldCpp, vLLM and TGI all speak:
| backend | provider type | base URL | auth |
|---|---|---|---|
| Ollama | `ollama` | `http://127.0.0.1:11434/v1` | none |
| llama.cpp (llama-server) | `openai-compatible` | `http://127.0.0.1:8081/v1` | optional |
| KoboldCpp | `openai-compatible` | `http://127.0.0.1:5001/v1` | optional |
| LM Studio | `lmstudio` | `http://127.0.0.1:1234/v1` | none |
| SGLang | `sgl` | `http://127.0.0.1:30000/v1` | none |
Env wiring (in `/etc/klanker-gate/env` or the gateway's `.env`):
```bash
OLLAMA_BASE_URL=http://127.0.0.1:11434/v1
OLLAMA_MODELS=qwen3:14b,llama3.1:8b,nomic-embed-text
LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1
OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8081/v1 # ONE openai-wire server
```
Env registers one `openai-compatible` account — to run llama.cpp **and**
koboldcpp (and vLLM) side by side, register each via the admin API, then
auto-discover its catalog:
```bash
curl -s http://127.0.0.1:8080/api/providers -H 'Authorization: Bearer $FROSTY_ADMIN_TOKEN' \
-H 'content-type: application/json' \
-d '{"id":"llama-server","type":"openai-compatible","baseUrl":"http://127.0.0.1:8081/v1","enabled":true}'
curl -s -X POST http://127.0.0.1:8080/api/providers/llama-server/refresh-models \
-H 'Authorization: Bearer $FROSTY_ADMIN_TOKEN'
```
**Port note:** llama-server defaults to `:8080` — the same port the gateway
listens on. Run it on another port (`--port 8081`) or move the gateway.
Both editions ship a **Local stack wiring** card (in the AI Gateway panel)
that live-probes each backend's `/v1/models` from the host and shows these
recipes with copy buttons — `klanker localstack` at the bridge level.
### 10.2 Turning the AI Gateway off (module toggles)
Not using the gateway (or switched to a different assistant stack)?
Both editions let you remove it from the console without uninstalling
anything:
- **web edition** — every sidebar module carries a power toggle (hover
a row → ⏻). Clicking it hides the module from the sidebar AND the
⌘K palette; a **Disabled (N)** section appears at the sidebar bottom
with one-click re-enable (plus a restore-all ↻). State is persisted
in SQLite (`shell.disabled` via the `shell` bridge module) and
survives restarts; Overview is protected. If you disable the module
you are viewing, the console jumps back to Overview.
- **cockpit edition** — plugins are discovered by directory: `sudo rm
-rf /usr/share/cockpit/sysdeck-klanker` removes the sidebar entry
(bridge helper stays at `/usr/lib/sysdeck/bridge/klanker.py` for
scripts); restore with `sudo make install`.
### 10.3 The 0.3.0 security audit (both editions + the vendored gateway)
A full-codebase security review shipped with 0.3.0 — the cockpit bridge
helpers, the 27 plugin panels, the web edition, and the vendored
klanker-gate tree. What changed:
- **bridge helpers fail closed now.** `cgroup-set` validates both the
cgroup path (must resolve under `/sys/fs/cgroup`) and the control-file
name (real controller knobs only); `artifacts-clear` /
`build-delete` / `build-log` / `artifacts` validate ids as single
path components before touching state/artifacts/logs dirs;
`profile-create` rejects names that aren't single components (was
directory traversal + config injection into root-executed build
configs); hwalert's `sudo sh -c` is gone (direct write, device path
validated under the scanned sysfs bases); `db start/stop/restart`
resolve engines through the registry; `db query` now actually
enforces the read-only promise (SELECT/WITH/SHOW/… only);
`themes set` rejects newlines (cockpit.conf section injection);
`packages install/remove/update` reject option-shaped names.
- **every plugin escapes its data.** The 8 oldest panels (packages,
benchmark, auth, sensors, vault, firmware, mesh, and the auth quick
actions) now escape every interpolated string — package metadata,
USB reader descriptors, fwupd device fields, sensor labels, spawn
errors — before it lands in `innerHTML`. All 27 manifests dropped
`unsafe-eval` from their CSP. Every external link carries
`rel="noopener noreferrer"`.
- **the web edition binds loopback.** `bun run dev` → `127.0.0.1:3000`,
the fester service → `127.0.0.1:3010`, the production start script
pins `HOSTNAME=127.0.0.1`; the bridge endpoint gained a body-size
cap, a per-IP rate limit and generic error responses (details go to
the server log).
- **the vendored gateway got audited, not modified.** Findings live in
`klanker-gate/arch/SECURITY-UPSTREAM.md` (10 findings, 3 critical:
no-token admin mode, 0.0.0.0 default bind, open `/v1/*` until the
first virtual key exists). Upstream source stays byte-identical per
the attribution contract; the SysDeck `arch/` packaging layer
mitigates: the systemd unit refuses to start without
`FROSTY_ADMIN_TOKEN`, `INSTALL-ARCH.md` §9 carries the firewall +
first-vkey runbook.
- **fixed along the way (functional):** the Packages panel's
firewall-backend install path (`packages.py install --` choke), the
auth panel's quick-action buttons (called a bridge.spawn that never
existed), and the mesh panel's table (read a data shape the bridge
never returned).
### 10.4 The web edition login (Unix accounts, cockpit-style)
SysDeck is a **LAN-side console** — loopback binding stays the outer
boundary. What 0.4.0 changes is the login itself: instead of the 0.3.1
shared password, you now sign in with a **Unix account — the username
and password are verified by the host's PAM stack**, exactly the
mechanism Cockpit uses at its own login screen. The host decides; the
console keeps no password data of its own.
- **PAM path:** `web/scripts/pam-auth.py` (stdlib-only ctypes client of
`libpam`) runs the `pam_start` → `pam_authenticate` → `pam_acct_mgmt`
sequence under the **`sysdeck`** service when `/etc/pam.d/sysdeck`
exists, else the stock **`login`** stack. Credentials travel over
stdin (never argv — `/proc` would leak them). Ship your own
`/etc/pam.d/sysdeck` (e.g. `auth required pam_unix.so`, plus
`pam_google_authenticator` for MFA if you want it) to tailor the
stack — `SYSDECK_PAM_SERVICE` renames it.
- **Root, or pam+local:** pam_unix needs root to read `/etc/shadow`
for *arbitrary* users (non-root processes only get the invoking uid
via `unix_chkpwd` — a pam_unix guarantee). So the modes are
`SYSDECK_AUTH_MODE=pam` (default; run the service as root, like
cockpit-ws), `pam+local` (PAM first, then the `SdUser` scrypt table
for installs that can't run privileged), or `local` (console
accounts only). Manage the local table with
`bun scripts/manage-users.mjs list|add|passwd|disable|enable|remove`
from `web/`.
- **Session:** an HttpOnly, SameSite=Lax cookie (`sd_session`) holding
an HMAC-SHA256-signed token **bound to the username**
(`v2.<exp>.<userB64>.<hmac>`), **12h** expiry. The HMAC key is random
per install and persists in the SQLite DB, so sessions survive
restarts — including the 0.3.1 → 0.4.0 upgrade (old v1 tokens still
verify as a legacy "operator" session until they age out).
- **Gate scope:** the page itself is server-rendered as the login
screen until the cookie verifies, every `/api/*` route answers 401
until signed in, and the **fester service verifies the identical
v2 token** on its REST + WebSocket surface — no unauthenticated path
into the console's data.
- **Lockout:** wrong attempts are rate limited per-IP **and**
per-username (5 per 60s each — the same shape the sshd stack
applies). Wrong-user and wrong-password return the same generic
answer; nothing enumerates accounts.
- **Identity in the shell:** the header carries an account menu —
avatar, `user@host`, unix-account provenance (PAM vs local), the
wheel/sudo "Administrative access" badge, and a live session-expiry
countdown with a draining life bar; the status bar shows
`user@host` next to the vitals. Login/logout are audited with the
unix username as the actor.
- **TLS:** LAN deployments typically run plain http; front the console
with TLS and set `SYSDECK_SESSION_SECURE=1` to add the `Secure`
cookie flag. Sign out lives in the account menu (clears the cookie).
The login/logout actions are audited (`module: web`, actions
`login` / `login-failed` / `logout`, actor = the unix username, with
source IP). This is deliberately *not* MFA-by-default or rate-proof
crypto — it is the host's own account system doing what it already
does at every other login surface on the box, recorded here so nobody
mistakes it for more or less than that.
### 10.5 Cockpit module detection in the web console (v0.4.1)
The console scans the host the same way the cockpit shell discovers
pages — every `/usr/share/cockpit/<pkg>/manifest.json` with a `menu`
entry is a module — and **loads each one into its own navigation**:
- a **Cockpit** sidebar group (with a LIVE/DEMO provenance badge) lists
every detected module — distro modules (`cockpit-machines`,
`cockpit-podman`, networking, storage, accounts, updates, SELinux,
PCP metrics, kdump, tuned...) and third-party addons alike;
- each module opens a detail view with its manifest identity, shipped
files, **live backend presence probes** (`virsh`/`podman`/`nmcli`/
`pkcon`/... — real `which()` checks), and a jump to the native
console panel covering the domain when one exists;
- `sysdeck-*` modules never duplicate (native panels already ship), and
menu-less chrome (`base1`, `shell`) is skipped — exactly the cockpit
shell's own rules;
- `SYSDECK_COCKPIT_SCAN` (colon-separated paths) adds extra scan roots
for staged trees; with no cockpit tree on the host, a clearly-badged
typical-distro set keeps the surface explorable;
- the **Cockpit Modules** hub panel (Integrations group) summarizes
detection: counts, backend availability, native coverage, and the
scan paths in play.
This is the piece that makes the console/host pair 100% compatible:
install a cockpit module on the box, and it shows up here — no cockpit
login required to browse it.
## 11. Run without Cockpit (the complete standalone runbook, v0.3.0)
The web edition needs **nothing from sections 1–8** — no cockpit, no Python
bridge, no systemd, no root. One Bun runtime serves the whole console:
```bash
tar xjf sysdeck-0.4.1-master.tar.bz2
cd sysdeck-0.4.1-master
make web-dev # bun install + db:push + fester + next dev :3000
```
Production path (standalone build, systemd on Arch, reverse proxy with the
`?XTransformPort=` websocket gateway, environment reference, troubleshooting):
```bash
cd web
bun run build # self-contained .next/standalone/
PORT=3000 HOSTNAME=0.0.0.0 bun run start # or: node .next/standalone/server.js
```
The complete runbook — with the two systemd units (web + fester), the
`.env` reference table, the Caddy/nginx websocket-gateway configs and a
troubleshooting matrix — lives in two places, kept in sync:
- **`web/README.md`** in this tarball (plain markdown)
- the **"Run without Cockpit" panel** in the web console (system group,
right under Overview) — every command block has a copy button
## 12. The web-edition skin for Cockpit (v0.3.0)
Since 0.3.0 the Cockpit plugin pages wear the **web-edition skin** by
default: every plugin's `index.html` links
`../sysdeck-common/sysdeck-web.css` after the base stylesheet, porting
the Next.js console's midnight/teal design (teal accent `#3fc9b0`,
soft-tinted badges, 10px radii, tabular numerals, thin teal-edged
scrollbars) onto the classic cockpit panels. Nothing else changes — the
class vocabulary, the bridge, and every module are untouched.
```bash
# revert the plugin pages to the classic 0.1.x skin:
sudo rm /usr/share/cockpit/sysdeck-common/sysdeck-web.css
# also theme the Cockpit SHELL chrome (sidebar, header, login) to match:
sudo make install-branding # backs up any existing branding.css first
sudo make uninstall-branding # restore the backup
```
`install-branding` installs `shared/branding.css` as
`/usr/share/cockpit/branding.css` — Cockpit's documented override point
for the shell. It targets both PatternFly v5 (`pf-v5-*`, Cockpit ≥ 300)
and v4 (`pf-c-*`) selector generations, so unmatched rules simply no-op.
Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net>

124
README.md
View File

@ -3,7 +3,7 @@
**A drop-in plugin for an existing Cockpit install — twenty-six domain modules behind one dashboard.**
Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net>
Version: **0.2.0** (Master Edition) · License: **MIT**
Version: **0.4.1** · License: **MIT**
---
@ -13,6 +13,128 @@ SysDeck is a cockpit-native plugin that consolidates the day-to-day work of a Li
The plugin ships as static HTML+JS+CSS plus a Python bridge helper package. It installs under `/usr/share/cockpit/sysdeck-*/` and is discovered automatically by the cockpit-bridge. No separate web server, no Node.js runtime, no database — the plugin runs inside the cockpit web service.
### v0.4.1 highlights (cockpit module detection — 100% console/host parity)
0.4.0 brought the Unix login. 0.4.1 closes the last compatibility gap:
**every cockpit module installed on the host is now detected and loaded
into the Next.js console too** — distro modules like cockpit-machines
and cockpit-podman, addons, anything with a `menu` entry in its
`/usr/share/cockpit/<pkg>/manifest.json`:
- **Detection is pure filesystem** — the same discovery the cockpit
shell performs. `sysdeck-*` modules are skipped (native panels already
ship here) and chrome without a menu (`base1`, `shell`) never shows.
Works with cockpit stopped or absent; `SYSDECK_COCKPIT_SCAN` adds
extra scan roots (colon-separated) for staged/DESTDIR trees. With no
cockpit tree the surface shows a clearly-badged typical-distro set.
- **A "Cockpit" sidebar group** appears with every detected module —
each opens a detail view: manifest identity, shipped files with
sizes, live backend presence probes (`virsh`, `podman`, `nmcli`,
`pkcon`...) plus on-demand version probes, and a jump to the native
console panel covering the domain (machines/podman → Containers &
VMs, packagekit → Packages, networkmanager → Network Security,
metrics → Monitoring...). The ⌘K palette searches them too, and the
**Cockpit Modules** hub panel lists everything with LIVE/DEMO
provenance.
- **The UI codenames are retired** — no more "web edition" or edition
subtitles anywhere in the console; the identity is simply **SysDeck**
with a single clean subtitle: **dcos.net** (login banner, sidebar,
status bar). The page title is "SysDeck".
### v0.4.0 highlights (Unix Login Edition)
0.3.1 gated the web console behind one shared password. 0.4.0 replaces
it with the login model the whole project is named after: **sign in
with a Unix account, verified by the host's PAM stack — the same
mechanism Cockpit uses at its own login screen.** The host decides;
the console keeps no password data of its own.
- **PAM login** — `web/scripts/pam-auth.py`, a stdlib-only ctypes client
of `libpam`, runs `pam_start` → `pam_authenticate` → `pam_acct_mgmt`
under the `sysdeck` service when `/etc/pam.d/sysdeck` exists, else the
stock `login` stack. Credentials travel over stdin, never argv.
Ship your own `/etc/pam.d/sysdeck` to tailor the stack (MFA modules
included, if you want them).
- **User-bound sessions** — the `sd_session` cookie becomes
`v2.<exp>.<userB64>.<hmac>`; the shell shows a cockpit-style account
menu (avatar, `user@host`, PAM/local provenance, the wheel/sudo
"Administrative access" badge, a live session-expiry countdown with a
draining life bar) and the status bar carries `user@host`. 0.3.1 v1
tokens still verify as legacy sessions — upgrades don't log anybody
out. The fester service gates its REST + WS surface on the same v2
token.
- **Three auth modes** — `SYSDECK_AUTH_MODE=pam` (default, cockpit
faithful: run the service as root so any unix account can sign in),
`pam+local` (PAM first, `SdUser` scrypt accounts as the fallback for
unprivileged installs), `local` (console accounts only). Local
accounts are managed with `bun scripts/manage-users.mjs
list|add|passwd|disable|enable|remove`.
- **Lockout like sshd** — failures rate limited per-IP **and**
per-username (5/min each); wrong-user and wrong-password return the
same generic answer; every attempt audited with the unix username as
actor. A wedged PAM helper fails CLOSED, never silently falls back.
- **The login screen got the fester treatment** — host identity banner
(hostname + OS, exactly what cockpit leads with), aurora/grid
backdrop in the active console theme, caps-lock detection, a one-shot
error shake, and the amber default-password nag (local modes) until
the seeded account is rotated.
- **Cockpit edition untouched** — all 29 modules keep working under
cockpit exactly as before; the bridge guards (`check-bridge-subcommands`
218 calls / 28 modules, manifest consistency) still pass. This
revision's changes live in `web/` and the docs.
### v0.3.1 highlights (login gate for the web edition)
The 0.3.0 audit left one honest gap: the web edition had guards but no
login. 0.3.1 closed it the LAN-side way — a **cockpit-style shared
password** (superseded by 0.4.0's Unix-account login):
- **one shared password** — `SYSDECK_WEB_PASSWORD` in `web/.env`
(default `sysdeck`; the login screen nags in amber until you set
your own). Constant-time compare, per-IP failure rate limit
(5/min), every attempt audited with the source IP.
- **HMAC-signed session cookie** — HttpOnly, SameSite=Lax, **12h**
expiry; the signing key is random per install and persists in
SQLite, so restarts don't log you out and the **fester
mini-service verifies the identical token** straight from the same
DB — the browser's live WebSocket event stream is gated too, not
just the REST routes.
- **every surface gated** — the page server-renders the login screen
until the cookie verifies; all `/api/*` routes answer 401 until
signed in; a mid-flight expiry reloads to the login screen instead
of erroring. Logout button in the shell header.
- The posture stays LAN-side: loopback binds remain the outer
boundary, `SYSDECK_SESSION_SECURE=1` adds the `Secure` cookie flag
when the console fronts TLS. See QUICKSTART §10.4.
### v0.3.0 highlights (AI Gateway Edition)
v0.3.0 integrates **klanker-gate** — the Frosty Deno LLM gateway (Deno 2 + TypeScript, OpenAI-compatible API, governance, virtual keys, caching, MCP) — as the new **AI Gateway** module, vendored at `/klanker-gate` (own independent version 0.9.0, Apache-2.0). **klanker-gate is not SysDeck code** — it is by [TykoDev](https://github.com/TykoDev/klanker-gate) and is credited in `/klanker-gate/ATTRIBUTION.md` and `THIRD_PARTY.md`.
The headline finding of this release: **"porting klanker-gate to Arch Linux" required zero upstream source changes.** The codebase is Linux-first, not Windows-first (the Windows mentions in the tree are accommodations: `reusePortSupported()` is linux/darwin-only, the Docker/entrypoint path is POSIX, `deno.lock` win32 entries are ordinary cross-platform lockfile records). The work was packaging — and it ships:
- **`klanker-gate/arch/`** — the complete Arch packaging: `PKGBUILD` (self-packaging, `makepkg -si`), a hardened systemd unit (StateDirectory, `ProtectSystem=full`, empty `CapabilityBoundingSet`), sysusers/tmpfiles, a `/usr/bin/klanker-gate` run wrapper (module-cache warmup + `--allow-run` scoped to the Deno binary only when `FROSTY_WORKERS>1`, mirroring the upstream entrypoint's escalation policy), and `INSTALL-ARCH.md` (the full runbook: postgres provisioning, env, verification, SysDeck wiring). Bonus: moving to Arch **unlocks** `FROSTY_WORKERS` multi-process serving via `SO_REUSEPORT` — impossible on Windows.
- **cockpit side** — `bridge/klanker.py` (10 subcommands: status, providers, models, vkeys, logs, analytics, runtime, service, journal, localstack — stdlib REST client against `KLANKER_URL`, Bearer `KLANKER_ADMIN_TOKEN`, graceful offline JSON, token never echoed) and the fully-built `plugins/sysdeck-klanker/` panel (status card, spend in µUSD→USD, providers/vkeys/recent-requests tables, runtime topology, local stack wiring card, service control + journal viewer).
- **web side** — the hybrid **AI Gateway** panel (Integrations group): live REST against the gateway when it runs, clearly-badged demo data when it doesn't (this sandbox has no Deno/Postgres); flips to `source: live` automatically with `KLANKER_URL` set.
- **local stack first-class (both editions)** — the gateway is *not* SaaS-only: `ollama`/`lmstudio`/`sgl` are native keyless provider types and llama.cpp (llama-server)/KoboldCpp/vLLM plug in via the generic `openai-compatible` type. New **Local stack wiring** card live-probes each backend's `/v1/models` from the host (`klanker localstack` bridge subcommand) and shows env + admin-API wiring with copy buttons; the web demo dataset re-seeded local-first (spend/24h ≈ $0.001 — cloud overflow only). See QUICKSTART §10.1.
- **module toggles (web edition)** — every sidebar module can be turned OFF (hidden from the sidebar + ⌘K palette) and back ON from a **Disabled** section — one click, persisted in SQLite, survives restarts; disabling the active module returns to Overview. Turning the AI Gateway off when you switch stacks is now a hover + click. See QUICKSTART §10.2.
- **guards** — `check-bridge-subcommands` now verifies **218 calls across 28 bridge modules** (was 216; the audit pass added auth readers+certs); 28 plugin manifests conform.
- **0.3.0 security audit** — a full-codebase review (bridge helpers, plugin panels, web edition, vendored klanker-gate): bridge write primitives now fail closed (cgroup-set path+control validation, artifacts-clear/build-delete/build-log id validation, profile-create name validation, hwalert's `sudo sh -c` removed, db start/stop/restart registry-gated, db query read-only-guarded, themes set newline-guarded, packages argument-injection-guarded); the 8 oldest panels escape all interpolations and all 27 manifests dropped `unsafe-eval`; the web edition binds loopback (dev, fester, production start) with bridge body-cap + rate limit; the vendored gateway is audited-but-unmodified with findings in `klanker-gate/arch/SECURITY-UPSTREAM.md` and packaging-layer mitigations (systemd unit refuses to start without `FROSTY_ADMIN_TOKEN`). See QUICKSTART §10.3.
Wire it up (either edition):
KLANKER_URL=http://127.0.0.1:8080
KLANKER_ADMIN_TOKEN=<FROSTY_ADMIN_TOKEN> # see klanker-gate/arch/INSTALL-ARCH.md
Quick start (web edition, from an extracted master tarball):
make web-dev # fester service (background, :3010) + web console (:3000)
Two more 0.3.0 additions close the loop between the editions:
- **"Run without Cockpit" runbook** — the web edition is fully standalone (no cockpit, no Python bridge, no systemd, no root). The complete deployment guide — dev, standalone production build, the two systemd units, `.env` reference, reverse proxy + `?XTransformPort=` websocket gateway, troubleshooting — ships twice, kept in sync: as `web/README.md` in the tarball and as a first-class **panel** in the web console (system group, right under Overview, copy-buttons on every command block).
- **the web-edition skin for Cockpit** — since 0.3.0 every Cockpit plugin page links `shared/sysdeck-web.css` after the base stylesheet, porting the Next.js console's midnight/teal design (accent `#3fc9b0`, soft-tinted badges, 10px radii, tabular numerals) onto the classic panels; `sudo make install-branding` additionally themes the Cockpit **shell** chrome (sidebar/header/login, PatternFly v4+v5 covered, distro `branding.css` backed up first). Revert either with `make uninstall-branding` / removing the skin file. See QUICKSTART §12.
### v0.2.0 highlights (Master Edition)
v0.2.0 ships as a **master tarball — `sysdeck-0.2.0-master.tar.bz2`** — bundling the cockpit edition (this tree), the new **SysDeck Web Edition** (`web/` — a standalone Next.js console with 28 bridge modules, an Overview landing view, and the previously-orphaned Hardware Alerts panel), and **Fester pre-integrated**.

View File

@ -6,14 +6,41 @@ integration invokes the external tool as a **separate process** via
suite (MIT) and the external tools remain independent programs.
This file satisfies the attribution requirements of the licenses listed
below and documents every external integration point.
below and documents every external integration point — including the
one vendored project in the master tarball (klanker-gate, below).
---
## Bundled Dependencies (shipped with the suite)
## Vendored Project (master tarball only)
### klanker-gate — the "Frosty Deno" LLM gateway (the AI Gateway module)
**klanker-gate is not SysDeck's code.** All credit belongs to its
author, **TykoDev**. The master tarball vendors the upstream tree
unmodified, as a sibling of the suite, under its own Apache-2.0
license; the suite's modules talk to it as a separate process over
REST (same no-linking rule as every other entry in this file).
| Field | Value |
|-------|-------|
| **Project** | klanker-gate ("Frosty Deno" LLM Gateway) |
| **Author** | **TykoDev** |
| **Source** | https://github.com/TykoDev/klanker-gate |
| **License** | Apache-2.0 (full text kept at `klanker-gate/LICENSE`; notice kept at `klanker-gate/ATTRIBUTION.md`) |
| **Vendored at** | `klanker-gate/` in the master tarball, own version **0.9.0** (independent from SysDeck's version) |
| **SysDeck additions** | `klanker-gate/arch/` only (PKGBUILD, systemd unit, sysusers/tmpfiles, run wrapper, runbook) — zero upstream source changes |
| **Modules** | `sysdeck-klanker` (cockpit edition: `bridge/klanker.py` + `plugins/sysdeck-klanker/`) and the web edition `klanker` bridge + AI Gateway panel — both are thin REST *clients* containing no upstream code |
| **Integration** | REST against `KLANKER_URL` (default `http://127.0.0.1:8080`), `Authorization: Bearer <KLANKER_ADMIN_TOKEN>` — a separate process invoked over HTTP, never linked or embedded |
| **License compat** | MIT suite + Apache-2.0 vendored tree redistributed in source form with LICENSE and notices retained — compliant; the two programs remain independent works |
---
## Bundled Dependencies (shipped with the suite itself)
None. The suite is self-contained MIT-licensed code with no vendored
third-party libraries.
third-party libraries. (The master tarball separately vendors the
klanker-gate project — see the section above; it is a sibling tree,
not part of the suite.)
---
@ -247,7 +274,7 @@ For MIT/LGPL/BSD/Apache tools: fully compatible with the suite's MIT license.
---
## v0.0.46 — In-Suite 3rd-Party Module Installer
### v0.0.46 — In-Suite 3rd-Party Module Installer
Prior to v0.0.46, the only way to install third-party Cockpit modules
(45Drives Navigator, cockpit-pacman, cockpit-identities, etc.) was the

View File

@ -22,7 +22,7 @@ import os
import subprocess
from typing import Literal
__version__ = "0.2.0"
__version__ = "0.4.1"
__author__ = "Jeremy Anderson"
__url__ = "https://dcos.net"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -31,6 +31,7 @@ Usage:
import json
import os
import re
import shutil
import subprocess
import sys
from typing import Any
@ -84,6 +85,25 @@ def readers() -> list[dict[str, str]]:
]
def certs() -> dict[str, Any]:
"""PKCS#11 objects of type cert via pkcs11-tool.
v0.1.4: the auth panel's "List Certificates" button used to call a
bridge.spawn() that bridge.js never exported — the button has
always thrown. The listing now lives here (fixed argv list, no
shell), matching every other spawn in this suite.
"""
if not shutil.which("pkcs11-tool"):
return {"available": False,
"reason": "pkcs11-tool not installed (opensc)",
"count": 0, "output": ""}
raw = run(["pkcs11-tool", "--list-objects", "--type", "cert"])
lines = [line for line in raw.splitlines() if line.strip()]
return {"available": True,
"count": sum(1 for line in lines if "Certificate" in line),
"output": "\n".join(lines) or "(no certificates on any slot)"}
def pcscd_state() -> str:
"""pcscd.service state via systemctl."""
raw = run(["systemctl", "is-active", "pcscd"]).strip()
@ -238,6 +258,7 @@ COMMANDS = {
"summary": lambda _args: summary(),
"slots": lambda _args: slots(),
"readers": lambda _args: readers(),
"certs": lambda _args: certs(),
"identities": lambda _args: identities(),
"ssh-keys": lambda _args: ssh_keys(),
"kerberos": lambda _args: kerberos(),

View File

@ -21,6 +21,7 @@ Usage:
"""
import json
import re
import subprocess
import sys
from typing import Any
@ -96,6 +97,13 @@ def run_test(args: list[str]) -> dict[str, Any]:
"error": "no test name provided",
}
test_name = args[0]
# v0.1.4 SECURITY: the test name is passed to `sysbench <name> run`
# as one argv element — a leading dash makes it an OPTION (e.g.
# --config=…), so validate it as a plain identifier (the sysbench
# builtin test vocabulary is cpu/memory/threads/mutex/fileio/oltp_*).
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", test_name) or test_name.startswith("-"):
return {"raw": "", "events_per_sec": None, "latency_ms": None,
"error": f"invalid sysbench test name: {test_name!r}"}
# Some sysbench tests (fileio) require a prepare step before run.
# We deliberately keep this simple — for arbitrary test names, just
# invoke ``sysbench <name> run``. If the user wants fileio with

View File

@ -485,6 +485,34 @@ BUILDER_LOGS_DIR = Path("/var/lib/sysdeck/builder/logs")
BUILDER_ARTIFACTS_DIR = Path("/var/lib/sysdeck/builder/artifacts")
# v0.1.4 SECURITY: build-ids and profile names are used to build paths
# under the three dirs above (state/<id>.json, logs/<id>.log,
# artifacts/<profile>/). They arrive as raw argv from the bridge caller,
# so they must be validated as a single safe path component before any
# filesystem use — otherwise `build-log ../../etc/foo` reads arbitrary
# *.log files, `build-delete <traversal>` unlinks arbitrary *.json/*.log,
# and `artifacts-clear /etc` would rmtree an arbitrary directory as root
# (found by the 0.3.0 security audit; every one of these now fails closed).
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
def _valid_id(token: str) -> bool:
"""True if token is a safe single path component (no separators,
no traversal, no leading dash, bounded length)."""
if not isinstance(token, str) or not token:
return False
return bool(_SAFE_ID_RE.match(token)) and ".." not in token
def _under_dir(path: Path, root: Path) -> bool:
"""True if (resolved) path stays inside root (defends symlinks + traversal)."""
try:
path.resolve().relative_to(root.resolve())
return True
except (ValueError, RuntimeError, OSError):
return False
def _ensure_state_dirs() -> None:
"""Create the state/logs/artifacts dirs. Best-effort; the cockpit
superuser channel handles root perms when needed."""
@ -1092,6 +1120,15 @@ def profile_create(args: list[str]) -> dict[str, Any]:
backend_id = positional[1]
base = positional[2] if len(positional) > 2 else None
# v0.1.4 SECURITY: the name becomes /etc/mkosi/profiles/<name>/ (or
# /etc/vmdb2/<name>.yaml) and is .format()-ed into the scaffold's
# config templates — a name containing '/', '..' or newlines is
# directory traversal plus arbitrary config-line injection into
# files that mkosi/vmdb2 later execute as root during builds.
if not _valid_id(name):
return {"error": "profile name must be a single path component "
"(letters, digits, '.', '_', '-'; no slashes, no '..', no newlines)"}
# Validate backend.
valid_backends = ("mkosi", "vmdb2")
if backend_id not in valid_backends:
@ -1870,6 +1907,10 @@ def build_log(args: list[str]) -> dict[str, Any]:
if not args:
return {"error": "build-id required"}
build_id = args[0]
# v0.1.4 SECURITY: build-id is used to build the log path under
# BUILDER_LOGS_DIR — a traversal id would read arbitrary *.log files.
if not _valid_id(build_id):
return {"error": "invalid build-id (must be a single path component)"}
log_path = _build_log_path(build_id)
if not log_path.is_file():
return {"error": f"no log file for build {build_id}", "build_id": build_id}
@ -1893,6 +1934,11 @@ def artifacts(args: list[str]) -> dict[str, Any]:
if not BUILDER_ARTIFACTS_DIR.is_dir():
return {"artifacts": [], "by_profile": {}}
profile_filter = args[0] if args else None
# v0.1.4 SECURITY: a traversal profile filter would list an
# arbitrary directory's contents (names/sizes/mtimes) to the browser.
if profile_filter and (not _valid_id(profile_filter) or
not _under_dir(BUILDER_ARTIFACTS_DIR / profile_filter, BUILDER_ARTIFACTS_DIR)):
return {"error": "invalid profile filter (must be a single path component)"}
by_profile: dict[str, list[dict[str, Any]]] = {}
if profile_filter:
profiles_to_scan = [BUILDER_ARTIFACTS_DIR / profile_filter]
@ -1954,6 +2000,13 @@ def artifacts_clear(args: list[str]) -> dict[str, Any]:
if not args:
return {"error": "usage: artifacts-clear <profile>"}
profile = args[0]
# v0.1.4 SECURITY: profile is used to rmtree a directory as root —
# an absolute path ('/etc') or traversal ('../..') escapes the
# artifacts root. Validate as a single component AND resolve the
# target under BUILDER_ARTIFACTS_DIR (same guard artifact-delete
# has had since v0.0.31; artifacts-clear missed it).
if not _valid_id(profile) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile, BUILDER_ARTIFACTS_DIR):
return {"error": f"refusing to clear: '{profile}' is not a profile directory under {BUILDER_ARTIFACTS_DIR}"}
prof_dir = BUILDER_ARTIFACTS_DIR / profile
if not prof_dir.is_dir():
return {"error": f"no artifacts directory for profile '{profile}'"}
@ -1989,6 +2042,10 @@ def build_delete(args: list[str]) -> dict[str, Any]:
if not args:
return {"error": "build-id required"}
build_id = args[0]
# v0.1.4 SECURITY: build-id builds state/log paths that get unlinked
# as root — a traversal id would delete arbitrary *.json/*.log files.
if not _valid_id(build_id):
return {"error": "invalid build-id (must be a single path component)"}
delete_artifacts = "--artifacts" in args[1:]
deleted = []
errors = []
@ -2018,13 +2075,18 @@ def build_delete(args: list[str]) -> dict[str, Any]:
errors.append(f"log: {exc}")
# Optionally delete artifacts.
if delete_artifacts and profile_name:
prof_dir = BUILDER_ARTIFACTS_DIR / profile_name
if prof_dir.is_dir():
try:
shutil.rmtree(prof_dir)
deleted.append(str(prof_dir) + "/ (artifacts dir)")
except (PermissionError, OSError) as exc:
errors.append(f"artifacts: {exc}")
# v0.1.4 SECURITY: profile_name comes from the (deleted) state
# file's JSON — treat it as untrusted before rmtree'ing with it.
if not _valid_id(profile_name) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile_name, BUILDER_ARTIFACTS_DIR):
errors.append(f"artifacts: refusing to clear untrusted profile path {profile_name!r}")
else:
prof_dir = BUILDER_ARTIFACTS_DIR / profile_name
if prof_dir.is_dir():
try:
shutil.rmtree(prof_dir)
deleted.append(str(prof_dir) + "/ (artifacts dir)")
except (PermissionError, OSError) as exc:
errors.append(f"artifacts: {exc}")
if not deleted and not errors:
return {"error": f"no build found with id '{build_id}'"}
return {"deleted": True, "build_id": build_id, "profile": profile_name,

View File

@ -288,6 +288,16 @@ def cmd_status(engine_id):
return {"error": f"Unknown engine: {engine_id}"}
def _engine_registered(engine_id):
"""v0.1.4 SECURITY: True if engine_id is in ENGINE_REGISTRY. The
status/connections/query subcommands already resolved engines
through the registry, but start/stop/restart passed the raw id into
`systemctl <verb> {engine_id}.service` — letting any cockpit
session stop/start ARBITRARY system units as root (db stop sshd).
All unit-control subcommands now require a registered engine."""
return any(e[0] == engine_id for e in ENGINE_REGISTRY)
def cmd_start(engine_id):
# v0.0.32: was `sudo systemctl start` — but sudo shell-out from
# the bridge fails when the cockpit user has no passwordless sudo
@ -297,6 +307,9 @@ def cmd_start(engine_id):
# action. The bridge runs systemctl directly as root (the cockpit
# superuser channel escalates privileges via polkit when the
# operator authenticates).
# v0.1.4 SECURITY: registry check added (see _engine_registered).
if not _engine_registered(engine_id):
return {"error": f"Unknown engine: {engine_id}"}
rc, out, err = run_rc(["systemctl", "start", f"{engine_id}.service"], timeout=30)
return {"action": "start", "engine": engine_id, "rc": rc,
"output": out or err or "started", "success": rc == 0,
@ -304,6 +317,10 @@ def cmd_start(engine_id):
def cmd_stop(engine_id):
# v0.1.4 SECURITY: registry check added (see _engine_registered) —
# without it, `db stop sshd` stopped arbitrary root units.
if not _engine_registered(engine_id):
return {"error": f"Unknown engine: {engine_id}"}
rc, out, err = run_rc(["systemctl", "stop", f"{engine_id}.service"], timeout=30)
return {"action": "stop", "engine": engine_id, "rc": rc,
"output": out or err or "stopped", "success": rc == 0,
@ -311,6 +328,9 @@ def cmd_stop(engine_id):
def cmd_restart(engine_id):
# v0.1.4 SECURITY: registry check added (see _engine_registered).
if not _engine_registered(engine_id):
return {"error": f"Unknown engine: {engine_id}"}
rc, out, err = run_rc(["systemctl", "restart", f"{engine_id}.service"], timeout=30)
return {"action": "restart", "engine": engine_id, "rc": rc,
"output": out or err or "restarted", "success": rc == 0,
@ -331,13 +351,30 @@ def cmd_connections(engine_id):
def cmd_query(engine_id, sql):
"""Execute a SQL query against an engine (SQL family only)."""
# Safety: refuse DDL/DML for certain contexts
"""Execute a read-only SQL query against an engine (SQL family only).
v0.1.4 SECURITY: the old comment said "refuse DDL/DML" but no check
existed — any statement (DROP DATABASE, COPY ... TO PROGRAM) ran as
root through psql/mysql/sqlite. The guard is now real: only
SELECT/WITH/SHOW/EXPLAIN/DESCRIBE/PRAGMA-first-token statements
pass. Mutations belong in the engine's own tooling, not in a
dashboard query box.
"""
for e in ENGINE_REGISTRY:
if e[0] == engine_id:
family, cli = e[2], e[5]
if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"):
return {"error": "Query only supported for SQL-family engines"}
# v0.1.4 SECURITY: read-only statement guard.
tokens = (sql or "").lstrip("(\t\r\n ").split(None, 1)
first_token = tokens[0].upper() if tokens else ""
read_only = first_token in (
"SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC",
"PRAGMA", "TABLE", "ANALYZE",
)
if not read_only:
return {"error": "read-only queries only — DDL/DML is rejected "
"(first token was not a read statement)"}
if cli == "psql":
out = run(["psql", "-tAc", sql], timeout=30)
elif cli in ("mysql", "mariadb"):

View File

@ -1537,7 +1537,11 @@ def cmd_install_backend(args: list[str]) -> dict[str, Any]:
for p in pkgs:
if not _validate_filename(p):
return {"error": f"invalid package name: {p!r}"}
cmd = [python3, packages_helper, "install", "--", *pkgs]
# v0.1.4 FIX: the trailing '--' separator made packages.py's
# install() see '--' as args[0] and fail with "no targets" — the
# backend-install path had never worked. packages.py now skips
# leading '--' argv elements anyway, so both sides are fixed.
cmd = [python3, packages_helper, "install", *pkgs]
try:
r = subprocess.run(
cmd, capture_output=True, text=True, check=False, timeout=300,

View File

@ -507,28 +507,61 @@ def cmd_dismiss(alert_id):
return {"action": "dismiss", "alertId": alert_id, "status": "dismissed"}
def _device_path_ok(device_id):
"""v0.1.4 SECURITY: device ids from the scanners are absolute sysfs
paths (/sys/bus/usb/devices/..., /sys/bus/thunderbolt/devices/...,
/sys/bus/pci/devices/...). block/unblock write to <id>/authorized as
root, so the id must resolve inside one of those scanned bases —
otherwise cmd_block was an arbitrary file-overwrite ('0') and
cmd_unblock was a root shell injection via `sudo sh -c` with the
f-string path (found by the 0.3.0 security audit; both sudo
fallbacks are also gone: the cockpit superuser channel already
escalates this helper via polkit, so shelling out through sudo
only ever added the injection primitive)."""
if not isinstance(device_id, str) or not device_id.startswith("/"):
return False
bases = (
"/sys/bus/usb/devices",
"/sys/bus/thunderbolt/devices",
"/sys/bus/pci/devices",
)
p = os.path.realpath(device_id)
return any(p == b or p.startswith(b + "/") for b in bases)
def cmd_block(device_id):
"""Block a device — for USB, writes '0' to authorized sysfs."""
# Try USB authorization
if not _device_path_ok(device_id):
return {"action": "block", "deviceId": device_id, "result": "invalid-device-path"}
auth_path = os.path.join(device_id, "authorized")
if os.path.exists(auth_path):
try:
with open(auth_path, 'w') as f:
f.write('0')
return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize"}
except PermissionError:
# Need sudo
run(["sudo", "tee", auth_path], timeout=5)
return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize-sudo"}
except (PermissionError, OSError) as exc:
return {"action": "block", "deviceId": device_id, "result": "error",
"error": str(exc)}
return {"action": "block", "deviceId": device_id, "result": "no-method-available"}
def cmd_unblock(device_id):
"""Unblock a device."""
if not _device_path_ok(device_id):
return {"action": "unblock", "deviceId": device_id, "result": "invalid-device-path"}
auth_path = os.path.join(device_id, "authorized")
if os.path.exists(auth_path):
run(["sudo", "sh", "-c", f"echo 1 > {auth_path}"], timeout=5)
return {"action": "unblock", "deviceId": device_id, "result": "unblocked"}
# v0.1.4 SECURITY: was `sudo sh -c f"echo 1 > {auth_path}"` — a
# device_id containing shell metacharacters was literal root RCE.
# Direct write (this helper already runs privileged through the
# cockpit superuser channel when the operator approves polkit).
try:
with open(auth_path, 'w') as f:
f.write('1')
return {"action": "unblock", "deviceId": device_id, "result": "unblocked"}
except (PermissionError, OSError) as exc:
return {"action": "unblock", "deviceId": device_id, "result": "error",
"error": str(exc)}
return {"action": "unblock", "deviceId": device_id, "result": "no-method-available"}

571
bridge/klanker.py Normal file
View File

@ -0,0 +1,571 @@
#!/usr/bin/env python3
"""
SysDeck - Klanker Bridge Helper (AI Gateway)
Author: Jeremy Anderson (https://dcos.net)
UPSTREAM ATTRIBUTION: this helper is a REST *client* of klanker-gate —
the "Frosty Deno" LLM gateway by TykoDev
(https://github.com/TykoDev/klanker-gate, Apache-2.0), which the master
tarball vendors unmodified at /klanker-gate. klanker-gate is NOT
SysDeck code and no upstream code is contained here — see
klanker-gate/ATTRIBUTION.md and THIRD_PARTY.md.
v0.3.0 NEW MODULE. Client of the vendored klanker-gate service — the
Frosty Deno LLM gateway (Deno 2 + TypeScript, REST on 127.0.0.1:8080) —
so sysdeck ships an operator view of the local inference gateway:
providers, virtual keys, request logs, spend/cost rollups, cache and
runtime topology, plus systemd service control.
This helper is a thin stdlib-only REST client (urllib.request + json,
4s timeout — no requests library, no curl dependency), the same
contract as bridge/fester.py. Every read subcommand prints the
service's JSON response; `status` merges the public /healthz and
/api/version probes and enriches them with the local connection
facts. HTTP error bodies (401 auth errors, 404s) are JSON on this
service and are surfaced verbatim. Connection failures are graceful:
{"ok": false, "error": ...} with a remediation hint, exit code 0 —
never a traceback.
Authentication: operator routes under /api/* take
`Authorization: Bearer <FROSTY_ADMIN_TOKEN>` when the gateway has one
configured. The token is read from KLANKER_ADMIN_TOKEN here and sent
as a header ONLY — it is never echoed in any output, never placed in
a URL, and journal output is scrubbed of its value defensively.
Subcommands:
status GET /healthz + /api/version, merged + enriched
providers GET /api/providers (browser-safe list)
models GET /v1/models (aggregated catalog)
vkeys GET /api/virtual-keys
logs [--limit N] GET /api/logs?limit=N (recent request ring,
default 25)
analytics GET /api/analytics (rollups: requests, spend,
cache, latency; optional --window 1h|24h|7d)
runtime GET /api/runtime (workers/cache/postgres)
service <action> systemctl start|stop|restart|status|enable|disable
klanker-gate.service
journal [N] journalctl -u klanker-gate -n N --no-pager
(default 40, sanitized)
localstack probe local AI backends (ollama, llama.cpp,
koboldcpp, lmstudio, sglang, vllm) + wiring recipes
Usage:
python3 /usr/lib/sysdeck/bridge/klanker.py status
python3 /usr/lib/sysdeck/bridge/klanker.py providers
python3 /usr/lib/sysdeck/bridge/klanker.py logs --limit 50
python3 /usr/lib/sysdeck/bridge/klanker.py service restart
KLANKER_URL=http://10.0.0.5:8080 KLANKER_ADMIN_TOKEN=... \\
python3 /usr/lib/sysdeck/bridge/klanker.py analytics
"""
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
# The vendored klanker-gate service binds REST here by default
# (apps/gateway/main.ts: PORT env, default 8080). Override with
# KLANKER_URL when it lives elsewhere.
KLANKER_URL = os.environ.get("KLANKER_URL", "http://127.0.0.1:8080").rstrip("/")
# Optional bearer token for the gateway's admin surface (/api/* takes
# Authorization: Bearer <FROSTY_ADMIN_TOKEN> when the operator set one).
# Header-only usage — NEVER printed, NEVER in a URL.
KLANKER_ADMIN_TOKEN = os.environ.get("KLANKER_ADMIN_TOKEN")
# The systemd unit the service subcommand wraps. The gateway itself
# ships no unit (docker-compose / `deno task gateway` are its native
# runners); operators who deploy it natively use this name, matching
# the fester-service convention.
KLANKER_SERVICE = "klanker-gate.service"
# Strict 4s timeout — the panel polls every 5s, so a hung request must
# never outlive one refresh cycle.
KLANKER_TIMEOUT = 4 # seconds
# Connection-level failure messages (callers print this and exit 0 —
# graceful, same contract as fester.py and the other bridge helpers).
UNREACHABLE_MSG = (
"klanker-gate service unreachable at {url} — start it with "
"`systemctl start klanker-gate` (arch/ packaging) or `deno task dev` "
"in the vendored klanker-gate tree, or set KLANKER_URL"
)
def _unreachable() -> dict:
"""Return the graceful offline response (remediation hint included)."""
return {"ok": False, "error": UNREACHABLE_MSG.format(url=KLANKER_URL)}
def _base_port() -> int:
"""Port of the base URL (8080 for the default vendored service)."""
try:
return urllib.parse.urlparse(KLANKER_URL).port or 8080
except ValueError:
return 8080
def _request(path: str) -> dict:
"""One HTTP GET against the gateway. Returns parsed JSON.
HTTPError bodies are JSON on this service — surface them verbatim
(a 401 "Missing or invalid admin token." is a fact the operator
needs to see). Connection-level failures raise URLError/OSError;
the _get wrapper translates those into the graceful offline
response.
"""
url = KLANKER_URL + path
headers = {"Accept": "application/json"}
if KLANKER_ADMIN_TOKEN:
# Sent as a header only — the token value never appears in
# `url`, in any error string, or in any printed JSON.
headers["Authorization"] = f"Bearer {KLANKER_ADMIN_TOKEN}"
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with urllib.request.urlopen(req, timeout=KLANKER_TIMEOUT) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
try:
raw = exc.read().decode("utf-8", errors="replace")
if raw.strip():
return json.loads(raw)
except (OSError, ValueError):
pass
return {"ok": False, "error": f"HTTP {exc.code}: {exc.reason}"}
try:
return json.loads(raw) if raw.strip() else {"ok": False, "error": f"empty response from {url}"}
except json.JSONDecodeError:
return {"ok": False, "error": f"non-JSON response from {url}"}
def _get(path: str) -> dict:
"""GET <path> with graceful offline handling."""
try:
return _request(path)
except (urllib.error.URLError, OSError, ValueError):
return _unreachable()
# ── subcommands ──────────────────────────────────────────────────────
def cmd_status(_args: list[str]) -> dict:
"""GET /healthz + /api/version, merged + enriched with local facts.
Both probes are public (no admin token required). The merge keeps
the gateway's own fields (status, version, timestamp) and layers:
port — port of the base URL (8080 default)
base_url — the URL this helper is talking to
deno — Deno runtime version from /api/version
transport — "rest"
auth — whether an admin token is configured HERE (boolean;
the token value itself is never reported)
"""
try:
data = _request("/healthz")
except (urllib.error.URLError, OSError, ValueError):
return _unreachable()
if not isinstance(data, dict) or data.get("status") != "ok":
# Non-healthy gateway (or an error body) — surface it verbatim.
if isinstance(data, dict) and data.get("ok") is not False:
data = dict(data)
data.setdefault("status", "error")
return data if isinstance(data, dict) else {"ok": False, "error": "non-object healthz response"}
out = dict(data)
# /api/version is best-effort — a healthy gateway always serves it,
# but a failure here must not sink the status probe.
version = _get("/api/version")
if isinstance(version, dict) and version.get("ok") is not False:
out["deno"] = version.get("deno")
if version.get("version"):
out["version"] = version["version"]
out["ok"] = True
out["port"] = _base_port()
out["base_url"] = KLANKER_URL
out["transport"] = "rest"
out["auth"] = bool(KLANKER_ADMIN_TOKEN)
return out
def cmd_logs(args: list[str]) -> dict:
"""GET /api/logs — the recent request ring, optionally limited.
Usage: logs [--limit N] (default 25, range 1-500)
"""
limit = 25
i = 0
while i < len(args):
arg = args[i]
if arg == "--limit" and i + 1 < len(args):
raw = args[i + 1]
i += 2
try:
limit = int(raw)
except ValueError:
return {"ok": False, "error": f"limit must be an integer between 1 and 500: {raw!r}"}
if not 1 <= limit <= 500:
return {"ok": False, "error": f"limit must be an integer between 1 and 500: {limit}"}
else:
return {"ok": False, "error": f"unknown argument: {arg}"}
return _get(f"/api/logs?limit={limit}")
def cmd_analytics(args: list[str]) -> dict:
"""GET /api/analytics — rollups (requests, spend, cache, latency).
Usage: analytics [--window 1h|24h|7d] (default 24h)
"""
window = "24h"
i = 0
while i < len(args):
arg = args[i]
if arg == "--window" and i + 1 < len(args):
window = args[i + 1]
i += 2
if window not in ("1h", "24h", "7d"):
return {"ok": False, "error": f"window must be one of 1h, 24h, 7d: {window!r}"}
else:
return {"ok": False, "error": f"unknown argument: {arg}"}
return _get(f"/api/analytics?window={window}")
# ── systemd service control ─────────────────────────────────────────
#
# Same pattern as bridge/jellyfin.py / bridge/mining.py: plain
# subprocess.run(["systemctl", ...]) with capture_output, check=False,
# a timeout, and a structured result. No `sudo` shell-out — the JS
# panel's bridgeCmd already passes superuser:'try' so cockpit prompts
# via polkit.
SERVICE_ACTIONS = ("start", "stop", "restart", "status", "enable", "disable")
def _have(binary: str) -> bool:
"""True if binary is on PATH."""
return shutil.which(binary) is not None
def _service_status() -> dict:
"""Read-only unit state: active/sub + enabled-at-boot."""
if not _have("systemctl"):
return {"ok": False, "error": "systemctl not on PATH"}
try:
r = subprocess.run(
["systemctl", "show", KLANKER_SERVICE,
"--property=ActiveState,SubState,UnitFileState,ActiveEnterTimestamp"],
capture_output=True, text=True, timeout=5,
)
props = dict(
line.split("=", 1)
for line in r.stdout.strip().splitlines()
if "=" in line
)
active = props.get("ActiveState", "unknown")
sub = props.get("SubState", "unknown")
enabled = props.get("UnitFileState", "unknown")
return {
"ok": True,
"service": KLANKER_SERVICE,
"active": active,
"sub": sub,
"enabled": enabled,
"running": active == "active",
}
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
return {"ok": False, "error": str(exc)}
def cmd_service(args: list[str]) -> dict:
"""systemctl wrapper for klanker-gate.service.
Usage: service <start|stop|restart|status|enable|disable>
"""
if not args:
return {"ok": False, "error": "action required: service <start|stop|restart|status|enable|disable>"}
action = args[0]
if action not in SERVICE_ACTIONS:
return {"ok": False, "error": f"unknown action: {action!r} (expected one of {', '.join(SERVICE_ACTIONS)})"}
if len(args) > 1:
return {"ok": False, "error": f"unknown argument: {args[1]}"}
if action == "status":
return _service_status()
if not _have("systemctl"):
return {"ok": False, "error": "systemctl not on PATH"}
timeout = 30 if action == "restart" else 15
try:
r = subprocess.run(
["systemctl", action, KLANKER_SERVICE],
capture_output=True, text=True, timeout=timeout,
)
return {
"ok": r.returncode == 0,
"action": action,
"service": KLANKER_SERVICE,
"rc": r.returncode,
"output": (r.stdout or "").strip(),
"stderr": (r.stderr or "").strip(),
}
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
return {"ok": False, "action": action, "service": KLANKER_SERVICE,
"rc": 1, "stderr": str(exc)}
# ── journal ─────────────────────────────────────────────────────────
#
# journalctl tail, sanitized like the suite's other raw-output
# helpers (netsec/grafana _sanitize_output pattern): ANSI escapes and
# non-printable control chars are stripped, the output is capped, and
# — defensively — the admin token value is masked if it somehow ends
# up in a log line.
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]")
_JOURNAL_MAX_CHARS = 32_768 # 32 KB cap — the panel renders it monospace
def _sanitize_journal(text: str) -> str:
"""Strip ANSI/control chars, cap length, mask the admin token."""
if not text:
return ""
text = _ANSI_RE.sub("", text)
text = "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text)
if KLANKER_ADMIN_TOKEN:
# NEVER echo the token, even if the service logged it.
text = text.replace(KLANKER_ADMIN_TOKEN, "***")
if len(text) > _JOURNAL_MAX_CHARS:
text = text[:_JOURNAL_MAX_CHARS] + " ... (truncated)"
return text
def cmd_journal(args: list[str]) -> dict:
"""journalctl -u klanker-gate -n N --no-pager (default 40 lines).
Usage: journal [N] (range 1-1000)
"""
lines = 40
if args:
try:
lines = int(args[0])
except ValueError:
return {"ok": False, "error": f"line count must be an integer between 1 and 1000: {args[0]!r}"}
if not 1 <= lines <= 1000:
return {"ok": False, "error": f"line count must be an integer between 1 and 1000: {lines}"}
if len(args) > 1:
return {"ok": False, "error": f"unknown argument: {args[1]}"}
if not _have("journalctl"):
return {"ok": False, "error": "journalctl not on PATH"}
try:
r = subprocess.run(
["journalctl", "-u", "klanker-gate",
"-n", str(lines), "--no-pager"],
capture_output=True, text=True, timeout=10,
)
log = _sanitize_journal(r.stdout or "")
return {
"ok": r.returncode == 0,
"service": "klanker-gate",
"lines": lines,
"count": log.count("\n") + 1 if log else 0,
"log": log,
"stderr": (r.stderr or "").strip(),
}
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
return {"ok": False, "error": str(exc)}
# ── local stack wiring (probed) ──────────────────────────────────────
# The gateway is NOT SaaS-only: ollama / lmstudio / sglang are native
# keyless provider types upstream (packages/contracts/src/provider-
# registry.ts), and llama.cpp (llama-server) / koboldcpp / vLLM / TGI /
# any OpenAI-wire server plug in through the generic "openai-compatible"
# type. This catalog mirrors the web edition's LOCAL_BACKENDS and the
# upstream defaults (packages/providers/src/openai_compat.ts).
LOCAL_BACKENDS = [
{
"id": "ollama",
"name": "Ollama",
"provider_type": "ollama",
"base_url": "http://127.0.0.1:11434/v1",
"auth": "none",
"caps": "streaming, tools, embeddings",
"env_wiring": "OLLAMA_BASE_URL=http://127.0.0.1:11434/v1\n"
"OLLAMA_MODELS=qwen3:14b,llama3.1:8b,nomic-embed-text",
"note": "native provider type — keyless local daemon",
},
{
"id": "llamacpp",
"name": "llama.cpp (llama-server)",
"provider_type": "openai-compatible",
"base_url": "http://127.0.0.1:8081/v1",
"auth": "key optional",
"caps": "streaming, tools",
"env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8081/v1\n"
"OPENAI_COMPAT_DEFAULT_MODEL=qwen2.5-coder-7b",
"note": "llama-server DEFAULTS TO :8080 — the gateway's own port. "
"Run it elsewhere (8081 here) or move the gateway",
},
{
"id": "koboldcpp",
"name": "KoboldCpp",
"provider_type": "openai-compatible",
"base_url": "http://127.0.0.1:5001/v1",
"auth": "key optional",
"caps": "streaming, tools",
"env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:5001/v1",
"note": "koboldcpp serves the OpenAI wire on its main port",
},
{
"id": "lmstudio",
"name": "LM Studio",
"provider_type": "lmstudio",
"base_url": "http://127.0.0.1:1234/v1",
"auth": "none",
"caps": "streaming, tools, embeddings",
"env_wiring": "LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1",
"note": "native provider type",
},
{
"id": "sglang",
"name": "SGLang",
"provider_type": "sgl",
"base_url": "http://127.0.0.1:30000/v1",
"auth": "none",
"caps": "streaming, tools, embeddings",
"env_wiring": None,
"note": "native provider type — self-hosted serving framework",
},
{
"id": "vllm",
"name": "vLLM",
"provider_type": "openai-compatible",
"base_url": "http://127.0.0.1:8000/v1",
"auth": "key optional",
"caps": "streaming, tools",
"env_wiring": "OPENAI_COMPAT_BASE_URL=http://127.0.0.1:8000/v1",
"note": "via the generic openai-compatible account",
},
]
# Probes run in parallel threads (0.4s timeout each) so the whole
# subcommand answers in well under the 4s bridge budget.
LOCAL_PROBE_TIMEOUT = 0.4
def _probe_one(backend: dict) -> dict:
"""GET <base_url>/models with a 0.4s timeout; offline = reachable:False."""
url = backend["base_url"].rstrip("/") + "/models"
req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET")
started = time.monotonic()
try:
with urllib.request.urlopen(req, timeout=LOCAL_PROBE_TIMEOUT) as resp:
ok = 200 <= resp.status < 300
except (urllib.error.URLError, OSError, ValueError):
ok = False
out = dict(backend)
out["reachable"] = ok
out["latency_ms"] = round((time.monotonic() - started) * 1000) if ok else None
return out
def cmd_localstack(_args: list[str]) -> dict:
"""Probe the local AI-stack backends from this host and return the
wiring recipes (provider type, base URL, env / admin-API examples).
Always LIVE — the probes do not involve the gateway at all: green
means that local daemon answered /v1/models on this machine. This
is the wiring aid for an all-local inference stack (ollama,
llama.cpp, koboldcpp, LM Studio, SGLang, vLLM).
"""
workers = []
threads = []
for backend in LOCAL_BACKENDS:
worker = {"backend": backend, "result": None}
workers.append(worker)
def run(b=backend, w=worker):
w["result"] = _probe_one(b)
thread = threading.Thread(target=run)
thread.start()
threads.append(thread)
for thread in threads:
thread.join(timeout=LOCAL_PROBE_TIMEOUT + 0.2)
backends = [w["result"] or {**w["backend"], "reachable": False, "latency_ms": None} for w in workers]
reachable = sum(1 for b in backends if b.get("reachable"))
token_note = "$KLANKER_ADMIN_TOKEN"
example_lines = [
f"curl -s {KLANKER_URL}/api/providers -H 'Authorization: Bearer {token_note}' "
"-H 'content-type: application/json' "
"-d '{\"id\":\"llama-server\",\"type\":\"openai-compatible\","
"\"baseUrl\":\"http://127.0.0.1:8081/v1\",\"enabled\":true,"
"\"models\":[\"qwen2.5-coder-7b\"]}'",
f"curl -s {KLANKER_URL}/api/providers -H 'Authorization: Bearer {token_note}' "
"-H 'content-type: application/json' "
"-d '{\"id\":\"koboldcpp\",\"type\":\"openai-compatible\","
"\"baseUrl\":\"http://127.0.0.1:5001/v1\",\"enabled\":true,"
"\"models\":[\"mistral-nemo-12b\"]}'",
"# auto-discover the model catalog after registering:",
f"curl -s -X POST {KLANKER_URL}/api/providers/llama-server/refresh-models "
f"-H 'Authorization: Bearer {token_note}'",
]
return {
"ok": True,
"source": "live",
"backends": backends,
"count": len(backends),
"reachable": reachable,
"base_url": KLANKER_URL,
"admin_register_example": "\n".join(example_lines),
"note": (
f"probed from this host (0.4s timeout each): {reachable}/{len(backends)} "
"local backends answered /v1/models. ollama + lmstudio register via env; "
"llama.cpp + koboldcpp + vllm register as openai-compatible accounts "
"(env registers ONE such account — use POST /api/providers for several). "
"Port note: llama-server defaults to :8080, the gateway's own port"
),
}
# ── dispatch table ───────────────────────────────────────────────────
COMMANDS = {
"status": lambda _args: cmd_status(_args),
"providers": lambda _args: _get("/api/providers"),
"models": lambda _args: _get("/v1/models"),
"vkeys": lambda _args: _get("/api/virtual-keys"),
"logs": cmd_logs,
"analytics": cmd_analytics,
"runtime": lambda _args: _get("/api/runtime"),
"service": cmd_service,
"journal": cmd_journal,
"localstack": cmd_localstack,
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

Binary file not shown.

View File

@ -295,6 +295,34 @@ def info(args: list[str]) -> dict[str, Any]:
return fn(args) if fn else {}
def _pkg_name_ok(pkg: str) -> bool:
"""v0.1.4 SECURITY: package names are passed to the system package
manager as one argv element. A leading dash turns them into manager
OPTIONS (pacman --config=…, dnf --setopt=…) and a URL makes dnf
fetch a remote RPM — argument injection, not shell injection. One
safe component: no leading dash, no whitespace/control chars, no
URL scheme, bounded length."""
return (
isinstance(pkg, str)
and 0 < len(pkg) <= 256
and not pkg.startswith("-")
and "://" not in pkg
and not re.search(r"[\s\x00\x1b]", pkg)
)
def _first_pkg_arg(args: list[str]) -> str | None:
"""v0.1.4 FIX: firewall.py's install-backend used to call this
helper as `packages.py install -- <pkgs…>` (a `--` argv separator,
shell convention) — install() read args[0] == '--' and the
backend-install path has been broken since it shipped. Skip any
leading '--' separators instead of choking on them."""
for a in args:
if a != "--":
return a
return None
def install(args: list[str]) -> dict[str, str]:
"""Install a package — actually runs the package manager via subprocess.
@ -315,7 +343,14 @@ def install(args: list[str]) -> dict[str, str]:
"""
if not args:
return {"error": "No package name provided"}
pkg = args[0]
pkg = _first_pkg_arg(args)
if not pkg:
return {"error": "No package name provided"}
if not _pkg_name_ok(pkg):
return {"error": f"invalid package name: {pkg!r}"}
# _pkg_name_ok already rejects leading-dash/URL names (argument
# injection), so no '--' end-of-options separator is needed here —
# pacman in particular does not accept one.
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "install", "-y", pkg],
"apt": ["apt", "install", "-y", pkg]}
@ -336,7 +371,11 @@ def remove(args: list[str]) -> dict[str, str]:
"""Remove a package — actually runs the package manager. See install()."""
if not args:
return {"error": "No package name provided"}
pkg = args[0]
pkg = _first_pkg_arg(args)
if not pkg:
return {"error": "No package name provided"}
if not _pkg_name_ok(pkg):
return {"error": f"invalid package name: {pkg!r}"}
cmd_map = {"pacman": ["pacman", "-R", "--noconfirm", pkg],
"dnf": ["dnf", "remove", "-y", pkg],
"apt": ["apt", "remove", "-y", pkg]}
@ -354,7 +393,11 @@ def update(args: list[str]) -> dict[str, str]:
"""Update a package — actually runs the package manager. See install()."""
if not args:
return {"error": "No package name provided"}
pkg = args[0]
pkg = _first_pkg_arg(args)
if not pkg:
return {"error": "No package name provided"}
if not _pkg_name_ok(pkg):
return {"error": f"invalid package name: {pkg!r}"}
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "upgrade", "-y", pkg],
"apt": ["apt", "upgrade", "-y", pkg]}

View File

@ -245,6 +245,28 @@ def cmd_acl_default(args: list[str]) -> dict[str, Any]:
CGROUP_ROOT = Path("/sys/fs/cgroup")
def _cgroup_path_ok(path: Path) -> bool:
"""v0.1.4 SECURITY: True if (resolved) path stays inside the unified
cgroup hierarchy. cmd_cgroup_create has always enforced a prefix
check, but the cgroup-* siblings didn't — cgroup-set wrote to
`<any-path>/<control-file>` as root (arbitrary file overwrite:
`cgroup-set /etc/cron.d x '* * * * * root curl ...'` was a one-prompt
persistent-root primitive; found by the 0.3.0 security audit). All
cgroup subcommands now resolve + bound-check the same way."""
try:
path.resolve().relative_to(CGROUP_ROOT.resolve())
return True
except (ValueError, RuntimeError, OSError):
return False
# v0.1.4 SECURITY: cgroup control files are a closed vocabulary (cgroup.*
# + controller knobs). Restricting the filename to this shape blocks
# using cgroup-set as an arbitrary-named file writer.
_CGROUP_CTRL_RE = re.compile(r"^(cgroup\.(procs|controllers|subtree_control|type|freeze|kill)|"
r"(memory|cpu|io|pids|rdma|misc|hugetlb)\.[A-Za-z0-9_.-]{1,32})$")
def _cgroup_v2_available() -> bool:
"""True if /sys/fs/cgroup/ is a cgroups v2 unified hierarchy."""
return (CGROUP_ROOT / "cgroup.controllers").is_file()
@ -315,6 +337,11 @@ def cmd_cgroup_show(args: list[str]) -> dict[str, Any]:
if not args:
return {"error": "cgroup path required"}
path = Path(args[0])
# v0.1.4 SECURITY: cgroup paths are client-supplied; every cgroup
# subcommand must keep them inside the unified hierarchy — see the
# _cgroup_path_ok guard comment below cmd_cgroup_create.
if not _cgroup_path_ok(path):
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
if not path.is_dir():
return {"error": f"{path} is not a directory"}
info: dict[str, Any] = {"path": str(path), "name": path.name}
@ -356,6 +383,8 @@ def cmd_cgroup_procs(args: list[str]) -> dict[str, Any]:
"""List PIDs in a cgroup (just the PIDs, no metadata)."""
if not args:
return {"error": "cgroup path required"}
if not _cgroup_path_ok(Path(args[0])):
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
procs_file = Path(args[0]) / "cgroup.procs"
if not procs_file.is_file():
return {"error": f"{procs_file} not found"}
@ -373,7 +402,9 @@ def cmd_cgroup_create(args: list[str]) -> dict[str, Any]:
path = Path(args[0])
if not _cgroup_v2_available():
return {"available": False, "reason": "cgroups v2 not mounted"}
if not str(path).startswith(str(CGROUP_ROOT)):
# v0.1.4 SECURITY: upgraded from a str().startswith() prefix check
# (which "/sys/fs/cgroup-evil" would satisfy) to resolve + bound.
if not _cgroup_path_ok(path):
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
try:
path.mkdir(parents=True, exist_ok=False)
@ -390,6 +421,8 @@ def cmd_cgroup_move(args: list[str]) -> dict[str, Any]:
if len(args) < 2:
return {"error": "usage: cgroup-move <pid> <cgroup-path>"}
pid, cgrp = args[0], args[1]
if not _cgroup_path_ok(Path(cgrp)):
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
procs_file = Path(cgrp) / "cgroup.procs"
if not procs_file.is_file():
return {"error": f"{procs_file} not found"}
@ -407,6 +440,16 @@ def cmd_cgroup_set(args: list[str]) -> dict[str, Any]:
if len(args) < 3:
return {"error": "usage: cgroup-set <path> <control-file> <value>"}
path, control, value = args[0], args[1], args[2]
# v0.1.4 SECURITY: this command writes as root. Both halves of the
# target are client-supplied, so BOTH are validated: the cgroup path
# must resolve under /sys/fs/cgroup (was missing — arbitrary root
# file overwrite, see _cgroup_path_ok) and the control file must be
# a real cgroup controller knob name, not a traversal/probe.
if not _cgroup_path_ok(Path(path)):
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
if not _CGROUP_CTRL_RE.match(control):
return {"error": f"'{control}' is not a cgroup control file name "
"(expected e.g. memory.max, cpu.weight, cgroup.procs)"}
# control is a filename like 'memory.max' or 'cpu.weight'
target = Path(path) / control
if not target.parent.is_dir():

View File

@ -292,10 +292,23 @@ def cmd_set(args: list[str]) -> dict[str, Any]:
"""Set one key in cockpit.conf.
Usage: set <section> <key> <value>. Creates the section if absent.
v0.1.4 SECURITY: section/key/value arrive as raw argv and are
serialized into /etc/cockpit/cockpit.conf with naive `f"{k} = {v}"
lines. A value containing a newline could inject whole new
sections/keys into cockpit.conf ([WebService]/[Session] knobs) the
next time cockpit parses it (found by the 0.3.0 security audit).
Newlines, NULs, brackets in section names and '=' in keys are now
rejected; write-config remains the operator's explicit raw editor.
"""
if len(args) < 3:
return {"error": "usage: set <section> <key> <value>"}
section, key, value = args[0], args[1], args[2]
if re.search(r"[\r\n\0]", section + key + value) or re.search(r"[\[\]]", section):
return {"error": "refusing to set: section/key/value must be single-line "
"(no newlines, no NULs; no brackets in section names)"}
if "=" in key:
return {"error": "refusing to set: key must not contain '='"}
text = _read_text()
sections = _parse_conf(text)
sections.setdefault(section, {})[key] = value

View File

@ -1,6 +1,6 @@
{
"_comment": "Compatibility Manifest — sysdeck v0.1.3",
"version": "0.2.0",
"version": "0.4.1",
"suite_requires": { "cockpit": ">=239", "python": ">=3.9" },
"modules": {
"containers": {

11
klanker-gate/.dockerignore Executable file
View File

@ -0,0 +1,11 @@
# Docker build context excludes (context root = frosty-deno).
# The Control UI is built inside the image (multi-stage Dockerfile), so host
# node_modules (Windows/Linux platform mismatch) and any prebuilt / host-locked
# dist must never enter the build context. Keeping these out also fixes the
# Windows "EPERM rm" failure that blocks a clean host `deno task build-ui`.
**/node_modules
apps/control-ui/dist
apps/control-ui/dist_new
data
*.log
.env

9
klanker-gate/.editorconfig Executable file
View File

@ -0,0 +1,9 @@
root = true
[*]
insert_final_newline = false
end_of_line = lf
charset = utf-8
[*.{js,jsx,ts,tsx,mjs,json,md,css,scss,html}]
insert_final_newline = false

328
klanker-gate/.env.example Executable file
View File

@ -0,0 +1,328 @@
# =============================================================================
# Frosty Deno - full configuration surface
# =============================================================================
# Every operator knob the gateway reads, grouped the same way as
# docs/reference/environment-variables.md so the two can be diffed. For the
# smallest config that boots, use `.env.example.dev` instead.
#
# cp .env.example .env
# docker compose up -d postgres # REQUIRED (section 4)
# deno task setup && deno task dev
#
# Conventions in this file:
# * A blank value means "leave the feature off" - it is never a placeholder to
# be filled in blindly. Secrets ship blank on purpose.
# * A value that IS filled in is either a real default or an inert format
# example (a URL shape, a model list), safe to copy as-is.
# * Anything absent from this file has a safe default. Adding a new knob means
# a bounded parse, a row in the reference doc above, and a line here.
#
# Sections
# 1 Provider credentials and provider catalogs
# 2 Azure OpenAI, Bedrock, and Vertex AI
# 3 Generic compatible endpoints
# 4 Core gateway and PostgreSQL <- required
# 5 Worker topology and shared governance
# 6 Admin protection and origin control
# 7 Cache and vector store
# 8 MCP and Code Mode
# 9 Logging, analytics display, and observability
# 10 Pricing sync, encryption, plugins, HTTP client
# 11 Not operator knobs (harness, migration, supervisor-set)
# =============================================================================
# 1. Provider credentials and provider catalogs
# =============================================================================
# Any subset. A provider registers itself at boot when its credential is set,
# and stays absent otherwise, so a blank key is a supported state rather than a
# broken one. Persisted config from /api/providers overlays these and WINS on an
# id collision.
# OpenAI-wire vendors.
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GEMINI_API_KEY=
OPENROUTER_API_KEY=
GROQ_API_KEY=
MISTRAL_API_KEY=
XAI_API_KEY=
PERPLEXITY_API_KEY=
CEREBRAS_API_KEY=
NEBIUS_API_KEY=
PARASAIL_API_KEY=
# Native adapters (their own wire formats, translated to canonical internally).
HF_TOKEN=
COHERE_API_KEY=
# Audio only: /v1/audio/speech and /v1/audio/transcriptions.
ELEVENLABS_API_KEY=
# Ollama needs no key; it registers on BASE_URL alone. MODELS is a
# comma-separated catalog, because Ollama's tag list is host-specific.
OLLAMA_BASE_URL=
OLLAMA_MODELS=
# =============================================================================
# 2. Azure OpenAI, Bedrock, and Vertex AI
# =============================================================================
# These three do not take a bare API key alone: each needs its own coordinates
# before a model name can be resolved to an endpoint.
# Azure routes per DEPLOYMENT, not per model, so the deployment list is what
# makes models addressable. Comma-separated.
AZURE_OPENAI_API_KEY=
AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com
AZURE_OPENAI_API_VERSION=2024-02-15-preview
AZURE_OPENAI_DEPLOYMENTS=gpt-4o-deployment,gpt-35-deployment
# AWS Bedrock (SigV4). SESSION_TOKEN only for temporary credentials.
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_SESSION_TOKEN=
BEDROCK_MODELS=
# Vertex AI. The service-account JSON goes in as ONE line, quotes intact.
VERTEX_PROJECT_ID=
VERTEX_LOCATION=us-central1
VERTEX_SERVICE_ACCOUNT_JSON=
VERTEX_MODELS=gemini-2.5-pro
# =============================================================================
# 3. Generic compatible endpoints
# =============================================================================
# Point Frosty at any OpenAI-wire or Anthropic-wire server. Each stays off until
# its BASE_URL is set; the API key is optional because many local servers take
# none.
# Any OpenAI-wire server (vLLM, llama.cpp, TGI, ...). Id: `openai-compatible`.
OPENAI_COMPAT_BASE_URL=
OPENAI_COMPAT_API_KEY=
OPENAI_COMPAT_DEFAULT_MODEL=
# Any Anthropic Messages-wire server. Id: `anthropic-compatible`.
ANTHROPIC_COMPAT_BASE_URL=
ANTHROPIC_COMPAT_API_KEY=
ANTHROPIC_COMPAT_DEFAULT_MODEL=
# LM Studio (local OpenAI-compatible server; its default base URL shown).
LMSTUDIO_BASE_URL=http://localhost:1234/v1
LMSTUDIO_API_KEY=
LMSTUDIO_DEFAULT_MODEL=
# =============================================================================
# 4. Core gateway and PostgreSQL
# =============================================================================
PORT=8080
# Provider account used for model names without a `provider/` prefix. Must match
# a registered id from section 1-3.
FROSTY_DEFAULT_PROVIDER=openai
# --- PostgreSQL: the gateway's ONE stateful dependency, and it is REQUIRED ----
# Holds the control-plane config, governance counters, the request-log trail,
# the L2 response cache, and the pgvector embedding index. There is no fallback:
# an unreachable or unset URL ABORTS BOOT rather than serving with empty
# governance state (decision-log 61). Deno KV, which used to hold this, is
# retired - migrate an existing data/frosty.kv with
# `deno task migrate:kv-pg -- --commit`.
#
# docker compose up -d postgres
#
# Credentials are required; the Compose service sets user/password/db to
# `frosty`. Use `localhost` from the host, `postgres` from inside Compose.
FROSTY_PG_URL=postgres://frosty:frosty@localhost:5432/frosty
# Session-stable connection used ONLY for LISTEN (cross-process cache
# invalidation). Defaults to FROSTY_PG_URL, which is correct until a pooler sits
# in between: LISTEN through PgBouncer's transaction mode stops delivering
# SILENTLY. With `--profile pgbouncer` up, point FROSTY_PG_URL at :6432 and
# leave this one on :5432.
FROSTY_PG_DIRECT_URL=
# Connections held PER PROCESS (default 8, max 100). The number PostgreSQL sees
# is this times FROSTY_WORKERS, plus one LISTEN connection per process.
FROSTY_PG_POOL_SIZE=
# Table holding the semantic cache's embedding vectors.
FROSTY_PG_TABLE=frosty_vectors
# =============================================================================
# 5. Worker topology and shared governance
# =============================================================================
# Worker processes sharing one port through SO_REUSEPORT. Unset or 1 = single
# process. LINUX/macOS ONLY - Windows has no SO_REUSEPORT and the second bind
# fails with os error 10048, so the gateway logs why and serves single-process
# instead. See docs/guides/multi-process.md.
FROSTY_WORKERS=
# Fleet-wide rate limiting. Fixed rate/token windows live in an in-process Map
# by default, which is exact for ONE process and admits N times the limit across
# N. `auto` (default) moves them to a shared PostgreSQL authority only when
# FROSTY_WORKERS>1, because that reservation costs ~1.8 ms per governed request
# versus ~1 us in memory. Set `on` when running separate REPLICAS (auto cannot
# see those); `off` accepts N-times-the-limit. auto|on|off
FROSTY_SHARED_RATE_LIMIT=
# How often each process re-reads durable config, in ms (0-3600000, 0 disables).
# Config changes normally arrive over LISTEN/NOTIFY within milliseconds; this
# poll is the backstop that bounds staleness when a notification is lost, so a
# revoked virtual key stops working even then. Default 30000.
FROSTY_CONFIG_RECONCILE_MS=
# =============================================================================
# 6. Admin protection and origin control
# =============================================================================
# Unset = explicit local admin mode (no auth on /api/*). Set to require
# "Authorization: Bearer <token>" on all /api/* config routes.
FROSTY_ADMIN_TOKEN=
# Admin origin guard allow-list (DNS-rebind defense). localhost, 127.0.0.1, and
# ::1 are always allowed; add your public host(s) here (comma-separated) when
# the gateway is reachable beyond localhost. Applies to every /api/* request.
FROSTY_ALLOWED_HOSTS=
# =============================================================================
# 7. Cache and vector store
# =============================================================================
# Unset = off, "exact" = exact-match, "semantic" adds embedding similarity via
# the embed model below (the provider must support embeddings).
FROSTY_CACHE=
FROSTY_CACHE_TTL_MS=
# Sent VERBATIM and case-sensitive, so it must match an id the provider serves
# (check GET /v1/models). Lookups are fail-open, so a wrong id reduces the cache
# to exact-match only; every failed lookup warns `semantic cache lookup degraded
# to miss`, and frosty_cache_events_total stays at 100% result="miss".
FROSTY_CACHE_EMBED_MODEL=text-embedding-3-small
# Similarity vectors live in-process unless FROSTY_VECTOR_STORE=pgvector puts
# them in the same PostgreSQL as everything else, which is also what makes them
# survive a restart. The Redis (RediSearch) option was removed: Compose
# provisioned it and no shipped configuration ever selected it, so it was a
# dependency that served zero requests (decision-log 60).
FROSTY_VECTOR_STORE=
# =============================================================================
# 8. MCP and Code Mode
# =============================================================================
# MCP client transports are configured per server via POST /api/mcp/clients
# ("transport": "http-sse" (default) | "streamable-http" | "auto" | "stdio").
# Servers that only speak streamable-http need an explicit transport (or "auto"
# for spec-order negotiation).
#
# stdio (subprocess) MCP servers are DISABLED by default. Enabling them requires
# BOTH this flag ("1" or "true") AND running with --allow-run, which is outside
# the standard permission set on purpose (see permissions.md).
FROSTY_MCP_ALLOW_STDIO=
# Background MCP health sweeps (0/unset = on-demand via /api/mcp/health only).
FROSTY_MCP_HEALTH_INTERVAL_MS=
# Code Mode is default-off behind TWO independent gates. The VFS metadata
# surface is live and inert; the sandboxed executor is HARD-OFF and
# experimental. off|on (default off). The executor additionally requires a
# per-request `x-frosty-code-mode: run` header and still refuses (501) because
# the run primitive is intentionally stubbed.
FROSTY_CODE_MODE=off
FROSTY_CODE_MODE_VFS=on
# =============================================================================
# 9. Logging, analytics display, and observability
# =============================================================================
# Durable request-log store, in the same PostgreSQL as the rest of the state. ON
# by default. Disable with FROSTY_LOG_STORE=off. The legacy value `kv` is still
# accepted and means "on" - it named the retired Deno KV backend, and rejecting
# it would break existing .env files over a backend that is gone.
FROSTY_LOG_STORE=pg
FROSTY_LOG_STORE_MAX=5000
# Paths kept OUT of the Logs dashboard trail (live stream + durable store). The
# container healthcheck and the Prometheus scrape hit the gateway on a fixed
# interval, so without this they accumulate until they are ~99% of the capped
# trail and real requests get pruned away. Console access logging is NOT
# affected: `docker logs` still shows every request. Blank uses the default
# below; set to `off` to log everything; `/prefix/*` matches a subtree.
FROSTY_LOG_EXCLUDE_PATHS=/healthz,/metrics,/favicon.ico
# Request/response CONTENT capture in the durable log store (OPT-IN; default OFF
# for privacy; secrets are never captured). on to enable.
FROSTY_LOG_CONTENT=
# Display currency: cost is accounted in USD internally; the Control UI presents
# euros by multiplying by this EUR-per-USD rate (default 0.92). Operator-only.
FROSTY_EUR_RATE=0.92
# OpenTelemetry OTLP/HTTP trace export. Unset = off. For Docker Compose use
# http://otel-collector:4318 and start `--profile observability`; spans go to
# Tempo for drill-down and to Prometheus as RED metrics.
OTEL_EXPORTER_OTLP_ENDPOINT=
OTEL_FLUSH_INTERVAL_MS=5000
# Distinct models admitted as the `frosty.metrics.model` span-metric label
# before the rest fold to "other". Bounds Prometheus series growth; traces keep
# the real model either way. Non-positive/unparseable falls back to the default.
FROSTY_OTEL_MODEL_CARDINALITY_CAP=11
# =============================================================================
# 10. Pricing sync, encryption, plugins, HTTP client
# =============================================================================
# LiteLLM pricing sync. Opt-in and DEFAULT OFF (offline/no-outbound default):
# set FROSTY_PRICING_SYNC=on to fetch model prices + metadata at boot and
# refresh on an interval. Operator /api/pricing overrides always win over synced
# prices. POST /api/pricing/force-sync triggers a sync on demand even when this
# is off.
FROSTY_PRICING_SYNC=
# Refresh cadence (ms); default 24h, floored at 60s.
FROSTY_PRICING_SYNC_INTERVAL_MS=86400000
# Source URL for the price list (server-side only; never taken from a request).
FROSTY_PRICING_URL=https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
# --- Config secret encryption-at-rest (OPT-IN; default OFF = plaintext) -------
# Set a base64-encoded 32-byte key (preferred) OR a strong passphrase (PBKDF2).
# When set, provider API keys / AWS secret+session / Vertex SA-JSON / proxy
# password / CA cert / virtual-key tokens / MCP header values are AES-256-GCM
# encrypted in PostgreSQL. Fail-closed: once a store has encrypted data, an
# unset or wrong key REFUSES boot. The key is effectively set-once (rotate via
# _OLD).
# Generate: `deno eval "console.log(btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))))"`
FROSTY_ENCRYPTION_KEY=
# Rotation only: set to the previous key alongside a new FROSTY_ENCRYPTION_KEY
# to rewrap the data-encryption key offline (data is not re-encrypted).
FROSTY_ENCRYPTION_KEY_OLD=
# JSON-repair plugin (OPT-IN; default OFF). Repairs invalid-JSON model output
# post-response and, for streams, post-completion via the reconstructed message
# (the live client stream is never mutated). on|1|true|yes to enable.
FROSTY_JSON_REPAIR=
# Request mocker: short-circuits upstream calls with synthetic responses, for
# offline dev/demo/load-testing. OPT-IN and OFF unless set to on|1|true|yes -
# leave it empty for a realistic deployment. Rules come from
# FROSTY_MOCKER_CONFIG (inline JSON starting with `{`, or a file path).
FROSTY_MOCKER=
FROSTY_MOCKER_CONFIG=
# Provider HTTP client: default per-request timeout in ms (default 120000; 0
# disables). Never total-caps an in-progress SSE/eventstream. FROSTY_NO_PROXY
# takes comma-separated bypass patterns (*, .example.com, *.example.com, or an
# exact host).
FROSTY_HTTP_TIMEOUT_MS=120000
FROSTY_NO_PROXY=
# =============================================================================
# 11. Not operator knobs
# =============================================================================
# Listed for parity with the code, so a reader who greps for one of these finds
# out why it is not above. Do NOT set these in a deployment .env.
#
# FROSTY_BASE_URL target for scripts/full_suite.ts and the browser
# harness; defaults to http://localhost:8080
# FROSTY_BENCH_TARGET scripts/load-bench.ts only
# FROSTY_BENCH_KEY scripts/load-bench.ts only
# FROSTY_BENCH_MODEL scripts/load-bench.ts only
# FROSTY_KV_PATH read ONLY by scripts/migrate_kv_to_pg.ts, to find an
# existing data/frosty.kv to migrate. It configures
# nothing at runtime; Deno KV is retired.
# FROSTY_WORKER_ROLE set BY the supervisor on each child it spawns
# FROSTY_WORKER_INDEX set BY the supervisor on each child it spawns

71
klanker-gate/.env.example.dev Executable file
View File

@ -0,0 +1,71 @@
# =============================================================================
# Frosty Deno - minimal local development configuration
# =============================================================================
# cp .env.example.dev .env
# docker compose up -d postgres # REQUIRED, see below
# deno task setup # one-time
# deno task dev # gateway on http://localhost:8080
#
# Everything omitted here has a safe default, so this is the smallest config
# that boots a useful gateway. For the full surface copy `.env.example` and read
# docs/reference/environment-variables.md.
# --- REQUIRED: PostgreSQL -----------------------------------------------------
# The gateway keeps ALL durable state here (control-plane config, governance
# counters, the request-log trail, the L2 response cache, the pgvector index)
# and refuses to boot without it - `FROSTY_PG_URL is required` (decision-log
# 61). There is no embedded fallback; Deno KV used to hold this and is retired.
#
# docker compose up -d postgres
#
# The Compose service sets user/password/db to `frosty`. Use `localhost` when
# the gateway runs on the host (`deno task dev`); inside Compose the gateway
# service already defaults itself to the `postgres` hostname.
FROSTY_PG_URL=postgres://frosty:frosty@localhost:5432/frosty
# --- REQUIRED: one provider + the default route -------------------------------
# The gateway boots with no key, but has nothing to route to. Set ONE of the
# blocks below and point FROSTY_DEFAULT_PROVIDER at its id.
# a) A hosted vendor.
OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# b) Any OpenAI-compatible server (vLLM, llama.cpp, LM Studio, TGI, ...).
# Registers as provider id `openai-compatible`.
# OPENAI_COMPAT_BASE_URL=http://localhost:8000/v1
# OPENAI_COMPAT_API_KEY=
# OPENAI_COMPAT_DEFAULT_MODEL=
# Provider used for model names without a `provider/` prefix. Must match an id
# above (`openai`, `anthropic`, `openai-compatible`, ...).
FROSTY_DEFAULT_PROVIDER=openai
# --- Gateway basics (optional; defaults shown) --------------------------------
PORT=8080
# Unset = admin API open on localhost, which is what local dev wants. Set it to
# require `Authorization: Bearer <token>` on every /api/* route.
FROSTY_ADMIN_TOKEN=
# --- OPTIONAL: response cache -------------------------------------------------
# Unset = off. "exact" needs nothing beyond the Postgres above. "semantic" also
# needs an embedding model, and FROSTY_VECTOR_STORE=pgvector to keep the vectors
# in that same database rather than in-process (so they survive a restart).
#
# FROSTY_CACHE=semantic
# FROSTY_VECTOR_STORE=pgvector
#
# One thing that fails SILENTLY here, because cache lookups are fail-open: an
# embed model id the provider does not serve 404s on every lookup. The id below
# is sent verbatim and is case-sensitive - check GET /v1/models. Boot logs
# `semantic cache lookup degraded to miss: ...` when it is wrong.
# FROSTY_CACHE_EMBED_MODEL=text-embedding-3-small
# --- OPTIONAL: metrics and traces ---------------------------------------------
# `GET /metrics` is always on and needs nothing. Traces need a collector:
#
# docker compose --profile observability up -d
#
# then set the endpoint below (it is deliberately not defaulted, because without
# that profile the host does not resolve). Grafana lands on http://localhost:3000.
# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318

21
klanker-gate/.gitattributes vendored Executable file
View File

@ -0,0 +1,21 @@
# Force LF line endings in the repository AND on checkout across every platform.
# `eol=lf` (not just `text=auto`) is what keeps Windows working trees from
# drifting to CRLF, which `deno fmt` rejects. This is the authoritative codebase
# line-ending rule; `.editorconfig` mirrors it for editors.
* text=auto eol=lf
# Shell scripts must always be LF (kept explicit for clarity).
*.sh text eol=lf
# Binary assets: never normalize EOL, never diff as text.
*.kv binary
*.kv-shm binary
*.kv-wal binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.webp binary
*.woff binary
*.woff2 binary

75
klanker-gate/.github/CODEOWNERS vendored Executable file
View File

@ -0,0 +1,75 @@
# CODEOWNERS - review ownership for Frosty Deno (klanker-gate)
#
# Each line maps a path pattern to one or more owners. The LAST matching pattern
# for a changed file wins. Owners are auto-requested for review on pull requests
# that touch their paths (requires the repository to be on GitHub and the owners
# to have write access).
#
# NOTE: the handles below are PLACEHOLDERS. Replace @frosty-maintainers and the
# team handles with the real GitHub users or teams for this repository before
# relying on auto-review. Do not leave placeholder handles in a live repo - an
# unresolvable owner silently disables review requests for that path.
# ---------------------------------------------------------------------------
# Default owner for everything not matched more specifically below.
# ---------------------------------------------------------------------------
* @frosty-maintainers
# ---------------------------------------------------------------------------
# Gateway composition root and HTTP surface (middleware onion, routing, context).
# ---------------------------------------------------------------------------
/apps/gateway/ @frosty-maintainers
/apps/gateway/main.ts @frosty-maintainers
/apps/gateway/context.ts @frosty-maintainers
# ---------------------------------------------------------------------------
# Control-plane SPA (design system ds-r2, same-origin, no external origins).
# ---------------------------------------------------------------------------
/apps/control-ui/ @frosty-maintainers
/apps/control-ui/src/styles/ @frosty-maintainers
/apps/control-ui/CONVENTIONS.md @frosty-maintainers
# ---------------------------------------------------------------------------
# Core pipeline: canonical translation, streaming, router, orchestrator.
# ---------------------------------------------------------------------------
/packages/core/ @frosty-maintainers
# Provider adapters (one family per vendor; wire translation).
/packages/providers/ @frosty-maintainers
# Governance: virtual keys, hierarchy, budgets, pricing, rate limiting.
/packages/governance/ @frosty-maintainers
# Cache, MCP + Code Mode, telemetry, config/secrets, plugins, contracts.
/packages/cache/ @frosty-maintainers
/packages/mcp/ @frosty-maintainers
/packages/telemetry/ @frosty-maintainers
/packages/config/ @frosty-maintainers
/packages/plugins/ @frosty-maintainers
/packages/contracts/ @frosty-maintainers
# ---------------------------------------------------------------------------
# Security-sensitive surfaces: crypto-at-rest, origin guard, admin auth,
# permission contract. Changes here warrant extra scrutiny.
# ---------------------------------------------------------------------------
/packages/config/src/crypto.ts @frosty-maintainers
/apps/gateway/routes/origin-guard.ts @frosty-maintainers
/apps/gateway/routes/admin.ts @frosty-maintainers
/permissions.md @frosty-maintainers
/SECURITY.md @frosty-maintainers
# ---------------------------------------------------------------------------
# Infrastructure, deployment and observability.
# ---------------------------------------------------------------------------
/Dockerfile @frosty-maintainers
/docker-compose.yml @frosty-maintainers
/deploy/ @frosty-maintainers
/.github/ @frosty-maintainers
# ---------------------------------------------------------------------------
# Documentation and decision records.
# ---------------------------------------------------------------------------
/docs/ @frosty-maintainers
/docs/contracts/decision-log.md @frosty-maintainers
/CLAUDE.md @frosty-maintainers
/AGENTS.md @frosty-maintainers

View File

@ -0,0 +1,129 @@
name: Bug report
description: Report a problem or regression in Bifrost
title: "[Bug]: <short summary>"
labels: [bug]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out a bug report! Please provide as much detail as possible.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I have searched existing issues and discussions to avoid duplicates
required: true
- label: I am using the latest version (or have tested against main/nightly)
required: false
- type: textarea
id: description
attributes:
label: Description
description: What happened? Include screenshots if helpful.
placeholder: Clear and concise description of the bug
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Provide a minimal, reproducible example. Link to a repo, gist, or include exact steps.
placeholder: |
1. Go to '...'
2. Run '...'
3. Observe '...'
validations:
required: true
- type: input
id: expected
attributes:
label: Expected behavior
placeholder: What did you expect to happen?
validations:
required: true
- type: input
id: actual
attributes:
label: Actual behavior
placeholder: What actually happened?
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area(s)
multiple: true
options:
- Core (Go)
- Framework
- Transports (HTTP)
- Plugins
- UI (Next.js)
- Docs
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: Affected version(s).
placeholder: e.g., v1.0.3
validations:
required: true
- type: textarea
id: env
attributes:
label: Environment
description: Include as many as apply.
placeholder: |
- OS: macOS 14.5, Linux x.y, Windows 11
- Go: 1.22.x
- Node: 20.x, npm/pnpm/yarn version
- Browser (if UI): Chrome/Firefox/Safari versions
- Bifrost components and versions (core, transports, ui)
- Any relevant environment flags/config
render: text
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant logs/output
description: Paste error logs, stack traces, or console output.
render: shell
placeholder: |
<paste logs here>
validations:
required: false
- type: input
id: regression
attributes:
label: Regression?
description: If this worked in a previous version, which version?
placeholder: e.g., Worked in v0.8.0, broke in v0.9.0
validations:
required: false
- type: dropdown
id: severity
attributes:
label: Severity
options:
- Low (minor issue or cosmetic)
- Medium (some functionality impaired)
- High (major functionality broken)
- Critical (blocks releases or production)
validations:
required: true

View File

@ -0,0 +1 @@
blank_issues_enabled: false

View File

@ -0,0 +1,43 @@
name: Documentation issue
description: Report missing, unclear, or incorrect documentation
title: "[Docs]: <short summary>"
labels: [documentation]
assignees: []
body:
- type: markdown
attributes:
value: |
Help us improve the docs! Please provide links and suggestions.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I have searched existing issues and docs to avoid duplicates
required: true
- type: input
id: page
attributes:
label: Affected page(s)
description: Provide the path or URL to the affected doc(s)
placeholder: docs/usage/providers.md or https://...
validations:
required: true
- type: textarea
id: issue
attributes:
label: What’s wrong or missing?
description: Be as specific as possible.
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested change
description: Propose wording or structure improvements.
validations:
required: false

View File

@ -0,0 +1,67 @@
name: Feature request
description: Suggest an idea or enhancement for Bifrost
title: "[Feature]: <short summary>"
labels: [enhancement]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for proposing a feature! Please fill out the details below.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I have searched existing issues and discussions to avoid duplicates
required: true
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What problem does this feature solve? Who benefits?
placeholder: Describe the problem clearly.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe your proposed API/UX/CLI. Include examples if helpful.
placeholder: Provide details about how this should work.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What other solutions or workarounds did you consider?
validations:
required: false
- type: dropdown
id: area
attributes:
label: Area(s)
multiple: true
options:
- Core (Go)
- Framework
- Transports (HTTP)
- Plugins
- UI (Next.js)
- Docs
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context, sketches, or references here.
validations:
required: false

73
klanker-gate/.github/pull_request_template.md vendored Executable file
View File

@ -0,0 +1,73 @@
## Summary
Briefly explain the purpose of this PR and the problem it solves.
## Changes
- What was changed and why
- Any notable design decisions or trade-offs
## Type of change
- [ ] Bug fix
- [ ] Feature
- [ ] Refactor
- [ ] Documentation
- [ ] Chore/CI
## Affected areas
- [ ] Gateway / core
- [ ] Providers/Integrations
- [ ] Config / persistence
- [ ] Plugins
- [ ] Control UI
- [ ] Docs / CI
## How to test
Describe the steps to validate this change. Include commands and expected
outcomes.
```sh
# Gateway (Deno) — run from repository root
deno fmt --check
deno lint
deno task check
deno task test
# Control UI (driven through Deno; no npm)
deno task setup # once: installs the whole workspace
deno task test-ui
deno task build-ui
```
If adding new configs or environment variables, document them here.
## Screenshots/Recordings
If UI changes, add before/after screenshots or short clips.
## Breaking changes
- [ ] Yes
- [ ] No
If yes, describe impact and migration instructions.
## Related issues
Link related issues and discussions. Example: Closes #123
## Security considerations
Note any security implications (auth, secrets, PII, sandboxing, etc.).
## Checklist
- [ ] I read `README.md` and followed the guidelines
- [ ] I added/updated tests where appropriate
- [ ] I updated documentation where needed
- [ ] I verified builds succeed (Deno gateway and control UI)
- [ ] I ran the full gate locally and pasted the commands and results below
(nothing runs it automatically - there is no CI in this repository)

49
klanker-gate/.gitignore vendored Executable file
View File

@ -0,0 +1,49 @@
.env
.vscode
.DS_Store
*_creds*
**/venv/
**/__pycache__/**
private.*
.venv
test-coverage-local.sh
.harness-state/
skillset-saves/
.playwright-mcp/
DENO_KB
# Temporary directories
**/temp/
node_modules
/dist
apps/control-ui/dist/
**/tmp/
temp*/
tmp/
tmp-*
private
# Sqlite DBs
*.db
*.db-shm
*.db-wal
# Test reports
test-reports
# Diagram render checks: throwaway PNG rasterizations used to eyeball an SVG in
# docs/assets/diagrams/ before committing it. Root-anchored so it cannot swallow
# a real asset under docs/.
/*-check.png
# Editor / assistant
.claude
.cursor/
# Build outputs
build/
data/
target/
# Coverage output (deno test --coverage)
cov_profile/

183
klanker-gate/AGENTS.md Executable file
View File

@ -0,0 +1,183 @@
# AGENTS.md - Technical Documentation and Agent Guidelines
This file consolidates technical documentation and development guidelines into a
single agent-readable brief. It is the primary reference for AI agents,
copilots, and developers working on Frosty Deno ("klanker-gate"). It is
intentionally consistent with [CLAUDE.md](CLAUDE.md); where deeper detail is
needed, it links into [docs/](docs/).
Frosty Deno is a clean-room Deno 2 + TypeScript rebuild of an LLM gateway
(reference: the retired Go implementation, Bifrost). One `Deno.serve` process
fronts 20+ model providers behind an OpenAI-compatible API, adds governance,
caching, MCP tooling and telemetry, and serves the React control-plane SPA
same-origin from the same port.
## Section 1: Persona and Role
- **Persona and Role:** Senior development architect. A proactive expert focused
on robust, secure, scalable Deno TypeScript.
- **Primary Goal:** Translate user requests into high-quality, production-ready
code that fits the existing structure.
- **Core Traits:** Analytical, systematic, supportive, solutions-oriented, a
clear communicator.
- **Core Expertise:** Full-stack implementation, architectural design, code
quality, and complex problem deconstruction.
## Section 2: Default Workflow
- **Step 1 - Build:** Default to building the complete, working solution, in one
cohesive, fully-commented change that matches surrounding code.
- **Step 2 - Fallback:** Only if a request is too large or ambiguous, propose a
concise Solution Design (stack, components, data flow), offer a step-by-step
plan, and stop for explicit approval.
- **Step 3 - Plan:** After approval, write the full plan as a single Markdown
document.
## Section 3: Guiding Principles (non-negotiable)
- **Security by Design:** Fail closed. Unknown hierarchy references deny; a
broken durable budget authority denies; a failed crypto boot refuses to start;
the Code Mode capability probe defaults to `false` on any error. Secrets never
reach the browser.
- **Architectural Integrity:** OpenAI's `chat.completion` /
`chat.completion.chunk` SSE is the single canonical wire format. Every
non-OpenAI surface is produced by translating that one canonical stream in
[packages/core/src/translate.ts](packages/core/src/translate.ts), never by a
parallel per-vendor pipeline.
- **Code Quality:** Strict TypeScript (`"strict": true`). Clean, idiomatic, DRY.
The control UI is Tailwind v4 with the `ds-r2` design system.
- **Clarity:** Comment the "why," not the "what." Money is integer micro-USD
everywhere in accounting - never floating point.
## Section 4: Project-Specific Code Patterns
- **Errors** always go through `GatewayError` / `errorResponse` and the
canonical `{error:{message,type,param,code}}` envelope. No ad-hoc JSON error
bodies.
- **Request schemas are `.passthrough()`** (`ChatCompletionRequest`,
`CompletionRequest`, `EmbeddingRequest`, `AnthropicMessagesRequest`) so
unknown vendor fields survive to provider egress. Response schemas stay
strict.
- **Streaming is Web Streams end to end.** A passive tee
(`withStreamCompletion` + `StreamAccumulator`) reconstructs the assistant
message for plugins, cost and logging while byte-identical bytes reach the
client. Never buffer a client stream to inspect it.
- **Providers** implement `IProviderAdapter`
([packages/providers/src/types.ts](packages/providers/src/types.ts)); only
`chatCompletions` is required. `dispatchWithFallback` reroutes only on
429/5xx/network `TypeError`, never on a client abort or a 4xx.
## Section 5: Quality Assurance (pre-response check)
Before providing code, verify: Goal Alignment, Code Integrity (compiles, fits
the seam), Clarity, Assumption Handling (state assumptions), and a Security
Review (fail-closed, secret handling, permission surface).
## Section 6: Documentation Overview
- [docs/index.md](docs/index.md) - the documentation landing page.
- [docs/getting-started/](docs/getting-started/) - install, configure, local
dev.
- [docs/guides/](docs/guides/) - deploying-to-production, setting-up-monitoring,
run-tests, and development-planning.
- [docs/concepts/](docs/concepts/) - architectural-overview (embeds the three
SVG diagrams), security-model, and
[functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md)
(the authoritative capability inventory).
- [docs/design/ui-design.md](docs/design/ui-design.md) - the UI design system,
components, screens and flows.
- [docs/reference/](docs/reference/) - api-endpoints, data-model,
environment-variables, commands-scripts, dependencies, docker-reference, and
[sbom.md](docs/reference/sbom.md) plus the machine-readable
[sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json).
- [docs/assets/diagrams/](docs/assets/diagrams/) - the canonical
[logic-flow.svg](docs/assets/diagrams/logic-flow.svg),
[data-flow.svg](docs/assets/diagrams/data-flow.svg) and
[resource-flow.svg](docs/assets/diagrams/resource-flow.svg).
- [TODO.md](TODO.md) - the register of known follow-ups and accepted risks, and
the replacement for the numbered decision log retired on 2026-07-30. Check it
before changing behavior. Beware provenance numbers cited in code: several
early deferrals (Bedrock streaming, GenAI/Cohere compat streaming, stdio MCP,
Code Mode) have since shipped, so an early item is not proof a feature is
still missing.
## Section 7: Rules and Guidelines
- **Branching:** `feature/...` and `bugfix/...`. **Commits:** Conventional
Commits (e.g. `fix(gateway): ...`). **PRs:** fill the template and link
issues.
- **The gate:** `deno fmt --check`, `deno lint`, `deno task check`,
`deno task test`, plus `deno task check-ui` / `test-ui` / `build-ui` for the
UI. No automation runs it - there is no CI workflow in this repository, so
every step is the author's responsibility.
- **`deno task test` ignores `apps/control-ui`, `tests/browser` and
`tests/live`.** The live vector-store suite drives `docker compose` itself and
is reached only through `deno task test:live`, which needs a running Docker
daemon.
- **Definition of done:** tests in the matching suite prove the behavior (a
fixed bug gets a regression test that fails on the old code), the full gate is
green, the exact commands you ran are recorded as evidence, and docs moved
with the code. Missing evidence is treated as incomplete, not implied success.
- Performance work is measure-first: a win inside measurement noise is rejected.
## Section 8: File and Folder Structure
- `apps/gateway/` - the composition root ([context.ts](apps/gateway/context.ts))
and the HTTP surface. The middleware onion in [main.ts](apps/gateway/main.ts)
is load-bearing and ordered on purpose.
- `apps/control-ui/` - the React + Vite + TypeScript control plane (built to
`dist/` and served same-origin). Binding contract:
[apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md).
- `packages/*` - plain directories imported by relative path (no manifests).
Flow:
`contracts -> core/providers/governance/cache/mcp/config -> apps/gateway`.
`packages/auth/` is reserved and currently empty.
- `deploy/`, `Dockerfile`, `docker-compose.yml` - infrastructure. There is no
Kubernetes/Helm packaging (decision-log item 55).
- `tests/` - contract, e2e and integration run in `deno task test`; `live/`
(Docker-backed vector stores) and the separate `browser/` Playwright harness
are both outside it and have their own tasks.
## Section 9: SDKs and Dependencies
- **Runtime:** Deno 2.9.x. JSR: `@std/assert`, `@std/http`, `@std/path`. npm via
Deno specifiers: `zod@4` (validation), `postgres@3` (pgvector cache), No npm
CLI - the UI builds through Deno `npm:` specifiers.
- **Control UI:** React 19 + React DOM 19, Vite 8, TypeScript 7, Vitest 4,
Tailwind CSS 4, `lucide-react`, `clsx`, `tailwind-merge`.
- See [docs/reference/sbom.md](docs/reference/sbom.md) for the complete
component inventory and
[docs/reference/dependencies.md](docs/reference/dependencies.md) for
rationale.
## Section 10: Configuration
- Config is env-first, then overlaid by persisted PostgreSQL config; **persisted
wins on id collision**. `dev` / `start` load `.env` via `--env-file`; the
container does not (Compose supplies the process env).
- Every subsystem (cache, OTel, log store, pricing sync, Code Mode, encryption)
is an env-gated field on `AppContext`. [.env.example](.env.example) documents
the 76 checked-in gateway knobs; the full list and exact parse behavior live
in
[docs/reference/environment-variables.md](docs/reference/environment-variables.md).
- The Deno permission flag set is part of the contract:
`--unstable-net --unstable-worker-options --allow-net --allow-env --allow-read --allow-write=data`.
See [permissions.md](permissions.md).
## Section 11: Core Components and Logic
The request lifecycle is: client -> alias rewrite -> `errorHandler` -> plugin
transport hooks -> request logger -> metrics -> admin origin guard -> admin
token auth -> governance admission -> innermost telemetry -> router -> route
handler -> zod validation -> semantic cache lookup -> provider resolve ->
dispatch with narrow fallback -> streaming tee -> edge translation -> response,
then an unwind that bills usage (except on cache hits) and emits metrics and
spans. Provider credentials and config live in PostgreSQL, optionally
AES-256-GCM encrypted at rest. Background work (MCP health sweep, pricing sync,
OTel flush, durable counter sinks) runs strictly off the request path.
The canonical visual representations are
[logic-flow.svg](docs/assets/diagrams/logic-flow.svg),
[data-flow.svg](docs/assets/diagrams/data-flow.svg), and
[resource-flow.svg](docs/assets/diagrams/resource-flow.svg), explained in
[docs/concepts/architectural-overview.md](docs/concepts/architectural-overview.md).

View File

@ -0,0 +1,41 @@
# Attribution
## Upstream project
This tree is a **vendored, unmodified copy** of **klanker-gate** — the
"Frosty Deno" LLM gateway — distributed inside the SysDeck master
tarball.
| Field | Value |
|---------------|------------------------------------------------------|
| **Project** | klanker-gate (Frosty Deno LLM Gateway) |
| **Author** | **TykoDev** |
| **Source** | https://github.com/TykoDev/klanker-gate |
| **License** | Apache-2.0 (full text: [`LICENSE`](LICENSE)) |
| **Version** | 0.9.0 (independent from SysDeck's version) |
**klanker-gate is NOT SysDeck code.** All credit for the gateway —
the Deno 2 + TypeScript OpenAI-compatible API surface, provider
management, virtual keys, governance, caching, MCP integration, and
the same-origin React control plane — belongs to TykoDev.
## What SysDeck added
SysDeck's integration work is **additive only** — the upstream source
required zero changes (the "port" to Linux was packaging, not code):
- `arch/` — Arch Linux packaging (PKGBUILD, hardened systemd unit,
sysusers/tmpfiles, run wrapper, `INSTALL-ARCH.md` runbook), written
by the SysDeck project for the SysDeck master tarball.
- Outside this tree, SysDeck ships two *clients* of the gateway
(they contain no upstream code): `sysdeck-klanker` — a Cockpit
panel + Python bridge helper — and the Web Edition "AI Gateway"
panel, which talk to the gateway over its REST API.
Everything else in this tree is upstream klanker-gate code by
TykoDev, redistributed under the Apache-2.0 license, which permits
redistribution in source form provided the license and copyright
notices are retained (they are — see `LICENSE`).
Upstream releases, issues, and development happen at
https://github.com/TykoDev/klanker-gate.

34
klanker-gate/CHANGELOG.md Executable file
View File

@ -0,0 +1,34 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- A complete Diataxis-oriented documentation set under [docs/](docs/), including
tutorials, guides, concepts, design reference, technical reference, and
standalone SVG architecture diagrams.
- A human-readable and machine-readable SBOM generated from the checked-in
manifests, lockfile, and Docker assets.
- A repository-local SBOM generation script at `scripts/generate_sbom.ts`.
### Changed
- Root documentation was aligned with the current PostgreSQL-backed
architecture, same-origin control-plane flow, and shipped observability stack.
- Contributor-facing documentation now points at the new documentation index and
the checked-in validation and SBOM workflows.
### Security
- Security documentation now points directly at the current security model,
SBOM, and private-reporting workflow.
## Historical note
The repository does not currently expose a tag-based release history. Earlier
release entries are therefore not reconstructed here from commit names alone,
because that would require guessing at version boundaries and release dates.

251
klanker-gate/CLAUDE.md Executable file
View File

@ -0,0 +1,251 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with
code in this repository.
## What this is
Frosty Deno ("klanker-gate") is a clean-room Deno 2 + TypeScript rebuild of an
LLM gateway (reference: the retired Go implementation, Bifrost). One
`Deno.serve` process fronts 20+ model providers behind an OpenAI-compatible API,
adds governance / caching / MCP tooling / telemetry, and serves the React
control-plane SPA **same-origin** from the same port.
## Commands
Run everything from the repo root. There is no `npm`/`node` toolchain — the
control UI is driven through Deno (`deno run -A npm:vite`, `npm:vitest`,
`npm:typescript`). Do not reintroduce an npm CLI step.
```bash
deno task setup # one-time bootstrap (deno install --allow-scripts=npm:esbuild)
deno task dev # gateway on :8080 with --watch, loads .env
deno task start # same without --watch
# The full gate - run it yourself; nothing runs it for you:
deno task test:all # every stage below, in order, one verdict
# (+ live and browser stages; skips are reported, never silent)
# Or the same checks one at a time:
deno fmt --check
deno lint
deno task check # backend typecheck (deno check)
deno task test # unit + contract + integration + e2e (ignores control-ui, tests/browser, tests/live)
deno task check-ui # control-ui tsc --noEmit
deno task test-ui # control-ui vitest (jsdom)
deno task build-ui # control-ui production build -> apps/control-ui/dist
```
Single test / filter — pass the same unstable flags the task does:
```bash
deno test --unstable-net --unstable-worker-options -A tests/integration/cache_test.ts
deno test --unstable-net -A --filter "fallback" packages/providers/
```
Other suites: `deno task test:e2e`, `deno task test:live` (drives
`docker compose up -d --wait postgres` itself, so it needs a running Docker
daemon), `deno task test:load` (`scripts/load-bench.ts`), `deno task bench` (23
micro-benchmarks in six `*_bench.ts` files colocated next to the source they
measure). The two performance harnesses answer different questions: `test:load`
measures end-to-end request cost, `bench` isolates a single pure hot-path
function, which is what decision-log 45's measure-first rule needs. Neither is
part of `test:all`; both are recorded in
[docs/benchmark-report.md](docs/benchmark-report.md) (decision-log 78).
`tests/browser/` is a Node/Playwright harness with its own `package.json`,
outside `deno task test` — it is the `browser` stage of `deno task test:all` and
needs a running gateway.
The `test` task carries `--ignore=apps/control-ui,tests/browser,tests/live`.
`--ignore` _replaces_ the `test.exclude` list in `deno.jsonc` rather than adding
to it, which is why all three are repeated in the task string; and a
config-level exclude would also filter the explicit path `test:live` passes, so
`tests/live` can only be dropped from the gate at the task level.
The gateway runs API-only until `deno task build-ui` has produced
`apps/control-ui/dist`; boot logs say which mode you are in.
## Architecture
Read
[docs/concepts/architectural-overview.md](docs/concepts/architectural-overview.md)
for the full picture with diagrams. The parts that matter before you edit:
**OpenAI's wire format is the lingua franca.** Internally every response is a
canonical `chat.completion` / `chat.completion.chunk` SSE stream ending in
`data: [DONE]`. Provider adapters translate _inbound_ to canonical; every
non-OpenAI surface the gateway exposes (Anthropic Messages, OpenAI Responses,
Google GenAI, Cohere v2, legacy completions) is produced by translating the
canonical stream in `packages/core/src/translate.ts` — never by a second
parallel pipeline per vendor. This is what keeps provider count × surface count
from multiplying.
**The middleware onion order in [apps/gateway/main.ts](apps/gateway/main.ts) is
load-bearing.** The order is recorded here, not in code comments - long
rationale lives in the register and code carries JSDoc plus short notes only
(the rule retired decision-log item 68 stated). Outermost `errorHandler` (so
even plugin-hook failures return the canonical envelope) → plugin transport
hooks → compat prefix rewrite → request logger → metrics → admin origin guard →
admin token auth → governance → telemetry (innermost, so usage is captured even
with zero virtual keys) → router → SPA fallback. Alias prefixes (`/openai`,
`/anthropic`, `/litellm`, `/langchain`, `/pydanticai`) are pure URL rewrites
applied _before_ auth/governance so aliased paths are admitted identically to
`/v1/*`.
**`AppContext` ([apps/gateway/context.ts](apps/gateway/context.ts)) is the
composition root.** `createContext()` is env-only and is what unit tests use;
`createDefaultContext()` is the production path — it opens **PostgreSQL and
refuses to start without it**, attaches optional encryption-at-rest, seeds
providers from env then overlays persisted config (**persisted wins on id
collision**), and wires durable counter sinks. Deno KV was retired for it
(decision-log 57): everything durable now lives in one Postgres, reached through
the `StateStore` seam in `packages/config/src/store.ts` — `PostgresStateStore`
in production, `MemoryStateStore` in tests, with one shared contract
(`store_contract.ts`) run against both so they cannot drift. Nearly every
optional subsystem (cache, OTel, log store, pricing sync, Code Mode) is an
env-gated field on this object.
**Package layout.** `packages/*` are plain directories with **no manifests** —
they are imported by relative path (`../../contracts/src/mod.ts`), not by a
workspace alias. Only `apps/control-ui` is a Deno workspace member. The ten
directories are `cache`, `config`, `contracts`, `core`, `governance`, `mcp`,
`plugins`, `providers`, `telemetry`, `testing`. Only `contracts`, `core`,
`providers`, and `testing` have `src/mod.ts` barrels; the rest are imported
file-by-file. Dependency flow is
`contracts → core/providers/governance/cache/mcp/config → apps/gateway`. A
change that wants to cross a package boundary usually means the seam is wrong —
`mcp` must not import from `core`'s dependents, and `providers` deliberately has
no `governance` import (the budget guard is a structural interface satisfied by
`ProviderBudgetTracker`, wired in `context.ts`).
**Providers.** One adapter per vendor family implementing `IProviderAdapter`
([packages/providers/src/types.ts](packages/providers/src/types.ts)) — only
`chatCompletions` is required; everything else (`completions`, `embeddings`,
`listModels`, `generateImage`, `rawProxy`, `countTokens`) is optional and its
absence has a defined fallback. `ProviderManager.resolve()` maps
`provider/model` or a bare name to an account; `resolveChain()` adds
request-level `fallbacks` plus the pool; `dispatchWithFallback` reroutes only on
429 / 5xx / network `TypeError` — never on a client abort or a 4xx.
**Streaming** is Web Streams end to end. A passive tee (`withStreamCompletion` +
`StreamAccumulator`) reconstructs the assistant message for plugins, cost, and
logging while byte-identical bytes still reach the client. Never buffer a client
stream to inspect it. The plugin stream-complete tap runs on the **canonical**
stream, before edge translation.
## Conventions that will bite you
- **Errors** always go through `GatewayError` / `errorResponse` and the
canonical `{error:{message,type,param,code}}` envelope. No ad-hoc JSON error
bodies.
- **Request schemas are `.passthrough()`** (`ChatCompletionRequest`,
`CompletionRequest`, `EmbeddingRequest`, `AnthropicMessagesRequest`) so
unknown vendor fields survive to provider egress. Do not "tidy" this into
`.strict()`. Everything else uses bare `z.object()`, which _strips_ unknown
keys rather than rejecting them. The two `.strict()` schemas in the contracts
package are `GatewayConfigSchema` (`config.ts`) and `ModelPriceSchema`
(`pricing.ts`), for the same reason (decision-log 46): both are whole-object
replaces of persisted operator data, where silent key-stripping wipes fields
the operator never meant to clear. The pricing one has a deliberate non-strict
twin, `PersistedModelPriceSchema`, so a catalog written by a newer gateway
still loads on an older one. Two response families are deliberately
`.passthrough()` because their vendor payloads vary: `TranscriptionResponse`
(`audio.ts`) and the file/batch family (`file_batch.ts`).
- **Money is integer micro-USD** everywhere in accounting. No floating-point
accumulation.
- **Fail closed.** Unknown hierarchy references deny; a broken durable budget
authority denies; a failed crypto boot refuses to start; the Code Mode
capability probe defaults to `false` on any error.
- **The Deno permission flag set is part of the contract**:
`--unstable-net --unstable-worker-options --allow-net --allow-env --allow-read --allow-write=data`.
Needing more is a design escalation — see [permissions.md](permissions.md).
`--unstable-worker-options` _narrows_ (it lets the Code Mode worker spawn with
everything denied); it grants the process nothing. `--allow-run` is opt-in for
stdio MCP only and is kept solely in the `test` task for a fixture.
- **Secrets never reach the browser.** The admin API returns redacted views with
`hasX` presence markers. Gateway `PUT` is a shallow top-level merge, so a
nested group you send _replaces_ the stored group — diff and send only changed
groups.
- **Code Mode is default-off** (`FROSTY_CODE_MODE`) behind two independent
gates. The VFS metadata surface is live and inert; the executor requires both
the app gate and a passing boot probe.
- New config knobs need an env var with a bounded parse, a row in
[docs/reference/environment-variables.md](docs/reference/environment-variables.md),
and `.env.example` coverage.
- **Comments are JSDoc plus short notes.** Public API gets JSDoc (editors
surface it); a non-obvious constraint gets a note under four lines at the
point of use. Long rationale goes in [TODO.md](TODO.md), which is the register
now that the numbered decision log is retired - a duplicated explanation in
code drifts and then misleads. Cite it by **stable key**
(`TODO.md D-REBUILD-HEADERS`), never by item number: the numbered items are
reading order and renumber as items close, which is exactly how the previous
scheme became uncitable. Add a keyed entry only when the constraint genuinely
will not fit in a note at the point of use. Test files are exempt: a comment
explaining why an assertion exists has no other home.
- Gateway-specific request/response headers are `x-frosty-*`
(`confirm-side-effects`, `mcp-tools`, `cache`, `cache-type`, `code-mode`,
`virtual-key`, `responses-passthrough`).
## Before you change behavior
The numbered decision log that recorded every deliberate divergence from the Go
original was **retired on 2026-07-30**; [TODO.md](TODO.md) is the register in
its place, and item 1 there carries the consequences. Item numbers still cited
in code and below are provenance only and resolve to nothing on disk. Still-live
"missing on purpose" items: **serving** a cache hit as a stream (cache _reads_
are non-streaming; completed streams _are_ stored via the passive tee),
Bedrock-native ingress under the aggregator prefixes (explicit 501 stubs), and
Kubernetes/Helm packaging (item 55). Do **not** assume the older deferrals still
hold — Bedrock streaming, GenAI/Cohere compat streaming, stdio MCP, Code Mode,
and **multi-replica deployment** all started as deferrals and have since
shipped, each with a follow-up entry. Multi-process serving in particular is
live (`FROSTY_WORKERS`, decision-log 62/70/71): budgets and rate-limit windows
are fleet-wide through PostgreSQL, and the residual per-process gap is named in
item 73. Reopening a decision is fine, but do it explicitly, and record any new
divergence as a [TODO.md](TODO.md) item with its mechanism, evidence, "done
means" and reopen trigger.
Definition of done per
[docs/guides/development-planning.md](docs/guides/development-planning.md):
tests in the matching suite prove the behavior (a fixed bug gets a regression
test that fails on the old code), the full gate is green, **the exact commands
you ran are recorded as evidence** (missing evidence is treated as incomplete,
not implied success), and docs moved with the code. Nothing enforces the gate
automatically — there is no CI workflow in this repository, so running it and
reporting the result honestly is entirely on the author.
Performance work is measure-first: a win inside measurement noise is rejected
and reverted (see decision-log item 45 and
[docs/benchmark-report.md](docs/benchmark-report.md)).
## Control UI
[apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) is the binding
contract for the SPA — design system `ds-r2`, tokens in `src/styles/tokens.css`,
`PageHeader` on every view, `DataTable` for every resource list, hash router
keyed off the first segment, and all transport through `src/api.ts` (no `fetch`
in views). Hard taste rules there include **zero em/en dashes anywhere** (plain
hyphen only), one cool-blue accent, lucide-react icons only, and same-origin
only — no external CDN/font/script origins.
## Related
`AGENTS.md` holds the shorter agent-facing brief. `docs/` is the deep reference:
[getting-started/](docs/getting-started/), [guides/](docs/guides/),
[reference/](docs/reference/), [concepts/](docs/concepts/),
[design/](docs/design/).
Four pages carry more ground truth than the rest and are worth reading before a
non-trivial change:
- [docs/concepts/functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md)
— the authoritative capability inventory, including a "gaps and partial
implementations" table and a list of stale claims found in older docs.
- [TODO.md](TODO.md) - every accepted risk and known follow-up with the
constraint holding it and the trigger that reopens it, since the separate
open-risks register was retired.
- [docs/reference/sbom.md](docs/reference/sbom.md) plus
[sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json) — the
component-level bill of materials.
- [docs/assets/diagrams/](docs/assets/diagrams/) — the canonical
`logic-flow.svg`, `data-flow.svg`, `resource-flow.svg`.

129
klanker-gate/CODE_OF_CONDUCT.md Executable file
View File

@ -0,0 +1,129 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
akshay@getmaxim.ai. All complaints will be reviewed and investigated promptly
and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by
[Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

71
klanker-gate/CONDUCT.md Executable file
View File

@ -0,0 +1,71 @@
# Conduct and Contribution Quickstart
This file collects the practical contribution and collaboration rules for Frosty
Deno. Community behavior expectations still apply through the separate
[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
## How you can contribute
- Report bugs through the forms in
[.github/ISSUE_TEMPLATE/](.github/ISSUE_TEMPLATE/).
- Suggest improvements or new capabilities through issues before implementing
large behavioral changes.
- Improve the documentation in [docs/](docs/), especially when code and docs
drift.
- Contribute code for provider adapters, governance, caching, observability,
MCP, control-plane UI work, tests, or deployment hardening.
## Development setup
Detailed tutorials live under [docs/getting-started/](docs/getting-started/).
The shortest verified setup is:
```bash
git clone <your-fork-url> klanker-gate
cd klanker-gate
deno task setup
cp .env.example .env
docker compose up -d postgres
deno task dev
```
Important repo-specific notes:
- Deno 2.9.x is the primary toolchain.
- The gateway and control UI are driven through Deno, not the npm CLI.
- PostgreSQL is a hard dependency on the production bootstrap path.
- `tests/browser` is a separate Node-based Playwright harness and is the only
area that expects `npx`.
## Submission guidelines
- Branches should follow `feature/<name>` or `bugfix/<name>`.
- Commits should follow
[Conventional Commits](https://www.conventionalcommits.org/).
- Pull requests should link their issue when applicable and use the checked-in
[pull request template](.github/pull_request_template.md).
- Run the validation gate locally before opening the PR:
```bash
deno fmt --check
deno lint
deno task check
deno task test
deno task check-ui
deno task test-ui
deno task build-ui
```
- If the change affects dependencies, regenerate the SBOM with
`deno run -A scripts/generate_sbom.ts`.
- If the change affects public or operator-facing behavior, update the relevant
documentation under [docs/](docs/).
## Coding standards
- Preserve the canonical error envelope and shared request/response contracts.
- Preserve the load-bearing middleware order unless the change explicitly
requires a routing or security move.
- Keep same-origin control-plane assumptions intact.
- Follow [apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) for UI
work.

82
klanker-gate/CONTRIBUTING.md Executable file
View File

@ -0,0 +1,82 @@
# Contributing to Frosty Deno
Thank you for considering a contribution. Frosty Deno is a clean-room Deno 2 +
TypeScript LLM gateway, and it improves fastest when fixes, provider additions,
UI refinements and documentation all come from people who use it. Whether you
are fixing a typo, adding a provider adapter, or hardening the governance layer,
your work is welcome and valued.
## Ways to contribute
- **Report bugs** and **request features** through GitHub Issues using the issue
forms in [.github/ISSUE_TEMPLATE/](.github/ISSUE_TEMPLATE/).
- **Improve the docs** in [docs/](docs/) - they move with the code, so a
docs-only PR that corrects a stale claim is a real contribution.
- **Write code**: pick up an issue labelled `good first issue`, or open an issue
first for anything that changes behavior so the approach can be agreed.
## Development setup
Full instructions live in [docs/getting-started/](docs/getting-started/) and
[docs/index.md](docs/index.md). The short version:
```bash
git clone <your-fork-url> klanker-gate
cd klanker-gate
deno task setup # one-time bootstrap (installs esbuild for the UI build)
cp .env.example .env # then set at least one provider key
deno task dev # gateway on http://localhost:8080 (loads .env)
```
There is no `npm` / `node` toolchain. The control UI is driven entirely through
Deno (`deno task dev-ui`, `deno task build-ui`, `deno task test-ui`). Do not
reintroduce an npm CLI step.
## Before you open a pull request
Run the full gate yourself - there is no CI in this repository, so nothing runs
it for you and nothing blocks a pull request that skips it:
```bash
deno fmt --check
deno lint
deno task check # backend typecheck
deno task test # unit + contract + integration + e2e
deno task check-ui # control-UI typecheck
deno task test-ui # control-UI tests
deno task build-ui # control-UI production build
```
- **Tests prove the behavior.** A fixed bug must come with a regression test
that fails on the old code. New behavior needs tests in the matching suite.
- **Record your evidence.** Paste the exact commands you ran and their result in
the PR. Missing evidence is treated as incomplete, not implied success.
- **Docs move with the code.** If you change a knob, endpoint or behavior,
update the relevant file under [docs/reference/](docs/reference/) (and
[.env.example](.env.example) for a new env var). If the change affects setup
or operations, update the matching tutorial or guide under [docs/](docs/).
- **Check the register.** Deliberate divergences and accepted risks are recorded
in [TODO.md](TODO.md), which replaced the numbered decision log retired on
2026-07-30. If your change reopens one, say so, and record a new divergence
there with its mechanism, evidence, "done means" and reopen trigger.
## Submission guidelines
- **Branches:** `feature/<short-name>` or `bugfix/<short-name>`.
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/), for
example `fix(gateway): reject empty Azure api-version` or
`feat(providers): add <vendor> adapter`.
- **Pull requests:** fill out the
[pull request template](.github/pull_request_template.md), link the issue it
closes, and keep the change focused.
- **Coding standards:** `deno fmt` and `deno lint` are the source of truth for
style. The control UI additionally follows
[apps/control-ui/CONVENTIONS.md](apps/control-ui/CONVENTIONS.md) (design
system `ds-r2`, `lucide-react` icons only, same-origin only, plain hyphens -
no em or en dashes).
## Code of conduct
By participating you agree to uphold the standards in [CONDUCT.md](CONDUCT.md).
See also [AGENTS.md](AGENTS.md) for the deeper technical and agent-facing brief.

57
klanker-gate/Dockerfile Executable file
View File

@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1
# --- Stage 1: build the Control UI bundle (Vite/React/TS) INSIDE the image ---
# The runtime no longer depends on a prebuilt apps/control-ui/dist on the host
# (which, on Windows, can be locked by Defender / Docker file-sharing and block
# `deno task build-ui`). The builder is the glibc (Debian) Deno image, thrown away
# after emitting dist/ - glibc dodges the musl/rollup/oxide native-binding edge
# cases, and the emitted bundle is static, so the runtime still runs deno:alpine.
# There is no npm in the repo: the UI builds THROUGH Deno via npm: specifiers.
FROM denoland/deno:2.9.3 AS ui-builder
WORKDIR /app
# Dep-install layer: cache on the workspace root config + lockfile + the member
# manifest only. `deno task setup` == `deno install --allow-scripts=npm:esbuild`;
# esbuild's postinstall is required or the Vite build cannot start.
COPY deno.jsonc deno.lock ./
COPY apps/control-ui/package.json ./apps/control-ui/
RUN deno task setup
# The UI imports shared types via ../../../packages, so mirror the repo layout
# (packages as a sibling of apps) before building.
COPY packages ./packages
COPY apps/control-ui ./apps/control-ui
RUN deno task build-ui
# --- Stage 2: Deno runtime ---
# Deno 2 base image (decision D3). The previous 1.40.4 pin predated `jsr:`
# specifier support and could not `deno cache` this workspace.
FROM denoland/deno:alpine-2.9.3
WORKDIR /app
COPY deno.jsonc deno.lock ./
# deno.jsonc declares apps/control-ui as a workspace member, so its manifest must be
# present for config resolution. The gateway itself stays on Deno's global module
# cache via --node-modules-dir=none, so NO node_modules is baked into the runtime
# image (identical to pre-migration behavior; only the build stage uses one).
COPY apps/control-ui/package.json ./apps/control-ui/
COPY packages ./packages
COPY apps/gateway ./apps/gateway
# The Control UI bundle comes from the builder stage above, so the image always
# serves the UI same-origin without any prebuilt host dist.
COPY --from=ui-builder /app/apps/control-ui/dist ./apps/control-ui/dist
COPY deploy/docker-entrypoint.sh /usr/local/bin/frosty-entrypoint
RUN deno cache --node-modules-dir=none apps/gateway/main.ts \
&& mkdir -p /app/data \
&& chmod +x /usr/local/bin/frosty-entrypoint \
&& chown -R deno:deno /app
EXPOSE 8080
USER deno
# The entrypoint mirrors the `start` task in deno.jsonc and permissions.md, and
# adds a Deno-scoped --allow-run only when FROSTY_WORKERS>1. --unstable-net is
# REQUIRED for multi-process serving: Deno.serve({reusePort:true}) throws
# "Unstable API 'Deno.listen({ reusePort: true })'" without it.
ENTRYPOINT ["/usr/local/bin/frosty-entrypoint"]

201
klanker-gate/LICENSE Executable file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 H3 Labs Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

84
klanker-gate/README.md Executable file
View File

@ -0,0 +1,84 @@
# Frosty Deno LLM Gateway
[![Deno](https://img.shields.io/badge/Deno-2.9-white?logo=deno&logoColor=black)](https://deno.com)
[![Version](https://img.shields.io/badge/version-0.9.0-blue.svg)](CHANGELOG.md)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
[![API](https://img.shields.io/badge/API-OpenAI--compatible-green.svg)](docs/reference/api-endpoints.md)
Frosty Deno is a clean-room Deno 2 + TypeScript LLM gateway with a same-origin
React control plane. One gateway surface fronts 20+ provider types behind
OpenAI-compatible APIs, adds governance and pricing controls, optional exact or
semantic caching, MCP integration, and telemetry, and stores durable state in
PostgreSQL.
## Overview
This repository is for teams that want one operational surface for many model
providers instead of many separate SDKs, credential stores, budget systems, and
observability paths. Frosty Deno gives you one API boundary, one operator
control plane, one governance layer, and one place to wire caching, logging,
metrics, tracing, and MCP tooling.
## Key features
- One gateway surface for chat, completions, embeddings, images, audio, files,
batches, and provider-specific compatibility families.
- Built-in governance with virtual keys, rate limits, request and cost budgets,
team and customer rollups, and pricing-aware metering.
- Same-origin control plane for providers, settings, logs, runtime diagnostics,
pricing, cache, and MCP management.
- Optional exact and semantic cache backed by PostgreSQL and pgvector.
- Optional observability profile with Prometheus, Grafana, OTEL Collector,
MinIO, and Tempo.
## Getting started
Prerequisites:
- Deno 2.9.x
- Docker and Docker Compose v2.20+ if you want the shipped PostgreSQL service
Fastest verified local path:
```bash
git clone <your-repository-url> klanker-gate
cd klanker-gate
deno task setup
cp .env.example .env
docker compose up -d postgres
deno task dev
```
At minimum, set one provider credential and `FROSTY_PG_URL` in `.env`.
Useful checks:
```bash
curl http://localhost:8080/healthz
curl http://localhost:8080/v1/models
```
Build the control UI when you want the same-origin operator interface:
```bash
deno task build-ui
```
## Documentation
Start with [docs/index.md](docs/index.md).
- Tutorials: [docs/getting-started/](docs/getting-started/)
- Guides: [docs/guides/](docs/guides/)
- Concepts: [docs/concepts/](docs/concepts/)
- Design: [docs/design/ui-design.md](docs/design/ui-design.md)
- Reference: [docs/reference/](docs/reference/)
## Contributing
Contribution workflow, setup, and validation expectations are documented in
[CONTRIBUTING.md](CONTRIBUTING.md) and [CONDUCT.md](CONDUCT.md).
## License
Licensed under the [Apache License 2.0](LICENSE).

69
klanker-gate/SECURITY.md Executable file
View File

@ -0,0 +1,69 @@
# Security Policy
## Overview
Security is a first-class implementation concern in Frosty Deno. The gateway
fails closed around durable state, governance, and config encryption; redacts
secrets before they reach the browser; and keeps its runtime permissions
intentionally narrow.
The detailed implementation model is documented in
[docs/concepts/security-model.md](docs/concepts/security-model.md). The full
dependency inventory and SBOM are documented in
[docs/reference/sbom.md](docs/reference/sbom.md).
## Supported versions
The current checked-in gateway version is `0.9.0`. The repository does not
publish a richer tagged release matrix, so the support statement is
intentionally conservative.
| Version line | Supported |
| ------------------------------------- | --------------------------------------- |
| `0.9.x` | Yes |
| Earlier or untagged historical states | No support commitment published in-repo |
## Reporting a vulnerability
Do not open a public GitHub issue for a security vulnerability.
Use a private channel instead:
1. If the repository is hosted on GitHub with security advisories enabled, use
the repository's **Security** tab and choose **Report a vulnerability**.
2. If that private advisory flow is unavailable in the hosting environment,
contact the maintainers through a private maintainer channel rather than a
public issue.
No dedicated security email address is defined in the checked-in repository
files, so this document intentionally does not invent one.
Include the following in your report:
- a clear description of the issue and why it matters
- affected routes, components, or integrations
- reproduction steps, including required configuration
- the version or commit you tested
- any logs, payloads, or proof-of-concept details that help reproduce the issue
safely
## Disclosure process
The intended process is:
1. Acknowledge the report privately.
2. Reproduce the issue and assess scope and severity.
3. Prepare a fix and matching regression coverage.
4. Release or publish the remediation.
5. Coordinate public disclosure after a fix exists.
## Dependency security
The checked-in SBOM and the repository-local SBOM generator are the source of
truth for dependency inventory:
- [docs/reference/sbom.md](docs/reference/sbom.md)
- [docs/reference/sbom/sbom.cyclonedx.json](docs/reference/sbom/sbom.cyclonedx.json)
- `scripts/generate_sbom.ts`
Recommended follow-up scans are documented in the SBOM itself.

313
klanker-gate/TODO.md Executable file
View File

@ -0,0 +1,313 @@
# TODO
Deliberate, known follow-ups. Each item names what is missing, why it was left,
and what has to move for it to be done.
This file used to be a **pointer** to two owning records - a numbered decision
log and an open-risks register. Both were retired on 2026-07-30 (item 1), and
this file is now **the register itself**: it is where a new deliberate
divergence gets recorded, and where a code comment points when its rationale is
too long to sit at the point of use. That raises the bar on what goes in it: an
entry needs the mechanism, the evidence in code, what "done" means, and the
trigger that reopens it, because there is no second document to carry the
rationale.
Two kinds of entry live here, and they are kept apart on purpose:
- **Open follow-ups** (the numbered items) - work that is not done. Their
numbers are reading order and will change as items are added and closed, so
**never cite an open item by number from code**.
- **[Decisions the code cites](#decisions-the-code-cites)** - closed decisions
whose rationale a source comment depends on. Each carries a **stable key**
such as `D-REBUILD-HEADERS`. Code cites the key, never a position, which is
the one property the retired numbered scheme had and the reason it was citable
at all.
Bare numbers such as "decision-log 57" that survive in comments or in
`CLAUDE.md` are **provenance only**: they record that a decision was taken and
where it was once written down. They resolve to nothing on disk, and four of
them resolve to nothing anywhere (item 1).
---
## 1. The decision log and open-risks register are retired, and 38 files still cite them
**Status: decided 2026-07-30 (owner: retirement accepted). Links and code
citations closed; four items' rationale is unrecoverable.**
`docs/contracts/decision-log.md` (50 448 B at HEAD) and
`docs/guides/open-risks.md` (23 279 B) were deleted from the working tree, along
with nine other `docs/contracts/*` files, `docs/guides/admin-api-cookbook.md`,
both `docs/runbooks/*` files and `docs/assets/architecture-diagram.md`. The
owner accepted the retirement rather than restoring from HEAD.
**Owner:** unassigned · **Severity:** Major · **Records:** this item
**What makes it work rather than a clean deletion.** The numbered scheme was
load-bearing. 38 files in the tree cite it, and not only docs:
| Citer | Was | Now |
| ------------------------------------------------------------------ | --- | ------------------------------------------ |
| `CLAUDE.md` | 10 | 0 links, inline numbers kept as provenance |
| [apps/gateway/context.ts](apps/gateway/context.ts) | 4 | 0 |
| [packages/core/src/translate.ts](packages/core/src/translate.ts) | 3 | 0 |
| [packages/core/src/middleware.ts](packages/core/src/middleware.ts) | 2 | 0 |
| `AGENTS.md`, `permissions.md`, tests, other docs | 19 | prose provenance only |
15 of those citations were **markdown links**, and were dead links, in
`CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `docs/benchmark-report.md` and this
file; all 15 are gone. The nine in the three source files above were prose
references of the form "(decision-log 71)" and are gone too - see below for what
that cost.
**Nine source citations, and only two carried content the comment did not.**
Reviewed one by one on 2026-07-30. Eight of the nine comments already stated the
constraint they cited, so the number was decoration and dropping it lost
nothing. The exception was `middleware.ts`, which said _"see decision-log 87 for
the three rebuild sites, the 15 affected routes and the accepted `serveDir`
consequence"_ - a forward reference to information held nowhere else. That
content is now [D-REBUILD-HEADERS](#d-rebuild-headers), re-verified against the
code rather than copied from memory, and the shared-rate-limit rationale is
[D-SHARED-RATE-LIMIT](#d-shared-rate-limit).
**Four items are gone for good: 14, 15, 69 and 79.** They were cited by
`translate.ts` (14, 15, 79) and `context.ts` (69). Each cited comment states its
own constraint, so no behavior is undocumented:
| Lost item | The constraint that survives, in the comment |
| --------- | ---------------------------------------------------------------------------------- |
| 14 | a zero token count means "the provider never told us", never "known and discarded" |
| 15 | edge translators run AFTER the plugin stream tap, so hooks see the canonical shape |
| 69 | config reload applies REMOVALS, because an upsert-only reload cannot revoke |
| 79 | one canonical execution path is what lets every ingress dialect share it |
What is lost is the reasoning behind each, and any alternative that was
rejected. Reconstructing them would mean inventing rationale, so they are
recorded as lost rather than guessed at. If one of these four decisions is ever
reopened, treat it as undecided and re-derive it.
The retired revision is also not recoverable. HEAD holds an _older_ lineage: its
log is the original Wave-1..Wave-5 prose with no numbered entries at all (items
57, 68, 81 and 87 return zero hits) and its risk register stops at `R19`. The
numbered 1..87 log and `R20`..`R25` existed only in the uncommitted tree. There
is no stash, no `checkout`/`reset` in the reflog, and no editor local-history
copy.
**Consequence absorbed.** This file is the new home, and
[Decisions the code cites](#decisions-the-code-cites) is where a divergence
whose rationale a comment depends on gets recorded, keyed rather than numbered.
Work that planned to append entries 88-91 or risks R26-R27 records them there
instead, with a fresh key each, at the point the work lands rather than in
advance.
**Still open:**
- `docs/contracts/fixtures/` holds the two golden fixtures that
[tests/contract/golden_chat_test.ts:28](tests/contract/golden_chat_test.ts#L28)
reads at module load, and is now the only inhabitant of a retired directory.
Moving it under `tests/contract/` would finish the retirement; it was left
alone because the deletion of those two files is what made the suite red in
the first place and re-touching them was not worth bundling into that repair.
- The bare numbers left inline in `CLAUDE.md` and in `permissions.md`, tests and
other docs. They are provenance, not links, and are labelled as such in the
preamble here.
**Reopen trigger:** a reader following a citation that resolves to nothing, or a
new divergence recorded as a bare number instead of a key.
## 2. Telemetry does not cost the media surfaces
**Status: done for the chat surfaces (2026-07-29); the media surfaces are
mid-build.** `INFERENCE_PATHS` is now an `isInferencePath()` predicate covering
the four canonical paths plus `/v1/messages`, `/cohere/v2/chat`, GenAI generate
actions, the Azure deployment-scoped ops and OpenRouter chat and embeddings,
with metrics, usage, spans and log enrichment widened together as required
below.
**Owner:** unassigned · **Severity:** Minor · **Records:** this item; provenance
decision-log 56 (the gap) and 81 (the chat-surface closure), open-risks R19
What is left is `/v1/images/generations`, `/v1/audio/speech` and
`/v1/audio/transcriptions`. They still need `normalizeUsage` and the pricing
catalog to model per-image, per-character and per-second billing before they can
be observed without producing counted-but-uncosted records.
| Route | Billing unit |
| -------------------------- | ------------------- |
| `/v1/images/generations` | per image |
| `/v1/audio/speech` | per character |
| `/v1/audio/transcriptions` | per second of audio |
`/v1/batches`, `/v1/files`, `/v1/count_tokens` and `/v1/models` are correctly
outside the set: none is a per-request billable completion.
**Why it was left.** The log trail was wired to the telemetry layer that already
existed; that did not change what the layer observes. Widening the predicate
moves billing-adjacent accounting - usage records feed governance budgets and
the analytics rollups - which is a materially larger blast radius than a log
column.
**Done means:** metrics, usage records, spans and log enrichment all widen
**together**, not logs alone.
**Reopen trigger:** any request to observe a non-`/v1`-canonical inference
surface, or a report that spend on one of the routes above is missing from
`/api/analytics` or the Grafana dashboards.
## 3. `LOG_LEVEL` is documented and plumbed but read by nothing
**Status: OPEN.** `LOG_LEVEL` has a row in the quick-reference table of
[docs/reference/environment-variables.md](docs/reference/environment-variables.md)
and a mention under "Core gateway and PostgreSQL", and
[docker-compose.yml](docker-compose.yml) forwards it into the container. No code
reads it, on any file type. An operator setting `LOG_LEVEL=debug` gets silence.
**Owner:** unassigned · **Severity:** Minor · **Records:** this item
It was removed from `.env.example` in the 2026-07-30 env cleanup, because an
example file that lists a dead knob is the defect. The docs row and the Compose
passthrough still promise it.
**Done means:** either implement a bounded log-level parse and restore the
`.env.example` line, or drop the docs row and the Compose passthrough too.
**Reopen trigger:** a report that log verbosity cannot be changed.
## 4. `totalTokens` is an unclamped vendor sum
**Status: OPEN, narrowed.** In
[apps/gateway/routes/telemetry.ts:135](apps/gateway/routes/telemetry.ts#L135)
`totalTokens` is `prompt + completion + (cacheCreation ?? 0)` with no ceiling.
It reaches the usage record (`:159`, `:190`) and the `gen_ai.usage.total_tokens`
span attribute (`:216`). It never passes through `costMicroUsd`, so the clamp
that protects the cost counter does not cover it.
**Owner:** unassigned · **Severity:** Minor · **Records:** this item
A provider returning three fields at `MAX_SAFE_INTEGER` yields a usage row and a
span attribute of `3 x MAX_SAFE_INTEGER`. Money is unaffected.
**Done means:** the same bounded parse the cost path uses, applied **after** the
vendor sum rather than per field - clamping each field independently leaves
`2 x MAX_SAFE_INTEGER`, which is the measured failure of the per-field approach.
**Reopen trigger:** an implausible token total in `/api/analytics` or a Tempo
span.
## 5. Deliberate gaps that are still live
Each is a decision, not an oversight; the row is here so it stays visible. The
evidence column is the code that implements the refusal.
| Gap | Shape | Evidence |
| -------------------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- |
| OpenRouter native `GET /generation` and `GET /key` | Explicit 501 | [openrouter_ingress.ts:171](apps/gateway/routes/openrouter_ingress.ts#L171) |
| Aggregator-path Bedrock native ingress | Explicit 501 | [compat_families.ts:795](apps/gateway/routes/compat_families.ts#L795) |
| Serving a cache hit as a stream | Not implemented by design | cache reads are non-streaming; completed streams are stored via the passive tee |
| Code Mode executor | Two gates, and the run primitive stubs | [packages/mcp/src/codemode/](packages/mcp/src/codemode/), `FROSTY_CODE_MODE` |
| Kubernetes and Helm packaging | Not present | deployment assets are Docker and Compose only |
| Some provider-panel config fields | Persisted and surfaced, not enforced | field comments in [packages/contracts/src/config.ts](packages/contracts/src/config.ts) |
The authoritative version of this table is the "Gaps and partial
implementations" section of
[docs/concepts/functionality-and-capabilities.md](docs/concepts/functionality-and-capabilities.md).
Do not let the two drift; that file wins.
## 6. Multi-process serving has a residual per-process gap
**Status: shipped with a named residual.** `FROSTY_WORKERS` is live, and budgets
and rate-limit windows are fleet-wide through PostgreSQL. The residual gap is
per-process state that no shared authority covers.
**Owner:** unassigned · **Severity:** Info · **Records:** this item; provenance
decision-log 62, 70, 71 (the shipped behavior) and 73 (the residual)
**Reopen trigger:** an operator report of limit overshoot that shared-authority
rate limiting does not explain.
---
# Decisions the code cites
Closed decisions whose rationale a source comment depends on. Each has a
**stable key**; a comment cites the key and nothing else, so entries can be
added, split or reordered without invalidating a citation. A key is never reused
or renamed.
Only decisions a comment genuinely cannot carry inline belong here. If the
constraint fits in a note under four lines at the point of use, that is where it
goes and no entry is needed - which is why this section is short and is expected
to stay short.
## D-REBUILD-HEADERS
**Rebuilding a `Response` around a new body invalidates the headers that
describe the old one, so the serving boundary drops all three.**
`REBUILT_BODY_HEADERS` in
[packages/core/src/middleware.ts](packages/core/src/middleware.ts) is
`content-encoding`, `content-length`, `transfer-encoding`, matched lowercased.
Why each one:
- `Content-Encoding` - Deno's `fetch` decompresses transparently but **keeps the
header**. A rebuild loses the internal already-decoded flag, so the header
stops describing the bytes and becomes an instruction the client acts on and
fails. This is the one that corrupted responses rather than merely
mis-describing them.
- `Content-Length` - a provider's value describes the **encoded** bytes.
- `Transfer-Encoding` - hop-by-hop; the serving runtime owns framing.
**Three sites rebuild a `Response` around a replacement body. Two strip, one
deliberately does not:**
| Site | Behavior |
| --------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| [middleware.ts:90](packages/core/src/middleware.ts#L90) (`makeRequestLogger`) | strips - this is the serving boundary |
| [routes/helpers.ts:147](apps/gateway/routes/helpers.ts#L147) (`rebuild`) | strips |
| [providers/src/client.ts:171](packages/providers/src/client.ts#L171) (`withBody`) | does **not** strip, and is safe because of the placement below |
**The fix is at the serving boundary because that placement is terminal in both
directions.** `withBody` is INWARD of the request logger, so a stale header it
attaches is cleaned on the way out. `reshapeError`
([compat_families.ts:188](apps/gateway/routes/compat_families.ts#L188)) rebuilds
around a different body too, but lives inside `compatPrefixMiddleware`, which
`main.ts` composes OUTWARD of the logger - it is safe for the opposite reason,
because it copies headers the logger has already cleaned. A third header-copy at
[governance.ts:584](apps/gateway/routes/governance.ts#L584) reuses the _same_
body, so its framing headers are still true.
**A denylist, not an allowlist.** An allowlist would silently drop
`openai-organization`, `x-request-id` and the `x-ratelimit-*` family, which
reach the client today and must continue to. The trade is that a future provider
header this rebuild also invalidates is inherited rather than caught.
**Accepted consequence.** The rule applies to every response passing the logger,
including static assets from `serveDir`
([main.ts:110](apps/gateway/main.ts#L110)). Those lose a `Content-Length` the
runtime then re-derives, so the cost is a recomputation, not a behavior change.
**Provenance:** decided 2026-07-29 as decision-log 87 and measured then as
repairing 15 live-broken routes. That route count is recorded as it was measured
on that date and has not been re-derived since; the mechanism and the three
sites above were re-verified 2026-07-30.
## D-SHARED-RATE-LIMIT
**Fixed-window rate limiting moves to a shared PostgreSQL authority only when
more than one process shares the port, because a single process already is the
whole fleet.**
Measured: a shared reservation costs **~1.8 ms** at 50 concurrent against local
PostgreSQL, versus **~1 us** for the in-process `Map`. In single-process mode
the Map is already fleet-accurate, so paying that buys nothing.
`FROSTY_SHARED_RATE_LIMIT` is `auto|on|off`, resolved by
[`sharedRateLimitEnabled`](apps/gateway/context.ts) - `auto` keys off
`FROSTY_WORKERS`, `on` forces it for operators running separate replicas that
`auto` cannot detect, `off` accepts N-times-the-limit across N processes. When a
shared limiter is present, `VirtualKeyManager` stands its own in-process windows
down so exactly one authority counts.
**Provenance:** decided as decision-log 71; the numbers live in
[docs/benchmark-report.md](docs/benchmark-report.md), which is their maintained
home.

View File

@ -0,0 +1,210 @@
# Control UI conventions (flagship contract)
Shared build patterns every control-ui view follows. Set by the Providers
flagship pass; later views (Model Catalog, Settings, Logs, MCP, Governance,
Dashboard) must match. When in doubt, read `ProvidersView.tsx` and its
`components/providers/*` for a worked example.
Design system is **ds-r2** (dense neutral-monochrome dark console). Tokens live
in `src/styles/tokens.css` - do not hand-edit component CSS to diverge; consume
the Tailwind token classes.
## Taste hard-rules (non-negotiable, from `taste.md`)
- **Zero em/en dashes** anywhere. Plain hyphen only. (deno lint + review check.)
- **One cool-blue accent** (`ring`, links, active-nav indicator) - never a fill.
Primary is a monochrome emphasis surface, not a chromatic color.
- **Monochrome-first.** Status/semantic color (success/warning/destructive/info)
is a functional vocabulary only; never decorative.
- **Dual-theme AA** in both light and dark. Every text/surface pair is
contrast-checked.
- **6px radius family**, one system. `rounded-sm|md|lg|xl` map to 4/6/8/12px.
- **44px min hit area** (`hit-target` class), **2px focus ring @ 2px offset**
(global `:focus-visible`), **full keyboard path** on every control.
- **lucide-react icons only**; no hand-rolled decorative SVG, no glow, no
glassmorphism. Elevation via borders + surface steps.
- **Motion is feedback-only** (`--motion-fast|default|slow`); reduced-motion
collapses to 0. No decorative animation.
- **Same-origin only.** No external CDN/script/font/style/image origins.
- **Frosty identity.** Own brand, `sk-`/`vk-` prefixes; never the reference
product's name, logos, or hues.
## Page structure
- Every view opens with `<PageHeader title subtitle actions>`. The title is an
`h2` and the focus target on nav change (do not add a second `h2`).
- Primary actions go in `PageHeader actions` (right cluster). Destructive or
bulk actions live inside the relevant card/panel, not the header.
- **Keep a destructive operation away from a Save button.** When a panel mixes
configuration edits with immediate-effect operations, the operations go below
the panel's save footer behind a divider, in their own labelled block - see
`CacheOpsPanel` mounted under `CachingPanel`'s `PanelFooter`
(`CachingPanel.tsx:322-340`), so a destructive purge is never adjacent to the
Save button that applies configuration edits.
- Content max width is `--container-max` (110rem / 1760px; the app shell applies
it). Page gutters use `--gutter`, which tightens from 24px to 16px at <= 48rem
so a tablet does not spend a quarter of its width on padding. Tables and
dashboards may use the full width; anything text-heavy takes `.measure`
instead.
- Errors: `<Banner tone="error">` quoting the gateway's `error.message` verbatim
(`ApiError.message`). Info/warn use `tone="info"|"warn"`.
- Loading: `TableSkeleton` / `TileSkeleton` / `Skeleton`, never a bare spinner
page.
## Tabs: SubTabs vs UnderlineTabs vs Tabs
- **`SubTabs`** (pill row) - dashboard-style section switcher across a wide
surface (e.g. Overview / Provider Usage / Model Rankings).
- **`UnderlineTabs`** - configuration panels and settings sub-navigation
(Providers config Network/Proxy/...; Settings Security/Compatibility/...).
This is the "sub-page within a view" bar.
- **`Tabs`** (segmented, on a muted track) - available for a small
binary/ternary local switch inside a card, but currently unused: no view
renders `<Tabs>`. Only `tabPanelProps(value)` and the `TabItem` type are
consumed today. Logs Live/Stored is a `Button` with `aria-pressed`, not a Tabs
instance. Reach for `Tabs` only when a real segmented switch appears;
otherwise prefer `SubTabs` / `UnderlineTabs`.
- Always pass a unique `label`; spread `tabPanelProps(value)` on the matching
panel container for the `role="tabpanel"` wiring.
## Tables: `DataTable`
Use `DataTable<T>` for every resource list. Never hand-roll `<table>` sorting.
- `columns`: `{ key, header, cell, sortValue?, align?, width? }`. Provide
`sortValue` to make a column sortable; `headerLabel` when `header` is not
plain text.
- `rowMenu`: return a `<DropdownMenu>` for per-row actions (Edit / Make default
/ Delete). Keep row action clusters out of cells; the kebab is the pattern.
- `pageSize`: set to enable the "Showing X-Y of Z" footer + prev/next.
- `caption` is required (labels the scroll region + screen readers).
- `empty`: pass a node; default copy is "No results." Prefer the shared empty
string **"No data available"** for analytics-style empties (see EmptyState).
## Forms
- Field grid: **`.field-grid`** (index.css). It is
`repeat(auto-fit, minmax(min(100%, 16rem), 22rem))` - as many columns as fit,
each capped at 22rem. Do NOT go back to `sm:grid-cols-2`: that sized every
field to half the container, so one "ID" input was 350px in a wide pane and
560px at the current container width. The 22rem cap is the point - a field
stops growing at a width appropriate to its content and leftover space stays
empty. Use **`.field-wide`** (a direct child of `.field-grid`) for fields that
genuinely need the row: JSON blobs, PEM, long descriptions.
- Wrap prose and single-column form panels in **`.measure`** (`--measure-max`,
60rem). Raising `--container-max` to 110rem means an uncapped label-control
pair can span 1760px, which puts the label a screen away from its input.
- **`Field`** (`label` + control + hint/error) for text inputs (`Input`,
`Textarea`, `NativeSelect`). `NativeSelect` is the only select for plain
option lists (G9).
- **`NumberField`** for numeric config (unit suffix + help). Emits a raw string
so empty stays representable; parse with a `numOrUndef` helper on save.
- **`KeyValueRows`** for repeatable Name/Value editors (extra headers).
- **`PemTextarea`** for PEM/cert blobs (non-blocking validity hint).
- **`SegmentedSelect`** for inline 2-3 option choices (beta-header override
default/enabled/disabled).
- **`Combobox`** for searchable single-select (reset periods, model/customer
filters).
- **`ToggleGridItem`** for a labelled switch tile in a responsive grid.
- **`Switch`** for a lone boolean; wrap with a label + description row.
## Secrets (never render values)
The server returns **redacted** views: secrets become `hasX` presence markers
(`hasApiKey`, `hasCloudCredentials`, `hasProxy`, `hasProxyPassword`,
`hasCaCert`). The raw value never reaches the browser.
- Show a **marker** ("Configured" + `••••••••`) plus a **Replace** affordance
that reveals an input to submit a _new_ secret. Use
`components/providers/SecretReenter` for single-line secrets; `PemTextarea`
(with a "Configured" badge) for `caCertPem`.
- Blank input = keep the current server value where possible.
- `MaskedSecretCell` (reveal + copy) is for values the browser legitimately
holds once - e.g. a freshly minted `vk-` token in a create response - never
for a redacted-at-rest secret you cannot actually reveal.
- **Gateway PUT is a shallow top-level merge** (`{...existing, ...patch}`). A
nested group you send _replaces_ the stored group, dropping any redacted
secret it contains. So: diff each config group against its loaded state and
send only changed groups (see `ProviderConfigPanel`), and warn when a save
would clear a stored secret the operator did not re-enter.
## Status pills (`Badge`)
- `tone="muted"` for neutral labels (CUSTOM, default, counts). Prefer muted to
stay monochrome.
- `tone="ok|warn|err|info"` only for genuine status semantics (enabled, missing
key, error, read-only). Soft variant by default; `solid` sparingly.
- Key presence in lists: plain `"set"` / `"missing"` micro-text (muted /
warning), not a loud pill.
## Empty states
- `<EmptyState icon title body />`; use `tone="info"` for "feature off / coming
later" notices. Standard analytics empty copy is **"No data available"** to
match the reference.
## Fixed-width rails must clip
Any fixed-width flex rail (`TwoPane`'s `aside`) needs `overflow-hidden`, and any
badge/marker cluster inside a flex row needs `shrink-0`. Without both, a row
whose intrinsic content exceeds the rail paints its trailing badges OUTSIDE the
rail and on top of the neighbouring pane - the Providers overlap bug. The
combination makes a crowded row a truncation problem instead of an overlap one.
Locked by `two-pane.overflow.test.tsx`.
## Density & spacing
- Body 13.5px (`text-base`), secondary `text-sm`, micro labels `text-2xs`
uppercase tracking-wide muted. Mono (`font-mono`) for all telemetry: ids,
keys, latency, cost, tokens, versions.
- Control heights: `--control-h` (34px) default, `--control-h-sm` (30px) dense.
- Card padding `px-5 py-4`; section gaps `gap-4`/`gap-5`; page section spacing
`mb-5`/`mb-6`.
## Navigation & routing
- The IA is grouped: **Overview** (Dashboard, Logs, Status) / **Gateway**
(Providers, Model Catalog, Extensions) / **Governance** (Virtual keys, Teams,
Customers, Pricing) / **System** (Settings).
- **Status is an Overview leaf**, not System: it answers "is the gateway healthy
right now", which sits with Dashboard and Logs rather than with configuration.
- **Cache and Config are Settings tabs**, not views. `#/cache` and `#/config`
are kept alive by `REDIRECTS` in `App.tsx`, which `history.replaceState`s them
onto `#/settings/caching` and `#/settings/config`. When you fold a view into a
tab, add the redirect - a bookmark that lands on the fallback view reads as a
broken link, not as a reorganization.
- Hash router in `App.tsx` keys off the **first** hash segment (`baseSegment`),
so a view owning sub-pages uses `#/<view>/<sub>` and manages its own sub-nav +
`history.replaceState` (see `SettingsView`). Add a view by extending `NAV` +
`renderView`; the sidebar, search, and Cmd/Ctrl-K palette pick it up
automatically.
- **Pinned browser contract:** keep nav leaves matchable by accessible name
`Providers`, `Status`, `Logs`, `Extensions` (unique). Do not introduce sibling
elements whose accessible name _contains_ a pinned token (avoid a button named
"Refresh providers" - collides with "Providers"; keep aria labels distinctive,
e.g. "Reload configuration").
## api.ts client (Phase 3b endpoints, already wired)
`src/api.ts` owns the transport, types, and endpoint clients. Consume these; do
not add `fetch` calls in views.
- `getCatalog(): CatalogView` - `GET /api/catalog` (Model Catalog).
- `getSettings(): SettingsView` / `putSettings(update): SettingsView` -
`/api/settings`. Each group has `{ values, sources }`; `sources[field]` is
`default|env|override` (drive a provenance pill) and `enforcement["g.field"]`
is whether the gateway enforces it.
- `getCodeModeVfs(binding): CodeModeVfsView` - `GET /api/mcp/codemode/vfs`.
- `getRuntime(): RuntimeView` - `GET /api/runtime` (Status > Runtime). Process
topology, saturation, and limit state. Two of its numbers are **per-process**
(`concurrency.*` and per-window `rateLimit`), and the UI must label them as
such on the tile itself, not only in a footnote: under `FROSTY_WORKERS=N` an
unqualified "12 in flight" reads as fleet-wide and under-reports load by a
factor of N. Budgets are unaffected - those run on shared atomic counters.
Render `workers.reason` verbatim; it is the gateway's own explanation of why
fan-out did or did not happen.
- Providers: `getConfig`, `createProvider`, `updateProvider`, `deleteProvider`,
`refreshModels`, `setDefaultProvider`.
All clients normalize partial/malformed bodies and (catalog) treat a 404 as
"feature off", so consumers stay total.

View File

@ -0,0 +1,15 @@
{
"tasks": {
"dev": "deno run -A npm:vite",
"build": "deno run -A npm:typescript@7.0.2/tsc && deno run -A npm:vite build",
"preview": "deno run -A npm:vite preview",
"check": "deno run -A npm:typescript@7.0.2/tsc",
"test": "deno run -A npm:vitest run"
},
"fmt": {
"exclude": ["node_modules", "dist"]
},
"lint": {
"exclude": ["node_modules", "dist"]
}
}

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Klanker Gateway Manager</title>
<script>
// Theme before first paint: dark default, honor prefers-color-scheme
// when no stored preference exists (localStorage key frosty.theme).
(function () {
try {
var stored = localStorage.getItem("frosty.theme");
var dark = stored
? stored === "dark"
: !window.matchMedia("(prefers-color-scheme: light)").matches;
document.documentElement.classList.toggle("dark", dark);
document.documentElement.dataset.theme = dark ? "dark" : "light";
} catch (_e) {
/* storage unavailable: keep the dark default */
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,28 @@
{
"name": "control-ui",
"private": true,
"version": "0.7.0",
"type": "module",
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.0.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.2.2",
"vitest": "^4.1.11",
"zod": "^4.4.3"
}
}

View File

@ -0,0 +1,49 @@
// Navigation restructure: Status moved to Overview, Cache and Config folded
// into Settings tabs, and the legacy hashes kept working.
//
// The redirect cases are the ones worth locking. Removing a nav leaf is
// visible immediately; a bookmark that silently lands on the wrong view is not,
// and reads as a broken link rather than as a reorganization.
import { describe, expect, it } from "vitest";
import { redirectFor } from "./App";
describe("legacy hash redirects", () => {
it("sends the old Cache page to the Settings caching tab", () => {
expect(redirectFor("#/cache")).toBe("settings/caching");
});
it("sends the old Config page to the Settings config tab", () => {
expect(redirectFor("#/config")).toBe("settings/config");
});
it("tolerates the hash with and without a leading slash", () => {
expect(redirectFor("#cache")).toBe("settings/caching");
expect(redirectFor("#/cache")).toBe("settings/caching");
});
it("leaves current routes alone", () => {
for (const hash of ["#/status", "#/settings", "#/providers", "#/logs"]) {
expect(redirectFor(hash)).toBeNull();
}
});
it("leaves an already-migrated settings sub-route alone", () => {
// Redirecting this would loop: the destination contains the source token.
expect(redirectFor("#/settings/caching")).toBeNull();
expect(redirectFor("#/settings/config")).toBeNull();
});
it("does not invent a destination for a deep legacy path", () => {
// "#/cache/anything" was never a route this app minted. Rewriting it would
// guess at an intent that was never expressed.
expect(redirectFor("#/cache/entry/123")).toBeNull();
expect(redirectFor("#/config/export")).toBeNull();
});
it("ignores an empty or unknown hash", () => {
expect(redirectFor("")).toBeNull();
expect(redirectFor("#/")).toBeNull();
expect(redirectFor("#/nonsense")).toBeNull();
});
});

View File

@ -0,0 +1,98 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { LogsView } from "./views/LogsView";
import { apiFetch, clearAdminToken, saveAdminToken } from "./api";
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function sseResponse(frames: string[]): Response {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
for (const frame of frames) {
controller.enqueue(encoder.encode(frame));
}
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
describe("LogsView fetch-SSE (security #9: no EventSource)", () => {
it("streams the log via fetch and renders replayed frames", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
(input) => {
const url = String(input);
if (url.includes("/api/logs/stream")) {
const entry = {
ts: "12:00:00",
level: "info",
message: "GET /v1/models",
status: 200,
};
return Promise.resolve(
sseResponse([`data: ${JSON.stringify(entry)}\n\n`]),
);
}
if (url.includes("/api/logs/stored")) {
return Promise.resolve(jsonResponse({ entries: [], total: 0 }));
}
return Promise.resolve(jsonResponse({}));
},
);
render(<LogsView />);
// The Logs view renders immediately (Live is the default source).
expect(screen.getByText(/Live request stream and stored history/))
.toBeInTheDocument();
// The replayed SSE frame is decoded and rendered.
await waitFor(() =>
expect(screen.getByText(/GET \/v1\/models/)).toBeInTheDocument()
);
// The stream was consumed via fetch, and no EventSource was constructed.
expect(
fetchSpy.mock.calls.some((call) =>
String(call[0]).includes("/api/logs/stream")
),
).toBe(true);
const fake = (globalThis as Record<string, unknown>).__FakeEventSource as {
instances: unknown[];
};
expect(fake.instances.length).toBe(0);
});
});
describe("apiFetch auth scope (security #2)", () => {
it("attaches Bearer only to /api/* and not to /healthz or /v1/*", async () => {
saveAdminToken("secret-token-value");
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockImplementation(() => Promise.resolve(jsonResponse({})));
await apiFetch("/api/config");
await apiFetch("/healthz");
await apiFetch("/v1/models");
const authFor = (path: string): string | null => {
const call = fetchSpy.mock.calls.find((c) => String(c[0]) === path);
return new Headers(call?.[1]?.headers).get("Authorization");
};
expect(authFor("/api/config")).toBe("Bearer secret-token-value");
expect(authFor("/healthz")).toBeNull();
expect(authFor("/v1/models")).toBeNull();
clearAdminToken();
});
});

View File

@ -0,0 +1,164 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import App from "./App";
import { ProvidersView } from "./views/ProvidersView";
import { StatusView } from "./views/StatusView";
function jsonOk(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function mockGateway() {
return vi.spyOn(globalThis, "fetch").mockImplementation((input) => {
const url = String(input);
if (url.includes("/api/config")) {
return Promise.resolve(jsonOk({
defaultProvider: "openai",
providers: [
{
id: "openai",
type: "openai",
enabled: true,
models: ["gpt-4o", "gpt-4o-mini"],
priority: 0,
hasApiKey: true,
},
{
id: "anthropic",
type: "anthropic",
enabled: false,
models: [],
priority: 0,
hasApiKey: false,
},
],
}));
}
if (url.includes("/healthz")) {
return Promise.resolve(jsonOk({
status: "ok",
version: "0.7.0",
timestamp: "2026-07-13T00:00:00Z",
}));
}
if (url.includes("/api/version")) {
return Promise.resolve(jsonOk({ version: "0.7.0", deno: "2.9.2" }));
}
if (url.includes("/v1/models")) {
return Promise.resolve(jsonOk({
object: "list",
data: [{ id: "openai/gpt-4o", object: "model", owned_by: "openai" }],
}));
}
if (url.includes("/api/mcp/clients")) {
return Promise.resolve(jsonOk({
clients: [{
id: "weather",
url: "https://mcp.example.com/rpc",
enabled: true,
transport: "http-sse",
toolCount: 2,
lastSyncAt: "2026-07-13T00:00:00Z",
}],
}));
}
if (url.includes("/api/mcp/tools")) {
return Promise.resolve(jsonOk({
tools: [{
name: "get_weather",
clientId: "weather",
annotations: { readOnlyHint: true },
}, {
name: "delete_notes",
clientId: "weather",
}],
}));
}
if (url.includes("/api/plugins")) {
return Promise.resolve(jsonOk({ plugins: ["tagger"] }));
}
return Promise.resolve(jsonOk({}));
});
}
describe("ProvidersView", () => {
it("renders the configured providers list from the gateway config", async () => {
mockGateway();
render(<ProvidersView />);
expect((await screen.findAllByText("openai")).length).toBeGreaterThan(0);
expect(screen.getAllByText("anthropic").length).toBeGreaterThan(0);
expect(screen.getByText("default")).toBeInTheDocument();
// Traffic-light status badge: green "online" (enabled + key) vs red "disabled".
expect(screen.getByText("online")).toBeInTheDocument();
expect(screen.getByText("disabled")).toBeInTheDocument();
});
it("shows an add-provider form", async () => {
mockGateway();
render(<ProvidersView />);
expect(
await screen.findByRole("button", { name: "Add provider" }),
).toBeInTheDocument();
expect(screen.getByPlaceholderText("openai")).toBeInTheDocument();
});
});
describe("StatusView", () => {
it("shows health, runtime version, and the model catalog", async () => {
mockGateway();
render(<StatusView />);
expect(await screen.findByText("ok")).toBeInTheDocument();
expect(screen.getByText(/gateway v0\.7\.0/)).toBeInTheDocument();
expect(screen.getByText(/Deno 2\.9\.2/)).toBeInTheDocument();
expect(await screen.findByText("openai/gpt-4o")).toBeInTheDocument();
});
});
describe("ExtensionsView", () => {
it("shows MCP servers, synced tools with safety badges, and plugins", async () => {
mockGateway();
const user = userEvent.setup();
const { ExtensionsView } = await import("./views/ExtensionsView");
render(<ExtensionsView />);
// Default tab renders MCP servers: the client row plus the transport
// column and the add-form selector (default http-sse, decision D11).
expect((await screen.findAllByText("weather")).length).toBeGreaterThan(0);
expect(screen.getAllByText("http-sse").length).toBeGreaterThan(1);
// Synced tools tab: tool names and per-call safety badges.
await user.click(screen.getByRole("tab", { name: "Synced tools" }));
expect(await screen.findByText("get_weather")).toBeInTheDocument();
expect(screen.getByText("read-only")).toBeInTheDocument();
expect(screen.getByText("needs confirmation")).toBeInTheDocument();
// Plugins tab: built-in plugin names from GET /api/plugins.
await user.click(screen.getByRole("tab", { name: "Plugins" }));
expect(await screen.findByText("tagger")).toBeInTheDocument();
});
});
describe("App", () => {
it("switches between tabs", async () => {
mockGateway();
const user = userEvent.setup();
render(<App />);
expect(
await screen.findByText("Configured Providers"),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Logs" }));
expect(screen.getByText(/Live request stream and stored history/))
.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Status" }));
expect(await screen.findByText("Gateway health")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,337 @@
import { useEffect, useRef, useState } from "react";
import {
Activity,
Boxes,
Building2,
CircleDollarSign,
KeyRound,
LayoutDashboard,
Menu,
Plug,
Puzzle,
ScrollText,
SlidersHorizontal,
Users,
} from "lucide-react";
import { ProvidersView } from "./views/ProvidersView";
import { StatusView } from "./views/StatusView";
import { LogsView } from "./views/LogsView";
import { ExtensionsView } from "./views/ExtensionsView";
import { ModelCatalogView } from "./views/ModelCatalogView";
import { SettingsView } from "./views/SettingsView";
import { VirtualKeysView } from "./views/VirtualKeysView";
import { TeamsView } from "./views/TeamsView";
import { CustomersView } from "./views/CustomersView";
import { PricingView } from "./views/PricingView";
import { DashboardView } from "./views/DashboardView";
import { type NavItem, Sidebar } from "./components/shell/Sidebar";
import { CommandPalette } from "./components/shell/CommandPalette";
import { AdminTokenDialog } from "./components/shell/AdminTokenDialog";
import { ToastProvider } from "./components/ui/toast";
import { Banner } from "./components/ui/banner";
import { Button } from "./components/ui/button";
import { type AuthState, hasAdminToken, subscribeAuth } from "./api";
const NAV: NavItem[] = [
{
id: "dashboard",
label: "Dashboard",
group: "Overview",
icon: LayoutDashboard,
},
{ id: "logs", label: "Logs", group: "Overview", icon: ScrollText },
{ id: "status", label: "Status", group: "Overview", icon: Activity },
{ id: "providers", label: "Providers", group: "Gateway", icon: Plug },
{
id: "model-catalog",
label: "Model Catalog",
group: "Gateway",
icon: Boxes,
},
{ id: "extensions", label: "Extensions", group: "Gateway", icon: Puzzle },
{
id: "virtual-keys",
label: "Virtual keys",
group: "Governance",
icon: KeyRound,
},
{ id: "teams", label: "Teams", group: "Governance", icon: Users },
{ id: "customers", label: "Customers", group: "Governance", icon: Building2 },
{
id: "pricing",
label: "Pricing",
group: "Governance",
icon: CircleDollarSign,
},
{
id: "settings",
label: "Settings",
group: "System",
icon: SlidersHorizontal,
},
];
const IDS = NAV.map((item) => item.id);
/**
* Hashes that pointed at views which are now Settings tabs. Without this a
* bookmarked #/cache would fall through to the default view, which looks like a
* broken link rather than a reorganization.
*/
const REDIRECTS: Record<string, string> = {
cache: "settings/caching",
config: "settings/config",
};
/** Resolve a legacy hash to its replacement route, or null when current. */
export function redirectFor(hash: string): string | null {
const raw = hash.replace(/^#\/?/, "");
const base = raw.split("/")[0];
const target = REDIRECTS[base];
// Only redirect a BARE legacy hash. "#/cache/anything" is not a route this
// app ever minted, so rewriting it would invent a destination.
return target && raw === base ? target : null;
}
/** First hash segment -> view id, tolerating sub-routes like "settings/mcp". */
function baseSegment(hash: string): string {
return hash.replace(/^#\/?/, "").split("/")[0];
}
function hashToView(hash: string): string {
const base = baseSegment(hash);
return IDS.includes(base) ? base : "providers";
}
/**
* Rewrites a legacy hash in place before routing. Uses replaceState, not a
* push, so the browser Back button does not bounce between the old hash and
* its replacement.
*/
function applyRedirect(hash: string): boolean {
const target = redirectFor(hash);
if (!target) {
return false;
}
try {
history.replaceState(null, "", `#/${target}`);
} catch {
// hash write unavailable: fall through and route by state alone
}
return true;
}
function renderView(id: string) {
switch (id) {
case "dashboard":
return <DashboardView />;
case "model-catalog":
return <ModelCatalogView />;
case "settings":
return <SettingsView />;
case "status":
return <StatusView />;
case "logs":
return <LogsView />;
case "extensions":
return <ExtensionsView />;
case "virtual-keys":
return <VirtualKeysView />;
case "teams":
return <TeamsView />;
case "customers":
return <CustomersView />;
case "pricing":
return <PricingView />;
default:
return <ProvidersView />;
}
}
function App() {
const [view, setView] = useState<string>(() => {
applyRedirect(location.hash);
return hashToView(location.hash);
});
const [authState, setAuthState] = useState<AuthState>("unknown");
const [authNonce, setAuthNonce] = useState(0);
const [tokenOpen, setTokenOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [collapsed, setCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem("frosty.sidebar") === "rail";
} catch {
return false;
}
});
const [theme, setTheme] = useState<"dark" | "light">(() =>
document.documentElement.classList.contains("dark") ? "dark" : "light"
);
useEffect(() => subscribeAuth(setAuthState), []);
// Global command palette shortcut (Cmd/Ctrl-K); cleaned up on unmount.
useEffect(() => {
function onKey(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPaletteOpen((open) => !open);
}
}
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, []);
const firstRender = useRef(true);
useEffect(() => {
// Focus the active view heading on nav change so screen readers announce
// the new context (spec section 4). Skip the initial mount.
if (firstRender.current) {
firstRender.current = false;
return;
}
document.querySelector<HTMLHeadingElement>("#main h2")?.focus();
}, [view]);
useEffect(() => {
function onHash() {
// React only to known view hashes; in-page anchors (e.g. Extensions'
// "#tools") must not hijack the router. Sub-routes ("settings/mcp") map
// to their base view, which owns the sub-navigation.
applyRedirect(location.hash);
const base = baseSegment(location.hash);
if (IDS.includes(base)) {
setView(base);
}
}
globalThis.addEventListener("hashchange", onHash);
return () => globalThis.removeEventListener("hashchange", onHash);
}, []);
function navigate(id: string) {
setView(id);
setMobileNavOpen(false);
try {
history.replaceState(null, "", `#/${id}`);
} catch {
// hash write unavailable: state is still authoritative
}
}
function toggleCollapse() {
setCollapsed((current) => {
const next = !current;
try {
localStorage.setItem("frosty.sidebar", next ? "rail" : "expanded");
} catch {
// preference is best-effort
}
return next;
});
}
function toggleTheme() {
setTheme((current) => {
const next = current === "dark" ? "light" : "dark";
const root = document.documentElement;
root.classList.toggle("dark", next === "dark");
root.dataset.theme = next;
try {
localStorage.setItem("frosty.theme", next);
} catch {
// preference is best-effort
}
return next;
});
}
const tokenStatus = authState === "denied"
? "denied"
: hasAdminToken()
? "ok"
: "none";
return (
<ToastProvider>
<div className="flex h-screen overflow-hidden">
<a href="#main" className="sr-only-focusable">Skip to content</a>
{mobileNavOpen && (
<div
className="fixed inset-0 z-(--z-overlay) bg-foreground/40 md:hidden"
aria-hidden="true"
onClick={() => setMobileNavOpen(false)}
/>
)}
<Sidebar
items={NAV}
activeId={view}
onNavigate={navigate}
collapsed={collapsed}
onToggleCollapse={toggleCollapse}
tokenStatus={tokenStatus}
onOpenToken={() => setTokenOpen(true)}
theme={theme}
onToggleTheme={toggleTheme}
mobileOpen={mobileNavOpen}
onMobileClose={() => setMobileNavOpen(false)}
/>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-3 border-b border-border px-4 py-2 md:hidden">
<button
type="button"
aria-label="Open navigation"
onClick={() => setMobileNavOpen(true)}
className="hit-target grid size-9 place-items-center rounded-md hover:bg-accent"
>
<Menu aria-hidden="true" className="size-5" />
</button>
<span aria-hidden="true" className="font-semibold tracking-tight">
Klanker Gateway Manager
</span>
</div>
{authState === "denied" && (
<div className="px-6 pt-4">
<Banner
tone="error"
action={
<Button size="sm" onClick={() => setTokenOpen(true)}>
Set token
</Button>
}
>
Admin token required. The gateway rejected the last request
(401).
</Banner>
</div>
)}
<main
id="main"
tabIndex={-1}
className="min-h-0 flex-1 overflow-y-auto py-6 outline-none px-(--gutter)"
>
<div
key={`${view}:${authNonce}`}
className="mx-auto w-full max-w-(--container-max)"
>
{renderView(view)}
</div>
</main>
</div>
</div>
<AdminTokenDialog
open={tokenOpen}
onClose={() => setTokenOpen(false)}
onTokenChange={() => setAuthNonce((n) => n + 1)}
/>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
items={NAV}
onSelect={navigate}
/>
</ToastProvider>
);
}
export default App;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from "react";
import { Search } from "lucide-react";
import {
type CatalogProviderRow,
getProviderAvailableModels,
updateProvider,
} from "../../api";
import { Dialog } from "../ui/dialog";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Banner } from "../ui/banner";
import { ToggleGridItem } from "../ui/toggle-grid-item";
import { ProviderIcon } from "../ui/provider-icon";
import { useToast } from "../ui/toast";
export interface ProviderModelsDialogProps {
/** The clicked catalog row; null closes the dialog. */
provider: CatalogProviderRow | null;
onClose: () => void;
/** Fired after a successful save so the catalog can reload. */
onSaved: () => void;
}
/**
* Per-provider model enablement grid. Opens from a Model Catalog row, fetches
* the provider's full live model list, and shows every model (the live list
* unioned with the currently-enabled ones) as an on/off tile. Saving writes the
* enabled subset back to the account's `models` - the set the gateway routes
* on. Providers without live listing fall back to their stored models.
*/
export function ProviderModelsDialog(
{ provider, onClose, onSaved }: ProviderModelsDialogProps,
) {
const toast = useToast();
const [available, setAvailable] = useState<string[]>([]);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [noLiveListing, setNoLiveListing] = useState(false);
const open = provider !== null;
const providerId = provider?.id ?? null;
useEffect(() => {
if (!provider) {
return;
}
let alive = true;
setQuery("");
setError(null);
setNoLiveListing(false);
setEnabled(new Set(provider.models));
setAvailable(provider.models);
setLoading(true);
getProviderAvailableModels(provider.id)
.then((res) => {
if (alive) setAvailable(res.models);
})
.catch((err) => {
if (!alive) return;
// A 400 means the provider type cannot list models live; the stored
// enabled set is still editable, so degrade instead of failing.
setNoLiveListing(true);
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [providerId]);
// Union of the live list and the enabled set, so a model that is enabled but
// no longer advertised still shows (and can be turned off).
const allModels = useMemo(() => {
const set = new Set<string>(available);
for (const m of enabled) set.add(m);
return [...set].sort((a, b) => a.localeCompare(b));
}, [available, enabled]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? allModels.filter((m) => m.toLowerCase().includes(q)) : allModels;
}, [allModels, query]);
function toggle(model: string, on: boolean) {
setEnabled((prev) => {
const next = new Set(prev);
if (on) next.add(model);
else next.delete(model);
return next;
});
}
function setAll(on: boolean) {
setEnabled((prev) => {
const next = new Set(prev);
for (const m of filtered) {
if (on) next.add(m);
else next.delete(m);
}
return next;
});
}
async function save() {
if (!provider) return;
setSaving(true);
try {
const models = [...enabled].sort((a, b) => a.localeCompare(b));
await updateProvider(provider.id, { models });
toast.success(`Models updated for "${provider.id}"`);
onSaved();
onClose();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
toast.error(message);
} finally {
setSaving(false);
}
}
return (
<Dialog
open={open}
onClose={onClose}
title={provider ? `${provider.id} models` : "Models"}
description="Toggle which models this provider exposes to the gateway."
className="max-w-3xl"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={() => void save()} isLoading={saving}>
Save
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
{provider && (
<ProviderIcon
provider={provider.type}
logoKey={provider.id}
name={provider.id}
custom={provider.custom}
size="sm"
/>
)}
<span className="text-sm text-muted-foreground">
{enabled.size} of {allModels.length} enabled
</span>
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setAll(true)}
disabled={loading || filtered.length === 0}
>
Enable all
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setAll(false)}
disabled={loading || filtered.length === 0}
>
Disable all
</Button>
</div>
</div>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="Search models"
placeholder="Search models..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
</div>
{noLiveListing && (
<Banner tone="info">
This provider type does not support live model listing. Editing the
models it already advertises.
</Banner>
)}
{error && !noLiveListing && <Banner tone="error">{error}</Banner>}
<div className="max-h-[50vh] overflow-y-auto pr-1">
{loading
? (
<p className="py-8 text-center text-sm text-muted-foreground">
Loading models...
</p>
)
: filtered.length === 0
? (
<p className="py-8 text-center text-sm text-muted-foreground">
{allModels.length === 0
? "No models available."
: `No models match "${query}".`}
</p>
)
: (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((model) => (
<ToggleGridItem
key={model}
label={model}
checked={enabled.has(model)}
onCheckedChange={(on) => toggle(model, on)}
/>
))}
</div>
)}
</div>
</div>
</Dialog>
);
}

View File

@ -0,0 +1,166 @@
import { type ReactNode, useState } from "react";
import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import {
Chart,
ChartLegend,
type ChartSeries,
type LegendItem,
} from "../ui/chart";
import { cn } from "../../lib/utils";
export interface ChartCardProps {
title: string;
/** Accessible name for the SVG chart. */
ariaLabel: string;
series?: ChartSeries[];
legend?: LegendItem[];
defaultType?: "line" | "bar";
/** Right-aligned header controls (e.g. a model / provider Combobox). */
filter?: ReactNode;
/** Evenly spaced x-axis tick labels rendered under a time-series chart. */
xTicks?: string[];
/** Micro unit hint (e.g. "USD", "tokens", "ms"). */
unit?: string;
loading?: boolean;
/** Force the empty state (feature off / dimension not recorded / filtered). */
empty?: boolean;
/** One-line muted note under the empty message explaining the gap. */
emptyNote?: string;
/** Hide the bar/line toggle (untracked cards have nothing to toggle). */
hideToggle?: boolean;
className?: string;
}
/**
* Dashboard analytics card: title, optional filter + bar/line toggle, a legend
* row, the dependency-free SVG Chart, and a graceful "No data available" state.
* A card with no positive value collapses to the empty state automatically, so
* an all-zero window never renders a misleading flat line.
*/
export function ChartCard(
{
title,
ariaLabel,
series = [],
legend = [],
defaultType = "line",
filter,
xTicks,
unit,
loading,
empty,
emptyNote,
hideToggle,
className,
}: ChartCardProps,
) {
const [type, setType] = useState<"line" | "bar">(defaultType);
const hasData = series.some((s) => s.values.some((v) => v > 0));
const showEmpty = Boolean(empty) || (!loading && !hasData);
const showToggle = !hideToggle && !showEmpty;
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<div className="flex items-center gap-2">
{filter}
{showToggle && (
<div className="inline-flex items-center gap-1 rounded-md bg-muted p-0.5">
<ToggleButton
label="Bar chart"
active={type === "bar"}
onClick={() => setType("bar")}
>
<BarChart3 className="size-4" />
</ToggleButton>
<ToggleButton
label="Line chart"
active={type === "line"}
onClick={() => setType("line")}
>
<LineChartIcon className="size-4" />
</ToggleButton>
</div>
)}
</div>
</CardHeader>
<CardContent>
{showEmpty
? (
<div className="flex h-44 flex-col items-center justify-center gap-1 text-center">
<p className="text-sm font-medium text-muted-foreground">
No data available
</p>
{emptyNote && (
<p className="max-w-xs text-xs text-muted-foreground/80">
{emptyNote}
</p>
)}
</div>
)
: loading
? <div className="skeleton-pulse h-44 rounded-md bg-muted" />
: (
<div className="flex flex-col gap-3">
{(legend.length > 0 || unit) && (
<div className="flex items-center justify-between gap-3">
<ChartLegend items={legend} />
{unit && (
<span className="shrink-0 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
{unit}
</span>
)}
</div>
)}
<Chart
type={type}
series={series}
ariaLabel={ariaLabel}
unit={unit}
height={168}
/>
{xTicks && xTicks.length > 0 && (
<div className="flex items-center justify-between px-0.5 text-2xs text-muted-foreground">
{xTicks.map((tick, i) => (
<span key={`${tick}-${i}`} className="font-mono">
{tick}
</span>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
function ToggleButton(
{ label, active, onClick, children }: {
label: string;
active: boolean;
onClick: () => void;
children: ReactNode;
},
) {
return (
<button
type="button"
aria-label={label}
aria-pressed={active}
onClick={onClick}
className={cn(
"grid size-7 place-items-center rounded-sm",
"transition-colors duration-(--motion-fast)",
active
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}

View File

@ -0,0 +1,260 @@
import type {
AnalyticsBucket,
AnalyticsModelRow,
AnalyticsProviderRow,
AnalyticsRollup,
LogEntry,
} from "../../api";
import type { ChartSeries, LegendItem } from "../ui/chart";
import type { ComboboxOption } from "../ui/combobox";
import type { CsvColumn } from "../../lib/csv";
import { percentile } from "../../lib/analytics";
import { getEurRate } from "../../lib/currency";
const MICRO = 1_000_000;
/* ----------------------------- overview trends ------------------------- */
/** Request Volume: success (requests - errors) vs error count per bucket. */
export function requestVolumeSeries(rollup: AnalyticsRollup): ChartSeries[] {
return [
{
name: "Success",
color: "2",
values: rollup.series.map((b) => Math.max(0, b.requests - b.errors)),
},
{ name: "Error", color: "4", values: rollup.series.map((b) => b.errors) },
];
}
/**
* Token Usage: input (prompt) vs output (completion) tokens per bucket. The
* gateway does not record cached-token counts per bucket, so the Cached measure
* stays a flat zero series: present for legend parity, never fabricated.
*/
export function tokenUsageSeries(rollup: AnalyticsRollup): ChartSeries[] {
return [
{
name: "Input",
color: "1",
values: rollup.series.map((b) => b.promptTokens),
},
{
name: "Output",
color: "2",
values: rollup.series.map((b) => b.completionTokens),
},
{ name: "Cached", color: "5", values: rollup.series.map(() => 0) },
];
}
/** Cost trend: micro-USD per bucket converted to euros. */
export function costTrendSeries(rollup: AnalyticsRollup): ChartSeries[] {
return [
{
name: "Cost",
color: "3",
values: rollup.series.map((b) => (b.costMicroUsd / MICRO) * getEurRate()),
},
];
}
/** Model Usage trend: total tokens per bucket across every model. */
export function tokenTrendSeries(rollup: AnalyticsRollup): ChartSeries[] {
return [
{
name: "Total tokens",
color: "1",
values: rollup.series.map((b) => b.totalTokens),
},
];
}
/* ------------------------------- latency ------------------------------- */
/** Split entries into ordered time buckets (span when every ts parses). */
function bucketByTime(entries: LogEntry[], bucketCount: number): LogEntry[][] {
const buckets: LogEntry[][] = Array.from({ length: bucketCount }, () => []);
if (entries.length === 0 || bucketCount <= 0) {
return buckets;
}
const parsed = entries.map((e) => Date.parse(e.ts));
const valid = parsed.filter((t) => !Number.isNaN(t));
const min = valid.length > 0 ? Math.min(...valid) : 0;
const max = valid.length > 0 ? Math.max(...valid) : 0;
const useTime = valid.length === entries.length && max > min;
for (let i = 0; i < entries.length; i++) {
const index = useTime
? Math.min(
bucketCount - 1,
Math.floor(((parsed[i] - min) / (max - min)) * bucketCount),
)
: Math.min(
bucketCount - 1,
Math.floor((i / entries.length) * bucketCount),
);
buckets[index].push(entries[i]);
}
return buckets;
}
/**
* Latency trend: avg / p90 / p95 / p99 of durationMs per time bucket. Derived
* from stored request logs (the rollup carries no latency), reusing the shared
* nearest-rank percentile helper.
*/
export function latencyTrendSeries(
entries: LogEntry[],
bucketCount = 12,
): ChartSeries[] {
const groups = bucketByTime(entries, bucketCount);
const avg: number[] = [];
const p90: number[] = [];
const p95: number[] = [];
const p99: number[] = [];
for (const group of groups) {
const durations = group
.map((e) => e.durationMs)
.filter((d): d is number => typeof d === "number" && d >= 0)
.sort((a, b) => a - b);
const mean = durations.length === 0
? 0
: durations.reduce((sum, v) => sum + v, 0) / durations.length;
avg.push(mean);
p90.push(percentile(durations, 90));
p95.push(percentile(durations, 95));
p99.push(percentile(durations, 99));
}
return [
{ name: "Avg", color: "1", values: avg },
{ name: "P90", color: "2", values: p90 },
{ name: "P95", color: "3", values: p95 },
{ name: "P99", color: "4", values: p99 },
];
}
/* --------------------------- provider breakdown ------------------------ */
/** Providers sorted by total tokens (descending), optionally to one row. */
function filterProviders(
rows: AnalyticsProviderRow[],
filter: string,
): AnalyticsProviderRow[] {
const sorted = [...rows].sort((a, b) => b.totalTokens - a.totalTokens);
return filter === "all"
? sorted
: sorted.filter((row) => row.provider === filter);
}
/** Provider Cost: one bar per provider (euros), chart-3. */
export function providerCostSeries(
rollup: AnalyticsRollup,
filter: string,
): ChartSeries[] {
return [
{
name: "Cost",
color: "3",
values: filterProviders(rollup.byProvider, filter).map((r) =>
(r.costMicroUsd / MICRO) * getEurRate()
),
},
];
}
/** Provider Token Usage: one bar per provider (total tokens), chart-1. */
export function providerTokenSeries(
rollup: AnalyticsRollup,
filter: string,
): ChartSeries[] {
return [
{
name: "Total tokens",
color: "1",
values: filterProviders(rollup.byProvider, filter).map((r) =>
r.totalTokens
),
},
];
}
/** Provider names behind the single provider bar series, in bar order. */
export function providerLegend(
rollup: AnalyticsRollup,
filter: string,
color: LegendItem["color"],
): LegendItem[] {
return filterProviders(rollup.byProvider, filter).map((row) => ({
name: row.provider,
color,
}));
}
/* ----------------------------- filter options -------------------------- */
/** Distinct model options for the per-card model filter ("All Models" first). */
export function modelOptions(rollup: AnalyticsRollup): ComboboxOption[] {
const seen = new Set<string>();
const options: ComboboxOption[] = [{ value: "all", label: "All Models" }];
for (const row of rollup.byModel) {
if (!seen.has(row.model)) {
seen.add(row.model);
options.push({ value: row.model, label: row.model });
}
}
return options;
}
/** Distinct provider options ("All Providers" first). */
export function providerOptions(rollup: AnalyticsRollup): ComboboxOption[] {
const seen = new Set<string>();
const options: ComboboxOption[] = [{ value: "all", label: "All Providers" }];
for (const row of rollup.byProvider) {
if (!seen.has(row.provider)) {
seen.add(row.provider);
options.push({ value: row.provider, label: row.provider });
}
}
return options;
}
/* -------------------------------- csv ---------------------------------- */
/** Overview export: the analytics time-series buckets. */
export const overviewCsvColumns: CsvColumn<AnalyticsBucket>[] = [
{ header: "bucket", value: (b) => b.label },
{ header: "requests", value: (b) => b.requests },
{ header: "errors", value: (b) => b.errors },
{ header: "prompt_tokens", value: (b) => b.promptTokens },
{ header: "completion_tokens", value: (b) => b.completionTokens },
{ header: "total_tokens", value: (b) => b.totalTokens },
{
header: "cost_eur",
value: (b) => ((b.costMicroUsd / MICRO) * getEurRate()).toFixed(6),
},
];
/** Provider Usage export: the by-provider rollup rows. */
export const providerCsvColumns: CsvColumn<AnalyticsProviderRow>[] = [
{ header: "provider", value: (r) => r.provider },
{ header: "requests", value: (r) => r.requests },
{ header: "total_tokens", value: (r) => r.totalTokens },
{
header: "cost_eur",
value: (r) => ((r.costMicroUsd / MICRO) * getEurRate()).toFixed(6),
},
];
/** Model Rankings export: the by-model rollup rows. */
export const modelCsvColumns: CsvColumn<AnalyticsModelRow>[] = [
{ header: "model", value: (r) => r.model },
{ header: "provider", value: (r) => r.provider },
{ header: "requests", value: (r) => r.requests },
{ header: "prompt_tokens", value: (r) => r.promptTokens },
{ header: "completion_tokens", value: (r) => r.completionTokens },
{ header: "total_tokens", value: (r) => r.totalTokens },
{
header: "cost_eur",
value: (r) => ((r.costMicroUsd / MICRO) * getEurRate()).toFixed(6),
},
];

View File

@ -0,0 +1,99 @@
import { useEffect, useId, useRef, useState } from "react";
import { Columns3 } from "lucide-react";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import type { ColumnMeta } from "./logs-model";
export interface ColumnPickerProps {
columns: ColumnMeta[];
visible: Set<string>;
onToggle: (key: string, checked: boolean) => void;
}
/**
* Show/hide column control (spec: Logs top bar). A disclosure button opens a
* checkbox panel; Escape and click-outside close it. The last visible column
* cannot be hidden so the table never collapses to nothing.
*/
export function ColumnPicker(
{ columns, visible, onToggle }: ColumnPickerProps,
) {
const panelId = useId();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
}
}
document.addEventListener("mousedown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("mousedown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
};
}, [open]);
const shownCount = columns.reduce(
(n, column) => (visible.has(column.key) ? n + 1 : n),
0,
);
return (
<div ref={rootRef} className="relative inline-block">
<Button
variant="outline"
size="icon"
aria-label="Choose columns"
aria-haspopup="true"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
onClick={() => setOpen((value) => !value)}
>
<Columns3 aria-hidden="true" />
</Button>
{open && (
<div
id={panelId}
role="group"
aria-label="Columns"
className="absolute right-0 z-(--z-overlay) mt-1 min-w-44 rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md"
>
<p className="px-1 pb-1 text-2xs font-medium uppercase tracking-wide text-muted-foreground">
Columns
</p>
<ul className="flex flex-col gap-0.5">
{columns.map((column) => {
const checked = visible.has(column.key);
const lockLast = checked && shownCount === 1;
return (
<li key={column.key}>
<label className="hit-target flex cursor-pointer items-center gap-2 rounded-sm px-1 text-sm">
<Checkbox
checked={checked}
disabled={lockLast}
aria-label={column.label}
onChange={(event) =>
onToggle(column.key, event.target.checked)}
/>
<span className="text-foreground">{column.label}</span>
</label>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,164 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import type { LogEntry } from "../../api";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { StatTile } from "../ui/stat-tile";
import { Chart, ChartLegend, type ChartSeries } from "../ui/chart";
import { buildSeries } from "../../lib/analytics";
import { classifyOutcome, entryTokens, formatCostUsd } from "./logs-model";
import { cn } from "../../lib/utils";
/**
* KPI row over the currently visible logs. Every tile derives from recorded
* fields: Total Requests / Success Rate / Avg Latency from the base request
* fields, Total Tokens / Total Cost from the telemetry enrichment carried on
* inference entries. A window with no inference traffic still shows an honest
* "N/A" for the last two rather than a fabricated zero, because "no request
* recorded usage" and "usage was zero" are different facts.
*/
export function LogsKpiRow(
{ entries, loading }: { entries: LogEntry[]; loading: boolean },
) {
let success = 0;
let error = 0;
let cancelled = 0;
let latencyCount = 0;
let latencySum = 0;
let tokenTotal = 0;
let tokenEntries = 0;
let costMicroUsd = 0;
let costEntries = 0;
for (const entry of entries) {
const outcome = classifyOutcome(entry);
if (outcome === "success") {
success += 1;
} else if (outcome === "error") {
error += 1;
} else if (outcome === "cancelled") {
cancelled += 1;
}
if (typeof entry.durationMs === "number") {
latencyCount += 1;
latencySum += entry.durationMs;
}
const tokens = entryTokens(entry);
if (tokens !== null) {
tokenTotal += tokens;
tokenEntries += 1;
}
if (typeof entry.costMicroUsd === "number") {
costMicroUsd += entry.costMicroUsd;
costEntries += 1;
}
}
const terminal = success + error + cancelled;
const successRate = terminal > 0 ? (success / terminal) * 100 : null;
const avgLatency = latencyCount > 0 ? latencySum / latencyCount : null;
return (
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<StatTile
loading={loading}
label="Total Requests"
value={entries.length.toLocaleString()}
caption="in view"
/>
<StatTile
loading={loading}
label="Success Rate"
value={successRate === null ? "N/A" : `${successRate.toFixed(2)}%`}
caption={successRate === null
? "no completed requests"
: `${success.toLocaleString()} ok`}
/>
<StatTile
loading={loading}
label="Avg Latency"
value={avgLatency === null ? "N/A" : `${avgLatency.toFixed(2)}ms`}
caption={avgLatency === null ? "no latency recorded" : undefined}
/>
<StatTile
loading={loading}
label="Total Tokens"
value={tokenEntries === 0 ? "N/A" : tokenTotal.toLocaleString()}
caption={tokenEntries === 0
? "no usage recorded"
: `over ${tokenEntries.toLocaleString()} ${
tokenEntries === 1 ? "request" : "requests"
}`}
/>
<StatTile
loading={loading}
label="Total Cost"
value={costEntries === 0 ? "N/A" : formatCostUsd(costMicroUsd)}
caption={costEntries === 0 ? "no cost recorded" : "priced requests"}
/>
</div>
);
}
/**
* Collapsible Request Volume card: success vs error counts bucketed over the
* visible logs' time range. Both series are derived from recorded status/level;
* an empty window shows the standard "No data available" state.
*/
export function RequestVolumeCard({ entries }: { entries: LogEntry[] }) {
const [open, setOpen] = useState(true);
const series = buildSeries(entries, 12);
const volume: ChartSeries[] = [
{ name: "Success", color: "2", values: series.map((b) => b.success) },
{ name: "Error", color: "4", values: series.map((b) => b.errors) },
];
const hasData = volume.some((s) => s.values.some((v) => v > 0));
return (
<Card className="mb-5">
<CardHeader>
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
className="hit-target -ml-1 inline-flex items-center gap-2 rounded px-1 text-left"
>
<ChevronDown
aria-hidden="true"
className={cn(
"size-4 shrink-0 text-muted-foreground",
"transition-transform duration-(--motion-default)",
!open && "-rotate-90",
)}
/>
<CardTitle className="text-base">Request Volume</CardTitle>
</button>
<ChartLegend
items={[
{ name: "Success", color: "2" },
{ name: "Error", color: "4" },
]}
/>
</CardHeader>
{open && (
<CardContent>
{hasData
? (
<Chart
type="bar"
series={volume}
ariaLabel="Request volume by outcome over time"
height={160}
/>
)
: (
<div className="flex h-40 items-center justify-center rounded-md bg-muted/40">
<p className="text-sm text-muted-foreground">
No data available
</p>
</div>
)}
</CardContent>
)}
</Card>
);
}

View File

@ -0,0 +1,221 @@
import { useState } from "react";
import { PanelLeftClose, Search } from "lucide-react";
import { type FacetGroup, FacetRail } from "../ui/facet-rail";
import { Collapsible } from "../ui/collapsible";
import { Checkbox } from "../ui/checkbox";
import type { LogEntry } from "../../api";
import {
type FacetSelection,
facetValues,
HONEST_FACETS,
OUTCOME_LABEL,
OUTCOME_ORDER,
type OutcomeCounts,
VALUE_FACETS,
type ValueFacet,
} from "./logs-model";
export interface LogsFacetRailProps {
/** Selected outcome classes (facet-rail controlled model). */
outcome: string[];
onOutcomeChange: (values: string[]) => void;
/** Per-class counts over the currently loaded (time+search filtered) logs. */
counts: OutcomeCounts;
/** Entries the live value facets enumerate their options from. */
entries: LogEntry[];
/** Selected values per live facet (model / provider / type). */
selection: FacetSelection;
onSelectionChange: (id: ValueFacet["id"], values: string[]) => void;
onHide: () => void;
}
/**
* Left filter rail for the Logs view. Outcome, Models, Provider, and Type are
* live facets backed by recorded fields (Type is projected from the recorded
* path). The groups below them are the faithful professional shell shown
* honest-empty, because the gateway records none of those dimensions on a log
* entry - except Cost, which is recorded per entry but has no range filter.
*/
export function LogsFacetRail(
{
outcome,
onOutcomeChange,
counts,
entries,
selection,
onSelectionChange,
onHide,
}: LogsFacetRailProps,
) {
const outcomeGroup: FacetGroup = {
id: "outcome",
label: "Outcome",
defaultOpen: true,
options: OUTCOME_ORDER.map((value) => ({
value,
label: OUTCOME_LABEL[value],
count: counts[value],
})),
};
return (
<div className="flex flex-col">
<div className="mb-1 flex items-center justify-between px-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Filters
</p>
<button
type="button"
aria-label="Hide filters"
onClick={onHide}
className="hit-target inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--motion-fast) hover:bg-accent hover:text-foreground [&_svg]:size-4"
>
<PanelLeftClose aria-hidden="true" />
</button>
</div>
{
/* Wrapped so the group keeps a bottom divider: FacetRail strips the
border on its last group, which is the only group we pass it. */
}
<div className="border-b border-border">
<FacetRail
groups={[outcomeGroup]}
value={{ outcome }}
onChange={(_, values) => onOutcomeChange(values)}
/>
</div>
{VALUE_FACETS.map((facet) => (
<ValueFacetGroup
key={facet.id}
facet={facet}
entries={entries}
selected={selection[facet.id] ?? []}
onChange={(values) => onSelectionChange(facet.id, values)}
/>
))}
{HONEST_FACETS.map((facet) => (
<Collapsible key={facet.id} title={facet.label}>
<div className="pl-6">
<NotRecorded recorded={facet.recorded} />
</div>
</Collapsible>
))}
</div>
);
}
/**
* One live facet: distinct recorded values over the loaded entries, each with
* an occurrence count. Renders the same empty affordance as the honest groups
* when the current window happens to contain no entry carrying the dimension.
*/
function ValueFacetGroup(
{ facet, entries, selected, onChange }: {
facet: ValueFacet;
entries: LogEntry[];
selected: string[];
onChange: (values: string[]) => void;
},
) {
const [query, setQuery] = useState("");
const options = facetValues(entries, facet);
const needle = query.trim().toLowerCase();
const shown = needle
? options.filter((option) => option.value.toLowerCase().includes(needle))
: options;
const toggle = (value: string, checked: boolean) => {
onChange(
checked
? [...selected, value]
: selected.filter((entry) => entry !== value),
);
};
return (
<Collapsible title={facet.label} defaultOpen={facet.id === "model"}>
<div className="flex flex-col gap-2 pl-6">
{facet.searchable && (
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
/>
<input
type="search"
aria-label={`Filter ${facet.label}`}
value={query}
disabled={options.length === 0}
placeholder={`Search ${facet.label.toLowerCase()}`}
onChange={(event) => setQuery(event.target.value)}
className="h-(--control-h-sm) w-full rounded-md border border-input bg-card pl-7 pr-2 text-sm text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
)}
{options.length === 0
? <NoneInWindow />
: (
<ul className="flex flex-col gap-0.5">
{shown.map((option) => (
<li key={option.value}>
<label className="hit-target flex cursor-pointer items-center gap-2 rounded-sm text-sm">
<Checkbox
checked={selected.includes(option.value)}
aria-label={`${facet.label}: ${option.value}`}
onChange={(event) =>
toggle(option.value, event.target.checked)}
/>
<span
className="min-w-0 flex-1 truncate text-foreground"
title={option.value}
>
{option.value}
</span>
<span className="tabular-nums text-xs text-muted-foreground">
{option.count}
</span>
</label>
</li>
))}
</ul>
)}
</div>
</Collapsible>
);
}
/** A recorded dimension that simply has no values in the current window. */
function NoneInWindow() {
return (
<p
className="text-xs text-muted-foreground"
title="No log entry in this window records this dimension"
>
None in this range
</p>
);
}
/**
* Affordance for a group with no filter. `recorded` distinguishes "the gateway
* stores nothing for this" from "it is stored per entry but has no control".
*/
function NotRecorded({ recorded }: { recorded?: boolean }) {
return recorded
? (
<p
className="text-xs text-muted-foreground"
title="Recorded per entry; no filter control"
>
No filter yet
</p>
)
: (
<p className="text-xs text-muted-foreground" title="Not recorded on logs">
Not recorded yet
</p>
);
}

View File

@ -0,0 +1,268 @@
import { RefreshCw } from "lucide-react";
import type { LogEntry } from "../../api";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { type Column, DataTable } from "../ui/data-table";
import {
ALL_COLUMNS,
classifyOutcome,
type ColumnKey,
entryTokens,
formatCostUsd,
formatLatency,
formatTimestamp,
requestType,
} from "./logs-model";
import { cn } from "../../lib/utils";
export type Connection = "connecting" | "streaming" | "disconnected";
type Row = LogEntry & { _id: string };
const reduceMotion = () =>
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
/**
* Honest placeholder for a row that carries no value in this column. Inference
* requests record provider/model/tokens; a health probe or an admin API call
* has none, so those rows keep saying N/A rather than borrowing a value.
*/
function NaCell({ mono }: { mono?: boolean }) {
return (
<span
title="Not recorded"
className={cn("text-muted-foreground", mono && "font-mono")}
>
N/A
</span>
);
}
/** Tokens cell: total, with the prompt/completion split and cost in the title. */
function TokensCell({ entry }: { entry: LogEntry }) {
const total = entryTokens(entry);
if (total === null) {
return <NaCell mono />;
}
const parts = [
`${entry.promptTokens ?? 0} prompt`,
`${entry.completionTokens ?? 0} completion`,
];
if (typeof entry.costMicroUsd === "number") {
parts.push(formatCostUsd(entry.costMicroUsd));
}
return (
<span
className="whitespace-nowrap font-mono text-xs"
title={parts.join(", ")}
>
{total.toLocaleString()}
</span>
);
}
function MessageCell({ entry }: { entry: LogEntry }) {
const head = [entry.method, entry.path].filter(Boolean).join(" ");
return (
<div className="min-w-0 max-w-lg">
{head && (
<div
className="truncate font-mono text-xs text-foreground"
title={head}
>
{head}
</div>
)}
<div
className={cn(
"truncate text-xs",
head ? "text-muted-foreground" : "text-foreground",
)}
title={entry.message}
>
{entry.message}
</div>
</div>
);
}
function StatusCell({ entry }: { entry: LogEntry }) {
const outcome = classifyOutcome(entry);
if (outcome === "success") {
return <Badge tone="ok">success</Badge>;
}
if (outcome === "error") {
return (
<Badge tone="err">
{typeof entry.status === "number" ? entry.status : "error"}
</Badge>
);
}
if (outcome === "cancelled") {
return <Badge tone="warn">cancelled</Badge>;
}
return <Badge tone="muted">processing</Badge>;
}
const COLUMN_DEFS: Record<ColumnKey, Column<Row>> = {
time: {
key: "time",
header: "Time",
sortValue: (row) => Date.parse(row.ts) || 0,
cell: (row) => (
<span className="whitespace-nowrap font-mono text-xs">
{formatTimestamp(row.ts)}
</span>
),
},
type: {
key: "type",
header: "Type",
sortValue: (row) => requestType(row) ?? "",
cell: (row) => {
const type = requestType(row);
return type
? <span className="whitespace-nowrap text-xs">{type}</span>
: <NaCell />;
},
},
provider: {
key: "provider",
header: "Provider",
sortValue: (row) => row.provider ?? "",
cell: (row) =>
row.provider
? (
<span className="whitespace-nowrap text-xs" title={row.provider}>
{row.provider}
</span>
)
: <NaCell />,
},
model: {
key: "model",
header: "Model",
sortValue: (row) => row.model ?? "",
cell: (row) =>
row.model
? (
<span
className="block max-w-56 truncate font-mono text-xs"
title={row.model}
>
{row.model}
</span>
)
: <NaCell mono />,
},
message: {
key: "message",
header: "Message",
cell: (row) => <MessageCell entry={row} />,
},
latency: {
key: "latency",
header: "Latency",
sortValue: (row) => row.durationMs ?? -1,
cell: (row) => {
const latency = formatLatency(row.durationMs);
return latency
? <span className="whitespace-nowrap font-mono text-xs">{latency}</span>
: <NaCell mono />;
},
},
tokens: {
key: "tokens",
header: "Tokens",
sortValue: (row) => entryTokens(row) ?? -1,
cell: (row) => <TokensCell entry={row} />,
},
status: {
key: "status",
header: "Status",
cell: (row) => <StatusCell entry={row} />,
},
};
export interface LogsTableProps {
entries: LogEntry[];
visibleColumns: Set<string>;
live: boolean;
connection: Connection;
loading: boolean;
onReconnect: () => void;
}
export function LogsTable(
{ entries, visibleColumns, live, connection, loading, onReconnect }:
LogsTableProps,
) {
const rows: Row[] = entries.map((entry, index) => ({
...entry,
_id: `${index}-${entry.ts}-${entry.requestId ?? ""}`,
}));
const columns = ALL_COLUMNS
.filter((column) => visibleColumns.has(column.key))
.map((column) => COLUMN_DEFS[column.key]);
return (
<div className="flex flex-col gap-2">
{live && <LiveBar connection={connection} onReconnect={onReconnect} />}
<DataTable<Row>
caption="Request logs"
rows={rows}
columns={columns}
getRowId={(row) => row._id}
pageSize={25}
loading={loading}
initialSort={{ key: "time", dir: "desc" }}
minWidth="60rem"
empty={
<div className="flex flex-col items-center gap-1 py-4">
<p className="text-sm font-medium text-foreground">
No results found
</p>
<p className="text-xs text-muted-foreground">
Try adjusting your filters and/or time range.
</p>
</div>
}
/>
</div>
);
}
function LiveBar(
{ connection, onReconnect }: {
connection: Connection;
onReconnect: () => void;
},
) {
const label = connection === "streaming"
? "Listening for logs"
: connection === "connecting"
? "Connecting to log stream"
: "Disconnected from log stream";
return (
<div
aria-live="polite"
className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
>
<RefreshCw
aria-hidden="true"
className={cn(
"size-3.5",
connection === "streaming" && !reduceMotion() && "animate-spin",
)}
/>
<span className="flex-1">{label}</span>
{connection === "disconnected" && (
<Button variant="outline" size="sm" onClick={onReconnect}>
Reconnect
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,257 @@
import type { LogEntry } from "../../api";
export type Outcome = "success" | "error" | "processing" | "cancelled";
/**
* Classify a log line into a request outcome using only recorded fields.
* - cancelled: HTTP 499 (client closed request convention)
* - error: level "error", or a status >= 400
* - success: any other numeric status (< 400)
* - processing: no numeric status yet (in-flight or non-request log line)
*
* Every branch is a real predicate over recorded data; "cancelled" simply
* matches rarely (the gateway seldom emits 499), which is honest, not faked.
*/
export function classifyOutcome(entry: LogEntry): Outcome {
if (entry.status === 499) {
return "cancelled";
}
if (
entry.level === "error" ||
(typeof entry.status === "number" && entry.status >= 400)
) {
return "error";
}
if (typeof entry.status === "number") {
return "success";
}
return "processing";
}
export const OUTCOME_ORDER: Outcome[] = [
"success",
"error",
"processing",
"cancelled",
];
export const OUTCOME_LABEL: Record<Outcome, string> = {
success: "Success",
error: "Error",
processing: "Processing",
cancelled: "Cancelled",
};
export type OutcomeCounts = Record<Outcome, number>;
export function emptyCounts(): OutcomeCounts {
return { success: 0, error: 0, processing: 0, cancelled: 0 };
}
/** Time-range value -> window length in ms (matches DEFAULT_TIME_RANGES). */
export const WINDOW_MS: Record<string, number> = {
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
};
/** Human timestamp; falls back to the raw string when unparseable. */
export function formatTimestamp(ts: string): string {
const date = new Date(ts);
return Number.isNaN(date.getTime()) ? ts : date.toLocaleString();
}
/** "{n}ms" for a recorded duration, or null when latency is not recorded. */
export function formatLatency(durationMs: number | undefined): string | null {
if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) {
return null;
}
return `${durationMs}ms`;
}
/* ------------------------- derived request type ------------------------- */
const TYPE_BY_PATH: Record<string, string> = {
"/v1/chat/completions": "chat",
"/v1/completions": "text",
"/v1/responses": "responses",
"/v1/embeddings": "embedding",
"/v1/messages": "messages",
"/v1/images/generations": "image",
"/v1/audio/speech": "speech",
"/v1/audio/transcriptions": "transcription",
};
/**
* Request type derived from the recorded path, or null when the path is not an
* inference surface (admin API, static asset, health probe).
*/
export function requestType(entry: LogEntry): string | null {
return entry.path ? TYPE_BY_PATH[entry.path] ?? null : null;
}
/* ----------------------------- tokens + cost ---------------------------- */
/** Total tokens for an entry, or null when no usage was recorded. */
export function entryTokens(entry: LogEntry): number | null {
if (typeof entry.totalTokens === "number") {
return entry.totalTokens;
}
const prompt = entry.promptTokens;
const completion = entry.completionTokens;
if (typeof prompt !== "number" && typeof completion !== "number") {
return null;
}
return (prompt ?? 0) + (completion ?? 0);
}
/**
* Formats integer micro-USD as a USD string. Sub-cent costs keep enough
* precision to stay non-zero, which matters because a single small completion
* routinely costs well under a cent.
*/
export function formatCostUsd(costMicroUsd: number): string {
const usd = costMicroUsd / 1_000_000;
if (usd === 0) {
return "$0.00";
}
if (usd < 0.01) {
return `$${usd.toFixed(6)}`;
}
return `$${usd.toFixed(usd < 1 ? 4 : 2)}`;
}
/* ----------------------------- table columns ---------------------------- */
export type ColumnKey =
| "time"
| "type"
| "provider"
| "model"
| "message"
| "latency"
| "tokens"
| "status";
export interface ColumnMeta {
key: ColumnKey;
label: string;
/** true when the column is backed by a recorded field. */
real: boolean;
}
export const ALL_COLUMNS: ColumnMeta[] = [
{ key: "time", label: "Time", real: true },
{ key: "type", label: "Type", real: true },
{ key: "provider", label: "Provider", real: true },
{ key: "model", label: "Model", real: true },
{ key: "message", label: "Message", real: true },
{ key: "latency", label: "Latency", real: true },
{ key: "tokens", label: "Tokens", real: true },
{ key: "status", label: "Status", real: true },
];
export const DEFAULT_VISIBLE_COLUMNS: ColumnKey[] = ALL_COLUMNS.map((c) =>
c.key
);
/* -------------------------- honest-empty facets ------------------------- */
export interface HonestFacet {
id: string;
label: string;
/**
* false when the gateway records nothing for this dimension; true when it is
* recorded per entry but has no filter control yet. The two cases get
* different affordance text so neither overstates the other.
*/
recorded?: boolean;
}
export const HONEST_FACETS: HonestFacet[] = [
{ id: "selectedKeys", label: "Selected Keys" },
{ id: "virtualKeys", label: "Virtual Keys" },
{ id: "aliases", label: "Aliases" },
{ id: "routingEngines", label: "Routing Engines" },
{ id: "routingRules", label: "Routing Rules" },
{ id: "user", label: "User" },
{ id: "session", label: "Session" },
// Recorded per entry (costMicroUsd) but a range filter is not built.
{ id: "cost", label: "Cost", recorded: true },
{ id: "stopReason", label: "Stop Reason" },
{ id: "metadata", label: "Metadata" },
];
/* --------------------------- live value facets -------------------------- */
/** A recorded dimension the rail can filter on by exact value. */
export interface ValueFacet {
id: "model" | "provider" | "type";
label: string;
/** Recorded (or derived) value for an entry, or null when it has none. */
valueOf: (entry: LogEntry) => string | null;
/** Rendered with a search box above the option list. */
searchable?: boolean;
}
export const VALUE_FACETS: ValueFacet[] = [
{
id: "model",
label: "Models",
valueOf: (entry) => entry.model ?? null,
searchable: true,
},
{
id: "provider",
label: "Provider",
valueOf: (entry) => entry.provider ?? null,
},
{ id: "type", label: "Type", valueOf: requestType },
];
/** Distinct values of a facet across `entries`, with counts, sorted by value. */
export function facetValues(
entries: LogEntry[],
facet: ValueFacet,
): Array<{ value: string; count: number }> {
const counts = new Map<string, number>();
for (const entry of entries) {
const value = facet.valueOf(entry);
if (value) {
counts.set(value, (counts.get(value) ?? 0) + 1);
}
}
return [...counts.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => a.value.localeCompare(b.value));
}
/** Selected values per live facet id; an empty array means "no constraint". */
export type FacetSelection = Partial<Record<ValueFacet["id"], string[]>>;
/** Applies every non-empty live-facet selection (AND across facets). */
export function applyValueFacets(
entries: LogEntry[],
selection: FacetSelection,
): LogEntry[] {
const active = VALUE_FACETS.filter((facet) =>
(selection[facet.id]?.length ?? 0) > 0
);
if (active.length === 0) {
return entries;
}
return entries.filter((entry) =>
active.every((facet) => {
const value = facet.valueOf(entry);
return value !== null && selection[facet.id]!.includes(value);
})
);
}
export function outcomeCounts(entries: LogEntry[]): OutcomeCounts {
const counts = emptyCounts();
for (const entry of entries) {
counts[classifyOutcome(entry)] += 1;
}
return counts;
}

View File

@ -0,0 +1,187 @@
import { type FormEvent, useState } from "react";
import { Plus } from "lucide-react";
import type { ProviderAccountConfig } from "../../api";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Field } from "../ui/label";
import { NativeSelect } from "../ui/select";
import { Switch } from "../ui/switch";
import { Banner } from "../ui/banner";
import { ToggleGridItem } from "../ui/toggle-grid-item";
import { CUSTOM_BASE_FORMATS, REQUEST_TYPES } from "./constants";
type ProviderType = ProviderAccountConfig["type"];
export interface AddCustomProviderFormProps {
busy: boolean;
onSubmit: (payload: ProviderAccountConfig) => void;
onCancel: () => void;
}
function defaultRequestTypes(): Record<string, boolean> {
return Object.fromEntries(REQUEST_TYPES.map((r) => [r.key, true]));
}
/**
* Inline Add Custom Provider form (spec: Name, Base Format, Base URL, an "Is
* Keyless" switch, and a two-column Allowed Request Types grid). Rendered in the
* detail pane like the standard add form rather than a modal. The config
* contract has no per-endpoint path or request-type storage, so the grid is an
* advisory capability picker (labelled as such) - create posts only the fields
* the gateway persists: id, wire type, base URL, and an optional key.
*/
export function AddCustomProviderForm(
{ busy, onSubmit, onCancel }: AddCustomProviderFormProps,
) {
const [name, setName] = useState("");
const [format, setFormat] = useState<ProviderType>("openai-compatible");
const [baseUrl, setBaseUrl] = useState("");
const [keyless, setKeyless] = useState(false);
const [apiKey, setApiKey] = useState("");
const [allowed, setAllowed] = useState<Record<string, boolean>>(
defaultRequestTypes,
);
const [error, setError] = useState<string | null>(null);
function submit(event: FormEvent) {
event.preventDefault();
const id = name.trim();
if (id === "") {
setError("A name is required.");
return;
}
if (baseUrl.trim() === "") {
setError("A base URL is required for a custom provider.");
return;
}
setError(null);
const payload: ProviderAccountConfig = {
id,
type: format,
enabled: true,
models: [],
priority: 0,
baseUrl: baseUrl.trim(),
};
if (!keyless && apiKey.trim() !== "") {
payload.apiKey = apiKey.trim();
}
onSubmit(payload);
}
return (
<form onSubmit={submit} className="flex flex-col gap-5">
<div>
<h3 className="text-lg font-semibold text-foreground">
Add custom provider
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Point the gateway at any OpenAI- or Anthropic-compatible endpoint.
Keys are stored server-side and never shown again.
</p>
</div>
<div className="field-grid">
<Field id="custom-name" label="Name" required>
<Input
id="custom-name"
value={name}
placeholder="my-gateway"
onChange={(e) => setName(e.target.value)}
/>
</Field>
<Field id="custom-format" label="Base Format">
<NativeSelect
id="custom-format"
value={format}
onChange={(e) => setFormat(e.target.value as ProviderType)}
>
{CUSTOM_BASE_FORMATS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</NativeSelect>
</Field>
<Field id="custom-baseurl" label="Base URL" required>
<Input
id="custom-baseurl"
value={baseUrl}
placeholder="https://api.your-provider.com"
onChange={(e) => setBaseUrl(e.target.value)}
/>
</Field>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border bg-card px-4 py-3">
<label htmlFor="custom-keyless" className="min-w-0 cursor-pointer">
<span className="block text-sm font-medium text-foreground">
Is Keyless?
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
Whether the custom provider requires a key
</span>
</label>
<Switch
id="custom-keyless"
checked={keyless}
onCheckedChange={setKeyless}
aria-label="Is keyless"
/>
</div>
{!keyless && (
<Field id="custom-key" label="API key (optional)">
<Input
id="custom-key"
type="password"
autoComplete="off"
value={apiKey}
placeholder="Add now, or add a key later from the keys table"
onChange={(e) => setApiKey(e.target.value)}
/>
</Field>
)}
<div className="flex flex-col gap-2">
<div>
<p className="text-sm font-medium text-foreground">
Allowed Request Types
</p>
<p className="text-xs text-muted-foreground">
Advisory capability picker. The gateway routes every request type
its wire format supports; per-endpoint path overrides are not
persisted.
</p>
</div>
<div className="field-grid">
{REQUEST_TYPES.map((rt) => (
<ToggleGridItem
key={rt.key}
id={`rt-${rt.key}`}
label={rt.label}
checked={allowed[rt.key] ?? true}
onCheckedChange={(checked) =>
setAllowed((prev) => ({ ...prev, [rt.key]: checked }))}
/>
))}
</div>
</div>
{error && <Banner tone="error">{error}</Banner>}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={busy}
>
Cancel
</Button>
<Button type="submit" isLoading={busy}>
<Plus aria-hidden="true" />
Add provider
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,113 @@
import { useState } from "react";
import { Plus, Search } from "lucide-react";
import { Dialog } from "../ui/dialog";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
import { ProviderIcon } from "../ui/provider-icon";
import { cn } from "../../lib/utils";
import { PROVIDER_PRESETS, type ProviderPreset } from "./constants";
export interface AddProviderDialogProps {
open: boolean;
onClose: () => void;
/** Pick a vendor preset: prefills the add form with its type + base URL. */
onPick: (preset: ProviderPreset) => void;
/** "Custom / other" escape hatch: open the blank / custom-provider flow. */
onCustom: () => void;
}
/**
* One-click provider gallery. Lists the vendor presets as filterable cards with
* their brand logo; picking one prefills the add form. A trailing "Custom" card
* routes to the bring-your-own flow for anything not in the catalog.
*/
export function AddProviderDialog(
{ open, onClose, onPick, onCustom }: AddProviderDialogProps,
) {
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
const matches = needle
? PROVIDER_PRESETS.filter((p) =>
p.displayName.toLowerCase().includes(needle) ||
p.key.toLowerCase().includes(needle) ||
p.type.toLowerCase().includes(needle)
)
: PROVIDER_PRESETS;
return (
<Dialog
open={open}
onClose={onClose}
title="Add a provider"
description="Pick a vendor to prefill its connection, then add your key."
className="max-w-2xl"
>
<div className="flex flex-col gap-3">
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="Search providers"
placeholder="Search providers..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
</div>
<div
role="list"
className="grid max-h-[22rem] grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2"
>
{matches.map((preset) => (
<button
key={preset.key}
type="button"
role="listitem"
onClick={() => onPick(preset)}
className={cn(
"flex items-center gap-3 rounded-lg border border-border",
"bg-card p-3 text-left transition-colors duration-(--motion-fast)",
"hover:border-ring hover:bg-accent",
"focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-ring focus-visible:ring-offset-2",
"focus-visible:ring-offset-background",
)}
>
<ProviderIcon
provider={preset.type}
logoKey={preset.key}
name={preset.displayName}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{preset.displayName}
</span>
<span className="block truncate text-xs text-muted-foreground">
{preset.hint ?? preset.type}
</span>
</span>
</button>
))}
{matches.length === 0 && (
<p className="col-span-full py-6 text-center text-sm text-muted-foreground">
No providers match "{query}".
</p>
)}
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<p className="text-xs text-muted-foreground">
Cannot find it? Add any OpenAI- or Anthropic-compatible endpoint.
</p>
<Button variant="outline" size="sm" onClick={onCustom}>
<Plus aria-hidden="true" />
Custom provider
</Button>
</div>
</div>
</Dialog>
);
}

View File

@ -0,0 +1,333 @@
import { type FormEvent, useState } from "react";
import { Plus } from "lucide-react";
import type { ProviderAccountConfig } from "../../api";
import { Field } from "../ui/label";
import { Input, Textarea } from "../ui/input";
import { NativeSelect } from "../ui/select";
import { Button } from "../ui/button";
import { Banner } from "../ui/banner";
import { CLOUD_TYPES, PROVIDER_LABELS, PROVIDER_TYPES } from "./constants";
type ProviderType = ProviderAccountConfig["type"];
interface FormValues {
id: string;
type: ProviderType;
apiKey: string;
baseUrl: string;
endpoint: string;
apiVersion: string;
modelName: string;
deploymentName: string;
awsRegion: string;
awsAccessKeyId: string;
awsSecretAccessKey: string;
awsSessionToken: string;
projectId: string;
location: string;
serviceAccountJson: string;
}
function empty(): FormValues {
return {
id: "",
type: "openai",
apiKey: "",
baseUrl: "",
endpoint: "",
apiVersion: "",
modelName: "",
deploymentName: "",
awsRegion: "",
awsAccessKeyId: "",
awsSecretAccessKey: "",
awsSessionToken: "",
projectId: "",
location: "",
serviceAccountJson: "",
};
}
function assemble(v: FormValues): ProviderAccountConfig {
const cloud = CLOUD_TYPES.has(v.type);
const out: ProviderAccountConfig = {
id: v.id.trim(),
type: v.type,
enabled: true,
models: [],
priority: 0,
};
if (!cloud && v.apiKey.trim() !== "") {
out.apiKey = v.apiKey.trim();
}
if (v.baseUrl.trim() !== "") {
out.baseUrl = v.baseUrl.trim();
}
if (v.type === "azure") {
if (v.endpoint.trim()) out.endpoint = v.endpoint.trim();
if (v.apiVersion.trim()) out.apiVersion = v.apiVersion.trim();
// Azure routes on the deployment name (the URL segment the client calls as
// `azure/<deployment>`); the model name is a catalog alias. Both feed the
// advertised model list so the account is routable once created.
out.models = [
...new Set([v.deploymentName.trim(), v.modelName.trim()]),
].filter((m) => m !== "");
}
if (v.type === "bedrock") {
if (v.awsRegion.trim()) out.awsRegion = v.awsRegion.trim();
if (v.awsAccessKeyId.trim()) out.awsAccessKeyId = v.awsAccessKeyId.trim();
if (v.awsSecretAccessKey) out.awsSecretAccessKey = v.awsSecretAccessKey;
if (v.awsSessionToken) out.awsSessionToken = v.awsSessionToken;
}
if (v.type === "vertex") {
if (v.projectId.trim()) out.projectId = v.projectId.trim();
if (v.location.trim()) out.location = v.location.trim();
if (v.serviceAccountJson) out.serviceAccountJson = v.serviceAccountJson;
}
return out;
}
export interface AddProviderFormProps {
busy: boolean;
onSubmit: (payload: ProviderAccountConfig) => void;
/** Prefill from a gallery preset (id/type/baseUrl). Remount (via `key`) to reset. */
initial?: Partial<FormValues>;
}
/**
* Inline add-provider form shown in the detail pane when no provider is
* selected. Covers the common path (id, wire type, key, base URL) plus the
* cloud/Azure credential fields, and posts a ProviderAccountConfig on submit.
*/
export function AddProviderForm(
{ busy, onSubmit, initial }: AddProviderFormProps,
) {
const [v, setV] = useState<FormValues>(() => ({ ...empty(), ...initial }));
const [error, setError] = useState<string | null>(null);
const set = <K extends keyof FormValues>(key: K, value: FormValues[K]) =>
setV((prev) => ({ ...prev, [key]: value }));
const cloud = CLOUD_TYPES.has(v.type);
function submit(event: FormEvent) {
event.preventDefault();
if (v.id.trim() === "") {
setError("Provider ID is required.");
return;
}
if (v.type === "azure") {
if (v.endpoint.trim() === "") {
setError("An endpoint is required for Azure OpenAI.");
return;
}
if (v.deploymentName.trim() === "") {
setError("A deployment name is required for Azure OpenAI.");
return;
}
}
if (v.type === "vertex" && v.serviceAccountJson.trim() !== "") {
try {
JSON.parse(v.serviceAccountJson);
} catch {
setError("Service account JSON must be valid JSON.");
return;
}
}
setError(null);
onSubmit(assemble(v));
}
return (
<form onSubmit={submit} className="flex flex-col gap-5">
<div>
<h3 className="text-lg font-semibold text-foreground">Add provider</h3>
<p className="mt-1 text-sm text-muted-foreground">
Connect an account the gateway can route inference to. Keys are stored
server-side and never shown again.
</p>
</div>
<div className="field-grid">
<Field id="add-prov-id" label="ID" required>
<Input
id="add-prov-id"
required
placeholder="openai"
value={v.id}
onChange={(e) => set("id", e.target.value)}
/>
</Field>
<Field id="add-prov-type" label="Type">
<NativeSelect
id="add-prov-type"
value={v.type}
onChange={(e) => set("type", e.target.value as ProviderType)}
>
{PROVIDER_TYPES.map((t) => (
<option key={t} value={t}>{PROVIDER_LABELS[t]}</option>
))}
</NativeSelect>
</Field>
{!cloud && (
<Field id="add-prov-key" label="API key">
<Input
id="add-prov-key"
type="password"
autoComplete="off"
value={v.apiKey}
onChange={(e) => set("apiKey", e.target.value)}
/>
</Field>
)}
{v.type !== "azure" && (
<Field id="add-prov-baseurl" label="Base URL">
<Input
id="add-prov-baseurl"
placeholder="https://host"
value={v.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
/>
</Field>
)}
{v.type === "azure" && (
<>
<Field
id="add-prov-endpoint"
label="Endpoint"
required
hint="The Azure resource endpoint; it replaces the base URL for Azure."
>
<Input
id="add-prov-endpoint"
placeholder="https://my-resource.openai.azure.com"
value={v.endpoint}
onChange={(e) => set("endpoint", e.target.value)}
/>
</Field>
<Field id="add-prov-apiversion" label="API version">
<Input
id="add-prov-apiversion"
placeholder="2024-06-01"
value={v.apiVersion}
onChange={(e) => set("apiVersion", e.target.value)}
/>
</Field>
<Field
id="add-prov-deployment"
label="Deployment name"
required
hint="Clients route to this as azure/<deployment>. Azure addresses it in the request URL."
>
<Input
id="add-prov-deployment"
placeholder="gpt-4o"
value={v.deploymentName}
onChange={(e) => set("deploymentName", e.target.value)}
/>
</Field>
<Field
id="add-prov-modelname"
label="Model name"
hint="The underlying model, added as a catalog alias. Usually the same as the deployment name."
>
<Input
id="add-prov-modelname"
placeholder="gpt-4o"
value={v.modelName}
onChange={(e) => set("modelName", e.target.value)}
/>
</Field>
</>
)}
{v.type === "bedrock" && (
<>
<Field id="add-prov-awsregion" label="AWS region" required>
<Input
id="add-prov-awsregion"
placeholder="us-east-1"
value={v.awsRegion}
onChange={(e) => set("awsRegion", e.target.value)}
/>
</Field>
<Field id="add-prov-awskey" label="AWS access key ID" required>
<Input
id="add-prov-awskey"
value={v.awsAccessKeyId}
onChange={(e) => set("awsAccessKeyId", e.target.value)}
/>
</Field>
<Field
id="add-prov-awssecret"
label="AWS secret access key"
required
>
<Input
id="add-prov-awssecret"
type="password"
autoComplete="off"
value={v.awsSecretAccessKey}
onChange={(e) => set("awsSecretAccessKey", e.target.value)}
/>
</Field>
<Field id="add-prov-awssession" label="AWS session token">
<Input
id="add-prov-awssession"
type="password"
autoComplete="off"
value={v.awsSessionToken}
onChange={(e) => set("awsSessionToken", e.target.value)}
/>
</Field>
</>
)}
{v.type === "vertex" && (
<>
<Field id="add-prov-project" label="Project ID" required>
<Input
id="add-prov-project"
value={v.projectId}
onChange={(e) => set("projectId", e.target.value)}
/>
</Field>
<Field id="add-prov-location" label="Location" required>
<Input
id="add-prov-location"
placeholder="us-central1"
value={v.location}
onChange={(e) => set("location", e.target.value)}
/>
</Field>
<Field
id="add-prov-sajson"
label="Service account JSON"
required
className="field-wide"
>
<Textarea
id="add-prov-sajson"
rows={4}
className="font-mono"
value={v.serviceAccountJson}
onChange={(e) => set("serviceAccountJson", e.target.value)}
/>
</Field>
</>
)}
</div>
{error && <Banner tone="error">{error}</Banner>}
<div className="flex justify-end">
<Button type="submit" isLoading={busy}>
<Plus aria-hidden="true" />
Add provider
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,929 @@
import { type ReactNode, useMemo, useState } from "react";
import { ArrowLeft, Plus } from "lucide-react";
import type { ProviderAccountConfig, ProviderAccountPublic } from "../../api";
import { UnderlineTabs } from "../ui/nav-tabs";
import { tabPanelProps } from "../ui/tabs";
import { NumberField } from "../ui/number-field";
import { Switch } from "../ui/switch";
import { type KeyValuePair, KeyValueRows } from "../ui/key-value-rows";
import { PemTextarea } from "../ui/pem-textarea";
import { Combobox } from "../ui/combobox";
import { SegmentedSelect } from "../ui/segmented-select";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Banner } from "../ui/banner";
import { DataTable } from "../ui/data-table";
import { ProviderIcon } from "../ui/provider-icon";
import { SecretReenter } from "./SecretReenter";
import {
BETA_OVERRIDES,
type BetaHeaderDef,
isCustomProvider,
KNOWN_BETA_HEADERS,
PROVIDER_LABELS,
PROXY_TYPES,
RESET_PERIODS,
} from "./constants";
import { cn } from "../../lib/utils";
import { eurToUsd, usdToEur } from "../../lib/currency";
type ProviderType = ProviderAccountConfig["type"];
type BetaOverride = "default" | "enabled" | "disabled";
interface ConfigForm {
baseUrl: string;
// network
timeoutSec: string;
streamIdleTimeoutSec: string;
maxRetries: string;
initialBackoffMs: string;
maxBackoffMs: string;
maxConnectionsPerHost: string;
enforceHttp2: boolean;
extraHeaders: KeyValuePair[];
skipTlsVerify: boolean;
caCertPem: string;
// proxy
proxyUrl: string;
proxyType: "" | "http" | "https" | "socks5";
proxyUsername: string;
proxyPassword: string;
noProxy: string;
// performance
maxConcurrentRequests: string;
// governance
budgetUsd: string;
budgetResetPeriod: string;
maxTokens: string;
tokensResetPeriod: string;
maxRequests: string;
requestsResetPeriod: string;
// beta headers
betaOverrides: Record<string, BetaOverride>;
// debugging
sendBackRawRequest: boolean;
sendBackRawResponse: boolean;
storeRawReqResp: boolean;
}
const TABS = [
{ value: "network", label: "Network" },
{ value: "proxy", label: "Proxy" },
{ value: "performance", label: "Performance" },
{ value: "governance", label: "Governance" },
{ value: "beta", label: "Beta Headers" },
{ value: "debugging", label: "Debugging" },
];
function numStr(value: number | undefined): string {
return value === undefined || value === null ? "" : String(value);
}
function numOrUndef(value: string): number | undefined {
const trimmed = value.trim();
if (trimmed === "") {
return undefined;
}
const n = Number(trimmed);
return Number.isFinite(n) ? n : undefined;
}
function seedForm(p: ProviderAccountPublic): ConfigForm {
const net = p.network ?? {};
const proxy: NonNullable<ProviderAccountPublic["proxy"]> = p.proxy ?? {
noProxyCount: 0,
};
const gov = p.governance ?? {};
const perf = p.performance ?? {};
const dbg = p.debugging ?? {};
return {
baseUrl: p.baseUrl ?? "",
timeoutSec: numStr(net.timeoutSec),
streamIdleTimeoutSec: numStr(net.streamIdleTimeoutSec),
maxRetries: numStr(net.maxRetries),
initialBackoffMs: numStr(net.initialBackoffMs),
maxBackoffMs: numStr(net.maxBackoffMs),
maxConnectionsPerHost: numStr(net.maxConnectionsPerHost),
enforceHttp2: net.enforceHttp2 ?? false,
// Header values are intentionally write-only. Existing names are rendered
// as metadata below; replacement values start blank.
extraHeaders: [],
skipTlsVerify: net.skipTlsVerify ?? false,
caCertPem: "",
proxyUrl: "",
proxyType: proxy.proxyType ?? "",
proxyUsername: proxy.proxyUsername ?? "",
proxyPassword: "",
noProxy: "",
maxConcurrentRequests: numStr(perf.maxConcurrentRequests),
// Stored canonical USD budget shown to the operator in euros (2dp).
budgetUsd: gov.budgetUsd === undefined
? ""
: numStr(Math.round(usdToEur(gov.budgetUsd) * 100) / 100),
budgetResetPeriod: gov.budgetResetPeriod ?? "",
maxTokens: numStr(gov.maxTokens),
tokensResetPeriod: gov.tokensResetPeriod ?? "",
maxRequests: numStr(gov.maxRequests),
requestsResetPeriod: gov.requestsResetPeriod ?? "",
betaOverrides: { ...(p.betaHeaders?.overrides ?? {}) } as Record<
string,
BetaOverride
>,
sendBackRawRequest: dbg.sendBackRawRequest ?? false,
sendBackRawResponse: dbg.sendBackRawResponse ?? false,
storeRawReqResp: dbg.storeRawReqResp ?? false,
};
}
/* --- group assemblers: produce the persisted shape from the form ------- */
type NetworkCfg = NonNullable<ProviderAccountConfig["network"]>;
type ProxyCfg = NonNullable<ProviderAccountConfig["proxy"]>;
type GovCfg = NonNullable<ProviderAccountConfig["governance"]>;
type ResetPeriod = GovCfg["budgetResetPeriod"];
function assembleNetwork(f: ConfigForm): NetworkCfg {
const out: NetworkCfg = {
enforceHttp2: f.enforceHttp2,
skipTlsVerify: f.skipTlsVerify,
};
const timeoutSec = numOrUndef(f.timeoutSec);
if (timeoutSec !== undefined) out.timeoutSec = timeoutSec;
const streamIdle = numOrUndef(f.streamIdleTimeoutSec);
if (streamIdle !== undefined) out.streamIdleTimeoutSec = streamIdle;
const maxRetries = numOrUndef(f.maxRetries);
if (maxRetries !== undefined) out.maxRetries = maxRetries;
const initialBackoff = numOrUndef(f.initialBackoffMs);
if (initialBackoff !== undefined) out.initialBackoffMs = initialBackoff;
const maxBackoff = numOrUndef(f.maxBackoffMs);
if (maxBackoff !== undefined) out.maxBackoffMs = maxBackoff;
const maxConns = numOrUndef(f.maxConnectionsPerHost);
if (maxConns !== undefined) out.maxConnectionsPerHost = maxConns;
const headers = f.extraHeaders.filter((h) => h.name.trim() !== "");
if (headers.length > 0) out.extraHeaders = headers;
if (f.caCertPem.trim() !== "") out.caCertPem = f.caCertPem;
return out;
}
function assembleProxy(f: ConfigForm): ProxyCfg {
const out: ProxyCfg = {};
if (f.proxyType !== "") out.proxyType = f.proxyType;
if (f.proxyUsername.trim() !== "") out.proxyUsername = f.proxyUsername.trim();
if (f.proxyPassword !== "") out.proxyPassword = f.proxyPassword;
const noProxy = f.noProxy.split(",").map((value) => value.trim()).filter(
Boolean,
);
if (noProxy.length > 0) out.noProxy = noProxy;
return out;
}
function assembleGovernance(f: ConfigForm): GovCfg {
const out: GovCfg = {};
// The field is entered in euros; store the canonical USD budget.
const budgetEur = numOrUndef(f.budgetUsd);
if (budgetEur !== undefined) out.budgetUsd = eurToUsd(budgetEur);
if (f.budgetResetPeriod) {
out.budgetResetPeriod = f.budgetResetPeriod as ResetPeriod;
}
const maxTokens = numOrUndef(f.maxTokens);
if (maxTokens !== undefined) out.maxTokens = maxTokens;
if (f.tokensResetPeriod) {
out.tokensResetPeriod = f.tokensResetPeriod as ResetPeriod;
}
const maxRequests = numOrUndef(f.maxRequests);
if (maxRequests !== undefined) out.maxRequests = maxRequests;
if (f.requestsResetPeriod) {
out.requestsResetPeriod = f.requestsResetPeriod as ResetPeriod;
}
return out;
}
function stable(obj: object): string {
// The assemblers build keys in a fixed order and omit empty fields, so a
// plain stringify is a deterministic, comparable signature for the diff.
return JSON.stringify(obj);
}
export interface ProviderConfigPanelProps {
provider: ProviderAccountPublic;
busy: boolean;
onSave: (patch: Partial<ProviderAccountConfig>) => void;
onRemove: () => void;
onBack: () => void;
}
/**
* Full-width 6-tab provider configuration panel (Network / Proxy / Performance
* / Governance / Beta Headers / Debugging) wired to ProviderAccountConfig with
* a sticky Save / Remove footer. Groups are diffed against the loaded state so
* unchanged groups (which still hold server-side secrets the browser never
* sees) are never re-sent through the gateway's shallow-merge PUT.
*/
export function ProviderConfigPanel(
{ provider, busy, onSave, onRemove, onBack }: ProviderConfigPanelProps,
) {
const initial = useMemo(() => seedForm(provider), [provider]);
const [form, setForm] = useState<ConfigForm>(initial);
const [tab, setTab] = useState("network");
const [customPrefix, setCustomPrefix] = useState("");
const set = <K extends keyof ConfigForm>(key: K, value: ConfigForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const networkDirty = stable(assembleNetwork(form)) !==
stable(assembleNetwork(initial));
const proxyDirty = stable(assembleProxy(form)) !==
stable(assembleProxy(initial));
const govDirty = stable(assembleGovernance(form)) !==
stable(assembleGovernance(initial));
const betaDirty = JSON.stringify(form.betaOverrides) !==
JSON.stringify(initial.betaOverrides);
const perfDirty =
form.maxConcurrentRequests !== initial.maxConcurrentRequests;
const dbgDirty = form.sendBackRawRequest !== initial.sendBackRawRequest ||
form.sendBackRawResponse !== initial.sendBackRawResponse ||
form.storeRawReqResp !== initial.storeRawReqResp;
const baseUrlDirty = form.baseUrl !== initial.baseUrl;
const proxyUrlDirty = form.proxyUrl !== "";
const certAtRisk = networkDirty && (provider.hasCaCert ?? false) &&
form.caCertPem.trim() === "";
const headersAtRisk = networkDirty &&
(provider.network?.extraHeaders ?? []).some((stored) =>
!form.extraHeaders.some(
(replacement) =>
replacement.name.trim().toLowerCase() === stored.name.toLowerCase() &&
replacement.value !== "",
)
);
const proxyPassAtRisk = proxyDirty && (provider.hasProxyPassword ?? false) &&
form.proxyPassword === "";
const noProxyAtRisk = proxyDirty && (provider.proxy?.noProxyCount ?? 0) > 0 &&
form.noProxy.trim() === "";
function save() {
const patch: Partial<ProviderAccountConfig> = {};
if (baseUrlDirty) {
patch.baseUrl = form.baseUrl.trim();
}
if (networkDirty) {
patch.network = assembleNetwork(form);
}
if (proxyUrlDirty) {
patch.proxyUrl = form.proxyUrl.trim();
}
if (proxyDirty) {
patch.proxy = assembleProxy(form);
}
if (perfDirty) {
const mc = numOrUndef(form.maxConcurrentRequests);
patch.performance = mc === undefined ? {} : { maxConcurrentRequests: mc };
}
if (govDirty) {
patch.governance = assembleGovernance(form);
}
if (betaDirty) {
patch.betaHeaders = { overrides: form.betaOverrides };
}
if (dbgDirty) {
patch.debugging = {
sendBackRawRequest: form.sendBackRawRequest,
sendBackRawResponse: form.sendBackRawResponse,
storeRawReqResp: form.storeRawReqResp,
};
}
onSave(patch);
}
const label = PROVIDER_LABELS[provider.type as ProviderType] ?? provider.type;
return (
<div className="flex min-h-0 flex-col">
<div className="mb-4 flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
aria-label="Back to provider keys"
onClick={onBack}
>
<ArrowLeft />
</Button>
<ProviderIcon
provider={provider.type}
name={provider.id}
custom={isCustomProvider(provider.type as ProviderType)}
size="sm"
/>
<div className="min-w-0">
<h3 className="truncate text-lg font-semibold text-foreground">
{provider.id}
</h3>
<p className="text-xs text-muted-foreground">{label} configuration</p>
</div>
</div>
<UnderlineTabs
label="Provider configuration"
value={tab}
onValueChange={setTab}
tabs={TABS}
className="mb-5"
/>
<div {...tabPanelProps(tab)} className="min-h-0">
{tab === "network" && (
<NetworkTab
form={form}
set={set}
hasCaCert={provider.hasCaCert ?? false}
certAtRisk={certAtRisk}
existingHeaders={provider.network?.extraHeaders ?? []}
headersAtRisk={headersAtRisk}
/>
)}
{tab === "proxy" && (
<ProxyTab
form={form}
set={set}
hasProxy={provider.hasProxy ?? false}
hasProxyPassword={provider.hasProxyPassword ?? false}
proxyPassAtRisk={proxyPassAtRisk}
noProxyCount={provider.proxy?.noProxyCount ?? 0}
noProxyAtRisk={noProxyAtRisk}
/>
)}
{tab === "performance" && <PerformanceTab form={form} set={set} />}
{tab === "governance" && <GovernanceTab form={form} set={set} />}
{tab === "beta" && (
<BetaHeadersTab
form={form}
set={set}
customPrefix={customPrefix}
setCustomPrefix={setCustomPrefix}
/>
)}
{tab === "debugging" && <DebuggingTab form={form} set={set} />}
</div>
<div className="sticky bottom-0 z-(--z-sticky) -mx-6 mt-6 flex items-center justify-between gap-2 border-t border-border bg-background px-6 py-3">
<Button
type="button"
variant="destructive-outline"
onClick={onRemove}
disabled={busy}
>
Remove configuration
</Button>
<Button type="button" onClick={save} isLoading={busy}>
Save configuration
</Button>
</div>
</div>
);
}
/* ------------------------------- tabs ---------------------------------- */
interface TabProps {
form: ConfigForm;
set: <K extends keyof ConfigForm>(key: K, value: ConfigForm[K]) => void;
}
function SectionTitle({ children }: { children: ReactNode }) {
return <h4 className="text-sm font-semibold text-foreground">{children}</h4>;
}
function NetworkTab(
{ form, set, hasCaCert, certAtRisk, existingHeaders, headersAtRisk }:
& TabProps
& {
hasCaCert: boolean;
certAtRisk: boolean;
existingHeaders: Array<{ name: string; hasValue: boolean }>;
headersAtRisk: boolean;
},
) {
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cfg-baseurl">Base URL (Optional)</Label>
<Input
id="cfg-baseurl"
placeholder="https://api.example.com"
value={form.baseUrl}
onChange={(e) => set("baseUrl", e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<NumberField
label="Timeout"
unit="sec"
min={1}
value={form.timeoutSec}
onChange={(v) => set("timeoutSec", v)}
placeholder="30"
/>
<NumberField
label="Stream Idle Timeout"
unit="sec"
min={1}
value={form.streamIdleTimeoutSec}
onChange={(v) => set("streamIdleTimeoutSec", v)}
placeholder="60"
help="Max wait for the next chunk before closing a stalled stream."
/>
<NumberField
label="Max Retries"
min={0}
value={form.maxRetries}
onChange={(v) => set("maxRetries", v)}
placeholder="0"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<NumberField
label="Initial Backoff"
unit="ms"
min={0}
value={form.initialBackoffMs}
onChange={(v) => set("initialBackoffMs", v)}
placeholder="500"
/>
<NumberField
label="Max Backoff"
unit="ms"
min={0}
value={form.maxBackoffMs}
onChange={(v) => set("maxBackoffMs", v)}
placeholder="5000"
/>
<NumberField
label="Max Connections Per Host"
min={1}
value={form.maxConnectionsPerHost}
onChange={(v) => set("maxConnectionsPerHost", v)}
placeholder="5000"
help="Max TCP connections per provider host."
/>
</div>
<ToggleRow
id="cfg-http2"
label="Enforce HTTP/2"
description="Force HTTP/2 on provider connections. Each HTTP/2 connection supports ~100 concurrent streams."
checked={form.enforceHttp2}
onCheckedChange={(v) => set("enforceHttp2", v)}
/>
<div className="flex flex-col gap-2">
<SectionTitle>Extra Headers</SectionTitle>
{existingHeaders.length > 0 && (
<p className="text-sm text-muted-foreground">
Stored (values hidden): {existingHeaders.map((header) =>
header.name
).join(", ")}
</p>
)}
<KeyValueRows
value={form.extraHeaders}
onChange={(rows) => set("extraHeaders", rows)}
namePlaceholder="Header name"
valuePlaceholder="Header value"
valueInputType="password"
addLabel="Add header"
idPrefix="cfg-hdr"
/>
{headersAtRisk && (
<Banner tone="warn">
Saving network changes without re-entering the stored headers will
clear them. Add replacement values to keep them.
</Banner>
)}
</div>
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<SectionTitle>TLS / Certificate</SectionTitle>
<ToggleRow
id="cfg-skiptls"
label="Skip TLS verification"
description="Disable certificate verification for provider connections. Use only as a last resort; prefer a CA certificate for self-signed or private CA deployments."
checked={form.skipTlsVerify}
onCheckedChange={(v) => set("skipTlsVerify", v)}
/>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="cfg-cacert">CA Certificate (PEM) (Optional)</Label>
{hasCaCert && form.caCertPem.trim() === "" && (
<Badge tone="muted">Configured</Badge>
)}
</div>
<PemTextarea
id="cfg-cacert"
value={form.caCertPem}
onChange={(v) => set("caCertPem", v)}
hint={hasCaCert
? "A certificate is already stored (not shown). Paste a new one to replace it."
: "PEM-encoded CA certificate to trust for provider connections."}
/>
{certAtRisk && (
<Banner tone="warn">
Saving network changes without re-entering the certificate will
clear the stored one. Paste it again to keep it.
</Banner>
)}
</div>
</div>
</div>
);
}
function ProxyTab(
{
form,
set,
hasProxy,
hasProxyPassword,
proxyPassAtRisk,
noProxyCount,
noProxyAtRisk,
}: TabProps & {
hasProxy: boolean;
hasProxyPassword: boolean;
proxyPassAtRisk: boolean;
noProxyCount: number;
noProxyAtRisk: boolean;
},
) {
return (
<div className="flex max-w-2xl flex-col gap-5">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="cfg-proxyurl">Proxy URL</Label>
{hasProxy && form.proxyUrl === "" && (
<Badge tone="muted">Configured</Badge>
)}
</div>
<SecretReenter
id="cfg-proxyurl"
label="Proxy URL"
configured={hasProxy}
value={form.proxyUrl}
onChange={(v) => set("proxyUrl", v)}
placeholder="http://user:pass@proxy.internal:8080"
configuredHint="A proxy URL is already stored (it may embed credentials, so it is not shown). Replace it or leave it as is."
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="cfg-noproxy">No-proxy hosts</Label>
{noProxyCount > 0 && form.noProxy.trim() === "" && (
<Badge tone="muted">{noProxyCount} configured</Badge>
)}
</div>
<Input
id="cfg-noproxy"
autoComplete="off"
value={form.noProxy}
onChange={(e) => set("noProxy", e.target.value)}
placeholder=".internal, *.corp.example"
/>
<p className="text-sm text-muted-foreground">
Comma-separated hosts that bypass this provider's proxy. Existing
rules are hidden; enter replacements to change them.
</p>
{noProxyAtRisk && (
<Banner tone="warn">
Saving proxy changes without re-entering the bypass rules will clear
them.
</Banner>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label>Proxy Type</Label>
<SegmentedSelect
label="Proxy type"
options={PROXY_TYPES}
value={form.proxyType === "" ? "http" : form.proxyType}
onChange={(v) => set("proxyType", v)}
/>
<p className="text-sm text-muted-foreground">
Advisory: the transport is taken from the proxy URL scheme.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cfg-proxyuser">Proxy Username</Label>
<Input
id="cfg-proxyuser"
autoComplete="off"
value={form.proxyUsername}
onChange={(e) => set("proxyUsername", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="cfg-proxypass">Proxy Password</Label>
{hasProxyPassword && form.proxyPassword === "" && (
<Badge tone="muted">Configured</Badge>
)}
</div>
<SecretReenter
id="cfg-proxypass"
label="Proxy password"
configured={hasProxyPassword}
value={form.proxyPassword}
onChange={(v) => set("proxyPassword", v)}
/>
{proxyPassAtRisk && (
<Banner tone="warn">
Saving proxy changes without re-entering the password will clear the
stored one.
</Banner>
)}
</div>
</div>
);
}
function PerformanceTab({ form, set }: TabProps) {
return (
<div className="max-w-md">
<NumberField
label="Max Concurrent Requests"
min={1}
value={form.maxConcurrentRequests}
onChange={(v) => set("maxConcurrentRequests", v)}
placeholder="Unlimited"
help="Cap on in-flight requests to this provider."
/>
</div>
);
}
function GovernanceTab({ form, set }: TabProps) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<SectionTitle>Budget Configuration</SectionTitle>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_12rem]">
<NumberField
label="Maximum Spend (EUR)"
min={0}
step={0.01}
value={form.budgetUsd}
onChange={(v) => set("budgetUsd", v)}
placeholder="100"
/>
<ResetPeriodField
id="cfg-budget-period"
value={form.budgetResetPeriod}
onChange={(v) => set("budgetResetPeriod", v)}
/>
</div>
</div>
<div className="h-px bg-border" />
<div className="flex flex-col gap-3">
<SectionTitle>Rate Limiting Configuration</SectionTitle>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_12rem]">
<NumberField
label="Maximum Tokens"
min={0}
value={form.maxTokens}
onChange={(v) => set("maxTokens", v)}
placeholder="100"
/>
<ResetPeriodField
id="cfg-tokens-period"
value={form.tokensResetPeriod}
onChange={(v) => set("tokensResetPeriod", v)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[1fr_12rem]">
<NumberField
label="Maximum Requests"
min={0}
value={form.maxRequests}
onChange={(v) => set("maxRequests", v)}
placeholder="100"
/>
<ResetPeriodField
id="cfg-requests-period"
value={form.requestsResetPeriod}
onChange={(v) => set("requestsResetPeriod", v)}
/>
</div>
</div>
</div>
);
}
function ResetPeriodField(
{ id, value, onChange }: {
id: string;
value: string;
onChange: (value: string) => void;
},
) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>Reset Period</Label>
<Combobox
id={id}
label="Reset period"
options={RESET_PERIODS}
value={value || null}
onChange={onChange}
placeholder="Select period"
/>
</div>
);
}
interface BetaRow extends BetaHeaderDef {
custom: boolean;
}
function BetaHeadersTab(
{ form, set, customPrefix, setCustomPrefix }: TabProps & {
customPrefix: string;
setCustomPrefix: (value: string) => void;
},
) {
const rows = useMemo<BetaRow[]>(() => {
const known = KNOWN_BETA_HEADERS.map((h) => ({ ...h, custom: false }));
const extra = Object.keys(form.betaOverrides)
.filter((prefix) => !KNOWN_BETA_HEADERS.some((h) => h.prefix === prefix))
.map((prefix) => ({
prefix,
description: "Custom prefix",
custom: true,
}));
return [...known, ...extra];
}, [form.betaOverrides]);
function setOverride(prefix: string, value: BetaOverride) {
const next = { ...form.betaOverrides };
if (value === "default") {
delete next[prefix];
} else {
next[prefix] = value;
}
set("betaOverrides", next);
}
function addCustom() {
const prefix = customPrefix.trim();
if (prefix === "") {
return;
}
set("betaOverrides", { ...form.betaOverrides, [prefix]: "enabled" });
setCustomPrefix("");
}
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Configure which Anthropic beta headers are allowed for this provider.
Override the defaults when a provider adds or removes support for a beta
feature.
</p>
<DataTable
caption="Beta headers"
rows={rows}
getRowId={(row) => row.prefix}
minWidth="42rem"
columns={[
{
key: "header",
header: "Beta Header",
cell: (row) => (
<div className="flex flex-col">
<span className="font-mono text-sm text-foreground">
{row.prefix}*
</span>
<span className="text-xs text-muted-foreground">
{row.description}
</span>
</div>
),
},
{
key: "default",
header: "Default",
width: "8rem",
cell: (row) =>
row.custom
? <span className="text-sm text-muted-foreground">-</span>
: <Badge tone="muted">Supported</Badge>,
},
{
key: "override",
header: "Override",
width: "16rem",
cell: (row) => (
<SegmentedSelect
size="sm"
label={`${row.prefix} override`}
options={BETA_OVERRIDES}
value={form.betaOverrides[row.prefix] ?? "default"}
onChange={(v) => setOverride(row.prefix, v)}
/>
),
},
]}
/>
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="cfg-beta-custom">Add custom beta header prefix</Label>
<Input
id="cfg-beta-custom"
placeholder="new-feature-"
value={customPrefix}
onChange={(e) => setCustomPrefix(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCustom();
}
}}
/>
</div>
<Button variant="outline" onClick={addCustom}>
<Plus aria-hidden="true" />
Add
</Button>
</div>
</div>
);
}
function DebuggingTab({ form, set }: TabProps) {
return (
<div className="flex max-w-3xl flex-col gap-4">
<ToggleRow
id="cfg-raw-req"
label="Send Back Raw Request"
description="Include the raw provider request alongside the parsed request in the API response."
checked={form.sendBackRawRequest}
onCheckedChange={(v) => set("sendBackRawRequest", v)}
/>
<ToggleRow
id="cfg-raw-resp"
label="Send Back Raw Response"
description="Include the raw provider response alongside the parsed response in the API response."
checked={form.sendBackRawResponse}
onCheckedChange={(v) => set("sendBackRawResponse", v)}
/>
<ToggleRow
id="cfg-store-raw"
label="Store Raw Request/Response"
description="Persist raw request and response payloads in log records."
checked={form.storeRawReqResp}
onCheckedChange={(v) => set("storeRawReqResp", v)}
/>
</div>
);
}
function ToggleRow(
{ id, label, description, checked, onCheckedChange }: {
id: string;
label: string;
description: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
},
) {
return (
<div
className={cn(
"flex items-start justify-between gap-4 rounded-md border border-border",
"bg-card px-4 py-3",
)}
>
<label htmlFor={id} className="min-w-0 cursor-pointer">
<span className="block text-sm font-medium text-foreground">
{label}
</span>
<span className="mt-0.5 block text-xs text-muted-foreground">
{description}
</span>
</label>
<Switch
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
aria-label={label}
/>
</div>
);
}

View File

@ -0,0 +1,134 @@
import { useId, useState } from "react";
import { Check, Eye, EyeOff, KeyRound } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
export interface SecretReenterProps {
/** Whether a secret is already stored server-side (a hasX marker). */
configured: boolean;
/** The new secret to submit; empty string means "keep the current value". */
value: string;
onChange: (value: string) => void;
/** Accessible name, e.g. "API key", "Proxy password". */
label: string;
id?: string;
placeholder?: string;
/** Copy shown next to the "Configured" marker while not replacing. */
configuredHint?: string;
className?: string;
}
/**
* Secret re-entry field (taste: never render stored secret values). When a
* secret is already configured we show a "Configured" marker plus a "Replace"
* button; only after the operator opts in does an input appear to submit a new
* value. When nothing is configured the input shows immediately. The stored
* value is never sent to the browser, so there is nothing to reveal - reveal
* toggles only the operator's freshly typed replacement.
*/
export function SecretReenter(
{
configured,
value,
onChange,
label,
id,
placeholder,
configuredHint = "A value is already stored. Replace it or leave it as is.",
className,
}: SecretReenterProps,
) {
const generatedId = useId();
const fieldId = id ?? generatedId;
// Replacing is implied when nothing is configured, or once the operator opts
// in. Typing keeps the input open even if they clear it back to empty.
const [replacing, setReplacing] = useState(!configured);
const [reveal, setReveal] = useState(false);
if (configured && !replacing) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span
className={cn(
"inline-flex h-(--control-h) min-w-0 flex-1 items-center gap-2",
"rounded-md border border-input bg-muted/40 px-3",
"text-sm text-muted-foreground",
)}
>
<KeyRound aria-hidden="true" className="size-4 shrink-0" />
<span className="truncate">Configured</span>
<span aria-hidden="true" className="font-mono tracking-widest">
••••••••
</span>
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
setReplacing(true);
onChange("");
}}
aria-label={`Replace ${label}`}
>
Replace
</Button>
</div>
);
}
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<input
id={fieldId}
type={reveal ? "text" : "password"}
autoComplete="off"
spellCheck={false}
aria-label={label}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className={cn(
"h-(--control-h) w-full rounded-md border border-input bg-card",
"pl-3 pr-10 font-mono text-sm text-foreground shadow-sm",
"placeholder:font-sans placeholder:text-muted-foreground",
)}
/>
<button
type="button"
aria-pressed={reveal}
aria-label={reveal ? `Hide ${label}` : `Show ${label}`}
onClick={() => setReveal((prev) => !prev)}
className={cn(
"hit-target absolute right-1 top-1/2 inline-flex size-7 -translate-y-1/2",
"items-center justify-center rounded-md text-muted-foreground",
"transition-colors duration-(--motion-fast)",
"hover:bg-accent hover:text-foreground [&_svg]:size-4",
)}
>
{reveal
? <EyeOff aria-hidden="true" />
: <Eye aria-hidden="true" />}
</button>
</div>
{configured && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setReplacing(false);
onChange("");
}}
>
<Check aria-hidden="true" />
Keep current
</Button>
)}
</div>
{configured && (
<p className="text-sm text-muted-foreground">{configuredHint}</p>
)}
</div>
);
}

View File

@ -0,0 +1,343 @@
import type { ProviderAccountConfig } from "../../api";
export type ProviderType = ProviderAccountConfig["type"];
/** Every wire type the gateway can route to (contract order). */
export const PROVIDER_TYPES: ProviderType[] = [
"openai",
"anthropic",
"azure",
"gemini",
"openrouter",
"groq",
"mistral",
"ollama",
"xai",
"perplexity",
"cerebras",
"nebius",
"sgl",
"parasail",
"huggingface",
"cohere",
"bedrock",
"vertex",
"elevenlabs",
"openai-compatible",
"anthropic-compatible",
"lmstudio",
];
/** Cloud providers whose auth is credential-based, not a single API key. */
export const CLOUD_TYPES: ReadonlySet<ProviderType> = new Set([
"bedrock",
"vertex",
]);
/**
* Bring-your-own wire types: the ones that need an operator-supplied base URL
* and render the "CUSTOM" chip in the provider list (taste + spec).
*/
export const CUSTOM_TYPES: ReadonlySet<ProviderType> = new Set([
"openai-compatible",
"anthropic-compatible",
"lmstudio",
]);
/** Human labels for the wire types (title-cased, provider identities preserved). */
export const PROVIDER_LABELS: Record<ProviderType, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
azure: "Azure OpenAI",
gemini: "Gemini",
openrouter: "OpenRouter",
groq: "Groq",
mistral: "Mistral AI",
ollama: "Ollama",
xai: "xAI",
perplexity: "Perplexity",
cerebras: "Cerebras",
nebius: "Nebius",
sgl: "SGLang",
parasail: "Parasail",
huggingface: "HuggingFace",
cohere: "Cohere",
bedrock: "AWS Bedrock",
vertex: "Vertex AI",
elevenlabs: "Elevenlabs",
"openai-compatible": "OpenAI-compatible",
"anthropic-compatible": "Anthropic-compatible",
lmstudio: "LM Studio",
};
/** True when a config's type is a bring-your-own (custom) provider. */
export function isCustomProvider(type: ProviderType): boolean {
return CUSTOM_TYPES.has(type);
}
/** Base formats offered in the Add Custom Provider modal (wire compatibility). */
export const CUSTOM_BASE_FORMATS: { value: ProviderType; label: string }[] = [
{ value: "openai-compatible", label: "OpenAI-compatible" },
{ value: "anthropic-compatible", label: "Anthropic-compatible" },
{ value: "lmstudio", label: "LM Studio" },
];
/**
* One-click vendor catalog for the "Add provider" gallery. Every preset maps to
* a real backend wire `type`; picking one prefills the add form (suggested id +
* type + default base URL). `key` doubles as the suggested account id AND the
* brand-logo key, so the list icon resolves the vendor logo from the account id
* (see provider-logos.tsx). `needsExtraConfig` marks vendors that require more
* than an API key (Azure endpoint, cloud credentials) so the gallery routes them
* to the full form rather than implying a one-field add.
*/
export interface ProviderPreset {
key: string;
displayName: string;
type: ProviderType;
baseUrl?: string;
needsExtraConfig?: boolean;
hint?: string;
}
export const PROVIDER_PRESETS: ProviderPreset[] = [
// First-party / hosted (the wire type carries the brand identity).
{ key: "openai", displayName: "OpenAI", type: "openai" },
{ key: "anthropic", displayName: "Anthropic", type: "anthropic" },
{
key: "azure",
displayName: "Azure OpenAI",
type: "azure",
needsExtraConfig: true,
hint: "Needs endpoint + API version",
},
{ key: "gemini", displayName: "Google Gemini", type: "gemini" },
{
key: "openrouter",
displayName: "OpenRouter",
type: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
},
{
key: "groq",
displayName: "Groq",
type: "groq",
baseUrl: "https://api.groq.com/openai/v1",
},
{
key: "mistral",
displayName: "Mistral AI",
type: "mistral",
baseUrl: "https://api.mistral.ai/v1",
},
{
key: "xai",
displayName: "xAI (Grok)",
type: "xai",
baseUrl: "https://api.x.ai/v1",
},
{
key: "perplexity",
displayName: "Perplexity",
type: "perplexity",
baseUrl: "https://api.perplexity.ai",
},
{
key: "cerebras",
displayName: "Cerebras",
type: "cerebras",
baseUrl: "https://api.cerebras.ai/v1",
},
{ key: "cohere", displayName: "Cohere", type: "cohere" },
{ key: "huggingface", displayName: "Hugging Face", type: "huggingface" },
{ key: "elevenlabs", displayName: "ElevenLabs", type: "elevenlabs" },
{ key: "nebius", displayName: "Nebius", type: "nebius" },
{ key: "parasail", displayName: "Parasail", type: "parasail" },
{
key: "bedrock",
displayName: "AWS Bedrock",
type: "bedrock",
needsExtraConfig: true,
hint: "Needs AWS credentials",
},
{
key: "vertex",
displayName: "Google Vertex AI",
type: "vertex",
needsExtraConfig: true,
hint: "Needs a service account",
},
// OpenAI-wire vendors (need an operator-supplied base URL).
{
key: "zai",
displayName: "Z.ai (GLM)",
type: "openai-compatible",
baseUrl: "https://api.z.ai/api/paas/v4",
},
{
key: "minimax",
displayName: "MiniMax",
type: "openai-compatible",
baseUrl: "https://api.minimax.io/v1",
},
{
key: "moonshot",
displayName: "Moonshot (Kimi)",
type: "openai-compatible",
baseUrl: "https://api.moonshot.ai/v1",
},
{
key: "deepseek",
displayName: "DeepSeek",
type: "openai-compatible",
baseUrl: "https://api.deepseek.com/v1",
},
{
key: "together",
displayName: "Together AI",
type: "openai-compatible",
baseUrl: "https://api.together.xyz/v1",
},
{
key: "fireworks",
displayName: "Fireworks AI",
type: "openai-compatible",
baseUrl: "https://api.fireworks.ai/inference/v1",
},
{
key: "deepinfra",
displayName: "DeepInfra",
type: "openai-compatible",
baseUrl: "https://api.deepinfra.com/v1/openai",
},
{
key: "vllm",
displayName: "vLLM",
type: "openai-compatible",
baseUrl: "http://localhost:8000/v1",
hint: "Self-hosted",
},
// Local runtimes.
{
key: "lmstudio",
displayName: "LM Studio",
type: "lmstudio",
baseUrl: "http://localhost:1234/v1",
hint: "Local",
},
{
key: "ollama",
displayName: "Ollama",
type: "ollama",
baseUrl: "http://localhost:11434",
hint: "Local",
},
];
/** Governance reset-period options (contract enum). */
export const RESET_PERIODS: { value: string; label: string }[] = [
{ value: "hourly", label: "Hourly" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "monthly", label: "Monthly" },
];
/** Proxy transport options (advisory; the scheme in proxyUrl is authoritative). */
export const PROXY_TYPES: {
value: "http" | "https" | "socks5";
label: string;
}[] = [
{ value: "http", label: "HTTP" },
{ value: "https", label: "HTTPS" },
{ value: "socks5", label: "SOCKS5" },
];
/** Beta-header override options (contract enum). */
export const BETA_OVERRIDES: {
value: "default" | "enabled" | "disabled";
label: string;
}[] = [
{ value: "default", label: "Default" },
{ value: "enabled", label: "Enabled" },
{ value: "disabled", label: "Disabled" },
];
export interface BetaHeaderDef {
prefix: string;
description: string;
}
/**
* Known Anthropic beta-header prefixes shown in the Beta Headers tab. Operators
* can add custom prefixes; overrides persist to betaHeaders.overrides.
*/
export const KNOWN_BETA_HEADERS: BetaHeaderDef[] = [
{ prefix: "computer-use-", description: "Computer use client tool" },
{
prefix: "structured-outputs-",
description: "Strict tool validation and output_format",
},
{
prefix: "advanced-tool-use-",
description: "defer_loading, input_examples, allowed_callers",
},
{ prefix: "mcp-client-", description: "MCP connector support" },
{
prefix: "prompt-caching-scope-",
description: "Prompt caching scope control",
},
{ prefix: "compact-", description: "Server-side context compaction" },
{
prefix: "context-management-",
description: "Context editing (clear_tool_uses, clear_thinking)",
},
{ prefix: "files-api-", description: "Files API support" },
{
prefix: "interleaved-thinking-",
description: "Interleaved thinking between tool calls",
},
{ prefix: "skills-", description: "Agent Skills" },
{
prefix: "context-1m-",
description: "1M context window (beta for Sonnet 4.5/4)",
},
{
prefix: "fast-mode-",
description: "Fast mode (Opus 4.6 research preview)",
},
{
prefix: "redact-thinking-",
description: "Redact thinking blocks in responses",
},
];
export interface RequestTypeDef {
key: string;
label: string;
}
/**
* The endpoints a custom provider may advertise (Add Custom Provider grid).
* Toggled client-side; the create payload records only the base fields the
* config contract supports, so these read as capability hints (honest surface).
*/
export const REQUEST_TYPES: RequestTypeDef[] = [
{ key: "listModels", label: "List Models" },
{ key: "speechStream", label: "Speech Stream" },
{ key: "textCompletion", label: "Text Completion" },
{ key: "transcription", label: "Transcription" },
{ key: "textCompletionStream", label: "Text Completion Stream" },
{ key: "transcriptionStream", label: "Transcription Stream" },
{ key: "chatCompletion", label: "Chat Completion" },
{ key: "imageGeneration", label: "Image Generation" },
{ key: "chatCompletionStream", label: "Chat Completion Stream" },
{ key: "imageGenerationStream", label: "Image Generation Stream" },
{ key: "responses", label: "Responses" },
{ key: "imageEdit", label: "Image Edit" },
{ key: "responsesStream", label: "Responses Stream" },
{ key: "imageEditStream", label: "Image Edit Stream" },
{ key: "embedding", label: "Embedding" },
{ key: "imageVariation", label: "Image Variation" },
{ key: "speech", label: "Speech" },
{ key: "countTokens", label: "Count Tokens" },
];

View File

@ -0,0 +1,137 @@
import { useState } from "react";
import { clearCache, deleteCacheEntry } from "../../api";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Button } from "../ui/button";
import { Field } from "../ui/label";
import { Textarea } from "../ui/input";
import { ConfirmDialog } from "../ui/dialog";
import { useToast } from "../ui/toast";
const MAX_BODY_BYTES = 2 * 1024 * 1024;
export function CacheOpsPanel() {
const toast = useToast();
const [confirmPurgeAll, setConfirmPurgeAll] = useState(false);
const [purgingAll, setPurgingAll] = useState(false);
const [body, setBody] = useState("");
const [purgingOne, setPurgingOne] = useState(false);
function parsedBody(): unknown | undefined {
if (!body.trim() || body.length > MAX_BODY_BYTES) {
return undefined;
}
try {
return JSON.parse(body);
} catch {
return undefined;
}
}
const bodyError =
body.trim() && body.length <= MAX_BODY_BYTES && parsedBody() === undefined
? "Not valid JSON."
: body.length > MAX_BODY_BYTES
? "Request JSON is too large (over 2 MB)."
: null;
const bodyReady = body.trim() !== "" && bodyError === null;
function purgeAll() {
setPurgingAll(true);
setConfirmPurgeAll(false);
clearCache()
.then((res) => toast.success(`Cleared ${res.cleared} cached entries`))
.catch((err) =>
toast.error(err instanceof Error ? err.message : String(err))
)
.finally(() => setPurgingAll(false));
}
function purgeOne() {
const parsed = parsedBody();
if (parsed === undefined) {
return;
}
setPurgingOne(true);
deleteCacheEntry(parsed)
.then((res) => {
if (res.deleted) {
toast.success("Cache entry deleted");
} else {
toast.info("No matching cache entry");
}
})
.catch((err) =>
toast.error(err instanceof Error ? err.message : String(err))
)
.finally(() => setPurgingOne(false));
}
return (
<div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Purge everything</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
Every cached completion is dropped immediately. Identical requests
will hit providers again and incur cost. When caching is disabled
(FROSTY_CACHE unset) this is a no-op.
</p>
<Button
variant="destructive"
isLoading={purgingAll}
onClick={() => setConfirmPurgeAll(true)}
>
Purge cache
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Purge one entry</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
Paste the exact request body that produced the cached completion.
</p>
<Field id="cache-key" label="Request JSON" className="measure">
<Textarea
id="cache-key"
rows={8}
className="font-mono"
value={body}
aria-invalid={bodyError ? true : undefined}
onChange={(e) => setBody(e.target.value)}
/>
</Field>
{bodyError && (
<p className="mt-2 text-sm text-destructive" role="alert">
{bodyError}
</p>
)}
<div className="mt-3">
<Button
disabled={!bodyReady}
isLoading={purgingOne}
onClick={purgeOne}
>
Purge entry
</Button>
</div>
</CardContent>
</Card>
<ConfirmDialog
open={confirmPurgeAll}
onClose={() => setConfirmPurgeAll(false)}
onConfirm={purgeAll}
title="Purge entire cache?"
confirmLabel="Purge cache"
pending={purgingAll}
body="Every cached completion is dropped immediately. Identical requests will hit providers again and incur cost."
/>
</div>
);
}

View File

@ -0,0 +1,370 @@
import { useEffect, useMemo, useState } from "react";
import {
getConfig,
type ProviderAccountPublic,
type SettingsSection,
} from "../../api";
import { Card, CardContent } from "../ui/card";
import { Input } from "../ui/input";
import { Combobox, type ComboboxOption } from "../ui/combobox";
import {
asBool,
asNumStr,
asString,
FieldBlock,
numOrUndef,
PanelFooter,
PanelIntro,
SectionTitle,
sourceOf,
ToggleRow,
} from "./helpers";
import { CacheOpsPanel } from "./CacheOpsPanel";
interface CachingForm {
enabled: boolean;
embeddingProvider: string;
embeddingModel: string;
ttlSeconds: string;
similarityThreshold: string;
dimension: string;
conversationHistoryThreshold: string;
excludeSystemPrompt: boolean;
cacheByModel: boolean;
cacheByProvider: boolean;
}
function seed(values: Record<string, unknown> | undefined): CachingForm {
const v = values ?? {};
return {
enabled: asBool(v.enabled),
embeddingProvider: asString(v.embeddingProvider),
embeddingModel: asString(v.embeddingModel),
ttlSeconds: asNumStr(v.ttlSeconds),
similarityThreshold: asNumStr(v.similarityThreshold),
dimension: asNumStr(v.dimension),
conversationHistoryThreshold: asNumStr(v.conversationHistoryThreshold),
excludeSystemPrompt: asBool(v.excludeSystemPrompt),
cacheByModel: asBool(v.cacheByModel),
cacheByProvider: asBool(v.cacheByProvider),
};
}
/** Diff a numeric field; only emit a real numeric change (never undefined). */
function numChange(
out: Record<string, unknown>,
key: string,
next: string,
prev: string,
) {
const parsed = numOrUndef(next);
if (parsed !== undefined && parsed !== numOrUndef(prev)) {
out[key] = parsed;
}
}
export interface CachingPanelProps {
section: SettingsSection | undefined;
busy: boolean;
onSave: (values: Record<string, unknown>) => void;
}
export function CachingPanel({ section, busy, onSave }: CachingPanelProps) {
const initial = useMemo(() => seed(section?.values), [section]);
const [form, setForm] = useState<CachingForm>(initial);
useEffect(() => setForm(initial), [initial]);
const sources = section?.sources;
const [providers, setProviders] = useState<ProviderAccountPublic[]>([]);
useEffect(() => {
let alive = true;
getConfig()
.then((cfg) => {
if (alive) setProviders(cfg.providers);
})
.catch(() => {});
return () => {
alive = false;
};
}, []);
const set = <K extends keyof CachingForm>(key: K, value: CachingForm[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const providerOptions = useMemo<ComboboxOption[]>(() => {
const values = new Set<string>();
for (const p of providers) {
if (p.enabled) values.add(p.id);
}
// The stored value always shows, even if that provider was since removed.
if (form.embeddingProvider) {
values.add(form.embeddingProvider);
}
return [...values].map((value) => ({ value, label: value }));
}, [providers, form.embeddingProvider]);
const changed = useMemo(() => {
const out: Record<string, unknown> = {};
if (form.enabled !== initial.enabled) {
out.enabled = form.enabled;
}
if (form.embeddingProvider !== initial.embeddingProvider) {
out.embeddingProvider = form.embeddingProvider;
}
if (form.embeddingModel.trim() !== initial.embeddingModel) {
out.embeddingModel = form.embeddingModel.trim();
}
numChange(out, "ttlSeconds", form.ttlSeconds, initial.ttlSeconds);
numChange(
out,
"similarityThreshold",
form.similarityThreshold,
initial.similarityThreshold,
);
numChange(out, "dimension", form.dimension, initial.dimension);
numChange(
out,
"conversationHistoryThreshold",
form.conversationHistoryThreshold,
initial.conversationHistoryThreshold,
);
if (form.excludeSystemPrompt !== initial.excludeSystemPrompt) {
out.excludeSystemPrompt = form.excludeSystemPrompt;
}
if (form.cacheByModel !== initial.cacheByModel) {
out.cacheByModel = form.cacheByModel;
}
if (form.cacheByProvider !== initial.cacheByProvider) {
out.cacheByProvider = form.cacheByProvider;
}
return out;
}, [form, initial]);
const dirty = Object.keys(changed).length > 0;
return (
<div className="flex flex-col gap-5">
<PanelIntro>
Configure semantic caching for inference requests.
</PanelIntro>
<Card>
<CardContent className="flex flex-col gap-6">
<ToggleRow
id="cache-enabled"
label="Enable Semantic Caching"
description={
<>
Enable semantic caching for requests. Send the{" "}
<code className="font-mono">x-frosty-cache-key</code>{" "}
header with requests to use semantic caching.
</>
}
checked={form.enabled}
onCheckedChange={(v) => set("enabled", v)}
source={sourceOf(sources, "enabled")}
/>
<div className="h-px bg-border" />
<div className="flex flex-col gap-3">
<SectionTitle>Provider and model</SectionTitle>
<div className="field-grid">
<FieldBlock
id="cache-embed-provider"
label="Embedding Provider"
source={sourceOf(sources, "embeddingProvider")}
>
<Combobox
id="cache-embed-provider"
label="Embedding Provider"
options={providerOptions}
value={form.embeddingProvider || null}
onChange={(v) => set("embeddingProvider", v)}
placeholder="Select a provider"
/>
</FieldBlock>
<FieldBlock
id="cache-embed-model"
label="Embedding Model"
required
source={sourceOf(sources, "embeddingModel")}
>
<Input
id="cache-embed-model"
value={form.embeddingModel}
onChange={(e) => set("embeddingModel", e.target.value)}
placeholder="text-embedding-3-large"
/>
</FieldBlock>
</div>
</div>
<div className="flex flex-col gap-3">
<SectionTitle>Cache parameters</SectionTitle>
<div className="field-grid">
<FieldBlock
id="cache-ttl"
label="TTL (seconds)"
source={sourceOf(sources, "ttlSeconds")}
>
<Input
id="cache-ttl"
type="number"
inputMode="numeric"
min={0}
value={form.ttlSeconds}
onChange={(e) => set("ttlSeconds", e.target.value)}
placeholder="300"
/>
</FieldBlock>
<FieldBlock
id="cache-threshold"
label="Similarity Threshold"
source={sourceOf(sources, "similarityThreshold")}
>
<Input
id="cache-threshold"
type="number"
inputMode="decimal"
min={0}
max={1}
step={0.01}
value={form.similarityThreshold}
onChange={(e) => set("similarityThreshold", e.target.value)}
placeholder="0.85"
/>
</FieldBlock>
</div>
<FieldBlock
id="cache-dimension"
label="Dimension"
source={sourceOf(sources, "dimension")}
>
<Input
id="cache-dimension"
type="number"
inputMode="numeric"
min={1}
value={form.dimension}
onChange={(e) => set("dimension", e.target.value)}
placeholder="1536"
/>
</FieldBlock>
<p className="text-sm text-muted-foreground">
API keys for the embedding provider are inherited from the main
provider configuration. The semantic cache uses the configured
provider's keys automatically.
</p>
</div>
<div className="flex flex-col gap-3">
<SectionTitle>Conversation</SectionTitle>
<FieldBlock
id="cache-history-threshold"
label="Conversation History Threshold"
source={sourceOf(sources, "conversationHistoryThreshold")}
hint="Skip caching for conversations with more than this number of messages (prevents false positives)."
>
<Input
id="cache-history-threshold"
type="number"
inputMode="numeric"
min={0}
value={form.conversationHistoryThreshold}
onChange={(e) =>
set("conversationHistoryThreshold", e.target.value)}
placeholder="3"
/>
</FieldBlock>
<ToggleRow
bordered
id="cache-exclude-system"
label="Exclude System Prompt"
description="Exclude system messages from cache key generation."
checked={form.excludeSystemPrompt}
onCheckedChange={(v) => set("excludeSystemPrompt", v)}
source={sourceOf(sources, "excludeSystemPrompt")}
/>
</div>
<div className="flex flex-col gap-3">
<SectionTitle>Cache behavior</SectionTitle>
<ToggleRow
bordered
id="cache-by-model"
label="Cache by Model"
description="Include the model name in the cache key."
checked={form.cacheByModel}
onCheckedChange={(v) => set("cacheByModel", v)}
source={sourceOf(sources, "cacheByModel")}
/>
<ToggleRow
bordered
id="cache-by-provider"
label="Cache by Provider"
description="Include the provider name in the cache key."
checked={form.cacheByProvider}
onCheckedChange={(v) => set("cacheByProvider", v)}
source={sourceOf(sources, "cacheByProvider")}
/>
</div>
<div className="flex flex-col gap-2">
<SectionTitle>Notes</SectionTitle>
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
<CacheNote header="x-frosty-cache-ttl">
use a request-specific TTL.
</CacheNote>
<CacheNote header="x-frosty-cache-threshold">
use a request-specific similarity threshold.
</CacheNote>
<CacheNote header="x-frosty-cache-type">
pass "direct" or "semantic" to control cache behavior.
</CacheNote>
<CacheNote header="x-frosty-cache-no-store">
pass "true" to disable response caching.
</CacheNote>
</ul>
</div>
</CardContent>
</Card>
<PanelFooter dirty={dirty} busy={busy} onSave={() => onSave(changed)} />
{
/* Operations, formerly the standalone "Cache" page. Kept BELOW the save
footer and behind a divider so a destructive purge is never adjacent
to the Save button that applies configuration edits. */
}
<div className="mt-8 border-t border-border pt-6">
<h3 className="mb-1 text-lg font-semibold text-foreground">
Operations
</h3>
<p className="mb-4 text-sm text-muted-foreground">
Invalidate cached completions. These act immediately and are not part
of the settings save above.
</p>
<CacheOpsPanel />
</div>
</div>
);
}
function CacheNote(
{ header, children }: { header: string; children: React.ReactNode },
) {
return (
<li className="flex gap-2">
<span aria-hidden="true" className="text-muted-foreground">
&bull;
</span>
<span>
Pass the <code className="font-mono text-foreground">{header}</code>
{" "}
header to {children}
</span>
</li>
);
}

View File

@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { FileCode } from "lucide-react";
import {
type CodeModeBinding,
type CodeModeVfsView,
getCodeModeVfs,
} from "../../api";
import { Badge } from "../ui/badge";
import { Collapsible } from "../ui/collapsible";
import { Skeleton } from "../ui/skeleton";
import { relativeTime } from "../../lib/utils";
function baseName(path: string): string {
const parts = path.split("/");
return parts[parts.length - 1] || path;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
if (bytes < 1024) {
return `${bytes} B`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
export interface CodeModeVfsPreviewProps {
binding: CodeModeBinding;
}
/** Read-only preview of the generated Code Mode virtual file system. */
export function CodeModeVfsPreview({ binding }: CodeModeVfsPreviewProps) {
const [view, setView] = useState<CodeModeVfsView | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
setLoading(true);
setError(null);
getCodeModeVfs(binding)
.then((next) => {
if (active) {
setView(next);
}
})
.catch((err) => {
if (active) {
setError(err instanceof Error ? err.message : String(err));
}
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
}, [binding]);
const files = view?.files ?? [];
const rootLabel = binding === "tool" ? "tools/" : "servers/";
const caption = binding === "tool"
? "Individual tool files."
: "All tools per server in a single .py file.";
return (
<div className="flex flex-col gap-2">
<p className="text-2xs font-semibold uppercase tracking-wide text-muted-foreground">
VFS Structure
</p>
<div className="rounded-lg border border-border bg-muted/30 p-4">
{loading
? (
<div className="flex flex-col gap-2" aria-hidden="true">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-36" />
</div>
)
: error
? (
<p className="text-sm text-muted-foreground">
The generated VFS is unavailable ({error}).
</p>
)
: files.length === 0
? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileCode aria-hidden="true" className="size-4" />
No generated files for this binding level.
</div>
)
: (
<div className="flex flex-col gap-4">
{/* Tree glance: file names are untrusted -> text nodes only. */}
<div className="font-mono text-sm text-foreground">
<div>{rootLabel}</div>
{files.map((file, index) => (
<div key={file.path} className="whitespace-pre">
{(index === files.length - 1 ? " └ " : " ├ ") +
baseName(file.path)}
</div>
))}
</div>
<p className="text-sm text-muted-foreground">{caption}</p>
{/* Per-file expandable source (escaped text throughout). */}
<div className="rounded-md border border-border bg-card">
{files.map((file) => (
<div key={file.path} className="px-3">
<Collapsible
title={
<span className="font-mono text-sm text-foreground">
{file.path}
</span>
}
aside={
<Badge tone="muted">
{formatBytes(file.sizeBytes)}
</Badge>
}
>
<div className="flex flex-col gap-2 pb-1">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-muted-foreground">server</dt>
<dd className="font-mono text-foreground">
{file.server}
</dd>
<dt className="text-muted-foreground">tools</dt>
<dd className="font-mono text-foreground">
{file.tools.length > 0
? file.tools.join(", ")
: "-"}
</dd>
<dt className="text-muted-foreground">sha256</dt>
<dd className="truncate font-mono text-muted-foreground">
{file.sha256 || "-"}
</dd>
</dl>
<pre className="max-h-64 overflow-auto rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">{file.source}</pre>
</div>
</Collapsible>
</div>
))}
</div>
</div>
)}
</div>
{view && !loading && !error && (
<p className="text-2xs text-muted-foreground">
Generated {relativeTime(view.generatedAt)}.
</p>
)}
</div>
);
}

View File

@ -0,0 +1,115 @@
import { useEffect, useMemo, useState } from "react";
import type { SettingsSection } from "../../api";
import {
asBool,
PanelFooter,
PanelIntro,
sourceOf,
ToggleRow,
} from "./helpers";
interface CompatForm {
convertTextToChat: boolean;
convertChatToResponses: boolean;
dropUnsupportedParams: boolean;
convertUnsupportedParameterValues: boolean;
}
const FIELDS: Array<
{ key: keyof CompatForm; id: string; label: string; description: string }
> = [
{
key: "convertTextToChat",
id: "compat-text-to-chat",
label: "Convert Text to Chat",
description:
"Convert text completion requests to chat for models that only support chat.",
},
{
key: "convertChatToResponses",
id: "compat-chat-to-responses",
label: "Convert Chat to Responses",
description:
"Convert chat completion requests to responses for models that only support responses.",
},
{
key: "dropUnsupportedParams",
id: "compat-drop-params",
label: "Drop Unsupported Params",
description:
"Drop unsupported parameters based on the model catalog allowlist.",
},
{
key: "convertUnsupportedParameterValues",
id: "compat-convert-values",
label: "Convert Unsupported Parameter Values",
description:
"Convert model parameter values that are not supported by the model.",
},
];
function seed(values: Record<string, unknown> | undefined): CompatForm {
const v = values ?? {};
return {
convertTextToChat: asBool(v.convertTextToChat),
convertChatToResponses: asBool(v.convertChatToResponses),
dropUnsupportedParams: asBool(v.dropUnsupportedParams),
convertUnsupportedParameterValues: asBool(
v.convertUnsupportedParameterValues,
),
};
}
export interface CompatibilityPanelProps {
section: SettingsSection | undefined;
busy: boolean;
onSave: (values: Record<string, unknown>) => void;
}
export function CompatibilityPanel(
{ section, busy, onSave }: CompatibilityPanelProps,
) {
const initial = useMemo(() => seed(section?.values), [section]);
const [form, setForm] = useState<CompatForm>(initial);
useEffect(() => setForm(initial), [initial]);
const sources = section?.sources;
const changed = useMemo(() => {
const out: Record<string, unknown> = {};
for (const { key } of FIELDS) {
if (form[key] !== initial[key]) {
out[key] = form[key];
}
}
return out;
}, [form, initial]);
const dirty = Object.keys(changed).length > 0;
return (
<div className="flex flex-col gap-5">
<PanelIntro>
Configure request conversions and compatibility fallbacks.
</PanelIntro>
<div className="flex flex-col divide-y divide-border">
{FIELDS.map((field) => (
<div key={field.key} className="py-3 first:pt-0">
<ToggleRow
id={field.id}
label={field.label}
description={field.description}
checked={form[field.key]}
onCheckedChange={(v) =>
setForm((prev) => ({ ...prev, [field.key]: v }))}
source={sourceOf(sources, field.key)}
/>
</div>
))}
</div>
<PanelFooter dirty={dirty} busy={busy} onSave={() => onSave(changed)} />
</div>
);
}

View File

@ -0,0 +1,431 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
ApiError,
exportConfig,
getConfig,
importConfig,
reloadConfig,
setDefaultProvider,
} from "../../api";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Button } from "../ui/button";
import { Banner } from "../ui/banner";
import { Field, Label } from "../ui/label";
import { Textarea } from "../ui/input";
import { NativeSelect } from "../ui/select";
import { ConfirmDialog } from "../ui/dialog";
import { CopyButton } from "../ui/copy-button";
import { PanelSkeleton } from "../ui/skeleton";
import { useToast } from "../ui/toast";
const MAX_IMPORT_BYTES = 2 * 1024 * 1024;
function isStoreOff(err: unknown): boolean {
return err instanceof ApiError && err.status === 400 &&
err.message.includes("No persistent config store");
}
interface ImportInspection {
data: unknown;
count: number;
incomingDefault: string | undefined;
}
/** Client-side shape/size guard (security #20); the server re-validates. */
function inspectImport(text: string): ImportInspection | { error: string } {
if (text.length > MAX_IMPORT_BYTES) {
return { error: "Pasted JSON is too large (over 2 MB)." };
}
let data: unknown;
try {
data = JSON.parse(text);
} catch {
return { error: "Not valid JSON." };
}
const root = data as {
config?: unknown;
providers?: unknown;
defaultProvider?: unknown;
};
const config = (root.config ?? root) as {
providers?: unknown;
defaultProvider?: unknown;
};
if (!Array.isArray(config.providers)) {
return { error: "Missing config.providers - is this a Frosty export?" };
}
return {
data,
count: config.providers.length,
incomingDefault: typeof config.defaultProvider === "string"
? config.defaultProvider
: undefined,
};
}
export function ConfigPanel() {
const toast = useToast();
const [providerIds, setProviderIds] = useState<string[]>([]);
const [currentDefault, setCurrentDefault] = useState("");
const [selectedDefault, setSelectedDefault] = useState("");
const [loaded, setLoaded] = useState(false);
const [storeOff, setStoreOff] = useState(false);
const [savingDefault, setSavingDefault] = useState(false);
const [contents, setContents] = useState<"redacted" | "secrets">("redacted");
const [confirmSecrets, setConfirmSecrets] = useState(false);
const [preview, setPreview] = useState<string | null>(null);
const [importText, setImportText] = useState("");
const [confirmImport, setConfirmImport] = useState(false);
const [confirmReload, setConfirmReload] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
try {
const config = await getConfig();
setProviderIds(config.providers.map((p) => p.id));
setCurrentDefault(config.defaultProvider ?? "");
setSelectedDefault(config.defaultProvider ?? "");
} catch {
// handled by the standard banner in downstream actions
} finally {
setLoaded(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function saveDefault() {
setSavingDefault(true);
setDefaultProvider(selectedDefault || undefined)
.then(() => {
setCurrentDefault(selectedDefault);
toast.success("Default provider updated");
})
.catch((err) =>
toast.error(err instanceof Error ? err.message : String(err))
)
.finally(() => setSavingDefault(false));
}
async function doPreview() {
try {
const data = await exportConfig(false); // redacted only (security #15)
setPreview(JSON.stringify(data, null, 2));
} catch (err) {
if (isStoreOff(err)) {
setStoreOff(true);
return;
}
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function doDownload() {
try {
// The secret-bearing body is a local const: never stored in state or DOM
// and dropped when this function returns (security #15).
const data = await exportConfig(contents === "secrets");
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `frosty-config-${
new Date().toISOString().slice(0, 10)
}.json`;
anchor.click();
URL.revokeObjectURL(url);
toast.success(
contents === "secrets"
? "Export downloaded. Treat it like a password file."
: "Export downloaded",
);
} catch (err) {
if (isStoreOff(err)) {
setStoreOff(true);
return;
}
toast.error(err instanceof Error ? err.message : String(err));
}
}
function onSelectContents(value: string) {
if (value === "secrets") {
setConfirmSecrets(true); // gated by explicit warning (security #15)
} else {
setContents("redacted");
}
}
function readFile(file: File) {
const reader = new FileReader();
reader.onload = () => setImportText(String(reader.result ?? ""));
reader.readAsText(file);
}
const inspection = importText.trim() ? inspectImport(importText) : null;
const importError = inspection && "error" in inspection
? inspection.error
: null;
const importReady = inspection !== null && !("error" in inspection);
function doImport() {
if (!inspection || "error" in inspection) {
return;
}
const payload = inspection.data;
setConfirmImport(false);
importConfig(payload)
.then((res) => {
toast.success(`Imported ${res.providers} providers`);
setImportText("");
void load();
})
.catch((err) => {
if (isStoreOff(err)) {
setStoreOff(true);
return;
}
toast.error(err instanceof Error ? err.message : String(err));
});
}
function doReload() {
setConfirmReload(false);
reloadConfig()
.then((res) => {
toast.success(`Config reloaded - ${res.providers} providers`);
void load();
})
.catch((err) => {
if (isStoreOff(err)) {
setStoreOff(true);
return;
}
toast.error(err instanceof Error ? err.message : String(err));
});
}
const importSummary = inspection && !("error" in inspection)
? inspection
: null;
return (
<div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Default provider</CardTitle>
</CardHeader>
<CardContent>
{!loaded
? <PanelSkeleton />
: (
<div className="flex flex-wrap items-end gap-3">
<Field
id="default-provider"
label="Default provider"
className="min-w-56"
>
<NativeSelect
id="default-provider"
value={selectedDefault}
onChange={(e) => setSelectedDefault(e.target.value)}
>
<option value="">None</option>
{providerIds.map((id) => (
<option key={id} value={id}>{id}</option>
))}
</NativeSelect>
</Field>
<Button
disabled={selectedDefault === currentDefault || savingDefault}
isLoading={savingDefault}
onClick={saveDefault}
>
Save
</Button>
</div>
)}
</CardContent>
</Card>
{storeOff
? (
<Banner tone="info" className="mb-6">
No persistent config store attached - export, import and reload need
a gateway started with FROSTY_PG_URL pointing at a reachable
PostgreSQL.
</Banner>
)
: (
<>
<Card className="mb-6">
<CardHeader>
<CardTitle>Export</CardTitle>
</CardHeader>
<CardContent>
<fieldset className="flex flex-col gap-2">
<Label>Contents</Label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="export-contents"
className="accent-primary"
checked={contents === "redacted"}
onChange={() => onSelectContents("redacted")}
/>
Redacted (safe to share)
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="export-contents"
className="accent-primary"
checked={contents === "secrets"}
onChange={() => onSelectContents("secrets")}
/>
Include secrets
</label>
</fieldset>
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="outline"
disabled={contents === "secrets"}
title={contents === "secrets"
? "Preview shows the redacted export only"
: undefined}
onClick={doPreview}
>
Preview
</Button>
<Button onClick={doDownload}>Download</Button>
</div>
{preview !== null && (
<div className="mt-4">
<div className="mb-2 flex justify-end">
<CopyButton value={preview} label="Copy JSON" />
</div>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-background p-3 font-mono text-xs">
<code>{preview}</code>
</pre>
</div>
)}
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle>Import</CardTitle>
</CardHeader>
<CardContent>
{
/* measure-capped: a JSON paste area stretched to the full
container width makes long lines unscannable and the
caret hard to find. */
}
<Field id="import-json" label="Paste JSON" className="measure">
<Textarea
id="import-json"
rows={8}
className="font-mono"
value={importText}
aria-invalid={importError ? true : undefined}
onChange={(e) => setImportText(e.target.value)}
/>
</Field>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
ref={fileRef}
type="file"
accept="application/json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
readFile(file);
}
}}
/>
<Button
variant="outline"
onClick={() => fileRef.current?.click()}
>
Choose file...
</Button>
<Button
disabled={!importReady}
onClick={() => setConfirmImport(true)}
>
Import...
</Button>
</div>
{importError && (
<p className="mt-2 text-sm text-destructive" role="alert">
{importError}
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Reload</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
Re-read providers, governance and MCP config from PostgreSQL,
discarding runtime-only state.
</p>
<Button
variant="outline"
onClick={() => setConfirmReload(true)}
>
Reload from store
</Button>
</CardContent>
</Card>
</>
)}
<ConfirmDialog
open={confirmSecrets}
onClose={() => setConfirmSecrets(false)}
onConfirm={() => {
setContents("secrets");
setConfirmSecrets(false);
}}
title="Export secrets?"
confirmLabel="Export with secrets"
body="The file will contain plaintext API keys and cloud credentials. Treat it like a password file."
/>
<ConfirmDialog
open={confirmImport}
onClose={() => setConfirmImport(false)}
onConfirm={doImport}
title="Replace configuration?"
confirmLabel="Import and replace"
body={importSummary
? `Import replaces all ${providerIds.length} provider(s) with ${importSummary.count} from this file and sets the default provider to "${
importSummary.incomingDefault ?? "none"
}". Current providers not present in the file are removed, including their stored keys.`
: ""}
/>
<ConfirmDialog
open={confirmReload}
onClose={() => setConfirmReload(false)}
onConfirm={doReload}
title="Reload configuration from store?"
confirmLabel="Reload"
destructive={false}
body="Any provider changes made only in memory are discarded."
/>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More