diff --git a/internal/dev-entrypoint/opencode-provider-proxy.mjs b/internal/dev-entrypoint/opencode-provider-proxy.mjs index 1fd6b598..a0106f20 100644 --- a/internal/dev-entrypoint/opencode-provider-proxy.mjs +++ b/internal/dev-entrypoint/opencode-provider-proxy.mjs @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import { createServer, request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; +import { Transform } from "node:stream"; const serviceId = process.env.OTEL_SERVICE_NAME || process.env.HWLAB_SERVICE_ID || "opencode-provider-proxy"; const port = parsePositiveInteger(process.env.PORT || process.env.HWLAB_OPENCODE_PROVIDER_PROXY_PORT, 4097); @@ -110,12 +111,14 @@ async function proxyProviderRequest(clientReq, clientRes, url) { headers: providerRequestHeaders(clientReq.headers, target, trace, startSpanId) }, (upstreamRes) => { statusCode = upstreamRes.statusCode || 502; + const filterReasoning = shouldFilterOpenAIReasoning(route, upstreamRes.headers); clientRes.writeHead(statusCode, { ...providerResponseHeaders(upstreamRes.headers), traceparent: traceparent(trace.traceId, startSpanId), "x-hwlab-otel-trace-id": trace.traceId }); - upstreamRes.pipe(clientRes); + const responseStream = filterReasoning ? upstreamRes.pipe(openAIReasoningFilterStream()) : upstreamRes; + responseStream.pipe(clientRes); upstreamRes.on("end", settle); upstreamRes.on("close", settle); upstreamRes.on("error", (error) => { @@ -183,6 +186,76 @@ function providerRoute(pathname) { return `${publicBasePath}${stripped.startsWith("/") ? stripped : `/${stripped}`}`; } +function shouldFilterOpenAIReasoning(route, headers) { + return route === `${publicBasePath}/chat/completions` && /^text\/event-stream(?:\s*;|$)/iu.test(firstHeaderValue(headers?.["content-type"])); +} + +function openAIReasoningFilterStream() { + let pending = ""; + return new Transform({ + transform(chunk, _encoding, callback) { + pending += chunk.toString("utf8"); + let newlineIndex = pending.indexOf("\n"); + while (newlineIndex >= 0) { + const line = pending.slice(0, newlineIndex + 1); + pending = pending.slice(newlineIndex + 1); + const next = filterOpenAISseLine(line); + if (next) this.push(next); + newlineIndex = pending.indexOf("\n"); + } + callback(); + }, + flush(callback) { + if (pending) { + const next = filterOpenAISseLine(pending); + if (next) this.push(next); + } + callback(); + } + }); +} + +function filterOpenAISseLine(line) { + const match = line.match(/^(data:\s*)(.*?)(\r?\n)?$/u); + if (!match) return line; + const [, prefix, payload, lineEnding = ""] = match; + const text = payload.trim(); + if (!text || text === "[DONE]") return line; + try { + const parsed = JSON.parse(text); + const choices = Array.isArray(parsed.choices) ? parsed.choices.map(filterOpenAIChoiceReasoning).filter(Boolean) : parsed.choices; + if (Array.isArray(parsed.choices)) { + if (choices.length === 0 && parsed.usage === null) return ""; + parsed.choices = choices; + } + return `${prefix}${JSON.stringify(parsed)}${lineEnding}`; + } catch { + return line; + } +} + +function filterOpenAIChoiceReasoning(choice) { + if (!choice || typeof choice !== "object") return choice; + const next = { ...choice }; + if (next.delta && typeof next.delta === "object") { + const delta = { ...next.delta }; + delete delta.reasoning_content; + delete delta.reasoning; + delete delta.reasoning_details; + if (delta.content === null) delete delta.content; + next.delta = delta; + } + if (next.message && typeof next.message === "object") { + next.message = { ...next.message }; + delete next.message.reasoning_content; + delete next.message.reasoning; + delete next.message.reasoning_details; + } + const deltaEmpty = next.delta && typeof next.delta === "object" && Object.keys(next.delta).length === 0; + if (deltaEmpty && next.finish_reason == null && next.logprobs == null) return null; + return next; +} + function providerRequestHeaders(headers, target, trace, parentSpanId) { const result = {}; for (const [name, value] of Object.entries(headers)) {