Merge remote-tracking branch 'origin/main' into release/v3.4.9

This commit is contained in:
diegosouzapw
2026-04-03 03:21:40 -03:00
18 changed files with 618 additions and 125 deletions

View File

@@ -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([

View File

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

View File

@@ -166,12 +166,18 @@ export function buildClaudeCodeCompatibleRequest({
systemBlocks: preparedClaudeBody?.system as Record<string, unknown>[] | 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<string, unknown>[])
? applyClaudeCodeCompatibleToolCacheStrategy(
preparedClaudeBody.tools as Record<string, unknown>[],
{
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<Record<string, unknown>> | 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<string, unknown> => !!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<Record<string, unknown>> }>
messages: Array<{ role: "user" | "assistant"; content: Array<Record<string, unknown>> }>,
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<Record<string, unknow
}
}
function markLastContentCacheControl(content: Array<Record<string, unknown>>, ttl?: string) {
function applyClaudeCodeCompatibleToolCacheStrategy(
tools: Array<Record<string, unknown>>,
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<Record<string, unknown>>,
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<Record<string, unknown>>) {
for (let i = tools.length - 1; i >= 0; i--) {
if (!tools[i].defer_loading) {

View File

@@ -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 */

View File

@@ -0,0 +1,44 @@
const MAX_SIGNATURES = 1000;
const TTL_MS = 1000 * 60 * 60;
type Entry = {
signature: string;
expiresAt: number;
};
const signatures = new Map<string, Entry>();
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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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