diff --git a/internal/dev-entrypoint/opencode-provider-proxy.mjs b/internal/dev-entrypoint/opencode-provider-proxy.mjs index a0106f20..0139c4c8 100644 --- a/internal/dev-entrypoint/opencode-provider-proxy.mjs +++ b/internal/dev-entrypoint/opencode-provider-proxy.mjs @@ -74,6 +74,7 @@ async function proxyProviderRequest(clientReq, clientRes, url) { let statusCode = 0; let errorCode = ""; let timeout = null; + let streamStats = createOpenAIStreamStats(false); const settle = () => { if (settled) return; settled = true; @@ -95,7 +96,8 @@ async function proxyProviderRequest(clientReq, clientRes, url) { "http.response.status_class": statusCode ? `${Math.floor(statusCode / 100)}xx` : "unknown", "opencode.provider.elapsed_ms": elapsedMs, "opencode.provider.timeout_ms": timeoutMs, - "opencode.provider.phase": errorCode ? "failed" : "complete" + "opencode.provider.phase": errorCode ? "failed" : "complete", + ...openAIStreamStatsAttributes(streamStats) } }); resolve(); @@ -112,12 +114,13 @@ async function proxyProviderRequest(clientReq, clientRes, url) { }, (upstreamRes) => { statusCode = upstreamRes.statusCode || 502; const filterReasoning = shouldFilterOpenAIReasoning(route, upstreamRes.headers); + streamStats = createOpenAIStreamStats(filterReasoning); clientRes.writeHead(statusCode, { ...providerResponseHeaders(upstreamRes.headers), traceparent: traceparent(trace.traceId, startSpanId), "x-hwlab-otel-trace-id": trace.traceId }); - const responseStream = filterReasoning ? upstreamRes.pipe(openAIReasoningFilterStream()) : upstreamRes; + const responseStream = filterReasoning ? upstreamRes.pipe(openAIReasoningFilterStream(streamStats)) : upstreamRes; responseStream.pipe(clientRes); upstreamRes.on("end", settle); upstreamRes.on("close", settle); @@ -190,7 +193,45 @@ function shouldFilterOpenAIReasoning(route, headers) { return route === `${publicBasePath}/chat/completions` && /^text\/event-stream(?:\s*;|$)/iu.test(firstHeaderValue(headers?.["content-type"])); } -function openAIReasoningFilterStream() { +function createOpenAIStreamStats(filterEnabled) { + return { + filterEnabled: Boolean(filterEnabled), + dataLines: 0, + outputDataLines: 0, + doneLines: 0, + jsonErrors: 0, + choices: 0, + contentChunks: 0, + contentChars: 0, + reasoningChunks: 0, + reasoningChars: 0, + reasoningOnlyChoicesDropped: 0, + emptyDeltaChoicesDropped: 0, + finishChoices: 0, + usageChunks: 0 + }; +} + +function openAIStreamStatsAttributes(stats) { + return { + "opencode.provider.sse.filter_enabled": stats.filterEnabled, + "opencode.provider.sse.data_lines": stats.dataLines, + "opencode.provider.sse.output_data_lines": stats.outputDataLines, + "opencode.provider.sse.done_lines": stats.doneLines, + "opencode.provider.sse.json_errors": stats.jsonErrors, + "opencode.provider.sse.choice_count": stats.choices, + "opencode.provider.sse.content_chunks": stats.contentChunks, + "opencode.provider.sse.content_chars": stats.contentChars, + "opencode.provider.sse.reasoning_chunks": stats.reasoningChunks, + "opencode.provider.sse.reasoning_chars": stats.reasoningChars, + "opencode.provider.sse.reasoning_only_choices_dropped": stats.reasoningOnlyChoicesDropped, + "opencode.provider.sse.empty_delta_choices_dropped": stats.emptyDeltaChoicesDropped, + "opencode.provider.sse.finish_choices": stats.finishChoices, + "opencode.provider.sse.usage_chunks": stats.usageChunks + }; +} + +function openAIReasoningFilterStream(stats = createOpenAIStreamStats(true)) { let pending = ""; return new Transform({ transform(chunk, _encoding, callback) { @@ -199,46 +240,76 @@ function openAIReasoningFilterStream() { while (newlineIndex >= 0) { const line = pending.slice(0, newlineIndex + 1); pending = pending.slice(newlineIndex + 1); - const next = filterOpenAISseLine(line); - if (next) this.push(next); + const next = filterOpenAISseLine(line, stats); + if (next) { + stats.outputDataLines += isOpenAIDataLine(next) ? 1 : 0; + this.push(next); + } newlineIndex = pending.indexOf("\n"); } callback(); }, flush(callback) { if (pending) { - const next = filterOpenAISseLine(pending); - if (next) this.push(next); + const next = filterOpenAISseLine(pending, stats); + if (next) { + stats.outputDataLines += isOpenAIDataLine(next) ? 1 : 0; + this.push(next); + } } callback(); } }); } -function filterOpenAISseLine(line) { +function isOpenAIDataLine(line) { + return /^data:\s*/u.test(line); +} + +function filterOpenAISseLine(line, stats = createOpenAIStreamStats(true)) { 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; + if (!text) return line; + stats.dataLines += 1; + if (text === "[DONE]") { + stats.doneLines += 1; + return line; + } try { const parsed = JSON.parse(text); - const choices = Array.isArray(parsed.choices) ? parsed.choices.map(filterOpenAIChoiceReasoning).filter(Boolean) : parsed.choices; + if (parsed.usage != null) stats.usageChunks += 1; + const choices = Array.isArray(parsed.choices) ? parsed.choices.map((choice) => filterOpenAIChoiceReasoning(choice, stats)).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 { + stats.jsonErrors += 1; return line; } } -function filterOpenAIChoiceReasoning(choice) { +function filterOpenAIChoiceReasoning(choice, stats = createOpenAIStreamStats(true)) { if (!choice || typeof choice !== "object") return choice; const next = { ...choice }; + stats.choices += 1; + if (next.finish_reason != null) stats.finishChoices += 1; if (next.delta && typeof next.delta === "object") { const delta = { ...next.delta }; + const reasoningText = [delta.reasoning_content, delta.reasoning, delta.reasoning_details] + .filter((value) => typeof value === "string") + .join(""); + if (reasoningText) { + stats.reasoningChunks += 1; + stats.reasoningChars += reasoningText.length; + } + if (typeof delta.content === "string" && delta.content.length > 0) { + stats.contentChunks += 1; + stats.contentChars += delta.content.length; + } delete delta.reasoning_content; delete delta.reasoning; delete delta.reasoning_details; @@ -252,7 +323,11 @@ function filterOpenAIChoiceReasoning(choice) { 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; + if (deltaEmpty && next.finish_reason == null && next.logprobs == null) { + stats.emptyDeltaChoicesDropped += 1; + stats.reasoningOnlyChoicesDropped += 1; + return null; + } return next; } diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index e8c223f2..67be9a8c 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -5842,6 +5842,27 @@ function opencodeServerManifest({ profile = "v03", source, deploy = null, catalo spec: { serviceAccountName: name, securityContext: { fsGroup: 1000, fsGroupChangePolicy: "OnRootMismatch" }, + initContainers: [{ + name: "opencode-workspace-git-init", + image: helperImage, + imagePullPolicy: "IfNotPresent", + command: ["/bin/sh", "-ec"], + args: [ + [ + "set -eu", + "mkdir -p /workspace", + "if [ ! -d /workspace/.git ]; then", + " git -C /workspace init", + "fi", + "git -C /workspace config user.name 'HWLAB OpenCode' || true", + "git -C /workspace config user.email 'opencode@hwlab.local' || true", + "chgrp -R 1000 /workspace/.git 2>/dev/null || true", + "chmod -R g+rwX /workspace/.git 2>/dev/null || true" + ].join("\n") + ], + resources: { requests: { cpu: "25m", memory: "64Mi" }, limits: { cpu: "250m", memory: "256Mi" } }, + volumeMounts: [{ name: "workspace", mountPath: "/workspace" }] + }], containers: [{ name, image: "ghcr.io/anomalyco/opencode:1.17.7", diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 591045fd..5905375b 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -761,6 +761,11 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async () assert.equal(opencodeDeployment.metadata?.annotations?.["hwlab.pikastech.local/opencode-provider-proxy-boot-sh"], "deploy/runtime/boot/opencode-provider-proxy.sh"); assert.equal(opencodeDeployment.metadata?.annotations?.["hwlab.pikastech.local/values-printed"], "false"); assert.match(opencodeDeployment.spec?.template?.metadata?.annotations?.["hwlab.pikastech.local/opencode-config-sha256"] ?? "", /^[a-f0-9]{64}$/u); + const opencodeGitInit = opencodeDeployment.spec?.template?.spec?.initContainers?.find((container) => container.name === "opencode-workspace-git-init"); + assert.ok(opencodeGitInit, "expected opencode workspace git init container"); + assert.deepEqual(opencodeGitInit.command, ["/bin/sh", "-ec"]); + assert.match(opencodeGitInit.args?.[0] ?? "", /git -C \/workspace init/u); + assert.ok(opencodeGitInit.volumeMounts?.some((entry) => entry.name === "workspace" && entry.mountPath === "/workspace")); const opencodeContainer = collectContainersFromItem(opencodeDeployment).find((container) => container.name === "opencode-server"); assert.deepEqual(opencodeContainer?.command, ["/bin/sh", "-ec"]); assert.equal(opencodeContainer?.args?.[0], "exec opencode serve --hostname 0.0.0.0 --port 4096");