mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
feat: Fix support for claude-cli using Gemini provider (#1779)
Integrated into release/v3.7.5
This commit is contained in:
@@ -2888,17 +2888,18 @@ export async function handleChatCore({
|
||||
} else {
|
||||
try {
|
||||
responseBody = rawBody ? JSON.parse(rawBody) : {};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
appendRequestLog({
|
||||
model,
|
||||
provider,
|
||||
connectionId,
|
||||
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
||||
}).catch(() => {});
|
||||
const detailedError = `Invalid JSON response from provider (error: ${err instanceof Error ? err.message : String(err)}): ${rawBody.substring(0, 1000)}`;
|
||||
const invalidJsonMessage = "Invalid JSON response from provider";
|
||||
persistAttemptLogs({
|
||||
status: HTTP_STATUS.BAD_GATEWAY,
|
||||
error: invalidJsonMessage,
|
||||
error: detailedError,
|
||||
providerRequest: finalBody || translatedBody,
|
||||
providerResponse: normalizedProviderPayload,
|
||||
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage),
|
||||
|
||||
@@ -127,7 +127,7 @@ function toTrimmedString(value): string | null {
|
||||
* 1. Body is valid JSON
|
||||
* 2. Has at least one choice with non-empty content or tool_calls
|
||||
*/
|
||||
async function validateResponseQuality(
|
||||
export async function validateResponseQuality(
|
||||
response: Response,
|
||||
isStreaming: boolean,
|
||||
log: { warn?: (...args: unknown[]) => void }
|
||||
@@ -161,7 +161,7 @@ async function validateResponseQuality(
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
if (text.startsWith("data:")) return { valid: true };
|
||||
if (text.startsWith("data:") || text.startsWith("event:")) return { valid: true };
|
||||
return { valid: false, reason: "response is not valid JSON" };
|
||||
}
|
||||
|
||||
|
||||
@@ -178,7 +178,9 @@ export function claudeToGeminiRequest(model, body, stream) {
|
||||
|
||||
// ── Thinking config ────────────────────────────────────────────
|
||||
// Priority: thinking.budget_tokens (Claude native) > output_config.effort (Claude Code).
|
||||
if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) {
|
||||
if (model.startsWith("gemma-4")) {
|
||||
// gemma-4 models returns - 400: Thinking budget is not supported for this model
|
||||
} else if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) {
|
||||
result.generationConfig.thinkingConfig = {
|
||||
thinkingBudget: body.thinking.budget_tokens,
|
||||
includeThoughts: true,
|
||||
|
||||
@@ -45,14 +45,23 @@ function PayloadSection({ title, json, onCopy }) {
|
||||
|
||||
// ─── Detail Modal ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function RequestLoggerDetail({ log, detail, loading, onClose, onCopy }) {
|
||||
type StreamChunks = Record<string, string | string[]>;
|
||||
|
||||
export default function RequestLoggerDetail({
|
||||
log,
|
||||
detail,
|
||||
loading,
|
||||
debugEnabled,
|
||||
onClose,
|
||||
onCopy,
|
||||
}) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
globalThis.addEventListener("keydown", handler);
|
||||
return () => globalThis.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const statusStyle = getStatusStyle(log.status);
|
||||
@@ -64,6 +73,9 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
label: (log.provider || "-").toUpperCase(),
|
||||
};
|
||||
|
||||
const providerStatus = detail?.pipelinePayloads?.providerResponse?.status;
|
||||
const hasStatusDiscrepancy = providerStatus && providerStatus !== log.status;
|
||||
|
||||
const formatDate = (iso) => {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
@@ -104,6 +116,36 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
: [];
|
||||
const requestJson = detail?.requestBody ? toPrettyJson(detail.requestBody) : null;
|
||||
const responseJson = detail?.responseBody ? toPrettyJson(detail.responseBody) : null;
|
||||
const streamChunksText = (() => {
|
||||
if (!debugEnabled || !detail?.pipelinePayloads?.streamChunks) return null;
|
||||
let chunks: StreamChunks = detail.pipelinePayloads.streamChunks;
|
||||
|
||||
// If stored as a JSON string, try to parse it so we can render joined raw chunks
|
||||
if (typeof chunks === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(chunks);
|
||||
chunks = parsed;
|
||||
} catch {
|
||||
// Keep as string and return raw text (don't JSON-stringify)
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks && typeof chunks === "object") {
|
||||
try {
|
||||
return Object.entries(chunks)
|
||||
.map(([stage, arr]) => {
|
||||
const joined = Array.isArray(arr) ? arr.join("") : String(arr);
|
||||
return `--- ${stage} ---\n${joined}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
} catch {
|
||||
return toPrettyJson(chunks);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
const detailIssue =
|
||||
detail?.detailState === "missing"
|
||||
? "Detailed payload artifact is no longer available for this log entry."
|
||||
@@ -144,14 +186,28 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
{/* Modal Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-xs font-bold"
|
||||
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
<span className="font-bold text-lg">{log.method}</span>
|
||||
<span className="text-text-muted font-mono text-sm">{log.path}</span>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block px-2.5 py-1 rounded text-xs font-bold"
|
||||
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
{hasStatusDiscrepancy && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded bg-bg-subtle border border-border text-text-muted">
|
||||
Upstream: {providerStatus}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-bold text-lg">{log.method}</span>
|
||||
</div>
|
||||
{hasStatusDiscrepancy && (
|
||||
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium mt-0.5">
|
||||
OmniRoute returned {log.status} even though provider returned {providerStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-text-muted font-mono text-sm self-center ml-2">{log.path}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -333,6 +389,14 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
/>
|
||||
))}
|
||||
|
||||
{streamChunksText && (
|
||||
<PayloadSection
|
||||
title="Event Stream (Debug)"
|
||||
json={streamChunksText}
|
||||
onCopy={() => onCopy(streamChunksText)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{payloadSections.length === 0 && responseJson && (
|
||||
<PayloadSection
|
||||
title="Response Payload (Legacy)"
|
||||
|
||||
@@ -885,6 +885,7 @@ export default function RequestLoggerV2() {
|
||||
log={selectedLog}
|
||||
detail={detailData}
|
||||
loading={detailLoading}
|
||||
debugEnabled={detailLoggingEnabled}
|
||||
onClose={closeDetail}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
|
||||
130
tests/manual/claude.http
Normal file
130
tests/manual/claude.http
Normal file
@@ -0,0 +1,130 @@
|
||||
###
|
||||
# @name Gemini - Anthropics Messages API-format - streaming response test - max_tokens test
|
||||
POST {{omniroute-address}}/v1/messages
|
||||
x-api-key: {{OMNIROUTE_API_KEY}}
|
||||
anthropic-version: 2023-06-01
|
||||
Content-Type: text/event-stream
|
||||
|
||||
{
|
||||
"model": "default",
|
||||
"max_tokens": 10,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hi"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# @name Gemini - Anthropics Messages API-format - non-streaming response test - max_tokens test
|
||||
POST {{omniroute-address}}/v1/messages
|
||||
x-api-key: {{OMNIROUTE_API_KEY}}
|
||||
anthropic-version: 2023-06-01
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "default",
|
||||
"stream": false,
|
||||
"max_tokens": 10,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hi"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# @name Gemini - Anthropics Messages API-format - streaming response test - thinking-budget not supported
|
||||
POST {{omniroute-address}}/v1/messages
|
||||
x-api-key: {{OMNIROUTE_API_KEY}}
|
||||
anthropic-version: 2023-06-01
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "gemini/gemma-4-31b-it",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hi"
|
||||
}
|
||||
],
|
||||
"output_config": {
|
||||
"effort": "high"
|
||||
},
|
||||
"stream": true
|
||||
}
|
||||
|
||||
###
|
||||
# @name Gemini - Anthropics Messages API-format - streaming response test
|
||||
# @timeout 300
|
||||
POST {{omniroute-address}}/v1/messages
|
||||
x-api-key: {{OMNIROUTE_API_KEY}}
|
||||
anthropic-version: 2023-06-01
|
||||
Content-Type: text/event-stream
|
||||
|
||||
{
|
||||
"model": "default",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hi"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# @name Gemini - Anthropics Messages API-format - non-streaming response test
|
||||
# @timeout 300
|
||||
POST {{omniroute-address}}/v1/messages
|
||||
x-api-key: {{OMNIROUTE_API_KEY}}
|
||||
anthropic-version: 2023-06-01
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "default",
|
||||
"stream": false,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hi"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# @name Gemini - OpenAI API-format - streaming response test - max_tokens test
|
||||
POST {{omniroute-address}}/v1/chat/completions
|
||||
Content-Type: text/event-stream
|
||||
Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
|
||||
{
|
||||
"model": "default",
|
||||
"max_tokens": 10,
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hi"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
###
|
||||
# @name Gemini - OpenAI API-format - non-streaming response test - max_tokens test
|
||||
POST {{omniroute-address}}/v1/chat/completions
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{OMNIROUTE_API_KEY}}
|
||||
|
||||
{
|
||||
"model": "default",
|
||||
"max_tokens": 10,
|
||||
"stream": false,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "hi"
|
||||
}
|
||||
]
|
||||
}
|
||||
95
tests/unit/call-log-stream-debug.test.ts
Normal file
95
tests/unit/call-log-stream-debug.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
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-stream-debug-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("saveCallLog preserves streamChunks in pipeline payloads", async () => {
|
||||
const logId = "req_stream_debug_1";
|
||||
|
||||
const streamChunks = {
|
||||
provider: [
|
||||
'data: {"content": "hello"}\n\n',
|
||||
'data: {"content": " world"}\n\n',
|
||||
"data: [DONE]\n\n",
|
||||
],
|
||||
openai: [
|
||||
'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
],
|
||||
client: [
|
||||
'data: {"choices":[{"delta":{"content":"hello"}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"content":" world"}}]}\n\n',
|
||||
],
|
||||
};
|
||||
|
||||
await callLogs.saveCallLog({
|
||||
id: logId,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 200,
|
||||
model: "gemini/gemma-4-26b-a4b-it",
|
||||
provider: "gemini",
|
||||
pipelinePayloads: {
|
||||
clientRawRequest: { body: { stream: true } },
|
||||
streamChunks: streamChunks,
|
||||
},
|
||||
});
|
||||
|
||||
const detail = await callLogs.getCallLogById(logId);
|
||||
|
||||
assert.ok(detail, "Log detail should exist");
|
||||
assert.ok(detail.pipelinePayloads, "Pipeline payloads should exist");
|
||||
assert.ok(detail.pipelinePayloads.streamChunks, "streamChunks should exist in pipeline payloads");
|
||||
|
||||
assert.deepEqual(detail.pipelinePayloads.streamChunks.provider, streamChunks.provider);
|
||||
assert.deepEqual(detail.pipelinePayloads.streamChunks.openai, streamChunks.openai);
|
||||
assert.deepEqual(detail.pipelinePayloads.streamChunks.client, streamChunks.client);
|
||||
});
|
||||
|
||||
test("saveCallLog preserves partial streamChunks", async () => {
|
||||
const logId = "req_stream_debug_2";
|
||||
|
||||
const streamChunks = {
|
||||
provider: ["raw chunk 1", "raw chunk 2"],
|
||||
// other stages missing
|
||||
};
|
||||
|
||||
await callLogs.saveCallLog({
|
||||
id: logId,
|
||||
status: 200,
|
||||
model: "test-model",
|
||||
pipelinePayloads: {
|
||||
streamChunks: streamChunks,
|
||||
},
|
||||
});
|
||||
|
||||
const detail = await callLogs.getCallLogById(logId);
|
||||
|
||||
assert.ok(detail?.pipelinePayloads?.streamChunks, "streamChunks should exist");
|
||||
assert.deepEqual(detail.pipelinePayloads.streamChunks.provider, streamChunks.provider);
|
||||
assert.equal(detail.pipelinePayloads.streamChunks.openai, undefined);
|
||||
assert.equal(detail.pipelinePayloads.streamChunks.client, undefined);
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { updateSettings } from "../../src/lib/db/settings";
|
||||
|
||||
const TEST_LOG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-console-log-levels-"));
|
||||
const TEST_LOG_PATH = path.join(TEST_LOG_DIR, "app.log");
|
||||
|
||||
@@ -12,7 +14,12 @@ process.env.APP_LOG_FILE_PATH = TEST_LOG_PATH;
|
||||
|
||||
const route = await import("../../src/app/api/logs/console/route.ts");
|
||||
|
||||
test.after(() => {
|
||||
test.before(async () => {
|
||||
await updateSettings({ requireLogin: false });
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await updateSettings({ requireLogin: true });
|
||||
if (originalLogFilePath === undefined) {
|
||||
delete process.env.APP_LOG_FILE_PATH;
|
||||
} else {
|
||||
|
||||
@@ -28,6 +28,15 @@ import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
import { getDbInstance } from "../../src/lib/db/core.ts";
|
||||
import { getReasoningCache, setReasoningCache } from "../../src/lib/db/reasoningCache.ts";
|
||||
import { DELETE, GET } from "../../src/app/api/cache/reasoning/route.ts";
|
||||
import { updateSettings } from "../../src/lib/db/settings";
|
||||
|
||||
before(async () => {
|
||||
await updateSettings({ requireLogin: false });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await updateSettings({ requireLogin: true });
|
||||
});
|
||||
|
||||
describe("Reasoning Replay Cache — Service Layer", () => {
|
||||
before(() => {
|
||||
|
||||
181
tests/unit/request-log-detail-stream.test.ts
Normal file
181
tests/unit/request-log-detail-stream.test.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const { default: RequestLoggerDetail } =
|
||||
await import("../../src/shared/components/RequestLoggerDetail.tsx");
|
||||
|
||||
test("event stream shows only when debugEnabled and appears above legacy response", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(RequestLoggerDetail, {
|
||||
log: {
|
||||
status: 504,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
timestamp: "2026-04-09T21:27:08.000Z",
|
||||
duration: 2500,
|
||||
provider: "gemini",
|
||||
sourceFormat: "openai-chat",
|
||||
model: "test-model",
|
||||
tokens: { in: 1, out: 1 },
|
||||
},
|
||||
detail: {
|
||||
pipelinePayloads: {
|
||||
streamChunks: {
|
||||
provider: ['data: {"content": "hello"}\n\n'],
|
||||
openai: ['data: {"choices":[{"delta":{"content":"hi"}}]}\n\n'],
|
||||
},
|
||||
// No providerResponse here so payloadSections will be empty and the legacy
|
||||
// response payload should still be rendered; Event Stream must appear above it.
|
||||
},
|
||||
responseBody: "{}",
|
||||
},
|
||||
|
||||
loading: false,
|
||||
debugEnabled: true,
|
||||
onClose: () => {},
|
||||
onCopy: async () => true,
|
||||
})
|
||||
);
|
||||
|
||||
assert.notEqual(
|
||||
html.indexOf(">Event Stream (Debug)<"),
|
||||
-1,
|
||||
"Event Stream should be present when debugEnabled"
|
||||
);
|
||||
// Ensure the legacy response payload is present and that the Event Stream appears above it
|
||||
assert.notEqual(
|
||||
html.indexOf(">Response Payload (Legacy)<"),
|
||||
-1,
|
||||
"Legacy response payload should be present"
|
||||
);
|
||||
assert(
|
||||
html.indexOf(">Event Stream (Debug)<") < html.indexOf(">Response Payload (Legacy)<"),
|
||||
"Event Stream should appear before Response Payload (Legacy)"
|
||||
);
|
||||
});
|
||||
|
||||
test("event stream hidden when debugEnabled is false", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(RequestLoggerDetail, {
|
||||
log: {
|
||||
status: 504,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
timestamp: "2026-04-09T21:27:08.000Z",
|
||||
duration: 2500,
|
||||
provider: "gemini",
|
||||
sourceFormat: "openai-chat",
|
||||
model: "test-model",
|
||||
tokens: { in: 1, out: 1 },
|
||||
},
|
||||
detail: {
|
||||
pipelinePayloads: {
|
||||
streamChunks: { provider: ["data: chunk"] },
|
||||
providerResponse: { status: 200 },
|
||||
},
|
||||
responseBody: "{}",
|
||||
},
|
||||
loading: false,
|
||||
debugEnabled: false,
|
||||
onClose: () => {},
|
||||
onCopy: async () => true,
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
html.indexOf(">Event Stream (Debug)<"),
|
||||
-1,
|
||||
"Event Stream should be hidden when debugEnabled is false"
|
||||
);
|
||||
});
|
||||
|
||||
test("status discrepancy shows both OmniRoute and provider statuses", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(RequestLoggerDetail, {
|
||||
log: {
|
||||
status: 504,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
timestamp: "2026-04-09T21:27:08.000Z",
|
||||
duration: 2500,
|
||||
provider: "gemini",
|
||||
sourceFormat: "openai-chat",
|
||||
model: "test-model",
|
||||
tokens: { in: 1, out: 1 },
|
||||
},
|
||||
detail: {
|
||||
pipelinePayloads: {
|
||||
providerResponse: { status: 200 },
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
debugEnabled: false,
|
||||
onClose: () => {},
|
||||
onCopy: async () => true,
|
||||
})
|
||||
);
|
||||
|
||||
assert.notEqual(html.indexOf("Upstream: 200"), -1, "Should display upstream/provider status");
|
||||
assert.notEqual(
|
||||
html.indexOf("OmniRoute returned 504"),
|
||||
-1,
|
||||
"Should indicate OmniRoute returned its own status"
|
||||
);
|
||||
});
|
||||
|
||||
test("request logger detail renders stream chunks correctly", () => {
|
||||
const log = {
|
||||
status: 200,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
provider: "gemini",
|
||||
model: "gemma-4-31b-it",
|
||||
timestamp: new Date().toISOString(),
|
||||
duration: 100,
|
||||
};
|
||||
|
||||
const detail = {
|
||||
pipelinePayloads: {
|
||||
streamChunks: {
|
||||
provider: [
|
||||
'data: {"type": "message_start"}\n\n',
|
||||
'data: {"type": "content_block_start"}\n\n',
|
||||
": x-omniroute-latency-ms=1\n",
|
||||
"data: [DONE]\n\n",
|
||||
],
|
||||
},
|
||||
},
|
||||
responseBody: "{}",
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(RequestLoggerDetail, {
|
||||
log,
|
||||
detail,
|
||||
loading: false,
|
||||
debugEnabled: true,
|
||||
onClose: () => {},
|
||||
onCopy: async () => true,
|
||||
})
|
||||
);
|
||||
|
||||
const expectedFragment = "message_start";
|
||||
assert.notEqual(
|
||||
html.indexOf(">Event Stream (Debug)<"),
|
||||
-1,
|
||||
"Event Stream header should be present"
|
||||
);
|
||||
// The payload is HTML-escaped; check for the provider key token and the message content
|
||||
assert.notEqual(
|
||||
html.indexOf("provider"),
|
||||
-1,
|
||||
"Stream chunks output should reference provider key"
|
||||
);
|
||||
assert.notEqual(
|
||||
html.indexOf(expectedFragment),
|
||||
-1,
|
||||
"Stream content (message_start) should be present in rendered HTML"
|
||||
);
|
||||
});
|
||||
27
tests/unit/validate-response-quality.test.ts
Normal file
27
tests/unit/validate-response-quality.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "assert";
|
||||
import { validateResponseQuality } from "../../open-sse/services/combo";
|
||||
|
||||
function makeResponse(body: string, contentType = "text/plain") {
|
||||
return {
|
||||
headers: {
|
||||
get: (name: string) => (name.toLowerCase() === "content-type" ? contentType : null),
|
||||
},
|
||||
clone: () => ({ text: async () => body }),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
test("returns valid=true for SSE with 'event:' lines", async () => {
|
||||
const res = await validateResponseQuality(makeResponse("event: message\n\n"), false, {});
|
||||
assert.strictEqual(res.valid, true);
|
||||
});
|
||||
|
||||
test("returns valid=true for SSE with 'data:' lines", async () => {
|
||||
const res = await validateResponseQuality(makeResponse('data: {"foo":"bar"}\n\n'), false, {});
|
||||
assert.strictEqual(res.valid, true);
|
||||
});
|
||||
|
||||
test("returns valid=false for non-JSON non-SSE text", async () => {
|
||||
const res = await validateResponseQuality(makeResponse("Hello world"), false, {});
|
||||
assert.strictEqual(res.valid, false);
|
||||
});
|
||||
Reference in New Issue
Block a user