Skip to content

Latest commit

 

History

History
297 lines (199 loc) · 40.2 KB

File metadata and controls

297 lines (199 loc) · 40.2 KB

corti-bridge — Guide

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.

Table of contents

The translation layer

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_KEY is 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-5corti-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 thinking and output_config.effort map to upstream reasoning_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 sends output_config.effort (six levels: low→ultracode), which collapses onto Corti's two real levels — at-or-below highhigh, above → max. thinking.type: "adaptive" enables thinking only and contributes no depth; output_config.effort wins over the legacy thinking.enabled budget mapping. reasoning_effort is gated on a thinking block being present, so a non-reasoning request (no thinking) gets no effort level. The advisor child overrides both with CORTI_ADVISOR_EFFORT.
  • images — converted to image_url parts, including images inside tool results, which are attached as a following user message
  • auth — whatever token the client sends is discarded; the real CORTI_BEARER is injected
  • /v1/messages/count_tokens — handled locally (estimator: chars/4 + tools schema + per-image flat count). The same estimator seeds message_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_tokens itself 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

Model config

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"

The tiers

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.

How tiers are ranked

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.

Capability derivation

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:

  • reasoningthinking,adaptive_thinking
  • effort.supportedeffort,max_effort
  • temperaturetemperature
  • mid_conversation_system is 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.

Channels and context window

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.

The fingerprint probe

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.

Changing the mapping

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 tier

The 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 scratch

The 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.

Pinning the fable tier

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 profile

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.

The gateway

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.

Upstream failures and retries

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 numeric Retry-After is honoured, clamped to 4s.
  • Not retryable: 400, 401, 403, 404, 413. A bad CORTI_BEARER has 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.

Debug logging

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-bridge

Each 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.

Environment reference

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.effortreasoning_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.)

Known degradations (openai mode)

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_KEY is unset or Tavily fails/rate-limits, so the model gets real search results. Each search runs once per session, cached by tool_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 rewritten tool_result bytes 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-side context-1m beta gate. bin/corti-bridge neutralizes it by exporting CLAUDE_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 bypass ANTHROPIC_BASE_URL and reach api.anthropic.com directly, so they don't go through the gateway. bin/corti-bridge exports CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 to 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, unset the export (or export …=0) in a wrapper-local launch script before claude starts — there is no corti-bridge flag 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_WINDOW to 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 clean 400 "…is not a multimodal model", which kills the session. The gateway intercepts image blocks (Read on a .png, screenshots, user-attached images) when the resolved model's catalog capabilities.image_input is 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 honors response_format: { type: "json_object" }) with typed fields: a verbatim text_content transcription, a structural description, a palette of approximate hex bound to what carries it, and an uncertainties list 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 is lib/vision-describe-prompt.txt and carries per-type checklists; decoding is pinned at temperature: 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 later Read of 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 multimodal corti-s1 reports image_input: true and 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 with CORTI_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-bridge and 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 long with 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 advisor (openai mode)

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 by CORTI_ADVISOR; the former CORTI_ADVISOR_TOOL is removed — set CORTI_ADVISOR=on to get the old behavior, or leave it unset for the mode default (on in openai).

anthropic mode

corti-bridge --anthropic

Points 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 in anthropic mode (experimental, off by default)

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.

PATH and uninstalling

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 --uninstall

removes 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.