fix(sse): floor muse-spark output budget to prevent empty-content 502s (#11214)

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!
This commit is contained in:
Harvey Doan
2026-08-23 19:47:10 +07:00
committed by GitHub
parent e66181b182
commit 3d8448b45e
3 changed files with 319 additions and 5 deletions

View File

@@ -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 196253 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<string, unknown>): 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<string, unknown>,
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<string, unknown>;
if (record.finish_reason !== "length") continue;
if (requestedBudget === null || requestedBudget === undefined) continue;
const usage = payload.usage as Record<string, unknown> | 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<string, unknown>;
const usage = payload.usage as Record<string, unknown> | undefined;
if (usage && typeof usage.completion_tokens === "number") {
completionTokens = usage.completion_tokens;
}
const hadFinish = Array.isArray(payload.choices)
? (payload.choices as Array<Record<string, unknown>>).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<string, unknown>)
: 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<Uint8Array>({
async start(controller) {
try {
const text = await response.clone().text();
let out = text;
try {
const parsed = JSON.parse(text) as Record<string, unknown>;
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<Uint8Array>({
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<string, unknown>);
}
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;
}

View File

@@ -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",

View File

@@ -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 196253 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = {
choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "length" }],
usage: { completion_tokens: 270 },
};
normalizeMuseSparkFinishReason(payload, 128000);
assert.equal((payload.choices as Array<Record<string, unknown>>)[0].finish_reason, "stop");
});
test("RED: genuine truncation at the budget keeps finish_reason length", () => {
const payload: Record<string, unknown> = {
choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "length" }],
usage: { completion_tokens: 127000 },
};
normalizeMuseSparkFinishReason(payload, 128000);
assert.equal((payload.choices as Array<Record<string, unknown>>)[0].finish_reason, "length");
});
test("RED: non-length finish reasons and missing usage are untouched", () => {
const payload: Record<string, unknown> = {
choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "stop" }],
};
normalizeMuseSparkFinishReason(payload, 128000);
assert.equal((payload.choices as Array<Record<string, unknown>>)[0].finish_reason, "stop");
const noUsage: Record<string, unknown> = {
choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "length" }],
};
normalizeMuseSparkFinishReason(noUsage, 128000);
assert.equal(
(noUsage.choices as Array<Record<string, unknown>>)[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");
});