# 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`.