CLAUDE.md · git:20260917.6aa7e90 · 2026-09-17 · sha256 8e021cebd47350d0
CLAUDE.md git:20260917.6aa7e90A
Immutable. This exact content never changes and is served at /api/v1/blob/8e021cebd47350d0.
# cc-proxy — maintainer handoff
A local HTTP proxy that lets Claude Code use GLM (Z.ai), OpenRouter, DeepSeek,
Qwen, LM Studio (self-hosted, `lmstudio:` selector only) and Claude in one
session. Claude Code points `ANTHROPIC_BASE_URL` at it; the proxy routes each
request **by model name** and forwards. That is the whole product. Resist
making it more than that.
This file carries the rules. The evidence behind each rule (measurements,
histories, refuted alternatives) is in
[`docs/MAINTAINING.md`](docs/MAINTAINING.md); read the matching section there
before reversing anything here.
Read next: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (design and why),
[`docs/ROUTING.md`](docs/ROUTING.md) / [`docs/DISCOVERY.md`](docs/DISCOVERY.md)
/ [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) (the user-facing contracts),
[`docs/OPERATIONS.md`](docs/OPERATIONS.md) (runtime),
[`docs/RELEASING.md`](docs/RELEASING.md), [`docs/BACKLOG.md`](docs/BACKLOG.md)
(open work and why past decisions went the way they did),
[`CONTRIBUTING.md`](CONTRIBUTING.md) (add-a-provider procedure).
## Gates
- `pnpm check` (= `pnpm lint && pnpm test`) before any commit; CI runs the
same. The suite spins **real local HTTP backends**: if you change forwarding
and no test fails, you have not tested it. Add one.
- `pnpm probe:vendors` is the MANUAL gate for claims about someone else's
server. Never in `pnpm check` (real keys, real quota). It exits 1 when a
vendor stops behaving the way a source comment says. Run it when you touch
routing or forwarding, or when a probe date in a comment looks old.
- Node 22 or newer. `.npmrc` is engine-strict because `process.loadEnvFile`
is how `~/.env` is read.
## Invariants (breaking one is a design decision, not a refactor)
Each is locked by tests; the test names tell you what you broke.
1. **Transparent pipe.** Auth and headers only. The full inbound path
_including the query string_ reaches upstream; bodies forwarded byte for
byte. Three body exceptions (thinking-strip, `<provider>:` selector strip,
`[1m]` suffix strip: each a spelling the CLIENT uses that no BACKEND knows)
and two header exceptions (hop-by-hop dropped; the upstream's own
`x-request-id` dropped, because `writeHead` replaces what `setHeader` put
there). → `server.test.js` "query string is preserved…", "provider selector
strip…", "routing log annotates the normalized id…", "keeps the proxy's
x-request-id when the upstream sets its own…"
2. **Stateless.** No breakers, no on-disk state, no in-proxy waiting. Rate
limits inject `Retry-After` and let the client back off. → "…1302 … gets a
Retry-After", "1313 … no Retry-After"
3. **Credential isolation.** Inbound `Authorization`/`x-api-key` never reach a
third party; the Claude route passes them through (OAuth). Never set
`ANTHROPIC_API_KEY` in a settings template; it shadows OAuth.
→ `providers.test.js` "…drops an inbound x-api-key…"
4. **`claude-haiku-*` pins to Claude.** The pin tests the STRIPPED tail;
pinning the raw id lets `glm:claude-haiku-…` skip it. → `router.test.js`
5. **Anthropic Messages only.** No OpenAI-to-Anthropic translation, ever. The
media tunnel forwards a DashScope body byte for byte and is not an
exception. → `models.test.js` "media generation tunnel (issue #40)"
6. **Client abort propagates upstream**, or a cancelled turn bills into a dead
socket. → "client abort mid-stream aborts the upstream request"
7. **Loopback bind by default.** `PROXY_HOST` is the explicit opt-out;
`PROXY_AUTH_TOKEN` makes it safe. → `config.test.js`
8. **One bad request never ends the process.** No `uncaughtException` handler
exists; every dispatch point contains its own throws (a JSON `null` body
used to exit the process for every session). → `server.test.js`
"dispatcher containment (a bad request never ends the process)"
## Load-bearing map (ranked by blast radius)
| # | Thing | If broken |
| --- | --- | --- |
| 1 | `src/proxy.js` `upstreamRequestOptions()` + `forward()` | every request in every session |
| 2 | `src/providers.js` `applyAuth()` / `buildUpstreamHeaders()` | credential leak, or auth failure everywhere |
| 3 | `src/server.js` `createServer()` / `dispatch()` | a throw ends the shared process |
| 4 | `hooks/proxy-lifecycle.js` `ensureProxyRunning()` | proxy never starts; every session `ECONNREFUSED` |
| 5 | `src/server.js` `forwardBuffered()` | GLM overflow becomes silent empty turns again |
| 6 | `src/router.js` `resolve()` + `parseModelSelector()` | wrong backend; haiku burns paid quota |
| 6b | `src/routes.js` `rankRoutes()` | every shared id silently takes the expensive route |
| 7 | `scripts/render-model-picker.js` `writeSettings()` | corrupts or forks the user's `~/.claude/settings.json` |
| 8 | `.claude-plugin/plugin.json` `version` | users silently never receive updates (cache key) |
| 9 | `src/models.js` `collectModels()` / `gradeOf()` | the `/v1/models` contract other plugins consume |
| 10 | `src/sanitize.js` | mid-session backend switch 400s; prompt caching breaks if non-deterministic |
| 11 | `scripts/statusline.js` + `scripts/refresh-lock.js` | the status bar blocks, vanishes, or spawns a refresher per render |
## Couplings: if you touch X, you must also update Y
`test/couplings.test.js` is the executable copy of this table. Rows marked ⚠
have no lock and drift silently. The reason behind each row is in
[`docs/MAINTAINING.md`](docs/MAINTAINING.md#couplings-the-long-form).
| Touch | Also update | Lock |
| --- | --- | --- |
| routing log format (`server.js`) | `status.js` `parseRoutingLines()` | couplings |
| `stripVariantSuffix` / `routingIdOf` (`router.js`) | `routes.test.js` composition lock; the `(routed as …)` annotation | routes.test.js |
| a version | `pnpm version patch\|minor --no-git-tag-version` on the branch, never by hand; tag on `main` after the squash | version-guard, release.yml |
| a `v<x.y.z>` tag | its CHANGELOG section, non-empty, in the bumping PR | release-gate.test.js |
| `PROXY_PORT` default | every file the couplings walk finds (`config.js`, the hook, `statusline.js`, `status.js`, `list-models.js`, `bench-speed.js`, `render-models.js`) | couplings |
| `PROXY_READY_TIMEOUT_MS` | `hooks/hooks.json` `timeout: 10`; the restart path spends it twice, so past ~3900 ms the hook is killed mid-poll | couplings |
| `buildProviders()` | `PROVIDER_IDS`, CONTRIBUTING step 2, `.env.example`'s `DEFAULT_BACKEND` comment | couplings |
| a `scripts/*.js` entry point | `isDirectRun(import.meta.url)` guard; `loadEnv()` directly under the imports | couplings |
| an `await` body read in a `models.js` live leg | classify `AbortError` and `BodyTooLargeError` before `invalid response shape`, in all four legs | couplings, models.test.js |
| a comment citing `file.js:NNN` | rewrite it to the symbol | couplings |
| an OpenRouter example id in a doc | never `anthropic/…` | couplings |
| a new env var | `.env.example` + the table in `docs/CONFIGURATION.md` | couplings (both directions) |
| a human-facing `pnpm` script | README, CONTRIBUTING, OPERATIONS or RELEASING, as `pnpm <name>` | couplings |
| a comment claiming an input→output | a `@doctest fn(<json>) -> <json>` line | doc-examples.test.js |
| a comment claiming vendor behaviour | a case in `scripts/probe-vendors.mjs` | manual |
| the outbound-id contract (`upstreamModel`) | the prose describing it | couplings |
| the plugin description | `package.json`, `plugin.json`, `marketplace.json` | couplings |
| an upstream request option | `upstreamRequestOptions()` only | server.test.js |
| `QWEN_PLAN_RESELLS` (`providers.js`) | `QWEN_PLAN_ALSO` (`render-models.js`) | couplings |
| a statusline gauge | the `GAUGES` table in `statusline.js` | statusline.test.js |
| ⚠ `MODEL_GRADES` | nothing in the repo, but `gradeOf()` overlays `~/.claude/cc-proxy/grades.json` | none |
| `identityOf` (`models.js`) | its `@doctest` lines, keeping one with TWO slashes | doc-examples.test.js |
| a static catalog id | a `ROUTES` entry | couplings |
| the `/v1/models` wire shape | `docs/DISCOVERY.md` + `docs/ARCHITECTURE.md` | couplings (field names), prose by hand |
| a handler in the dispatcher (`server.js`) | its whole body inside a `try` | server.test.js |
| ⚠ `mediaBaseUrl` (`providers.js`) | the media branch in `server.js`, its only reader | none |
| `CONTEXT_WINDOW` (`models.js`) | `ROUTES`, `MODEL_GRADES` if assessed, `docs/models.html`, and every user's picker on their next setup | couplings count tripwire, render-models.test.js |
| a writer of a file under `~/.claude` | `.tmp-<pid>` sibling + `renameSync`, through a symlink, mode preserved | couplings, model-picker.test.js |
| `buildRows()` (`model-picker.js`) | `[1m]` AND `behavesAs` together | model-picker.test.js |
| a hook's need for something in `src/` | duplicate it or join by a string; hooks never import `src/` | couplings (marker) |
| a doc file | README's docs map, links resolve, no line over 400 chars | docs.test.js |
Three questions, three places, never merged: a **catalog** says what a backend
serves, **`ROUTES`** who serves it cheapest, **`ownsId`** how it is spelled.
`grade` (capability) and `tier` (cost) are independent; tier 4 + Flagship is
normal.
**`GET /v1/models` is a publishing contract, and the arrow points one way.**
cc-proxy publishes curated model facts; downstream plugins (cc-reload,
cc-operator) consume them. A field is omitted, never `null`, when unknown, so
`"grade" in entry` and `"context_window" in entry` are the checks. Never read
a consumer's file back to decide anything here.
## Traps
One line each; the story and the measurement are in
[`docs/MAINTAINING.md`](docs/MAINTAINING.md#traps).
- **A comment that states behaviour rots like untested code, but louder.**
Give every claim its lock: `@doctest`, a probe case, or a couplings lock.
- **Plumbing.** The hook and setup load `~/.env` (since 0.10.2), so `PROXY_*`
may live there. `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` must stay in
settings.json `env`; Claude Code reads those.
- **Setup order matters.** `ANTHROPIC_BASE_URL` retargets open sessions
instantly; setup starts the proxy itself. Do not simplify it into a spawn.
- **An inline `ANTHROPIC_BASE_URL=… claude` prefix is SILENTLY IGNORED.** Use
`claude --settings '{"env":{…}}'`, and read the target listener's log.
- **Never `rm && touch` the proxy log** while it runs. `truncate -s 0`.
- **429 is the ONE buffering exception on the streaming path**, gated on GLM
`1302` exactly; `1113` must not get a retry hint.
- **Response inspection requires identity encoding**, buffered path only.
- **A slash command has NO positional parameters.** `$ARGUMENTS` is spliced
as SOURCE; `commands/bench.md` reads it through a quoted heredoc.
- **A session SNAPSHOTS command bodies at startup.** Verify which body you
have, then relaunch.
- **The statusline render path must never touch the network.** Detached
refresher, single-flight lock, 15 s backoff after a failure.
- **A lock's stale reclaim is check-then-act**; inode AND mtime, reclaim
serialized on a claim file, restore via `link()`.
- **A test that kills a subprocess must kill it unconditionally.**
- **Claude Code internals may drift**: `[1m]`, `claude-haiku-*`,
`ANTHROPIC_CUSTOM_MODEL_OPTION`, the `modelPicker` schema, `behavesAs`, the
`CLAUDE_CODE_MAX_CONTEXT_TOKENS` gate. Check these first after a CC update.
- **A script that prints nothing and exits 0 may never have run.** The
direct-run guard is the first suspect.
- **`/v1/models` is synthesized, `/v1/models/<id>` is forwarded.** Attach
windows via `withContextWindow()`, never a bare object lookup.
- **Hooks do not import `src/`.** The fixture trees copy three hook files.
- **The dispatcher has no safety net above it.** A throw in the request's
`end` listener or an un-awaited handler ends the process; wrap the body.
## Decision procedures
- **Adding a provider** → `CONTRIBUTING.md`, step by step.
- **Changing the forwarding path** → build options only via
`upstreamRequestOptions()`; bytes held in memory need a cap and a
passthrough; cross-request state is invariant 2; test both paths
end to end. → `docs/MAINTAINING.md`
- **Adding a curated model** → `CONTEXT_WINDOW`, `ROUTES`, `MODEL_GRADES`,
`docs/models.html`, the count tripwire. → `docs/MAINTAINING.md`
- **Merging a PR** → `gh pr merge <n> --squash`, never `--rebase`.
→ `docs/RELEASING.md`
- **Releasing** → CHANGELOG, bump on the branch, regenerate `models.html`
before the merge, tag on `main` after the squash. → `docs/RELEASING.md`
## Backlog
Open work, closed items with their evidence, and reversed decisions live in
[`docs/BACKLOG.md`](docs/BACKLOG.md); item numbers are stable and never reused.
Worth knowing exist: **1** thinking-strip vs Claude tool-use loops (the fix to
apply _if_ it fires); **8** the `<provider>:` selector and the measured
+79-token plan preamble; **9** where grades come from; **12** `ROUTES` rots
silently and no test can catch it; **16–20** the free-claude-code recon and the
declined-features register; **21–24** the 0.10.2 audit's deferred items.