One proxy, three API schemas. Talk to Claude, Gemini, and OpenAI-compatible models through a single endpoint, no matter which API format your client speaks.
The proxy accepts requests in the Claude, Gemini, or OpenAI Responses format, converts them for whatever upstream provider you've configured, and converts the response back. It also handles model aliasing, routing, fallback, and basic usage accounting out of the box.
Claude / Gemini / OpenAI SDK
│
▼
┌─────────────┐
sidecar plugins <- │ Model Proxy │ ← routes by model id or wildcard
└─────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
Anthropic Gemini OpenAI-Compatible
upstream upstream upstream(s)
- Four API formats in, any provider out — accept requests in any of these schemas:
/v1/messages— Claude Messages API/v1beta/models/{model}:generateContent— Gemini GenerateContent API/v1/interactions— Gemini Interactions API/v1/responses— OpenAI Responses API
- Model-based routing — route each model name to its own upstream URL, API key,
and protocol via a simple TOML config. Exact model keys are supported in every
[models.*]category; provider wildcards (claude-*) and the final catch-all (*) are scoped as described in Model Routing. - Composite aliases — group several models under one name with weighted random, primary/fallback, or automatic retry-on-failure routing.
- Fusion mode — fan one request out to multiple models in parallel, then have a "synth" model write the final answer.
- Schedule aliases — timetable-based routing: pick which model (or composite) serves a request based on server-local hour-of-day and day-of-week, with a fallback target for any time outside the configured windows.
- Extended thinking / reasoning — Claude-style thinking blocks, with conversion to
OpenAI
reasoning_effortfor upstreams that need it. - Usage accounting — per-model token and request stats, viewable in a web dashboard or a live terminal UI.
- Token limits — global and per-alias rolling-window caps that return HTTP 429 when hit.
- Sidecars — optional privacy-filter and compression sidecars for redacting or shrinking request payloads before they reach the upstream.
- Runs anywhere — Node.js server, Docker, PM2, or Cloudflare Workers.
git clone <repo-url>
cd model_proxy_v3
npm installCopy the example config and edit it:
cp proxy_config.toml_example proxy_config.tomlNote — inline
#comments are supported.proxy_config.tomlis parsed by a hand-rolled TOML reader (src/utils/config-loader.ts → parseSimpleToml) that strips trailing comments before matching values. Comments must be preceded by at least one space (standard TOML convention), e.g.filter_mode = "local" # sidecar | local. A bare#with no leading space inside a quoted value (e.g.api_key = "abc#def") is preserved correctly. Standalone#-only lines are always fine.
A minimal proxy_config.toml looks like this:
# Global settings not tied to a specific upstream.
[general]
# auth_url = "https://auth.example.com/validate" # validates proxy endpoint auth headers
# (Authorization, x-api-key, x-goog-api-key)
# auth_with_model = false # when true, defers the auth_url call until after body
# parsing and forwards the requested model id as
# x-resource-for header to the remote auth sidecar
# auth_passthrough_with = "user_key" # separate from auth_url/auth_with_model; controls
# which key is passed upstream: caller key (default)
# or configured api_key for every section
# Optional: POST model usage records to an HTTP collector.
# Includes request_id, endpoint, raw user_key, model, and token usage counters.
# [model_usage]
# record_url = "http://127.0.0.1:8080/model-usage"
# Global upstream defaults — applied ONLY to models that are NOT claimed by
# any `[models.*]` category section below. A model name that falls through
# every section's exact / wildcard / catch-all lookup gets routed here.
[default_upstream]
default_base_url = "https://api.your-provider.com"
# default_api_key = "your-key" # used in config_key mode or as fallback for [models.free]
# Claude models, spoken to in native Anthropic format
[models.claude]
upstream_mode = "anthropic-messages"
base_url = "https://api.anthropic.com"
api_key = "your-claude-key"
"claude-sonnet-4-6" = {} # exact entry; inline table {target, base_url, api_key, mode} — empty fields inherit from section
"claude-*" = {} # prefix wildcard — catch-all for every other claude-* model
# Gemini models, spoken to in native Gemini format
[models.gemini]
upstream_mode = "gemini-generatecontent"
base_url = "https://generativelanguage.googleapis.com"
api_key = "your-gemini-key"
"gemini-*" = {}
# Everything else goes here (OpenAI-compatible). Empty entry fields inherit
# from the section, then from `[default_upstream]` defaults.
[models.default]
upstream_mode = "openai-completions"
base_url = "https://api.your-provider.com" # section base_url overrides [default_upstream].default_base_url
"*" = {} # final catch-all for this section
"deepseek/deepseek-v3.2" = {}
# /v1/embeddings — OpenAI-compatible. The proxy appends `v1/embeddings` to
# `base_url`, so set the **API root** (e.g. `https://integrate.api.nvidia.com`,
# NOT `…/v1`). When `api_key` is set, it always overrides any caller-supplied
# header on `/v1/embeddings` — see "Who wins — caller's key vs. configured
# api_key" below. Model entries are exact-match only; bare model names without
# the upstream's required prefix (e.g. `nvidia/`) will fail at the upstream.
[models.embedding]
upstream_mode = "openai-completions" # value is informational — the proxy hardcodes this for /v1/embeddings
base_url = "https://integrate.api.nvidia.com"
api_key = "nvapi-..."
"nvidia/nemotron-3-embed-1b" = {}Key ideas:
- Categories group models by provider:
[models.claude],[models.gemini],[models.default], etc. upstream_modepicks the protocol:anthropic-messages,gemini-generatecontent,gemini-interactions,openai-completions, oropenai-responses.sdk://target URLs route supported Claude/OpenAI upstream calls through the local SDK handler instead of plain HTTP fetch, while still using the configuredupstream_modefor request/response shape.- Per-model overrides use an inline table:
"my-model" = {target = "real-name", base_url = "...", api_key = "..."}. Empty fields inherit from the category.
Note —
anthropic-messagesand extended thinking: Whenupstream_mode = "anthropic-messages"is used with a third-party Anthropic-compatible endpoint (e.g. MiniMax atapi.minimaxi.com/anthropic), the proxy passes the client'sthinkingfield through as-is. If the client sends{"type": "adaptive"}the upstream model will respond with thinking blocks; if the client omitsthinkingentirely, the proxy injects{"type": "disabled"}as a safe default (needed for DeepSeek-compatible endpoints that otherwise default to thinking mode). On a fresh conversation (no prior assistant turns containing thinking blocks), a client-sent{"type": "enabled"}will be silently dropped — use"adaptive"instead, which the model handles autonomously.
Note —
thinking.budget_tokensvsmax_tokens: onPOST /v1/messagesandPOST /v1/messages/count_tokens, whenthinkingis enabled the validator reducesthinking.budget_tokensdown tomax_tokensif the budget would otherwise exceed it. If you sendanthropic-beta: interleaved-thinking-2025-05-14, the budget is left unchanged (interleaved thinking may consume the full context window). Ifmax_tokensis below1024with thinking enabled and a non-null budget, validation throws with a message explaining the minimum.
Note —
kimi-k2.7-codeandthinking_budgetcollision: Moonshot'skimi-k2.7-codemapsreasoning_effort: "medium"to a fixedthinking_budget = 32768, so any request withmax_tokens ≤ 32768fails withInvalidParameter: max_completion_tokens [N] must be greater than thinking_budget [32768]. Under[general], setbudget_to_effort_high = 0— the proxy then emitsreasoning_effort: "high"for any thinking budget. if setbudget_to_effort_low = 32768budget_to_effort_medium = 65536budget_to_effort_high = 128000mapreasoning_effort: "low"would avoid the limit ofkimi-k2.7-code.
- Tag-based reasoning extraction (
openai-completionsonly): when an upstream replies with reasoning wrapped in<think>...</think>or<thinking>...</thinking>tags, the proxy splits the content into the endpoint's native reasoning field./v1/messagesreturns it as a Claudethinkingblock;/v1/responsesreturns it as areasoningoutput item plus an embeddedreasoning_textpart for round-trip;/v1/interactionsreturns it as athoughtoutput item;/v1beta/models/<model>:generateContentreturns it as a Geminithoughtpart. The tag itself is stripped from the user-visible text. This applies to both streaming and non-streaming responses; the streaming path stitches tags that cross SSE chunk boundaries before extraction. reasoning_contentfield round-trip (openai-completions, Gemini endpoints): reasoning models such as DeepSeek emit thinking as a dedicatedreasoning_contentfield on the delta rather than inline tags. The proxy extracts it the same way as tag-based reasoning (emitted as a Geminithoughtpart, Claudethinkingblock, etc.) and ensures it is replayed on the assistant turn in subsequent requests — required by DeepSeek-compatible upstreams that reject conversations where a prior thinking turn omitsreasoning_content. Tool-call streaming is also handled: DeepSeek fragments a single tool call across multiple SSE chunks (first chunk carriesid+name+partial args, continuation chunks carry only more arg text). The proxy accumulates these fragments and flushes one completefunctionCallpart per tool call whenfinish_reasonarrives, avoiding name-lesscall_undefinedentries in the client's history.- Wildcards (
claude-*) apply only in the provider/default sections listed in Model Routing; the catch-all (*) is only the final[models.default]fallback for anything not claimed earlier.
See proxy_config.toml_example for a fully commented config
covering every section and option.
npm run server # starts on http://localhost:8788Send a request:
curl http://localhost:8788/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: your-key" \
-d '{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello!"}]
}'The proxy reads proxy_config.toml from the working directory by default. Point it
elsewhere with PROXY_CONFIG_PATH=./other.toml npm run server. Change the port with
PORT=7777.
Start with the terminal dashboard:
TUI=true npm run serverYou get a live view of configured models, token usage, response times, and tool stats.
Press c to edit composite aliases, s to edit schedule aliases, t to send a test
request, r to reload config, Ctrl+C to quit. A web dashboard is also available at
GET /dashboard.
When TUI=true or DUMP=true is set, token stats are appended to
model_proxy_tokens.jsonl in the working directory. Each line is one JSON dump:
{
"date": "2026-07-15",
"timestamp": 1784112345,
"lastDumpTs": 1784109999,
"modelStats": [
{
"model": "claude-sonnet-4-6",
"requests": 12,
"failed_requests": 0,
"input_tokens": 12345,
"cached_tokens": 0,
"cache_written_tokens": 0,
"output_tokens": 6789,
"total_tokens": 19134
}
],
"toolStats": [
{ "name": "Read", "agent": "unknown", "req": 3, "resp": 1, "len": 2048, "blocked": 0 }
],
"heatmapEvents": {
"models": { "ab12": "claude-sonnet-4-6" },
"sequences": [{ "ts": 1784112300, "values": 19134, "id": "ab12" }]
}
}Fields:
date: localYYYY-MM-DDbucket for the dump.timestamp: dump time in Unix seconds.lastDumpTs: previous dump timestamp.0means a full snapshot; non-zero meansheatmapEventsis a delta since that timestamp.modelStats: cumulative per-model totals for that date.toolStats: optional cumulative per-tool/per-agent totals.heatmapEvents: token events used for the Tokens Panel and rolling global token limit. Current files use the compact{models, sequences}shape, where model names are mapped to short ids and each sequence stores{ts, values, id}in Unix seconds. Older files withheatmapEvents: [{timestamp, values, model}]are still accepted.compositeLimitWindows: optional persisted per-alias rolling-window state, written by day-rollover/full-snapshot dumps.
On startup, the proxy avoids double-counting persisted stats as follows:
modelStats,toolStats, andcompositeLimitWindowsare loaded only from the latest dump for each retained date because they are cumulative snapshots.modelStatsfrom those latest per-day dumps are summed across days to rebuild the all-time dashboard totals.heatmapEventsare loaded from all retained rows, because delta rows and multiple proxy instances can contain different events. The loader skips events older than the retention cutoff, skips events at or before a row's non-zerolastDumpTs, and deduplicates bytimestamp:values:modelIdbefore adding them to memory.
| Endpoint | Purpose |
|---|---|
POST /v1/messages |
Claude Messages API |
POST /v1/messages/count_tokens |
Count tokens (Claude/OpenAI format) |
POST /v1/responses |
OpenAI Responses API |
POST /v1beta/models/{model}:generateContent |
Gemini content (also :streamGenerateContent, :countTokens) |
POST /v1/interactions |
Gemini Interactions API |
POST /v1/embeddings |
Embeddings (proxied to an OpenAI-compatible upstream) |
GET /v1/models |
List available models |
GET /dashboard |
Web dashboard for config + stats |
A Gemini /v1/models/{model}:... variant exists for each /v1beta/models/{model}:...
endpoint. :countTokens is supported too: native Gemini routes forward to Gemini
countTokens, while non-Gemini upstream modes are bridged through the proxy's token-counting path.
For streaming conversions, the proxy forwards final upstream usage where the upstream emits it: OpenAI Chat Completions uses stream_options.include_usage, Gemini streams use final usageMetadata / interaction usage, and cache-read tokens are preserved through Claude/Responses usage fields when available.
Full request/response examples live in the API reference docs.
Each endpoint can be routed to one or more upstream API families (upstream_mode).
The mode is selected by the route's defaultMode / model config:
| Client endpoint | anthropic-messages |
openai-completions |
openai-responses |
gemini-generatecontent |
gemini-interactions |
|---|---|---|---|---|---|
POST /v1/messages |
Native passthrough to /v1/messages; request stays Claude Messages format end-to-end. |
Direct transform: Claude Messages → Chat Completions → Claude Messages. If input is already OpenAI-shaped, it can pass through. | Direct transform: Claude/OpenAI-chat-shaped request → Responses input → Claude Messages. Basic tools and streaming are supported; max_tokens is rewritten to max_completion_tokens. |
Direct transform: Claude Messages → Gemini generateContent → Claude Messages. | Direct transform: Claude Messages → Gemini Interactions/generateContent-compatible upstream → Claude Messages. |
POST /v1/responses |
Direct transform: Responses input/instructions → Claude Messages → Responses. Text and tool-use are supported for non-streaming and streaming. |
Direct transform: Responses → Chat Completions → Responses. For api.qnaigc.com, keeps legacy max_tokens; otherwise uses max_completion_tokens. |
Native passthrough to /v1/responses. |
Direct transform via Claude Messages: Responses → Claude Messages → Gemini generateContent → Claude Messages → Responses. | Direct transform via Claude Messages: Responses → Claude Messages → Gemini Interactions/generateContent → Claude Messages → Responses. |
POST /v1/chat/completions |
Convert passthrough only when DEV_PASS_THROUGH=true; Chat Completions body is converted to Claude Messages format and forwarded to /v1/messages; response (streaming and non-streaming) is converted back to OpenAI completions format. Tool schema types are lowercased; content: "" on assistant messages with tool_calls is normalized to null; consecutive tool messages are grouped into one user turn. |
Native passthrough only when DEV_PASS_THROUGH=true; otherwise rejected. Uses the resolved per-model route; composite aliases and target-mapped model ids are resolved and the model field in the forwarded body is rewritten to the target model id. |
Transform passthrough only when DEV_PASS_THROUGH=true; Chat Completions body is converted to Responses input and forwarded to /v1/responses using the resolved per-model route. |
Not supported. | Not supported. |
POST /v1beta/models/{model}:generateContent / :streamGenerateContent |
Indirect transform via openai-completions: generateContent → Chat Completions → Claude Messages → generateContent. Forwards upstream to /v1/messages; text, tool calls, and streaming text deltas return as Gemini candidates[].content.parts; tool calls become functionCall parts. |
Direct transform: generateContent → Chat Completions → generateContent. Forwards upstream to /v1/chat/completions. |
Indirect transform via openai-completions: generateContent → Chat Completions → Responses input → generateContent. Forwards upstream to /v1/responses; system/developer messages become Responses instructions; content-part arrays are normalized to text. |
Native passthrough to :generateContent / :streamGenerateContent using the configured Gemini API version. |
Native Gemini-family route; forwards to Gemini generateContent/stream endpoint using Interactions-compatible mode. |
POST /v1/interactions |
Indirect transform via openai-completions: Interactions → Chat Completions → Claude Messages → Interactions. Forwards upstream to /v1/messages; text, tool calls, and streaming text deltas return in Interactions shape. |
Direct transform: Interactions → Chat Completions → Interactions. Forwards upstream to /v1/chat/completions. |
Indirect transform via openai-completions: Interactions → Chat Completions → Responses input → Interactions. Forwards upstream to /v1/responses; system/developer messages become Responses instructions; content-part arrays are normalized to text. |
Native Gemini-family route; forwards to Gemini generateContent/stream endpoint. | Native Gemini-family route; forwards to Gemini generateContent/stream endpoint using Interactions-compatible mode. |
GET /v1/models |
Passthrough model listing; no upstreamMode conversion is applied. |
Passthrough model listing; no upstreamMode conversion is applied. |
Passthrough model listing; no upstreamMode conversion is applied. |
Passthrough model listing; no upstreamMode conversion is applied. |
Passthrough model listing; no upstreamMode conversion is applied. |
POST /v1/embeddings |
Not supported. | Only supported mode; forwards to OpenAI-compatible embeddings upstream. | Not supported. | Not supported. | Not supported. |
Notes:
- Native passthrough means the client endpoint and upstream API family already match, so the request body is not converted to another provider's format.
- Direct transform means the proxy converts directly between the client endpoint format and the selected upstream family, then converts the response directly back to the client endpoint shape.
- Direct transform via Claude Messages means Responses uses Claude Messages as its internal bridge before calling Gemini; it does not go through
openai-completions. - Indirect transform via
openai-completionsmeans Gemini endpoint input first becomes OpenAI Chat Completions, then becomes Claude Messages or OpenAI Responses. This reuses the Chat Completions middle mode while preserving the original Gemini endpoint response shape. - Direct transforms are preferred long-term for endpoint fidelity. The current
/v1/interactions→anthropic-messages/openai-responsesroutes use the indirectopenai-completionsbridge for code reuse; see Routing transform review for tradeoffs and recommendations.
The proxy only preserves OpenAI prompt-caching controls when the target mode can carry them without changing prompt structure. Cross-mode conversion preserves the top-level routing key, but not request-wide cache policy or content-block breakpoints.
| Client endpoint | upstream_mode |
prompt_cache_key |
prompt_cache_options |
prompt_cache_breakpoint |
|---|---|---|---|---|
POST /v1/responses |
openai-responses |
Preserved | Preserved | Preserved |
POST /v1/responses |
openai-completions |
Preserved | Dropped | Dropped during input → messages conversion |
POST /v1/chat/completions |
openai-completions |
Preserved | Preserved | Preserved |
POST /v1/chat/completions |
openai-responses |
Preserved | Dropped | Dropped during messages → Responses input / instructions conversion |
The /dashboard web UI is driven by a small JSON API. Dashboard/admin routes
are restricted to loopback clients by the Node server adapter. To also require a
bearer token for the JSON API, set:
[dashboard]
api_key = "your-dashboard-key"When dashboard.api_key is configured, every /dashboard/api/* route requires
Authorization: Bearer <dashboard.api_key>. GET /dashboard remains loadable
from loopback without auth. The browser dashboard prompts for the key on the
first API 401, sends dashboard API requests sequentially, stores the key in
browser localStorage, and expires the saved key after 7 days. The /dashboard
HTML response uses no-cache headers so browser users get the latest dashboard
script. If dashboard.api_key is omitted or empty, dashboard APIs keep the old
loopback-only behavior.
| Endpoint | Purpose |
|---|---|
GET /dashboard/api/config |
Read current config snapshot; ?reload=1 re-reads the TOML file |
PUT /dashboard/api/config |
Replace the whole config snapshot (also auto-saves the TOML) |
POST /dashboard/api/global-token-limit |
Set / update the global rolling-window token cap |
POST /dashboard/api/schedule/alias |
Add a new [schedule] alias (body: {alias: string}) |
DELETE /dashboard/api/schedule/alias/:alias |
Remove a [schedule] alias |
POST /dashboard/api/schedule/alias/:alias/target |
Upsert a target's window list (body: {target, windows}) |
DELETE /dashboard/api/schedule/alias/:alias/target/:target |
Remove a target from an alias |
POST /dashboard/api/test-model |
Send a test request through a configured model |
GET /dashboard/api/stats/models |
Per-model token and request stats |
GET /dashboard/api/stats/agents |
Per-agent request stats |
GET /dashboard/api/stats/requests |
Endpoint, upstream, status-code, timing, and tool-response stats |
GET /dashboard/api/tools/blocklist |
Read the current tool blocklist |
POST /dashboard/api/tools/toggle-block |
Block or unblock a tool by name |
The four schedule/* routes are the dedicated CRUD for [schedule] aliases;
mutations also round-trip through the TOML file so the change persists across restarts.
Stats are keyed by resolved upstream model id, not the alias/target key.
A [models.*] entry like max-m3 = {target = "MiniMax-M3", ...} is looked up
under the key max-m3, but every request routed through it — including
requests/failed_requests counts and all token counters — is recorded
against MiniMax-M3 (the resolved target), because that's the model id
actually sent upstream. If a [models.*] entry has no explicit target
(so key == resolved model id), this distinction doesn't matter. But when
target differs from the key, check stats under the target value, not
the alias key, if a row looks stuck at zero requests/tokens despite live traffic.
Each [models.<category>] section groups models by provider. An incoming model name is
resolved against the configured sections in three priority levels (highest first):
| Priority | Lookup | Where it's checked |
|---|---|---|
| 1 | Exact key match | All [models.*] sections |
| 2 | prefix-* wildcard |
models.claude, then models.gemini, then models.gpt |
| 3 | * catch-all |
models.default |
- An exact entry always wins over a wildcard in the same category — e.g. an explicit
claude-sonnet-4-6is matched beforeclaude-*. - Only provider wildcard sections (
models.claude,models.gemini,models.gpt) and optionalmodels.defaultwildcards are checked forprefix-*matches. Other sections, includingmodels.freeandmodels.embedding, are exact-only. - Only
prefix-*(hyphen before*) is a wildcard; the*is substituted so the upstream sees the real model name. A bare*key is the finalmodels.defaultcatch-all and preserves the original model name.
| Section | Exact | prefix-* |
* catch-all |
|---|---|---|---|
models.claude |
✅ | ✅ | ❌ |
models.gemini |
✅ | ✅ | ❌ |
models.free |
✅ | ❌ | ❌ |
models.default |
✅ | ✅ (optional) | ✅ (recommended) |
models.embedding |
✅ | ❌ | ❌ |
Section flavors — wildcards vs. exact-only: user-defined provider sections such as
[models.gpt]and[models.nvidia]inherit the same exact /prefix-*/*catch-all routing surface as[models.default],[models.claude], and[models.gemini], so wildcards work in them. The concrete sections[models.free]and[models.embedding]are exact-only and never pick up wildcards. Runtime caller-vs-config key priority is governed separately by the Who wins tables below.
Each model entry is an inline table {target, base_url, api_key}. Resolution walks an
inheritance chain — anything left empty falls back to the level above:
base_url: per-entry override → sectionbase_url→[default_upstream] default_base_url→http://localhost.- Configured
api_key: per-entry override → sectionapi_key→[default_upstream] default_api_key. This only resolves the configured fallback key; runtime caller-vs-config priority is section-specific below. upstream_mode: per-entrymode→ sectionupstream_mode→[default_upstream] upstream_mode→"openai-completions".- The target-only form (
opus48 = {target = "..."}) inheritsbase_urlfrom the section, then[default_upstream] default_base_url;api_keymay be inherited from the section or[default_upstream] default_api_key, or supplied by the caller for non-freesections.
What
[default_upstream] default_base_urlis for: it is the global upstream endpoint used when no per-entry or sectionbase_urlis configured, including models that fall through every section's exact / wildcard / catch-all lookup.
base_urlmay include the full endpoint path. Ifbase_urlalready contains a known full upstream endpoint path, the proxy uses it as-is instead of appending the endpoint suffix again. This lets you point a model at the exact URL an upstream expects (e.g.base_url = "https://api.anthropic.com/v1/messages"withupstream_mode = "anthropic-messages") without producing a doubled path like.../v1/messages/v1/messages. Recognised full-endpoint markers (case-insensitive):/v1/messages,/anthropic/messages,/v1/chat/completions,/chat/completions,/v1/interactions,/v1/responses,/openai/responses, and/v1beta/models/{model}:generateContentor/v1/models/{model}:generateContent(:streamGenerateContent,:countTokens). For Gemini,base_urlmay also end at the API version or models collection (for example/v1betaor/v1beta/models); the proxy appends the model endpoint without duplicating the version path.
Who wins — caller's key vs. configured api_key — controlled by [general] auth_passthrough_with:
auth_passthrough_with = "user_key" (default)
| Section | Caller's auth header | Configured api_key |
|---|---|---|
[models.free] |
Ignored | Section/per-entry key always wins — the proxy authenticates upstream on the caller's behalf (this is what makes the FREE tier work). |
[models.default] |
Wins | Used only when the caller sends no key. May come from the entry, section, or [default_upstream] default_api_key. |
[models.claude], [models.gemini] |
Wins | Caller's key passes through; configured keys are not used. |
[models.embedding] |
Overridden for embeddings | Section api_key wins for /v1/embeddings requests when configured. |
auth_passthrough_with = "config_key"
| Section | Caller's auth header | Configured api_key |
|---|---|---|
[models.free] |
Ignored | Section/per-entry key always wins (unchanged). |
[models.default], [models.claude], [models.gemini], etc. |
Replaced | Configured key always wins — per-entry → section → [default_upstream] default_api_key. |
Models hitting [default_upstream] (no section match) |
Replaced | [default_upstream] default_api_key is used if set. |
[models.embedding] |
Overridden for embeddings | Section api_key wins for /v1/embeddings requests when configured. |
Why
[models.embedding].api_keyalways wins — the fixed-route branch insrc/index.ts(around line 1534) applies the embedding section'sapi_keyaftertransformAuthHeadersForUpstreamhas populatedmodelAuthHeadersfrom the caller's headers, and the spread order is{ ...modelAuthHeaders, ...formatApiKeyForUpstream(embeddingApiKey, …) }. So when the section has anapi_key, the config key replaces whatever the caller sent. This is intentional:[models.embedding]is typically used to pin a single provider-scoped key (e.g. NVIDIA integrate) so callers don't need to manage upstream credentials. For other sections, theauth_passthrough_withsetting controls this same priority.
Use config_key when the proxy is a shared gateway and callers should not supply their own upstream credentials.
Composite and fusion aliases don't route directly: each target is resolved through its own
[models.*] section, so the rules above apply per target. The rule is keyed on
route.section === 'free', so it holds uniformly across direct, composite, and fusion paths.
Group multiple models under one name in a [composite] section:
[composite]
# Weighted random: ~70% to model-a, ~30% to model-b
"smart" = {"model-a" = {share = 70}, "model-b" = {share = 30}}
# Primary with fallback, plus a daily token cap
"gpt" = {token_limit = {num = 80000, duration = "1d"}, "gpt-5-mini" = {primary = true}, "gpt-5.4-mini" = {fallback = 1}}share— weighted random selection across targets.primary/fallback— try primary first, fall back in order on failure. When a target returns a non-200 upstream error, its effective share is reduced in memory by half for later requests, down to a floor of one tenth of its configured share:- Primary target: decay fires when the
primary = truetarget fails. Subsequent requests use a weighted pick between the primary (at its reduced share) and the other targets, so a heavily degraded primary is less likely to be tried first. - Fallback targets (no primary): when the alias has two or more
fallback-numbered targets and the first-tried one fails, its effective share is decayed by the same rule. The next request picks the first attempt by weighted share, so a degraded fallback-1 can be overtaken by fallback-2. - Decay is runtime-only —
proxy_config.tomlis never modified and state resets when the proxy process restarts.
- Primary target: decay fires when the
token_limit—{num, duration}rolling-window cap (1h/1d/1w/1m); returns HTTP 429 when exceeded.
Fusion fans a request out to multiple "panel" models in parallel and routes through an optional "judge" and an optional but recommended "synth" model that writes the final answer. If no synth is configured, the judge is used as synth; if no judge exists, the first panel is used:
[composite]
"answer" = {opus = {fusion = 1, role = "panel"}, sonnet = {fusion = 1, role = "panel"}, "judge-m" = {fusion = 1, role = "judge"}, "synth-m" = {role = "synth"}}For the full set of composite/fusion options and the TUI editor workflow, see
docs/design_fusion_composite_alias.md.
Cycles are not allowed. A composite alias may target another composite alias, but the chain must terminate at a real
[models.*]entry —A → B → C → Ais rejected at load time with a[FATAL]log, marked with a redxin the TUI / dashboard, and the cyclic target is omitted from the snapshot. See CHANGELOG.md for the full safety rules.
A [schedule] alias is the top-most layer: it picks one target for the request
based on a timetable (server-local hour-of-day and day-of-week), then hands that target
down to whatever routing rule resolves it ([models.*] or another [composite]).
There is no weighting or fan-out here — exactly one target is selected per request.
[schedule]
"saver" = {"maxplan" = [{from = 9, to = 12}, {from = 14, to = 18}], "code-small" = [{from = 0, to = 9, days = "weekday"}], "max-m3" = [{days = "weekend"}], "max-m2.7-high" = []}In the example above, on weekday mornings code-small serves, on weekday office
hours maxplan serves, on weekends max-m3 serves, and max-m2.7-high (the
fallback with an empty [] window list) handles anything that falls between
the configured windows.
Window syntax — every entry is {from?, to?, days?}:
| Field | Range | Default | Meaning |
|---|---|---|---|
from |
0..24 (inclusive of start) |
0 |
Hour-of-day the window opens (server-local time). |
to |
0..24 (exclusive of end) |
24 |
Hour-of-day the window closes. 24 is a legal value (end-of-day). |
days |
"weekday"/"weekdays", "weekend"/"weekends" (any casing), or [mon, tue, ...] |
everyday | When the window applies, evaluated against server-local day-of-week. Any other string (including hand-typed typos) normalizes to "everyday" rather than raising an error. |
A target with windows = [] is a fallback: it serves when no other target
matches the current time. If multiple empty-window targets are configured, the first one listed is used.
If no fallback exists and no window matches, schedule does not select a target; the
request falls through to normal routing with the original model name, including
[models.default] / * catch-all routing when configured.
Selection rules (in order, first match wins):
- The current
(hour, day-of-week)matches one of the target'swindows→ that target. - Otherwise, the target with
windows = [](the fallback) → that target. - Otherwise, no schedule target is selected and normal/default routing handles the original model name.
Windows are unioned across the alias, not per-target: a single window belongs to exactly one target. If two targets cover overlapping hours, the first one listed in the TOML wins for the overlap.
A schedule target is itself routed through the rest of the config — maxplan,
code-small, max-m3, max-m2.7-high above are ordinary [composite] or
[models.*] entries. Schedule is transparent composition: it doesn't replace
composite/fusion/models, it just decides which of them serves this request at this
moment.
Manage via the dashboard / TUI:
- Web UI: open
GET /dashboard, scroll to the Schedule section, edit aliases and their window lists inline — each window hasfrom/tonumber inputs and a days dropdown (Every day / Weekdays / Weekend). Save persists toproxy_config.toml. - TUI: press
sto openScheduleAliasesOverlay(mirror of the composite editor atc).aadds an alias,madds a target under the selected alias (a concrete[models.*]entry or another composite/fusion alias — wildcard patterns like*/claude-*and the alias itself are excluded from the picker),ddeletes,eopens a step-by-step window editor (from → to → a Every day/Weekdays/Weekend picker, repeat to add more windows, or choose "Set as fallback" to clear all windows), arrow keys navigate,Esccloses. - HTTP: the four
/dashboard/api/schedule/*routes listed in Dashboard API.
Auth / section flag: schedule targets inherit whatever route.section === 'free'
or "caller's key wins" rule their underlying [models.*] section imposes — schedule
selects the target, but the target's section still governs upstream auth.
The proxy has three logic levels, stacked bottom-up. Each level chooses which level below gets to serve this request:
┌────────────────────────────────┐
Level 3 (top) │ [schedule] │ ← timetable (hour-of-day, day-of-week)
│ "what should serve *now*?" │
├────────────────────────────────┤
Level 2 (middle) │ [composite] │ ← share / primary+fallback / fusion fan-out
│ "split across N targets?" │
├────────────────────────────────┤
Level 1 (base) │ [models.*] │ ← exact name / prefix-* wildcard / * catch-all
│ "which upstream?" │
└────────────────────────────────┘
Direct routing to an upstream. Three lookup modes, tried in priority order:
- Exact key —
"claude-sonnet-4-6" = {...}resolves only that exact name. - Prefix wildcard —
"claude-*" = {...}resolves anyclaude-*and substitutes the*with the real suffix. *catch-all —"*" = {}(typically in[models.default]) resolves anything that wasn't claimed by an earlier mode, preserving the original model name.
Each entry picks its upstream_mode / base_url / api_key from an inheritance
chain (per-entry → section → [default_upstream] defaults). Custom/target models are the
only level that actually talks to an upstream — Levels 2 and 3 must always
resolve down to a Level-1 entry before a single byte is sent.
Logical grouping of two or more Level-1 entries under one name. Two strategies:
share-weighted distribution —{"max-m2.7-high" = {share = 100}, "max-m3" = {share = 100}}splits each request randomly across targets by weight. One or more may be markedprimary(the default target) orfallback(consulted in order if the primary fails). This is one request → one target.fusionfan-out — every target withfusion = 1, role = "panel"runs in parallel against the same request; an optionalrole = "judge"scores them; and an optional but recommendedrole = "synth"merges them into one final response. Without synth, fusion uses the judge, then the first panel.fusion_optionsconfiguresmin_panel,panel_timeout_ms,judge_required,expose_metadata,max_concurrent. This is one request → many targets → one response.
A composite alias does not route directly. Each target it names is resolved
through its own [models.*] section, so per-target base_url, api_key, and
section-based auth rules all still apply. Section flag route.section === 'free'
is computed per-target, so a composite made of free-tier targets stays free-tier end-to-end.
The highest layer. Each request asks: given the current server-local hour and day-of-week, which [composite] or [models.] entry should serve me right now?* The chosen target then flows through Levels 2 → 1 exactly as if the caller had asked for that target by name. Schedule is transparent: it adds when without overriding how.
| Level | Section | Selects by | Cardinality | Re-routes to |
|---|---|---|---|---|
| 3 | [schedule] |
Timetable windows | 1 → 1 (one target picked per request) | Level 2 or 1 |
| 2 | [composite] (share / primary+fallback) |
Weighted random or fallback order | 1 → 1 | Level 1 |
| 2 | [composite] (fusion) |
Role + fusion_options |
1 → N → 1 (panel×N + judge + synth) | Level 1 |
| 1 | [models.*] |
Exact / prefix-* / * catch-all |
1 → 1 (one upstream) | — (sends) |
Three concrete examples of the same caller request resolving differently per layer:
- Level 1 only —
model: "claude-sonnet-4-6"→ matched exactly in[models.claude]→ sent toapi.anthropic.com. - Level 2 (share) —
model: "maxplan"→[composite].maxplanpicksmax-m2.7-highormax-m3by weight → that target resolved in[models.*]→ sent to its upstream. - Level 2 (fusion) —
model: "smarter"→[composite].smarterfans out to three panel targets in parallel, judges them, and asynthtarget merges the result → each leg resolved in its own[models.*]. - Level 3 (schedule) —
model: "saver"at 10 AM Tuesday →[schedule].saverpicks themaxplantarget (itsfrom = 9, to = 12window matches) →[composite].maxplanpicks one of its targets by weight → that target resolved in[models.*]→ sent.
Docker
cp proxy_config.toml_example proxy_config.toml
#COMMIT=$(git rev-parse --short HEAD)
#docker build --network=host --build-arg VERSION=$COMMIT -t model-proxy-v3:$COMMIT -t model-proxy-v3:latest .
docker build -t model-proxy-v3 .
docker run --network host -p 8788:8788 -v $(pwd)/proxy_config.toml:/app/proxy_config.toml -e DEV_PASS_THROUGH=true -e LOG_LEVEL=info model-proxy-v3PM2 (multiple workers)
npm run build
pm2 start dist/server.js -i 4Cloudflare Workers
npm run dev # local
npm run deploy # publishThe Node server adapter normalizes response headers before writing them to the
client. It removes content-encoding and content-length because Node fetch()
can decode upstream-compressed bodies while leaving the original upstream headers
visible on the Response. The previous direct header copy:
Object.fromEntries(response.headers.entries())could therefore send plain text with a stale content-encoding: br / gzip
header. Clients such as opencode may then try to decode Brotli by default and
fail to read the response body.
Most users only need proxy_config.toml. Optional environment variables tune behavior.
On the Node server (npm run server / dist/server.js) these come from the process
environment; on Cloudflare Workers they come from [vars] in wrangler.toml.
[general] config fields
| Field | Example | Purpose |
|---|---|---|
auth_url |
"https://auth.example.com/validate" |
If set, every inbound request's proxy auth headers (Authorization, x-api-key, x-goog-api-key) plus User-Agent are validated by a GET to this URL before routing. HTTP 200 = pass; 4xx/5xx = 401 to client; network error = 503. |
auth_with_model |
false |
When true, the auth_url call is deferred until after the request body is parsed so the requested model id can be forwarded as x-resource-for header. Allows the auth server to make per-model decisions. Default: false (auth runs before body parsing). |
auth_passthrough_with |
"user_key" |
Standalone upstream-auth setting, separate from auth_url / auth_with_model. Controls which key is passed to the upstream provider: "user_key" (default) forwards the caller's key; "config_key" uses the configured api_key. |
global_token_limit |
"1B 1d" |
Rolling-window token cap across all models. Format: "<num><K/M/B> <duration>" where duration is 1h/1d/1w/1m. Returns HTTP 429 when exceeded. |
budget_to_effort_low |
32768 |
Thinking-budget threshold (tokens) below which reasoning_effort: "low" is emitted for upstreams that use effort levels instead of token budgets. |
budget_to_effort_medium |
65536 |
Threshold above low and below this → reasoning_effort: "medium". |
budget_to_effort_high |
128000 |
Threshold above medium → reasoning_effort: "high". Set to 0 to always emit "high". |
[default_upstream] config fields
| Field | Example | Purpose |
|---|---|---|
default_base_url |
"https://api.example.com" |
Global upstream endpoint fallback when a route has no per-entry or section base_url, and for models not claimed by any [models.*] section. |
default_api_key |
"sk-..." |
Global configured-key fallback. In user_key mode (default): wins only for [models.free], acts as a fallback for other sections when the caller sends no key. In config_key mode: used for all models that have no per-entry or section api_key. Typically left unset in production. |
upstream_mode |
"openai-completions" |
Default protocol for models not claimed by any [models.*] section. |
[model_usage] config fields
| Field | Example | Purpose |
|---|---|---|
record_url |
"http://127.0.0.1:8080/model-usage" |
Optional HTTP collector. When set, the proxy POSTs per-request usage records with request_id, endpoint, raw user_key, model, and token counters (input_tokens, cached_tokens, cache_written_tokens, output_tokens, total_tokens). |
Core / server
| Variable | Default | Purpose |
|---|---|---|
PORT |
8788 |
Listen port (Node server) |
LOG_LEVEL |
info |
debug / info / warn / error |
ALLOWED_ORIGINS |
* |
CORS origins |
DEFAULT_MAX_TOKENS |
8192 |
max_tokens for requests that omit it |
TUI |
unset | true launches the terminal dashboard + enables stat persistence |
DUMP |
unset | true enables token-log persistence without the TUI |
DEV_MODE |
unset | true enables development behaviors |
Config source
| Variable | Default | Purpose |
|---|---|---|
PROXY_CONFIG_PATH |
./proxy_config.toml |
Path to the local config file |
PROXY_CONFIG_URL |
unset | Remote/Consul config URL (read-only dashboard when set) |
Token counting & upstream
sdk://... model base_url values are handled by the local SDK adapter instead of
HTTP fetch for supported Claude/OpenAI-shaped upstream calls. For example, a model
entry can set base_url = "sdk://chatjimmy.ai/api" and keep the appropriate
upstream_mode for the client/upstream protocol shape.
| Variable | Default | Purpose |
|---|---|---|
LOCAL_TIKTOKEN |
false |
Count tokens locally instead of calling upstream. Local counts are best-effort estimates and include text, tool results, tool use, images, documents, thinking, and web-search result blocks. |
TIKTOKEN_MODEL |
unset | tiktoken encoding to use, e.g. o200k_base |
UPSTREAM_BODY_TIMEOUT_MS |
600000 |
Upstream body timeout (also judge/synth timeout in fusion) |
MODELS_CACHE_TTL |
unset | Seconds to cache the upstream /v1/models list |
JSON_STRINGIFY_METHOD |
json |
Serialization method for outgoing bodies |
DEV_PASS_THROUGH |
false |
true enables /v1/chat/completions. The proxy resolves the request model first; openai-completions routes forward the Chat Completions body as-is, while openai-responses routes convert it to Responses format and forward to /v1/responses, and anthropic-messages routes convert the body to Claude Messages format and forward to /v1/messages. Before forwarding, the proxy applies these normalization steps: (1) JSON Schema type strings in tool parameters are lowercased ("STRING" → "string") for strict upstreams like DeepSeek; (2) assistant messages with tool_calls and content: "" are rewritten to content: null per the OpenAI spec; (3) missing name fields on role: "tool" messages are recovered from the preceding assistant turn's tool_calls index by tool_call_id. Notice: the caller's Authorization / x-api-key / x-goog-api-key is forwarded to the upstream as-is — the proxy does not perform a local credential check, so the upstream directly authenticates the request. A valid upstream key returns 200; an invalid one returns the upstream's 401. Do not use in production. |
CONVERSATION |
unset | true enables experimental in-process stateful conversation cache |
IMAGE_BLOCK_DATA_MAX_SIZE |
10485760 |
Max inline image bytes accepted |
ALLOWED_HOSTS |
127.0.0.1,localhost |
SSRF allowlist for dynamic per-request upstream hosts |
Privacy-filter sidecar (inert unless PRIVACY_FILTER_URL is set)
| Variable | Default | Purpose |
|---|---|---|
PRIVACY_FILTER_URL |
unset | Sidecar base URL, e.g. http://127.0.0.1:8799. Unset = off |
PRIVACY_FILTER_TIMEOUT_MS |
40000 |
Per-call timeout to the sidecar |
PRIVACY_FILTER_MAX_CHARS |
1024000 |
Skip redaction above this total text size |
When the sidecar is serve.py from submodules/privacy-filter, it emits two sentinel prefixes: ⟦PII:n⟧ (model-detected PII) and ⟦HASH:n⟧ (cryptographic-hash-shaped secrets such as API keys and tokens, caught by the entropy-based hash_detect.py scan). The proxy restores both prefixes transparently on the response, including for streaming SSE. The sidecar mode covers broad PII — emails, addresses, phone numbers, names, and credit card numbers — in addition to hex-shaped secrets.
Local hash-only mode (in-process, no sidecar). If you only need to redact hash-shaped secrets (API keys, tokens) and want to skip the OPF PII model entirely, add a [privacy_filter] section to proxy_config.toml with filter_mode = "local". This mode detects two token shapes by entropy analysis; emails, addresses, and other free-text PII are not redacted:
- hex tokens (
0–9,a–f) — MD5, SHA-1, SHA-256 digests and similar - base64url tokens (
A–Z,a–z,0–9,_,-) — API keys such assk-…orouV7bwSq…that contain non-hex characters
The proxy runs an in-process TypeScript port of hash_detect.py (src/utils/hash-detect.ts) on every text fragment; no HTTP call, no Python sidecar. Non-text content blocks are never touched: Anthropic image/document (source.data), OpenAI image_url (image_url.url), and Gemini inlineData (inlineData.data) are skipped. Detected spans are replaced with ⟦HASH:n⟧ sentinels and restored on the response, exactly as in sidecar mode. The plugin is enabled when filter_mode = "local" (no URL needed), or when filter_mode = "sidecar" is paired with a valid filter_url; otherwise it stays inert. Env vars override toml values.
[privacy_filter]
filter_mode = "local" # "sidecar" (default when a filter_url is configured) | "local"
# filter_url = "http://127.0.0.1:8799" # required for sidecar mode
# timeout_ms = 40000 # sidecar only: per-call timeout
max_chars = 1024000 # skip redaction above this total text size
entropy_threshold = 3.0 # local mode only: Shannon entropy cutoff for hash detection
hash_min_len = 8 # local mode only: minimum hex token length to classify as a hash
whitelist_add = [] # hex tokens to add to the built-in skip-list
whitelist_remove = [] # built-in whitelist tokens to remove (so they get detected)
# whitelist_file = "" # Node-only: path to a whitelist-override fileThe hash_min_len and entropy_threshold knobs are also accepted by the Python sidecar via --hash-min-len and --entropy-threshold CLI flags (see submodules/privacy-filter/README.md).
Note — "Keys filtered" counter: whenever the privacy filter redacts one or more spans from a request, the proxy increments an in-process cumulative counter. The total is shown in two places:
- TUI — a
keys filtered: Nline appears above the Custom Models section, right-aligned to the Tokens Panel width. The line is hidden while the count is zero.- Dashboard — the Request Statistic card contains a Privacy Filter sub-table with a "Keys filtered (total)" row, refreshed every 10 seconds alongside other stats.
The counter is runtime-only and resets to zero when the proxy process restarts. Each redacted span (one
⟦HASH:n⟧sentinel) counts as one key, so a single request carrying three API keys increments the counter by three.
Compression sidecar (inert unless KOMPRESS_URL is set)
| Variable | Default | Purpose |
|---|---|---|
KOMPRESS_URL |
unset | Sidecar base URL, e.g. http://127.0.0.1:7777. Unset = off |
KOMPRESS_ENDPOINTS |
/v1/messages,/v1/chat/completions,/v1/responses |
Proxy paths to compress |
KOMPRESS_FAIL_OPEN |
true |
true = fail-open (forward original text on sidecar error) |
KOMPRESS_TIMEOUT_MS |
40000 |
Per-call timeout to the sidecar |
KOMPRESS_MAX_CHARS |
1024000 |
Skip compression above this total text size |
KOMPRESS_KEEP_RATIO |
0.5 |
Fraction of tokens to keep (lower = more aggressive) |
KOMPRESS_MIN_CHARS |
200 |
Skip fragments shorter than this |
The full list (including the Consul-backed config and hardcoded upstream-mode defaults)
is documented in the comments at the top of wrangler.toml and in
docs/README_DETAILS.md.
# Coverage testcases: run from the project root; the runner builds an isolated test config.
node run-tests.js --all
# Agent SDK / provider tests live under ./tests and need the proxy running first.
node tests/multi-agents-test.ts
node tests/multi-agents-composite.ts
npm run test:unit
# Point testcases at a specific proxy / key
PROXY_URL=http://localhost:8788 API_KEY=sk-test node run-tests.js --all- Coverage test cases live in
testcases/; usenode run-tests.js --allor selected suite indices. - Agent-SDK and provider tests live in
tests/.
The docs/ folder has deep-dives on specific topics:
- Routing —
proxy_config.toml_example,docs/routing_refactor.md,docs/routing_config_revision.md - Config loading —
docs/config_loader.md - Thinking / reasoning —
docs/claude-extended-thinking.md,docs/claude-adaptive-thinking.md - API formats —
docs/claude-api-reference.md,docs/gemini-api-reference.md,docs/openai-api-reference.md - Fusion & composite design —
docs/design_fusion_composite_alias.md
- Fork the repository
- Create a feature branch
- Make your changes
- Submit a pull request
DeepSeek-R1,V3.2,V4-Flash,V4-ProMinimax-M2.6,M2.7-highspeed,M3Kimi-K2.6,K2.7-CodeGPT-5.4-Mini,GPT-5.4,GPT-5.5Gemini-2.5-Flash,3.0-Preview,3.1-FlashClaude-Sonnet-4.5,Sonnet-4.6,Opus 4.6,Opus 4.8,Fable 5Nemotron-3-Super-120b,gpt-oss-120bGLM-5.2
Claude CodeKiroGemini-CliPiCodexopencode
This project is licensed under the MIT License.