Files
OmniRoute/src/lib/combos/testHealth.ts
Bob.Hou ce98c30cfb fix(combos): stop Gemini thinking from failing dashboard combo tests (#13560)
Two related fixes: the combo health probe sends `reasoning_effort: "none"` for Gemini-family models so the probe budget is not spent on thinking, and `detectMalformedNonStream` stops classifying a response with `finish_reason` `length`/`tool_calls`/`content_filter` and empty content as `empty_choices`.

The second half is the important one: it brings the post-translation check in line with `isEmptyContentResponse` (`open-sse/services/errorClassifier.ts`, `LEGIT_EMPTY_OPENAI_FINISH`), which already treated those finish reasons as legitimate. Until now a response could pass the pre-translation check and still be rewritten into a synthetic 502 afterwards — for every non-streaming completion, not just combo probes.

Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thanks @HouMinXi!
2026-09-16 01:47:32 -03:00

276 lines
8.6 KiB
TypeScript

type JsonRecord = Record<string, unknown>;
const COMBO_TEST_MAX_TOKENS = 64;
const STREAMING_MODEL_TEST_MAX_TOKENS = 64;
const COMBO_TEST_PROMPT = "Reply with exactly: pong";
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function joinNonEmpty(parts: string[]) {
return parts.filter(Boolean).join("\n").trim();
}
function extractTextFromContent(content: unknown): string {
if (typeof content === "string") return content.trim();
if (!Array.isArray(content)) return "";
return content
.map((part) => {
if (typeof part === "string") return part.trim();
const block = asRecord(part);
const blockType = typeof block.type === "string" ? block.type : "";
const blockText = typeof block.text === "string" ? block.text.trim() : "";
if (blockText && (blockType === "" || blockType === "text" || blockType === "output_text")) {
return blockText;
}
return "";
})
.filter(Boolean)
.join("\n")
.trim();
}
function extractReasoningText(record: JsonRecord): string {
const reasoningDetails = Array.isArray(record.reasoning_details) ? record.reasoning_details : [];
const detailText = reasoningDetails
.map((detail) => {
const detailRecord = asRecord(detail);
const detailType = typeof detailRecord.type === "string" ? detailRecord.type : "";
const text =
typeof detailRecord.text === "string"
? detailRecord.text.trim()
: typeof detailRecord.content === "string"
? detailRecord.content.trim()
: "";
if (
text &&
(detailType === "" ||
detailType === "reasoning" ||
detailType === "reasoning.text" ||
detailType === "thinking")
) {
return text;
}
return "";
})
.filter(Boolean);
return joinNonEmpty([
typeof record.reasoning_content === "string" ? record.reasoning_content.trim() : "",
typeof record.reasoning === "string" ? record.reasoning.trim() : "",
typeof record.reasoning_text === "string" ? record.reasoning_text.trim() : "",
joinNonEmpty(detailText),
]);
}
function getUsageReasoningTokens(body: JsonRecord): number {
const usage = asRecord(body.usage);
if (!usage) return 0;
const completionDetails = asRecord(usage.completion_tokens_details);
const topLevelReasoning =
typeof usage.reasoning_tokens === "number" && Number.isFinite(usage.reasoning_tokens)
? usage.reasoning_tokens
: 0;
const detailedReasoning =
typeof completionDetails.reasoning_tokens === "number" &&
Number.isFinite(completionDetails.reasoning_tokens)
? completionDetails.reasoning_tokens
: 0;
return Math.max(topLevelReasoning, detailedReasoning);
}
function hasReasoningOnlyCompletion(body: JsonRecord): boolean {
if (!Array.isArray(body.choices) || body.choices.length === 0) return false;
if (getUsageReasoningTokens(body) <= 0) return false;
return body.choices.some((choice) => {
const choiceRecord = asRecord(choice);
const message = asRecord(choiceRecord.message);
const finishReason =
typeof choiceRecord.finish_reason === "string" ? choiceRecord.finish_reason : "";
if (!message || message.role !== "assistant") return false;
if (!finishReason) return false;
if (extractTextFromContent(message.content)) return false;
if (extractReasoningText(message)) return false;
return true;
});
}
export function buildComboTestPrompt() {
return COMBO_TEST_PROMPT;
}
function isGeminiComboProbe(modelStr: string) {
return /(?:^|\/)gemini(?:-|$)/i.test(modelStr);
}
export function buildComboTestRequestBody(
modelStr: string,
isEmbedding: boolean = false,
options: { stream?: boolean; maxTokens?: number } = {}
) {
if (isEmbedding) {
return {
model: modelStr,
input: "Hello World",
};
}
const body: {
model: string;
messages: { role: string; content: string }[];
max_tokens: number;
stream: boolean;
reasoning_effort?: "none";
} = {
model: modelStr,
messages: [{ role: "user", content: buildComboTestPrompt() }],
// Keep the smoke probe short so reasoning-heavy models do not burn the
// health-check budget on arithmetic.
max_tokens:
options.maxTokens ??
(options.stream ? STREAMING_MODEL_TEST_MAX_TOKENS : COMBO_TEST_MAX_TOKENS),
stream: options.stream ?? false,
};
// Gemini 3.8 flash-high injects thinkingLevel=high unless the documented
// off-switch is set. Other providers must not see this field: some
// OpenAI-compatible endpoints 400 unknown parameters.
if (isGeminiComboProbe(modelStr)) {
body.reasoning_effort = "none";
}
return body;
}
export type ComboTestStreamResult = {
text: string;
error?: { message: string; statusCode?: number };
};
function extractStreamError(body: JsonRecord): ComboTestStreamResult["error"] {
const error = asRecord(body.error);
const message =
typeof error.message === "string"
? error.message.trim()
: typeof body.message === "string"
? body.message.trim()
: "";
if (!message) return undefined;
const status = error.status ?? error.statusCode ?? error.code ?? body.status ?? body.statusCode;
const statusCode =
typeof status === "number"
? status
: typeof status === "string" && /^\d+$/.test(status)
? Number(status)
: undefined;
return {
message,
...(Number.isInteger(statusCode) ? { statusCode } : {}),
};
}
function extractStreamPayload(payload: string): ComboTestStreamResult | undefined {
try {
const body = JSON.parse(payload);
const error = extractStreamError(asRecord(body));
if (error) return { text: "", error };
const collected: string[] = [];
const direct = extractComboTestResponseText(body);
if (direct) collected.push(direct);
for (const choice of Array.isArray(body?.choices) ? body.choices : []) {
const delta = asRecord(choice?.delta);
const content = extractTextFromContent(delta.content);
const reasoning = extractReasoningText(delta);
if (content) collected.push(content);
else if (reasoning) collected.push(reasoning);
}
return { text: collected.join("") };
} catch {
// Ignore malformed/non-JSON SSE events; a later valid event can still
// prove that the model is healthy.
return undefined;
}
}
export function extractComboTestStreamResult(streamBody: string): ComboTestStreamResult {
const collected: string[] = [];
let streamError: ComboTestStreamResult["error"];
for (const line of streamBody.split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
const result = extractStreamPayload(payload);
if (!result) continue;
if (result.error) streamError = result.error;
else if (result.text) collected.push(result.text);
}
return { text: collected.join("").trim(), ...(streamError ? { error: streamError } : {}) };
}
export function extractComboTestStreamText(streamBody: string): string {
return extractComboTestStreamResult(streamBody).text;
}
export function extractComboTestResponseText(responseBody: unknown): string {
const body = asRecord(responseBody);
if (typeof body.output_text === "string" && body.output_text.trim()) {
return body.output_text.trim();
}
if (Array.isArray(body.data) && body.data[0]?.embedding) {
return "[Embedding generated successfully]";
}
if (Array.isArray(body.choices)) {
for (const choice of body.choices) {
const choiceRecord = asRecord(choice);
const message = asRecord(choiceRecord.message);
const messageText = extractTextFromContent(message.content);
if (messageText) return messageText;
const reasoningText = extractReasoningText(message);
if (reasoningText) return reasoningText;
if (typeof choiceRecord.text === "string" && choiceRecord.text.trim()) {
return choiceRecord.text.trim();
}
}
}
if (Array.isArray(body.output)) {
for (const item of body.output) {
const itemRecord = asRecord(item);
const contentText = extractTextFromContent(itemRecord.content);
if (contentText) return contentText;
const reasoningText = extractReasoningText(itemRecord);
if (reasoningText) return reasoningText;
}
}
const topLevelText = extractTextFromContent(body.content);
if (topLevelText) return topLevelText;
const topLevelReasoning = extractReasoningText(body);
if (topLevelReasoning) return topLevelReasoning;
if (hasReasoningOnlyCompletion(body)) {
return "[reasoning-only completion]";
}
return "";
}