| 1 | #!/usr/bin/env node |
| 2 | /** |
| 3 | * opencode-chat2responses-proxy.mjs |
| 4 | * |
| 5 | * Minimal local proxy that exposes POST /v1/chat/completions (Chat API) |
| 6 | * but forwards as POST /v1/responses (Responses API) to opencode.ai/zen. |
| 7 | * |
| 8 | * Purpose: CodeWhale only spoke Chat Completions, but |
| 9 | * muse-spark-1.2-contributor-free on https://opencode.ai/zen/v1 only |
| 10 | * speaks Responses. This shim lets any Chat-only client use that model |
| 11 | * without modifying Rust code. |
| 12 | * |
| 13 | * Usage: |
| 14 | * node scripts/opencode-chat2responses-proxy.mjs |
| 15 | * # listens on http://127.0.0.1:8765 |
| 16 | * |
| 17 | * Then in CodeWhale config.toml: |
| 18 | * [providers.my_opencode] |
| 19 | * kind = "openai-compatible" |
| 20 | * base_url = "http://127.0.0.1:8765/v1" |
| 21 | * model = "muse-spark-1.2-contributor-free" |
| 22 | * api_key_env = "OPENCODE_ZEN_API_KEY" |
| 23 | * # proxy speaks chat to CodeWhale, responses to upstream |
| 24 | * |
| 25 | * Prefer the native fix (no proxy needed): |
| 26 | * [providers.opencode_zen] |
| 27 | * api_key_env = "OPENCODE_ZEN_API_KEY" |
| 28 | * base_url = "https://opencode.ai/zen/v1" |
| 29 | * model = "muse-spark-1.2-contributor-free" |
| 30 | * The bundled offering + resolver now correctly routes muse-spark over |
| 31 | * Responses (see crates/config/src/route/offering.rs). |
| 32 | */ |
| 33 | |
| 34 | import http from "node:http"; |
| 35 | |
| 36 | const LISTEN_PORT = Number(process.env.PROXY_PORT ?? 8765); |
| 37 | const UPSTREAM_BASE = process.env.UPSTREAM_BASE ?? "https://opencode.ai/zen/v1"; |
| 38 | const UPSTREAM_PATH = "/responses"; |
| 39 | |
| 40 | function chatToResponses(chatBody) { |
| 41 | const model = chatBody.model ?? "muse-spark-1.2-contributor-free"; |
| 42 | const messages = chatBody.messages ?? []; |
| 43 | const tools = chatBody.tools; |
| 44 | const sysMsgs = messages.filter((m) => m.role === "system"); |
| 45 | const instructions = |
| 46 | sysMsgs.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))).join("\n\n") || |
| 47 | "You are a helpful assistant."; |
| 48 | const input = []; |
| 49 | for (const m of messages) { |
| 50 | if (m.role === "system") continue; |
| 51 | if (m.role === "tool") { |
| 52 | input.push({ |
| 53 | type: "function_call_output", |
| 54 | call_id: m.tool_call_id ?? m.toolCallId ?? "call_unknown", |
| 55 | output: typeof m.content === "string" ? m.content : JSON.stringify(m.content), |
| 56 | }); |
| 57 | continue; |
| 58 | } |
| 59 | const content = typeof m.content === "string" ? [{ type: "input_text", text: m.content }] : m.content; |
| 60 | if (m.tool_calls || m.toolCalls) { |
| 61 | for (const tc of m.tool_calls ?? m.toolCalls ?? []) { |
| 62 | input.push({ |
| 63 | type: "function_call", |
| 64 | call_id: tc.id, |
| 65 | name: tc.function?.name ?? tc.name, |
| 66 | arguments: tc.function?.arguments ?? "{}", |
| 67 | }); |
| 68 | } |
| 69 | } |
| 70 | input.push({ |
| 71 | type: "message", |
| 72 | role: m.role === "assistant" ? "assistant" : "user", |
| 73 | content, |
| 74 | }); |
| 75 | } |
| 76 | const body = { |
| 77 | model, |
| 78 | stream: chatBody.stream ?? false, |
| 79 | store: false, |
| 80 | instructions, |
| 81 | input, |
| 82 | }; |
| 83 | if (chatBody.max_tokens) body.max_output_tokens = chatBody.max_tokens; |
| 84 | if (chatBody.temperature != null) body.temperature = chatBody.temperature; |
| 85 | if (chatBody.top_p != null) body.top_p = chatBody.top_p; |
| 86 | if (tools) { |
| 87 | body.tools = tools.map((t) => ({ |
| 88 | type: "function", |
| 89 | name: t.function.name, |
| 90 | description: t.function.description ?? "", |
| 91 | parameters: t.function.parameters ?? { type: "object", properties: {} }, |
| 92 | strict: false, |
| 93 | })); |
| 94 | body.tool_choice = "auto"; |
| 95 | } |
| 96 | return body; |
| 97 | } |
| 98 | |
| 99 | function translateResponsesSseToChat(responsesChunk, model) { |
| 100 | let out = ""; |
| 101 | const lines = responsesChunk.split("\n"); |
| 102 | for (const line of lines) { |
| 103 | if (!line.startsWith("data:")) continue; |
| 104 | const payload = line.slice(5).trim(); |
| 105 | if (payload === "[DONE]") { |
| 106 | out += `data: [DONE]\n\n`; |
| 107 | continue; |
| 108 | } |
| 109 | try { |
| 110 | const evt = JSON.parse(payload); |
| 111 | const type = evt.type ?? ""; |
| 112 | if (type === "response.output_text.delta") { |
| 113 | const delta = evt.delta ?? evt.text ?? ""; |
| 114 | out += `data: ${JSON.stringify({ id: evt.response?.id ?? "chatcmpl-proxy", object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { content: delta }, finish_reason: null }] })}\n\n`; |
| 115 | } else if (type === "response.output_item.added" && evt.item?.type === "function_call") { |
| 116 | const item = evt.item; |
| 117 | out += `data: ${JSON.stringify({ id: evt.response?.id ?? "chatcmpl-proxy", object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: item.call_id, type: "function", function: { name: item.name, arguments: "" } }] }, finish_reason: null }] })}\n\n`; |
| 118 | } else if (type === "response.function_call_arguments.delta") { |
| 119 | out += `data: ${JSON.stringify({ id: evt.response?.id ?? "chatcmpl-proxy", object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: evt.delta ?? "" } }] }, finish_reason: null }] })}\n\n`; |
| 120 | } else if (type === "response.completed" || type === "response.incomplete") { |
| 121 | out += `data: ${JSON.stringify({ id: evt.response?.id ?? "chatcmpl-proxy", object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`; |
| 122 | } |
| 123 | } catch {} |
| 124 | } |
| 125 | return out; |
| 126 | } |
| 127 | |
| 128 | const server = http.createServer(async (req, res) => { |
| 129 | if (req.method === "GET" && req.url === "/health") { |
| 130 | res.writeHead(200, { "content-type": "application/json" }); |
| 131 | res.end(JSON.stringify({ ok: true, upstream: UPSTREAM_BASE })); |
| 132 | return; |
| 133 | } |
| 134 | if (req.method !== "POST" || !req.url?.includes("/chat/completions")) { |
| 135 | res.writeHead(404, { "content-type": "application/json" }); |
| 136 | res.end(JSON.stringify({ error: "only POST /v1/chat/completions is proxied" })); |
| 137 | return; |
| 138 | } |
| 139 | let body = ""; |
| 140 | req.on("data", (chunk) => (body += chunk)); |
| 141 | req.on("end", async () => { |
| 142 | try { |
| 143 | const chatBody = JSON.parse(body || "{}"); |
| 144 | const model = chatBody.model ?? "muse-spark-1.2-contributor-free"; |
| 145 | const isStream = chatBody.stream === true; |
| 146 | const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") ?? process.env.OPENCODE_ZEN_API_KEY ?? ""; |
| 147 | const responsesBody = chatToResponses(chatBody); |
| 148 | const upstreamUrl = `${UPSTREAM_BASE}${UPSTREAM_PATH}`; |
| 149 | const headers = { |
| 150 | "content-type": "application/json", |
| 151 | accept: isStream ? "text/event-stream" : "application/json", |
| 152 | }; |
| 153 | if (apiKey) headers.authorization = `Bearer ${apiKey}`; |
| 154 | const upstreamRes = await fetch(upstreamUrl, { method: "POST", headers, body: JSON.stringify(responsesBody) }); |
| 155 | if (!upstreamRes.ok) { |
| 156 | const text = await upstreamRes.text(); |
| 157 | res.writeHead(upstreamRes.status, { "content-type": "application/json" }); |
| 158 | res.end(JSON.stringify({ error: `upstream ${upstreamRes.status}`, body: text.slice(0, 4000) })); |
| 159 | return; |
| 160 | } |
| 161 | if (!isStream) { |
| 162 | const data = await upstreamRes.json(); |
| 163 | const outputText = data.output?.flatMap((item) => item.content ?? []).filter((c) => c.type === "output_text").map((c) => c.text).join("") ?? data.output_text ?? ""; |
| 164 | const chatRes = { |
| 165 | id: data.id ?? "chatcmpl-proxy", |
| 166 | object: "chat.completion", |
| 167 | created: Math.floor(Date.now() / 1000), |
| 168 | model, |
| 169 | choices: [{ index: 0, message: { role: "assistant", content: outputText }, finish_reason: "stop" }], |
| 170 | usage: data.usage ? { prompt_tokens: data.usage.input_tokens, completion_tokens: data.usage.output_tokens, total_tokens: (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0) } : undefined, |
| 171 | }; |
| 172 | res.writeHead(200, { "content-type": "application/json" }); |
| 173 | res.end(JSON.stringify(chatRes)); |
| 174 | return; |
| 175 | } |
| 176 | res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", "x-accel-buffering": "no" }); |
| 177 | const reader = upstreamRes.body.getReader(); |
| 178 | const decoder = new TextDecoder(); |
| 179 | let buf = ""; |
| 180 | while (true) { |
| 181 | const { done, value } = await reader.read(); |
| 182 | if (done) break; |
| 183 | buf += decoder.decode(value, { stream: true }); |
| 184 | let idx; |
| 185 | while ((idx = buf.indexOf("\n\n")) !== -1) { |
| 186 | const chunk = buf.slice(0, idx + 2); |
| 187 | buf = buf.slice(idx + 2); |
| 188 | const translated = translateResponsesSseToChat(chunk, model); |
| 189 | if (translated) res.write(translated); |
| 190 | } |
| 191 | } |
| 192 | if (buf.trim()) { |
| 193 | const translated = translateResponsesSseToChat(buf, model); |
| 194 | if (translated) res.write(translated); |
| 195 | } |
| 196 | res.write(`data: [DONE]\n\n`); |
| 197 | res.end(); |
| 198 | } catch (e) { |
| 199 | res.writeHead(500, { "content-type": "application/json" }); |
| 200 | res.end(JSON.stringify({ error: String(e?.message ?? e).slice(0, 2000) })); |
| 201 | } |
| 202 | }); |
| 203 | }); |
| 204 | |
| 205 | server.listen(LISTEN_PORT, "127.0.0.1", () => { |
| 206 | console.log(`[opencode-proxy] listening on http://127.0.0.1:${LISTEN_PORT}/v1/chat/completions -> ${UPSTREAM_BASE}${UPSTREAM_PATH}`); |
| 207 | console.log(`[opencode-proxy] health: http://127.0.0.1:${LISTEN_PORT}/health`); |
| 208 | }); |
| 209 |