mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
* fix(translator): Normalize tool call names from lowercase to PascalCase (#1) * Fix: Map lowercase tool names from Antigravity (Gemini format) to Claude Code expected PascalCase * Fix: toolNameMap in fun restoreClaudePassthroughToolUseName * fix(translator): Normalize tool call names from lowercase to PascalCase when translating upstream responses (OpenAI, Gemini, Antigravity) to Claude Messages API format This resolves `Error: No such tool available: read`/`bash`/`write` errors when using Claude Code CLI with third-party providers that emit lowercase tool names. The fix adds case-insensitive tool name lookups in `openai-to-claude.ts`, `gemini-to-claude.ts`, and related translators, ensuring tool names like `read`/`bash` are mapped to `Read`/`Bash` before being sent to Claude Code. Includes unit tests and comprehensive changelog notes ([#10250](https://github.com/diegosouzapw/OmniRoute/pull/10250)) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats fr… (#2) * fix(translator): Parse <tool_call> JSON and TOOL_CALL text formats from model output Some models (DeepSeek, Qwen) emit tool calls as text instead of proper tool_calls JSON: either <tool_call>{...}</tool_call> or TOOL_CALL Name: {...}. Extend extractXmlInvokeBlocks to handle all 3 formats in a single scan pass, picking whichever pattern appears first. Includes unit tests for all formats. * fix(translator): Parse text-format tool calls in gemini-to-claude translator Extend the Gemini->Claude translator to detect <invoke>, <tool_call> JSON, and TOOL_CALL text formats emitted inline in text parts (Antigravity/Gemini models), converting them to proper tool_use content blocks instead of leaking raw text to Claude Code. * docs(changelog): Add changelog entry for text tool call parsing fix * fix(translator): consolidate tool name casing normalization and restore thought-signature persistence (#3) * fix(translator): sanitize tool_use.id and tool_result.tool_use_id to match Anthropic schema (#4) Ensure tool IDs from OpenAI-compatible upstreams (which may contain dots, colons, or special characters) are sanitized to ^[a-zA-Z0-9_-]+$ in response translators and passthrough requests before reaching Claude endpoints. * fix(responses): preserve native tools for openai-compatible Responses targets (#5) A Responses-shaped request to a custom openai-compatible connection whose outbound protocol is Responses took a Responses -> Chat -> Responses round trip, so Codex custom tools lost their grammar (`exec`), namespace groups were flattened (`collaboration`), and tool invocations failed upstream. Gate a native Responses passthrough on the connection's configured protocol (`apiType: "responses"` / `_omnirouteForceResponsesUpstream`) so the original tool definitions reach a Responses-capable upstream unchanged. Chat-only connections keep the existing downgrade. Closes #10374 * fix(translator): add support for 'applypatch' tool name in tool call checks * test(translator): add unit test for apply_patch and applypatch tool name remapping * fix(translator): remove no-explicit-any lint errors in tool-use-id-sanitization test Type the openaiToClaudeResponse/translateNonStreamingResponse return values with narrow local shapes instead of `any`, satisfying the repo's no-explicit-any = error rule for tests/. No behavior change — the same 3 assertions still pass. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * test: update 9568 casing regression to match #10392's consolidated fix restoreClaudeToolName's static casing map now normalizes known lowercase tool names to canonical PascalCase unconditionally on the gemini-to-claude and openai-to-claude Claude Messages API paths (not gated behind toolNameMap), superseding the earlier per-map-only fix that the original #9568 regression test locked in as "expected" (it was previously labeled a known bug case). The gemini-to-openai passthrough path is unaffected by #10392 and keeps its original pass-through assertion. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import { FORMATS } from "../../translator/formats.ts";
|
|
import { isVerifiedNativeCodexRequest } from "../../config/codexIdentity.ts";
|
|
import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts";
|
|
import { isResponsesEndpointPath } from "../../utils/responsesEndpoint.ts";
|
|
import { getHeaderValueCaseInsensitive } from "./headers.ts";
|
|
|
|
export { isResponsesEndpointPath };
|
|
|
|
export const XAI_API_PROVIDERS = new Set(["xai", "xai-oauth", "xao"]);
|
|
|
|
export function shouldUseNativeCodexPassthrough({
|
|
provider,
|
|
sourceFormat,
|
|
endpointPath,
|
|
body,
|
|
headers,
|
|
}: {
|
|
provider?: string | null;
|
|
sourceFormat?: string | null;
|
|
endpointPath?: string | null;
|
|
body?: unknown;
|
|
headers?: Headers | Record<string, unknown> | null;
|
|
}): boolean {
|
|
if (provider !== "codex" && provider !== "chatgpt-web-codex") return false;
|
|
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
|
|
let normalizedEndpoint = String(endpointPath || "");
|
|
while (normalizedEndpoint.endsWith("/")) normalizedEndpoint = normalizedEndpoint.slice(0, -1);
|
|
const segments = normalizedEndpoint.split("/");
|
|
if (!segments.includes("responses")) return false;
|
|
return provider === "codex" || isVerifiedNativeCodexRequest(body, headers);
|
|
}
|
|
|
|
export function shouldUseNativeXaiResponsesPassthrough({
|
|
provider,
|
|
sourceFormat,
|
|
endpointPath,
|
|
}: {
|
|
provider?: string | null;
|
|
sourceFormat?: string | null;
|
|
endpointPath?: string | null;
|
|
}): boolean {
|
|
if (!provider || !XAI_API_PROVIDERS.has(provider)) return false;
|
|
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
|
|
return isResponsesEndpointPath(endpointPath);
|
|
}
|
|
|
|
export function stampNativeResponsesPassthroughBody(
|
|
body: Record<string, unknown>,
|
|
mode: "codex" | "xai" | "openai-compatible"
|
|
): Record<string, unknown> {
|
|
if (mode === "codex") return { ...body, _nativeCodexPassthrough: true };
|
|
if (mode === "xai") return { ...body, _nativeXaiResponsesPassthrough: true };
|
|
return { ...body, _nativeOpenAICompatibleResponsesPassthrough: true };
|
|
}
|
|
|
|
export function shouldUseNativeOpenAICompatibleResponsesPassthrough({
|
|
provider,
|
|
sourceFormat,
|
|
endpointPath,
|
|
providerSpecificData,
|
|
}: {
|
|
provider?: string | null;
|
|
sourceFormat?: string | null;
|
|
endpointPath?: string | null;
|
|
providerSpecificData?: unknown;
|
|
}): boolean {
|
|
if (!provider?.startsWith("openai-compatible-")) return false;
|
|
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false;
|
|
if (providerSpecificData && typeof providerSpecificData === "object") {
|
|
const psd = providerSpecificData as Record<string, unknown>;
|
|
if (psd.apiType === "responses" || psd._omnirouteForceResponsesUpstream === true) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Pass `thinking` / `redacted_thinking` blocks through UNCHANGED.
|
|
*
|
|
* This used to rewrite every assistant thinking block to `redacted_thinking`
|
|
* carrying a synthetic signature, on the assumption that a thinking signature is
|
|
* bound to the auth token that produced it and would be rejected after a token /
|
|
* model switch with 400 "Invalid signature in thinking block" (issue #2454).
|
|
*
|
|
* That rewrite is the actual cause of a different, far more common failure on the
|
|
* Anthropic-native Claude OAuth passthrough:
|
|
*
|
|
* 400 messages.N.content.M: `thinking` or `redacted_thinking` blocks in the
|
|
* latest assistant message cannot be modified. These blocks must remain as
|
|
* they were in the original response.
|
|
*
|
|
* The Messages API validates submitted thinking blocks against the original
|
|
* response and rejects ANY modification — so converting them to
|
|
* `redacted_thinking` makes every multi-turn request with thinking fail (most
|
|
* visible on long Claude Code tool-loops). The thinking-block signature is
|
|
* validated server-side by Anthropic and stays valid when the blocks are replayed,
|
|
* including under a different OAuth token — verified by preserving the blocks
|
|
* across a mid-conversation account switch with zero "Invalid signature"
|
|
* responses. The redaction is therefore both unnecessary and the cause of the
|
|
* regression, so the blocks are now returned verbatim. The `signature` parameter
|
|
* is kept for call-site compatibility.
|
|
*/
|
|
export function redactPassthroughThinkingSignatures(
|
|
messages: unknown,
|
|
_signature: string
|
|
): unknown {
|
|
return messages;
|
|
}
|
|
|
|
type MessageLike = {
|
|
role?: unknown;
|
|
content?: unknown;
|
|
};
|
|
|
|
type ThinkingSignatureError = {
|
|
provider?: string | null;
|
|
status?: number | null;
|
|
message?: string | null;
|
|
};
|
|
|
|
function isThinkingBlock(block: unknown): boolean {
|
|
if (!block || typeof block !== "object") return false;
|
|
const type = (block as { type?: unknown }).type;
|
|
return type === "thinking" || type === "redacted_thinking";
|
|
}
|
|
|
|
function hasBlock(message: MessageLike | null | undefined, type: string): boolean {
|
|
return (
|
|
!!message &&
|
|
Array.isArray(message.content) &&
|
|
message.content.some(
|
|
(block) => !!block && typeof block === "object" && (block as { type?: unknown }).type === type
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Match only the Anthropic validation failure this recovery path understands.
|
|
* Generic 400s and the separate "latest assistant message cannot be modified"
|
|
* validation error must continue through the normal error path unchanged.
|
|
*/
|
|
export function isAnthropicThinkingSignatureError({
|
|
provider,
|
|
status,
|
|
message,
|
|
}: ThinkingSignatureError): boolean {
|
|
const isAnthropicTarget =
|
|
provider === "claude" ||
|
|
(typeof provider === "string" && provider.startsWith("anthropic-compatible-"));
|
|
if (!isAnthropicTarget || status !== 400 || typeof message !== "string") return false;
|
|
|
|
return /invalid\s+[`'\"]?signature[`'\"]?\s+in\s+[`'\"]?thinking[`'\"]?\s+block/i.test(message);
|
|
}
|
|
|
|
/**
|
|
* Build a one-shot recovery body after Anthropic has explicitly rejected a
|
|
* thinking signature. Historical thinking blocks are omitted, but the complete
|
|
* active tool-use cycle is preserved verbatim: when the request ends in one or
|
|
* more `user[tool_result]` turns, every paired assistant `tool_use` turn in that
|
|
* still-open cycle keeps its thinking blocks. A trailing unresolved assistant
|
|
* `tool_use` turn is protected as well.
|
|
*
|
|
* This helper is intentionally NOT used eagerly. Normal same-model requests must
|
|
* retain their thinking history, cache shape, and current-model semantics.
|
|
* Returns the original body reference when no safe recovery change is possible.
|
|
*/
|
|
export function stripHistoricalThinkingForSignatureRecovery<T>(body: T): T {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
|
|
const record = body as Record<string, unknown>;
|
|
if (!Array.isArray(record.messages)) return body;
|
|
|
|
const messages = record.messages as MessageLike[];
|
|
const protectedAssistantIndexes = new Set<number>();
|
|
let cursor = messages.length - 1;
|
|
|
|
// Some internal callers can resume from an unresolved assistant tool_use.
|
|
if (
|
|
cursor >= 0 &&
|
|
messages[cursor]?.role === "assistant" &&
|
|
hasBlock(messages[cursor], "tool_use")
|
|
) {
|
|
protectedAssistantIndexes.add(cursor);
|
|
cursor -= 1;
|
|
}
|
|
|
|
// Walk the complete trailing tool-result chain. Interleaved thinking can span
|
|
// several assistant/tool_result pairs, so protecting only the latest assistant
|
|
// message is insufficient.
|
|
while (
|
|
cursor >= 0 &&
|
|
messages[cursor]?.role === "user" &&
|
|
hasBlock(messages[cursor], "tool_result")
|
|
) {
|
|
cursor -= 1;
|
|
while (cursor >= 0 && messages[cursor]?.role !== "assistant") cursor -= 1;
|
|
if (cursor < 0 || !hasBlock(messages[cursor], "tool_use")) break;
|
|
protectedAssistantIndexes.add(cursor);
|
|
cursor -= 1;
|
|
}
|
|
|
|
let changed = false;
|
|
const recoveredMessages = messages.map((message, index) => {
|
|
if (
|
|
!message ||
|
|
message.role !== "assistant" ||
|
|
!Array.isArray(message.content) ||
|
|
protectedAssistantIndexes.has(index)
|
|
) {
|
|
return message;
|
|
}
|
|
|
|
const content = message.content.filter((block) => !isThinkingBlock(block));
|
|
if (content.length === message.content.length) return message;
|
|
changed = true;
|
|
return { ...message, content };
|
|
});
|
|
|
|
if (!changed) return body;
|
|
return { ...record, messages: recoveredMessages } as T;
|
|
}
|
|
|
|
type SignatureRecoveryExecution<T> = {
|
|
result: T;
|
|
retried: boolean;
|
|
recoveryBody: unknown | null;
|
|
};
|
|
|
|
/** Execute the normal body once, then perform at most one exact-error recovery. */
|
|
export async function executeWithAnthropicThinkingSignatureRecovery<T>(args: {
|
|
provider?: string | null;
|
|
body: unknown;
|
|
execute: (body: unknown) => Promise<T>;
|
|
getError: (
|
|
result: T
|
|
) =>
|
|
| { status?: number | null; message?: string | null }
|
|
| null
|
|
| Promise<{ status?: number | null; message?: string | null } | null>;
|
|
}): Promise<SignatureRecoveryExecution<T>> {
|
|
const first = await args.execute(args.body);
|
|
const failure = await args.getError(first);
|
|
if (
|
|
!failure ||
|
|
!isAnthropicThinkingSignatureError({
|
|
provider: args.provider,
|
|
status: failure.status,
|
|
message: failure.message,
|
|
})
|
|
) {
|
|
return { result: first, retried: false, recoveryBody: null };
|
|
}
|
|
|
|
const recoveryBody = stripHistoricalThinkingForSignatureRecovery(args.body);
|
|
if (recoveryBody === args.body) {
|
|
return { result: first, retried: false, recoveryBody: null };
|
|
}
|
|
|
|
return {
|
|
result: await args.execute(recoveryBody),
|
|
retried: true,
|
|
recoveryBody,
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|