mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge remote-tracking branch 'origin/main' into release/v3.4.9
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
44
open-sse/services/geminiThoughtSignatureStore.ts
Normal file
44
open-sse/services/geminiThoughtSignatureStore.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
Reference in New Issue
Block a user