mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 21:02:50 +03:00
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!
276 lines
8.6 KiB
TypeScript
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 "";
|
|
}
|