A reverse proxy that exposes the Command Code API as OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages compatible endpoints.
Built by observing official CLI traffic to faithfully replicate the upstream protocol — device fingerprint, lifecycle events, session headers, versioning, and tracing.
Stack: Bun + Elysia + TypeScript. Single-file binary via bun build --compile, distroless Docker image.
- Triple protocol:
POST /v1/chat/completions(OpenAI) +POST /v1/responses(OpenAI Responses) +POST /v1/messages(Anthropic) - Streaming & non-streaming, tool calling, multimodal images,
reasoning_effort/thinking - Dynamic models:
GET /v1/modelsfrom Provider API (5 min cache) with builtin fallback - Account balance:
GET /v1/dashboard/billing/credit_grantsreturns the monthly allowance as OpenAIcredit_summary - CLI emulation: per-key device fingerprint (8h + 2h jitter, official
thumbmarkformula), lifecycle events (cli_installed/cli_session_exists/cli_first_message), per-key sessionsess_<16hex>(12h + 1h jitter) with derivedthreadId,User-Agent: cli,x-command-code-versionfrom npm (24h refresh),traceparent,x-project-slug - Resilience: zero-output →
429retryable, idle timeout (30s stream / 90s non-stream, overridable viaCC_STREAM_IDLE_MS/CC_NONSTREAM_IDLE_MS, defaults unchanged; thinking phasestart/start-step/reasoning-start/reasoning-deltagets a 120s window viaCC_THINKING_IDLE_MS) →429, disconnect aborts upstream. The Bun transport-layer idle cap is disabled (idleTimeout: 0, overriding Elysia's hardcoded 30s) so these budgets are the sole authority. - Auth flexibility: per-request
Bearer user_*/x-api-key, optionalCC_API_KEYfallback for self-host - Ops ready:
GET /health,server healthcheckCLI, Docker HEALTHCHECK, privacy-aware logs (no keys, bodies, or stacks)
No runtime needed — download the single-file binary for your platform from GitHub Releases and run it:
| OS | Arch | Asset |
|---|---|---|
| Linux | x64 / arm64 | cc-p-linux-x64, cc-p-linux-arm64 |
| Windows | x64 / arm64 | cc-p-windows-x64.exe, cc-p-windows-arm64.exe |
| macOS | x64 / arm64 | cc-p-darwin-x64, cc-p-darwin-arm64 |
# Linux / macOS
chmod +x cc-p-linux-x64
CC_API_KEY=user_xxxxxxxxx ./cc-p-linux-x64 # listens on http://0.0.0.0:3050# Windows (PowerShell)
$env:CC_API_KEY="user_xxxxxxxxx"; .\cc-p-windows-x64.exePrefer a file over env vars? Put a config.json / .env next to the binary
(see Configuration) — the binary reads them from its working
directory on top of the embedded defaults. Verify with:
curl http://127.0.0.1:3050/health
# {"ok":true}
curl http://127.0.0.1:3050/v1/chat/completions \
-H "Authorization: Bearer user_xxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek/deepseek-v4-flash","messages":[{"role":"user","content":"hi"}]}'
CC_API_KEYis optional: it acts as a fallback when a request carries no key (handy for self-host). Omit it and every request must send its ownAuthorization: Bearer user_xxx/x-api-key. Details in API key.
# OpenAI SDK
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:3050/v1", api_key="user_xxxxxxxxx")
resp = client.chat.completions.create(
model="deepseek/deepseek-v4-flash",
messages=[{"role": "user", "content": "hi"}],
stream=True,
)# Anthropic SDK
import anthropic
client = anthropic.Anthropic(base_url="http://127.0.0.1:3050", api_key="user_xxxxxxxxx")
msg = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "hi"}],
)# OpenAI SDK (Responses API)
resp = client.responses.create(
model="deepseek/deepseek-v4-flash",
input="hi",
stream=True,
)Any OpenAI-compatible tool (Claude Code, Cline, Roo, NextChat, etc.) works by pointing base_url at /v1 and using a user_* key.
| Method | Path | Description |
|---|---|---|
GET |
/ |
OK (plain text) |
GET |
/health |
{"ok":true} |
GET |
/v1/models |
OpenAI-style model list |
POST |
/v1/chat/completions |
OpenAI Chat Completions |
POST |
/v1/responses |
OpenAI Responses |
POST |
/v1/messages |
Anthropic Messages |
Standard OpenAI schema. stream: true returns SSE (data: {...} chunks + data: [DONE]); otherwise a full chat.completion object with prompt_tokens_details.cached_tokens. Images via content: [{type:"image_url", image_url:{url}}] are forwarded as CC image parts. reasoning_effort is passed through; reasoning also surfaces as reasoning_content deltas.
Anthropic schema with automatic conversion:
| Anthropic | Handling |
|---|---|
system (string / blocks) |
→ OpenAI system message |
tool_result in user blocks |
→ role: "tool" messages |
tools[].input_schema |
→ parameters |
tool_choice: auto / any / tool / none |
→ auto / required / {function} / none |
thinking.budget_tokens |
→ reasoning_effort (≥10000 high, ≥5000 medium, ≥2000 low) |
thinking.type: adaptive |
→ reasoning_effort: effort |
CC finishReason |
→ end_turn / max_tokens / tool_use |
Streaming emits message_start / content_block_* / message_delta / message_stop; thinking blocks carry the same empty signature the official CLI emits (the field must be present, its content is not validated upstream).
OpenAI Responses schema with automatic conversion:
| Responses | Handling |
|---|---|
instructions |
→ OpenAI system message |
input (string / items) |
→ messages; input_text / input_image / output_text parts pass through |
input[].type: function_call |
→ assistant tool_calls |
input[].type: function_call_output |
→ role: "tool" message |
tools[].{name,parameters,strict} (flat) |
→ nested {type:"function",function:{…}} |
tool_choice: auto / none / required / {function} |
→ auto / none / required / {function} |
reasoning.effort |
→ reasoning_effort |
max_output_tokens |
→ max_tokens |
metadata.user_id |
→ user |
store / previous_response_id / include / truncation / text |
ignored (stateless proxy; debug-logged) |
Streaming emits response.created / response.in_progress / response.output_item.added / response.output_text.delta / response.function_call_arguments.delta / … / response.completed (no [DONE]; the SDK terminates on response.completed). Reasoning surfaces as a {type:"reasoning",summary:[…]} item with response.reasoning_summary_text.delta events. finishReason: length maps to status: "incomplete" + incomplete_details.reason: "max_output_tokens". Built-in tools (web_search/file_search/…) have no CC equivalent and are dropped with a warning.
Tries GET {CC_API_BASE}/provider/v1/models with your key (10s timeout); caches for CC_MODEL_REFRESH_INTERVAL_MS. Falls back to the builtin list in src/modules/models/catalog.ts on any failure. Set CC_USE_PROVIDER_MODELS=false to always use the builtin list.
Tries GET {CC_API_BASE}/alpha/billing/credits with your key (10s timeout) and maps the CC monthly allowance to OpenAI credit_summary: total_granted (monthly granted), total_used, total_available (remaining plus purchased credits), with a single grants.data[] entry. No key → OpenAI-shaped 401; upstream failure → 502 api_error (never fabricates a balance).
Precedence (low → high): builtin defaults → config.json → .env / environment. Bun auto-loads .env. An empty value means "keep config.json"; real shell vars beat .env.
config.json holds non-sensitive defaults (tracked in git). .env holds secrets (git-ignored).
| Variable | config.json key |
Default |
|---|---|---|
PORT |
port |
3050 |
HOST |
host |
0.0.0.0 |
CC_API_BASE |
apiBase |
https://api.commandcode.ai |
CC_API_KEY |
apiKey |
"" (no fallback) |
CORS_ALLOW_ORIGIN |
corsAllowOrigin |
auto (see below) |
LOG_FILE |
logFile |
"" (console only) |
LOG_LEVEL |
logLevel |
info |
CC_USE_PROVIDER_MODELS |
useProviderModels |
true |
CC_MODEL_REFRESH_INTERVAL_MS |
modelRefreshIntervalMs |
300000 |
CMD_ZDR |
zdr |
false |
CC_MAX_BODY_MB |
— (env only) | 100 |
CC_STREAM_IDLE_MS |
— (env only) | 30000 |
CC_NONSTREAM_IDLE_MS |
— (env only) | 90000 |
CC_THINKING_IDLE_MS |
— (env only) | 120000 (thinking-phase grace: start/start-step/reasoning-start/reasoning-delta; use 180000 for deep reasoning / high reasoning_effort; cost of raising is slower failure detection on true hangs) |
CC_FORWARD_SAMPLING_PARAMS |
— (env only) | false (faithful CLI wire: top_p / stop / user / seed are accepted but not forwarded upstream, because the official CLI never sends them; set true to restore passthrough. Tool-protocol fields tool_choice / parallel_tool_calls are always forwarded — dropping them would silently change tool-call behaviour) |
Note on defaults: source runs (
bun start), Docker images, and Release binaries all share one set of builtin defaults —3050/0.0.0.0— matching the trackedconfig.json.PORT/HOSTmust be positive finite numbers; an invalid value aborts startup with a clear error.
Access-Control-Allow-Origin is auto-derived from whether you configured a
fallback key:
CC_API_KEY |
CORS_ALLOW_ORIGIN |
Effect |
|---|---|---|
| empty | unset | Allow-Origin: * — any web page may call, but must send its own user_* key |
| set | unset | Browser cross-origin calls are refused (returned as null) so arbitrary web pages can't silently drain your fallback key's quota; curl / SDKs (no Origin header) are unaffected |
| any | e.g. https://app.example.com |
Allow exactly that origin (comma-separated list also works) |
Set CORS_ALLOW_ORIGIN=* explicitly if you truly want open browser access
alongside a fallback key.
Per-request key first: Authorization: Bearer user_xxx or x-api-key: user_xxx (must match user_[A-Za-z0-9_-]+). If missing/invalid, the proxy falls back to CC_API_KEY:
CC_API_KEY=user_xxxxxxxxx ./cc-p-linux-x64Leave it empty to disable the fallback — keyless requests get 401. A client-supplied key always wins. Per-request x-cmd-zdr: 1 header enables the ZDR route for that call even when CMD_ZDR is off.
Oversized bodies (> CC_MAX_BODY_MB) are rejected with 413.
| Status | When | Client action |
|---|---|---|
400 |
Bad JSON / invalid request shape | Fix request |
401 |
Missing key, bad user_ format, or upstream 401/403 |
Check key |
413 |
Body over size limit | Shrink payload |
429 |
Zero output tokens (retry_after: 10), idle timeout (retry_after: 5) |
SDK auto-retries via Retry-After; after 3 consecutive timeouts the message suggests reducing context |
502/503 |
Upstream CC error (mapped from CC status/event) | Retry / backoff |
Upstream mapping (src/shared/errors.ts): CC true 429 → 429 (carries retry_after only when upstream sent Retry-After); 402 → 402 with no retry_after; usage_window/payment/auth/context classes carry no retry_after and are not retryable; 401/403 → 401/403, 400/422 → 400, 500/502 → 502, 503 → 503. CC tool-calls is normalized to OpenAI tool_calls and Anthropic tool_use on both stream and non-stream paths.
Client disconnects (request.signal) abort the upstream fetch immediately; unfinished streams are closed without leaking sockets.
Retries match the official CLI's retryable set (408 / 429 / 5xx): a true rate limit is retried in the HTTP retry loop, and a pre-output stream error — an upstream gateway failure that arrives as the first NDJSON event of an HTTP 200 stream (e.g. Gateway request failed) — is retried before anything reaches the client. Terminal business errors (usage window / payment / model-not-in-plan / auth / context overflow) are never retried. CC_RETRY_MAX (default 3) bounds total upstream attempts.
The proxy is stateless:
src/infra/cc.tsforwards the full message history on every request — no prune / trim / compact. History growth lives on the caller (Claude Code, Cline, your agent loop), not in the proxy. So context hygiene is a client habit, not a server setting. Facts that shape the habits below: stream idle timeout 30s / non-stream 90s (overridable viaCC_STREAM_IDLE_MS/CC_NONSTREAM_IDLE_MS, defaults unchanged; per-key consecutive counter, ≥3 → message tells you to reduce context); body cap 100MB (CC_MAX_BODY_MB); over-long prompts are normalized to400context_window_exceededon both HTTP (mapCcError) and in-stream error events (mapCcEventError, keyword match wins even over<429>); sessions are per-key, 12h + ≤1h jitter — a new key or a new session resets to zero;GET /v1/modelsexposescontext_window(provider passthroughcontext_window/context_length/max_context_tokens+ static fallback insrc/modules/models/catalog.ts), so pin a large-window model programmatically and fall back to manual lookup only for models still without a window.
- Pass file paths, don't paste contents. Anything pasted into
messagesis re-sent verbatim on every turn and can never be trimmed by the proxy. PreferRead-style tool calls (/path/to/file, offset/limit) over inlining whole files. - Narrow subagent scope + read-only tools. One task per subagent, with only the tools it needs (e.g. read/grep, no write/edit/exec). A wide subagent drags its whole transcript back into your main context.
- Cap each tool result. Truncate / head / grep before returning: large
tool_resultblocks are history too and compound every round-trip. If a result is huge, summarize it and drop the raw text in the next turn. - New task → new session (≈
/clear). At a task boundary, start a fresh conversation instead of reusing a long one. Switching API key (new per-key session) has the same reset effect. Reusingx-session-id/prompt_cache_keyheaders keeps the session — omit them when you want a clean slate. - Pin a large-window model for context-heavy work.
GET /v1/modelsnow carriescontext_window(provider fieldscontext_window/context_length/max_context_tokens, static fallback for known ids insrc/modules/models/catalog.ts). Query it and hardcode the model id on tasks that need long context (repo-wide refactors, big log dives). Manual lookup is only needed for ids still without a published window. - Watch
finishusage, not just errors. On stream / non-stream paths the final chunk carriesusage(prompt_tokens/inputTokensclimbing turn after turn is your early warning). IfinputTokenskeeps rising with no task progress, trim or restart before you hit the wall.
| Signal | Meaning | Do this (don't blind-retry) |
|---|---|---|
400 context_window_exceeded |
Prompt matched CONTEXT_WINDOW_EXCEEDED_PATTERN (src/shared/errors.ts) on HTTP or in-stream error — over-long by keyword even if upstream said 429 |
Trim history / summarize / start a new session. Retrying the same payload always fails. |
429 Empty response / zero output, retry_after: 10 |
Upstream returned zero output tokens | Safe to retry once with backoff; if it repeats, shrink context and simplify the last turn. |
429 idle timeout, retry_after: 5 |
No upstream bytes for 30s (stream) / 90s (non-stream) (overridable via CC_STREAM_IDLE_MS / CC_NONSTREAM_IDLE_MS, defaults unchanged); thinking phase (lastCcEvent in start/start-step/reasoning-start/reasoning-delta) gets a 120s window (CC_THINKING_IDLE_MS); per-key consecutive counter, ≥3 → message tells you to reduce context even when the current request is small (idle ≠ large context) |
Don't blind-compress: first check which 429 it is (see below). If retry_after: 5, suspect slow upstream / fan-out / huge tool_result / reasoning pause; split the task, cap tool results, lower concurrency. If the log shows thinkingPhase=true + lastCcEvent=reasoning-start + elapsedMs≈timeoutMs, raise CC_THINKING_IDLE_MS instead (see "Fails while thinking" below). |
429 thinking timeout, retry_after: 5 + thinkingPhase=true |
reasoning-start followed by 30s+ of zero upstream bytes: readWithTimeout used to kill it at 30s (before the 120s thinking grace existed). Unrelated to context size — streaming-timeout inputTokens is always 0, so it can't judge size. Self-proof triple: lastCcEvent=reasoning-start/start with no delta + bytesReceived of tens of bytes + elapsedMs pinned at the threshold. Opencode wraps it as failed to send message. |
Don't compress context. Raise CC_THINKING_IDLE_MS (e.g. 180000 for deep reasoning), or split the task / lower reasoning_effort. True hang cost: failure detection is delayed to the threshold. |
429 true rate limit (has Retry-After → has retry_after) |
Real upstream 429 mapped through src/shared/errors.ts |
Back off and honor Retry-After. Trimming won't help — wait, then retry. 402 → 402 with no retry_after; usage_window/payment/auth/context classes carry no retry_after and are not retryable. |
502/503 other |
Genuine upstream error (CC_STATUS_MAP; unlisted → 502 upstream_error) |
Retry / backoff. |
How to tell the three 429s apart: read the body — message text plus the
numeric retry_after (10 = zero-output, 5 = idle timeout, 30 = real
rate limit). sendJSON also mirrors retry_after as a Retry-After
response header, so SDK auto-retry works when the case is actually retryable.
Opencode wraps this proxy's 429 body as Opencode failed to send message ... rate_limit_error — when you see that wrapper, unwrap it and check the inner
retry_after before deciding.
- Symptom:
429retry_after: 5wrapped by Opencode asfailed to send message, right after a long reasoning pause (30s+ with no output). - Confirm: server log line carries
thinkingPhase=true+lastCcEvent=reasoning-start(orstartwith no delta) +elapsedMspinned attimeoutMs+bytesReceivedof tens of bytes. That triple = thinking timeout, not context bloat. Do not compress context. - Fix: raise
CC_THINKING_IDLE_MS(e.g.180000); if still pinned at the threshold, keep raising, or split the task / lowerreasoning_effort. Trade-off: a true hang now takes the full threshold to surface.
src/shared/runtime.ts:92-104 switches the idle-timeout copy to try reducing context length (summarize earlier messages) once the same API key has
≥3 consecutive timeouts (TTL 30min, success resets to zero). After that
point every idle timeout on that key carries the "reduce context" wording —
even a tiny request. It does not mean the current prompt is too large.
- Idle ≠ large. Idle timeout fires on no upstream bytes for 30s/90s.
Small context + subagent fan-out, one giant
tool_result, a long reasoning pause, or just a slow upstream all trigger it. - Counter is per-key, shared. Main + subagents using the same key share
one counter and (by default) one upstream
x-session-id(12h,src/infra/session.ts), so they pollute each other: 3 slow subagent calls poison the 4th tiny call. - Read the log. A
Stream idle timeoutline with smallinputTokens+lastCcEventstuck with no delta +bytesReceived ≈ 0= upstream was slow, not your context. LargeinputTokensclimbing turn after turn = real bloat. - Stop the bleed (30s triage): give subagents their own key; new task →
new session (omit
x-session-id/prompt_cache_key); lower concurrency (one subagent, one task); truncatetool_resultbefore returning; if the upstream is legitimately slow, raiseCC_STREAM_IDLE_MS=60000/CC_NONSTREAM_IDLE_MS=120000(tolerates slow first-token but delays failure detection — see.env.example).
Per API key, before the first upstream call (and every ~8h after):
POST /alpha/fingerprint/record— random but plausible fingerprint (SHA-256 hashed machine/MAC/user/hostname IDs, CPU pool, memory, timezone,win32/x64), bound to the key.POST /alpha/lifecycle-events(cli_session_exists) — sent in parallel with the fingerprint.
Each POST /alpha/generate then carries User-Agent: cli, Authorization, x-cli-environment: production, x-command-code-version (npm command-code@latest, refreshed daily), x-session-id (sess_<16hex>, 12h per-key session, reusable via x-session-id / prompt_cache_key headers), x-project-slug, traceparent (W3C), and optional x-cmd-zdr: 1. The request body mirrors the CLI's top-level shape: config / memory / taste / skills: null / permissionMode / threadId / params, where params carries only model / messages / tools / system / max_tokens / stream / temperature? / reasoning_effort?.
.
├── config.json # Non-sensitive defaults (tracked)
├── .env.example # Template for local secrets (copy to .env)
├── src/
│ ├── index.ts # Routes, CORS, error mapping, startup, healthcheck CLI
│ ├── config.ts # config.json + env resolution, body-limit
│ ├── openai.ts # POST /v1/chat/completions (stream + non-stream)
│ ├── responses.ts # POST /v1/responses + Responses↔OpenAI conversion
│ ├── anthropic.ts # POST /v1/messages + Anthropic↔OpenAI conversion
│ ├── cc.ts # CC request building + forwarding (/alpha/generate)
│ ├── sse.ts # SSE pipeline + CC NDJSON → OpenAI chunks
│ ├── fingerprint.ts # Fingerprint pool + init pre-requests (per key)
│ ├── session.ts # Per-key sessions + hourly cleanup
│ ├── models.ts # Model list + Provider API cache
│ ├── errors.ts # Status/error/finish-reason/usage mapping
│ ├── http.ts # JSON/SSE helpers, body reader, timeout reader
│ ├── auth.ts # Bearer / x-api-key extraction + fallback
│ ├── runtime.ts # Idle timeouts + consecutive-timeout counter
│ ├── version.ts # CC version from npm registry
│ ├── util.ts # IDs, hashing, slug, traceparent
│ └── logger.ts # Console (+ optional file) logger
├── test/
│ ├── e2e.ts # Integration suite against a mock upstream
│ └── timeouts.ts # Idle-timeout + disconnect suite (~35s)
├── Dockerfile # bun --compile → distroless
├── docker-compose.yml # Local run (uses .env)
├── docker-compose.prod.yml# Prod run (ghcr.io image, env-driven)
└── .github/workflows/
├── release.yml # Tag + cross-compile 6 binaries → draft Release
└── deploy.yml # Prod deploy
Prefer containers? The image is the same single binary on distroless (no shell).
Only config.json is baked in; secrets come from the environment:
# Local container (injects ./.env via env_file)
docker compose up -d
PROXY_PORT=13050 docker compose up -d
# Manual
docker build -t commandcode-proxy:latest .
docker run -d -p 3050:3050 --env-file .env commandcode-proxy:latestHealth check runs the embedded CLI (exit 0 iff GET /health → {"ok":true}):
/app/server healthcheckRequires Bun 1.1+. Source runs and tests use bun run scripts:
bun install
cp .env.example .env # fill in CC_API_KEY (optional)
bun start # run from source → http://0.0.0.0:3050
bun run dev # watch mode (auto-reload)Mock upstream — no real API calls:
bun run test # e2e suite (protocol, streaming, errors)
bun run test:timeouts # idle timeout + client disconnect
bun run test:idle-transport # transport idle cap regression (~33s)
bunx tsc --noEmit # typecheck (also runs in CI)Build the binary yourself:
bun build ./src/index.ts --compile --minify --outfile server && ./server
./server healthcheckPushes to master (non-doc changes) trigger the Release workflow: typecheck +
e2e tests, patch-bump from the latest v*.*.* tag, cross-compile the 6
platform binaries, and draft a GitHub Release with SHA-256 checksums. Manual
minor / major / custom bumps via Actions → Release → Run workflow.
For educational and research purposes only. Not affiliated with Command Code. You are responsible for complying with the Command Code Terms of Service. Keys are sent per request via headers and never logged. Keep call frequency within normal CLI usage to avoid risk controls.