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:
parent
490bb6fc36
commit
d25af0e307
322
BLOG.md
322
BLOG.md
|
|
@ -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 — 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."*
|
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 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.
|
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.
|
||||||
|
|
|
||||||
49
Makefile
49
Makefile
|
|
@ -1,9 +1,13 @@
|
||||||
# SysDeck - Makefile
|
# SysDeck - Makefile
|
||||||
# Author: Jeremy Anderson (https://dcos.net)
|
# Author: Jeremy Anderson (https://dcos.net)
|
||||||
#
|
#
|
||||||
# v0.2.0 MASTER EDITION: two distributions in one tree —
|
# v0.3.0 AI GATEWAY EDITION: two distributions in one tree —
|
||||||
# / the cockpit edition: 26 standalone Cockpit plugins + shared bridge
|
# / the cockpit edition: 27 standalone Cockpit plugins + shared bridge
|
||||||
# /web the SysDeck Web Edition (Next.js console, 28 bridge modules)
|
# /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)
|
# /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
|
# Each plugin ships to /usr/share/cockpit/sysdeck-<name>/ and appears as
|
||||||
# its own sidebar entry in Cockpit. The Python bridge helpers stay at
|
# 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.
|
# Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS.
|
||||||
|
|
||||||
PACKAGE := sysdeck
|
PACKAGE := sysdeck
|
||||||
VERSION := 0.2.0
|
VERSION := 0.4.1
|
||||||
LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE)
|
LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE)
|
||||||
PYTHON_DIR := $(LIB_DIR)/bridge
|
PYTHON_DIR := $(LIB_DIR)/bridge
|
||||||
SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE)
|
SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE)
|
||||||
|
|
@ -55,7 +59,7 @@ SMOKE_TEST_SCRIPT := cockpit-smoke-test.sh
|
||||||
# Generator script (regenerates plugins/ and shared/).
|
# Generator script (regenerates plugins/ and shared/).
|
||||||
GENERATOR := scripts/generate-plugins.py
|
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: regenerate from generator ──────────────────────────────
|
||||||
plugins:
|
plugins:
|
||||||
|
|
@ -86,6 +90,10 @@ install:
|
||||||
install -m 0644 shared/manifest.json $(DESTDIR)/usr/share/cockpit/sysdeck-common/manifest.json
|
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/bridge.js $(DESTDIR)/usr/share/cockpit/sysdeck-common/bridge.js
|
||||||
install -m 0644 shared/sysdeck.css $(DESTDIR)/usr/share/cockpit/sysdeck-common/sysdeck.css
|
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/
|
# Python bridge helpers: /usr/lib/sysdeck/bridge/
|
||||||
# v0.0.27: install each helper as an executable script (0755, not 0644)
|
# v0.0.27: install each helper as an executable script (0755, not 0644)
|
||||||
# so they can be invoked by absolute path:
|
# so they can be invoked by absolute path:
|
||||||
|
|
@ -385,7 +393,7 @@ distcheck: dist
|
||||||
@rm -rf /tmp/sysdeck-distcheck-$$
|
@rm -rf /tmp/sysdeck-distcheck-$$
|
||||||
@echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct."
|
@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
|
# Run these from an extracted master tarball (where web/ sits alongside
|
||||||
# this Makefile) or the canonical dev tree with web/ present.
|
# this Makefile) or the canonical dev tree with web/ present.
|
||||||
|
|
||||||
|
|
@ -403,6 +411,35 @@ web-install:
|
||||||
cd $(FESTER_DIR) && bun install
|
cd $(FESTER_DIR) && bun install
|
||||||
|
|
||||||
web-dev: web-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)"
|
@echo ">>> Starting fester in the background (log: /tmp/fester.log)"
|
||||||
cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 &
|
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)"
|
@echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)"
|
||||||
|
|
|
||||||
132
QA.md
132
QA.md
|
|
@ -1072,3 +1072,135 @@ PASS — guard fails with a clear, actionable message naming the exact file, lin
|
||||||
### 11. Honest accounting
|
### 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.
|
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.
|
||||||
|
|
|
||||||
270
QUICKSTART.md
270
QUICKSTART.md
|
|
@ -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.
|
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
|
```bash
|
||||||
make web-dev # fester service in the background (:3010) + web console (:3000)
|
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`.
|
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>
|
Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net>
|
||||||
|
|
|
||||||
124
README.md
124
README.md
|
|
@ -3,7 +3,7 @@
|
||||||
**A drop-in plugin for an existing Cockpit install — twenty-six domain modules behind one dashboard.**
|
**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>
|
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.
|
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 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**.
|
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**.
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,41 @@ integration invokes the external tool as a **separate process** via
|
||||||
suite (MIT) and the external tools remain independent programs.
|
suite (MIT) and the external tools remain independent programs.
|
||||||
|
|
||||||
This file satisfies the attribution requirements of the licenses listed
|
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
|
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
|
Prior to v0.0.46, the only way to install third-party Cockpit modules
|
||||||
(45Drives Navigator, cockpit-pacman, cockpit-identities, etc.) was the
|
(45Drives Navigator, cockpit-pacman, cockpit-identities, etc.) was the
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.4.1"
|
||||||
__author__ = "Jeremy Anderson"
|
__author__ = "Jeremy Anderson"
|
||||||
__url__ = "https://dcos.net"
|
__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.
|
|
@ -31,6 +31,7 @@ Usage:
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
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:
|
def pcscd_state() -> str:
|
||||||
"""pcscd.service state via systemctl."""
|
"""pcscd.service state via systemctl."""
|
||||||
raw = run(["systemctl", "is-active", "pcscd"]).strip()
|
raw = run(["systemctl", "is-active", "pcscd"]).strip()
|
||||||
|
|
@ -238,6 +258,7 @@ COMMANDS = {
|
||||||
"summary": lambda _args: summary(),
|
"summary": lambda _args: summary(),
|
||||||
"slots": lambda _args: slots(),
|
"slots": lambda _args: slots(),
|
||||||
"readers": lambda _args: readers(),
|
"readers": lambda _args: readers(),
|
||||||
|
"certs": lambda _args: certs(),
|
||||||
"identities": lambda _args: identities(),
|
"identities": lambda _args: identities(),
|
||||||
"ssh-keys": lambda _args: ssh_keys(),
|
"ssh-keys": lambda _args: ssh_keys(),
|
||||||
"kerberos": lambda _args: kerberos(),
|
"kerberos": lambda _args: kerberos(),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ Usage:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -96,6 +97,13 @@ def run_test(args: list[str]) -> dict[str, Any]:
|
||||||
"error": "no test name provided",
|
"error": "no test name provided",
|
||||||
}
|
}
|
||||||
test_name = args[0]
|
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.
|
# Some sysbench tests (fileio) require a prepare step before run.
|
||||||
# We deliberately keep this simple — for arbitrary test names, just
|
# We deliberately keep this simple — for arbitrary test names, just
|
||||||
# invoke ``sysbench <name> run``. If the user wants fileio with
|
# invoke ``sysbench <name> run``. If the user wants fileio with
|
||||||
|
|
|
||||||
|
|
@ -485,6 +485,34 @@ BUILDER_LOGS_DIR = Path("/var/lib/sysdeck/builder/logs")
|
||||||
BUILDER_ARTIFACTS_DIR = Path("/var/lib/sysdeck/builder/artifacts")
|
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:
|
def _ensure_state_dirs() -> None:
|
||||||
"""Create the state/logs/artifacts dirs. Best-effort; the cockpit
|
"""Create the state/logs/artifacts dirs. Best-effort; the cockpit
|
||||||
superuser channel handles root perms when needed."""
|
superuser channel handles root perms when needed."""
|
||||||
|
|
@ -1092,6 +1120,15 @@ def profile_create(args: list[str]) -> dict[str, Any]:
|
||||||
backend_id = positional[1]
|
backend_id = positional[1]
|
||||||
base = positional[2] if len(positional) > 2 else None
|
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.
|
# Validate backend.
|
||||||
valid_backends = ("mkosi", "vmdb2")
|
valid_backends = ("mkosi", "vmdb2")
|
||||||
if backend_id not in valid_backends:
|
if backend_id not in valid_backends:
|
||||||
|
|
@ -1870,6 +1907,10 @@ def build_log(args: list[str]) -> dict[str, Any]:
|
||||||
if not args:
|
if not args:
|
||||||
return {"error": "build-id required"}
|
return {"error": "build-id required"}
|
||||||
build_id = args[0]
|
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)
|
log_path = _build_log_path(build_id)
|
||||||
if not log_path.is_file():
|
if not log_path.is_file():
|
||||||
return {"error": f"no log file for build {build_id}", "build_id": build_id}
|
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():
|
if not BUILDER_ARTIFACTS_DIR.is_dir():
|
||||||
return {"artifacts": [], "by_profile": {}}
|
return {"artifacts": [], "by_profile": {}}
|
||||||
profile_filter = args[0] if args else None
|
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]]] = {}
|
by_profile: dict[str, list[dict[str, Any]]] = {}
|
||||||
if profile_filter:
|
if profile_filter:
|
||||||
profiles_to_scan = [BUILDER_ARTIFACTS_DIR / 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:
|
if not args:
|
||||||
return {"error": "usage: artifacts-clear <profile>"}
|
return {"error": "usage: artifacts-clear <profile>"}
|
||||||
profile = args[0]
|
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
|
prof_dir = BUILDER_ARTIFACTS_DIR / profile
|
||||||
if not prof_dir.is_dir():
|
if not prof_dir.is_dir():
|
||||||
return {"error": f"no artifacts directory for profile '{profile}'"}
|
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:
|
if not args:
|
||||||
return {"error": "build-id required"}
|
return {"error": "build-id required"}
|
||||||
build_id = args[0]
|
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:]
|
delete_artifacts = "--artifacts" in args[1:]
|
||||||
deleted = []
|
deleted = []
|
||||||
errors = []
|
errors = []
|
||||||
|
|
@ -2018,13 +2075,18 @@ def build_delete(args: list[str]) -> dict[str, Any]:
|
||||||
errors.append(f"log: {exc}")
|
errors.append(f"log: {exc}")
|
||||||
# Optionally delete artifacts.
|
# Optionally delete artifacts.
|
||||||
if delete_artifacts and profile_name:
|
if delete_artifacts and profile_name:
|
||||||
prof_dir = BUILDER_ARTIFACTS_DIR / profile_name
|
# v0.1.4 SECURITY: profile_name comes from the (deleted) state
|
||||||
if prof_dir.is_dir():
|
# file's JSON — treat it as untrusted before rmtree'ing with it.
|
||||||
try:
|
if not _valid_id(profile_name) or not _under_dir(BUILDER_ARTIFACTS_DIR / profile_name, BUILDER_ARTIFACTS_DIR):
|
||||||
shutil.rmtree(prof_dir)
|
errors.append(f"artifacts: refusing to clear untrusted profile path {profile_name!r}")
|
||||||
deleted.append(str(prof_dir) + "/ (artifacts dir)")
|
else:
|
||||||
except (PermissionError, OSError) as exc:
|
prof_dir = BUILDER_ARTIFACTS_DIR / profile_name
|
||||||
errors.append(f"artifacts: {exc}")
|
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:
|
if not deleted and not errors:
|
||||||
return {"error": f"no build found with id '{build_id}'"}
|
return {"error": f"no build found with id '{build_id}'"}
|
||||||
return {"deleted": True, "build_id": build_id, "profile": profile_name,
|
return {"deleted": True, "build_id": build_id, "profile": profile_name,
|
||||||
|
|
|
||||||
41
bridge/db.py
41
bridge/db.py
|
|
@ -288,6 +288,16 @@ def cmd_status(engine_id):
|
||||||
return {"error": f"Unknown engine: {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):
|
def cmd_start(engine_id):
|
||||||
# v0.0.32: was `sudo systemctl start` — but sudo shell-out from
|
# v0.0.32: was `sudo systemctl start` — but sudo shell-out from
|
||||||
# the bridge fails when the cockpit user has no passwordless sudo
|
# 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
|
# action. The bridge runs systemctl directly as root (the cockpit
|
||||||
# superuser channel escalates privileges via polkit when the
|
# superuser channel escalates privileges via polkit when the
|
||||||
# operator authenticates).
|
# 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)
|
rc, out, err = run_rc(["systemctl", "start", f"{engine_id}.service"], timeout=30)
|
||||||
return {"action": "start", "engine": engine_id, "rc": rc,
|
return {"action": "start", "engine": engine_id, "rc": rc,
|
||||||
"output": out or err or "started", "success": rc == 0,
|
"output": out or err or "started", "success": rc == 0,
|
||||||
|
|
@ -304,6 +317,10 @@ def cmd_start(engine_id):
|
||||||
|
|
||||||
|
|
||||||
def cmd_stop(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)
|
rc, out, err = run_rc(["systemctl", "stop", f"{engine_id}.service"], timeout=30)
|
||||||
return {"action": "stop", "engine": engine_id, "rc": rc,
|
return {"action": "stop", "engine": engine_id, "rc": rc,
|
||||||
"output": out or err or "stopped", "success": rc == 0,
|
"output": out or err or "stopped", "success": rc == 0,
|
||||||
|
|
@ -311,6 +328,9 @@ def cmd_stop(engine_id):
|
||||||
|
|
||||||
|
|
||||||
def cmd_restart(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)
|
rc, out, err = run_rc(["systemctl", "restart", f"{engine_id}.service"], timeout=30)
|
||||||
return {"action": "restart", "engine": engine_id, "rc": rc,
|
return {"action": "restart", "engine": engine_id, "rc": rc,
|
||||||
"output": out or err or "restarted", "success": rc == 0,
|
"output": out or err or "restarted", "success": rc == 0,
|
||||||
|
|
@ -331,13 +351,30 @@ def cmd_connections(engine_id):
|
||||||
|
|
||||||
|
|
||||||
def cmd_query(engine_id, sql):
|
def cmd_query(engine_id, sql):
|
||||||
"""Execute a SQL query against an engine (SQL family only)."""
|
"""Execute a read-only SQL query against an engine (SQL family only).
|
||||||
# Safety: refuse DDL/DML for certain contexts
|
|
||||||
|
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:
|
for e in ENGINE_REGISTRY:
|
||||||
if e[0] == engine_id:
|
if e[0] == engine_id:
|
||||||
family, cli = e[2], e[5]
|
family, cli = e[2], e[5]
|
||||||
if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"):
|
if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"):
|
||||||
return {"error": "Query only supported for SQL-family engines"}
|
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":
|
if cli == "psql":
|
||||||
out = run(["psql", "-tAc", sql], timeout=30)
|
out = run(["psql", "-tAc", sql], timeout=30)
|
||||||
elif cli in ("mysql", "mariadb"):
|
elif cli in ("mysql", "mariadb"):
|
||||||
|
|
|
||||||
|
|
@ -1537,7 +1537,11 @@ def cmd_install_backend(args: list[str]) -> dict[str, Any]:
|
||||||
for p in pkgs:
|
for p in pkgs:
|
||||||
if not _validate_filename(p):
|
if not _validate_filename(p):
|
||||||
return {"error": f"invalid package name: {p!r}"}
|
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:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
cmd, capture_output=True, text=True, check=False, timeout=300,
|
cmd, capture_output=True, text=True, check=False, timeout=300,
|
||||||
|
|
|
||||||
|
|
@ -507,28 +507,61 @@ def cmd_dismiss(alert_id):
|
||||||
return {"action": "dismiss", "alertId": alert_id, "status": "dismissed"}
|
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):
|
def cmd_block(device_id):
|
||||||
"""Block a device — for USB, writes '0' to authorized sysfs."""
|
"""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")
|
auth_path = os.path.join(device_id, "authorized")
|
||||||
if os.path.exists(auth_path):
|
if os.path.exists(auth_path):
|
||||||
try:
|
try:
|
||||||
with open(auth_path, 'w') as f:
|
with open(auth_path, 'w') as f:
|
||||||
f.write('0')
|
f.write('0')
|
||||||
return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize"}
|
return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize"}
|
||||||
except PermissionError:
|
except (PermissionError, OSError) as exc:
|
||||||
# Need sudo
|
return {"action": "block", "deviceId": device_id, "result": "error",
|
||||||
run(["sudo", "tee", auth_path], timeout=5)
|
"error": str(exc)}
|
||||||
return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize-sudo"}
|
|
||||||
return {"action": "block", "deviceId": device_id, "result": "no-method-available"}
|
return {"action": "block", "deviceId": device_id, "result": "no-method-available"}
|
||||||
|
|
||||||
|
|
||||||
def cmd_unblock(device_id):
|
def cmd_unblock(device_id):
|
||||||
"""Unblock a device."""
|
"""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")
|
auth_path = os.path.join(device_id, "authorized")
|
||||||
if os.path.exists(auth_path):
|
if os.path.exists(auth_path):
|
||||||
run(["sudo", "sh", "-c", f"echo 1 > {auth_path}"], timeout=5)
|
# v0.1.4 SECURITY: was `sudo sh -c f"echo 1 > {auth_path}"` — a
|
||||||
return {"action": "unblock", "deviceId": device_id, "result": "unblocked"}
|
# 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"}
|
return {"action": "unblock", "deviceId": device_id, "result": "no-method-available"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
@ -295,6 +295,34 @@ def info(args: list[str]) -> dict[str, Any]:
|
||||||
return fn(args) if fn else {}
|
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]:
|
def install(args: list[str]) -> dict[str, str]:
|
||||||
"""Install a package — actually runs the package manager via subprocess.
|
"""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:
|
if not args:
|
||||||
return {"error": "No package name provided"}
|
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],
|
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
|
||||||
"dnf": ["dnf", "install", "-y", pkg],
|
"dnf": ["dnf", "install", "-y", pkg],
|
||||||
"apt": ["apt", "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()."""
|
"""Remove a package — actually runs the package manager. See install()."""
|
||||||
if not args:
|
if not args:
|
||||||
return {"error": "No package name provided"}
|
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],
|
cmd_map = {"pacman": ["pacman", "-R", "--noconfirm", pkg],
|
||||||
"dnf": ["dnf", "remove", "-y", pkg],
|
"dnf": ["dnf", "remove", "-y", pkg],
|
||||||
"apt": ["apt", "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()."""
|
"""Update a package — actually runs the package manager. See install()."""
|
||||||
if not args:
|
if not args:
|
||||||
return {"error": "No package name provided"}
|
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],
|
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
|
||||||
"dnf": ["dnf", "upgrade", "-y", pkg],
|
"dnf": ["dnf", "upgrade", "-y", pkg],
|
||||||
"apt": ["apt", "upgrade", "-y", pkg]}
|
"apt": ["apt", "upgrade", "-y", pkg]}
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,28 @@ def cmd_acl_default(args: list[str]) -> dict[str, Any]:
|
||||||
CGROUP_ROOT = Path("/sys/fs/cgroup")
|
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:
|
def _cgroup_v2_available() -> bool:
|
||||||
"""True if /sys/fs/cgroup/ is a cgroups v2 unified hierarchy."""
|
"""True if /sys/fs/cgroup/ is a cgroups v2 unified hierarchy."""
|
||||||
return (CGROUP_ROOT / "cgroup.controllers").is_file()
|
return (CGROUP_ROOT / "cgroup.controllers").is_file()
|
||||||
|
|
@ -315,6 +337,11 @@ def cmd_cgroup_show(args: list[str]) -> dict[str, Any]:
|
||||||
if not args:
|
if not args:
|
||||||
return {"error": "cgroup path required"}
|
return {"error": "cgroup path required"}
|
||||||
path = Path(args[0])
|
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():
|
if not path.is_dir():
|
||||||
return {"error": f"{path} is not a directory"}
|
return {"error": f"{path} is not a directory"}
|
||||||
info: dict[str, Any] = {"path": str(path), "name": path.name}
|
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)."""
|
"""List PIDs in a cgroup (just the PIDs, no metadata)."""
|
||||||
if not args:
|
if not args:
|
||||||
return {"error": "cgroup path required"}
|
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"
|
procs_file = Path(args[0]) / "cgroup.procs"
|
||||||
if not procs_file.is_file():
|
if not procs_file.is_file():
|
||||||
return {"error": f"{procs_file} not found"}
|
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])
|
path = Path(args[0])
|
||||||
if not _cgroup_v2_available():
|
if not _cgroup_v2_available():
|
||||||
return {"available": False, "reason": "cgroups v2 not mounted"}
|
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}"}
|
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
|
||||||
try:
|
try:
|
||||||
path.mkdir(parents=True, exist_ok=False)
|
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:
|
if len(args) < 2:
|
||||||
return {"error": "usage: cgroup-move <pid> <cgroup-path>"}
|
return {"error": "usage: cgroup-move <pid> <cgroup-path>"}
|
||||||
pid, cgrp = args[0], args[1]
|
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"
|
procs_file = Path(cgrp) / "cgroup.procs"
|
||||||
if not procs_file.is_file():
|
if not procs_file.is_file():
|
||||||
return {"error": f"{procs_file} not found"}
|
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:
|
if len(args) < 3:
|
||||||
return {"error": "usage: cgroup-set <path> <control-file> <value>"}
|
return {"error": "usage: cgroup-set <path> <control-file> <value>"}
|
||||||
path, control, value = args[0], args[1], args[2]
|
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'
|
# control is a filename like 'memory.max' or 'cpu.weight'
|
||||||
target = Path(path) / control
|
target = Path(path) / control
|
||||||
if not target.parent.is_dir():
|
if not target.parent.is_dir():
|
||||||
|
|
|
||||||
|
|
@ -292,10 +292,23 @@ def cmd_set(args: list[str]) -> dict[str, Any]:
|
||||||
"""Set one key in cockpit.conf.
|
"""Set one key in cockpit.conf.
|
||||||
|
|
||||||
Usage: set <section> <key> <value>. Creates the section if absent.
|
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:
|
if len(args) < 3:
|
||||||
return {"error": "usage: set <section> <key> <value>"}
|
return {"error": "usage: set <section> <key> <value>"}
|
||||||
section, key, value = args[0], args[1], args[2]
|
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()
|
text = _read_text()
|
||||||
sections = _parse_conf(text)
|
sections = _parse_conf(text)
|
||||||
sections.setdefault(section, {})[key] = value
|
sections.setdefault(section, {})[key] = value
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"_comment": "Compatibility Manifest — sysdeck v0.1.3",
|
"_comment": "Compatibility Manifest — sysdeck v0.1.3",
|
||||||
"version": "0.2.0",
|
"version": "0.4.1",
|
||||||
"suite_requires": { "cockpit": ">=239", "python": ">=3.9" },
|
"suite_requires": { "cockpit": ">=239", "python": ">=3.9" },
|
||||||
"modules": {
|
"modules": {
|
||||||
"containers": {
|
"containers": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
blank_issues_enabled: false
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Frosty Deno LLM Gateway
|
||||||
|
|
||||||
|
[](https://deno.com)
|
||||||
|
[](CHANGELOG.md)
|
||||||
|
[](LICENSE)
|
||||||
|
[](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).
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" },
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Pass the <code className="font-mono text-foreground">{header}</code>
|
||||||
|
{" "}
|
||||||
|
header to {children}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue