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. | /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 ; | | | } ; |