{"slug": "fix-agentrouter-exposing-or-leaking-billing-in-chat-responses-which-breaks", "title": "Fix AgentRouter exposing or leaking billing in chat responses which breaks OpenCode", "summary": "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.", "body_md": "| #!/usr/bin/env node | |\n| /** | |\n| * agentrouter-proxy.js | |\n| * | |\n| * Local filtering proxy for AgentRouter (https://agentrouter.org). | |\n| * | |\n| * WHY: AgentRouter's gateway currently ends every streamed response with one | |\n| * extra malformed SSE frame - either a leaked internal `billing.summary` | |\n| * object (on the OpenAI-compatible endpoint) or a bare `null` (on the | |\n| * Anthropic-native endpoint). OpenCode's strict stream-chunk validators | |\n| * reject that frame and kill the whole request, even though the real model | |\n| * output already streamed correctly. | |\n| * | |\n| * WHAT THIS DOES: Sits between OpenCode and agentrouter.org. Passes every | |\n| * request straight through unchanged. On the way back, for streaming | |\n| * (text/event-stream) responses, it inspects each SSE frame and silently | |\n| * drops just the malformed trailing frame, forwarding everything else | |\n| * byte-for-byte. Non-streaming responses are passed through untouched. | |\n| * | |\n| * REQUIRES: Node.js 18 or newer (uses the built-in fetch/streams, no | |\n| * npm install needed). | |\n| * | |\n| * USAGE: | |\n| * node agentrouter-proxy.js | |\n| * | |\n| * Then in opencode.json, change: | |\n| * \"baseURL\": \"https://agentrouter.org\" -> \"http://localhost:8787\" | |\n| * \"baseURL\": \"https://agentrouter.org/v1\" -> \"http://localhost:8787/v1\" | |\n| * | |\n| * (Both AgentRouter provider blocks - the @ai-sdk/anthropic one and the | |\n| * @ai-sdk/openai-compatible one - can point at this same local server; | |\n| * it just mirrors whatever path is requested onto the real upstream.) | |\n| * | |\n| * Set PORT=xxxx to use a different local port. | |\n| * Set LOG_BILLING=0 to silence the \"stripped billing frame\" console logs. | |\n| */ | |\n| const http = require('http'); | |\n| const UPSTREAM = 'https://agentrouter.org'; | |\n| const PORT = process.env.PORT || 8787; | |\n| const LOG_BILLING = process.env.LOG_BILLING !== '0'; | |\n| function isJunkFrame(payload) { | |\n| const trimmed = payload.trim(); | |\n| if (trimmed === '' || trimmed === '[DONE]') return false; | |\n| try { | |\n| const obj = JSON.parse(trimmed); | |\n| if (obj === null) return true; | |\n| if (obj && typeof obj === 'object' && obj.object === 'billing.summary') return true; | |\n| } catch { | |\n| // Not JSON (e.g. \"[DONE]\" or a comment line) - never junk. | |\n| } | |\n| return false; | |\n| } | |\n| function extractBillingCost(payload) { | |\n| try { | |\n| const obj = JSON.parse(payload.trim()); | |\n| if (obj && obj.object === 'billing.summary') { | |\n| return obj.billing?.request?.cost_cny?.total; | |\n| } | |\n| } catch { | |\n| // ignore | |\n| } | |\n| return null; | |\n| } | |\n| const server = http.createServer(async (req, res) => { | |\n| const targetUrl = UPSTREAM + req.url; | |\n| // Buffer the incoming request body (LLM request bodies are plain text/JSON, | |\n| // fine to hold fully in memory even for large contexts). | |\n| const chunks = []; | |\n| for await (const chunk of req) chunks.push(chunk); | |\n| const body = chunks.length ? Buffer.concat(chunks) : undefined; | |\n| const headers = { ...req.headers }; | |\n| delete headers.host; | |\n| delete headers['content-length']; | |\n| headers['accept-encoding'] = 'identity'; // avoid gzip/br ambiguity, keep it plain text | |\n| let upstreamRes; | |\n| try { | |\n| upstreamRes = await fetch(targetUrl, { | |\n| method: req.method, | |\n| headers, | |\n| body, | |\n| }); | |\n| } catch (err) { | |\n| res.writeHead(502, { 'content-type': 'application/json' }); | |\n| res.end(JSON.stringify({ error: { message: 'proxy fetch failed: ' + err.message } })); | |\n| return; | |\n| } | |\n| const resHeaders = {}; | |\n| upstreamRes.headers.forEach((value, key) => { | |\n| const k = key.toLowerCase(); | |\n| if (k === 'content-length' || k === 'content-encoding') return; | |\n| resHeaders[key] = value; | |\n| }); | |\n| res.writeHead(upstreamRes.status, resHeaders); | |\n| const contentType = upstreamRes.headers.get('content-type') || ''; | |\n| if (!contentType.includes('text/event-stream') || !upstreamRes.body) { | |\n| // Not a stream - pass through untouched. | |\n| const buf = Buffer.from(await upstreamRes.arrayBuffer()); | |\n| res.end(buf); | |\n| return; | |\n| } | |\n| // Streaming SSE response - filter frame by frame. | |\n| const reader = upstreamRes.body.getReader(); | |\n| const decoder = new TextDecoder(); | |\n| let buffer = ''; | |\n| while (true) { | |\n| const { done, value } = await reader.read(); | |\n| if (done) break; | |\n| buffer += decoder.decode(value, { stream: true }); | |\n| let idx; | |\n| while ((idx = buffer.indexOf('\\n\\n')) !== -1) { | |\n| const frame = buffer.slice(0, idx + 2); | |\n| buffer = buffer.slice(idx + 2); | |\n| const payload = frame | |\n| .split('\\n') | |\n| .filter((line) => line.startsWith('data:')) | |\n| .map((line) => line.slice(5)) | |\n| .join('\\n'); | |\n| if (isJunkFrame(payload)) { | |\n| if (LOG_BILLING) { | |\n| const cost = extractBillingCost(payload); | |\n| console.log( | |\n| cost | |\n| ? `[agentrouter-proxy] stripped billing frame (cost_cny total: ${cost})` | |\n| : '[agentrouter-proxy] stripped malformed null frame' | |\n| ); | |\n| } | |\n| continue; // drop it, don't forward | |\n| } | |\n| res.write(frame); | |\n| } | |\n| } | |\n| if (buffer.trim()) res.write(buffer); // flush rare trailing partial frame | |\n| res.end(); | |\n| }); | |\n| server.listen(PORT, () => { | |\n| console.log(`AgentRouter filtering proxy listening on http://localhost:${PORT}`); | |\n| console.log(` Anthropic-native baseURL: http://localhost:${PORT}`); | |\n| console.log(` OpenAI-compatible baseURL: http://localhost:${PORT}/v1`); | |\n| }); |", "url": "https://wpnews.pro/news/fix-agentrouter-exposing-or-leaking-billing-in-chat-responses-which-breaks", "canonical_source": "https://gist.github.com/SC0d3r/74da5f6414f841167cb56e509f7f0d55", "published_at": "2026-07-27 23:27:28+00:00", "updated_at": "2026-07-31 05:28:59.336572+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["AgentRouter", "OpenCode", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/fix-agentrouter-exposing-or-leaking-billing-in-chat-responses-which-breaks", "markdown": "https://wpnews.pro/news/fix-agentrouter-exposing-or-leaking-billing-in-chat-responses-which-breaks.md", "text": "https://wpnews.pro/news/fix-agentrouter-exposing-or-leaking-billing-in-chat-responses-which-breaks.txt", "jsonld": "https://wpnews.pro/news/fix-agentrouter-exposing-or-leaking-billing-in-chat-responses-which-breaks.jsonld"}}