The README sells. This document explains — the translation surface, the model-ranking program, the installer, and every known trade-off and degradation. Read it when something isn't behaving the way you expected, or before you change how the gateway works.
This is a reference, not a tutorial. For install and run, see the README.
- The translation layer
- Model config
- Claude Code profile
- The gateway
- Upstream failures and retries
- Debug logging
- Environment reference
- Known degradations (openai mode)
anthropicmode- PATH and uninstalling
gateway.mjs is the server (routing, phases, upstream client, logging) and translate.mjs holds all wire-format logic — zero dependencies. The default openai mode translates Anthropic Messages ⇄ OpenAI Chat Completions bidirectionally:
- system prompt — a string or content blocks, merged with in-conversation system entries into one system message
- tools — mapped to function tools; WebSearch is converted from a server-side tool to a function tool and its results intercepted via the Tavily API (with a keyless DuckDuckGo scrape fallback when
TAVILY_API_KEYis unset or Tavily fails/rate-limits). Other server-side tools (web_fetch etc.) are stripped with no replacement. - model names — mapped to configured Corti models (e.g.
claude-opus-5→corti-s1) - tool_use / tool_result — pairing repaired for re-wound histories; parallel tool calls round-trip byte-exact via index-keyed streaming
- thinking config — Anthropic
thinkingandoutput_config.effortmap to upstreamreasoning_effort+thinking_token_budget; upstream reasoning streams back as Anthropic thinking blocks. History thinking blocks are stripped on re-entry (signatures are synthetic, see below). Effort (depth) and thinking (whether) are separate axes: the harness picker sendsoutput_config.effort(six levels: low→ultracode), which collapses onto Corti's two real levels — at-or-belowhigh→high, above →max.thinking.type: "adaptive"enables thinking only and contributes no depth;output_config.effortwins over the legacythinking.enabledbudget mapping.reasoning_effortis gated on athinkingblock being present, so a non-reasoning request (no thinking) gets no effort level. The advisor child overrides both withCORTI_ADVISOR_EFFORT. - images — converted to
image_urlparts, including images inside tool results, which are attached as a following user message - auth — whatever token the client sends is discarded; the real
CORTI_BEARERis injected /v1/messages/count_tokens— handled locally (estimator: chars/4 + tools schema + per-image flat count). The same estimator seedsmessage_start.usage.input_tokens, but there it is first scaled by the last real/estimate ratio observed for that session and model, so a turn that dies before upstream reports usage does not record less context than the turn before it.count_tokensitself and the local overflow guard stay on the raw estimate.- errors — upstream errors translated into Anthropic's envelope. Critically, context-overflow conditions become
400 prompt is too long, which is what drives Claude Code's auto-compact /v1/models— serves the translated catalog for gateway model discovery
setup.sh asks whether to fetch Corti's catalog and generates ~/.corti-bridge/models.env — for example:
# Written by setup.sh. Do not edit by hand - run ./setup.sh --fresh to refresh.
ANTHROPIC_DEFAULT_FABLE_MODEL="corti-s1-ultra-beta"
ANTHROPIC_DEFAULT_FABLE_MODEL_NAME="corti-s1-ultra-beta"
ANTHROPIC_DEFAULT_FABLE_MODEL_SUPPORTED_CAPABILITIES="thinking,adaptive_thinking,effort,max_effort,temperature,mid_conversation_system"
ANTHROPIC_DEFAULT_OPUS_MODEL="corti-s1"
ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES="thinking,adaptive_thinking,effort,max_effort,temperature,mid_conversation_system"
ANTHROPIC_DEFAULT_SONNET_MODEL="corti-s1-instant"
ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES="temperature,mid_conversation_system"
ANTHROPIC_DEFAULT_HAIKU_MODEL="corti-s1-mini-instant"
ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES="temperature,mid_conversation_system"
CLAUDE_CODE_MAX_CONTEXT_TOKENS="524288"fable is a fourth tier Claude Code recognises alongside opus, sonnet and haiku, sitting above opus — it's where a model stronger than the opus pick goes. It is optional: Claude Code only offers it when ANTHROPIC_DEFAULT_FABLE_MODEL is set, which is why it's the one tier the installer may leave out entirely.
Only bin/corti-bridge reads this file; it exports these as environment variables just before launching Claude Code. Nothing is written to any settings.json, yours or otherwise — which is what keeps Corti model IDs from leaking into a plain claude session.
Tiers are picked by decomposing model IDs into size/speed/channel parts rather than by matching exact names, so a new Corti generation slots in without a code change, and the result doesn't depend on what order the API happens to list models in. The program lives in lib/models.mjs.
A tier takes the first [size, speed] shape it can fill from an explicit shape table, and a beta beats the GA of that same shape:
| Tier | Shapes (in fill order) |
|---|---|
fable |
["ultra", ""] |
opus |
["", ""] |
sonnet |
["", "instant"] |
haiku |
["mini", "instant"] → ["mini", ""] → ["tiny", "instant"] → ["tiny", ""] |
Sorting rather than scanning keeps every pick independent of the order the API returned. An unfilled tier borrows the one above it; nothing sits above opus, so it takes the roomiest model by context window. A fable that only repeats opus is not a tier — but a name comparison is not enough to tell (see the fingerprint probe).
CLAUDE_CODE_MAX_CONTEXT_TOKENS is derived from whichever model wins the opus slot, not hardcoded — that export is the window Claude Code compacts against. The gateway's own overflow backstop is a separate fixed constant that does not follow the mapping; it only catches absurd bodies, because upstream's 400 is authoritative for whichever model was actually called.
The _SUPPORTED_CAPABILITIES lines declare each model's capabilities from the catalog's per-model capabilities and effort metadata. They are inert behind ANTHROPIC_BASE_URL: per the gateway-compat docs, the harness reads them only under provider configs (Bedrock/Vertex/Foundry/Mantle), not a base-url gateway, so it falls back to inferring from the tier's model id and the effort picker shows all levels regardless. They're emitted anyway as correct catalog-driven values that would take effect under a provider config; the picker and send-time effort are shaped in translate.mjs (mapEffort). The derivation:
reasoning→thinking,adaptive_thinkingeffort.supported→effort,max_efforttemperature→temperaturemid_conversation_systemis always offered
A capability is omitted only when the catalog explicitly says false — absence keeps it, because Corti's metadata has understated capabilities before. xhigh is never offered in the capabilities string (no Corti model honours it, so acceptance wouldn't prove support) — but the harness picker still sends output_config.effort: "xhigh", which the proxy accepts and maps to max (see thinking config above). interleaved_thinking is omitted since the proxy strips thinking blocks from history on re-entry.
Opus, sonnet and haiku are drawn from the GA channel only. Fable is the exception: it takes the strongest model in the catalog whatever its channel, because when Corti ships a model larger than its GA line it has done so on the beta channel alone, and a GA-only rule would leave the tier permanently empty. Beta models only appear in the catalog when the fetch is made with --experimental (?experimental=true).
Models with a context window under 100k are excluded from every tier — small enough to break a coding session before it gets going. The window comes from each model's max_input_tokens in the catalog, so CLAUDE_CODE_MAX_CONTEXT_TOKENS tracks the opus tier's real window without a maintained table. A model that omits the field warns by name and falls back to a default — a drift signal, not a guess.
A fable that would only repeat the opus pick is dropped, and a name comparison is not enough to tell — several public model names can route to the same upstream backend, so an alias may resolve to whatever it aliases. The installer probes: it asks the candidate and the opus pick for a one-token completion and compares the system_fingerprint each reply carries, which identifies the backend. Same backend, no fable tier. A probe that fails to answer leaves the tiers as ranked rather than dropping one on a hunch.
Edit models.env directly — hand edits stick until the next --fresh overwrites the file — or re-detect:
./setup.sh --fresh # GA models only
./setup.sh --fresh --experimental # also consider beta models for the fable tierThe default flow never touches an existing models.env; --fresh is the only thing that overwrites it.
For per-tier overrides without a full re-fetch, use the interactive picker:
corti-bridge models # pick a model for each tier; a non-default choice pins it
corti-bridge models --experimental # include beta models in the candidate lists
corti-bridge models --reset # clear all pins and re-rank from scratchThe picker fetches the catalog on the spot and writes models.env with a _PIN=1 line for any tier you changed; pressing Enter keeps the auto-rank pick and unpins. Its candidate lists come from the same lib/models.mjs as the ranker, so the menu and the auto-rank can't drift.
You can pin the fable tier by hand: add ANTHROPIC_DEFAULT_FABLE_MODEL_PIN="1" to models.env beside the ANTHROPIC_DEFAULT_FABLE_MODEL you want (_NAME and _SUPPORTED_CAPABILITIES ride along if present). A pinned model is carried through verbatim — it survives --fresh, bypasses the duplicate check, and doesn't need to be in the catalog at all. It's the one hand-added line a refresh preserves.
Claude Code keeps conversation history, plugins, skills, MCP servers and per-project trust in a profile directory. setup.sh asks which one Corti sessions should use and records the answer in ~/.corti-bridge/profile.env:
~/.claude(default) — your normal profile, so your history, plugins, skills, and MCP servers carry over.~/.corti-bridge— a clean room. Starts empty: no history, no plugins, no skills, no MCP servers. Use this if you want Corti sessions kept separate from your usual ones.- A path you choose — any other config directory, for anyone already keeping profiles apart (a separate work profile, say).
~is expanded; the path must be absolute, and it does not need to exist yet.
Whichever you pick, your regular claude is unaffected, because the model aliases are process-scoped exports rather than persisted settings. --fresh re-asks the profile choice (alongside re-detecting models); a bare ./setup.sh reuses whatever profile.env already records.
Note that ~/.corti-bridge (the proxy's state directory, CORTI_PROXY_CONFIG_DIR) is not the same thing as Claude Code's profile directory.
corti-bridge starts a local gateway on 127.0.0.1:4192 (set CORTI_PORT to move it) the first time you run it, and it outlives any single session — it keeps running after corti-bridge exits, so the next session starts fast. That also means there's no first-class way to stop it just by quitting Claude Code. The gateway is managed with --stop and restart:
corti-bridge --stop # stop the gateway and exit
corti-bridge restart # stop then start it (needs CORTI_BEARER/CORTI_BASE_URL)--stop is a flag, not a bare command: claude's own stop|kill <id> subcommand passes through the wrapper to stop a background session, and a bare stop would intercept it and silently kill the gateway instead. restart is safe as a bare command because claude uses respawn, not restart, for background sessions — no collision. The other subcommands (doctor, models, theme) shadow claude-verb equivalents that are low-value when proxied (doctor checks the Claude Code install, which the wrapper leaves healthy) or that don't exist (models, theme), so the proxy's command is the useful one. --stop needs nothing — not even credentials — so it works when something's wrong. restart checks credentials before stopping, so a typo'd CORTI_BEARER won't take down a working gateway. Stopping a gateway that's already stopped is not an error.
Reconfiguring models (./setup.sh --fresh) or the profile does not require restarting the gateway: the gateway doesn't read models.env (the wrapper does, at launch), so a new mapping takes effect the next time you run corti-bridge. You only need restart if you've changed CORTI_BASE_URL — and even then, a normal corti-bridge run detects the staleness and restarts it for you. Switching --anthropic never needs one: both modes are always being served.
Corti's edge fails in bursts. During one on 2026-08-18 it answered most requests with an empty-bodied 503 for roughly a minute at a time, while a minority still succeeded — two requests 154ms apart came back 503 and 200. Empty-bodied 403s appeared in the same windows. That shape (no body, no x-request-id, sub-second) is the load balancer talking, not a model.
Claude Code retries on its own, but its ladder is about five attempts inside ~8 seconds — too fast to outlast a blip like that, so every message in the window failed. The gateway therefore retries upstream itself before the client ever sees an error:
- Up to 3 attempts (the original plus 2 retries), backing off ~0.5s then ~1.5s with jitter. Worst case adds under 3 seconds.
- Only while the client response is still unwritten. Once SSE frames have gone out, a retry would replay a partial turn, so a mid-stream failure is passed through as it always was.
- Retryable:
408,429,500,502,503,504,529, and connection-level failures (ECONNRESET,ETIMEDOUT, and friends). A numericRetry-Afteris honoured, clamped to 4s. - Not retryable:
400,401,403,404,413. A badCORTI_BEARERhas to fail on the first attempt rather than tripling the cost of every request. - Retries skip the connection pool. A keep-alive socket pinned to an unhealthy backend would just hand back the same instant
5xx, so attempt 2 opens a fresh connection.
The two ladders multiply — the client's five attempts each become up to three upstream calls — which is why this one stays deliberately short. It is meant to absorb sub-10-second edge blips, not to replace the client's policy or to ride out a real outage. When upstream is genuinely down you still get an overloaded_error, just a few seconds later.
Retries are invisible to the client but not to you: each one appends an attempt N failed (…); retried after Nms line to the request's diagnostics in the debug log, and the gateway's console log tags them (POST /v1/messages 503 (attempt 2)).
Separately, silence before upstream sends response headers now has its own deadline (CORTI_HEADERS_TIMEOUT_MS, default 60s) rather than sharing the 120s mid-stream idle timeout — an upstream that accepts the connection and then says nothing is a much stronger death signal than a pause mid-generation. Raise it if you see spurious timeouts on long generations; 0 restores the old shared 120s behaviour.
For a first pass when something's off, run corti-bridge doctor — it checks the install, gateway health, and state files passively (add --deep to also probe Corti's /models endpoint). If a specific request looks wrong, reach for the debug log below.
Set CORTI_DEBUG and the gateway writes every request and response to a log file, one per Claude Code session:
CORTI_DEBUG=1 corti-bridgeEach session's traffic lands in its own file — gateway-session-<sessionId>-<timestamp>.log — keyed by the session id Claude Code sends (x-claude-code-session-id). Advisor children file under the parent's session instead (x-corti-advisor-for), so an advisor consult stays in the parent's log. Untracked requests (no session id) share a single gateway-session-untracked-*.log.
The wrapper prints the log directory on startup, and /health reports it as debug (the directory, or false when logging is off):
curl -s http://127.0.0.1:4192/health
# {"status":"healthy","gatewayVersion":2,"mode":"openai","upstream":"https://ai.eu.corti.app/v1","debug":"/Users/you/Library/Logs/corti-bridge"}
#
# `mode` is not a process-wide setting — it reports what a request carrying no path prefix
# resolves to, which is what the wrapper compares when deciding whether to restart.In openai mode each request id gets up to four entries — REQUEST (what the client sent), UPSTREAM-REQUEST (translated OpenAI body), UPSTREAM-RESPONSE (raw upstream bytes, plus upstream headers when the status was an error — that's the only place server, retry-after and x-request-id survive), RESPONSE (translated bytes sent to the client, with a note of completed/upstream-error/client-abort/watchdog-timeout/parse-fail and per-request diagnostics). Mistranslation debugging is a diff problem: compare REQUEST→UPSTREAM-REQUEST and UPSTREAM-RESPONSE→RESPONSE.
The log contains complete prompt bodies — your source code, file contents, whatever Claude Code sent — including their translated forms. CORTI_BEARER is never written, and authorization/x-api-key/cookie headers are redacted, but treat the files as sensitive. The directory is created 0700 and files 0600. Nothing rotates or prunes them; delete them yourself when done.
Where they go, in order of precedence:
CORTI_DEBUG_DIR |
If set, used as-is |
| macOS | ~/Library/Logs/corti-bridge/ |
| Linux/other | $XDG_STATE_HOME/corti-bridge/ (or ~/.local/state/...) |
| Fallback | $TMPDIR/corti-bridge/ if the above isn't writable |
Bodies are capped at 2 MB each by default so a long streaming response doesn't produce a giant file; raise it with CORTI_DEBUG_MAX_BODY, or set 0 for no cap. Truncated bodies are marked as such.
The gateway is a background process that outlives any single corti-bridge run, so toggling CORTI_DEBUG has to restart it — the wrapper handles that automatically, in both directions. If you started the gateway some other way, stop it yourself first.
Read directly from the shell — no local secrets file.
| Var | Required | Notes |
|---|---|---|
CORTI_BEARER |
yes | Sent upstream, never the client's own token |
CORTI_BASE_URL |
yes | Must match https://ai.<env>.corti.app/v1; used as-is (OpenAI-compatible endpoints) |
CORTI_HOST |
no | Proxy bind address, default 127.0.0.1 |
CORTI_PORT |
no | Proxy bind port, default 4192 |
CORTI_NO_UPDATE_CHECK |
no | 1 disables the update check entirely — no background git fetch, no notice. Already off for print runs, advisor children, non-clone installs, and any branch but main |
CORTI_UPDATE_INTERVAL_S |
no | Seconds between background update fetches, default 86400 (once a day). The commits-behind count itself is read from local refs on every launch and costs no network |
CORTI_REASONING_MODE |
no | thinking (default: reasoning becomes Anthropic thinking blocks), text (fold into reply text), drop. Controls reasoning visibility on the response side; the request-side depth comes from output_config.effort → reasoning_effort (see thinking config in Translation surface) |
TAVILY_API_KEY |
no | Enables Tavily as the primary WebSearch backend; when unset (or when Tavily fails/rate-limits) the keyless DuckDuckGo scrape is used instead |
CORTI_SEARCH_DEPTH |
no | Tavily search depth: basic (default, 1 credit) or advanced (2 credits, richer snippets); ignored without TAVILY_API_KEY |
CORTI_HEADERS_TIMEOUT_MS |
no | How long to wait for upstream response headers before giving up, default 60000; 0 falls back to the 120s mid-stream idle timeout |
CORTI_DEBUG |
no | Any value except 0/false/no/off turns on request/response logging |
CORTI_DEBUG_DIR |
no | Where debug logs go; defaults per platform (see Debug logging) |
CORTI_DEBUG_MAX_BODY |
no | Per-body byte cap, default 2097152 (2 MB); 0 means unlimited |
CORTI_ADVISOR |
no | auto (default) — the consult_advisor advisor tool is on in openai mode, off in anthropic mode. on — on in both modes. off — off in both modes. Unrecognized values warn once and use auto. The advisor spawns a headless corti-bridge -p (Opus-tier by default, see CORTI_ADVISOR_MODEL) on the request path — a consult can take minutes; the child carries a recursion guard so it can't re-inject or recurse. |
CORTI_ADVISOR_MODEL |
no | Model alias for the advisor backing (default opus); resolved by the wrapper, so use a tier alias (opus, sonnet, haiku, fable), not a claude- name |
CORTI_ADVISOR_TIMEOUT_MS |
no | Bound on the advisor spawn, default 480000 (8 min); on timeout the tool_result becomes <advisor_guidance>advisor unavailable (execution_time_exceeded)</advisor_guidance> rather than hanging the turn |
CORTI_ADVISOR_MAX_TOKENS |
no | Soft per-call output cap, surfaced to the advisor via the serialized transcript's budget line; default 2048 (the official recommended starting point). No hard max_tokens cap exists through corti-bridge -p; this is a soft steer plus the hard CORTI_ADVISOR_TIMEOUT_MS / maxBuffer ceilings. Lower it to bias toward brevity. |
CORTI_ADVISOR_EFFORT |
no | Reasoning effort for the advisor child, default high (the official advisor default). The gateway applies this only to the advisor child (detected via the -noadvisor- token). Corti's effort vocabulary is {high, max} — medium/low floor somewhere unknown upstream, so low is not recommended (it undermines the advisor's value). A model that rejects the level will 400 upstream. |
CORTI_VISION_MODEL |
no | The sighted model used to describe images for a blind primary (see "Images on a non-multimodal model" above). Defaults to the user's resolved haiku tier from models.env (bin/corti-bridge stamps it from ANTHROPIC_DEFAULT_HAIKU_MODEL at gateway launch), falling back to corti-s1-mini-instant — multimodal and non-reasoning, so describing is fast and needs no thinking cap. Override explicitly to use a reasoning model (corti-s1-mini) for chart arithmetic or counting. Must itself be multimodal — if it's blind, the skipImages recursion guard stops the sidecall from re-describing and the upstream 400 surfaces as a graceful placeholder, not a crash. The gateway reads it from its process env (set at launch), so a models.env change needs a corti-bridge restart to take effect. |
CORTI_VISION_TIMEOUT_MS |
no | Bound on the describe sidecall, default 60000 (60s); on timeout the image block becomes a placeholder text rather than passing through to a 400. |
CORTI_PROXY_BIN_DIR (defaults to ~/.local/bin) controls where the wrapper is installed. CORTI_PROXY_CONFIG_DIR (defaults to ~/.corti-bridge) is the proxy's own state directory — model mapping, profile choice, gateway log. CORTI_PROXY_DIR tells the wrapper where gateway.mjs lives; setup.sh bakes your clone's real path into the installed wrapper, so you only need this if you move the clone afterwards. The legacy CC_PROXY_BIN_DIR / CC_PROXY_CONFIG_DIR / CC_PROXY_DIR names still work (read as a fallback), so existing scripts don't break on upgrade.
ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_ATTRIBUTION_HEADER=0, and CLAUDE_CODE_DISABLE_1M_CONTEXT=1 are exported by the corti-bridge wrapper itself — that's plumbing this tool owns, not something you configure. (The attribution header is off because a per-request attribution line in the system prompt would defeat upstream prefix caching. The 1M context badge is disabled because it's misleading for proxied models — see degradations.)
Compared to first-party Anthropic or anthropic mode, this setup cannot support:
- WebSearch — Anthropic's server-side search is stripped from requests, but the proxy converts it to a function tool and intercepts results via the Tavily API, falling back to a keyless DuckDuckGo scrape when
TAVILY_API_KEYis unset or Tavily fails/rate-limits, so the model gets real search results. Each search runs once per session, cached bytool_use_id: the intercept walks the whole history every request, so without the cache every past search was re-run every turn. That is not just wasted calls — live results drift, so the rewrittentool_resultbytes changed mid-conversation and collapsed Corti's prefix cache (A1). Measured in one session before the fix: 132 searches for 2 queries, 4 cache collapses, and 2 turns killed by the 120s stream-idle watchdog when ~100k tokens had to be reprocessed uncached. Other server-side tools (web_fetch etc.) are stripped with no replacement. - PDF input — base64 PDF document blocks are replaced with a visible
[PDF document omitted...]placeholder. - Misleading
[1M]context badge — newer Claude Code versions badge proxied models with a[1M]suffix via the server-sidecontext-1mbeta gate.bin/corti-bridgeneutralizes it by exportingCLAUDE_CODE_DISABLE_1M_CONTEXT=1. - Background/nonessential traffic is off — the harness makes background calls (bootstrap, registry, telemetry, crash reports, plugins, Projects,
/bug,/feedback) that bypassANTHROPIC_BASE_URLand reachapi.anthropic.comdirectly, so they don't go through the gateway.bin/corti-bridgeexportsCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1to cut all of them. This is a hard off, not a redirect: those calls carry a claude.ai auth token the gateway doesn't hold, so re-enabling them would produce auth failures rather than working telemetry. To override,unsetthe export (orexport …=0) in a wrapper-local launch script beforeclaudestarts — there is nocorti-bridgeflag for it, by design, since the path isn't functional through this proxy. - A startup notice about the 200K limit is expected — that export also makes Claude Code warn it can't enforce its 200K fallback. Auto-compaction already runs at the model's real window, so the notice is benign. Do not set
CLAUDE_CODE_AUTO_COMPACT_WINDOWto silence it — that value is a cap (min(real_window, value)), so it throws away context — and only values ≤ 200000 silence the notice at all; anything higher caps the window without silencing it. - Images on a non-multimodal model are described, not crashed — not every Corti model is multimodal;
corti-s1(the opus tier) rejects images with a clean400 "…is not a multimodal model", which kills the session. The gateway intercepts image blocks (Readon a.png, screenshots, user-attached images) when the resolved model's catalogcapabilities.image_inputis false: each image is described by a sighted side model and replaced with a text block, so the blind primary never receives an image. The describe sidecall is context-aware: the user's question (or the model's stated intent, if there's no user text) from the same request is passed to the vision model, so it focuses on what's actually being asked — layout, alignment, clipping, broken text — rather than a generic summary. It steers what the description covers, never what it answers;<system-reminder>spans are stripped first, since the harness puts a CLAUDE.md replay in the same user message as the image. The vision model returns a JSON object (Corti honorsresponse_format: { type: "json_object" }) with typed fields: a verbatimtext_contenttranscription, a structuraldescription, apaletteof approximate hex bound to what carries it, and anuncertaintieslist so the lossiness is a machine-readable signal, not a hunch. Every field is a property of the image rather than of the request, because the cache key is the image hash and a request-dependent field would be replayed stale on every later turn. The prompt islib/vision-describe-prompt.txtand carries per-type checklists; decoding is pinned attemperature: 0.1,top_p: 0.9, with the image part sent before the text part. The description is cached per image by a hash of the image data, however it arrived — a paste and a laterReadof the same file share one description — so it is byte-identical across turns — the same A1 prefix-cache invariant as WebSearch; the cache key is the image hash only, so the description is computed once on the turn the image first appears with its question. Capability-driven: a future multimodalcorti-s1reportsimage_input: trueand images pass through untouched with no code change. The side model defaults to the instant sonnet tier (corti-s1-mini-instant— multimodal, non-reasoning); override withCORTI_VISION_MODEL. A describe failure (timeout, upstream error) degrades to a placeholder text, never a crash. Describing is lossy — for pixel-precise UI work, route that turn at a multimodal model directly. - Prompt-caching economics — caching is upstream's automatic prefix cache; usage reports zeros for cache fields when caching isn't active.
- Reasoning signatures are synthetic — thinking blocks emitted by the proxy carry a constant signature (
corti-proxy, base64). Claude Code accepts and re-sends them; the proxy strips them from history on re-entry. If you ever take a session from~/.corti-bridgeand resume it against real Anthropic, those blocks will fail server-side signature validation — filter them out first. - Overflow is decided by tokens, not bytes — upstream accepts multi-megabyte bodies, so the model's context window is what binds. Over-window turns become
prompt is too longwith a real token count, which is what drives compaction; only bodies over the gateway's own 8 MB byte cap get a local 413 instead.
The consult_advisor advisor tool is on by default in openai mode (CORTI_ADVISOR=auto); set CORTI_ADVISOR=off to disable it. When the model calls the tool, the gateway holds the turn: it emits a synthetic server_tool_use + advisor_tool_result inline — the shape the harness renders as "Advising…" — runs the advisor (a headless corti-bridge -p), then makes a second upstream call so the model answers in the same turn. That "Advising" via hold-and-continue is the openai experience.
Each consult spawns a headless session on the request path, so a turn that consults blocks for up to CORTI_ADVISOR_TIMEOUT_MS (8 min default) while the advisor reasons over the serialized transcript. The executor timing prompt (lib/advisor-executor-prompt.txt, ~2 KB / ~500-700 tokens) is prepended to every openai system prompt while the advisor is on, consulted or not — that is the per-request cost of default-on.
The advisor receives the executor's full serialized transcript (system + tools + messages + a budget line carrying CORTI_ADVISOR_MAX_TOKENS), not a focus string; the tool takes empty input (additionalProperties: false) — the executor signals timing only, and the harness forwards context automatically. Prior advisor advice does not round-trip in this deployment. The official contract is that the harness replays the full assistant content — advisor_tool_result blocks included — on the next turn, and translate.mjs honours that (C6: an incoming advisor_tool_result becomes <advisor_guidance> text for the upstream). But that replay only happens when the harness has enabled its own advisor, which Gate 1 refuses for an unranked base model like corti-s1 (see .context/projects/2026-08/07-advisor-mechanism/findings.md). Our blocks are synthesized by the gateway for a harness that never opted in, so it renders them and drops them — verified as zero server_tool_use / advisor_tool_result in the raw client request bodies across sessions.
So the gateway supplies the missing block itself (A4). Left alone, a consult reaches the next turn as two adjacent text blocks with the advice excised from between them, and the model reads its own "calling the advisor now" as a promise it never kept — apologising for a call it did make, then calling again. The apology is plain text, so it is replayed, and each one makes the next more likely. recordAdvisorGuidance(sessionId, anchor, advice) stores the advice against the text block that survives beside the consult (translator.lastText, captured before the synthetic blocks are emitted, falling back to the continuation's last text); translateRequest re-inserts it at that anchor on every later turn, producing exactly the <advisor_guidance> text C6 would have. The store is per-session, in-process, and bounded (64 sessions × 32 consults) — a gateway restart drops it and that conversation reverts to the old behaviour. Insertion is byte-identical each turn, so the cached prefix stays stable (A1). The advice is restored as the consult's own tool_use plus its tool_result — the shape the continuation already sends — so the model sees a call it genuinely made. It must never be assistant text: rendered as prose, the model read advice it had no memory of writing as its own fabrication and told the user it had faked the consult and that the tool was not callable (measured: 4 real consults, 4 verbatim-matching blocks in history, 0 fabrications — it disowned every one). The restore also has to run after applyIntercepts, or the reconstructed pair matches the intercept's rewrite loop and spawns a fresh advisor run. The advisor's own transcript is restored the same way and from the same helper: shown the excised history it cannot see the consult it just answered, so it confirms the executor's false "I never called it" rather than correcting it — that is where the apology loop got its confidence. The anchor is the text that preceded the call, else the first block the continuation emitted — a text block or, when the model went straight from advice to action, its tool_use id (ids round-trip because tool_result pairing depends on them). Known limits: a rewind past the anchor drops the advice with it; two consults whose anchor text is byte-identical collapse onto one, so the later advice wins; and a continuation that produced no blocks at all records nothing rather than guessing a position.
The advisor is skipped for a request with no tools. Such a request has no next action to steer — it is one of the harness's one-shot side calls (summarise a fetched page, title a chat), not an agent loop. They were consulting anyway: in one measured session 6 of 7 consults came from 2-message toolless calls, spending 50s of Opus-tier advisor time on requests that could not act on a word of it. The gate covers both halves — no tool definition and no executor prompt from the intercept, and the gateway declines to hold a turn open for a consult it never offered. When the continuation fails there is no anchor to record against, so the failure note repeats the advice verbatim inside an <advisor_guidance> block: an ordinary text block does round-trip. If a future harness does replay the blocks, C6 turns them into the same shape — keep C6. A three-part guard keeps the advisor child from re-injecting or recursing: (1) CORTI_ADVISOR_NOINJECT=1 stamps a noadvisor- marker on the child's token, which the gateway's wantsNoAdvisor turns into skipAdvisor: true; (2) CORTI_NO_MANAGE_GATEWAY=1 keeps a debug-mode mismatch from making the child restart-kill its parent gateway mid-consult; (3) maxBuffer: 32 MB bounds the child's combined stdout.
The advisor child reasons at high effort by default (CORTI_ADVISOR_EFFORT, the official advisor default), not the medium that adaptive thinking maps to — the advisor's value is in its reasoning, so it gets a deeper pass than a routine turn.
Upgrading from
CORTI_ADVISOR_TOOL: the advisor is now controlled byCORTI_ADVISOR; the formerCORTI_ADVISOR_TOOLis removed — setCORTI_ADVISOR=onto get the old behavior, or leave it unset for the mode default (on inopenai).
corti-bridge --anthropicPoints this session at the gateway's pass-through route instead of the translating one — no translation, every path forwarded, auth swap only. It changes nothing about the gateway: one process serves both routes at all times, so the flag costs no restart and does not disturb sessions running in the other mode. count_tokens is still answered locally by the estimator on both routes.
Mechanically, the wrapper exports ANTHROPIC_BASE_URL=http://127.0.0.1:4192/anthropic rather than the bare origin, and the gateway dispatches on that prefix. You only need to know this if you are curling the gateway by hand or reading raw debug-log paths.
openai mode is the default because pass-through loses something Claude Code relies on (see below), which the translation layer supplies. Use openai mode unless you have a reason not to.
Use anthropic mode to escape-hatch a translation bug, or as a comparison harness: run corti-bridge and corti-bridge --anthropic at the same time. Both write to the same debug log, so the two modes interleave by timestamp in one file — compare by request id rather than diffing two logs from two gateway lifetimes.
The cost is specific and worth knowing before you reach for it: Corti's /anthropic endpoint drops input-token accounting on streaming responses. message_start reports usage: {"input_tokens": 0, "output_tokens": 0} and message_delta carries only output_tokens — no input count, no cache fields. Claude Code always streams, so in this mode every transcript records zero input tokens and context/cost readouts stop working. Non-streaming requests to the same endpoint return full usage, so no request parameter fixes it. Tool use is unaffected.
The advisor is off by default in anthropic mode (CORTI_ADVISOR=auto). The inline hold-and-continue above is a translator-stream hook: pass-through has no translator stream, so it can't fire here. With CORTI_ADVISOR=on the tool is still injected and its tool_result is synthesized by the same spawn, but the delivery differs — it arrives as a next-turn tool_result rewrite, not inline. The flow: the model calls consult_advisor, the client (Claude Code does this) synthesizes an is_error tool_result for the unknown tool and round-trips it, and the intercept rewrites that result with the advisor's output on the next request. There is no "Advising" indicator; the executor reads the advice on its next turn.
This depends on the client synthesizing an is_error tool_result for the unknown tool — if it doesn't, the model sees an unhandled-tool error with no recovery. Because of that client dependency and the loss of the inline experience, prefer openai mode for the advisor.
The wrapper installs to ~/.local/bin, which is not on macOS's default PATH and is only sometimes on Linux's. When it's missing, setup.sh offers to add it to your shell config — .zshrc for zsh, .bash_profile and .bashrc for bash, ~/.config/fish/conf.d/corti-bridge.fish for fish. It backs the file up first, marks what it added, and can only ever add it once. Decline with --no-modify-path and it prints the line for you to add yourself.
Nothing it writes takes effect in the terminal you ran it from — a script can't change its parent shell. Open a new terminal, or source the file it names.
./setup.sh --uninstallremoves the wrapper and the PATH block. It leaves ~/.corti-bridge alone, since that's your model mapping and profile choice, and prints the path so you can delete it yourself.