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_key |
null |
When set: 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. Protects inbound body size. |
retryable_failure_cooldown_secs |
15 |
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. |
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_key is unset. 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_keyenabled → require format-specific key. Local auth inputs are reserved for gateway access and not used as upstream credentials.- Public whitelist:
GET/HEAD/v1/modelsand/v1beta/openai/modelsdo not require local key. - OpenAI / Responses:
Authorization: Bearer <key> - Anthropic
/v1/messages:x-api-keyorx-anthropic-api-key - Gemini native API:
x-goog-api-keyor?key=...
- Public whitelist:
- When
local_api_keyis enabled, 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_keyis unset → no key. - Anthropic:
credential.api_keys→ requestx-api-key/x-anthropic-api-key/ bearer fallback only whenlocal_api_keyis unset → 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_keyis unset) → 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):
- 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(default15);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_keyis set, 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.



