Debugging a rogue SSE frame: getting OpenCode to talk to AgentRouter

Debugging AI_TypeValidationError when connecting OpenCode to the AgentRouter LLM gateway: a rogue billing.summary SSE frame, a data: null frame, and a 40-line local proxy fix.

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, and object == "billing.summary".

The fix

A tiny local reverse proxy, Python standard library only. Three responsibilities:

  1. Forward the request verbatim, preserving the client’s headers so the gateway’s fingerprinting still accepts it.
  2. Force Accept-Encoding: identity upstream and strip Content-Encoding from the relayed response. Skip this and there is nothing to filter, you’ll be slicing compressed bytes.
  3. 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