cmdcode2api is a small OpenAI-compatible gateway for Command Code. OpenAI-style clients call the familiar /v1/chat/completions and /v1/models endpoints, and the gateway forwards requests to Command Code — rotating across multiple accounts, tracking usage, and serving a built-in admin WebUI.
- OpenAI-compatible
POST /v1/chat/completions(streaming and non-streaming) andGET /v1/models - Multiple Command Code accounts with round-robin rotation and automatic failover (
401/403/429/5xx), including per-account 429 cooldown - Multiple local client API keys with per-key request and token accounting
- Command Code quota dashboard: 5-hour / weekly / estimated monthly progress bars, credit balances, plan and billing period, refreshed in the background every 5 minutes
- Embedded single-file WebUI: usage dashboard, account/key management, model exposure editor, live settings, and log tail
- Browser OAuth helper for obtaining a Command Code API key (CLI or WebUI); each OAuth run adds an account
- Local bearer-token auth for clients, separate admin password for the WebUI
- Usage counters (global, per-account, per-client-key) and cached quota snapshots persisted to
usage.json - Base64
image_urlconversion to Command Code image blocks; CORS enabled for local UI clients GET /healthandGET /usageendpoints
go build -o cmdcode2api ./cmd/cmdcode2api
./cmdcode2apiThe first start writes config.yaml in the working directory and prints the generated client key and WebUI admin password once. Add a Command Code account (next section), then point any OpenAI client at the gateway:
curl http://localhost:11434/v1/chat/completions \
-H "Authorization: Bearer <local-api-key>" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek/deepseek-v4-flash",
"messages": [
{"role": "user", "content": "Hello"}
],
"stream": false
}'The server starts fine with zero accounts — the WebUI, client keys, and settings all work, and chat requests return 503 no_accounts until one is added. Adding the first account from the WebUI fetches the model catalog immediately.
Prebuilt multi-arch images are published to GHCR by CI on every master push (latest) and every v* tag. config.yaml and usage.json live in the /data volume:
docker run -d --name cmdcode2api -p 11434:11434 -v cmdcode2api-data:/data ghcr.io/peach0x33a/cmdcode2api:latestA ready-to-copy Compose file is provided as docker-compose.example.yml:
cp docker-compose.example.yml docker-compose.yml
docker compose up -dWith an empty data directory the first start generates config.yaml, prints the client key and admin password once (docker compose logs), and then serves. To build the image locally, use docker build -t cmdcode2api . — networks that cannot reach proxy.golang.org can pass --build-arg GOPROXY=https://goproxy.cn,direct.
Open /webui, go to the Accounts tab, and paste a Command Code API key. This works regardless of container networking or SSH access.
The Accounts tab can also run the OAuth flow. It uses the server's local 127.0.0.1:5959-5968 callback ports, which works out of the box when the browser runs on the same machine. When it does not — remote or containerized deployments, or a browser that cannot reach that port — the Command Code page cannot hand the credential back automatically. In that case the page stops on a URL carrying the credential in its query string (?apiKey=…&state=…); paste that whole address-bar link into the 回调链接 field and submit it. The gateway parses the credential out of the link locally (it never fetches the URL), and the single-use state token still has to match the pending flow, so a forged link cannot inject an account.
./cmdcode2api --oauthThe OAuth callback server always binds to 127.0.0.1:5959-5968 on the machine running the binary. Each successful flow appends one account to config.yaml; run --oauth again (e.g. with a different browser profile) to add more accounts, and re-authorizing an existing key is a no-op.
Browser on the same machine — open the printed authorization URL directly.
Browser on a different machine — forward a callback port over SSH and pass an explicit callback URL:
# local machine
ssh -L 5959:127.0.0.1:5959 user@server
# server
./cmdcode2api --oauth --oauth-callback http://localhost:5959/callbackThe callback server listens on 127.0.0.1:5959-5968 inside the container, so the browser must be able to reach that port in the container's network namespace:
# browser on the same machine: share the host network
docker compose run --rm --network host cmdcode2api --oauth
# browser on a different machine: forward the port over SSH first
ssh -L 5959:127.0.0.1:5959 user@server
docker compose run --rm --network host cmdcode2api --oauth \
--oauth-callback http://localhost:5959/callback
# without Compose
docker run --rm -it --network host -v cmdcode2api-data:/data \
ghcr.io/peach0x33a/cmdcode2api:latest --oauthAfter authorizing, the account is appended to /data/config.yaml automatically; run docker compose up -d afterwards if the gateway is still stopped.
| Flag | Meaning |
|---|---|
--oauth |
Run the browser OAuth flow to obtain a Command Code API key |
--oauth-callback URL |
Explicit callback URL for --oauth, e.g. http://localhost:5959/callback |
--host HOST |
HTTP listen host override, e.g. 0.0.0.0 |
--port PORT |
HTTP listen port override |
--debug |
Print request bodies and all upstream SSE events to stderr |
--version |
Print the version and Go runtime version, then exit |
config.yaml lives in the working directory, is created automatically, and is ignored by git. Example shape:
api_key: ccgw-generated-local-client-key
api_keys:
- name: default
key: ccgw-generated-local-client-key
- name: my-agent
key: ccgw-another-client-key
admin_password: kR7vBn2xQm9T
webui: true
commandcode:
base_url: https://api.commandcode.ai
accounts:
- name: main
api_key: your-command-code-api-key
enabled: true
- name: backup
api_key: another-command-code-api-key
host: localhost
port: 11434
exclude_models:
- gpt-
- claude-
- gemini-Fields:
api_key— legacy single local client key. Migrated intoapi_keyson load and cleared on save once the list is non-empty.api_keys— local bearer keys that clients use to call this gateway. Requests and token usage are tracked per key. Manage them in the WebUI; changes apply immediately and persist here.admin_password— password for the WebUI admin API. Generated on first start when empty and printed once.webui— set tofalseto disable serving the embedded WebUI and admin API entirely.commandcode.accounts— list of Command Code credentials. Requests rotate across enabled accounts (see below). The legacy single-key fieldcommandcode.api_keyis still accepted and migrated to a one-entry list on load.commandcode.base_url— Command Code API base URL.host— HTTP listen host. Defaults tolocalhost; use0.0.0.0to listen on all interfaces.port— HTTP listen port. Defaults to11434.exclude_models— model ID prefixes hidden from/v1/modelsand rejected by/v1/chat/completions. Maintained from the WebUI's Models tab, where the upstream catalog is shown with checkboxes.
New configs exclude gpt-, claude-, and gemini- by default. These prefixes match both plain model IDs such as gpt-4 and provider-qualified IDs such as openai/gpt-4 by checking the part after the final /. To make all models available, remove the entries or set exclude_models: [].
Every chat request is sent with the next enabled account in round-robin order. When an account fails with 401, 403, 429, or a 5xx, the request is retried with the next account automatically:
- A
429puts the account into cooldown for the upstreamRetry-Afterduration (60 seconds by default); cooldown accounts are skipped until they recover. If every enabled account is cooling down, the client receives429 rate_limit_errorwith the earliest recovery time. 400/422(bad request) and client-canceled contexts are not retried.- Failover happens before any bytes are sent to the client; once a stream has started it is never replayed on another account.
- Per-account request/token counters persist in
usage.json; error state, last error, and cooldown windows are runtime-only and visible in the WebUI.
api_keys holds the bearer keys clients use to call this gateway; different clients can each use their own key:
- Create keys in the WebUI's Keys tab — values are always server-generated (
ccgw-prefix) and never accepted from input; keys can be enabled/disabled, copied, and deleted. - Each key gets independent request and token counters, persisted in
usage.jsonunderclient_keysand visible at/usage. - Keys are masked in the list; reveal or copy them on demand — the full value is shown once at creation and available via the reveal endpoint.
- The legacy single
api_keyfield keeps working and migrates to one key nameddefaulton load.
With webui enabled (the default), the binary serves an embedded single-file admin interface under /webui (the root path stays free for the API):
http://localhost:11434/webui
Log in with the server address and admin_password. The same internal/web/index.html can also be opened directly in a browser and pointed at any running instance.
Tabs:
- Overview — version, uptime, listen address, usage counters, account/key/model summaries, and a quota sync summary (synced / exceeded / low-balance accounts, last refresh)
- Accounts — add (paste a key, or run OAuth and paste the redirect link when the browser cannot reach the server), edit name/key, enable/disable, connectivity test, quota refresh, delete; per-account requests, tokens, errors, cooldown state, last error, and quota. OAuth-added accounts are named after the Command Code user automatically
- Models — checkbox list of upstream models; checked = exposed via
/v1/modelsand callable, unchecked = hidden. This is the editor forexclude_modelsand applies live - Keys — create local client API keys, enable/disable, copy, delete; per-key usage (see Client keys)
- Settings — edit
base_url(live),host/port/webui(persisted, applied on restart), and change the admin password (requires the current password; every existing admin session is kicked afterwards) - Logs — tail of the in-memory log ring (last 500 lines)
Changes to accounts and settings are written back to config.yaml immediately — no restart needed.
Security notes: admin authentication is rate limited per source IP (5 failed attempts in 10 minutes locks the source out for 15 minutes), responses carry hardening headers (CSP, X-Frame-Options: DENY, nosniff, Referrer-Policy: no-referrer) and are never cached, and the login form supports password managers (Bitwarden et al.). "Remember password" keeps the credential in localStorage; unchecked, it lives in sessionStorage and dies with the tab.
The Accounts tab shows each account's Command Code quota, read with the same API key from the undocumented /alpha/* endpoints (whoami, billing/credits, billing/subscriptions, usage/summary):
- 5-hour and weekly bars come straight from the upstream
windowLimitsobjects. Bars grade amber at ≥50% used, heavier amber at ≥75%, and red at ≥90%. - Monthly is derived: the API exposes no monthly window, so the cap comes from the community CLI's plan mapping (
individual-pro→ 30,individual-pro-v1→ 80, …), withused = cap − remaining credits. It is labelled as estimated; unknown plans fall back to balance-only display. - Credit balances (monthly remaining / purchased / free), plan name and status, billing-period end, and billing-period totals.
- A per-account refresh quota button and a refresh all button (which returns immediately and refreshes in the background).
Quota refresh runs once shortly after startup and every 5 minutes afterwards; the latest snapshot is cached in usage.json so it survives restarts. A failed query keeps the last successful snapshot and only records the error and check time. These endpoints come from commandcode-usage; they are unofficial, so the parser tolerates field drift (camelCase or snake_case, epoch seconds / milliseconds / ISO timestamps, flat or data-wrapped responses).
The UI talks to a JSON API under /admin/api/*, authenticated with Authorization: Bearer <admin_password> (works for scripts and curl too):
GET /admin/api/overview
GET /admin/api/accounts
POST /admin/api/accounts {"name": "...", "api_key": "..."}
PATCH /admin/api/accounts/{id} {"enabled": true}, {"name": "..."} or {"api_key": "..."}
DELETE /admin/api/accounts/{id}
POST /admin/api/accounts/{id}/test
POST /admin/api/accounts/{id}/quota/refresh
POST /admin/api/quotas/refresh 202 + background refresh of every account; with {"id": "..."} it refreshes one account synchronously
GET /admin/api/models
PUT /admin/api/models {"exposed": ["model-id", ...]}
GET /admin/api/keys
POST /admin/api/keys {"name": "..."} — the key value is always server-generated
GET /admin/api/keys/{id}/reveal
PATCH /admin/api/keys/{id} {"enabled": true} or {"name": "..."}
DELETE /admin/api/keys/{id}
GET /admin/api/settings
PUT /admin/api/settings
GET /admin/api/logs?after=SEQ
POST /admin/api/oauth/start
POST /admin/api/oauth/complete {"callback_url": "<浏览器跳转后的完整链接>"}
GET /admin/api/oauth/status
POST /admin/api/oauth/cancel
GET /admin/api/accounts includes a nested quota object per account; GET /admin/api/overview includes a quotas summary (synced, exceeded, low_balance, last_checked_at).
No authentication required.
{"status":"ok"}No authentication required. Returns locally accumulated usage counters plus per-account and per-client-key breakdowns:
{
"total_requests": 1,
"prompt_tokens": 7527,
"completion_tokens": 55,
"cache_read_tokens": 7424,
"cache_write_tokens": 0,
"accounts": {
"a1b2c3d4": {"requests": 1, "prompt_tokens": 7527, "completion_tokens": 55, "cache_read_tokens": 7424, "cache_write_tokens": 0}
},
"client_keys": {
"k9f8e7d6c": {"requests": 1, "prompt_tokens": 7527, "completion_tokens": 55, "cache_read_tokens": 7424, "cache_write_tokens": 0}
}
}Usage is persisted to usage.json, which is ignored by git. Cached quota snapshots also live in that file but are never exposed here.
Returns the model list after applying exclude_models filtering, so excluded models do not appear. Each entry carries a context_window field when the upstream reports one.
Accepts OpenAI-style chat completion requests and forwards them to Command Code. Requests for excluded models return 404 with an OpenAI-compatible error JSON shape.
Model IDs must match /v1/models output exactly, including the provider prefix:
deepseek/deepseek-v4-flash ✓
deepseek-v4-flash ✗ missing provider prefix
deepseek-ai/deepseek-v4-flash ✗ wrong provider prefix
Supported request styles: plain text messages, multimodal content arrays, stream: true server-sent events, and stream: false JSON responses.
Images: multimodal image_url values must be base64 data:image/...;base64,... URLs. Remote HTTP(S) image URLs are rejected with 400 invalid_request_error — the gateway never downloads remote images.
Upstream inputTokenDetails.cacheReadTokens is exposed in the response as usage.prompt_tokens_details.cached_tokens. cacheWriteTokens is available in /usage but not in the Chat Completions response, because OpenAI's standard usage schema has no cache-write field.
If nginx and cmdcode2api run on the same host, keep forwarding Cloudflare's CF-Connecting-IP and X-Forwarded-For headers. The server accepts these headers only from loopback proxy connections, then uses the resolved address for HTTP logs and admin login rate limiting. Direct connections with forged proxy headers continue to use their TCP peer address.
For X-Forwarded-For, only the rightmost entry (the one an appending proxy wrote) is honored: leftmost entries are client-controlled, and a client forging a fresh one per request would rotate its rate-limit key. Do not preserve the client-supplied header via proxy_add_x_forwarded_for, and prefer allowing only Cloudflare's published proxy CIDRs at the nginx level so CF-Connecting-IP cannot be forged by connecting to the origin directly. If nginx itself also needs $remote_addr to represent the end user, configure real_ip_header CF-Connecting-IP with Cloudflare's published proxy CIDRs.
Client Bearer Tokens are any key from the api_keys list in config.yaml.
cmd/cmdcode2api/ CLI entrypoint
internal/app/ gateway implementation
internal/web/ embedded single-file WebUI (index.html)
The repository ignores runtime/secrets artifacts:
cmdcode2api
cc-gateway
config.yaml
usage.json
*.exe
.oauth_state
.oauth_url
This is a personal utility gateway and currently targets the Command Code API shape observed during development. If Command Code changes its internal API, the adapter may need updates. The project was originally named cc-gateway and was renamed to avoid confusion with Claude Code's common cc abbreviation.