English | 中文
Local AI API gateway for OpenAI / Gemini / Anthropic. Runs on your machine, keeps tokens counted (SQLite), offers priority-based load balancing, optional API format conversion (OpenAI Chat/Responses ↔ Anthropic Messages, plus Gemini ↔ OpenAI/Anthropic, including SSE/tools/images), and one-click setup for Claude Code / Codex.
Default listen port: 9208 (release) / 19208 (debug builds).
- Multiple providers:
openai,openai-response,anthropic,gemini,kiro,codex,xai - Built-in routing + optional format conversion (OpenAI Chat ⇄ Responses; Anthropic Messages ↔ OpenAI; Gemini ↔ OpenAI/Anthropic; SSE supported)
- Upstream is the only scheduling unit: priority, proxy, enabled, models, and credential live on each upstream entry
- No separate Accounts / Providers page — on Upstreams, use Add Account to log in or import Kiro / Codex / xAI; the same editor manages identity, quota, token refresh / auto-refresh (where the provider supports it), and routing
- Account-backed upstreams: one account maps to one stable upstream; deleting it cascades account credentials; copy is disabled
- Model alias mapping (exact / prefix* / wildcard*) and response model rewrite
- Local access key + upstream credential injection (
credential.api_keysor request-header fallback when local auth is off) - SQLite-powered dashboard (requests, tokens, cached tokens, latency, recent)
- macOS tray live token rate (optional)
Dashboard![]() |
Core![]() |
Upstreams![]() |
Add upstream![]() |
- Install: move
Token Proxy.appto/Applications. If blocked:xattr -cr /Applications/Token\ Proxy.app. - Launch the app. The proxy starts automatically.
- Open Config File tab, edit and save (writes
config.jsoncin the Tauri config dir). Defaults are usable; just paste your upstream API keys. Running proxies auto-apply the new config via reload or restart when needed. - Call via curl (example with local auth):
curl -X POST \
-H "Authorization: Bearer YOUR_LOCAL_KEY" \
-H "Content-Type: application/json" \
http://127.0.0.1:9208/v1/chat/completions \
-d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":"hi"}]}'You can also call using the Anthropic Messages format (useful for Claude Code clients):
curl -X POST \
-H "x-api-key: YOUR_LOCAL_KEY" \
-H "Content-Type: application/json" \
http://127.0.0.1:9208/v1/messages \
-d '{"model":"claude-3-5-sonnet-20241022","max_tokens":256,"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}'- This repo is now a Cargo workspace; the Tauri app still lives in
src-tauri/. - CLI crate:
crates/token_proxy_cli(binarytoken-proxy). - Default config path:
./config.jsonc(override with--config). - GitHub Releases also publish packaged CLI archives per target:
- Unix:
token-proxy_cli_<version>_<target>.tar.gz - Windows:
token-proxy_cli_<version>_<target>.zip
- Unix:
# start proxy
cargo run -p token_proxy_cli -- serve
# start with custom config path
cargo run -p token_proxy_cli -- --config ./config.jsonc serve
# config helpers
cargo run -p token_proxy_cli -- config init
cargo run -p token_proxy_cli -- --config ./config.jsonc config path# watch mode
pnpm test
# run once (CI-friendly)
pnpm test:run
# coverage (optional)
pnpm test:coverage
# TypeScript typecheck
pnpm exec tsc --noEmitNotes:
- Test files live in
src/**/*.test.{ts,tsx}. - Global test setup (Tauri mocks + jsdom polyfills) is in
src/test/setup.ts. - Vitest config is in
vitest.config.ts.
- File:
config.jsonc(comments + trailing commas allowed) - Location:
- CLI:
--config(default:./config.jsonc) - Tauri: AppConfig directory (resolved automatically by the app)
- CLI:
| Field | Default | Notes |
|---|---|---|
host |
127.0.0.1 |
Listen address (IPv6 allowed; will be bracketed in URLs) |
port |
9208 release / 19208 debug |
Change if the port is taken |
local_api_keys |
[] |
When nonempty: local auth uses format-specific headers (see Auth rules); local auth inputs are not forwarded upstream. |
app_proxy_url |
null |
Proxy for app updater & as placeholder for upstreams ("$app_proxy_url"). Supports http/https/socks5/socks5h. |
log_level |
silent |
`silent |
max_request_body_bytes |
104857600 (100 MiB) |
0 = fallback to default. Shared inbound, JSON filter, and format-conversion ceiling. |
retryable_failure_cooldown_secs |
0 |
Cooldown window after retryable failures that should temporarily sideline an upstream. 0 disables cooldown. Reloading or restarting the running proxy resets current cooldown state. |
same_upstream_retry_count |
1 |
Extra same-upstream retries after a retryable failure (excluding the first attempt). 0 disables same-upstream retry; max 5. |
codex_session_scoped_cooldown_enabled |
false |
Only applies to Codex account-backed OpenAI Responses requests. When enabled, cooldown is isolated by session_id; final success clears that session, and requests without session_id do not share cooldown. |
tray_token_rate.enabled |
true |
macOS tray live rate; harmless elsewhere. |
tray_token_rate.format |
split |
combined (total), split (↑in ↓out), both (`total |
upstream_strategy |
{ "order": "fill_first", "dispatch": { "type": "serial" } } |
Structured strategy object. order controls candidate ordering inside one priority group; dispatch controls serial / hedged / race execution. |
| Field | Default | Notes |
|---|---|---|
id |
required | Unique per upstream. |
providers |
required | One upstream can serve multiple providers. Account-based providers kiro / codex / xai must be alone (cannot mix with each other or with API-key providers). |
base_url |
required | Full base; overlapping path parts are de-duplicated. (providers=["kiro"] / ["codex"] can be empty; xai account-backed uses the fixed CLI gateway base.) |
credential |
{ "type": "passthrough" } |
Discriminated union — the only credential shape. See below. |
preferred_endpoint |
null |
kiro only: ide or cli. |
proxy_url |
null |
Per-upstream proxy (not an account field); supports http/https/socks5/socks5h; default is no system proxy. $app_proxy_url placeholder allowed. |
priority |
0 |
Per-upstream scheduling weight (not an account field). Higher = tried earlier. |
enabled |
true |
Per-upstream toggle (not an account field). Disabled upstreams are skipped. |
available_models |
[] |
Inbound model allowlist; empty = no restriction. |
model_mappings |
{} |
Exact / prefix* / *. Priority: exact > longest prefix > wildcard. Response echoes original alias. |
overrides.model_capabilities |
{} |
Optional per-model tri-state capabilities, e.g. { "gpt-5": { "image_input": false, "native_web_search": true } }; omitted values stay unknown. |
convert_from_map |
{} |
Explicitly allow inbound format conversion per provider. Example: { "openai-response": ["openai_chat", "anthropic_messages"] }. |
overrides.header |
{} |
Set/remove headers (null removes). Hop-by-hop/Host/Content-Length are always ignored. |
type |
Shape | Notes |
|---|---|---|
passthrough |
{ "type": "passthrough" } |
No static upstream key; may rely on request-header fallback when local_api_keys is empty. Not allowed for kiro / codex / xai. |
api_keys |
{ "type": "api_keys", "api_keys": ["key-a", "key-b"] } |
Static keys; empty list behaves like passthrough after normalize. Not allowed for account-based providers. |
account |
{ "type": "account", "provider": "kiro"|"codex"|"xai", "account_id": "..." } |
Binds one Provider Account. provider must match the sole account-based entry in providers[]. One (provider, account_id) may bind only one upstream. |
Kiro / Codex / xAI must use credential.type = "account". Account login/import creates a stable binding via app lifecycle; do not invent frontend-only ids such as kiro-default / codex-default / xai-default. Deleting an account-backed upstream cascades deletion of that account credential; copy is forbidden.
Legacy flat fields (api_key, api_keys, kiro_account_id, codex_account_id, xai_account_id) are not part of the documented schema. On load they are migrated once into credential and written back (no dual-format docs).
- Gemini native API:
/v1beta/models/*(including:generateContent,:streamGenerateContent,:countTokens,:embedContent,:batchEmbedContents), model catalog/detail,/v1beta/files*,/upload/v1beta/files*,/v1beta/cachedContents*,/v1beta/tunedModels*→gemini. - Anthropic:
/v1/messages(and subpaths) and/v1/complete→anthropic(Kiro shares the same format). - OpenAI create routes:
/v1/chat/completions→openai;/v1/responses→openai-response. - OpenAI native pass-through routes are explicitly pinned to OpenAI-compatible providers and won't fall through to
anthropic:chat/completions/*,responses/*,assistants*,threads*,conversations*,chatkit*,containers*,evals*,files*,uploads*,batches*,vector_stores*,images/*,audio/*,embeddings,moderations,completions,fine_tuning/*,realtime/*,skills*,videos*. - For
responses/*resources, provider preference isopenai-response→openai; for other OpenAI native resources, provider preference isopenai→openai-response. - Other paths: choose the provider with the highest configured priority; tie-break is
openai>openai-response>anthropic. - Cross-format fallback/conversion is controlled by
upstreams[].convert_from_map(no global switch). If a provider has no eligible upstream for the inbound format, it won't be selected. - If
openaiis missing for/v1/chat/completions: fallback can beopenai-response,anthropic, orgemini(priority-based; tie-break prefersopenai-response). - For
/v1/messages: choose betweenanthropicandkiroby priority; tie-break uses upstream id. If the chosen provider returns a retryable error, the proxy will fall back to the other native provider (Anthropic ↔ Kiro) when configured. - If neither
anthropicnorkiroexists for/v1/messages: other providers can be selected only when allowed foranthropic_messagesviaconvert_from_map(e.g.openai-response,openai,gemini). - If
openai-responseis missing for/v1/responses: fallback can beopenai,anthropic, orgemini(priority-based; tie-break prefersopenai). - If
geminiis missing for/v1beta/models/*:generateContentor*:streamGenerateContent: fallback can beopenai-response,openai, oranthropic(priority-based; tie-break prefersopenai-response). - Other Gemini native endpoints are pass-through only and require a configured
geminiupstream.
- Local access:
local_api_keysnonempty → require format-specific key. Local auth inputs are reserved for gateway access and not used as upstream credentials.- Model catalogs (
GET/HEAD/v1/modelsand/v1beta/openai/models) require the same key and only include authorized upstreams. Connectivity probes and allowed CORS preflight keep their existing behavior. - OpenAI / Responses:
Authorization: Bearer <key> - Anthropic
/v1/messages:x-api-keyorx-anthropic-api-key - Gemini native API:
x-goog-api-keyor?key=...
- Model catalogs (
- When
local_api_keysis nonempty, inbound request auth headers are not collected for upstream; configurecredential.api_keys(or an account credential) on the upstream instead. - Upstream auth resolution (per request; runtime expands
credential.api_keysinto the attempt key):- OpenAI-compatible (and most non-Anthropic providers):
credential.api_keys→ requestx-openai-api-key/Authorizationonly whenlocal_api_keysis empty → no key. - Anthropic:
credential.api_keys→ requestx-api-key/x-anthropic-api-key/ bearer fallback only whenlocal_api_keysis empty → no key. Missinganthropic-versionis auto-filled with2023-06-01. - Gemini:
credential.api_keys→ requestx-goog-api-key→ query?key=...(query/header fallback only whenlocal_api_keysis empty) → skip attempt. - Account-backed (
kiro/codex/xai): uses the bound Provider Account identity (OAuth / Agent Assertion); notapi_keys.
- OpenAI-compatible (and most non-Anthropic providers):
Manage keys in API Key, after Upstreams. Each key has a stable ID, name, secret, enabled state and scope:
"local_api_keys": [
{ "id": "desktop", "name": "Desktop", "key": "replace-with-your-secret", "enabled": true, "scope": { "type": "auto" } },
{ "id": "limited", "name": "Limited", "key": "replace-with-another-secret", "enabled": true, "scope": { "type": "selected", "upstream_ids": ["your-upstream-id"] } }
]An empty list disables local authentication. A nonempty list with all keys disabled rejects authenticated routes. Auto includes all eligible enabled upstreams, including future additions; selected scope never expands automatically. Disabled or deleted upstream bindings remain restricted and are shown as unavailable. Explicit unauthorized upstream-id/model routes return 403; missing, invalid or disabled keys return 401. Retries, parallel dispatch, provider fallback and cached/augmented model catalogs share this scope.
The legacy local_api_key is migrated unchanged to an enabled Auto entry, with a backup and a migration notice. Both old and new fields in one configuration are an error. Configurations saved by this version are not guaranteed to be readable by older versions. New requests use saved changes immediately; in-flight requests retain their original configuration.
Claude Code and Codex setup require an explicit selection when multiple keys are enabled. One enabled key is selected automatically. Client setup sends the selected ID to the backend; disabled or deleted selections are rejected.
- Priorities: higher
prioritygroups first. upstream_strategy.ordercontrols selection inside the same priority group:fill_first: keep the configured list order.round_robin: rotate the starting point across requests.
upstream_strategy.dispatchcontrols how requests are launched inside one priority group:{"type":"serial"}: try one candidate at a time.{"type":"hedged","delay_ms":2000,"max_parallel":2}: launch the first candidate immediately, then add one more attempt afterdelay_msif the prior attempt is still unresolved, up tomax_parallel.{"type":"race","max_parallel":3}: launch up tomax_parallelcandidates immediately and take the first successful result.
- Retryable conditions: network timeout/connect errors, or status 400/401/403/404/408/413/422/429/307/5xx (including 504/524). Non-context-window 413 responses skip same-upstream retry and fail over directly; context-window errors remain terminal.
- Explicit Responses field rejections and xAI invalid encrypted reasoning use bounded same-identity request repair before ordinary retry/failover. Repair retries do not consume
same_upstream_retry_count, and the repaired body is retained for later attempts. - Same-upstream retry: on a retryable failure, retry the same upstream up to
same_upstream_retry_countextra times (default1, excluding the first attempt) before failing over. After the first client-visible stream output, the proxy does not replay the same attempt. - Cooldown conditions:
401/403/408/429/5xxwill temporarily move the failed upstream behind ready peers forretryable_failure_cooldown_secs(default0, disabled);400/404/422/307stay retryable but do not trigger cross-request cooldown. Withcodex_session_scoped_cooldown_enabled=true, Codex account-backed OpenAI Responses cooldown is isolated bysession_id; final successful requests do not keep same-session cooldown, and requests withoutsession_iddo not share cooldown. /v1/messagesonly: after the chosen native provider is exhausted (retryable errors), the proxy can fall back to the other native provider (anthropic↔kiro) if it is configured.
- SQLite log:
data.dbin config dir. Stores per-request stats (tokens, cached tokens, latency, model, upstream). - Token rate: macOS tray shows live total or split rates (configurable via
tray_token_rate). - Debug/trace log bodies capped at 64KiB.
- In-app Dashboard page visualizes totals, token usage trend, model usage ranking (Top 20), and upstream model probes
- Time range, upstream, and account filters apply to summary / series / models
- Recent requests live on the Logs page (page size 50, offset supported)
- The Logs panel supports a 30-second request-detail capture window: when enabled it stores request headers/bodies during that window, always keeps error responses for failed requests, and turns off automatically afterward.
- Claude Code: writes
~/.claude/settings.jsonenv(ANTHROPIC_BASE_URL,ANTHROPIC_MODEL=claude-sonnet-4-6,ANTHROPIC_AUTH_TOKENwhen local key is set). - Codex: writes
~/.codex/config.tomlmodel="gpt-5.5",model_provider="token_proxy", and[model_providers.token_proxy].base_url→http://127.0.0.1:<port>/v1; writes~/.codex/auth.jsonOPENAI_API_KEY. - A
.token_proxy.bakfile is created before overwriting; restart the CLI to apply.
- Port already in use? Change
portinconfig.jsonc; remember to update your client base URL. - Got 401? If
local_api_keysis nonempty, you must send the format-specific local key (OpenAI/Responses:Authorization, Anthropic:x-api-key, Gemini:x-goog-api-keyor?key=). With local auth enabled, configure upstream keys inupstreams[].credential(api_keysoraccount). - Got 504? Upstream did not send response headers or the first body chunk within 120s. For streaming responses, a 120s idle timeout between chunks may also close the connection.
- 413 Payload Too Large? Body exceeded
max_request_body_bytes(default 100 MiB) or the transform limit for format-conversion requests. - Why no system proxy? By design,
reqwestis built with.no_proxy(); set per-upstreamproxy_urlif needed.



