cd /news/developer-tools/fix-agentrouter-exposing-or-leaking-… · home topics developer-tools article
[ARTICLE · art-81396] src=gist.github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Fix AgentRouter exposing or leaking billing in chat responses which breaks OpenCode

A developer created a local proxy to fix AgentRouter exposing billing data in chat responses, which broke OpenCode. The proxy filters out malformed SSE frames containing billing.summary objects or null values, allowing OpenCode to process streams correctly.

read5 min views4 publishedJul 27, 2026

| #!/usr/bin/env node | | | /** | | | * agentrouter-proxy.js | | | * | | | * Local filtering proxy for AgentRouter (https://agentrouter.org). | | | * | | | * WHY: AgentRouter's gateway currently ends every streamed response with one | | | * extra malformed SSE frame - either a leaked internal billing.summary | | | * object (on the OpenAI-compatible endpoint) or a bare null (on the | | | * Anthropic-native endpoint). OpenCode's strict stream-chunk validators | | | * reject that frame and kill the whole request, even though the real model | | | * output already streamed correctly. | | | * | | | * WHAT THIS DOES: Sits between OpenCode and agentrouter.org. Passes every | | | * request straight through unchanged. On the way back, for streaming | | | * (text/event-stream) responses, it inspects each SSE frame and silently | | | * drops just the malformed trailing frame, forwarding everything else | | | * byte-for-byte. Non-streaming responses are passed through untouched. | | | * | | | * REQUIRES: Node.js 18 or newer (uses the built-in fetch/streams, no | | | * npm install needed). | | | * | | | * USAGE: | | | * node agentrouter-proxy.js | | | * | | | * Then in opencode.json, change: | |

| * "baseURL": "https://agentrouter.org" -> "http://localhost:8787" | |
| * "baseURL": "https://agentrouter.org/v1" -> "http://localhost:8787/v1" | |

| * | | | * (Both AgentRouter provider blocks - the @ai-sdk/anthropic one and the | | | * @ai-sdk/openai-compatible one - can point at this same local server; | | | * it just mirrors whatever path is requested onto the real upstream.) | | | * | | | * Set PORT=xxxx to use a different local port. | | | * Set LOG_BILLING=0 to silence the "stripped billing frame" console logs. | | | */ | |

| const http = require('http'); | |
| const UPSTREAM = 'https://agentrouter.org'; | |

| const PORT = process.env.PORT || 8787; | |

| const LOG_BILLING = process.env.LOG_BILLING !== '0'; | |
| function isJunkFrame(payload) { | |
| const trimmed = payload.trim(); | |
| if (trimmed === '' || trimmed === '[DONE]') return false; | |

| try { | |

| const obj = JSON.parse(trimmed); | |
| if (obj === null) return true; | |
| if (obj && typeof obj === 'object' && obj.object === 'billing.summary') return true; | |
| } catch { | |
| // Not JSON (e.g. "[DONE]" or a comment line) - never junk. | |

| } | | | return false; | | | } | | | function extractBillingCost(payload) { | | | try { | |

| const obj = JSON.parse(payload.trim()); | |
| if (obj && obj.object === 'billing.summary') { | |

| return obj.billing?.request?.cost_cny?.total; | | | } | | | } catch { | | | // ignore | | | } | | | return null; | | | } | | | const server = http.createServer(async (req, res) => { | | | const targetUrl = UPSTREAM + req.url; | | | // Buffer the incoming request body (LLM request bodies are plain text/JSON, | | | // fine to hold fully in memory even for large contexts). | |

| const chunks = []; | |
| for await (const chunk of req) chunks.push(chunk); | |
| const body = chunks.length ? Buffer.concat(chunks) : undefined; | |
| const headers = { ...req.headers }; | |

| delete headers.host; | |

| delete headers['content-length']; | |
| headers['accept-encoding'] = 'identity'; // avoid gzip/br ambiguity, keep it plain text | |

| let upstreamRes; | | | try { | | | upstreamRes = await fetch(targetUrl, { | | | method: req.method, | | | headers, | | | body, | |

| }); | |
| } catch (err) { | |
| res.writeHead(502, { 'content-type': 'application/json' }); | |
| res.end(JSON.stringify({ error: { message: 'proxy fetch failed: ' + err.message } })); | |

| return; | | | } | |

| const resHeaders = {}; | |
| upstreamRes.headers.forEach((value, key) => { | |
| const k = key.toLowerCase(); | |
| if (k === 'content-length' || k === 'content-encoding') return; | |
| resHeaders[key] = value; | |
| }); | |
| res.writeHead(upstreamRes.status, resHeaders); | |
| const contentType = upstreamRes.headers.get('content-type') || ''; | |
| if (!contentType.includes('text/event-stream') || !upstreamRes.body) { | |

| // Not a stream - pass through untouched. | |

| const buf = Buffer.from(await upstreamRes.arrayBuffer()); | |
| res.end(buf); | |

| return; | | | } | | | // Streaming SSE response - filter frame by frame. | |

| const reader = upstreamRes.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |

| let idx; | |

| while ((idx = buffer.indexOf('\n\n')) !== -1) { | |
| const frame = buffer.slice(0, idx + 2); | |
| buffer = buffer.slice(idx + 2); | |

| const payload = frame | |

| .split('\n') | |
| .filter((line) => line.startsWith('data:')) | |
| .map((line) => line.slice(5)) | |
| .join('\n'); | |
| if (isJunkFrame(payload)) { | |
| if (LOG_BILLING) { | |
| const cost = extractBillingCost(payload); | |

| console.log( | | | cost | |

| ? `[agentrouter-proxy] stripped billing frame (cost_cny total: ${cost})` | |
| : '[agentrouter-proxy] stripped malformed null frame' | |
| ); | |

| } | | | continue; // drop it, don't forward | | | } | | | res.write(frame); | | | } | | | } | |

| if (buffer.trim()) res.write(buffer); // flush rare trailing partial frame | |
| res.end(); | |
| }); | |
| server.listen(PORT, () => { | |
| console.log(`AgentRouter filtering proxy listening on http://localhost:${PORT}`); | |
| console.log(` Anthropic-native baseURL: http://localhost:${PORT}`); | |
| console.log(` OpenAI-compatible baseURL: http://localhost:${PORT}/v1`); | |
| }); |
── more in #developer-tools 4 stories · sorted by recency
── more on @agentrouter 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/fix-agentrouter-expo…] indexed:0 read:5min 2026-07-27 ·