Debugging a rogue SSE frame: getting OpenCode to talk to AgentRouter
- The setup
- Wrong diagnosis #1: trusting curl
- Wrong diagnosis #2: the workaround that didn’t work
- What’s actually in the stream
- The fix
- Smaller traps
- Was it worth it
If you landed here from a search for AI_TypeValidationError ... path ["choices"] expected array, received undefined, here’s the short answer: your config is probably fine. The gateway appends a frame the OpenAI SDK doesn’t expect. The fix is a ~40 line local proxy, described at the bottom.
The longer version is more useful, because I got the diagnosis wrong twice before I got it right.
The setup
I run several tools that each want a model: an agent in my editor, my own CLI agent, and a couple of scripts. Separate subscriptions for each is a bad deal, not mainly because of money but because quotas run out at different times and you can never compare two models on the same task.
A gateway fixes that: one base URL, one key, many models. I picked AgentRouter because it speaks the OpenAI-compatible protocol, so any client with a configurable base_url connects without code changes. Registration is via GitHub, no card, and there are starter credits: agentrouter.org/register. That’s a referral link, it pays me a bonus and gives you one. Nothing else in this post is a pitch, it’s all debugging.
You can list what the gateway actually serves without registering, which is handy:
curl -s https://agentrouter.org/api/pricing | jq '.data[].model_name'
Mine returned claude-opus-4-8, claude-opus-5, and gpt-5.6-sol. Keep that call in mind, the docs lag behind it.
Wrong diagnosis #1: trusting curl
I started by testing the key by hand:
curl https://agentrouter.org/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}'
Response:
{"error":{"message":"unauthorized client detected, contact support..."},
"message":"UNAUTHENTICATED","success":false,"type":"unauthorized_client_error"}
I’m in a region that gets geo-blocked often, so I concluded geo-block, wrote “key useless, need VPN” in my notes, and stopped.
Days later I ran the same key through a real client, no VPN, same machine. It answered. I checked the OpenCode database (~/.local/share/opencode/opencode.db, table message) and there were my assistant turns with error: null and real text.
The block is client fingerprinting, not network. Bare curl gets rejected, real clients pass. The key was fine the whole time.
The lesson I wrote down: curl is not the verification of record for a third-party gateway. Test with the client you’ll actually use before theorising about borders and ISPs.
Wrong diagnosis #2: the workaround that didn’t work
Next, the Claude path via @ai-sdk/anthropic worked while the OpenAI-compatible path kept throwing the validation error. So I decided to just use the Anthropic path and move on.
That fails in the worst possible way. On my account the Anthropic path accepts the request and returns error: null with zero tokens and empty text. No error, no content. It looks like the model declined to answer rather than like a protocol mismatch. AgentRouter only genuinely speaks OpenAI-compatible /v1; its Anthropic endpoint is decorative.
So the OpenAI path had to be fixed, not avoided.
What’s actually in the stream
I dumped the raw response and got binary garbage. Boring reason: undici asks for gzip, br, so I was staring at compressed bytes. Forcing identity revealed the culprit as the final frame:
data: {"billing":{...,"cost_cny":{...}},"object":"billing.summary"}
Every OpenAI SSE frame is supposed to be an object with choices or error. This one is a billing receipt with neither, so the zod validator in the AI SDK throws.
A week later, on claude-opus-5, I hit a second junk frame, a different one:
data: null
My billing-specific filter passed it straight through and the SDK died with a new error:
Invalid input: expected object, received null (path: [], code: invalid_type)
Hence the rule that took two rounds to learn: drop any data: payload that isn’t a JSON object, not just billing frames. Concretely:
- keep
[DONE], it’s part of the protocol; - keep non-JSON lines untouched;
- keep JSON objects, including
{"error":...}, since the SDK handles those itself; - drop
null, arrays, scalars, andobject == "billing.summary".
The fix
A tiny local reverse proxy, Python standard library only. Three responsibilities:
- Forward the request verbatim, preserving the client’s headers so the gateway’s fingerprinting still accepts it.
- Force
Accept-Encoding: identityupstream and stripContent-Encodingfrom the relayed response. Skip this and there is nothing to filter, you’ll be slicing compressed bytes. - Read the body line by line, dropping junk frames along with their trailing blank separator.
Then point the client at the proxy:
"agentrouter-openai": {
"name": "AgentRouter OpenAI",
"npm": "@ai-sdk/openai-compatible",
"options": { "baseURL": "http://127.0.0.1:8787/v1" },
"models": { "claude-opus-5": { "name": "Claude Opus 5" } }
}
Smoke test:
opencode run 'Reply with exactly: SMOKE_OK' --model agentrouter-openai/claude-opus-5
Returns the string with no exception.
Smaller traps
Provider id must match the credential key. In OpenCode, provider.<id> in opencode.jsonc has to match the key in auth.json exactly, or you get Model not found even though opencode models lists it. Half an hour gone.
ConnectionResetError [WinError 10054] in the proxy log is noise. It’s undici probing idle keep-alive sockets. Silence it in handle_error.
Intermittent 500s. I’ve seen sensitive_words_detected and a Chinese 未提供令牌, the former oddly fond of the auto title-generation request. Clients retry and succeed, but a non-interactive script needs its own retry logic.
Docs lag. A model missing from the documented list worked fine for me, reporting itself upstream as MaaS_Cl_Opus_5_20260724_cache. Trust /api/pricing and an actual test over the docs table.
Was it worth it
For my use, yes: one key across every tool, starter credits without a card, and switching models is a config line rather than a new subscription. The cost was one 40 line local shim I haven’t touched since writing it.
If you need production guarantees, go to the vendor directly, a gateway adds a failure point you don’t control. If you want to try several models cheaply, it earns its place: agentrouter.org/register.
One warning while I’m here. On one popular English AgentRouter guide there’s a comment saying the author’s code “doesn’t work, here’s a working one” with a different referral link. That’s referral-tag swapping, not helpfulness. The tag has zero effect on whether the key works.
Write a comment