fix: align opencode workspace event root
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user