Asia/Shanghai
April 28, 2026

When Opus Stringifies Your Tool Args: A Sidecar That Quietly Repairs the Damage

Opus 把 tool 参数糊成字符串怎么办:写个 sidecar 默默修好

Mingjian Shao
When Opus Stringifies Your Tool Args: A Sidecar That Quietly Repairs the Damage
The error came back twice in the same session, with \\u5708 instead of \u5708. Double-escaped unicode is the smoking gun for a model that stringified its own array.
Opus 4.7, asked to call my question tool with four bilingual options nested under each question. OpenCode crashed:
Text
The question tool was called with invalid arguments:
SchemaError(Expected array, got "[{\"question\": \"\\u5708\\u53f7..."
  at ["questions"])
Notice the \\u5708 — double backslash. In a normal JSON string a Chinese character is \u5708. Two backslashes means the value got stringified once by the model, then stringified again by the validator's error formatter. The first stringify is the bug.What the model emitted:
Json
{ "questions": "[{\"question\": \"...\"}]" }    // wrong — array as string
What it was supposed to emit:
Json
{ "questions": [{ "question": "..." }] }        // right — native array
Same surface, different shape. Strict schema validators reject it; loose ones (Gemini's API, for example) accept either. Anthropic's tool_use protocol is strict.The natural suspects:
  • My proxy (sub2api): Maybe it does an extra JSON.stringify somewhere.
  • The Vercel AI SDK in OpenCode: Maybe it's parsing stream deltas wrong.
  • OpenCode's validator: Too strict?
  • The model itself: Output is broken at source.
I went through each:
  • Direct curl to sub2api with the same payload — request worked, response had {"questions": [...]} correctly.
  • Streaming curl to sub2api with stream:trueinput_json_delta chunks concatenated to valid JSON.
  • Re-running the same OpenCode call with a simpler tool schema (no Chinese, fewer options) — succeeded.
Then I re-ran the original buggy case and it failed again, in the same session. Two reproductions in two minutes.The pattern is the model-level bug already documented:
ReferenceNotes
opencode#7512"Tools with array/object parameters fail when LLM sends stringified JSON"
opencode PR#7513Auto-coerce in Tool.define() — closed stale by bot, never merged
anthropic/claude-code#23500Anthropic acknowledges Opus 4.6 stringifies objects to MCP tools
opencode-google-antigravity-auth 022201aA working schema-aware fix in a fork
Triggers, in my testing: nested schema + heavy CJK unicode + ≥3 array items + mid-session, not first call. Opus 4.5 doesn't do this. Sonnet doesn't either. It's a 4.6/4.7 regression.OpenCode has a plugin system. There's a hook called tool.execute.before whose entire job sounds like it should let me intercept the args. But the binary tells the truth:
Js
// Decompiled from ~/.opencode/bin/opencode (annotated):
T.execute = (c, s) => effect.gen(function* () {
  // 1. Validate first
  let w = yield* y(c).pipe(n.mapError((O) =>
    Error(`The ${_} tool was called with invalid arguments: ${O}`)
  ));
  // 2. Hook fires AFTER validation
  yield* trigger("tool.execute.before", ..., { args: w });
  // ...
})
The hook runs after the schema validator. By the time it fires, the call has already crashed. There is no tool.input.transform or args.coerce hook upstream of validation.I confirmed there are no other hooks at the right layer:
Bash
strings ~/.opencode/bin/opencode | grep -oE 'c\.trigger\("[^"]+"' | sort -u
# c.trigger("chat.message"
# c.trigger("command.execute.before"
# c.trigger("experimental.chat.messages.transform"
# c.trigger("experimental.text.complete"
# c.trigger("shell.env"
# c.trigger("tool.execute.after"
# c.trigger("tool.execute.before"
PR #7513's fix lives in tool.ts source, before the validator. To do it as a plugin you'd need a hook that doesn't exist.I run my Anthropic traffic through sub2api already (a personal multi-account proxy on my Unraid box). That gives me a natural choke point upstream of OpenCode's validator. If I rewrite the bytes there, OpenCode never sees the broken shape.
The sidecar:
  • Reads each request's tools[].input_schema and indexes it by tool name.
  • Forwards everything verbatim to sub2api.
  • On the response, watches the SSE stream:
    • For each tool_use content block, buffers all input_json_delta chunks
    • At content_block_stop, concatenates them, parses, and runs schema-aware coercion
    • If a field that the schema says is array arrived as a string starting with [, try JSON.parse and use the result
    • Same for object (string starting with {) and stringified numbers/booleans
    • If coercion changed the value: emit one synthetic input_json_delta with the corrected JSON, replacing the buffered chunks
    • Otherwise: forward the original deltas verbatim
  • For non-stream JSON responses, just rewrites content[].input in place.
Schema-aware is the important part. A naive "any string that looks like JSON, try to parse it" rewrite would damage legitimate JSON-shaped strings (think: description: "[]" in a tool schema where the field's type really is string). The proxy only parses where the schema demands it.Bun, ~600 LOC, no npm dependencies. The whole project tree:
Text
src/
  index.ts          Bun.serve reverse proxy
  coerce.ts         pure schema-aware value coercion
  sse.ts            stream rewriter (TransformStream)
  coerce.test.ts    11 unit tests
  sse.test.ts       5 stream tests
  smoke.ts          e2e against real sub2api
  smoke-coerce.ts   e2e with mock buggy upstream
Dockerfile          oven/bun:1.3-alpine, 50MB
16/16 tests pass. The forced-coerce smoke is the most satisfying:
Text
[smoke-coerce] sending request through sidecar...
[sidecar] coerced tool_use input (stream) tool=ask
[smoke-coerce] reconstructed JSON: {"questions":[{"q":"圈号拼接损伤怎么处理?"}]}
[smoke-coerce] ✅ SIDECAR REPAIRED THE STRINGIFIED ARRAY
The actual hard part is buffering deltas correctly. Anthropic's SSE protocol streams a tool's input one fragment at a time:
Typescript
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","name":"ask","input":{}}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"que"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"stions\":["}}

... more chunks ...

event: content_block_stop
data: {"type":"content_block_stop","index":0}
The client reconstructs the input by concatenating all partial_json strings inside the same index. So if I want to rewrite the value, I have two options:
  • Modify chunks in-flight — risk: chunk boundaries don't fall on JSON token boundaries. Splicing is hard.
  • Buffer all chunks, emit one replacement at stop — simple, correct, costs a few hundred ms of latency on tool calls.
I chose option 2. The proxy holds the deltas for one block, and at content_block_stop it emits either:
  • Original buffered chunks unchanged (no coercion needed), or
  • The original content_block_start + one synthetic input_json_delta with the full corrected JSON, then the content_block_stop.
Text deltas, ping events, message_start, message_delta, message_stop all pass through untouched.The sidecar logs nothing on the happy path — only when it actually coerces. So docker logs sub2api-coerce-sidecar is empty most of the time, which initially looked like "no traffic." It's not. Verify via lsof instead:
Bash
$ lsof -nPp $(pgrep -f 'opencode serve')
opencode  ...  TCP 100.123.141.95:59194->10.0.2.88:8081 (ESTABLISHED)

$ ssh unraid "ss -tn 'dport = :8080'"
ESTAB  ...  172.18.0.X:46390  172.18.0.4:8080
The first connection is OpenCode → sidecar. The second is sidecar → sub2api on Docker's kranet bridge. Bytes flow.docker stats sub2api-coerce-sidecar shows ~30 MB RAM, sub-1% CPU, dozens of MB of network I/O after a few hours. It's invisible until the model misbehaves, then it logs coerced tool_use input (stream) tool=question and the call goes through.Patching OpenCode's binary or maintaining a fork is too high-maintenance for a model-level bug that Anthropic will probably fix in 4.8. The sidecar is purely additive — when the regression is gone, it's a 1ms passthrough. When OpenCode adds proper coercion (PR #7513 might still land), I point baseURL back at port 8080 and stop the container.The sidecar is at GitHub-equivalent state in my Unraid; not open-sourced because it's a workaround for an issue that should be fixed at the source. If you hit this exact bug and want the code, ping me.If you use an AI coding assistant like OpenCode, paste this prompt and let it handle everything:
Bash
I want to set up a Bun reverse proxy in front of an Anthropic-compatible API
that coerces stringified array/object args in tool_use blocks (Opus 4.6/4.7
regression). Reference: https://mjshao.fun/blog/opencode-opus-stringify-sidecar

My setup:
- Upstream Anthropic-compatible URL: [ask me]
- Sidecar deployment target (Unraid SSH alias / local / VPS): [ask me]
- Sidecar listen port: [ask me, default 8081]
- OpenCode config path to update: [ask me, default ~/.config/opencode/opencode.json]
- Provider key in opencode.json that points to the upstream: [ask me]

Steps:
1. Scaffold a Bun + TypeScript project: src/index.ts (Bun.serve reverse proxy),
   src/coerce.ts (schema-aware coercion), src/sse.ts (TransformStream that buffers
   tool_use deltas and emits one corrected delta at content_block_stop)
2. Write unit tests (coerce + sse) and smoke tests (real upstream + mock buggy upstream)
3. Build amd64 Docker image (oven/bun:1.3-alpine), no npm dependencies
4. Deploy to target with healthcheck on /healthz
5. Update OpenCode provider baseURL to point at sidecar; back up the config first
6. Verify via lsof that the OpenCode serve process holds an ESTABLISHED connection
   to the sidecar port; verify Docker stats shows network I/O
7. Tell me how to read sidecar logs to see when coerce actually fires
同一个会话里报了两次错,错误信息里中文是 \\u5708 不是 \u5708。双反斜杠就是模型自己把 array stringify 一遍的铁证。
Opus 4.7,让它调我的 question 工具,每个 question 下面嵌套四个中英对照选项。OpenCode 直接崩:
Text
The question tool was called with invalid arguments:
SchemaError(Expected array, got "[{\"question\": \"\\u5708\\u53f7..."
  at ["questions"])
注意 \\u5708 —— 两个反斜杠。正常 JSON 里中文是 \u5708。两个反斜杠说明这个值被 stringify 了两次:第一次是模型自己干的,第二次是 validator 报错时把 value 再 stringify 一次给你看。第一次那次就是 bug。模型实际输出的:
Json
{ "questions": "[{\"question\": \"...\"}]" }    // 错 —— array 变成字符串
它本应该输出的:
Json
{ "questions": [{ "question": "..." }] }        // 对 —— 原生 array
外观差不多,结构完全不同。严格的 schema validator 拒收;松的(比如 Gemini API)两种都收。Anthropic 的 tool_use 协议是严的。直觉嫌疑名单:
  • 我那个代理 sub2api:是不是哪里多 JSON.stringify 了一次?
  • OpenCode 里的 Vercel AI SDK:是不是流式 chunk 解析错了?
  • OpenCode 的 validator:是不是太严了?
  • 模型本身:源头就坏了?
逐个排查:
  • 用相同 payload 直接 curl sub2api —— 一切正常,response 里就是 {"questions": [...]}
  • 流式 curl sub2api(stream:true)—— input_json_delta chunk 拼起来就是合法 JSON。
  • 同样请求换简单 schema(无中文、选项少)—— 成功。
然后我把原 buggy case 重跑了一次,同一会话内又崩了。两分钟两次复现。这就是已经被记录的 model-level bug:
出处备注
opencode#7512"Tools with array/object parameters fail when LLM sends stringified JSON"
opencode PR#7513Tool.define() 自动 coerce —— 被 bot 自动 stale 关掉,没合
anthropic/claude-code#23500Anthropic 自己承认 Opus 4.6 把 object 序列化成字符串发给 MCP 工具
opencode-google-antigravity-auth 022201a某个 fork 里的 schema-aware fix,能跑
我实测的触发组合:嵌套 schema + 大量中文 unicode + ≥3 个 array 元素 + 会话中段,不是第一次调用。Opus 4.5 不会这样。Sonnet 不会。是 4.6/4.7 的退化。OpenCode 有插件系统。有个钩子叫 tool.execute.before,名字听起来就是为这事儿设计的。但二进制不会撒谎:
Js
// 反编译自 ~/.opencode/bin/opencode(已注释):
T.execute = (c, s) => effect.gen(function* () {
  // 1. 先校验
  let w = yield* y(c).pipe(n.mapError((O) =>
    Error(`The ${_} tool was called with invalid arguments: ${O}`)
  ));
  // 2. 钩子在校验「之后」才触发
  yield* trigger("tool.execute.before", ..., { args: w });
  // ...
})
钩子在 schema validator 之后跑。等它触发,请求已经崩了。没有任何一个 tool.input.transformargs.coerce 钩子在 validator 之前。我确认没漏掉别的钩子:
Bash
strings ~/.opencode/bin/opencode | grep -oE 'c\.trigger\("[^"]+"' | sort -u
# c.trigger("chat.message"
# c.trigger("command.execute.before"
# c.trigger("experimental.chat.messages.transform"
# c.trigger("experimental.text.complete"
# c.trigger("shell.env"
# c.trigger("tool.execute.after"
# c.trigger("tool.execute.before"
PR #7513 的修复是改 tool.ts 源码,写在 validator 前面。要插件实现就需要一个不存在的钩子。我的 Anthropic 流量本来就走 sub2api(自托管在 Unraid 上的多账号代理)。这给了我一个天然的拦截点,位于 OpenCode validator 上游。如果我在那一层把字节修好,OpenCode 永远看不到坏的形状。
Sidecar 干的事:
  • 读每个请求的 tools[].input_schema,按 tool name 建索引。
  • 原样转发给 sub2api。
  • 看响应 SSE:
    • 每个 tool_use content block,把所有 input_json_delta 缓冲住
    • 收到 content_block_stop 时,拼接、parse、做 schema-aware coerce
    • 如果某字段 schema 说是 array 但收到一个以 [ 开头的字符串,试 JSON.parse,成功就用结果
    • object(以 { 开头)、stringified 数字/布尔同理
    • 如果 coerce 改变了值:发一个合成的 input_json_delta,里面是修好的完整 JSON,替换掉缓冲的 chunk
    • 否则:原样把缓冲的 chunk 发出去
  • 非流式 JSON 响应直接改 content[].input
「schema-aware」是关键。如果搞个无脑的「凡是看起来像 JSON 的 string 都试着 parse」,会误伤合法的 JSON 形态字符串(想想:tool schema 里某字段类型就是 string,值正好是 "[]")。代理只在 schema 要求的地方才 parse。Bun,~600 行,零 npm 依赖。整个项目结构:
Text
src/
  index.ts          Bun.serve 反向代理
  coerce.ts         纯 schema-aware coerce
  sse.ts            流式 rewriter(TransformStream)
  coerce.test.ts    11 个单元测试
  sse.test.ts       5 个流测试
  smoke.ts          打真 sub2api 的 e2e
  smoke-coerce.ts   用 mock 坏上游强制 coerce 的 e2e
Dockerfile          oven/bun:1.3-alpine,50MB
16/16 测试过。强制 coerce 那个 smoke 看着最爽:
Text
[smoke-coerce] sending request through sidecar...
[sidecar] coerced tool_use input (stream) tool=ask
[smoke-coerce] reconstructed JSON: {"questions":[{"q":"圈号拼接损伤怎么处理?"}]}
[smoke-coerce] ✅ SIDECAR REPAIRED THE STRINGIFIED ARRAY
真正难的是缓冲 delta。Anthropic 的 SSE 协议把 tool input 一段一段发:
Typescript
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","name":"ask","input":{}}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"que"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"stions\":["}}

... 更多 chunk ...

event: content_block_stop
data: {"type":"content_block_stop","index":0}
客户端通过把同一个 index 的所有 partial_json 拼起来还原 input。要重写值,两条路:
  • chunk 在线改——风险:chunk 边界不会落在 JSON token 边界上。剪接很难。
  • 全缓冲,stop 时一次性发替换 chunk——简单、正确,代价是 tool call 多几百毫秒延迟。
选了第 2 条。代理把一个 block 的 delta 全囤住,content_block_stop 时发:
  • 原样的缓冲 chunk(不需要 coerce),或
  • content_block_start + 一个合成的 input_json_delta(修好的完整 JSON),然后 content_block_stop
文本 delta、ping、message_startmessage_deltamessage_stop 都原样透传。Sidecar 在「正常流量」下啥也不打日志,只在真发生 coerce 时打。所以 docker logs sub2api-coerce-sidecar 大部分时间空空如也,看着像「没流量」。其实有,要用 lsof 验证:
Bash
$ lsof -nPp $(pgrep -f 'opencode serve')
opencode  ...  TCP 100.123.141.95:59194->10.0.2.88:8081 (ESTABLISHED)

$ ssh unraid "ss -tn 'dport = :8080'"
ESTAB  ...  172.18.0.X:46390  172.18.0.4:8080
第一条是 OpenCode → sidecar。第二条是 sidecar → sub2api,走 Docker 的 kranet 桥接网络。字节真的在流。docker stats sub2api-coerce-sidecar 跑几个小时后大概 30 MB 内存、不到 1% CPU、几十 MB 网络 I/O。模型不犯病时它隐形;犯病时打一行 coerced tool_use input (stream) tool=question,然后请求继续走通。打 OpenCode 二进制补丁、维护 fork——为了一个 model-level bug 的代价太高,Anthropic 大概率会在 4.8 修。Sidecar 是纯增量的:模型不退化时它 1ms 透传;OpenCode 哪天合并了正经的 coerce(PR #7513 可能复活),我把 baseURL 改回 8080、把容器停了就行。代码托管在 Unraid 上,没开源——这是一个本就该上游修的 bug 的 workaround。如果你也踩了这个坑想要代码,ping 我。如果你用 OpenCode 这类 AI 编程助手,把这段话粘过去让它帮你做:
Bash
我想做一个 Bun 反向代理,挡在某个 Anthropic 协议兼容 API 前面,
修复 Opus 4.6/4.7 tool_use array/object 参数糊成字符串的退化。
参考:https://mjshao.fun/blog/opencode-opus-stringify-sidecar

我的环境:
- 上游 Anthropic 兼容 URL:[问我]
- 部署目标(Unraid SSH alias / 本地 / VPS):[问我]
- Sidecar 监听端口:[问我,默认 8081]
- 要改的 OpenCode 配置路径:[问我,默认 ~/.config/opencode/opencode.json]
- 配置里指向上游的 provider key:[问我]

步骤:
1. Bun + TypeScript 项目:src/index.ts(Bun.serve 反代)、
   src/coerce.ts(schema-aware coerce 纯函数)、src/sse.ts(缓冲
   tool_use delta TransformStream,content_block_stop 时发一条修好的 delta)
2. 写单元测试(coerce + sse)和 smoke 测试(真上游 + mock 坏上游)
3. 构建 amd64 Docker 镜像(oven/bun:1.3-alpine),零 npm 依赖
4. 部署到目标,配 /healthz 健康检查
5. OpenCode provider baseURL 指向 sidecar;先备份原配置
6. lsof 验证 OpenCode serve 进程到 sidecar 端口的 TCP ESTABLISHED;
   docker stats 看到网络 I/O 增长
7. 告诉我怎么看 sidecar 日志确认 coerce 真的触发了
Share this post:
Enjoy this post? Subscribe via RSS: English | 中文