| #!/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`); | |
| }); |