Language: English only for now (Chinese translation tracked).
📖 Docs Index | 🏠 Main README
This note describes the runtime shape of opencode2api, where the
duplication hotspots are, and how to extend the gateway without breaking
wire behavior. It is a companion to AGENTS.md §9.
index.ts (env > file > default merge + bootstrap)
└─> startProxy(opts) [src/proxy.ts]
├─> buildProxyConfig(opts) [src/config/proxy-config.ts]
└─> createApp(config) [src/proxy.ts]
├─> registerSystemRoutes [src/routes/system.ts] (models / health / metrics, no lock)
├─> registerChatRoutes [src/routes/chat.ts] (POST /v1/chat/completions, request lock)
├─> registerResponsesRoutes[src/routes/responses.ts] (POST /v1/responses, cancel race)
├─> registerInteractionsRoutes[src/routes/interactions.ts] (POST /v1beta|/v1/interactions, Gemini thin layer)
└─> registerMessagesRoutes [src/routes/messages.ts] (POST /v1/messages, Anthropic converters first)
Cross-cutting helpers (all routes depend on them one-way; no cycles):
| Layer | File | Role |
|---|---|---|
| Backend | src/backend/manager.ts |
lifecycle + request lock/queue + opencode binary lookup |
| Stream | src/stream/collector.ts |
prompt → poll → collect-from-events SSE pipeline |
| Errors | src/errors/upstream.ts |
normalizeBackendError, transformUpstreamError, isTransient* |
| Retry | src/retry/policy.ts |
pure resolveMaxRetries / backoff+jitter / retry-after parsing |
| Converters | src/converters/ (N×N registry) |
pure protocol↔protocol shapes; see §5 |
| Tool runtime | src/tool-runtime/ |
contracts → registry → router → parser → validator → policy |
| Guards | src/utils/guards.ts |
shared asRecord / toErrorMessage (single source) |
retry/policy, converters/*, and errors/upstream are pure
(no Express/SDK imports) — keep them that way.
Every POST route repeats the same template with small per-protocol
differences:
resolveRequestedModel(underwithTimeout)createRequestToolContext+trackToolModebuildSystemPrompt(system + external prompt + reasoning + tool mode)ensureBackend→client.config.update({ activeModel })→session.creategetToolOverridesForMode→ prompt → poll/collect → render- usage estimate (
Math.ceil(len / 4)orestimateTokens) + session cleanup
Known intentional differences — do not "unify" them without approval:
stream flushHeaders timing (responses flushes before preflight, chat /
messages after), lock() wrapping (chat + messages only), cancel via
res 'close', forbidThinkBlock per route, and the four error-exit
shapes (chat non-stream JSON; responses-stream response.failed SSE +
[DONE]; messages-stream error event without [DONE];
interactions-stream interaction.completed / error event without [DONE]).
- New route: add
src/routes/xxx.tsexportingregisterXxxRoutes(app, ctx), wire it insrc/proxy.tsbefore the 404 fallback, add request/response types undersrc/types/. Copy the §2 template; do not refactor the existing four routes in the same change. - New converter (e.g. another vendor protocol): add a pure module
under
src/converters/<pair>/(request.ts/response.ts/init.tswithregister*Pair), wire it insrc/converters/init.ts(registerAllTranslatorPairs), and addtests/translator-*.test.js. Keep route glue (choice mapping, id round-trip) in the route file. Legacysrc/converters/anthropic.tsstays live inmessages.ts(response rendering, SSE helpers, token estimate) alongside the N×N inbound path — do not delete/retrofit it in the same change. - New tool dialect: extend
src/tool-runtime/parser.ts(regex + extractor + registration in the collector) and add fixtures totests/parser-foreign-formats.test.js. The ambiguity policy is registry-gated — loosening it turns prose into phantom tool calls. - New policy dimension: add constants in
contracts.ts, read them inpolicy.tsvia the existing context, and thread config throughProxyConfig+index.tsmerge +proxy-config.tsmerge + the six-way matrix (AGENTS.md§4).
Timeout/retry constants, transformUpstreamError mappings,
isTransientUpstreamError matchers, parser ambiguity policy, prompt
guard text/order, model alias rules, policy evaluation order, backend
spawn/jail/HOME isolation, collector finish === 'tool' /
idle-exemption logic, and bool-fallthrough semantics. See AGENTS.md
§9 for the full list.
Port of CLIProxyAPI sdk/translator/ (registry.go / pipeline.go /
formats.go / types.go) plus per-pair translators under
internal/translator/. Four formats — openai (chat), openai-response
(responses), claude (messages), interactions — give 12 directed request
pairs and 12 directed response pairs (stream + non-stream), all registered
explicitly in src/converters/init.ts (registerAllTranslatorPairs;
no Go-style blank-import side effects so Jest ESM ordering stays
deterministic).
Rules that keep the port safe:
- Translators are pure JSON→JSON (no Express/SDK). Shared guards come
from
src/utils/guards.ts. - Wire stays route-owned: SSE envelopes,
[DONE]presence, the four error-exit shapes, and usage key names are never normalized by the registry. Error bodies must bypass translators (converters assume valid requests). - Lookup contract differs from Go on purpose: Go reads
responses[to][from]inTranslateStream/NonStreamwhileHas*reads[from][to]; here everything is[from][to]= (source, target) soHas*andTranslate*agree (seeregistry.tsheader). - Stream state rides
paramholder objects (one per stream), mirroring Goparam *any; callers must reuse the holder across chunks. - Fidelity is recorded in
src/converters/fidelity.ts: the three forward edges out of chat (→ responses / messages / interactions) are FULL fidelity; the other nine stream edges are TEXT-CORE (text deltas + terminal mapping, zero-filled usage). TokenCount translators are intentionally unregistered — counting stays in the collector. usage.tsmaps usage legs with zero-filled unknowns; interactions responses never carry tokens (grounding_tool_countonly).is_errorrides theERROR:text prefix on tool legs (chattoolcontent, responsesfunction_call_output, messagestool_result): onlytrue/"true"encode, and success text literally starting with the prefix round-trips asis_errorby design.incompletewithcontent_filterreason is preserved across chat<->responses (non-stream + stream terminal);*->messagesedges map truncation toend_turn(Anthropic has nocontent_filtervalue).
Wiring status (live, not tests-only):
- The registry initializes once per process (idempotent across
createAppcalls via theinit.tsguard) and is exposed viactx.translators(optional so manual test contexts still compile). Registration is explicit; duplicateregisterthrows instead of silently overwriting. - Inbound request legs use direct
registry.translateRequestafter validation (an adversarial extratype:'error'field must not bypass conversion). Response/stream legs MUST use thewire.tsSafe variants (translateNonStreamSafe/translateStreamSafe), which bypass error envelopes first — currently exercised by tests/integration only, as routes keep native response rendering. Error envelopes always bypass. - Stream state rides one holder per stream (
src/converters/holder.tssingle source, shared by all four pairinit.tsfiles). TranslatorPipeline(pipeline.ts) is test-only for now (exercised bytests/translator-registry.test.js); production routes call the registry viawire.tsSafe wrappers. Future pipeline wiring needs a dedicated review and must keep middleware away from tools/system.POST /v1/messagesinbound (claude.request → chat.request) goes through the registry; adaptive/auto thinking collapses to the legacy fallback (resolveMessagesReasoningLevel) so no new prompt line leaks. All other routes keep native handling; cross-protocol translation of their outputs is covered bytests/translator-integration.test.js.- TokenCount stays unregistered — use
isTokenCountRegistered(registry)(live registry query).
User-facing guidance (also stated in both READMEs and
docs/troubleshooting.md): clients should use their native protocol
endpoint. Only POST /v1/messages inbound goes through the registry
today; every other cross-protocol path is best-effort and may degrade
(TEXT-CORE: dropped tool/thinking deltas, zero-filled usage, lost
truncation signals). Suspected translation bugs must be reproduced on the
native endpoint first — ruling out backend flakiness — and reported with
a minimal repro.
- Extract shared
preflight(ctx, body)(resolve model → tool context → system prompt → ensureBackend → create session → tool overrides). - Extract a
stream-kit(SSE headers + 15s keepalive + filter/parser pair + collect + flush/parse + usage) with per-route differences kept as parameters, not unified. - Extract
withSessionRetry(maxAttempts, fn)for the non-stream delete → create →computeRetryDelaysleep skeleton. - Unify the three config-merge dialects behind one module without
changing the documented default drifts (
AGENTS.md§4) — defaults change behavior and need their own tested proposal.