- Align conversion layer with Bifrost reference (
fix(protocol), PR #41): 转换层对齐 maximhq/bifrost (Apache-2.0) 语义,修复 stop reason / thinking / tool 映射多处偏差:- stop reason:
responsesOutcome折叠finish_reason=length与 Anthropicmodel_context_window_exceeded为incomplete/max_output_tokens,content_filter→incomplete/content_filter,pause_turn/compaction不再误报 completed;normalizeFinishReason映射model_context_window_exceeded→length,pause_turn/compaction返回空让 chat choices 不再谎报完成;流式/非流式 Claude 路径model_context_window_exceeded→max_tokens、content_filter→ Anthropicrefusal;stop_sequence 往返(请求 stop_sequences 命中响应尾部即恢复stop_sequence+ 回显序列)。 - thinking:adaptive thinking 仅对 adaptive-only 模型(Opus 4.7+/Sonnet 5+/Fable)保留、不再强制 normalize 为 enabled+budget(该类模型直接拒绝);
reasoning.encrypted_content独立redacted_thinking块(不再塞thinking.signature),summary 每段独立 thinking 块;ReasoningDetails结构化携带 thinking 签名与 redacted 载荷并在 chat-to-anthropic 出站回放。 - tool:新增
sanitizeToolUseID(Anthropic^[a-zA-Z0-9_-]+$字符集,64 截断,FNV-1a 确定性映射保tool_calls[].id/tool_call_id配对);空 ID tool_use 跳过、孤儿 tool_result 降级 user 文本(不再必 400);tool_choice收窄allowed_tools、parallel_tool_calls→disable_parallel_tool_use、参数经parseToolCallArguments压实。 - 内容杂项:对话中段 system/developer 内联为 system-reminder user 轮(tool_result 块保持首位);file part → Anthropic document 块;非允许 scheme 图片 URL(如 file://)丢弃;part 级
cache_control透传;usage 合并 cache_creation 5m/1h 明细与server_tool_use.web_search_requests,mergeUsage按 max 合并嵌套计数(事件序无关)。 - 合并解决:与 main 上
9ca44b4(F1-F6)/829eacd(responses 透传恢复)冲突时采用已落定的 F1 语义,PR 自带 thinking 断言同步更新。 - 验证:
go test ./...、go vet、gofmt全过;三协议真实测试全过(muse-spark chat/responses/messages + thinking 推理链路);Claude Code 长上下文稳态 99.8%、pi 三协议稳态 98-99.9%。
- stop reason:
- Map downstream sessions to dedicated upstream sessions (
feat(session)): 网关此前全部流量共用一个进程全局x-opencode-session,在上游侧形成"超级会话"——所有下游 agent 钉在同一个 provider 粘性上、计费归因混合、一次 429 触发的本地解绑影响全体。已查证上游 zen handler(anomalyco/opencode v2)只读x-opencode-session且该值直接决定 provider 粘性与计费归因(stickyId = sessionId ?: workspaceID ?: ip),真实 opencode 客户端每个 agent 会话独立发 affinity 值(parentID ?? fork.sessionID ?? session.id)。新增有界映射表(下游会话身份 → 专属ses_,internal/app/opencode.go):ocSessionMap:256 上限 + 滑动 2h TTL + LRU 淘汰(对齐stickyMaxEntries,TTL 远大于 sticky 出口 15min 防止出口未换 session 先转),锁内创建防并发重复,newOCSessionID()生成(满足上游免费层^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$校验),refreshOCSession(admin reload)一并清空。downstreamSessionKey:客户端会话头(X-Claude-Code-Session-Id/Thread-Id/Session-Id大小写不敏感,复用sessionHeaderValue)> bodyuser(chatreq.User/ Claudemetadata.user)> token 哈希作用域前缀防跨账号同名串扰;全程 sha256 8 字节 hex,原文不入日志。下游显式x-opencode-session不进映射、原样透传(客户端自有语义最高优先级)。- 解析顺序(
callOpenCodeEndpoint):显式透传 > 映射命中/新建 > 全局 sessionID > 临时随机(不回写)。normalizedTransportScope输入自然变专属值,sticky 出口oc:后缀、keypool 路由零改动自动按下游会话隔离,429 解绑只影响出事会话。 - 开关
OPENCODE2API_OC_SESSION_MAP=off/0/false/no回退单一全局 session 行为;/api/config暴露oc_session_map_size可观测;buildOCRequest/systemone路径ocScope统一normalizedTransportScope哈希(与主路径一致)。 projectID/clientVersion/三头同值双发/prompt_cache_key 内容派生全部不变——上游 project 头零路由语义(纯来源标注),缓存身份与 session 头正交。- 验证:gofmt/vet/
go test ./...全过含-race;ultracode 工作流三视角对抗评审 0 findings;mock 上游 E2E 12 项检查全过(同会话稳定、异会话隔离、显式透传、off 回退全局、reload 清空、oc_session_map_size计数)。真实 7 轮 Claude Code agents 缓存复测两遍达标:总命中 78.7%/83.5%(基线 81.1-84.2%,遍 1 低 2.4pp 归因子代理冷启动 4 个 vs 基线 2-3 个),稳态 96-100%,prompt_cached_tokens突破 40960 封顶随 prompt 增长(max 57344/59264),截断 0;pck 全程稳定且跨会话共享同一上游分片(两遍主对话 pck 同值,遍 2 冷启动仅 1 个)。回归测试oc_session_map_test.go(稳定/隔离/透传/兜底/开关/淘汰/TTL/并发)。
- Strip volatile client injections and pin stable prefixes across both upstream body shapes (
fix(cache)): Claude Code 逐请求注入易变内容(<total_tokens>上下文计数、system-reminder 上下文、hand-back 通知),让上游请求前缀逐请求漂移、前缀缓存反复从漂移点截断。按两种上游 body 形状分别修复(internal/app/instructions_stable.go,接入点统一在buildOCRequestWithSubpathAndState的免费层指纹重做之后、prompt_cache_key重算之前——指纹重做按客户端原文重建 body,此前的前置剥离都会被覆盖):- 计数剥离扩展三种形状与三个 role:
stripVolatileCountersInMap剥离 instructions / input / messages 形状里 user、system、developer 消息的<total_tokens>计数块(regex 连同周围空白一起吃,防逐轮累积空行漂移;assistant 输出稳定不动;非文本 part 原样保留)。Responses 形状(muse-spark 系)计数走 instructions + user;chat 形状(ling 系)计数以 role=system join 进 messages[0] 尾部逐轮累积(实测 ling-3.1-flash-free 上累积 3 处、命中率 11.7%)。 - chat 形状 system 钉首轮(
clipChatSystemStable+appendChatDeltaToLastUser):chat 协议把 system 提示 join 进 messages[0],CLI 每轮往尾部追加 hand-back 通知(剥计数后仍逐轮增长 ~3K),上游前缀缓存从 system 增长点截断、tools 与会话历史全 miss(命中率封顶 ~17%)。首条 role=system 消息按会话注册首轮原文钉住、增量 TrimSpace 后并入末条 user 消息,注册表与回退行为同 Responses 形状的clipInstructionsToStablePrefix(未命中/前缀不匹配时原样返回,不丢上下文)。 - thinking 回放跳过(
claudeToResponsesBody):CLI 按 Anthropic 惯例只回放最后一轮 thinking,reasoning item 的有无/位置逐请求漂移,前缀缓存从首个 reasoning item 断;GLM 系上游推理自含,回放无增益。 - pck 稳定段挑选:
prompt_cache_key的 tools 分支只哈希工具名集合(排序 join,异步 MCP/插件加载的 schema 漂移不再换缓存分片);first_user 改取非 system-reminder 的稳定 part 并剥离计数(逐请求重写的 SR 注入块不再做会话标识)。 - 调试设施:新增
OPENCODE2API_DUMP_UPSTREAM=<dir>——把发往上游的请求体逐个落盘(纳秒时间戳命名),用于缓存前缀字节级 diff 定位漂移段;默认关闭,仅限本地调试。 - 验证(真实 4 轮 Claude Code agents 任务、同会话 resume、全程相同
--allowedTools):muse-spark-1.3-contributor 总命中率 92.9%、主对话稳态 96-100%、计数残留 0、截断 0;ling-3.1-flash-free 主对话 7/8 请求 87-100%(唯一 low 是冷启动 probe)、计数残留 0/208 dumps、截断 0(28/28truncated=false)。剩余低命中全为固有冷启动(每个新子代理 pck 按 first_user 隔离防串污染)与 CLI 2.1.293.45a 的 hand-back 中段插入(单次断点、断后即恢复——通知含子代理最终报告不可删,挪尾部实测更差,留原位为固有代价)。回归测试TestStripVolatileTokenCounters、TestStripVolatileTokenCountersSystemRole、TestClipChatSystemStable、TestStableUserTextFromParts、TestContentPromptCacheKey_ToolsNamesIgnoreSchemaDrift等;docs/claude-cache-test-method.md§6/§7 补两轮根因、验证结果与测试环境陷阱(8080 日常网关占用、CLI 429 后 fallback 桌面 3P OAuth 直连官方致流量绕网关、CLI 自动更新改变通知注入位置)。
- 计数剥离扩展三种形状与三个 role:
-
Fix muse-spark free-tier intermittent empty turns / truncation (
fix(responses),fix(keypool),fix(chat),fix(httpclient)): 免费档共享 Output-token 限速承压时,上游以 HTTP 200 +response.created→response.incomplete(零内容、usage 报 reasoning tokens)杀死请求;PeekFirstFrame壳帧即 commit(IsProductiveEvent是零调用死代码)+response.incomplete分支无零内容检查 → 客户端拿 200 空消息stop_reason=max_tokens、零重试零报错,Claude Code 把空轮记入历史、agent 任务停摆(表现为「任务永远无法完成」)。六层修复(18-agent 排查对抗验证 0 驳回;mock 上游确定性复现闭环:empty_incomplete 从「200 空轮零重试」变为「pre-commit 换 key 重试 → 仍空 → 502 诚实失败」;真实网关 keypool 接管生效,upstream_attempt出现 key_id 轮动):- 产出感知 commit + 壳帧宽限窗(
fix(responses)):壳帧(created/in_progress/queued/output_item.added/content_part.added)与空 output 终态帧不再判产出 commit;首个壳帧后进入OPENCODE2API_SHELL_GRACE_MS(默认 5000,0恢复「任意帧即 commit」旧行为)宽限窗,到期仍只有壳帧按健康慢流放行(thinking 类上游首 delta 迟到不误重试)。限速杀死型空流在未向客户端写字节前经stream_empty_retry_max+ key_pool 换 key 重试;宽限窗 commit 后才到达的零内容 incomplete 补发 in-band error。 - 终态
output[]内容收割(fix(responses)):response.completed/incomplete分支补遍历 output、把未经 delta 通道流出的内容收割补发(reasoning summary 文本与 encrypted_content→signature、message output_text、function_call/shell_call arguments,与非流式路径对齐),streamedItems/streamedOutIdx双键去重防重复补发;带内容的 incomplete 不再丢已产出内容。 - 传输中断诚实失败(
fix(responses)):已 commit 的流中途死亡按错误类型分流——只有干净 EOF(io.EOF)才合成正常收尾(PartialEOF/ThinkingOnlyEOF 既有行为不变);RST / unexpected EOF / 墙钟超时等传输中断补发 in-banderror事件,stream_result记truncated=true,Claude Code 得以感知失败并重试该回合,不再把半截文本/非法 JSON 的 tool_use 当完整回合记入历史。 - 非流式残缺 SSE 兜底(
fix(responses)):免费层对所有请求强制stream:true,非流客户端拿到被截断的 SSE(无终态帧)时——含 delta 内容则聚合为合成 response(status:"incomplete")保留部分内容,连 delta 都没有则 502 诚实失败(Claude Code 对 5xx 自动重试整回合,优于 200 空消息被记入历史);"data:"无空格前缀与 PeekFirstFrame 对齐。 - public auth 接入 key_pool(
fix(keypool)):免费模型 public 直连改为交由 key_pool round_robin 分摊每账号输出限额(kill-switchOPENCODE2API_POOL_PUBLIC,默认 on;池整体黑名单级失败自动回落 public 直连,绝不弄坏原本可用路径)。注意上线后 prompt 缓存亲和一次性重排(sticky 哈希基从 public-shared 变 token-based)。 - 429 退避重试(
fix(keypool)):callOpenCodeEndpoint的 429 重发加递增退避(Retry-After 头优先、缺省 1s/2s/3s 递增、cap 5s、尊重 ctx 取消),不再立即重发放大限速。 - 上游墙钟超时可配置(
fix(httpclient)):OPENCODE2API_UPSTREAM_TIMEOUT_SECS(默认 900)。Gohttp.Client.Timeout覆盖整个响应体流式读取——单出口 socks5 下任一 >5min 的 agent 长输出在恰好 300s 被掐且完全不可见;现为 900 可调,<=0关闭整体墙钟。 - chat 非流分支双重转换修复(
fix(chat)):forwardChatViaResponses非流分支聚合结果已是 chat.completion 形时直接使用,不再走第二次convertResponsesToChat按原生 Responses 形读raw["output"](不存在)→ 正文被抹成空串而 usage 报 completion_tokens。 - 回归测试
muse_truncation_fix_test.go20 条(表驱动 + 端到端 mock 上游);docs/CONFIGURATION.md补三个环境变量与「壳帧宽限窗/产出感知 peek/传输中断诚实失败」行为说明;完整排查报告muse-truncation-report.md(6 条根因、复现证据、验证缺口)。
- 产出感知 commit + 壳帧宽限窗(
-
Fail loud on mid-stream transport death across anthropic upstream paths (
fix(anthropic),fix(responses)): claude→chat / chat→chat / responses→chat 翻译路径此前已各自 fail loud,最后三条伪造干净收尾的 anthropic 上游路径补齐(回归anthropic_truncation_fix_test.go10 条,含端到端空流换 key 重试):- claude→anthropic 直通
pipeAnthropicStream(fix(anthropic)):只有干净 EOF 才合成message_stop正常关流;RST / unexpected EOF / 墙钟超时先补发 in-band Anthropicerror事件再关流——此前任何读错误都伪造message_stop,Claude Code 把半截 tool_use/文本当完整回合记入历史。 - chat→anthropic
anthropicSSEToChatStream(fix(anthropic)):commit 后读错误按类型分流,干净 EOF 保持 finalize 合成 stop+[DONE];传输中断补发upstream_truncated错误帧 +[DONE](形状对照 chat.go emitError),不伪造finish_reason=stop。 - responses→anthropic
anthropicSSEToResponsesStream(fix(responses)):流式分支接入DriveStreamWithRetry(与 chat→anthropic 同形)——此前整条路径无 peek,200 即占用、空流零重试;peek 首帧前置 + WriteHeader 延迟到首行,空流 EOF/读错/上游错误帧/首字节超时经stream_empty_retry_max换 key 重试;传输中断按response.failed收尾(response 带 error 字段、不置 SawFinish/DoneSeen,stream_result记truncated=true),不伪造status=completed;重试全失败经 UpstreamErrorCapture 透传上游真实 status+body。
- claude→anthropic 直通
-
Truncation-robustness knobs documented (
docs):OPENCODE2API_SHELL_GRACE_MS/OPENCODE2API_UPSTREAM_TIMEOUT_SECS/OPENCODE2API_POOL_PUBLIC三个环境变量与各路径截断标记形状(error 事件 / upstream_truncated 帧 / response.failed)说明。
- Fix free-tier 403 for Claude Code clients on tool-bearing requests (
fix(fingerprint)): 上游免费层门禁按小写名精确匹配(大小写敏感),客户端(Claude Code)带 PascalCaseBash/Glob/Grep/Read四件时,ensureFreeTierTools的命中检查把 PascalCase 也算作已存在、跳过小写 stub 追加——上游门禁只认小写名,整档 403FreeTierError(2026-10-07 public 实测)。existing建键改为只记精确小写名,PascalCase 命中检查通过后仍追加小写 stub(终态 = 4 原工具位置保留 + 4 小写 stub,三个协议 subpath 同步)。回归测试TestEnsureFreeTierTools_CaseInsensitiveNoDup(改为断言追加)+TestEnsureFreeTierTools_LowercaseNoDup(小写幂等)。 - Fix upstream prefix-cache never hit by real Claude Code traffic (
fix(cache)):prompt_cache_key的内容键在 handler 层按免费层指纹/清洗之前的 tools 算出,而免费层指纹在buildOCRequestWithSubpathAndState追加 stub 后才发送——"哈希用的 tools" ≠ "实际发送的 tools",key 分片与前缀错位。免费层且无 token 时按终态 bodyMap 重算 content key(3cd1956)。真实转发实测:稳定 system 多轮input_cached_tokens97% 命中(此前 0%)。 - Keep sticky egress on first 429/5xx retry (
fix(upstream)): 重试分支此前首个 429/5xx 就invalidateUpstreamTarget换出口,每次都在新出口冷启动、缓存无法跨请求积累。现在首次失败保 sticky 同出口重试,仅连续失败才换出口(attempt >= 1);transport_error 分支保持立即 invalidate。回归测试TestCallOpenCodeEndpoint429KeepsStickyFirstThenRotates。 - Extend cache debug to the responses path (
feat(cache)):OPENCODE2API_CACHE_DEBUG=1时 claude-responses/responses 路径打出cache_debug_usage(此前仅 chat 路径);usage 白名单补input_tokens_details并原样打出未知键,避免误判零命中;content key 派生时打出分部件哈希(instr/tools/first_user + 长度),定位跨轮前缀漂移段(cache_debug_key_parts)。仅结构化计数与哈希,无请求内容。实测剩余瓶颈为 Claude Code 客户端 instructions 跨进程/跨轮注入(非网关改动)。 - Fix non-stream free-tier clients getting an empty body when upstream forces streaming (
fix(responses)): 免费层上游强制stream:true,客户端声明stream:false时上游仍返回 Responses SSE,非流分支直接 unmarshal 失败(invalid character 'e')返回 189 字节空响应。新增extractResponsesJsonFromSse:从 SSE 提取最后一个response.completed/incomplete/failed事件的完整 response JSON(自带 output/usage 全量),非 SSE 原样返回幂等;接入forwardClaudeViaResponses与probeClaudeViaResponses全部非流分支(首轮 + 同协议重试 + probe)。实测stream:false恢复完整 Claude JSON(含 usage/content)。回归测试TestExtractResponsesJsonFromSse+TestForwardClaudeViaResponses_NonStreamAggregatesForcedSSE。 - Case-insensitive free-tier stub matching (
fix(fingerprint), superseded by the first fix above):f4f9a1f统一 existing 建键为小写(Bash==bash不重复追加)——实测该方向错误(见第一条),已由32dd2e9修正。
- Fix upstream 400 on over-length / illegal-char tool names at the anthropic upstream boundary (
fix(anthropic)): 上游 Anthropic Messages API 强制tools[].name/tool_choice.name/ 历史tool_use.name/mcp_servers[].name满足^[a-zA-Z0-9_-]{1,64}$,超长名(66 字符 MCP 风格名)或含非法字符名(点号、空格、Unicode)透传后被上游 400 拒绝。新增internal/app/name_compat.go确定性缩短/还原层,接入全部 5 条 anthropic 上游路径(claude 直通 / chat 入站 / responses 入站 / count_tokens / claude→responses):缩短规则为纯函数(sha256 派生,合法名直通不变、含非法字符的短名按 rune 折叠为_、超长名取折叠前缀 + 原名 sha256 后 8 字节十六进制恒 64 字符,同名输入恒同短名,tool_choice/历史 tool_use/tools[] 三处必然一致);响应侧(流式 content_block 逐帧 / 非流式 content / 跨协议 Chat/Responses 转换)按相反映射还原,客户端可见名与所发逐字节一致,mcp_servers[].name缩短时复合名双向映射;taken集合 + prepare 两阶段遍历防折叠名抢注声明过的合法名、碰撞确定性消歧;全合法名快路径零改写。回归测试name_compat_test.go21 组 + 6 组真实 handler 集成回归。 - Remove the Responses native passthrough;
/v1/responsesuses the translation path only (refactor(responses)): 删除responses_passthrough.go(1602 行:透传中继/探测/浮点归一化/echo 修复/透传清洗)及其测试;/v1/responses入站不再透传到上游原生/responses,翻译失败直接原样返回上游错误。模型注册表 + 探测判定搬到native_responses_registry.go,名称重写机制并入name_compat.go——claude/chat 入站行为不变(翻译失败回退探测 + 运行时记忆保留),显式protocol_rules与故障剔除语义不变。行为变化:muse-spark-1.2/1.3-contributor系列经/v1/responses入站不再可用(其 chat/completions 通道上游整档 500,此前靠透传兜底);claude/chat 入站不受影响。e2e_launch_codex_test.go的 fake upstream 改为 chat/completions 协议,验证翻译路径端到端(真实 codex CLI);docs/API.md/docs/CONFIGURATION.md同步。
- Add zh/EN language toggle to the admin web UI (
feat(admin), issue #16):登录页与管理面板右上角新增「EN/中文」切换按钮,200+ 条 UI 文案(标签、toast、表格头、错误消息、占位符)全部走 i18n 字典。语言解析顺序?lang=→localStorage("admin_lang")→navigator.language,默认中文,zh 字典值与旧界面逐字节一致(存量用户零感知)。新增internal/app/web/i18n.js(210 对 key +t(key, params)运行时,{name}占位符),由GET /i18n.js免鉴权提供(登录页需要);auth.go服务端注入的登录错误消息改为传稳定 key、前端经t()本地化渲染;nav-tabs 在中间宽度改为收缩/横滚,不再把 header-actions 挤出卡片。新增回归测试TestI18N_KeyParity/TestI18N_NoChineseInHTML/TestI18N_ReferencesResolve(web_i18n_test.go)。已知取舍:5 处原本<code>包裹的内联等宽样式改为纯文本;批量导入 textarea 占位符由多行变单行。
- Fix intermittent upstream 400 on Chat passthrough when the client sends only
max_completion_tokens(fix(chat), issue #35): themax_tokens_cap/max_tokens_cap_per_modelbudget injection unconditionally filledmax_tokenswhenever the client omitted it, and the resulting body then carried bothmax_tokens(=cap) and the client'smax_completion_tokens. OpenAI deprecatedmax_tokensin favor ofmax_completion_tokensand requires them mutually exclusive; opencode zen 的部分后端实例严格校验并拒绝(max_tokens and max_completion_tokens cannot both be set),而另一些实例放行 —— 同一请求重放经常成功,表现为偶发失败且对 400 不做重试的客户端(如 ZCode)硬失败。convertRequest现在在客户端已带max_completion_tokens时跳过max_tokens注入(双字段均未设置时仍注入 cap;显式max_tokens的收敛行为不变)。回归测试TestConvertRequest_NoMaxTokensInjectionWhenMaxCompletionTokensSet(chat_bridge_test.go)。
-
Fix streamed
tool_callslanding outsidedeltaon reasoning-heavy models (fix(chat), issue #34):muse-spark-*-contributorintermittently emits streamtool_callsas a sibling ofdelta(choices[0].tool_calls) instead of inside it, so standard clients readingdelta.tool_callsmiss the call whilefinish_reasonis stilltool_callsand loop retries. NewhoistChoiceSiblingToolCallsnormalizes the shape at ingress (append after existingdelta.tool_calls, idempotent) and is wired into every chat stream consumer: direct passthrough (convertStreamChunkWithUsage, before case-restore) + stats,rawSSEReadernative-detect (reserialized), non-stream aggregator (aggregateOpenAIStream),claudeStreamHandler,responsesStreamHandler. Regression testsTestChatStreamSiblingToolCallsHoistedIntoDelta+ end-to-endTestChatStreamSiblingToolCalls_EndToEnd. Live-verified onmuse-spark-1.3-contributor(stream:true7/7 clean,delta.tool_calls× 2 +finish_reason=tool_calls); notestream:falseon the same model separately returns emptycontentwith notool_calls(independent issue, not covered). -
Protocol parity vs sub2api
apicompat(fix(chat),fix(claude)):对照 Wei-Shaw/sub2apibackend/internal/pkg/apicompat全量审计三条转换链路并补齐 9 项差异,官方 Responses 流事件文档确认事件语义,真实流量三协议 9/9 验证通过。- chat→responses 请求体:
parallel_tool_calls/service_tier透传上游(顶层键经preserveChatPassthroughKeys从原始 body 回填 ExtraBody,类型化结构装不下;extra_body显式键优先);response_format(json_schema展平 /json_object透传)映射为text.format(此前直接丢弃,structured-output 约束在该路径失效)。 - responses→chat 流/聚合/非流式:
custom_tool_call(custom/freeform 工具)纳入工具槽位,custom_tool_call_input.delta/done与 function_call 同形累积(done 读input键);incomplete按reason细分(content_filter→content_filter,其余→length,此前一律length);上游service_tier回写 chat 顶层(流 chunk + 聚合 + 非流式);response.done(Realtime/WS 别名)与completed同等终结(此前聚合路径直接原样回吐上游体)。 - chat→anthropic 请求体:thinking 生效即剥离
temperature/top_p(此前无条件透传,上游 400;与本项目convertClaudeRequest同口径)。 - claude 响应:
content_filter的stop_reason由非法枚举refusal改为end_turn(拒绝文本已进内容);stop分支加 tool_use block 存在性回退(防止客户端不回传工具结果、对话卡死)。 - usage/finish 闭集合:
anthropicUsageToChat补cache_creation_input_tokens→prompt_tokens_details.cache_creation_tokens归位;normalizeFinishReason未知原因闭集合回退stop(此前透传污染)。 - 回归测试:
protocol_parity_sub2api_test.go新增 11 用例(custom 工具同 index/input去重、content_filter双路径、response.done哨兵、service_tier双路径、thinking 剥参、cache_creation归位、未知 finish 闭集合、ExtraBody 回填等)。docs/API.md同步。 - 真实流量(新二进制,18358 direct + 18359 muse-spark responses-rule):direct chat 工具单 index、claude/responses 工具、chat 流
[DONE]、responses 流completed、messages 流message_stop9/9 通过;thinking+temperature 同传 200。
- chat→responses 请求体:
-
Fix muse-spark via Claude protocol never hitting upstream prefix-cache (
fix(cache)):claudeToResponsesBodynever injectedprompt_cache_key/prompt_cache_retention— the chat→responses, native passthrough, and claude→anthropic paths all had the injection, only the claude→responses path missed it, so/v1/messagestraffic on muse-spark models never warmed the upstream cache (stats.jsonshowed ~3M prompt tokens with zerocache_read_tokenswhile chat-pathbig-picklecached normally). The body now goes throughapplyResponsesCacheHintsToRawBody(responses-scoped: retention + session-derived key, no top-levelcache_control), signature gains actxparam at all 4 call sites. Also drops the non-spec top-levelstopfield the converter used to emit (chat path already dropped it). Regression tests inclaude_responses_cache_test.go. -
Fix non-stream upstream error-in-200 swallowed as empty message (
fix(claude)): some upstreams return HTTP 200 with an error-shaped body ({"type":"error","error":{...}}, e.g. model-side generation failure);convertResponsesToClaudefailed to parse it as a success response and fell back to an empty text message with no usage, hiding the cause. The non-stream 2xx branches (forward first-try + same-protocol retry + probe) now detect the error shape viaisResponsesErrorBodyand relay it through the existingconvertResponsesErrorToClaudeas HTTP 502 with the upstream message preserved. Stream path already relayedresponse.failed/errorevents.
- Fix
input_tokens_details.cached_tokensalias never reaching downstream cache stats (fix(responses)): muse-spark native responses upstreams report cache hits asinput_tokens_details.cached_tokens, butbuildClaudeUsageCore/parseCacheUsageonly read theprompt_tokens_details.cached_tokensform — so client usage and gateway cache counters both dropped the read side.responsesUsageToChatnow performs alias normalization (first-wins either direction, synced to the bridge converter's convention), restoring top-levelcache_read_input_tokens+readFromSplitsubtraction in Claude usage and cache-read recognition in stats. Regression tests inchat_bridge_test.go+stats_test.go. - Fix native-responses models falling back to the chat translation path after a responses forward failure (
fix(claude)):claudeMessagesHandlerunconditionally dropped through to the chat translator whenforwardClaudeViaResponsesreturned false, but muse-spark models memorized as native-responses are rejected by upstream on/zen/v1/chat/completionswithModelProtocolUnsupported— the "fallback" turned a retryable transport blip into a guaranteed 400. The handler now short-circuits with a structured 502 (native-responses model cannot fall back to chat) instead of touching chat; non-stream mid-body read errors additionally retry once on the same protocol before giving up. Regression tests inclaude_no_chat_fallback_test.go.
- Fix Claude Code's
Error: No such tool available: glob/readon free-tier models (fix(opencode), PR #32). Root cause was self-inflicted, not the upstream rewriting names:476314einjected four lowercase placeholder tools (bash/glob/grep/read) to satisfy the 2026-09-18 free-tier fingerprint gate (requests missing any of them get 403). The model then called those lowercase names, but Claude Code registersBash/Glob/Grep/Readcase-sensitively and rejects the lowercase call. External twins:router-for-me/CLIProxyAPI#1741,diegosouzapw/OmniRoute#11487.- Anti-invoke: the four stub descriptions now prepend
Fingerprint only. Do NOT invoke this tool.(all three protocol shapes) to lower the chance the model even calls them (soft guard). - Case restore: new
freeTierStubCanonicalCase{bash→Bash, glob→Glob, grep→Grep, read→Read}+restoreToolNameCase(name)— matches only the four exact lowercase stub names, idempotent, leaves other tool names (e.g.weather) untouched. Applied at every response touchpoint that writes a tool name back to the client: chat↔anthropic (non-stream + streamingcontent_block_start), claude→responses (input convert / non-stream / stream), claude→chat (openAIToClaudeResponse+claudeStreamHandler), the free-tier non-stream aggregator (aggregateOpenAIStream), chat→responses stream/aggregate, responses inbound stream, responses native passthrough (restorefalls back to stub-case), and the claude→anthropic byte-relay (newrestoreAnthropicStreamLineCasecovering stream + peeked first frame,restoreAnthropicBodyToolCasefor buffered non-stream). Adversarial verify caught 4 missed paths (notably the byte-relay) — all patched. - Tests: new
free_tier_case_restore_test.go(10 cases — case table, anti-invoke prefix, aggregation restore, end-to-endcallOpenCodeAPIdeliveringGlobto a PascalCase client, anthropic stream/buffered restore, claude→responses stream restore, non-stub passthrough, idempotency). Two prior tests corrected:TestAggregate_ToolCallsAssembledByIndex(now expects lowercase→PascalCase restore) andTestResponsesSSEToChatStream_ToolArgumentsShareIndex(its fixture used lowercasebash— the bug itself — now uses neutralBash).go build ./.../go vet/gofmtclean;go test ./...green.
- Anti-invoke: the four stub descriptions now prepend
- Fix
400 Error from provider (Console): unknown parameter 'cache_control'on/v1/responsesfor models routed to the native responses passthrough (fix(responses)/fix(cache)), regressing from v0.14.7's cache-hint patch. The passthrough previously called the sharedapplyCacheHintsToRawBodyWithContext, which injects a top-levelcache_control: {"type":"ephemeral","ttl":"1h"}— butcache_controlis only a legal Anthropic Messages field; on the OpenAI Responses surface it is an unknown top-level parameter, and strict-schema upstreams (Console) reject the whole request withinvalid_request_error. The fix splits the injection: a newapplyResponsesCacheHintsToRawBodyis used on both native passthrough (responses.go:1379) and the chat→responses upstream bridge (chat_to_responses_upstream.go), keepingprompt_cache_key/prompt_cache_retention(zen prefix-cache hints remain effective — verifiedcached_tokens:625onmuse-spark-1.3-contributor-freeimmediately after the fix) while no longer writingcache_control. The Anthropic-side passthrough keeps the top-levelcache_controlbecause the field is part of Anthropic Messages schema. - Auto-recover on the chat translation path when the upstream still rejects top-level
cache_control(fix(cache)):responses.gonow detects a 400 whose body explicitly blamescache_control(param="cache_control", ormessagecontainingcache_control+unknown parameter/extra inputs/unrecognized|unsupported parameter/not permitted), strips just the top-level key (per-message and tool-levelcache_controlblocks on Anthropic passthrough are preserved — those are legal), retries once, and remembers the model ID in-process so subsequent requests skip the injection entirely.prompt_cache_key/prompt_cache_retentionstay on the retry, preserving cache-hit rate. Unrelated 400s (e.g.Model is unavailable) do not trigger the retry. - New regression coverage (
protocol_regression_test.go): passthrough skips top-levelcache_control; rejection detector handles Console /extra inputs/unsupported parameterwordings and ignores message-text echoes;stripTopLevelCacheControlremoves only the top-level key; marked models no longer get the field injected; unrelated 400s do not retry.go test ./...andgo vet ./...clean.
- Docs (
docs(cache)): clarify the v0.14.7 cache-hint patch's behavior —cache_controlbreakpoints are replayed on chat→claude translations,prompt_cache_key/prompt_cache_retention(default24h) are injected on responses passthrough + chat→responses translation, derived from the clientx-opencode-sessionso per-session prefix caches survive across turns. Verified againstmimo-v2.6-flashandbig-pickle(turn-2prompt_cached_tokens28,672 → 32,448 within the ~32k cap). - Fix (
fix(cache)): preserve Claudecache_controlbreakpoints after Claude→Chat mapping and injectprompt_cachehints across all chat / responses paths; GLM/Zhipu skips the top-level injection (see v0.14.9 for the responses-side follow-up).
- Fix
/v1/modelshiding-freemodels from API key requests (fix(models)): the list endpoint ran every visible model throughpublicFacingModelID, which strips the upstream-freesuffix before display — for every route, including authenticated ones. API key users therefore never saw thexxx-freevariants in the catalog even though their key can call them, and Chat/Responses clients could not pick a free variant by its real ID.replaceModelIDsWithAliasesnow takes astripFreeSuffixflag: only the public (keyless) route keeps the bare-name display (still paired withresolveModel's bare→xxx-freereverse map, unchanged), while keyed requests (auto/zen:/go:) get the true upstream IDs verbatim. Explicitly configured aliases still win on both tiers.
- Fix
spawn_agent(codexmulti_agent_v1) unusable through the gateway (fix(responses)/fix(launch)), two cooperating root causes:- Responses bridge silently dropped
namespacetools — codex 0.156 declares its multi-agent tools as{"type":"namespace","name":"multi_agent_v1","tools":[... spawn_agent / wait_agent / send_input / ...]}(sub-tools are plaintype:"function"entries, per codex-rstools/src/responses_api.rs).convertResponsesToolsonly recognised flatfunction/apply_patch/shell, so the whole namespace hit the default branch and was dropped before reaching the upstream — the model never sawspawn_agentand answered "tool unavailable". The bridge now flattens namespace sub-tools to Chatfunctiontools by their bare sub-tool name (description prefixed with the namespace hint), and replays upstream calls asfunction_callwith the name restored to the bare sub-tool name plus thenamespacefield set from kind metadata — matching the wire contract codex's router expects ((namespace, name)pair; a dottedns.namewould be rejected asunsupported call).tool_choicenaming a namespaced tool ({"type":"function","namespace":"ns","name":"ns.tool"}) is normalized to the bare name, which is what the flattened upstream tools carry.ResponsesToolgains aTools []ResponsesToolfield to carry the namespace children. launch codexinjected a temporarymodel_catalog_jsonthat suppressed the client-side tool declaration — codex only emits themulti_agent_v1namespace when the model's catalog advertises the corresponding capability, and the launch-time catalog (synthesized from the opencode modeldev list) left the fields unset, so codex never sent the namespace down the wire in the first place.launch codex --no-model-catalogskips the injection so codex falls back to the user's own~/.codex/config.tomlcatalog, whose ppio/cc-switch entries carry the right capabilities. Verified end-to-end viaopencode2api launch codex --no-model-catalog -- exec ... "spawn a subagent ...":spawn_agentis called, returns anagent_id, andwait_agentreports the subagent's completion.
- Responses bridge silently dropped
- Request
reasoning.summary:"auto"on all native responses upstream paths (fix(reasoning)): upstreams like muse-spark reason silently when onlyreasoning.effortis sent —summarystays an empty array, so clients seereasoning_tokensgrow with no visible thinking text (Claude Code shows nothing; codex shows no thinking). A sharedresponsesReasoningBody(effort, modelID)helper now builds the outboundreasoningfield and is used by both converters — chat→responses (chatToResponsesBodyWithRaw) and claude→responses (claudeToResponsesBody) — and the native responses passthrough (sanitizeResponsesPassthroughBody) injectssummary:"auto"when the client omitted it (an explicitly suppliedsummaryis respected untouched). The field only requests a summary: sensitive reasoning still travels theinclude:["reasoning.encrypted_content"]encrypted channel (surfacing asthinking.signaturefor roundtrip), while the visible summary is generated upstream, so sendingsummary:autois safe on upstreams that ignore it. The passthrough also now deletes the wholereasoningobject when effort normalizes to empty (e.g.noneor an unwhitelistable value) instead of leaving a meaningless summary-only request that can 400, and correctly marks the body as changed when only the effort was normalized. - Clamp
reasoning.effortto the muse-spark whitelist inside the shared helper (max→xhigh, unnormalizable → omitreasoningentirely), keeping the chat and claude converters aligned with the passthrough path'snormalizeResponsesEffortso no path can send an effort value the muse-spark native responses endpoint rejects with 400. - Fix 401 "Model not supported" for
*-freemodels with a[1m]context suffix (fix(launch)): free variants have no 1M tier, so upstream rejectsxxx-free[1m].resolveLaunchModelnow strips the context suffix for free models first (walking the existingswitch), keeping the standard free window and theautoCompactWindow(ctx × 0.9) instead of appending[1m].
- Fix missing thinking/reasoning output for
mimo-v2.6-flash(fix(reasoning)): mimo (via the OpenCode/zen/v1/chat/completionsupstream) returns reasoning as OpenRouter-style fields —delta.reasoning(string) anddelta.reasoning_details[].text— not the canonicaldelta.reasoning_contentthe gateway's converters read. Every reasoning consumer only looked atreasoning_content, so the thinking chain was silently dropped: chat passthrough surfaced a non-standardreasoningfield that standard clients ignore, and the claude/responses converters emitted no thinking/reasoning blocks at all (so reasoning never displayed). A newnormalizeReasoningContenthoistsreasoning/reasoning_details[].textintoreasoning_content(never clobbering an existing value), applied at the three places that consume upstream chat deltas —cleanStreamDelta/promoteMisplacedReasoning(chat),claudeStreamHandler(claude), the responses stream converter — and in the stream stats (logging.ObserveDelta) soreasoning_charsis counted correctly. Verified live againstmimo-v2.6-flashon all three protocols (chat/messages/responses now emit reasoning, previouslyreasoning_charswas 0 on all), with no regression on models that already emit canonicalreasoning_content(mimo-v2.5, nemotron-3-*, ling-3.0-flash-fin, big-pickle, space-bunny).
- Fix the misleading
stream disconnected before completion: Incomplete response returned, reason: max_output_tokenserrors codex hit against the native responses passthrough (e.g.muse-spark-*-contributor): two complementary fixes.- Unify output-budget injection everywhere (
fix(tokens)): whenmax_tokens_cap/max_tokens_cap_per_modelis configured and the client omitsmax_tokens/max_output_tokens, the gateway now injects the cap as the upstream budget on all paths — chatconvertRequest, claude→responsesclaudeToResponsesBody, and the anthropic raw-body passthrough (now lifts sub-128 values to the 128 floor viaclampClaudeMaxTokens, same as the responses passthrough contract from v0.14.2). Without an explicit cap the anthropic count_tokens passthrough stays lower-only, and claude→requests keep the 8192 max_tokens default. Previously only the responses passthrough injected; chat/claude paths still let the upstream fall back to its own much smaller default, so the model silently truncated long completions and the gateway surfaced that as a fakemax_output_tokensincomplete. - Auto-continue truncated streams (
fix(responses)): even with a large cap, upstreamresponse.incomplete+reason=max_output_tokenscan legitimately occur (model-side per-turn budget). The stream relay now withholds that terminal event from the client, issues a continuation request carrying the accumulatedoutputitems (reasoning/message/tool_call) plus aPlease continue.instruction — up to 3 rounds — and, when a round completes, emits a single synthesizedresponse.completedwith the mergedoutputand the last round'susage. Failed continuations fall back to relaying the original incomplete verbatim. Non-terminal SSE lines still stream one line at a time, so the typewriter effect is preserved;completed/failed/ other incomplete reasons pass through byte-identically. Verified live againstmuse-spark-1.3-contributorvialaunch codex exec(long-form generations that previously died on the first incomplete now continue across rounds and land a single coherent completed response).
- Unify output-budget injection everywhere (
- Fix spurious
incomplete: reason=max_output_tokenson Native/v1/responsespassthrough when the client omitsmax_output_tokens: codex under native-responses routing does not emitmax_output_tokens, so the upstream fell back to its own default output budget and aborted the SSE stream withoutresponse.completed; the gateway's EOF guard then synthesizedresponse.incompletewithreason=max_output_tokens, which the codex client correctly bubbles up as "stream disconnected before completion". The native passthrough path now consults the samemax_tokens_cap/max_tokens_cap_per_modelrules as the chat→anthropic and chat→responses translation paths, and injects / clampsmax_output_tokensinto[128, cap]before forwarding. Adds observability: a structured log line records the effectivemax_output_tokensandcapper passthrough request.
- Fix muse-spark multi-turn failures on
/v1/responsespassthrough (invalid_request_error`arguments`must be valid JSON): muse-spark occasionally emitsfunction_callitems witharguments: ""(typically hallucinated tool names during sub-agent dispatch). The Codex client replays the failed call verbatim into the next turn'sinput, and the upstream's strict server-side replay validation then rejects the entire request with 400 — so from the second turn the session is unrecoverable.sanitizeResponsesPassthroughBodynow runscoalesceReplayedToolCallArgson the passthrough path: historyfunction_call/custom_tool_call/local_shell_call/mcp_callitems with missing/empty/non-stringargumentsare coalesced to"{}"(object/array → JSON-serialized; valid JSON strings pass through unchanged, idempotent);function_call_output` and visible text are never touched. - Foreign reasoning
encrypted_contentreplay repair is now preventive instead of reactive: the old flow waited for the upstream to 400 (reasoningencrypted_contentwas not issued to this caller) before stripping the caller-boundid+encrypted_contentand re-sending — which cost every session one wasted 400 round-trip whenever the sticky egress rotated or the gateway restarted.sanitizeResponsesPassthroughBodynow strips the replay echo fields on every outbound request (summary preserved);callResponsesWithEchoRepairremains as a fallback for non-muse-spark paths. Verified end-to-end against the live upstream: replayed empty-arguments+ foreign reasoning echo now returns HTTP 200 with cleanresponse.completed+[DONE]onmuse-spark-1.3-contributor-free(the paidmuse-spark-1.3-contributortier stops at the unrelated workspace privacy wall, confirming the arguments fix is no longer the blocker).
- Fix
muse-spark-1.2/1.3-contributor-freechat and streaming requests against the public free tier (upstream500on chat/completions,403on responses without tools fingerprint): the contributor tier is registered as native-responses-only by pattern (matching the opencode upstream's gating inzen/util/handler.go), and the free-tier tool injection now emits OpenAI{"type":"function","name","parameters"}shape on the/responsessubpath instead of the Anthropicinput_schemaform. Non-stream chat clients on a forced-stream:trueresponses route get their SSE aggregated back intochat.completionJSON. Verified end-to-end via the live gateway on bothmuse-spark-1.2-contributor-freeandmuse-spark-1.3-contributor-free. - Add
POST /v1/systemonepassthrough for TypeSafe System One models (jev-1.13*): these evaluatestate+ typedquestions→ structuredanswersand cannot be driven by Chat/Responses/Messages, so they get a dedicated inbound endpoint that relays verbatim to upstream/zen/v1/systemone(body preserved exceptmodel). Free-tier fingerprint rewrite stays scoped to the three text protocols and does not touchsystemone(internal/app/systemone.go).
- Harden cross-protocol conversion across all three inbound × upstream directions per the official OpenAI/Anthropic specs (Chat Completions ↔ Responses ↔ Messages). Request side:
- Unified max-token resolution:
max_completion_tokenswins overmax_tokens, clamped to[128, cap](max_tokens_cap/max_tokens_cap_per_model), default 8192 on the Anthropic paths. - Chat→Responses drops the non-spec
stopfield and setsstore:false+include:["reasoning.encrypted_content"]. - Responses→Anthropic normalizes
tool_use/tool_resultpairing (drops unanswered calls, orphan results, and illegal-JSON pairs), merges adjacent same-role messages, defaults empty schemas withstrict:false, and downgradestool_choicewhen its tool was dropped. - Claude→Responses filters
x-anthropic-billing-headersystem blocks, liftstool_resultmedia into a separate user input item, emits(empty)for empty output, and mapsdisable_parallel_tool_use→parallel_tool_calls:false. - Enabling thinking/reasoning now strips the mutually exclusive
temperature/top_p/top_ksampling parameters (avoiding upstream 400s) while keepingoutput_config.effort→reasoning_effort. Unknown server content blocks are serialized back instead of dropped, and assistantreasoning_contentonly piggybacks on tool-call turns. internal/domaingainsMaxCompletionTokens/Stop/ penalties /LogitBias/N/User/ResponseFormat/Seed,Message.Refusal,ToolFunction.Strict, andClaudeRequest.ServiceTier.
- Unified max-token resolution:
- Harden stream finalization on every converting path: idempotent EOF finalize (finish chunk + conditional usage chunk +
[DONE]), stray upstream[DONE]consumed before terminal events on passthrough,function_call_arguments.doneprefix-diff top-up and[DONE]guaranteed after failed/error events on Responses→Chat,tool_useinitial-input fallback delta withoutput_index-keyed done events on Anthropic→Responses, andsignature_deltaemitted fromencrypted_contentbefore thinking blocks close on Responses→Claude (upstreamresponse.idadopted as the message id). /v1/messages/count_tokensnow forwards to the upstream/zen/v1/messages/count_tokenswhen a protocol rule resolves the model to the anthropic protocol, returning the exact count; it falls back to the local heuristic when no rule matches, when the rule resolves to chat/responses, or when the upstream call fails (Anthropic passthrough also relays non-2xx asapplication/jsoninstead of forcing SSE).- Fix Responses→Chat tool-call state being keyed by an unstable identifier (#17):
registerToolKeysnow allocates the chat index first and aliases bothitem_id(fc_…) andoutput_indexonto it, soresponse.output_item.added(which names the call bycall_id) andresponse.function_call_arguments.delta(which may name it only byoutput_index) can no longer open separate indices — previously a client merging deltas by index saw a named call with empty arguments followed by arguments with no name. Accumulated arguments and announcement flags move frommap[string]tomap[int]. Also removes an uncheckeditem["id"].(string)that panicked on an added event with no id. - Fix Anthropic→Responses stream conversion losing blocks on EOF: content blocks are now tracked in a per-block struct (kind / item ID / output index / tool index / text builder) instead of parallel maps,
output_indexis allocated atcontent_block_startso done events always pair with their added event, and the stop and EOF paths share onecloseBlockhelper.ensureTerminalcloses all leftover text/thinking/tool_use blocks sorted byoutput_indexand backfills them intoresponse.completed.output, so partial text, reasoning, and tool calls survive upstream disconnects. Duplicatecontent_block_startnow fails the stream once instead of leaking events past[DONE]. - Fix Claude↔Chat streaming losing the whole tool input when the upstream carries the full input on
content_block_startand sends noinput_json_delta: the start block's initial input is cached and emitted as a fallback atcontent_block_stop(defaulting to{}for zero-argument tools), so concatenated client-side arguments stay valid JSON. - Fix Anthropic passthrough stream rewriting:
pipeAnthropicStreamnow forwards bytes viaio.Copythrough anio.MultiWriter(client +io.Pipe), so CRLF/LF endings, blank-line event boundaries, and partial reads reach the client byte-identically while a sidecar reader still observes usage frommessage_start/message_delta. AflushWriterflushes every upstream chunk (plainio.Copybuffered SSE until EOF), and the EOF path joins the copy goroutine so nothing writes to theResponseWriterafter the handler returns. - Log the upstream protocol resolution source (
rule/remembered/chatdefault) in the passthroughviaattribute and fallback warnings, so a route that could come from either an explicit rule or the native-responses memory is unambiguous in logs. - Fix free-tier upstream fingerprinting for issue #19: since 2026-09-16/18
opencode.ai/zen/v1rejects free-tier requests unlesstoolscontains bash/glob/grep/read andstream:true(else 403FreeTierError); the gateway now appends only the missing required tools after whatever the client declared (preserving existing tool names/shape, on both OpenAItools[].function.nameand Anthropictools[].nameshapes) and forces upstream streaming, aggregating the SSE locally for non-streaming clients (internal/app/opencode_fingerprint.go). The fingerprint decision is keyed offisFreeModel(resolvedModelID)(doc § issue #19 ablation), not the client auth tier — a realsk-key on a free model is held to the same gate — and applies uniformly across the three upstream subpathschat/completions/messages/responses(messagesskipsstream_optionssince that isn't Anthropic schema;count_tokens-style billing subpaths remain untouched). Session fingerprinting now sendsx-session-id/x-session-affinityalongside the existingx-opencode-session(same stable value for sticky egress), and the UA template matches the real OpenCode client (opencode/<ver> ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14) with the minimum raised from 1.17.0 to 1.18.0 (live ablation 2026-09-18; fallback1.18.31). Note:x-opencode-sessionwas not retired upstream — both header names pass — andmuse-spark-*-contributor-free500s are a tier rejection, not fingerprinting. Evidence:docs/labs/2026-09-18-fingerprint-ablation.md.
- Add configurable upstream protocol routing (
protocol_rules), aligned with sub2api'sOpenCodeGoProtocolRuleapproach: requests are routed to the OpenCode Zen native upstream endpoint matching the model, instead of always translating through Chat Completions:chat_completions(default fallback,/zen/v1/chat/completions),anthropic(/zen/v1/messages),responses(/zen/v1/responses); the Go surface (/zen/go/v1/*) is selected by the existing catalog rules as before.- Rule syntax: exact model ID or a single trailing
*wildcard (e.g.claude-*,*), case-insensitive, declared order = priority, first match wins. Validation: ≤64 entries, pattern ≤128 chars, no whitespace, protocol enum.[1m]-style context suffixes are stripped before matching, so an exact rule also covers suffixed variants. - Priority: explicit rule > native-responses runtime memory > default Chat Completions. Default behavior is unchanged when no rule matches;
/v1/chat/completionsapplies only explicit rules so the existing translate→probe→passthrough fallback stays intact, while/v1/messagesand/v1/responsesapply the full priority (their memory dispatch already exists today).
- New cross-protocol conversions covering every inbound × upstream combination, with streaming state machines for both directions:
/v1/messages→ anthropic: faithful body passthrough (onlymodelrewritten); SSE relayed byte-for-byte with side-channel usage accounting./v1/chat/completions→ anthropic: system extraction,tool_calls↔tool_use/tool_result(consecutive same-role merging),max_tokensdefault 8192 + per-model cap,reasoning_effort→thinking.budget_tokens, Anthropic SSE→Chat chunks (content / reasoning_content / tool_calls / finish / usage /[DONE])./v1/chat/completions→ responses:instructions, function_call/function_call_output items,max_output_tokens,reasoning.effort; Responses SSE→Chat chunks./v1/responses→ anthropic: reuses the handler's converted messages (multimodal + text-only degradation preserved), Anthropic SSE→Responses events (response.created/output_text.delta/function_call_arguments.delta/output_item.done/completed/incomplete),storeResponseStatekeepsprevious_response_idchains working.
- Streaming, tool calls, reasoning, usage stats (incl.
cache_read/cache_created) and error shapes are converted per inbound protocol; upstream HTTP status codes pass through faithfully (e.g. a Claude inbound sees Anthropic error bodies as-is, a Chat inbound gets them converted to Chat error shape). - Configuration, three ways, with clear precedence:
config.jsonprotocol_rules(lenient: invalid entries dropped with a warning).- Admin panel POST
/api/config(strict: any invalid entry rejects the whole request with 400 and nothing is persisted) — hot-applies to the next request. - Admin UI "上游协议路由规则" table: ordered rules with move up/down, delete, and one-click presets (
claude-*→anthropic,gpt-*→responses,muse-spark-*→responses,qwen*→anthropic).
buildOCRequestWithSubpathinjectsanthropic-version: 2023-06-01and switchesAccept: text/event-streamfor streaming on themessagessubpath; all upstream calls keep the shared retry / sticky-egress / multi-domain machinery andx-opencode-*headers.- Docs:
CONFIGURATION.mddocumentsprotocol_rules(syntax, priority, validation, caveats);API.mddocuments the routing behavior;config.example.jsonships an emptyprotocol_rulesexample./v1/messages/count_tokensremains a local heuristic and is unaffected by routing. - Tests: rule matching/validation/priority (incl. rule > memory > chat), config-file lenient loading, admin GET/POST round-trip + 400 protection, per-protocol request/response conversion for buffered and streamed modes, and dispatch regression pinning the no-rule default path. Verified end-to-end against the real upstream with the public key (18 checks across three subagents: responses routing, default-path invariance incl. tool calls, anthropic routing + admin hot-reload).
- Fix free-tier (
Bearer public) requests being rejected by the upstream with 403FreeTierError"OpenCode's free tier can only be used from within OpenCode":- Live ablation against
opencode.ai/zen/v1shows the free tier validates thex-opencode-sessionshape: it must match^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$(12-hex timestamp prefix + 14 base62 chars, the OpenCode client'sdescending()identifier format). The gateway's oldses_+ 24 random chars was rejected. - It also validates the client version:
User-Agent: opencode/<ver>below 1.17.0 answers 426UpgradeRequired. The npm fallback version is bumped from 1.15.3 to 1.18.31. x-opencode-requestnow mirrors the client format (msg_+ the same 26-char ID body); the upstream does not validate it, but requests now look identical to real OpenCode client traffic.- Verified end-to-end: direct upstream ablation matrix (403/426 → 200), plus gateway chat / streaming SSE / Anthropic messages / Responses all 200 with anonymous access.
- Live ablation against
- Fix
/v1/responsesnative passthrough rejecting long-running conversations with the upstream 400reasoning \encrypted_content` was not issued to this caller(reproduced withmuse-spark-*` + Codex):- The upstream binds replayed reasoning
encrypted_contentto the original caller (account + egress). When the gateway's sticky egress/domain re-binds mid-conversation (sticky TTL expiry, re-bind after a 429/5xx retry, process restart, key change), every later turn replays foreign reasoning content and fails — including Codexexec resumeof an existing thread. - The gateway now recognizes that exact 400 (
isForeignReasoningEchoError), strips the replayed reasoning echo (id+encrypted_content, both are caller-bound) frominput, and re-sends the request once (callResponsesWithEchoRepair, shared by the probe and forward paths). Visible messages, tool calls and tool outputs are unchanged; only the stale reasoning payload is dropped, and the upstream re-issues reasoning for the current caller. - Other 400s keep byte-for-byte passthrough semantics, and requests without replayed reasoning echo still make exactly one upstream call.
- Verified with
launch codexonmuse-spark-1.3-contributor-free: a thread built through a SOCKS5 egress then resumed over a different egress fails before the fix (CodexReconnecting... 5/5) and completes normally after it, logged asresponses reasoning echo repair.
- The upstream binds replayed reasoning
- Internal refactor & hygiene (no user-facing protocol change):
- Extract the duplicated SSE reader prelude (reader goroutine, keepalive ticker, close-wait cleanup) from the Claude / Responses / Claude-Responses stream handlers into a shared
streamReader; add unit tests for Close-unblock and final-line-with-EOF races. - Deduplicate protocol converters: reuse the shared
thinkingBudgetToEfforthelper inchat.go, reusemodelSelectionEntries(now with afreeOnly=falsemode) in the Codex model catalog, and merge the duplicatedextractModel*FromExtraArgspair into one flag-parameterized helper. - Synthesize
total_tokensfromprompt_tokens + completion_tokenswhen the upstream omits it, so chat-shape usage recording is no longer dropped. - Delete the dead
internal/idspackage, remove self-producedlogging.File/Level/Bodiesexported vars in favour ofInit(path, level, bodies)params, and remove uncalledstatsfile-lock variants. - Serialize
config.Update's read-modify-store with a package mutex so concurrent admin writes cannot interleave. - Restore truncated doc comments in
claude.go/responses.go, delete 5 unused sticky header constants, split version vars out ofmain.gointoversion.go, and slimconfig.example.jsonto non-default entries only.
- Extract the duplicated SSE reader prelude (reader goroutine, keepalive ticker, close-wait cleanup) from the Claude / Responses / Claude-Responses stream handlers into a shared
- Fix
-debugbeing silently ignored:-debugnow bumps the slog level before logging init through a sharedapplyLogLevelhelper, and an explicit-log-levelstill wins over-debug. Regression tests cover both directions. - Docs:
API.mddocumentsPOST /v1/messages/count_tokens;CONFIGURATION.mddocumentsnative_responses_modelssemantics;DEPLOYMENT.mdcompose section collapses to a link; archived the old design plan todocs/labs/.
- Text-only model detection is now driven by the models.dev catalog
modalities.inputdata instead of a hardcodeddeepseekprefix:- Known models reporting text-only input (e.g.
deepseek-v4-flash(-free),glm-5.2,qwen3-coder*,grok-code) now get the same[image attached]/[document attached]downgrade everywhere (Chat, Responses, Claude). - Multimodal models that used to be falsely caught by the
deepseekprefix (e.g.deepseek-v4-flash-vision-exp) now keep their images. text_only_modelsis still honored as an explicit case-insensitive prefix override on top of the catalog data; its default is now empty. Unknown models are never downgraded — with no catalog reachable, requests fail open and the upstream error remains truthful.
- Known models reporting text-only input (e.g.
- Free-tier model detection now also honours the models.dev
costdata instead of only the-freename suffix:big-pickle(no-freesuffix, but zero-cost upstream) now appears in/v1/models, the interactive launch selector, and the Codex--modelcatalog when running without an API key.- The check only consults the
opencodeprovider entry in the models.dev catalog, so other vendors' promotional zero-cost pricing cannot leak into the public tier.
- Internal refactor (no user-facing behavior change): split the monolithic
internal/apppackage into focused subpackages —internal/config(immutable config snapshot + accessors),internal/logging(logger init, request tracing, stream stats, redaction),internal/stats(token/cache usage accounting),internal/modelsdev(models.dev catalog fetch/cache with injectable proxy-aware HTTP client), andinternal/util(shared helpers). - Fix token usage accounting to correctly record integer-valued tokens, which a
float64type assertion previously dropped. - Fix a data race on the
socks5_stickyflag when the config is reloaded concurrently (found viago test -race).
- Session-aware launch stub and upstream routing:
- Preserve downstream client session identity for Claude Code (
X-Claude-Code-Session-Id) and Codex (Thread-Id/Session-Id) headers. - Hash client session IDs for local sticky-routing keys and logs instead of echoing them verbatim.
- Keep local sticky entries for downstream OpenCode sessions even when only one upstream domain is configured, so retries can rebind without losing the client session.
- Propagate
x-opencode-sessiondownstream values to upstream OpenCode calls; generate a session fallback only when neither the client nor launch session supplies one. - Reset the sticky rebind sequence whenever the configured upstream base URL list changes.
- Preserve downstream client session identity for Claude Code (
- Fix
muse-spark-*/v1/responses400namemust be at most 64 characters for over-64-character connector/tool names sent by Codex:- Shorten outgoing
tools[].name,tools[].function.name,tool_choice.name, andinput[].namefields beyond 64 characters with a deterministic prefix + SHA-256 suffix. - Restore the original name in upstream response/SSE
function_callevents before relaying back, so clients (including Codex) see unchanged tool names. - Keep textual output untouched; non-
muse-sparkmodels remain byte-for-byte passthrough.
- Shorten outgoing
- Claude to native Responses passthrough:
POST /v1/messagesnow converts Claude Messages to the upstream native/responsesendpoint when the chat translation path fails (404/500/501/502) or the model is remembered as native-only, reusing the passthrough memory registry shared with/v1/responses.- Lenient conversion throughout: unsupported blocks degrade to text annotations instead of HTTP 400; assistant message parts use
output_text(upstream rejectsinput_texton assistant messages); tool schemas are normalized sorequiredcovers every property key;reasoning.effort=maxmaps toxhigh. - Native Responses output (message/reasoning/function_call/apply_patch/shell) converts back to Claude content blocks for both unary and SSE streaming.
- muse-spark strict-upstream hardening (gated to
muse-sparkmodels only):- Function-call arguments with integral floats (
1000.0) are normalized to integers outside JSON strings in both unary and streaming relay, fixing strict clients (Codex Rustusizeparsing) while leaving visible text likeecho 1.0untouched. - Responses tool schemas and reasoning effort are sanitized before native passthrough.
- Function-call arguments with integral floats (
- Verified end to end with
launch claudeandlaunch codexonmuse-spark-1.3-contributor, including multi-turn tool-use and shell execution.
- Fix
SSE stream ended without [DONE]on native Responses passthrough models (muse-sparketc.): the streaming relay now appends adata: [DONE]sentinel on clean EOF when the upstream ended its/zen/v1/responsesstream without one (same upstream quirk already tolerated in the chat translation path). Streams that already carry a sentinel are relayed verbatim with no duplicate, and empty zero-event streams still pass through untouched so upstream failures are not masked.
- Native Responses Passthrough with Memory:
- Requests whose chat translation path fails (404/501/502/500) are automatically probed against the upstream native
/responsesendpoint; on success the model is remembered and served directly on subsequent requests. - Confirmed models are served by a fidelity reverse proxy that preserves upstream 4xx/5xx status codes and error bodies instead of masking client errors as 502.
- Unified all upstream calls (
chat/completions,responses) oncallOpenCodeEndpointwith retry/rotation, context propagation, and structured attempt logging.
- Requests whose chat translation path fails (404/501/502/500) are automatically probed against the upstream native
- Streaming Relay Quality:
- SSE is relayed line-by-line with per-event flush, fixing the 32KB
io.Copybuffering stall that broke typewriter streaming. - Tail
usageis extracted fromresponse.completed/usage events for token accounting, upstream rate-limit headers are forwarded, and completed responses persist session state forprevious_response_idchains.
- SSE is relayed line-by-line with per-event flush, fixing the 32KB
- Safer Probing & Self-Healing Registry:
- Probing never fires on 401/403/429/400, typed conversion errors, context cancellation, or transport errors, avoiding retry storms under rate limiting or credential failure.
- Static preset models (
muse-spark-1.3-contributor) skip translation from the first request; the newnative_responses_modelsconfig merges additively without clearing learned models; dynamically learned models are evicted after consecutive failures while static models are immune.
- Redesigned Admin Dashboard & Workspace Layout:
- Upgraded Web Admin UI to a modern top-tab layout with four dedicated workspaces: Overview & Telemetry, Models & Routing, Network & Proxy, and System Settings.
- Added Live Match Tester and quick preset buttons for interactive model alias rule evaluation.
- Fully exposed upstream behavior controls (
prompt_cache_retention,cache_control_breakpoints,text_only_models) and network options (socks5_sticky) to prevent silent config overwrites when saving via the panel.
- Advanced Model Alias Matching Rules:
- Expanded
model_aliaswith multi-pattern matching rules (exact,contains,prefix,suffix,regex,wildcard). - Automatically preserves and propagates bracket suffixes (e.g.
claude-3-7-sonnet[1m]maps to target model while retaining[1m]). - Full backward compatibility for legacy key-value dictionary formats.
- Expanded
- Cross-Process Advisory Locking for Token Usage Statistics:
- Implemented atomic read-modify-write synchronization for
stats.jsonusing OS advisory locks (flockon Unix-like systems andLockFileExon Windows). - Prevents token usage and request count overwrite/loss when running the daemon server concurrently with short-lived
opencode2api launch claude|codexinstances.
- Implemented atomic read-modify-write synchronization for
- models.dev Dual-Layer Cache & Proxy Reuse:
- Added dual-layer caching (in-memory + disk cache) for models.dev catalog data.
- Automatically reuses active SOCKS5 proxy configurations for catalog fetches with graceful fallback to cached catalog on network timeouts or failures.
- Periodic background refresh every 6 hours.
- Shared Configuration, Statistics, and Log Path Resolution:
- Unified path resolution across
serverandlaunchmodes with user config directory fallback (~/.config/opencode2api). - Support for
OPENCODE2API_STATS/OPENCODE2API_STATS_FILEandOPENCODE2API_LOG/OPENCODE2API_LOG_FILEenvironment variables and CLI overrides.
- Unified path resolution across
- Automated Build & Release Versioning:
- Replaced hardcoded version constants with
runtime/debug.ReadBuildInfo()dynamic module and VCS metadata resolution (commit revision, timestamp, dirty state). - Added
scripts/release.shandmake version/make releasefor automated SemVer release derivation, pre-flight validation, and tag deployment.
- Replaced hardcoded version constants with
- Redesign Web Admin Control Panel:
- Modern aesthetic with refined dark palette, glassmorphism cards, and Lucide icons.
- Responsive multi-section layout with sidebar navigation (Overview, Proxy & Upstream, Logs, Documentation, Configuration).
- Live metric cards for active models, requests, cache hit rates, upstream health, and response latency.
- Real-time log streaming viewer with auto-refresh, search filter, level tags, auto-scroll toggle, and pause/resume controls.
- Visual upstream base URLs manager, model alias editor, and rate-limit cap configuration.
- Clean authentication modal with seamless session management.
- Unified Config File Resolution Order:
- Consistent config path resolution across server and launch modes:
OPENCODE2API_CONFIGenvironment variable- Explicit
-config/--configCLI flags - Existing
./config.jsonin the working directory (preserves backward compatibility) - Platform user configuration directory:
<UserConfigDir>/opencode2api/config.json(~/.config/opencode2api/config.jsonon Linux,~/Library/Application Support/opencode2api/config.jsonon macOS)
- When persisting configuration in server mode, automatically create parent user config directories if they do not exist.
- Launch mode respects the same resolution order while remaining read-only.
- Consistent config path resolution across server and launch modes:
- Add one-click cross-platform installers:
scripts/install.shfor Linux/macOS/FreeBSD andscripts/install.ps1for Windows. Both resolve the latest GitHub Release (or a pinned tag), select the correct OS/arch tarball, verify its SHA256 fromchecksums.txt, install the binary under~/.opencode2api/bin, and print next-steplaunch claude/launch codexcommands. Installation, platform support, version pinning, and release-asset naming are documented indocs/INSTALL.mdand linked from both READMEs. - Add
opencode2api launch codex: starts the same localhost proxy aslaunch claude, then runs the installed Codex CLI with temporary-cprovider overrides (model_provider,model_providers.opencode2api.*,wire_api="responses", andenv_key="OPENCODE2API_OPENAI_API_KEY") so the child process uses opencode2api through its Responses API without writing~/.codex/config.toml.--model/-mafter--are extracted, forwarded via Codex's--model, and the same--key/OPENCODE_API_KEY/publicresolution is used. A temporary Codex model catalog is written from the current upstream model list and passed via-c model_catalog_json=<temp path>; it defaults to free-tier models forpublic, includes all models for paid/tier keys, and setscontext_window,max_context_window, andauto_compact_token_limit=<ctx*0.9>so model switching and unknown upstream model IDs do not fall back to Codex's 258K default window. - Make
launch claudeandlaunch codexread-only for the proxy config:launchnow loads and appliesconfig.jsonwithout callingsaveConfig, so launching a child CLI can no longer rewrite the user's persistent proxy configuration. opencode2api launch claudenow supports interactive TUI model selection, 1M context window mode, and automatic compaction:- When
--modelis omitted, an interactive TUI lists free-tier models from the upstream catalogs, sorted by context window (largest first), with[1m]markers for ≥1M-context models. - After model selection, the context window is looked up from models.dev: ≥1M gets a
[1m]suffix on the model ID andCLAUDE_CODE_AUTO_COMPACT_WINDOW=ctx×0.9; <1M gets only the auto-compact; unknown gets neither. - Model ID is set via five
ANTHROPIC_*_MODELenvironment variables instead of--model, avoiding the[claude-code:unrecognized_model]warning. --modelcan be placed after--(alongside claude passthrough flags like--dangerously-skip-permissions) and is extracted by opencode2api instead of being forwarded to claude.- The
[1m]suffix flows throughresolveModel/resolveModelForAuth/mapPublicToFreeModeltransparently (stripped for catalog lookup, re-applied on the resolved ID). - models.dev catalog is fetched with a cache-busting query parameter and parsed from both the top-level
modelssection and the nestedproviders.*.modelssection (where OpenCode-specific models likex-preview-f-freelive). - TUI shows only models with a free variant; paid-only models are filtered out.
- When
- Tolerate upstream streams that end with a usage-only chunk but no
finish_reasonand no[DONE](observed onmuse-spark-1.2-contributor-free): when the turn produced output and the terminal usage chunk carried output-token accounting, the Claude and Responses stream converters now synthesizestop/response.completedinstead of failing withstream ended without finish_reason/server_error. Partial EOF with no output still fails instead of fabricating a reply. - Fix a data race in the raw-SSE wrapper used by streaming Chat conversion:
Close()andRead()onrawSSEReaderare now synchronized.
- Add
POST /v1/messages/count_tokenswith local heuristic estimation: Claude Code polls this endpoint to manage its context window and trigger auto-compaction, and previously it 404'd. The newclaudeCountTokensHandleranswers synchronously with a local estimate (text at ~4 chars/token, per-message/system/tool structural overhead, and fixed1600/3000token estimates for image/document blocks), never calling the upstream and never incurring usage. Invalid JSON or missingmodelreturn a protocol-shaped 400; non-POST returns 405. - Add multi-domain upstream load balancing with session stickiness: the new
upstream_base_urlsconfig spreads opencode zen traffic across multiple (reversed) domains. Sessions hash-stick to one (base URL, socks5 proxy) pair so per-egress prompt caches keep hitting, rebinding to a different target on 429/5xx/transport errors like the existing proxy stickiness. Catalog fetches round-robin over all domains, logs recordbase_urlper attempt, and the admin UI gains an upstream domain editor. Defaults to["https://opencode.ai"]when unset.
- Silence multi-modal downgrade for text-only upstream models: models matching the new configurable
text_only_modelsprefixes (default["deepseek"], prefix-matched case-insensitively) have image and document parts replaced with[image attached]/[document attached]text annotations before the request leaves the proxy, so DeepSeek requests with screenshots or pasted images keep working instead of failing upstream with an image-unsupported error. Applied uniformly across the Chat, Responses, and Claude protocol surfaces (single choke point atbuildUpstreamBody); text content and part order are preserved.
- Align Messages-API
input_tokenswith Anthropic semantics: whencache_read_input_tokensis derived from DeepSeek/OpenAI-style counters (prompt_cache_hit_tokensorprompt_tokens_details.cached_tokens), the hit portion is subtracted frominput_tokens, sinceprompt_tokensincludes it. Clients that price input and cache reads separately no longer bill hit tokens twice (289 prompt + 256 hit now reportsinput_tokens: 33,cache_read_input_tokens: 256). Anthropic-stylecache_read_input_tokenssources are left untouched.
- Fix DeepSeek cache usage accounting:
prompt_cache_hit_tokensis cached/read input, whileprompt_cache_miss_tokensis ordinary uncached input and is no longer counted ascache_creation_input_tokensin Claude usage orcache_created_tokensinstats.json. The admin stats table now also displayscache_read_tokens/cache_created_tokens. - Harden cache usage aggregation so canonical Anthropic cache fields take precedence over DeepSeek/
cached_tokensfallbacks and are never double-counted when multiple usage shapes are present. Add regression tests for DeepSeek and Anthropic cache semantics.
- Add session-sticky egress (
socks5_sticky, defaulttrue) for round-robin proxy mode. Each session/account (paid by API token, public by Claude metadatasession_id, otherwise a shared fallback) pins one egress proxy, so upstream per-egress prompt caches keep building up: measured 99.8% cache hit on a pinned egress vs ~0% when rotation randomly switches egress between requests. Different sessions still rotate, keeping the multi-egress distribution. - Release sticky bindings before retrying upstream errors: free-tier 429s are rate-limited per egress IP, so the retry rotates to the next proxy (verified live: 429 on egress A → automatic rebind to egress B → retry succeeds). Transport errors and 5xx release the binding too.
- Fix rebind-after-invalidate using a deterministic hash, which always landed the same session back on the same proxy (i.e. "switch IP on error" never actually happened). Rebinding now rotates egress via an incrementing sequence mixed into the hash.
- Clear all sticky bindings when the proxy configuration changes (
active_socks5or the proxy list), so stale bindings never point at removed egresses.
- Improve prompt-cache hit rate on the OpenCode zen upstream. Requests now inject
prompt_cache_retention: "24h"(upstream default is ~5 min) and an Anthropic-stylecache_control: {"type":"ephemeral","ttl":"1h"}breakpoint for models that accept it. GLM/Zhipu models, which reject unknown fields, are always skipped, and client-suppliedextra_bodyvalues win over the injected defaults. Both behaviors are configurable viaprompt_cache_retention("24h"default,"in_memory", or"off") andcache_control_breakpoints(defaulttrue). - Map DeepSeek-style
prompt_cache_hit_tokensinto Claude usage ascache_read_input_tokenswhen the standard fields are absent, so cached tokens are visible on the Messages API.prompt_cache_miss_tokensis ordinary uncached input, not a cache write, so it is intentionally not reported ascache_creation_input_tokens. - Aggregate per-model cache accounting in
stats.jsonascache_read_tokens/cache_created_tokensacross Chat, Responses, and Messages (streaming and non-streaming), and add these columns to the admin panel. For DeepSeek-style upstreams the hit rate iscache_read_tokens / prompt_tokens.
- Fix DeepSeek/Qwen models that emit raw DSML/XML tool-call markup (for example
<|DSML|tool_calls>,<|DSML|tool_calls>,<tool_calls>, and<tool_call>) by converting it to standard OpenAItool_callsbefore it reaches Chat/Claude/Responses clients. Non-streaming responses are normalized incallOpenCodeAPI; streaming responses are wrapped bycallOpenCodeAPIStreamso all three downstream protocols share one conversion layer. Nativetool_calls,usage,reasoning_content,finish_reason, and[DONE]semantics are preserved.
- Fix
cache_controlcounting so signature fields are not treated as cacheable message content, and ensure Responsescached_tokensremains present in Claude usage translation.
-
Refactor project layout: split the root
main.gomonolith intointernal/apppackages, addcmd/opencode2apias the executable entrypoint, extract protocol DTOs intointernal/domain, random helpers intointernal/random, response ID normalization intointernal/ids, and sync Makefile/Dockerfile/release script/CI paths and ldflags. HTTP behavior and CLI flags are unchanged. -
Fix
/v1/responsestextparameter being forwarded verbatim as upstreamresponse_format. The Responses APItextneststypeinsideformat({format:{type:...}, verbosity:...}), but upstream providers require a top-leveltypeinresponse_format, causing400 response_format: missing field type. AddedconvertResponsesTextToResponseFormatto translatetext.formatinto a valid Chat Completionsresponse_format(text/json_object/json_schema), and drop the field entirely when it cannot be represented (unknown type, missing requiredjson_schemafields, non-objecttext, or verbosity-only) so the proxy never forwards a malformedresponse_formatand never surfaces a 400 for this case. Verified against the real upstream and end-to-end through the proxy.
- Add
stop_sequencefield to non-streaming Claude responses, streamingmessage_start, and streamingmessage_deltaso Claude Code sees"stop_sequence": nullconsistently, matching the Anthropic Messages API contract. - Add
stop_detailsfield (omitempty) toClaudeResponsefor forward compatibility withrefusalstop reason. - Extend
claudeUnsupportedBlockTypeswith 7 new server-tool/MCP block types (code_execution_tool_use,code_execution_tool_result,mcp_tool_use,mcp_tool_result,bash_code_execution_tool_result,web_fetch_tool_result,tool_reference) for observability; requests with these blocks are still accepted (no 400).
- Fix 19 audited protocol compatibility issues across stream integrity, native Anthropic decoding, and request compatibility adapters.
- Stream integrity: emit protocol error events (
event: erroron Claude,response.failedon Responses) on upstream stream errors or abnormal EOF; support 15s keepalive ping before first token; adopt single-writer SSE architecture. - Native Anthropic decoding: strict SSE lifecycle validation, numeric index ordering for content blocks, support typed deltas (
text_delta,thinking_delta,signature_delta,input_json_delta,redacted_thinking.data), preserve ordered blocks across Claude roundtrips via_opencode2api_anthropic_content, deterministic response-ID prefix normalization, and return genericupstream_errorwithout error detail leakage. - Request compatibility:
/v1/responsestool_resultnormalization and collection; Anthropicdocumentand Responsesinput_filemapping to Chatfileparts; structure-aware file/document validation; strict temperature boundary validation (0..1 for Messages, 0..2 for Chat/Responses); safecache_controland signature counting. - Public free-model routing: automatically downgrade keyless public-tier requests for paid model IDs to
-freevariants (e.g.,deepseek-v4-flash→deepseek-v4-flash-free). - Add configurable
max_tokenscap (globalmax_tokens_capand per-modelmax_tokens_cap_per_model), clamping upstream requests exceeding limits, with Admin UI controls and/api/configsupport.
- Add
socks5_paid_direct(defaultfalse): when anactive_socks5proxy is set, keyed/paid upstream traffic also uses SOCKS5 unless this flag is explicitly enabled for the old paid-direct bypass.
- Stop cross-model upstream fallback for both public and keyed auth; retry transient 401/429/5xx and transport errors on the same requested model only.
- Treat upstream
CreditsError/ insufficient balance as non-retryable so Anthropic and Chat requests return the original billing error instead of silently trying other catalog models.
- Fix Claude Code
/v1/messagesstreaming: wait for the OpenAI usage-only chunk afterfinish_reasonbefore emittingmessage_delta, so token usage (and cache fields when present) is no longer dropped.
- Fix Docker image build: copy
go.sumso lumberjack dependency resolves during multi-arch image builds.
- Claude Code
/v1/messages→ Chat upstream conversion: acceptx-api-key(rejectsk-ant-), merge mid-conversationsysteminto one leading system message, converttool_resultimages to follow-upimage_url, maptool_choice.disable_parallel_tool_use→parallel_tool_calls=false, narrowmetadata.user_idJSON tosession_id, skip server tools withoutinput_schema, and log intentional drops (context_management,cache_control, betas) inrequest_plan. - Map Claude Code
output_config.effort(--effort/CLAUDE_CODE_EFFORT_LEVEL) onto upstreamreasoning_effort, and treatthinking.type=adaptiveas enabled. - Add structured request logging with lumberjack file rotation (default
opencode2api.log+ stdout),request_idtracing, protocol/upstream/stream summaries, secret redaction, and runtimelog_level/log_bodiesvia/api/config. - Restore
/v1/messagesdefault CoT passthrough for Claude Code (thinking off only when force-disabled orthinking.type=disabled), while keeping empty-reply fallbacks from v0.3.5. - Fix reasoning effort: stop stripping
reasoning_effortbefore upstream calls; forward Claudethinking(includingbudget_tokens) and derive effort from budget when needed. - Hide upstream
-freesuffix in/v1/modelsresponses and resolve stripped names back to free upstream IDs.
- Fix empty Claude Code / OpenAI replies when the Go gateway puts the answer in
reasoning_content(#37635): promote tocontent/textwhen thinking is not requested, and fall back to a text block if a thinking-only stream would otherwise end empty. - Temporarily made
/v1/messagesthinking opt-in; reverted in Unreleased because Claude Code lost visible CoT.
- Projectized the provided Go program.
- Added Go module metadata, local build targets, and release packaging script.
- Added CI and tag-driven multi-platform release automation.
- Changed release automation to parallel matrix builds with a final publish job.
- Added README, API, configuration, deployment, release, contribution, and security docs.
- Added build metadata and
-versionflag.