feat: Fix support for claude-cli using Gemini provider (#1779)

Integrated into release/v3.7.5
This commit is contained in:
Markus Hartung
2026-04-29 21:46:38 +02:00
committed by GitHub
parent 072b4cd414
commit 59fd2b2152
11 changed files with 534 additions and 17 deletions

View File

@@ -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),

View File

@@ -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" };
}

View File

@@ -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,

View File

@@ -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)"

View File

@@ -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
View 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"
}
]
}

View 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);
});

View File

@@ -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 {

View File

@@ -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(() => {

View 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"
);
});

View 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);
});