14 KiB
Executable File
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.
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:
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 (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 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 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) 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) — 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/errorResponseand 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 barez.object(), which strips unknown keys rather than rejecting them. The two.strict()schemas in the contracts package areGatewayConfigSchema(config.ts) andModelPriceSchema(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
falseon 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.--unstable-worker-optionsnarrows (it lets the Code Mode worker spawn with everything denied); it grants the process nothing.--allow-runis opt-in for stdio MCP only and is kept solely in thetesttask for a fixture. - Secrets never reach the browser. The admin API returns redacted views with
hasXpresence markers. GatewayPUTis 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,
and
.env.examplecoverage. - 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, 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 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 item with its mechanism, evidence, "done
means" and reopen trigger.
Definition of done per 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).
Control UI
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/, guides/,
reference/, concepts/,
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 — the authoritative capability inventory, including a "gaps and partial implementations" table and a list of stale claims found in older docs.
- 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 plus sbom.cyclonedx.json — the component-level bill of materials.
- docs/assets/diagrams/ — the canonical
logic-flow.svg,data-flow.svg,resource-flow.svg.