Files
OmniRoute/tests/unit/opencode-muse-spark-min-output.test.ts
Harvey Doan 3d8448b45e 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!
2026-08-23 09:47:10 -03:00

112 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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");
});