mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
Boarded with 13 sibling PRs into one worktree off release/v3.8.51 and validated as a set: 132 focused tests pass across all 15 test files in the batch, typecheck:core is clean, check-changelog-integrity reports no lost base bullets, and check-file-size is green. Your PR merged without conflict against its siblings. Thank you — the write-up made this reviewable: measuring the behaviour on the release tip and showing the before/after table meant the defect could be confirmed rather than taken on faith.
This commit is contained in:
1
changelog.d/fixes/0000-responses-node-model-test.md
Normal file
1
changelog.d/fixes/0000-responses-node-model-test.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** model health tests for a provider node set to the Responses API now call `/v1/responses` with a Responses-shaped body instead of `/v1/chat/completions` — those models were reported as `Provider returned HTTP 200 but no text content` even though the same model answered normally through `/v1/responses` ([#13070](https://github.com/diegosouzapw/OmniRoute/issues/13070))
|
||||
@@ -3,7 +3,9 @@ import { POST as postChatCompletion } from "@/app/api/v1/chat/completions/route"
|
||||
import { POST as postAudioTranscription } from "@/app/api/v1/audio/transcriptions/route";
|
||||
import { handleValidatedEmbeddingRequestBody } from "@/app/api/v1/embeddings/route";
|
||||
import { POST as postRerank } from "@/app/api/v1/rerank/route";
|
||||
import { POST as postResponses } from "@/app/api/v1/responses/route";
|
||||
import {
|
||||
buildComboTestPrompt,
|
||||
buildComboTestRequestBody,
|
||||
extractComboTestResponseText,
|
||||
extractComboTestStreamResult,
|
||||
@@ -29,6 +31,10 @@ const ZAI_WEB_PROVIDER_ID = "zai-web";
|
||||
const ZAI_WEB_TEST_TIMEOUT_MS = 60_000;
|
||||
const SLOW_WEB_TEST_MODELS = new Set(["dola-pro"]);
|
||||
const STREAMING_CHAT_TEST_MAX_TOKENS = 64;
|
||||
// Responses calls the same budget `max_output_tokens`; `max_tokens` is silently
|
||||
// ignored on that endpoint, which would let a reasoning model spend the whole
|
||||
// default budget before emitting any visible text.
|
||||
const RESPONSES_TEST_MAX_OUTPUT_TOKENS = 256;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -175,6 +181,26 @@ export function buildInternalChatRequest(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInternalResponsesRequest(
|
||||
testBody: Record<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
connectionId?: string
|
||||
) {
|
||||
return new Request(`${INTERNAL_ORIGIN}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Internal-Test": "combo-health-check",
|
||||
"X-OmniRoute-No-Cache": "true",
|
||||
"X-OmniRoute-Compression": "off",
|
||||
"X-Request-Id": `model-test-${randomUUID()}`,
|
||||
...(connectionId ? { "X-OmniRoute-Connection": connectionId } : {}),
|
||||
},
|
||||
body: JSON.stringify(testBody),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInternalRerankRequest(
|
||||
testBody: Record<string, unknown>,
|
||||
signal: AbortSignal,
|
||||
@@ -265,7 +291,22 @@ export function detectTestKind(modelStr: string, customModel: any, nodeApiType?:
|
||||
lowerModel.includes("text-embed") ||
|
||||
lowerModel.includes("jina-clip") ||
|
||||
lowerModel.includes("colbert"));
|
||||
return { isRerank, isEmbedding, isAudioTranscription };
|
||||
// A Responses node answers on /v1/responses only. Without this the model fell
|
||||
// through to the chat branch below, which posts a Chat Completions body to
|
||||
// /v1/chat/completions: the route can still answer 200 while carrying nothing a
|
||||
// Chat Completions reader recognises, so the model was marked unhealthy with
|
||||
// "Provider returned HTTP 200 but no text content" (#13070).
|
||||
//
|
||||
// Last in the chain deliberately: a Responses-typed node can still host an
|
||||
// embedding or rerank model, and those endpoints stay right for it.
|
||||
const isResponses =
|
||||
!isAudioTranscription &&
|
||||
!isRerank &&
|
||||
!isEmbedding &&
|
||||
(apiFormat === "responses" ||
|
||||
nodeType === "responses" ||
|
||||
supportedEndpoints.includes("responses"));
|
||||
return { isRerank, isEmbedding, isAudioTranscription, isResponses };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -424,7 +465,7 @@ export async function runSingleModelTest(
|
||||
findCustomModelMetadata(providerId, fullModelStr),
|
||||
findProviderNodeApiType(providerId),
|
||||
]);
|
||||
const { isRerank, isEmbedding, isAudioTranscription } = detectTestKind(
|
||||
const { isRerank, isEmbedding, isAudioTranscription, isResponses } = detectTestKind(
|
||||
fullModelStr,
|
||||
customModel,
|
||||
nodeApiType
|
||||
@@ -443,10 +484,22 @@ export async function runSingleModelTest(
|
||||
}
|
||||
: isAudioTranscription
|
||||
? { model: fullModelStr }
|
||||
: buildComboTestRequestBody(fullModelStr, isEmbedding, {
|
||||
stream: !isEmbedding && streamChat,
|
||||
maxTokens: !isEmbedding && streamChat ? STREAMING_CHAT_TEST_MAX_TOKENS : undefined,
|
||||
});
|
||||
: isResponses
|
||||
? {
|
||||
model: fullModelStr,
|
||||
// Responses takes `input`, not `messages`.
|
||||
input: buildComboTestPrompt(),
|
||||
max_output_tokens: RESPONSES_TEST_MAX_OUTPUT_TOKENS,
|
||||
// Non-streaming on purpose: the SSE reader below understands Chat
|
||||
// Completions deltas and the `output_text`/`output[]` shapes, but not
|
||||
// Responses stream events (`response.output_text.delta`), so a
|
||||
// streamed answer would read as empty — the very failure being fixed.
|
||||
stream: false,
|
||||
}
|
||||
: buildComboTestRequestBody(fullModelStr, isEmbedding, {
|
||||
stream: !isEmbedding && streamChat,
|
||||
maxTokens: !isEmbedding && streamChat ? STREAMING_CHAT_TEST_MAX_TOKENS : undefined,
|
||||
});
|
||||
|
||||
// Per-model AbortController. We track whether the timeout fired so we can
|
||||
// distinguish "rate-limit queue aborted" (withRateLimit threw AbortError
|
||||
@@ -473,6 +526,9 @@ export async function runSingleModelTest(
|
||||
buildInternalAudioTranscriptionRequest(fullModelStr, signal, connectionId)
|
||||
);
|
||||
}
|
||||
if (isResponses) {
|
||||
return postResponses(buildInternalResponsesRequest(testBody, signal, connectionId));
|
||||
}
|
||||
return postChatCompletion(buildInternalChatRequest(testBody, signal, connectionId));
|
||||
};
|
||||
|
||||
@@ -577,7 +633,7 @@ export async function runSingleModelTest(
|
||||
// deactivated") would run outside runAsProbe and could still reach
|
||||
// markAccountUnavailable (#9817).
|
||||
const parsedResponse = await runAsProbe(() =>
|
||||
extractModelTestResponseText(res, !isEmbedding && !isRerank && streamChat)
|
||||
extractModelTestResponseText(res, !isEmbedding && !isRerank && !isResponses && streamChat)
|
||||
);
|
||||
responseText = parsedResponse.text;
|
||||
streamError = parsedResponse.error;
|
||||
|
||||
@@ -112,7 +112,7 @@ function getRandomFiveDigitNumber() {
|
||||
return COMBO_TEST_OPERAND_MIN + Math.floor(Math.random() * COMBO_TEST_OPERAND_RANGE);
|
||||
}
|
||||
|
||||
function buildComboTestPrompt() {
|
||||
export function buildComboTestPrompt() {
|
||||
const left = getRandomFiveDigitNumber();
|
||||
const right = getRandomFiveDigitNumber();
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ test("detectTestKind defaults to a plain chat test for ordinary models", () => {
|
||||
isRerank: false,
|
||||
isEmbedding: false,
|
||||
isAudioTranscription: false,
|
||||
isResponses: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +96,7 @@ test("detectTestKind detects rerank by id and by metadata, and rerank wins over
|
||||
isRerank: true,
|
||||
isEmbedding: false,
|
||||
isAudioTranscription: false,
|
||||
isResponses: false,
|
||||
});
|
||||
// apiFormat metadata drives detection even when the id is opaque
|
||||
assert.equal(detectTestKind("vendor/opaque-model", { apiFormat: "rerank" }).isRerank, true);
|
||||
@@ -116,6 +118,7 @@ test("detectTestKind detects audio transcription from metadata, and it wins over
|
||||
isRerank: false,
|
||||
isEmbedding: false,
|
||||
isAudioTranscription: true,
|
||||
isResponses: false,
|
||||
});
|
||||
assert.equal(
|
||||
detectTestKind("vendor/opaque-model", { supportedEndpoints: ["audio-transcriptions"] })
|
||||
@@ -152,6 +155,7 @@ test("detectTestKind falls back to the provider node's configured apiType", () =
|
||||
isRerank: false,
|
||||
isEmbedding: false,
|
||||
isAudioTranscription: false,
|
||||
isResponses: false,
|
||||
});
|
||||
|
||||
// Per-model metadata still wins when present.
|
||||
|
||||
184
tests/unit/responses-node-model-test-13070.test.ts
Normal file
184
tests/unit/responses-node-model-test-13070.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* #13070 -- the dashboard's per-model health test ignored a provider node's
|
||||
* `apiType: "responses"`.
|
||||
*
|
||||
* `detectTestKind` mapped a node's apiType to audio, rerank and embeddings only,
|
||||
* so every text model on a Responses node fell through to the chat branch and
|
||||
* `buildInternalChatRequest` posted a Chat Completions body to
|
||||
* /v1/chat/completions. A Responses-native upstream can answer 200 to that and
|
||||
* still carry nothing a Chat Completions reader recognises, so the model went
|
||||
* red with "Provider returned HTTP 200 but no text content" while the same
|
||||
* model answered normally through /v1/responses.
|
||||
*
|
||||
* The classification tests below are cheap, but on their own they prove
|
||||
* nothing: reverting the dispatch in runSingleModelTest and leaving
|
||||
* detectTestKind alone keeps them all green. The last test is the one that
|
||||
* fails in that case -- it reads the body that actually leaves for the
|
||||
* upstream and asserts it is Responses-shaped.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-13070-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const nodesDb = await import("../../src/lib/db/providers/nodes.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const runner = await import("../../src/lib/api/modelTestRunner.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
const NODE_ID = "openai-compatible-responses-13070-0000-4000-8000-000000000000";
|
||||
const MODEL_ID = "opaque-text-model";
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detectTestKind — a Responses node must be recognised, and must not steal the
|
||||
// endpoints that were already right for it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("detectTestKind reports a Responses node, whichever field carries the signal", () => {
|
||||
// An imported model has no per-model metadata at all; the node's apiType is
|
||||
// the only signal available, which is exactly the reported case.
|
||||
assert.equal(runner.detectTestKind("vendor/opaque-guid", null, "responses").isResponses, true);
|
||||
assert.equal(
|
||||
runner.detectTestKind("vendor/opaque-guid", { apiFormat: "responses" }).isResponses,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
runner.detectTestKind("vendor/opaque-guid", { supportedEndpoints: ["responses"] }).isResponses,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("detectTestKind leaves an ordinary chat model alone", () => {
|
||||
const kind = runner.detectTestKind("openai/gpt-4o", null);
|
||||
assert.equal(kind.isResponses, false);
|
||||
assert.equal(kind.isRerank, false);
|
||||
assert.equal(kind.isEmbedding, false);
|
||||
assert.equal(kind.isAudioTranscription, false);
|
||||
});
|
||||
|
||||
test("embeddings, rerank and audio still win over a Responses node type", () => {
|
||||
// A Responses-typed node can host these too, and /v1/responses is the wrong
|
||||
// endpoint for all three. Losing this ordering would break working setups
|
||||
// rather than fix a broken one.
|
||||
assert.equal(
|
||||
runner.detectTestKind("baai/bge-m3", null, "responses").isEmbedding,
|
||||
true,
|
||||
"embedding id must still route to embeddings"
|
||||
);
|
||||
assert.equal(runner.detectTestKind("baai/bge-m3", null, "responses").isResponses, false);
|
||||
|
||||
assert.equal(runner.detectTestKind("jina/jina-reranker-v2", null, "responses").isRerank, true);
|
||||
assert.equal(
|
||||
runner.detectTestKind("jina/jina-reranker-v2", null, "responses").isResponses,
|
||||
false
|
||||
);
|
||||
|
||||
const audio = runner.detectTestKind(
|
||||
"vendor/whisper",
|
||||
{ apiFormat: "audio-transcriptions" },
|
||||
"responses"
|
||||
);
|
||||
assert.equal(audio.isAudioTranscription, true);
|
||||
assert.equal(audio.isResponses, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildInternalResponsesRequest — the endpoint, and the bypass headers the
|
||||
// other builders carry. A health check that lost X-Internal-Test would be
|
||||
// rejected by strict mode instead of testing anything.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("buildInternalResponsesRequest targets /v1/responses with the health-check headers", async () => {
|
||||
const controller = new AbortController();
|
||||
const req = runner.buildInternalResponsesRequest(
|
||||
{ model: "vendor/opaque", input: "hi" },
|
||||
controller.signal,
|
||||
"conn-1"
|
||||
);
|
||||
|
||||
assert.equal(new URL(req.url).pathname, "/v1/responses");
|
||||
assert.equal(req.method, "POST");
|
||||
assert.equal(req.headers.get("X-Internal-Test"), "combo-health-check");
|
||||
assert.equal(req.headers.get("X-OmniRoute-No-Cache"), "true");
|
||||
assert.equal(req.headers.get("X-OmniRoute-Compression"), "off");
|
||||
assert.equal(req.headers.get("X-OmniRoute-Connection"), "conn-1");
|
||||
assert.deepEqual(await req.json(), { model: "vendor/opaque", input: "hi" });
|
||||
});
|
||||
|
||||
test("buildInternalResponsesRequest omits the connection header when there is no connection", () => {
|
||||
const req = runner.buildInternalResponsesRequest({ model: "m" }, new AbortController().signal);
|
||||
assert.equal(req.headers.get("X-OmniRoute-Connection"), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The wiring. Everything above passes against the unfixed runner as long as
|
||||
// detectTestKind alone is changed; this one does not.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("a model on a Responses node is probed on the internal /v1/responses route", async () => {
|
||||
await nodesDb.createProviderNode({
|
||||
id: NODE_ID,
|
||||
type: "openai-compatible",
|
||||
name: "Responses Node 13070",
|
||||
prefix: "resp13070",
|
||||
apiType: "responses",
|
||||
baseUrl: "https://example.test/v1",
|
||||
});
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: NODE_ID,
|
||||
authType: "apikey",
|
||||
name: "responses-node-13070",
|
||||
apiKey: "sk-responses-node-13070",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
// A minimal Responses reply. `output_text` is a field the existing
|
||||
// extractor already understands, which is why this fix needs no reader
|
||||
// change -- only the request side was ever wrong.
|
||||
new Response(JSON.stringify({ output_text: "4" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await runner.runSingleModelTest({
|
||||
providerId: NODE_ID,
|
||||
modelId: MODEL_ID,
|
||||
connectionId: String(connection.id),
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
await callLogs.waitForCallLogSaves(10_000);
|
||||
const logs = await callLogs.getCallLogs({});
|
||||
const probe = logs.find((entry: { model?: string | null }) =>
|
||||
String(entry.model ?? "").includes(MODEL_ID)
|
||||
);
|
||||
|
||||
assert.ok(probe, "the model test should have produced a call log entry");
|
||||
// This is the line from the report: the call log showed
|
||||
// path=/v1/chat/completions for a Responses node. Asserting on the
|
||||
// upstream request instead would prove nothing -- the router translates a
|
||||
// chat body into Responses shape for such a node either way, so that
|
||||
// assertion stays green with the dispatch below reverted.
|
||||
assert.equal(
|
||||
probe.path,
|
||||
"/v1/responses",
|
||||
`a Responses node must be probed on /v1/responses (call log says ${probe.path})`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user