From 3d8448b45ee41803086ba8cd26eb13dddd1e7649 Mon Sep 17 00:00:00 2001 From: Harvey Doan Date: Sun, 23 Aug 2026 19:47:10 +0700 Subject: [PATCH] fix(sse): floor muse-spark output budget to prevent empty-content 502s (#11214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined batch board over tip 0b41259f: static gates clean (changelog, file-size 160 frozen, complexity 2628<=2774, cognitive 1187<=1223, dead-code 408<=416, docs-counts green at 351 providers, provider-consistency 268/351/0), typecheck:core clean, 430+ focused tests green across 5 groups. muse-spark's all-reasoning empty-answer payloads (41 failures in 2h captured live) now floor the output budget so the model can actually emit content — no more empty-content 502 churn. Thank you @linhdmn! --- open-sse/executors/opencode.ts | 212 +++++++++++++++++- stryker.conf.json | 1 + .../opencode-muse-spark-min-output.test.ts | 111 +++++++++ 3 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 tests/unit/opencode-muse-spark-min-output.test.ts diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 0829bfa871..fdb31fd132 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -1,4 +1,9 @@ -import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; +import { + BaseExecutor, + type ExecuteInput, + type ExecutorExecuteResult, + type ProviderCredentials, +} from "./base.ts"; import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; import { @@ -145,6 +150,101 @@ export function resolveOpencodeTargetFormat(provider: string, model: string): st return getModelTargetFormat(alias, model) || "openai"; } +/** + * muse-spark (opencode-go) burns its entire output budget on invisible + * server-side reasoning before emitting any content. With small caller-set + * budgets the upstream answers HTTP 200 with an empty message + * (`{"message":{"role":"assistant"},"finish_reason":null}` and + * `completion_tokens == max_tokens`) — chatCore then flags the fake success as + * "Provider returned empty content" / 502 and burns a fallback attempt. + * + * Verified live 2026-08-23: max_tokens=64/100 → empty content; + * 256/512/1024 → content present (hidden reasoning consumed 196–253 of it). + * + * Floor raised budgets only — explicit large budgets and non-muse-spark models + * are untouched, and no budget is synthesized when the caller set none. + */ +export const MUSE_SPARK_MIN_OUTPUT_TOKENS = 512; + +export function applyMuseSparkMinOutputTokens(model: string, body: Record): void { + if (!model.startsWith("muse-spark")) return; + const current = body.max_tokens; + if (typeof current !== "number" || !Number.isFinite(current)) return; + if (current >= MUSE_SPARK_MIN_OUTPUT_TOKENS) return; + body.max_tokens = MUSE_SPARK_MIN_OUTPUT_TOKENS; +} + +/** + * muse-spark's gateway reports `finish_reason:"length"` whenever its hidden + * reasoning consumed part of the output budget — even when the visible + * completion is tiny relative to the requested budget (observed: ~270 + * completion tokens on a 128000-token request). OpenAI-protocol clients map a + * "length" stop onto the caller's own max-tokens cap, so Claude Code aborts a + * fully-delivered answer with "response exceeded the 128000 output token + * maximum". + * + * Rewrite `length` → `stop` when the reported completion count proves the real + * token limit was never reached (<90% of the caller's budget). Genuine + * truncations at the budget are preserved. Streaming frames carry usage before + * the terminal finish frame, so the completion count is known in time. + */ +export function normalizeMuseSparkFinishReason( + payload: Record, + requestedBudget: number | null, + /** Streaming: usage arrives in an earlier frame than the finish frame — caller passes the tracked count here. */ + completionOverride?: number | null +): void { + const choices = Array.isArray(payload.choices) ? payload.choices : []; + for (const choice of choices) { + if (!choice || typeof choice !== "object") continue; + const record = choice as Record; + if (record.finish_reason !== "length") continue; + if (requestedBudget === null || requestedBudget === undefined) continue; + const usage = payload.usage as Record | undefined; + const completion = + typeof completionOverride === "number" + ? completionOverride + : typeof usage?.completion_tokens === "number" + ? usage.completion_tokens + : null; + if (completion === null) continue; + if (completion < Math.floor(requestedBudget * 0.9)) { + record.finish_reason = "stop"; + } + } +} + +/** SSE line normalizer for muse-spark streams: tracks usage, rewrites finish frames. */ +export function createMuseSparkStreamFinishNormalizer( + requestedBudget: number | null +): (dataLine: string) => string { + let completionTokens: number | null = null; + return (line: string): string => { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:") || trimmed.includes("[DONE]")) return line; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed.slice(5).trim()); + } catch { + return line; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return line; + const payload = parsed as Record; + const usage = payload.usage as Record | undefined; + if (usage && typeof usage.completion_tokens === "number") { + completionTokens = usage.completion_tokens; + } + const hadFinish = Array.isArray(payload.choices) + ? (payload.choices as Array>).some( + (c) => c && c.finish_reason === "length" + ) + : false; + if (!hadFinish) return line; + normalizeMuseSparkFinishReason(payload, requestedBudget, completionTokens); + return `data: ${JSON.stringify(payload)}`; + }; +} + export class OpencodeExecutor extends BaseExecutor { /** Delegates to `isPremiumOpencodeModel`. Exported for testability. */ static isPremiumModel(model: string, provider: string): boolean { @@ -224,6 +324,97 @@ export class OpencodeExecutor extends BaseExecutor { markAccountSuccess(account); } + /** + * Rewrite muse-spark's bogus `finish_reason:"length"` (see the + * normalizeMuseSparkFinishReason note) to `"stop"` on both streaming and + * non-streaming success responses. Non-muse-spark models pass through + * untouched. + */ + private normalizeMuseSparkResponse( + input: ExecuteInput, + result: ExecutorExecuteResult + ): ExecutorExecuteResult { + const model = String(input.model ?? ""); + if (!model.startsWith("muse-spark")) return result; + if (!("response" in result) || !result.response?.ok || !result.response.body) return result; + const bodyObj = + input.body && typeof input.body === "object" && !Array.isArray(input.body) + ? (input.body as Record) + : null; + const rawBudget = bodyObj?.max_tokens; + const budget = typeof rawBudget === "number" && Number.isFinite(rawBudget) ? rawBudget : null; + const response = result.response; + const isSse = response.headers.get("content-type")?.includes("event-stream") ?? false; + + if (!isSse) { + // Non-streaming JSON: rewrite in a buffered pass. + const stream = new ReadableStream({ + async start(controller) { + try { + const text = await response.clone().text(); + let out = text; + try { + const parsed = JSON.parse(text) as Record; + normalizeMuseSparkFinishReason(parsed, budget); + out = JSON.stringify(parsed); + } catch { + /* not JSON — forward verbatim */ + } + controller.enqueue(new TextEncoder().encode(out)); + } catch (err) { + controller.error(err); + return; + } + controller.close(); + }, + }); + return { + ...result, + response: new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + }; + } + + // Streaming SSE: line-buffered passthrough with finish_reason rewriting. + const normalizer = createMuseSparkStreamFinishNormalizer(budget); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + const reader = response.body.getReader(); + const stream = new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + if (done) { + if (buffer.length > 0) controller.enqueue(encoder.encode(normalizer(buffer))); + controller.close(); + return; + } + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) controller.enqueue(encoder.encode(normalizer(line) + "\n")); + } catch (err) { + controller.error(err); + } + }, + cancel(reason) { + reader.cancel(reason).catch(() => undefined); + }, + }); + return { + ...result, + response: new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }), + }; + } + async execute(input: ExecuteInput) { this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model); @@ -254,6 +445,14 @@ export class OpencodeExecutor extends BaseExecutor { } try { + // muse-spark reasoning models consume the entire output budget on hidden + // server-side reasoning; small caller budgets come back as empty-message + // 200s ("Provider returned empty content"). Raise tiny budgets to the + // floor before dispatch (see MUSE_SPARK_MIN_OUTPUT_TOKENS). + if (input.body && typeof input.body === "object" && !Array.isArray(input.body)) { + applyMuseSparkMinOutputTokens(String(input.model ?? ""), input.body as Record); + } + this.syncAccountsFromCredentials(input.credentials); const { log } = input; @@ -279,7 +478,7 @@ export class OpencodeExecutor extends BaseExecutor { "OPENCODE", `upstream empty rejection on direct account (${chatcmplId}), retrying once…` ); - return await super.execute(input); + return this.normalizeMuseSparkResponse(input, await super.execute(input)); } log?.debug?.( "OPENCODE", @@ -287,7 +486,7 @@ export class OpencodeExecutor extends BaseExecutor { ); } } - return single; + return this.normalizeMuseSparkResponse(input, single); } // This loop only ever dispatches through super.execute() (the HTTP request @@ -417,7 +616,7 @@ export class OpencodeExecutor extends BaseExecutor { } this.markSuccess(account); - return result; + return this.normalizeMuseSparkResponse(input, result); } // The loop exhausted without a result. If it's because every remaining @@ -431,7 +630,10 @@ export class OpencodeExecutor extends BaseExecutor { } // All accounts returned 429 (or errored) — surface the last response. - return lastResult ?? (await super.execute(input)); + return this.normalizeMuseSparkResponse( + input, + lastResult ?? (await super.execute(input)) + ); } finally { this._requestFormat = null; } diff --git a/stryker.conf.json b/stryker.conf.json index 4b753708cd..b098f50dae 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -285,6 +285,7 @@ "tests/unit/openapi-security-tiers.test.ts", "tests/unit/claude-to-openai-glm-user-turn.test.ts", "tests/unit/opencode-autocombo-search-pair.test.ts", + "tests/unit/opencode-muse-spark-min-output.test.ts", "tests/unit/opencode-v2-config-11070.test.ts", "tests/unit/openrouter-free-model-credits-exhausted.test.ts", "tests/unit/openrouter-passthrough-models.test.ts", diff --git a/tests/unit/opencode-muse-spark-min-output.test.ts b/tests/unit/opencode-muse-spark-min-output.test.ts new file mode 100644 index 0000000000..44b151c826 --- /dev/null +++ b/tests/unit/opencode-muse-spark-min-output.test.ts @@ -0,0 +1,111 @@ +/** + * muse-spark (opencode-go) burns its entire output budget on invisible + * server-side reasoning before emitting any content. With small caller-set + * budgets the upstream answers 200 with an empty message + * (`{"message":{"role":"assistant"},"finish_reason":null}` and + * `completion_tokens == max_tokens`) — chatCore then flags the fake success as + * "Provider returned empty content" / 502. + * + * Verified live 2026-08-23: max_tokens=64 → empty; 100 → empty; + * 256/512/1024 → content present (reasoning consumed 196–253 of it). + * + * Fix: OpencodeExecutor clamps muse-spark* output budgets UP to + * MUSE_SPARK_MIN_OUTPUT_TOKENS so the reasoning phase can never consume the + * whole budget. Other models are untouched. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { applyMuseSparkMinOutputTokens, MUSE_SPARK_MIN_OUTPUT_TOKENS } = await import( + "../../open-sse/executors/opencode.ts" +); +const { + normalizeMuseSparkFinishReason, + createMuseSparkStreamFinishNormalizer, +} = await import("../../open-sse/executors/opencode.ts"); + +test("RED: muse-spark tiny max_tokens is raised to the floor", () => { + const body: Record = { model: "x", max_tokens: 64, messages: [] }; + applyMuseSparkMinOutputTokens("muse-spark-1.2-contributor", body); + assert.equal(body.max_tokens, MUSE_SPARK_MIN_OUTPUT_TOKENS); +}); + +test("RED: all muse-spark id variants are covered by the prefix match", () => { + for (const model of ["muse-spark-1", "muse-spark-1.2", "muse-spark-1.2-contributor"]) { + const body: Record = { max_tokens: 100 }; + applyMuseSparkMinOutputTokens(model, body); + assert.equal(body.max_tokens, MUSE_SPARK_MIN_OUTPUT_TOKENS, model); + } +}); + +test("RED: budgets already at or above the floor are untouched", () => { + const body: Record = { max_tokens: 4096 }; + applyMuseSparkMinOutputTokens("muse-spark-1.2-contributor", body); + assert.equal(body.max_tokens, 4096); +}); + +test("RED: non-muse-spark models are never modified", () => { + const body: Record = { max_tokens: 16 }; + applyMuseSparkMinOutputTokens("ox-alpha-free", body); + assert.equal(body.max_tokens, 16); +}); + +test("RED: missing/non-numeric max_tokens stays absent (no synthetic budget)", () => { + const body: Record = { messages: [] }; + applyMuseSparkMinOutputTokens("muse-spark-1.2-contributor", body); + assert.equal("max_tokens" in body, false); +}); + +test("RED: finish_reason length is rewritten to stop when completion is far under budget", () => { + const payload: Record = { + choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "length" }], + usage: { completion_tokens: 270 }, + }; + normalizeMuseSparkFinishReason(payload, 128000); + assert.equal((payload.choices as Array>)[0].finish_reason, "stop"); +}); + +test("RED: genuine truncation at the budget keeps finish_reason length", () => { + const payload: Record = { + choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "length" }], + usage: { completion_tokens: 127000 }, + }; + normalizeMuseSparkFinishReason(payload, 128000); + assert.equal((payload.choices as Array>)[0].finish_reason, "length"); +}); + +test("RED: non-length finish reasons and missing usage are untouched", () => { + const payload: Record = { + choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "stop" }], + }; + normalizeMuseSparkFinishReason(payload, 128000); + assert.equal((payload.choices as Array>)[0].finish_reason, "stop"); + + const noUsage: Record = { + choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "length" }], + }; + normalizeMuseSparkFinishReason(noUsage, 128000); + assert.equal( + (noUsage.choices as Array>)[0].finish_reason, + "length", + "without a completion count the rewrite must stay conservative" + ); +}); + +test("RED: stream normalizer rewrites the finish frame after the usage frame", () => { + const norm = createMuseSparkStreamFinishNormalizer(128000); + const usageLine = + 'data: {"id":"r","object":"chat.completion.chunk","choices":[],"usage":{"completion_tokens":270}}'; + assert.equal(norm(usageLine), usageLine, "usage frame itself must not change"); + const finishLine = + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"length"}]}'; + const out = JSON.parse(norm(finishLine).slice(5).trim()); + assert.equal(out.choices[0].finish_reason, "stop"); +}); + +test("RED: stream normalizer passes through [DONE], comments and non-JSON lines", () => { + const norm = createMuseSparkStreamFinishNormalizer(128000); + assert.equal(norm("data: [DONE]"), "data: [DONE]"); + assert.equal(norm(": keepalive"), ": keepalive"); + assert.equal(norm("data: not-json"), "data: not-json"); +});