diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 83d880cd17..e88599a3a6 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -93,10 +93,12 @@ export class AntigravityExecutor extends BaseExecutor { role = "user"; } - // Strip thought parts (no valid signature -> provider rejects). - // Also drop entries that become empty after filtering, which can trigger - // 400 invalid argument on Gemini 3 Flash through Antigravity. - const parts = c.parts?.filter((p) => !p.thought && !p.thoughtSignature) || []; + const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false; + + // Antigravity rejects synthetic thought text, but Gemini 3+ requires any + // returned thoughtSignature metadata to survive model tool-call turns. + const parts = + c.parts?.filter((p) => !p.thought && (hasFunctionCall || !p.thoughtSignature)) || []; return { ...c, role, parts }; }) || []; @@ -230,7 +232,6 @@ export class AntigravityExecutor extends BaseExecutor { let timedOut = false; const timeout = AbortSignal.timeout(SSE_COLLECT_TIMEOUT_MS); try { - while (true) { if (signal?.aborted) throw new Error("Request aborted during SSE collection"); const { done, value } = await Promise.race([ diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 5399ae0901..8d0083a495 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -7,6 +7,18 @@ export class GithubExecutor extends BaseExecutor { super("github", PROVIDERS.github); } + getCopilotToken(credentials) { + return credentials?.copilotToken || credentials?.providerSpecificData?.copilotToken || null; + } + + getCopilotTokenExpiresAt(credentials) { + return ( + credentials?.copilotTokenExpiresAt || + credentials?.providerSpecificData?.copilotTokenExpiresAt || + null + ); + } + buildUrl(model, stream, urlIndex = 0) { const targetFormat = getModelTargetFormat("gh", model); if (targetFormat === "openai-responses") { @@ -73,7 +85,21 @@ export class GithubExecutor extends BaseExecutor { async execute(input: ExecuteInput) { const result = await super.execute(input); - if (!result || !result.response?.body) return result; + if (!result || !result.response) return result; + + if (!input.stream) { + // wreq-js clone/text semantics consume the original response body. Materialize + // non-streaming responses immediately so downstream code always sees a native + // fetch Response with a readable body. + const status = result.response.status; + const statusText = result.response.statusText; + const headers = new Headers(result.response.headers); + const payload = await result.response.text(); + result.response = new Response(payload, { status, statusText, headers }); + return result; + } + + if (!result.response.body) return result; const isStreaming = input.stream === true; const contentType = (result.response.headers.get("content-type") || "").toLowerCase(); @@ -105,7 +131,7 @@ export class GithubExecutor extends BaseExecutor { } buildHeaders(credentials, stream = true) { - const token = credentials.copilotToken || credentials.accessToken; + const token = this.getCopilotToken(credentials) || credentials.accessToken; return { Authorization: `Bearer ${token}`, "Content-Type": "application/json", @@ -213,11 +239,12 @@ export class GithubExecutor extends BaseExecutor { needsRefresh(credentials) { // Always refresh if no copilotToken - if (!credentials.copilotToken) return true; + if (!this.getCopilotToken(credentials)) return true; - if (credentials.copilotTokenExpiresAt) { + const copilotTokenExpiresAt = this.getCopilotTokenExpiresAt(credentials); + if (copilotTokenExpiresAt) { // Handle both Unix timestamp (seconds) and ISO string - let expiresAtMs = credentials.copilotTokenExpiresAt; + let expiresAtMs = copilotTokenExpiresAt; if (typeof expiresAtMs === "number" && expiresAtMs < 1e12) { expiresAtMs = expiresAtMs * 1000; // Convert seconds to ms } else if (typeof expiresAtMs === "string") { diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 51c6349e64..c900bcf35a 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -166,12 +166,18 @@ export function buildClaudeCodeCompatibleRequest({ systemBlocks: preparedClaudeBody?.system as Record[] | undefined, cwd, now, + preserveCacheControl, }); const resolvedSessionId = sessionId || randomUUID(); const effort = resolveClaudeCodeCompatibleEffort(sourceBody, normalizedBody, model); const maxTokens = resolveClaudeCodeCompatibleMaxTokens(sourceBody, normalizedBody); const tools = preparedClaudeBody?.tools - ? (preparedClaudeBody.tools as Record[]) + ? applyClaudeCodeCompatibleToolCacheStrategy( + preparedClaudeBody.tools as Record[], + { + preserveExisting: preserveCacheControl, + } + ) : buildClaudeCodeCompatibleTools(normalizedBody, sourceBody); const toolChoice = tools.length > 0 @@ -351,8 +357,10 @@ function buildClaudeCodeCompatibleMessagesFromClaude( for (const message of merged) { stripCacheControlFromContentBlocks(message.content); } - applyClaudeCodeCompatibleMessageCacheStrategy(merged); } + applyClaudeCodeCompatibleMessageCacheStrategy(merged, { + preserveExisting: preserveCacheControl, + }); if (merged.length === 0) { const fallbackText = converted @@ -379,11 +387,13 @@ function buildClaudeCodeCompatibleSystemBlocks({ systemBlocks, cwd, now, + preserveCacheControl, }: { messages: MessageLike[] | undefined; systemBlocks?: Array> | undefined; cwd: string; now: Date; + preserveCacheControl: boolean; }) { const customSystemBlocks = Array.isArray(systemBlocks) && systemBlocks.length > 0 @@ -412,6 +422,15 @@ function buildClaudeCodeCompatibleSystemBlocks({ blocks.push(systemBlock); } + if ( + preserveCacheControl && + customSystemBlocks.length > 0 && + !customSystemBlocks.some((block) => hasCacheControl(block)) + ) { + const lastCustomSystemBlock = customSystemBlocks[customSystemBlocks.length - 1]; + lastCustomSystemBlock.cache_control = { type: "ephemeral", ttl: "1h" }; + } + return blocks; } @@ -448,12 +467,10 @@ function buildClaudeCodeCompatibleTools( return rawTools .map((tool) => convertClaudeCodeCompatibleTool(tool)) .filter((tool): tool is Record => !!tool) + .map((tool) => ({ ...tool })) .map((tool, index, tools) => { if (index !== findLastCacheableToolIndex(tools)) return tool; - return { - ...tool, - cache_control: { type: "ephemeral", ttl: "1h" }, - }; + return { ...tool, cache_control: { type: "ephemeral", ttl: "1h" } }; }); } @@ -633,7 +650,7 @@ function convertClaudeCodeCompatibleClaudeMessage( function extractCustomSystemBlocks(messages: MessageLike[] | undefined) { if (!Array.isArray(messages)) return []; - return messages + const blocks = messages .filter((message) => { const role = String(message?.role || "").toLowerCase(); return role === "system" || role === "developer"; @@ -645,11 +662,21 @@ function extractCustomSystemBlocks(messages: MessageLike[] | undefined) { text, cache_control: { type: "ephemeral" }, })); + + if (blocks.length > 0) { + blocks[blocks.length - 1].cache_control = { type: "ephemeral", ttl: "1h" }; + } + + return blocks; } function applyClaudeCodeCompatibleMessageCacheStrategy( - messages: Array<{ role: "user" | "assistant"; content: Array> }> + messages: Array<{ role: "user" | "assistant"; content: Array> }>, + options: { + preserveExisting?: boolean; + } = {} ) { + const preserveExisting = options.preserveExisting === true; const userIndexes = messages.reduce((indexes, message, index) => { if (message.role === "user") indexes.push(index); return indexes; @@ -658,14 +685,28 @@ function applyClaudeCodeCompatibleMessageCacheStrategy( const secondToLastUserIndex = userIndexes.length >= 2 ? userIndexes[userIndexes.length - 2] : -1; if (secondToLastUserIndex >= 0) { - markLastContentCacheControl(messages[secondToLastUserIndex].content); + markLastContentCacheControl( + messages[secondToLastUserIndex].content, + undefined, + preserveExisting + ); } else if (!hasAssistant && userIndexes.length > 0) { - markLastContentCacheControl(messages[userIndexes[userIndexes.length - 1]].content); + markLastContentCacheControl( + messages[userIndexes[userIndexes.length - 1]].content, + undefined, + preserveExisting + ); + } + + const lastUserIndex = userIndexes.length > 0 ? userIndexes[userIndexes.length - 1] : -1; + const endsOnUser = lastUserIndex === messages.length - 1; + if (endsOnUser && lastUserIndex >= 0) { + markLastContentCacheControl(messages[lastUserIndex].content, undefined, preserveExisting); } for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role !== "assistant") continue; - if (markLastContentCacheControl(messages[i].content)) break; + if (markLastContentCacheControl(messages[i].content, undefined, preserveExisting)) break; } } @@ -675,14 +716,52 @@ function stripCacheControlFromContentBlocks(content: Array>, ttl?: string) { +function applyClaudeCodeCompatibleToolCacheStrategy( + tools: Array>, + options: { + preserveExisting?: boolean; + } = {} +) { + const preparedTools = tools.map((tool) => ({ ...tool })); + const lastCacheableToolIndex = findLastCacheableToolIndex(preparedTools); + if (lastCacheableToolIndex < 0) return preparedTools; + + if (options.preserveExisting && preparedTools.some((tool) => hasCacheControl(tool))) { + return preparedTools; + } + + preparedTools[lastCacheableToolIndex].cache_control = { type: "ephemeral", ttl: "1h" }; + return preparedTools; +} + +function markLastContentCacheControl( + content: Array>, + ttl?: string, + preserveExisting = false +) { if (!Array.isArray(content) || content.length === 0) return false; const lastBlock = content[content.length - 1]; if (!lastBlock) return false; + if ( + preserveExisting && + content.some( + (block) => + !!block && + typeof block === "object" && + !!readRecord(block)?.cache_control && + typeof readRecord(block)?.cache_control === "object" + ) + ) { + return true; + } lastBlock.cache_control = ttl ? { type: "ephemeral", ttl } : { type: "ephemeral" }; return true; } +function hasCacheControl(value: unknown) { + return !!readRecord(value)?.cache_control && typeof readRecord(value)?.cache_control === "object"; +} + function findLastCacheableToolIndex(tools: Array>) { for (let i = tools.length - 1; i >= 0; i--) { if (!tools[i].defer_loading) { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 13e9ffc0b1..e6ba23712b 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -818,8 +818,8 @@ export async function handleComboChat({ const { getLKGP } = await import("../../src/lib/localDb"); const lkgp = await getLKGP(combo.name, combo.id || combo.name); if (lkgp) lastKnownGoodProvider = lkgp; - } catch { - /* ignore db errors */ + } catch (err) { + log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err }); } const candidates = await buildAutoCandidates(eligibleModels, combo.name); @@ -998,7 +998,11 @@ export async function handleComboChat({ if (provider) { import("../../src/lib/localDb") .then(({ setLKGP }) => setLKGP(combo.name, combo.id || combo.name, provider)) - .catch(() => {}); + .catch((err) => + log.warn("COMBO", "Failed to record Last Known Good Provider. This is non-fatal.", { + err, + }) + ); } return result; @@ -1010,17 +1014,16 @@ export async function handleComboChat({ try { const cloned = result.clone(); try { - const errorBody = await cloned.json(); - errorText = - errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; - retryAfter = errorBody?.retryAfter || null; - } catch { - try { - const text = await result.text(); - if (text) errorText = text.substring(0, 500); - } catch { - /* Body consumed */ + const text = await cloned.text(); + if (text) { + errorText = text.substring(0, 500); + const errorBody = JSON.parse(text); + errorText = + errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; + retryAfter = errorBody?.retryAfter || null; } + } catch { + /* Clone parse failed */ } } catch { /* Clone failed */ @@ -1293,17 +1296,16 @@ async function handleRoundRobinCombo({ try { const cloned = result.clone(); try { - const errorBody = await cloned.json(); - errorText = - errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; - retryAfter = errorBody?.retryAfter || null; - } catch { - try { - const text = await result.text(); - if (text) errorText = text.substring(0, 500); - } catch { - /* Body consumed */ + const text = await cloned.text(); + if (text) { + errorText = text.substring(0, 500); + const errorBody = JSON.parse(text); + errorText = + errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; + retryAfter = errorBody?.retryAfter || null; } + } catch { + /* Clone parse failed */ } } catch { /* Clone failed */ diff --git a/open-sse/services/geminiThoughtSignatureStore.ts b/open-sse/services/geminiThoughtSignatureStore.ts new file mode 100644 index 0000000000..f52d7a8821 --- /dev/null +++ b/open-sse/services/geminiThoughtSignatureStore.ts @@ -0,0 +1,44 @@ +const MAX_SIGNATURES = 1000; +const TTL_MS = 1000 * 60 * 60; + +type Entry = { + signature: string; + expiresAt: number; +}; + +const signatures = new Map(); + +function pruneExpired() { + const now = Date.now(); + for (const [key, value] of signatures.entries()) { + if (value.expiresAt <= now) { + signatures.delete(key); + } + } + + while (signatures.size > MAX_SIGNATURES) { + const oldestKey = signatures.keys().next().value; + if (!oldestKey) break; + signatures.delete(oldestKey); + } +} + +export function storeGeminiThoughtSignature(toolCallId: unknown, signature: unknown) { + if (typeof toolCallId !== "string" || !toolCallId) return; + if (typeof signature !== "string" || !signature) return; + + pruneExpired(); + signatures.set(toolCallId, { + signature, + expiresAt: Date.now() + TTL_MS, + }); +} + +export function getGeminiThoughtSignature(toolCallId: unknown) { + if (typeof toolCallId !== "string" || !toolCallId) return null; + + pruneExpired(); + const entry = signatures.get(toolCallId); + if (!entry) return null; + return entry.signature; +} diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index dc4212ec95..2cee5d98f7 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -92,9 +92,6 @@ export function claudeToGeminiRequest(model, body, stream) { break; case "tool_use": - // Do NOT include thoughtSignature on functionCall parts — it is only valid - // on thinking/reasoning parts and causes HTTP 400 "invalid argument" from the - // Gemini API when present on a functionCall part. parts.push({ functionCall: { id: block.id, @@ -148,19 +145,20 @@ export function claudeToGeminiRequest(model, body, stream) { // Map Claude roles to Gemini roles const geminiRole = msg.role === "assistant" ? "model" : "user"; - // Gemini 3+ requires thoughtSignature as a sibling part in model content - // that contains functionCall parts. Inject if not already present from - // a thinking block. (#927) + // Gemini 3+ expects the signature on the first functionCall part in a tool-call + // batch. If the assistant turn had no explicit thinking block, inject a fallback + // signature into that first functionCall. (#927) if (geminiRole === "model") { const hasFunctionCall = parts.some((p) => p.functionCall); const hasSignature = parts.some((p) => p.thoughtSignature); if (hasFunctionCall && !hasSignature) { - // Insert before the first functionCall part const fcIndex = parts.findIndex((p) => p.functionCall); - parts.splice(fcIndex, 0, { - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, - text: "", - }); + if (fcIndex >= 0) { + parts[fcIndex] = { + ...parts[fcIndex], + thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, + }; + } } } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index fafe9c6796..96431330dc 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -2,6 +2,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.ts"; +import { getGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.ts"; import { capMaxOutputTokens, @@ -74,6 +75,15 @@ type CloudCodeEnvelope = { }; }; +function normalizeAntigravityToolName(name: unknown) { + if (typeof name !== "string") return name; + const trimmed = name.trim(); + if (!trimmed) return trimmed; + + const namespaceIndex = trimmed.indexOf(":"); + return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed; +} + // Core: Convert OpenAI request to Gemini format (base for all variants) function openaiToGeminiBase(model, body, stream) { const result: GeminiRequest = { @@ -167,31 +177,40 @@ function openaiToGeminiBase(model, body, stream) { } if (msg.tool_calls && Array.isArray(msg.tool_calls)) { - // Gemini 3+ requires thoughtSignature as a sibling part in model content - // that contains functionCall parts. If no reasoning_content was present - // (which already injects the signature above), inject one now. (#927) - const hasSignatureAlready = parts.some((p) => p.thoughtSignature); - if (!hasSignatureAlready) { - parts.push({ - thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE, - }); - } - const toolCallIds = []; + const firstPersistedSignature = msg.tool_calls + .map((tc) => getGeminiThoughtSignature(tc.id)) + .find((signature) => typeof signature === "string" && signature.length > 0); + + const shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature); + let embeddedSignatureUsed = false; + for (const tc of msg.tool_calls) { if (tc.type !== "function") continue; const args = tryParseJSON(tc.function?.arguments || "{}"); - // Do NOT include thoughtSignature ON the functionCall part itself — it is - // only valid as a separate sibling part. Including it inside functionCall - // causes HTTP 400 "invalid argument" (#725). + const signatureForToolCall = getGeminiThoughtSignature(tc.id); + const embeddedThoughtSignature = + shouldUseEmbeddedSignature && !embeddedSignatureUsed + ? firstPersistedSignature || + signatureForToolCall || + DEFAULT_THINKING_GEMINI_SIGNATURE + : undefined; + + // Gemini expects the signature on the functionCall part itself. For + // parallel calls, only the first functionCall in the batch carries it. parts.push({ + ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), functionCall: { id: tc.id, name: tc.function.name, args: args, }, }); + + if (embeddedThoughtSignature) { + embeddedSignatureUsed = true; + } toolCallIds.push(tc.id); } @@ -330,6 +349,7 @@ export function openaiToGeminiCLIRequest(model, body, stream) { // Clean schema for tools if (gemini.tools?.[0]?.functionDeclarations) { for (const fn of gemini.tools[0].functionDeclarations) { + fn.name = normalizeAntigravityToolName(fn.name); if (fn.parameters) { const cleanedSchema = cleanJSONSchemaForAntigravity(fn.parameters); fn.parameters = cleanedSchema; @@ -343,6 +363,20 @@ export function openaiToGeminiCLIRequest(model, body, stream) { } } + if (Array.isArray(gemini.contents)) { + for (const content of gemini.contents) { + if (!Array.isArray(content.parts)) continue; + for (const part of content.parts) { + if (part.functionCall?.name) { + part.functionCall.name = normalizeAntigravityToolName(part.functionCall.name); + } + if (part.functionResponse?.name) { + part.functionResponse.name = normalizeAntigravityToolName(part.functionResponse.name); + } + } + } + } + return gemini; } diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index c57ab9299a..e335231dbc 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -1,5 +1,6 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; +import { storeGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; // Convert Gemini response chunk to OpenAI format export function geminiToOpenAIResponse(chunk, state) { @@ -38,6 +39,9 @@ export function geminiToOpenAIResponse(chunk, state) { for (const part of content.parts) { const hasThoughtSig = part.thoughtSignature || part.thought_signature; const isThought = part.thought === true; + if (hasThoughtSig && typeof hasThoughtSig === "string") { + state.pendingThoughtSignature = hasThoughtSig; + } // Handle thought signature (thinking mode) if (hasThoughtSig) { @@ -75,6 +79,11 @@ export function geminiToOpenAIResponse(chunk, state) { }, }; + if (state.pendingThoughtSignature) { + storeGeminiThoughtSignature(toolCall.id, state.pendingThoughtSignature); + state.pendingThoughtSignature = null; + } + state.toolCalls.set(toolCallIndex, toolCall); results.push({ @@ -127,6 +136,11 @@ export function geminiToOpenAIResponse(chunk, state) { }, }; + if (state.pendingThoughtSignature) { + storeGeminiThoughtSignature(toolCall.id, state.pendingThoughtSignature); + state.pendingThoughtSignature = null; + } + state.toolCalls.set(toolCallIndex, toolCall); results.push({ diff --git a/open-sse/utils/cacheControlPolicy.ts b/open-sse/utils/cacheControlPolicy.ts index 0810674bb1..cd538e2ebf 100644 --- a/open-sse/utils/cacheControlPolicy.ts +++ b/open-sse/utils/cacheControlPolicy.ts @@ -148,6 +148,13 @@ export function shouldPreserveCacheControl({ return false; } + // CC-compatible bridges should default to OmniRoute-managed cache markers. + // Their request shape differs from native Claude Messages payloads, so + // preserving client markers in auto mode weakens cache coverage. + if (typeof targetProvider === "string" && targetProvider.startsWith("anthropic-compatible-cc-")) { + return false; + } + // Auto mode: use automatic detection (existing logic) // Must be a caching-aware client if (!isClaudeCodeClient(userAgent)) { diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 774552da93..f6b1fe35f7 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -277,7 +277,7 @@ export default function RoutingTab() { { value: "auto", label: "Auto (Recommended)", - desc: "Preserve cache_control only for caching-aware clients (Claude Code) with deterministic routing", + desc: "Preserve cache_control for native Claude-compatible flows with deterministic routing; CC-compatible bridges use OmniRoute-managed markers", }, { value: "always", diff --git a/src/lib/oauth/providers/claude.ts b/src/lib/oauth/providers/claude.ts index 9be6e75938..6f3f3eb560 100644 --- a/src/lib/oauth/providers/claude.ts +++ b/src/lib/oauth/providers/claude.ts @@ -3,12 +3,12 @@ import { CLAUDE_CONFIG } from "../constants/oauth"; export const claude = { config: CLAUDE_CONFIG, flowType: "authorization_code_pkce", - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + buildAuthUrl: (config, _redirectUri, state, codeChallenge) => { const params = new URLSearchParams({ code: "true", client_id: config.clientId, response_type: "code", - redirect_uri: redirectUri, + redirect_uri: config.redirectUri, scope: config.scopes.join(" "), code_challenge: codeChallenge, code_challenge_method: config.codeChallengeMethod, @@ -16,7 +16,7 @@ export const claude = { }); return `${config.authorizeUrl}?${params.toString()}`; }, - exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + exchangeToken: async (config, code, _redirectUri, codeVerifier, state) => { let authCode = code; let codeState = ""; if (authCode.includes("#")) { @@ -36,7 +36,7 @@ export const claude = { state: codeState || state, grant_type: "authorization_code", client_id: config.clientId, - redirect_uri: redirectUri, + redirect_uri: config.redirectUri, code_verifier: codeVerifier, }), }); diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 5cd8a8cefb..8f28073265 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -316,10 +316,13 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck }; * Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup */ function getProviderSearchPool(provider: string): string[] { - const pool = [provider]; - if (provider === "nvidia") pool.push("nvidia_nim"); - if (provider === "nvidia_nim") pool.push("nvidia"); - return [...new Set(pool)]; + if (provider === "nvidia") { + return ["nvidia", "nvidia_nim"]; + } + if (provider === "nvidia_nim") { + return ["nvidia_nim", "nvidia"]; + } + return [provider]; } /** @@ -350,11 +353,10 @@ export async function getProviderCredentials( // Fix #922: Check for aliases (nvidia/nvidia_nim) to ensure credentials are found const providersToSearch = getProviderSearchPool(provider); - let connectionsRaw: any[] = []; - for (const p of providersToSearch) { - const results = await getProviderConnections({ provider: p, isActive: true }); - if (Array.isArray(results)) connectionsRaw.push(...results); - } + const connectionResults = await Promise.all( + providersToSearch.map((p) => getProviderConnections({ provider: p, isActive: true })) + ); + const connectionsRaw = connectionResults.filter(Array.isArray).flat(); let connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []) .map(toProviderConnection) @@ -371,11 +373,10 @@ export async function getProviderCredentials( if (connections.length === 0) { // Check all connections (including inactive) to see if rate limited // Fix #922: Also search aliases here - let allConnectionsRaw: any[] = []; - for (const p of providersToSearch) { - const results = await getProviderConnections({ provider: p }); - if (Array.isArray(results)) allConnectionsRaw.push(...results); - } + const allConnectionsResults = await Promise.all( + providersToSearch.map((p) => getProviderConnections({ provider: p })) + ); + const allConnectionsRaw = allConnectionsResults.filter(Array.isArray).flat(); const allConnections = (Array.isArray(allConnectionsRaw) ? allConnectionsRaw : []) .map(toProviderConnection) .filter((conn) => conn.id.length > 0); diff --git a/tests/unit/cache-control-claude-providers.test.mjs b/tests/unit/cache-control-claude-providers.test.mjs index ae437cdf89..820a1676cd 100644 --- a/tests/unit/cache-control-claude-providers.test.mjs +++ b/tests/unit/cache-control-claude-providers.test.mjs @@ -138,4 +138,19 @@ describe("Cache Control Policy - Claude Protocol Providers", () => { false ); }); + + test("shouldPreserveCacheControl defaults CC-compatible providers to OmniRoute-managed cache in auto mode", () => { + const claudeCodeUA = "Claude-Code/1.0.0"; + + assert.equal( + shouldPreserveCacheControl({ + userAgent: claudeCodeUA, + isCombo: false, + targetProvider: "anthropic-compatible-cc-cm", + targetFormat: "claude", + settings: { alwaysPreserveClientCache: "auto" }, + }), + false + ); + }); }); diff --git a/tests/unit/cc-compatible-provider.test.mjs b/tests/unit/cc-compatible-provider.test.mjs index 022668a1e9..4060f825d5 100644 --- a/tests/unit/cc-compatible-provider.test.mjs +++ b/tests/unit/cc-compatible-provider.test.mjs @@ -106,7 +106,7 @@ test("buildClaudeCodeCompatibleRequest keeps prior role history while dropping t ); assert.deepEqual(payload.messages[0].content.at(-1).cache_control, { type: "ephemeral" }); assert.deepEqual(payload.messages[1].content.at(-1).cache_control, { type: "ephemeral" }); - assert.equal(payload.messages[2].content.at(-1).cache_control, undefined); + assert.deepEqual(payload.messages[2].content.at(-1).cache_control, { type: "ephemeral" }); assert.equal(payload.system.length, 4); assert.equal(payload.system.at(-1).text, "sys"); assert.equal(payload.tools.length, 1); @@ -182,10 +182,90 @@ test("buildClaudeCodeCompatibleRequest preserves Claude cache markers when reque type: "ephemeral", ttl: "10m", }); - assert.equal(payload.messages[2].content[0].cache_control, undefined); + assert.deepEqual(payload.messages[2].content[0].cache_control, { type: "ephemeral" }); assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "30m" }); }); +test("buildClaudeCodeCompatibleRequest supplements missing Claude cache markers in preserve mode", () => { + const payload = buildClaudeCodeCompatibleRequest({ + sourceBody: { + max_tokens: 64, + }, + normalizedBody: { + max_tokens: 64, + messages: [{ role: "user", content: "fallback" }], + }, + claudeBody: { + system: [{ type: "text", text: "sys" }], + messages: [ + { + role: "user", + content: [{ type: "text", text: "u1" }], + }, + { + role: "assistant", + content: [{ type: "text", text: "a1" }], + }, + { role: "user", content: [{ type: "text", text: "u2" }] }, + ], + tools: [ + { + name: "lookup_weather", + description: "Fetch weather", + input_schema: { + type: "object", + properties: { + city: { type: "string" }, + }, + required: ["city"], + }, + }, + ], + }, + model: "claude-sonnet-4-6", + sessionId: "session-preserve-defaults", + preserveCacheControl: true, + }); + + assert.deepEqual(payload.messages[0].content[0].cache_control, { type: "ephemeral" }); + assert.deepEqual(payload.messages[1].content[0].cache_control, { type: "ephemeral" }); + assert.deepEqual(payload.messages[2].content[0].cache_control, { type: "ephemeral" }); + assert.deepEqual(payload.tools[0].cache_control, { type: "ephemeral", ttl: "1h" }); +}); + +test("buildClaudeCodeCompatibleRequest marks final user turn and 1h system cache in non-preserve mode", () => { + const largeUserPrompt = Array.from( + { length: 200 }, + (_, index) => `Context line ${index + 1}: repeated stable context for cache testing.` + ).join("\n"); + + const payload = buildClaudeCodeCompatibleRequest({ + sourceBody: { + max_tokens: 64, + }, + normalizedBody: { + max_tokens: 64, + messages: [ + { role: "system", content: "Follow the house style exactly." }, + { role: "user", content: "[Start a new chat]" }, + { role: "assistant", content: "Hello short ack" }, + { role: "user", content: largeUserPrompt }, + ], + }, + model: "claude-sonnet-4-6", + sessionId: "session-last-user-cache", + preserveCacheControl: false, + }); + + assert.deepEqual(payload.system.at(-1).cache_control, { + type: "ephemeral", + ttl: "1h", + }); + assert.deepEqual(payload.messages[0].content[0].cache_control, { type: "ephemeral" }); + assert.deepEqual(payload.messages[1].content[0].cache_control, { type: "ephemeral" }); + assert.deepEqual(payload.messages[2].content[0].cache_control, { type: "ephemeral" }); +}); + test("buildClaudeCodeCompatibleRequest falls back to a user turn when the source only has assistant/model text", () => { const payload = buildClaudeCodeCompatibleRequest({ sourceBody: { @@ -419,7 +499,7 @@ test("handleChatCore forces upstream streaming for CC compatible while returning assert.equal(payload.usage.completion_tokens, 5); }); -test("handleChatCore preserves Claude cache_control when CC-compatible requests originate from Claude", async () => { +test("handleChatCore applies OmniRoute-managed cache strategy for CC-compatible requests in auto mode", async () => { const calls = []; globalThis.fetch = async (url, init = {}) => { calls.push({ @@ -524,18 +604,20 @@ test("handleChatCore preserves Claude cache_control when CC-compatible requests assert.equal(calls.length, 1); assert.deepEqual(calls[0].body.system.at(-1).cache_control, { type: "ephemeral", - ttl: "5m", + ttl: "1h", }); assert.deepEqual(calls[0].body.messages[0].content[0].cache_control, { type: "ephemeral", }); assert.deepEqual(calls[0].body.messages[1].content[0].cache_control, { type: "ephemeral", - ttl: "10m", + }); + assert.deepEqual(calls[0].body.messages[2].content[0].cache_control, { + type: "ephemeral", }); assert.deepEqual(calls[0].body.tools[0].cache_control, { type: "ephemeral", - ttl: "30m", + ttl: "1h", }); }); diff --git a/tests/unit/claude-oauth-provider.test.mjs b/tests/unit/claude-oauth-provider.test.mjs index 479a14429c..6189f60664 100644 --- a/tests/unit/claude-oauth-provider.test.mjs +++ b/tests/unit/claude-oauth-provider.test.mjs @@ -10,17 +10,22 @@ test.afterEach(() => { globalThis.fetch = originalFetch; }); -test("Claude OAuth provider uses the runtime redirectUri when building the auth URL", () => { - const redirectUri = "http://localhost:43121/callback"; - const authUrl = claude.buildAuthUrl(CLAUDE_CONFIG, redirectUri, "state-123", "challenge-456"); +test("Claude OAuth provider always uses the configured redirectUri when building the auth URL", () => { + const runtimeRedirectUri = "http://localhost:43121/callback"; + const authUrl = claude.buildAuthUrl( + CLAUDE_CONFIG, + runtimeRedirectUri, + "state-123", + "challenge-456" + ); const parsed = new URL(authUrl); - assert.equal(parsed.searchParams.get("redirect_uri"), redirectUri); + assert.equal(parsed.searchParams.get("redirect_uri"), CLAUDE_CONFIG.redirectUri); assert.equal(parsed.searchParams.get("state"), "state-123"); assert.equal(parsed.searchParams.get("code_challenge"), "challenge-456"); }); -test("Claude OAuth provider uses the runtime redirectUri during token exchange", async () => { +test("Claude OAuth provider always uses the configured redirectUri during token exchange", async () => { let captured = null; globalThis.fetch = async (url, init = {}) => { @@ -37,18 +42,18 @@ test("Claude OAuth provider uses the runtime redirectUri during token exchange", }); }; - const redirectUri = "http://localhost:43121/callback"; + const runtimeRedirectUri = "http://localhost:43121/callback"; await claude.exchangeToken( CLAUDE_CONFIG, "auth-code#state-from-fragment", - redirectUri, + runtimeRedirectUri, "verifier-123", "state-from-request" ); assert.equal(captured.url, CLAUDE_CONFIG.tokenUrl); assert.equal(captured.method, "POST"); - assert.equal(captured.body.redirect_uri, redirectUri); + assert.equal(captured.body.redirect_uri, CLAUDE_CONFIG.redirectUri); assert.equal(captured.body.code, "auth-code"); assert.equal(captured.body.state, "state-from-fragment"); assert.equal(captured.body.code_verifier, "verifier-123"); diff --git a/tests/unit/t27-github-copilot-response-format.test.mjs b/tests/unit/t27-github-copilot-response-format.test.mjs index 83945d9be8..4cc439c24a 100644 --- a/tests/unit/t27-github-copilot-response-format.test.mjs +++ b/tests/unit/t27-github-copilot-response-format.test.mjs @@ -1,6 +1,5 @@ import test from "node:test"; import assert from "node:assert/strict"; - const { GithubExecutor } = await import("../../open-sse/executors/github.ts"); const { BaseExecutor } = await import("../../open-sse/executors/base.ts"); @@ -16,6 +15,16 @@ function streamFromChunks(chunks) { }); } +const originalFetch = globalThis.fetch; + +test.afterEach(async () => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; +}); + test("T27: Claude + response_format=json_object injects system instruction and strips response_format field", () => { const executor = new GithubExecutor(); const request = { @@ -109,3 +118,103 @@ test("T27: streaming error responses keep their original body readable", async ( BaseExecutor.prototype.execute = originalExecute; } }); + +test("T27: requests use copilotToken from providerSpecificData when available", async () => { + globalThis.fetch = async (_url, init = {}) => { + assert.equal(init.headers.Authorization, "Bearer copilot_test"); + return new Response( + JSON.stringify({ + choices: [ + { index: 0, message: { role: "assistant", content: "OK" }, finish_reason: "stop" }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + }; + + const executor = new GithubExecutor(); + const result = await executor.execute({ + model: "gemini-3.1-pro-preview", + body: { messages: [{ role: "user", content: "Ping" }], stream: false }, + stream: false, + credentials: { + accessToken: "ghu_test", + providerSpecificData: { + copilotToken: "copilot_test", + }, + }, + }); + + assert.equal(result.response.status, 200); + assert.match(await result.response.text(), /OK/); +}); + +test("T27: non-stream execute materializes provider responses before returning", async () => { + const executor = new GithubExecutor(); + const originalExecute = BaseExecutor.prototype.execute; + + class WeirdResponse { + constructor(body, init = {}) { + this._body = body; + this.status = init.status || 200; + this.statusText = init.statusText || "OK"; + this.headers = new Headers(init.headers || {}); + this.bodyUsed = false; + this.body = {}; + } + + async text() { + if (this.bodyUsed) { + throw new TypeError("Response body is already used"); + } + this.bodyUsed = true; + return this._body; + } + } + + BaseExecutor.prototype.execute = async () => ({ + response: new WeirdResponse(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + url: "https://api.githubcopilot.com/chat/completions", + }); + + try { + const result = await executor.execute({ + model: "gemini-3.1-pro-preview", + body: { messages: [{ role: "user", content: "Ping" }], stream: false }, + stream: false, + credentials: { + accessToken: "ghu_test", + providerSpecificData: { + copilotToken: "copilot_test", + }, + }, + }); + + assert.equal(result.response.constructor.name, "Response"); + assert.equal(await result.response.text(), JSON.stringify({ ok: true })); + } finally { + BaseExecutor.prototype.execute = originalExecute; + } +}); + +test("T27: needsRefresh respects providerSpecificData copilot token metadata", () => { + const executor = new GithubExecutor(); + const expiresAt = Math.floor(Date.now() / 1000) + 3600; + + assert.equal( + executor.needsRefresh({ + accessToken: "ghu_test", + providerSpecificData: { + copilotToken: "copilot_test", + copilotTokenExpiresAt: expiresAt, + }, + }), + false + ); +}); diff --git a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs index 8adc7722b0..af517ad03c 100644 --- a/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs +++ b/tests/unit/t43-gemini-tool-call-no-thought-signature.test.mjs @@ -1,10 +1,10 @@ /** - * T43: Gemini tool call parts must NOT include thoughtSignature. + * T43: Gemini tool call parts must preserve thoughtSignature correctly. * * Regression test for HTTP 400 "invalid argument" when OmniRoute translates - * OpenAI tool_calls to Gemini format. The thoughtSignature field is only valid - * on thinking/reasoning parts — injecting it on functionCall parts causes the - * Gemini API to reject the request with a 400 error. + * OpenAI tool_calls to Gemini format. Gemini 3 requires the signature to live on + * the first functionCall part for a tool-call batch, and replays fail if the + * signature is stripped or emitted as a separate sibling part. * * Reproduces: https://github.com/diegosouzapw/OmniRoute/issues/725 */ @@ -24,7 +24,7 @@ function translateToGemini(messages, tools) { }); } -test("T43: functionCall parts must not contain thoughtSignature", () => { +test("T43: first functionCall part keeps thoughtSignature", () => { const messages = [ { role: "user", content: "What is the weather in Tokyo?" }, { @@ -69,19 +69,18 @@ test("T43: functionCall parts must not contain thoughtSignature", () => { assert.ok(modelTurn, "Expected a model turn with functionCall parts"); - for (const part of modelTurn.parts) { - if (part.functionCall) { - assert.ok( - !("thoughtSignature" in part), - `functionCall part must not contain thoughtSignature — Gemini API returns HTTP 400 "invalid argument" when it does. Got: ${JSON.stringify(part)}` - ); - assert.equal(part.functionCall.name, "get_weather"); - assert.deepEqual(part.functionCall.args, { location: "Tokyo" }); - } - } + const functionCallParts = modelTurn.parts.filter((part) => part.functionCall); + assert.equal(functionCallParts.length, 1, "Expected exactly 1 functionCall part"); + assert.equal(functionCallParts[0].functionCall.name, "get_weather"); + assert.deepEqual(functionCallParts[0].functionCall.args, { location: "Tokyo" }); + assert.ok( + typeof functionCallParts[0].thoughtSignature === "string" && + functionCallParts[0].thoughtSignature.length > 0, + `first functionCall part must carry thoughtSignature. Got: ${JSON.stringify(functionCallParts[0])}` + ); }); -test("T43: multiple tool calls — none of the functionCall parts may have thoughtSignature", () => { +test("T43: multiple tool calls only tag the first functionCall part", () => { const messages = [ { role: "user", content: "Get weather for Tokyo and London" }, { @@ -122,12 +121,15 @@ test("T43: multiple tool calls — none of the functionCall parts may have thoug const functionCallParts = modelTurn.parts.filter((p) => p.functionCall); assert.equal(functionCallParts.length, 2, "Expected 2 functionCall parts"); - for (const part of functionCallParts) { - assert.ok( - !("thoughtSignature" in part), - `functionCall part must not contain thoughtSignature. Got: ${JSON.stringify(part)}` - ); - } + assert.ok( + typeof functionCallParts[0].thoughtSignature === "string" && + functionCallParts[0].thoughtSignature.length > 0, + `first functionCall part must carry thoughtSignature. Got: ${JSON.stringify(functionCallParts[0])}` + ); + assert.ok( + !("thoughtSignature" in functionCallParts[1]), + `parallel follow-up functionCall parts must stay unsigned. Got: ${JSON.stringify(functionCallParts[1])}` + ); }); test("T43: thinking parts still include thoughtSignature (regression guard)", () => { diff --git a/tests/unit/t44-antigravity-thought-signature-preserved.test.mjs b/tests/unit/t44-antigravity-thought-signature-preserved.test.mjs new file mode 100644 index 0000000000..db9e6b290a --- /dev/null +++ b/tests/unit/t44-antigravity-thought-signature-preserved.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; + +test("T44: Antigravity preserves thoughtSignature for functionCall turns", () => { + const executor = new AntigravityExecutor(); + const transformed = executor.transformRequest( + "gemini-3-flash", + { + request: { + contents: [ + { + role: "model", + parts: [ + { thought: true, text: "internal reasoning" }, + { thoughtSignature: "sig_123" }, + { + functionCall: { + id: "call_1", + name: "default_api:memos_load_user_memory", + args: { userId: "u1" }, + }, + }, + ], + }, + ], + tools: [{ functionDeclarations: [{ name: "default_api:memos_load_user_memory" }] }], + }, + }, + true, + { projectId: "test-project" } + ); + + const parts = transformed.request.contents[0].parts; + + assert.equal( + parts.some((part) => part.thought === true), + false, + "thought text should still be stripped before sending to Antigravity" + ); + assert.equal( + parts.some((part) => part.thoughtSignature === "sig_123"), + true, + "tool-call turns must keep thoughtSignature for Gemini 3+ compatibility" + ); + assert.equal( + parts.some((part) => part.functionCall?.name === "default_api:memos_load_user_memory"), + true, + "functionCall must still be present" + ); +}); + +test("T44: Antigravity still strips standalone thoughtSignature without tool calls", () => { + const executor = new AntigravityExecutor(); + const transformed = executor.transformRequest( + "gemini-3-flash", + { + request: { + contents: [ + { + role: "model", + parts: [{ thoughtSignature: "sig_123" }, { text: "plain text" }], + }, + ], + }, + }, + true, + { projectId: "test-project" } + ); + + assert.deepEqual(transformed.request.contents[0].parts, [{ text: "plain text" }]); +});