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.
The Failure
Text
The question tool was called with invalid arguments:
SchemaError(Expected array, got "[{\"question\": \"\\u5708\\u53f7..."
at ["questions"])
Json
{ "questions": "[{\"question\": \"...\"}]" } // wrong — array as string
Json
{ "questions": [{ "question": "..." }] } // right — native array
Whose Fault Is It?
- 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.
- Direct curl to sub2api with the same payload — request worked, response had {"questions": [...]} correctly.
- Streaming curl to sub2api with stream:true — input_json_delta chunks concatenated to valid JSON.
- Re-running the same OpenCode call with a simpler tool schema (no Chinese, fewer options) — succeeded.
| Reference | Notes |
|---|---|
| opencode#7512 | "Tools with array/object parameters fail when LLM sends stringified JSON" |
| opencode PR#7513 | Auto-coerce in Tool.define() — closed stale by bot, never merged |
| anthropic/claude-code#23500 | Anthropic acknowledges Opus 4.6 stringifies objects to MCP tools |
| opencode-google-antigravity-auth 022201a | A working schema-aware fix in a fork |
Why the Plugin API Doesn't Save Me
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 });
// ...
})
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"
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.
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
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 Stream Bit
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}
- 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.
- 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.
Operational Notes
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
What I Won't Do
Let AI Do It
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 一遍的铁证。
故障现场
Text
The question tool was called with invalid arguments:
SchemaError(Expected array, got "[{\"question\": \"\\u5708\\u53f7..."
at ["questions"])
Json
{ "questions": "[{\"question\": \"...\"}]" } // 错 —— array 变成字符串
Json
{ "questions": [{ "question": "..." }] } // 对 —— 原生 array
怪谁
- 我那个代理 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(无中文、选项少)—— 成功。
| 出处 | 备注 |
|---|---|
| opencode#7512 | "Tools with array/object parameters fail when LLM sends stringified JSON" |
| opencode PR#7513 | 在 Tool.define() 自动 coerce —— 被 bot 自动 stale 关掉,没合 |
| anthropic/claude-code#23500 | Anthropic 自己承认 Opus 4.6 把 object 序列化成字符串发给 MCP 工具 |
| opencode-google-antigravity-auth 022201a | 某个 fork 里的 schema-aware fix,能跑 |
为什么 plugin API 救不了我
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 });
// ...
})
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"
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。
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
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
流式那段
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}
- chunk 在线改——风险:chunk 边界不会落在 JSON token 边界上。剪接很难。
- 全缓冲,stop 时一次性发替换 chunk——简单、正确,代价是 tool call 多几百毫秒延迟。
- 原样的缓冲 chunk(不需要 coerce),或
- 原 content_block_start + 一个合成的 input_json_delta(修好的完整 JSON),然后 content_block_stop。
运维 Tip
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
我不会做的
让 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 真的触发了