mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix: preserve Claude Code messages on direct routes (#2351)
Integrated into release/v3.8.0 — Claude Code semantic passthrough for /v1/messages payloads
This commit is contained in:
@@ -422,6 +422,36 @@ export function shouldUseNativeCodexPassthrough({
|
||||
return segments.includes("responses");
|
||||
}
|
||||
|
||||
export function isClaudeCodeSemanticPassthroughRequest({
|
||||
provider,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
headers,
|
||||
userAgent,
|
||||
}: {
|
||||
provider?: string | null;
|
||||
sourceFormat?: string | null;
|
||||
targetFormat?: string | null;
|
||||
headers?: Record<string, unknown> | Headers | null;
|
||||
userAgent?: string | null;
|
||||
}): boolean {
|
||||
const isDirectClaudeCodeProvider =
|
||||
provider === "claude" || isClaudeCodeCompatibleProvider(provider);
|
||||
if (!isDirectClaudeCodeProvider) return false;
|
||||
if (sourceFormat !== FORMATS.CLAUDE) return false;
|
||||
if (targetFormat !== FORMATS.CLAUDE) return false;
|
||||
|
||||
const headerUserAgent = getHeaderValueCaseInsensitive(headers, "user-agent");
|
||||
const ua = `${userAgent || ""} ${headerUserAgent || ""}`.toLowerCase();
|
||||
if (ua.includes("claude-code") || ua.includes("claude-cli")) return true;
|
||||
|
||||
const appHeader = getHeaderValueCaseInsensitive(headers, "x-app");
|
||||
if (typeof appHeader === "string" && appHeader.trim().toLowerCase() === "cli") return true;
|
||||
|
||||
const sessionId = getHeaderValueCaseInsensitive(headers, "x-claude-code-session-id");
|
||||
return typeof sessionId === "string" && sessionId.trim().length > 0;
|
||||
}
|
||||
|
||||
function buildClaudePassthroughToolNameMap(body: Record<string, unknown> | null | undefined) {
|
||||
if (!body || !Array.isArray(body.tools)) return null;
|
||||
|
||||
@@ -2408,6 +2438,13 @@ export async function handleChatCore({
|
||||
let translatedBody = body;
|
||||
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
||||
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
|
||||
const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({
|
||||
provider,
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
headers: clientRawRequest?.headers,
|
||||
userAgent,
|
||||
});
|
||||
const upstreamStream = stream || isClaudeCodeCompatible;
|
||||
let ccSessionId: string | null = null;
|
||||
const stripTypes = getStripTypesForProviderModel(provider || "", model || "");
|
||||
@@ -2568,7 +2605,9 @@ export async function handleChatCore({
|
||||
|
||||
// Claude Code-compatible providers expect Anthropic Messages-shaped payloads,
|
||||
// but we extract only role/text/max_tokens/effort from an OpenAI-like view first.
|
||||
if (sourceFormat !== FORMATS.OPENAI) {
|
||||
if (sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough) {
|
||||
log?.debug?.("FORMAT", "claude-code semantic passthrough enabled for compatible bridge");
|
||||
} else if (sourceFormat !== FORMATS.OPENAI) {
|
||||
const normalizeToolCallId = getModelNormalizeToolCallId(
|
||||
provider || "",
|
||||
model || "",
|
||||
@@ -2603,6 +2642,7 @@ export async function handleChatCore({
|
||||
cwd: process.cwd(),
|
||||
now: new Date(),
|
||||
preserveCacheControl,
|
||||
preserveClaudeMessages: sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough,
|
||||
});
|
||||
log?.debug?.("FORMAT", "claude-code-compatible bridge enabled");
|
||||
} else if (isClaudePassthrough) {
|
||||
@@ -2613,7 +2653,11 @@ export async function handleChatCore({
|
||||
// regardless of combo strategy or cache_control settings.
|
||||
translatedBody = { ...body };
|
||||
translatedBody._disableToolPrefix = true;
|
||||
normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true });
|
||||
if (!isClaudeCodeSemanticPassthrough) {
|
||||
normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true });
|
||||
} else {
|
||||
log?.debug?.("FORMAT", "claude-code semantic passthrough enabled");
|
||||
}
|
||||
|
||||
log?.debug?.("FORMAT", `claude passthrough (preserveCache=${preserveCacheControl})`);
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ type BuildRequestOptions = {
|
||||
now?: Date;
|
||||
sessionId?: string | null;
|
||||
preserveCacheControl?: boolean;
|
||||
preserveClaudeMessages?: boolean;
|
||||
};
|
||||
|
||||
function supportsClaudeXHighEffort(model: string | null | undefined): boolean {
|
||||
@@ -228,10 +229,13 @@ export function buildClaudeCodeCompatibleRequest({
|
||||
cwd = process.cwd(),
|
||||
sessionId,
|
||||
preserveCacheControl = false,
|
||||
preserveClaudeMessages = false,
|
||||
}: BuildRequestOptions) {
|
||||
const normalized = normalizedBody || {};
|
||||
const preparedClaudeBody = claudeBody
|
||||
? prepareClaudeCodeCompatibleBody(claudeBody, preserveCacheControl)
|
||||
? preserveClaudeMessages
|
||||
? prepareClaudeCodeCompatibleSemanticBody(claudeBody)
|
||||
: prepareClaudeCodeCompatibleBody(claudeBody, preserveCacheControl)
|
||||
: null;
|
||||
const normalizedMessages = Array.isArray(normalized.messages)
|
||||
? (normalized.messages as MessageLike[])
|
||||
@@ -242,13 +246,18 @@ export function buildClaudeCodeCompatibleRequest({
|
||||
: null;
|
||||
const effectiveClaudeBody = preparedClaudeBody || extractedClaudeBody;
|
||||
const messages = effectiveClaudeBody
|
||||
? buildClaudeCodeCompatibleMessagesFromClaude(
|
||||
effectiveClaudeBody.messages as MessageLike[],
|
||||
preserveCacheControl
|
||||
)
|
||||
? preserveClaudeMessages && preparedClaudeBody
|
||||
? cloneClaudeCodeCompatibleMessagesFromClaude(
|
||||
effectiveClaudeBody.messages as MessageLike[],
|
||||
preserveCacheControl
|
||||
)
|
||||
: buildClaudeCodeCompatibleMessagesFromClaude(
|
||||
effectiveClaudeBody.messages as MessageLike[],
|
||||
preserveCacheControl
|
||||
)
|
||||
: buildClaudeCodeCompatibleMessages(normalizedMessages);
|
||||
const system = buildClaudeCodeCompatibleSystemBlocks({
|
||||
messages: normalizedMessages,
|
||||
messages: preserveClaudeMessages ? [] : normalizedMessages,
|
||||
systemBlocks: effectiveClaudeBody?.system as Record<string, unknown>[] | undefined,
|
||||
preserveCacheControl,
|
||||
});
|
||||
@@ -616,6 +625,25 @@ function buildClaudeCodeCompatibleMessagesFromClaude(
|
||||
return merged;
|
||||
}
|
||||
|
||||
function cloneClaudeCodeCompatibleMessagesFromClaude(
|
||||
messages: MessageLike[] | undefined,
|
||||
preserveCacheControl: boolean
|
||||
) {
|
||||
const cloned = Array.isArray(messages)
|
||||
? messages.map((message) => cloneValue(message) as MessageLike)
|
||||
: [];
|
||||
|
||||
if (!preserveCacheControl) {
|
||||
for (const message of cloned) {
|
||||
if (Array.isArray(message.content)) {
|
||||
stripCacheControlFromContentBlocks(message.content as Array<Record<string, unknown>>);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function buildClaudeCodeCompatibleSystemBlocks({
|
||||
messages,
|
||||
systemBlocks,
|
||||
@@ -794,6 +822,28 @@ function prepareClaudeCodeCompatibleBody(
|
||||
return readRecord(prepared);
|
||||
}
|
||||
|
||||
function prepareClaudeCodeCompatibleSemanticBody(claudeBody: Record<string, unknown>) {
|
||||
const prepared: Record<string, unknown> = {
|
||||
system: normalizeClaudeSystemInput(claudeBody.system),
|
||||
messages: Array.isArray(claudeBody.messages)
|
||||
? (cloneValue(claudeBody.messages) as Array<Record<string, unknown>>)
|
||||
: [],
|
||||
tools: normalizeClaudeToolInput(claudeBody.tools),
|
||||
thinking: (readRecord(cloneValue(claudeBody.thinking)) || null) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
};
|
||||
|
||||
const metadata = readRecord(cloneValue(claudeBody.metadata));
|
||||
if (metadata) prepared.metadata = metadata;
|
||||
|
||||
const outputConfig = readRecord(cloneValue(claudeBody.output_config));
|
||||
if (outputConfig) prepared.output_config = outputConfig;
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function extractClaudeBodyFromSource(
|
||||
sourceBody: Record<string, unknown>,
|
||||
preserveCacheControl: boolean
|
||||
|
||||
@@ -37,6 +37,7 @@ const { getCallLogs, getCallLogById } = await import("../../src/lib/usage/callLo
|
||||
const {
|
||||
handleChatCore,
|
||||
shouldUseNativeCodexPassthrough,
|
||||
isClaudeCodeSemanticPassthroughRequest,
|
||||
isTokenExpiringSoon,
|
||||
clearUpstreamProxyConfigCache,
|
||||
buildStreamingResponseHeaders,
|
||||
@@ -488,6 +489,46 @@ test("chatCore helper exports detect responses passthrough paths and token expir
|
||||
assert.equal(isTokenExpiringSoon(null), false);
|
||||
});
|
||||
|
||||
test("chatCore helper detects Claude Code semantic passthrough only for direct Claude-Code routes", () => {
|
||||
assert.equal(
|
||||
isClaudeCodeSemanticPassthroughRequest({
|
||||
provider: "claude",
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
targetFormat: FORMATS.CLAUDE,
|
||||
userAgent: "claude-cli/2.1.137",
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isClaudeCodeSemanticPassthroughRequest({
|
||||
provider: "anthropic-compatible-cc-test",
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
targetFormat: FORMATS.CLAUDE,
|
||||
headers: new Headers({ "x-app": "cli" }),
|
||||
userAgent: "unit-test",
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isClaudeCodeSemanticPassthroughRequest({
|
||||
provider: "anthropic-compatible-test",
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
targetFormat: FORMATS.CLAUDE,
|
||||
userAgent: "claude-cli/2.1.137",
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isClaudeCodeSemanticPassthroughRequest({
|
||||
provider: "claude",
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
targetFormat: FORMATS.CLAUDE,
|
||||
userAgent: "generic-client",
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("chatCore applies payload rules after translating Responses input into Chat payloads", async () => {
|
||||
setPayloadRulesConfig({
|
||||
default: [
|
||||
@@ -565,6 +606,169 @@ test("chatCore builds Claude Code-compatible upstream requests for CC providers"
|
||||
assert.equal(call.body.messages[0].content[0].text, "Ping");
|
||||
});
|
||||
|
||||
test("chatCore preserves native Claude Code messages for native Claude OAuth passthrough", async () => {
|
||||
const clientMessages = [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "system-message-that-should-stay-in-messages" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "Run pwd", cache_control: { type: "ephemeral" } },
|
||||
{ type: "document", name: "README.md", content: "Do not flatten me" },
|
||||
{ type: "future_block", payload: { keep: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_pwd", name: "Bash", input: { command: "pwd" } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_pwd", content: "ok" }],
|
||||
},
|
||||
];
|
||||
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/messages",
|
||||
credentials: { apiKey: "claude-key", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "omniroute/alias-that-should-resolve",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "top-level-system" }],
|
||||
messages: clientMessages,
|
||||
tools: [{ name: "Bash", input_schema: { type: "object", properties: {} } }],
|
||||
},
|
||||
userAgent: "claude-cli/2.1.137",
|
||||
requestHeaders: { "x-app": "cli", "x-claude-code-session-id": "session-123" },
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(call.body.model, "claude-sonnet-4-6");
|
||||
assert.deepEqual(call.body.messages, clientMessages);
|
||||
assert.equal(
|
||||
call.body.system.some(
|
||||
(block: { text?: string }) => block.text === "system-message-that-should-stay-in-messages"
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(call.body.messages[1].content[0].text, "");
|
||||
assert.equal(call.body.messages[1].content[2].type, "document");
|
||||
assert.equal(call.body.messages[1].content[3].type, "future_block");
|
||||
assert.equal(call.body.messages[3].content[0].type, "tool_result");
|
||||
});
|
||||
|
||||
test("chatCore keeps Claude normalization for non-Claude-Code Claude passthrough", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/messages",
|
||||
credentials: { apiKey: "claude-key", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 64,
|
||||
messages: [
|
||||
{ role: "system", content: "system role should move" },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "document", name: "README.md", content: "Read me" },
|
||||
{ type: "future_block", payload: { drop: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
userAgent: "generic-client/1.0",
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(
|
||||
call.body.messages.some((message) => message.role === "system"),
|
||||
false
|
||||
);
|
||||
assert.equal(call.body.system.at(-1).text, "system role should move");
|
||||
assert.deepEqual(call.body.messages[0].content, [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "[README.md]\nRead me" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("chatCore preserves native Claude Code messages before CC-compatible relay transforms", async () => {
|
||||
const clientMessages = [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "system-message-remains-in-source-history" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "Inspect project", cache_control: { type: "ephemeral" } },
|
||||
{ type: "document", name: "design.md", content: "Keep as document block" },
|
||||
{ type: "future_block", payload: { keep: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_read", name: "Read", input: { file_path: "a.ts" } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_read", content: "file contents" }],
|
||||
},
|
||||
];
|
||||
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "anthropic-compatible-cc-test",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/messages",
|
||||
credentials: {
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com/v1/messages?beta=true",
|
||||
chatPath: "/v1/messages?beta=true",
|
||||
},
|
||||
},
|
||||
body: {
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "top-level-system" }],
|
||||
messages: clientMessages,
|
||||
tools: [{ name: "Read", input_schema: { type: "object", properties: {} } }],
|
||||
},
|
||||
userAgent: "Claude-Code/2.1.137",
|
||||
requestHeaders: { "x-app": "cli", "x-claude-code-session-id": "cc-session-123" },
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.match(call.url, /\/v1\/messages\?beta=true$/);
|
||||
assert.equal(call.body.stream, true);
|
||||
assert.deepEqual(call.body.messages, clientMessages);
|
||||
assert.equal(
|
||||
call.body.system[0].text,
|
||||
"You are a Claude agent, built on Anthropic's Claude Agent SDK."
|
||||
);
|
||||
assert.equal(
|
||||
call.body.system.some(
|
||||
(block: { text?: string }) => block.text === "system-message-remains-in-source-history"
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(call.body.messages[1].content[0].text, "");
|
||||
assert.equal(call.body.messages[1].content[2].type, "document");
|
||||
assert.equal(call.body.messages[1].content[3].type, "future_block");
|
||||
assert.equal(call.body.messages[3].content[0].type, "tool_result");
|
||||
});
|
||||
|
||||
test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
|
||||
Reference in New Issue
Block a user