diff --git a/.env.example b/.env.example index bd83328d19..792638803c 100644 --- a/.env.example +++ b/.env.example @@ -603,7 +603,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 CLAUDE_USER_AGENT="claude-cli/2.1.143 (external, cli)" CODEX_USER_AGENT="codex-cli/0.131.0 (Windows 10.0.26200; x64)" GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1" -ANTIGRAVITY_USER_AGENT="antigravity/1.23.2 darwin/arm64" +ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0" KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0" QODER_USER_AGENT="Qoder-Cli" QWEN_USER_AGENT="QwenCode/0.15.9 (linux; x64)" diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 45c6e8d079..ed19f31dbb 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -1,11 +1,132 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ - { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking" }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 Thinking" }, - { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, - { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, - { id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, - { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" }, - { id: "gpt-oss-120b-medium", name: "GPT OSS 120B Medium" }, + { + id: "claude-opus-4-6-thinking", + name: "Claude Opus 4.6 (Thinking)", + contextLength: 250000, + maxOutputTokens: 64000, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (Thinking)", + contextLength: 250000, + maxOutputTokens: 64000, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3-pro-preview", + name: "Gemini 3.1 Pro (High)", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.1-pro-low", + name: "Gemini 3.1 Pro (Low)", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-preview", + name: "Gemini 3.5 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3-flash-agent", + name: "Gemini 3.5 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.5-flash-low", + name: "Gemini 3.5 Flash (Low)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.1-flash-lite", + name: "Gemini 3.1 Flash Lite", + contextLength: 1048576, + maxOutputTokens: 65535, + toolCalling: true, + }, + { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-2.5-flash", + name: "Gemini 2.5 Flash", + contextLength: 1048576, + maxOutputTokens: 65535, + toolCalling: true, + }, + { + id: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash Lite", + contextLength: 1048576, + maxOutputTokens: 65535, + toolCalling: true, + }, + { + id: "gemini-2.5-flash-thinking", + name: "Gemini 2.5 Flash Thinking", + contextLength: 1048576, + maxOutputTokens: 65535, + toolCalling: true, + }, + { + id: "gemini-pro-agent", + name: "Gemini 3.1 Pro (High)", + contextLength: 1048576, + maxOutputTokens: 65535, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B (Medium)", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: true, + toolCalling: true, + }, + { id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image" }, + { id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash Image" }, { id: "gemini-2.5-computer-use-preview-10-2025", name: "Gemini 2.5 Computer Use Preview (10/2025)", @@ -14,6 +135,7 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ "gemini-3-pro-preview": "gemini-3.1-pro-high", + "gemini-3.5-flash-preview": "gemini-3-flash-agent", "gemini-3-flash-preview": "gemini-3-flash", "gemini-3-pro-image-preview": "gemini-3-pro-image", "gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p", @@ -26,6 +148,7 @@ type AntigravityModelAliasMap = Record; export const ANTIGRAVITY_REVERSE_MODEL_ALIASES: AntigravityModelAliasMap = Object.freeze({ "gemini-3.1-pro-high": "gemini-3-pro-preview", + "gemini-3-flash-agent": "gemini-3.5-flash-preview", "gemini-3-flash": "gemini-3-flash-preview", "gemini-3-pro-image": "gemini-3-pro-image-preview", "rev19-uic3-1p": "gemini-2.5-computer-use-preview-10-2025", diff --git a/open-sse/config/antigravityUpstream.ts b/open-sse/config/antigravityUpstream.ts index 843f917d82..539690665f 100644 --- a/open-sse/config/antigravityUpstream.ts +++ b/open-sse/config/antigravityUpstream.ts @@ -1,7 +1,7 @@ export const ANTIGRAVITY_BASE_URLS = Object.freeze([ - "https://daily-cloudcode-pa.sandbox.googleapis.com", "https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", ]); const ANTIGRAVITY_MODELS_PATH = "/v1internal:models"; diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index b4d4838e75..c1d2ef442d 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -174,17 +174,18 @@ export const CLI_FINGERPRINTS: Record = { }, antigravity: { headerOrder: [ - "Host", - "Content-Type", + "Accept", + "Accept-Encoding", "Authorization", + "Content-Type", "User-Agent", + "x-goog-api-client", "x-client-name", "x-client-version", "x-machine-id", "x-vscode-sessionid", - "x-goog-user-project", - "Accept", - "Accept-Encoding", + "Host", + "Connection", ], bodyFieldOrder: [ "project", diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5060b72b8c..21b67d5a71 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2820,7 +2820,12 @@ export async function handleChatCore({ credentials, provider, reqLogger, - { normalizeToolCallId, preserveDeveloperRole, preserveCacheControl } + { + normalizeToolCallId, + preserveDeveloperRole, + preserveCacheControl, + signatureNamespace: connectionId, + } ); } } catch (error) { @@ -3071,7 +3076,6 @@ export async function handleChatCore({ // Create stream controller for disconnect detection const streamController = createStreamController({ onDisconnect, - log, provider, model, connectionId, diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index e6cd541657..8ba6286a31 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -18,13 +18,8 @@ import { randomUUID } from "crypto"; import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; import { HTTP_STATUS } from "../config/constants.ts"; -import { antigravityUserAgent } from "../services/antigravityHeaders.ts"; -import { - deriveAntigravityMachineId, - getAntigravityEnvelopeUserAgent, - getAntigravityVscodeSessionId, -} from "../services/antigravityIdentity.ts"; -import { getCachedAntigravityVersion } from "../services/antigravityVersion.ts"; +import { applyAntigravityClientProfileHeaders } from "../services/antigravityClientProfile.ts"; +import { getAntigravityEnvelopeUserAgent } from "../services/antigravityIdentity.ts"; import { kieExecutor } from "../executors/kie.ts"; import { mapImageSize } from "../translator/image/sizeMapper.ts"; import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; @@ -699,13 +694,9 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden const headers = { "Content-Type": "application/json", Authorization: `Bearer ${token}`, - "User-Agent": antigravityUserAgent(), - "x-client-name": "antigravity", - "x-client-version": getCachedAntigravityVersion(), - "x-machine-id": deriveAntigravityMachineId(credentialRecord), - "x-vscode-sessionid": getAntigravityVscodeSessionId(), - "x-goog-user-project": projectId, }; + applyAntigravityClientProfileHeaders(headers, credentialRecord, antigravityBody); + delete headers["x-goog-user-project"]; if (log) { const promptPreview = promptText.slice(0, 60); @@ -716,25 +707,12 @@ async function handleGeminiImageGeneration({ model, providerConfig, body, creden } try { - let response = await fetch(url, { + const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(antigravityBody), }); - if (response.status === HTTP_STATUS.FORBIDDEN && headers["x-goog-user-project"]) { - const retryHeaders = { ...headers }; - delete retryHeaders["x-goog-user-project"]; - if (log) { - log.info("IMAGE", "antigravity image 403 with x-goog-user-project; retrying without it"); - } - response = await fetch(url, { - method: "POST", - headers: retryHeaders, - body: JSON.stringify(antigravityBody), - }); - } - if (!response.ok) { const errorText = await response.text(); const safeError = sanitizeImageProviderError(errorText); diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index 071ffa2262..3648d56912 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -24,6 +24,7 @@ export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT = ""; export const ANTIGRAVITY_NODE_API_CLIENT = "google-api-nodejs-client/10.3.0"; // Harness/bootstrap X-Goog-Api-Client synced with CLIProxyAPI misc.AntigravityGoogAPIClientUA. export const ANTIGRAVITY_CREDIT_PROBE_API_CLIENT = "gl-node/22.21.1"; +export const ANTIGRAVITY_API_CLIENT = ANTIGRAVITY_CREDIT_PROBE_API_CLIENT; type AntigravityLoadCodeAssistPlatform = "MACOS" | "WINDOWS" | "LINUX"; function getAntigravityLoadCodeAssistPlatformLabel( @@ -131,4 +132,9 @@ export function getAntigravityCreditProbeApiClientHeader(): string { return ANTIGRAVITY_CREDIT_PROBE_API_CLIENT; } +/** X-Goog-Api-Client used by harness/native Node Antigravity paths. */ +export function getAntigravityApiClientHeader(): string { + return ANTIGRAVITY_API_CLIENT; +} + export { ANTIGRAVITY_VERSION }; diff --git a/open-sse/services/antigravityProjectBootstrap.ts b/open-sse/services/antigravityProjectBootstrap.ts index 03e8ad9fc6..d9d4cdb58c 100644 --- a/open-sse/services/antigravityProjectBootstrap.ts +++ b/open-sse/services/antigravityProjectBootstrap.ts @@ -116,18 +116,20 @@ export async function ensureAntigravityProjectAssigned( accessToken: string, fetchImpl: FetchLike = fetch, clientProfile: AntigravityClientProfile = "ide" -): Promise { +): Promise { const cacheKey = getProjectCacheKey(accessToken, clientProfile); if (projectCache.has(cacheKey)) { - return; // already bootstrapped for this token + return projectCache.get(cacheKey); // already bootstrapped for this token } const projectId = await tryLoadCodeAssist(accessToken, fetchImpl, clientProfile); if (projectId) { projectCache.set(cacheKey, projectId); + return projectId; } // Non-fatal: if all endpoints failed, we proceed without caching. + return undefined; } /** Exported for tests. */ diff --git a/open-sse/services/geminiThoughtSignatureStore.ts b/open-sse/services/geminiThoughtSignatureStore.ts index 4f47b7e39a..ccc8d9670f 100644 --- a/open-sse/services/geminiThoughtSignatureStore.ts +++ b/open-sse/services/geminiThoughtSignatureStore.ts @@ -1,5 +1,10 @@ +import { getDbInstance } from "../../src/lib/db/core.ts"; + const MAX_SIGNATURES = 1000; -const TTL_MS = 1000 * 60 * 60; +const MAX_PERSISTED_SIGNATURES = 2_000; +const MEMORY_TTL_MS = 1000 * 60 * 60; +const PERSISTED_TTL_MS = 1000 * 60 * 60 * 24 * 30; +const NAMESPACE = "gemini_thought_signatures"; export type SignatureCacheMode = "enabled" | "bypass" | "bypass-strict"; @@ -8,8 +13,34 @@ type Entry = { expiresAt: number; }; +type PersistedEntry = Entry & { + createdAt: number; +}; + const signatures = new Map(); let signatureCacheMode: SignatureCacheMode = "enabled"; +let persistedPruneCounter = 0; +const loggedPersistenceErrors = new Set(); + +function warnPersistenceError(operation: string, error: unknown) { + if (process.env.NODE_ENV === "test") return; + if (loggedPersistenceErrors.has(operation)) return; + loggedPersistenceErrors.add(operation); + const message = error instanceof Error ? error.message : String(error); + console.warn(`[signature-cache] persisted ${operation} failed: ${message}`); +} + +export function buildGeminiThoughtSignatureKey(namespace: unknown, toolCallId: unknown): unknown { + if ( + typeof namespace === "string" && + namespace.length > 0 && + typeof toolCallId === "string" && + toolCallId.length > 0 + ) { + return `${namespace}:${toolCallId}`; + } + return toolCallId; +} function pruneExpired() { const now = Date.now(); @@ -26,15 +57,93 @@ function pruneExpired() { } } +function serializePersistedEntry(entry: PersistedEntry): string { + return JSON.stringify(entry); +} + +function parsePersistedEntry(value: string, now = Date.now()) { + try { + const parsed = JSON.parse(value) as Partial; + if (typeof parsed.signature !== "string" || parsed.signature.length === 0) return null; + const createdAt = typeof parsed.createdAt === "number" ? parsed.createdAt : now; + const expiresAt = + typeof parsed.expiresAt === "number" ? parsed.expiresAt : now + PERSISTED_TTL_MS; + if (expiresAt <= now) return null; + return { + entry: { signature: parsed.signature, createdAt, expiresAt }, + shouldRewrite: createdAt !== parsed.createdAt || expiresAt !== parsed.expiresAt, + }; + } catch { + if (!value) return null; + return { + entry: { + signature: value, + createdAt: now, + expiresAt: now + PERSISTED_TTL_MS, + }, + shouldRewrite: true, + }; + } +} + +function maybePrunePersistedSignatures(db: ReturnType) { + persistedPruneCounter += 1; + if (persistedPruneCounter % 100 !== 0) return; + + const rows = db + .prepare("SELECT key, value FROM key_value WHERE namespace = ?") + .all(NAMESPACE) as Array<{ key: string; value: string }>; + + const now = Date.now(); + const validRows: Array<{ key: string; createdAt: number }> = []; + const keysToDelete = new Set(); + + for (const row of rows) { + const parsed = parsePersistedEntry(row.value, now); + if (!parsed) { + keysToDelete.add(row.key); + continue; + } + validRows.push({ key: row.key, createdAt: parsed.entry.createdAt }); + } + + if (rows.length <= MAX_PERSISTED_SIGNATURES && keysToDelete.size === 0) return; + + validRows.sort((a, b) => b.createdAt - a.createdAt); + for (const row of validRows.slice(MAX_PERSISTED_SIGNATURES)) { + keysToDelete.add(row.key); + } + + if (keysToDelete.size === 0) return; + const remove = db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?"); + const tx = db.transaction((keys: string[]) => { + for (const key of keys) remove.run(NAMESPACE, key); + }); + tx([...keysToDelete]); +} + export function storeGeminiThoughtSignature(toolCallId: unknown, signature: unknown) { if (typeof toolCallId !== "string" || !toolCallId) return; if (typeof signature !== "string" || !signature) return; + const now = Date.now(); pruneExpired(); signatures.set(toolCallId, { signature, - expiresAt: Date.now() + TTL_MS, + expiresAt: now + MEMORY_TTL_MS, }); + + try { + const db = getDbInstance(); + db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( + NAMESPACE, + toolCallId, + serializePersistedEntry({ signature, createdAt: now, expiresAt: now + PERSISTED_TTL_MS }) + ); + maybePrunePersistedSignatures(db); + } catch (error) { + warnPersistenceError("store", error); + } } export function getGeminiThoughtSignature(toolCallId: unknown) { @@ -42,8 +151,44 @@ export function getGeminiThoughtSignature(toolCallId: unknown) { pruneExpired(); const entry = signatures.get(toolCallId); - if (!entry) return null; - return entry.signature; + if (entry) return entry.signature; + + try { + const db = getDbInstance(); + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") + .get(NAMESPACE, toolCallId) as { value: string } | undefined; + + if (row?.value) { + const persisted = parsePersistedEntry(row.value); + if (!persisted) { + db.prepare("DELETE FROM key_value WHERE namespace = ? AND key = ?").run( + NAMESPACE, + toolCallId + ); + return null; + } + + signatures.set(toolCallId, { + signature: persisted.entry.signature, + expiresAt: Date.now() + MEMORY_TTL_MS, + }); + + if (persisted.shouldRewrite) { + db.prepare("UPDATE key_value SET value = ? WHERE namespace = ? AND key = ?").run( + serializePersistedEntry(persisted.entry), + NAMESPACE, + toolCallId + ); + } + + return persisted.entry.signature; + } + } catch (error) { + warnPersistenceError("read", error); + } + + return null; } export function normalizeSignatureCacheMode(value: unknown): SignatureCacheMode { @@ -161,4 +306,19 @@ export function resolveGeminiThoughtSignature( export function clearGeminiThoughtSignatures() { signatures.clear(); signatureCacheMode = "enabled"; + try { + const db = getDbInstance(); + db.prepare("DELETE FROM key_value WHERE namespace = ?").run(NAMESPACE); + } catch (error) { + warnPersistenceError("clear", error); + } +} + +export function clearGeminiThoughtSignatureMemoryForTests() { + signatures.clear(); +} + +export function getGeminiThoughtSignatureMemorySizeForTests() { + pruneExpired(); + return signatures.size; } diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index d74947c435..ffa9758b2a 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -131,6 +131,7 @@ export function translateRequest( normalizeToolCallId?: boolean; preserveDeveloperRole?: boolean; preserveCacheControl?: boolean; + signatureNamespace?: string | null; } ) { let result = body; @@ -184,7 +185,13 @@ export function translateRequest( if (targetFormat !== FORMATS.OPENAI) { const fromOpenAI = getRequestTranslator(FORMATS.OPENAI, targetFormat); if (fromOpenAI) { - result = fromOpenAI(model, result, stream, credentials); + const translationCredentials = options?.signatureNamespace + ? { + ...(credentials && typeof credentials === "object" ? credentials : {}), + _signatureNamespace: options.signatureNamespace, + } + : credentials; + result = fromOpenAI(model, result, stream, translationCredentials); } } } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 1d4d016826..2140278d3f 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -2,7 +2,10 @@ 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 { resolveGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; +import { + buildGeminiThoughtSignatureKey, + resolveGeminiThoughtSignature, +} from "../../services/geminiThoughtSignatureStore.ts"; import { generateAntigravityRequestId, getAntigravityEnvelopeUserAgent, @@ -104,6 +107,8 @@ type CloudCodeEnvelope = { type GeminiToolNameOptions = { stripNamespace?: boolean; functionResponseShape?: "result" | "output"; + signatureNamespace?: string | null; + signaturelessToolCallMode?: "native" | "text"; }; type OpenAIToolCallLike = { @@ -286,20 +291,36 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName if (msg.tool_calls && Array.isArray(msg.tool_calls)) { const toolCallIds = []; - const firstPersistedSignature = msg.tool_calls - .map((tc) => resolveGeminiThoughtSignature(tc.id, extractClientThoughtSignature(tc))) - .find((signature) => typeof signature === "string" && signature.length > 0); + const resolvedSignatures = new Map(); + let firstPersistedSignature: string | undefined; + for (const tc of msg.tool_calls) { + const resolved = resolveGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(toolNameOptions.signatureNamespace, tc.id), + extractClientThoughtSignature(tc) + ); + if (typeof resolved === "string" && resolved.length > 0) { + resolvedSignatures.set(tc.id, resolved); + firstPersistedSignature ??= resolved; + } + } let shouldUseEmbeddedSignature = !parts.some((p) => p.thoughtSignature); + const stringifySignaturelessToolCalls = + toolNameOptions.signaturelessToolCallMode === "text"; for (const tc of msg.tool_calls) { if (tc.type !== "function") continue; + const signatureForToolCall = resolvedSignatures.get(tc.id); + if (!signatureForToolCall && stringifySignaturelessToolCalls) { + const args = tc.function?.arguments || "{}"; + parts.push({ + text: `[Tool call: ${tc.function?.name || "unknown"}]\nArguments: ${args}`, + }); + continue; + } + const args = tryParseJSON(tc.function?.arguments || "{}"); - const signatureForToolCall = resolveGeminiThoughtSignature( - tc.id, - extractClientThoughtSignature(tc) - ); const embeddedThoughtSignature = shouldUseEmbeddedSignature ? firstPersistedSignature || signatureForToolCall : undefined; @@ -326,7 +347,14 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } // Check if there are actual tool responses in the next messages - const hasActualResponses = toolCallIds.some((fid) => toolResponses[fid]); + const hasSignaturelessTextResponses = + stringifySignaturelessToolCalls && + msg.tool_calls.some( + (tc) => + tc.type === "function" && !resolvedSignatures.has(tc.id) && toolResponses[tc.id] + ); + const hasActualResponses = + toolCallIds.some((fid) => toolResponses[fid]) || hasSignaturelessTextResponses; if (hasActualResponses) { const toolParts = []; @@ -363,6 +391,23 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName }, }); } + + if (stringifySignaturelessToolCalls) { + // Signature-less historical tool responses are represented as text + // so strict Gemini/Antigravity endpoints don't reject them as native + // functionResponse parts missing a matching thoughtSignature. + for (const tc of msg.tool_calls) { + if (tc.type !== "function" || !tc.id) continue; + if (!resolvedSignatures.has(tc.id) && toolResponses[tc.id]) { + const name = tcID2Name[tc.id] || tc.function?.name || "unknown"; + const resp = toolResponses[tc.id]; + toolParts.push({ + text: `[Tool response: ${name}]\nResult: ${resp}`, + }); + } + } + } + if (toolParts.length > 0) { result.contents.push({ role: "user", parts: toolParts }); } @@ -420,11 +465,17 @@ export function openaiToGeminiCLIRequest( model, body, stream, - options: { functionResponseShape?: "result" | "output" } = {} + options: { + functionResponseShape?: "result" | "output"; + signatureNamespace?: string | null; + signaturelessToolCallMode?: "native" | "text"; + } = {} ) { const gemini = openaiToGeminiBase(model, body, stream, { stripNamespace: true, functionResponseShape: options.functionResponseShape, + signatureNamespace: options.signatureNamespace, + signaturelessToolCallMode: options.signaturelessToolCallMode, }); // Add thinking config for CLI @@ -536,7 +587,16 @@ function getAntigravityClaudeOutputTokens(body: Record): number // OpenAI -> Antigravity (Sandbox Cloud Code with wrapper) export function openaiToAntigravityRequest(model, body, stream, credentials = null) { const isClaude = model.toLowerCase().includes("claude"); - const geminiCLI = openaiToGeminiCLIRequest(model, body, stream); + const signatureNamespace = + credentials && + typeof credentials === "object" && + typeof (credentials as Record)._signatureNamespace === "string" + ? ((credentials as Record)._signatureNamespace as string) + : null; + const geminiCLI = openaiToGeminiCLIRequest(model, body, stream, { + signatureNamespace, + signaturelessToolCallMode: isClaude ? "native" : "text", + }); if (isClaude) { geminiCLI.generationConfig.maxOutputTokens = getAntigravityClaudeOutputTokens(body); diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index e558848cde..e6e8681a4f 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -1,13 +1,88 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; -import { storeGeminiThoughtSignature } from "../../services/geminiThoughtSignatureStore.ts"; +import { + buildGeminiThoughtSignatureKey, + storeGeminiThoughtSignature, +} from "../../services/geminiThoughtSignatureStore.ts"; -function buildToolCallId(functionCall, toolName, toolCallIndex) { +type GeminiToOpenAIState = { + functionIndex: number; + messageId: string; + model: string; + pendingThoughtSignature?: string | null; + signatureNamespace?: string | null; + toolCalls: Map; + toolNameMap?: Map; +}; + +type GeminiFunctionCallPart = { + functionCall: { + args?: unknown; + id?: string; + name: string; + }; +}; + +function buildToolCallId( + functionCall: GeminiFunctionCallPart["functionCall"], + toolName: string, + toolCallIndex: number +) { return typeof functionCall?.id === "string" && functionCall.id.length > 0 ? functionCall.id : `${toolName}-${Date.now()}-${toolCallIndex}`; } +function getSignatureCacheKey( + state: Pick, + toolCallId: unknown +) { + return buildGeminiThoughtSignatureKey(state?.signatureNamespace, toolCallId); +} + +function emitFunctionCallPart( + part: GeminiFunctionCallPart, + state: GeminiToOpenAIState, + results: Array> +) { + const rawToolName = part.functionCall.name; + const fcName = state.toolNameMap?.get(rawToolName) || rawToolName; + const fcArgs = part.functionCall.args || {}; + const toolCallIndex = state.functionIndex++; + const toolCall = { + id: buildToolCallId(part.functionCall, fcName, toolCallIndex), + index: toolCallIndex, + type: "function", + function: { + name: fcName, + arguments: JSON.stringify(fcArgs), + }, + }; + + if (state.pendingThoughtSignature) { + storeGeminiThoughtSignature( + getSignatureCacheKey(state, toolCall.id), + state.pendingThoughtSignature + ); + state.pendingThoughtSignature = null; + } + + state.toolCalls.set(toolCallIndex, toolCall); + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { tool_calls: [toolCall] }, + finish_reason: null, + }, + ], + }); +} + // Convert Gemini response chunk to OpenAI format export function geminiToOpenAIResponse(chunk, state) { if (!chunk) return null; @@ -111,41 +186,7 @@ export function geminiToOpenAIResponse(chunk, state) { } if (hasFunctionCall) { - const rawToolName = part.functionCall.name; - const fcName = state.toolNameMap?.get(rawToolName) || rawToolName; - const fcArgs = part.functionCall.args || {}; - const toolCallIndex = state.functionIndex++; - - const toolCall = { - id: buildToolCallId(part.functionCall, fcName, toolCallIndex), - index: toolCallIndex, - type: "function", - function: { - name: fcName, - arguments: JSON.stringify(fcArgs), - }, - }; - - if (state.pendingThoughtSignature) { - storeGeminiThoughtSignature(toolCall.id, state.pendingThoughtSignature); - state.pendingThoughtSignature = null; - } - - state.toolCalls.set(toolCallIndex, toolCall); - - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [ - { - index: 0, - delta: { tool_calls: [toolCall] }, - finish_reason: null, - }, - ], - }); + emitFunctionCallPart(part, state, results); } continue; } @@ -169,41 +210,7 @@ export function geminiToOpenAIResponse(chunk, state) { // Function call if (part.functionCall) { - const rawToolName = part.functionCall.name; - const fcName = state.toolNameMap?.get(rawToolName) || rawToolName; - const fcArgs = part.functionCall.args || {}; - const toolCallIndex = state.functionIndex++; - - const toolCall = { - id: buildToolCallId(part.functionCall, fcName, toolCallIndex), - index: toolCallIndex, - type: "function", - function: { - name: fcName, - arguments: JSON.stringify(fcArgs), - }, - }; - - if (state.pendingThoughtSignature) { - storeGeminiThoughtSignature(toolCall.id, state.pendingThoughtSignature); - state.pendingThoughtSignature = null; - } - - state.toolCalls.set(toolCallIndex, toolCall); - - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [ - { - index: 0, - delta: { tool_calls: [toolCall] }, - finish_reason: null, - }, - ], - }); + emitFunctionCallPart(part, state, results); } // Inline data (images) diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index a34d836173..cda5a84a4d 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -58,7 +58,7 @@ export { COLORS, formatSSE }; type JsonRecord = Record; -const PENDING_REQUEST_CLEARED_MARKER = "__omniroutePendingRequestCleared"; +export const PENDING_REQUEST_CLEARED_MARKER = "__omniroutePendingRequestCleared"; function markPendingRequestCleared(error: Error): Error { (error as Error & Record)[PENDING_REQUEST_CLEARED_MARKER] = true; @@ -149,6 +149,7 @@ type StreamOptions = { type TranslateState = ReturnType & { provider?: string | null; toolNameMap?: unknown; + signatureNamespace?: string | null; usage?: unknown; finishReason?: unknown; copilotCompatibleReasoning?: boolean; @@ -544,6 +545,7 @@ export function createSSEStream(options: StreamOptions = {}) { onComplete = null, onFailure = null, } = options; + const signatureNamespace = connectionId; const clientExpectsResponsesStream = (mode === STREAM_MODE.PASSTHROUGH @@ -583,6 +585,7 @@ export function createSSEStream(options: StreamOptions = {}) { ...(initState(sourceFormat) as TranslateState), provider, toolNameMap, + signatureNamespace, copilotCompatibleReasoning, accumulatedContent: "", } diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index ea33f18e79..e87b96f520 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -1,9 +1,9 @@ import { trackPendingRequest } from "@/lib/usageDb"; import { FORMATS } from "../translator/formats.ts"; +import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts"; // Stream handler with disconnect detection - shared for all providers -const PENDING_REQUEST_CLEARED_MARKER = "__omniroutePendingRequestCleared"; const DISCONNECT_ABORT_DELAY_MS = 2_000; type StreamDisconnectEvent = { @@ -13,7 +13,6 @@ type StreamDisconnectEvent = { type StreamControllerOptions = { onDisconnect?: (event: StreamDisconnectEvent) => void; - log?: unknown; provider?: string; model?: string; connectionId?: string | null; @@ -22,6 +21,18 @@ type StreamControllerOptions = { type StreamController = ReturnType; +type StreamErrorStatusKind = "rate_limit" | "authentication" | "permission" | "client" | "server"; + +type StreamErrorStatusMapping = { + responses: { + type: string; + code: string; + }; + claude: { + type: string; + }; +}; + function isResponsesClientFormat(clientResponseFormat?: string | null): boolean { return ( clientResponseFormat === FORMATS.OPENAI_RESPONSES || @@ -29,27 +40,70 @@ function isResponsesClientFormat(clientResponseFormat?: string | null): boolean ); } -function responsesErrorType(statusCode: number): string { - if (statusCode === 429) return "rate_limit_error"; - if (statusCode === 401 || statusCode === 403) return "authentication_error"; - if (statusCode >= 400 && statusCode < 500) return "invalid_request_error"; - return "server_error"; +function getStreamErrorStatusKind(statusCode: number): StreamErrorStatusKind { + if (statusCode === 429) return "rate_limit"; + if (statusCode === 401) return "authentication"; + if (statusCode === 403) return "permission"; + if (statusCode >= 400 && statusCode < 500) return "client"; + return "server"; } -function responsesErrorCode(statusCode: number): string { - if (statusCode === 429) return "rate_limit_exceeded"; - if (statusCode === 401) return "invalid_authentication"; - if (statusCode === 403) return "permission_denied"; - if (statusCode >= 400 && statusCode < 500) return "bad_request"; - return "server_error"; +function getStreamErrorStatusMapping(statusCode: number): StreamErrorStatusMapping { + switch (getStreamErrorStatusKind(statusCode)) { + case "rate_limit": + return { + responses: { type: "rate_limit_error", code: "rate_limit_exceeded" }, + claude: { type: "rate_limit_error" }, + }; + case "authentication": + return { + responses: { type: "authentication_error", code: "invalid_authentication" }, + claude: { type: "authentication_error" }, + }; + case "permission": + return { + responses: { type: "authentication_error", code: "permission_denied" }, + claude: { type: "permission_error" }, + }; + case "client": + return { + responses: { type: "invalid_request_error", code: "bad_request" }, + claude: { type: "invalid_request_error" }, + }; + case "server": + return { + responses: { type: "server_error", code: "server_error" }, + claude: { type: "api_error" }, + }; + default: + return { + responses: { type: "server_error", code: "server_error" }, + claude: { type: "api_error" }, + }; + } } -function claudeErrorType(statusCode: number): string { - if (statusCode === 429) return "rate_limit_error"; - if (statusCode === 401) return "authentication_error"; - if (statusCode === 403) return "permission_error"; - if (statusCode >= 400 && statusCode < 500) return "invalid_request_error"; - return "api_error"; +function encodeSseEvent( + data: unknown, + { + event, + includeDone = false, + }: { + event?: string; + includeDone?: boolean; + } = {} +) { + if (event && /[\r\n]/.test(event)) { + throw new Error("SSE event names must not contain newlines"); + } + + const encoder = new TextEncoder(); + const prefix = event ? `event: ${event}\n` : ""; + const chunks = [encoder.encode(`${prefix}data: ${JSON.stringify(data)}\n\n`)]; + if (includeDone) { + chunks.push(encoder.encode("data: [DONE]\n\n")); + } + return chunks; } // Get HH:MM:SS timestamp @@ -73,7 +127,6 @@ function getTimeString() { /** @param {StreamControllerOptions} options */ export function createStreamController({ onDisconnect, - log, provider, model, connectionId, @@ -180,7 +233,8 @@ function buildStreamErrorChunks( statusCode: number, clientResponseFormat?: string | null ) { - const encoder = new TextEncoder(); + const statusMapping = getStreamErrorStatusMapping(statusCode); + if (isResponsesClientFormat(clientResponseFormat)) { const errorEvent = { type: "response.failed", @@ -189,25 +243,25 @@ function buildStreamErrorChunks( status: "failed", error: { message: errorMsg, - type: responsesErrorType(statusCode), - code: responsesErrorCode(statusCode), + type: statusMapping.responses.type, + code: statusMapping.responses.code, }, }, }; - return [encoder.encode(`event: response.failed\ndata: ${JSON.stringify(errorEvent)}\n\n`)]; + return encodeSseEvent(errorEvent, { event: "response.failed" }); } if (clientResponseFormat === FORMATS.CLAUDE) { const errorEvent = { type: "error", error: { - type: claudeErrorType(statusCode), + type: statusMapping.claude.type, message: errorMsg, }, }; - return [encoder.encode(`event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`)]; + return encodeSseEvent(errorEvent, { event: "error" }); } const errorEvent = { @@ -226,10 +280,7 @@ function buildStreamErrorChunks( }, }; - return [ - encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`), - encoder.encode(`data: [DONE]\n\n`), - ]; + return encodeSseEvent(errorEvent, { includeDone: true }); } /** diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 682795da1f..bf9292770f 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -61,7 +61,12 @@ export const SQLITE_FILE = isCloud ? null : path.join(DATA_DIR, "storage.sqlite" const JSON_DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json"); export const DB_BACKUPS_DIR = isCloud ? null : path.join(DATA_DIR, "db_backups"); const DEFAULT_CRITICAL_TABLE_ROW_LIMIT = 10_000; -const SKIP_PRESERVE_NAMESPACES = new Set(["syncedAvailableModels", "providerLimitsCache", "lkgp"]); +const SKIP_PRESERVE_NAMESPACES = new Set([ + "syncedAvailableModels", + "providerLimitsCache", + "lkgp", + "gemini_thought_signatures", +]); const CRITICAL_DB_TABLES: CriticalTableSpec[] = [ { table: "key_value", diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index cbe95ba0ec..bfb86fd9ee 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -1,3 +1,7 @@ +import { + ANTIGRAVITY_BASE_URLS, + getAntigravityFetchAvailableModelsUrls, +} from "@omniroute/open-sse/config/antigravityUpstream.ts"; import { ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, @@ -157,13 +161,16 @@ export const ANTIGRAVITY_CONFIG = { "https://www.googleapis.com/auth/experimentsandconfigs", ], // Antigravity specific - apiEndpoint: "https://daily-cloudcode-pa.sandbox.googleapis.com", + apiEndpoint: ANTIGRAVITY_BASE_URLS[0], apiVersion: "v1internal", - loadCodeAssistEndpoint: - "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist", - onboardUserEndpoint: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:onboardUser", - fetchAvailableModelsEndpoint: - "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels", + loadCodeAssistEndpoints: ANTIGRAVITY_BASE_URLS.map( + (baseUrl) => `${baseUrl}/v1internal:loadCodeAssist` + ), + onboardUserEndpoints: ANTIGRAVITY_BASE_URLS.map((baseUrl) => `${baseUrl}/v1internal:onboardUser`), + fetchAvailableModelsEndpoints: getAntigravityFetchAvailableModelsUrls(), + loadCodeAssistEndpoint: `${ANTIGRAVITY_BASE_URLS[0]}/v1internal:loadCodeAssist`, + onboardUserEndpoint: `${ANTIGRAVITY_BASE_URLS[0]}/v1internal:onboardUser`, + fetchAvailableModelsEndpoint: getAntigravityFetchAvailableModelsUrls()[0], loadCodeAssistUserAgent: ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT, loadCodeAssistApiClient: ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT, loadCodeAssistClientMetadata: getAntigravityLoadCodeAssistClientMetadata(), diff --git a/src/lib/oauth/providers/antigravity.ts b/src/lib/oauth/providers/antigravity.ts index 96e33520a0..566e4ddd5a 100644 --- a/src/lib/oauth/providers/antigravity.ts +++ b/src/lib/oauth/providers/antigravity.ts @@ -5,6 +5,20 @@ import { getAntigravityLoadCodeAssistMetadata, } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +async function fetchFirstOk(endpoints: string[], init: RequestInit) { + let lastError: unknown = null; + for (const endpoint of endpoints) { + try { + const response = await fetch(endpoint, init); + if (response.ok) return response; + lastError = new Error(`${response.status} ${await response.text()}`); + } catch (error) { + lastError = error; + } + } + throw lastError || new Error("No Antigravity endpoints configured"); +} + export const antigravity = { config: ANTIGRAVITY_CONFIG, flowType: "authorization_code", @@ -61,20 +75,18 @@ export const antigravity = { let projectId = ""; let tierId = "legacy-tier"; try { - const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { + const loadRes = await fetchFirstOk(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoints, { method: "POST", headers, body: JSON.stringify({ metadata }), }); - if (loadRes.ok) { - const data = await loadRes.json(); - projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; - if (Array.isArray(data.allowedTiers)) { - for (const tier of data.allowedTiers) { - if (tier.isDefault && tier.id) { - tierId = tier.id.trim(); - break; - } + const data = await loadRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + if (Array.isArray(data.allowedTiers)) { + for (const tier of data.allowedTiers) { + if (tier.isDefault && tier.id) { + tierId = tier.id.trim(); + break; } } } @@ -85,23 +97,19 @@ export const antigravity = { if (projectId) { try { for (let i = 0; i < 10; i++) { - const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { + const onboardRes = await fetchFirstOk(ANTIGRAVITY_CONFIG.onboardUserEndpoints, { method: "POST", headers, - body: JSON.stringify({ tierId, metadata, cloudaicompanionProject: projectId }), + body: JSON.stringify({ tier_id: tierId, metadata }), }); - if (onboardRes.ok) { - const result = await onboardRes.json(); - if (result.done === true) { - if (result.response?.cloudaicompanionProject) { - const respProject = result.response.cloudaicompanionProject; - projectId = - typeof respProject === "string" - ? respProject.trim() - : respProject.id || projectId; - } - break; + const result = await onboardRes.json(); + if (result.done === true) { + if (result.response?.cloudaicompanionProject) { + const respProject = result.response.cloudaicompanionProject; + projectId = + typeof respProject === "string" ? respProject.trim() : respProject.id || projectId; } + break; } await new Promise((resolve) => setTimeout(resolve, 5000)); } diff --git a/src/lib/oauth/services/antigravity.ts b/src/lib/oauth/services/antigravity.ts index 1deb5b4fd7..91d530790e 100644 --- a/src/lib/oauth/services/antigravity.ts +++ b/src/lib/oauth/services/antigravity.ts @@ -99,20 +99,43 @@ export class AntigravityService { return getAntigravityLoadCodeAssistMetadata(); } + private getEndpointList(key: string, fallbackKey: string) { + const endpoints = this.config[key]; + if (Array.isArray(endpoints) && endpoints.length > 0) return endpoints; + const fallback = this.config[fallbackKey]; + return typeof fallback === "string" && fallback ? [fallback] : []; + } + + private async fetchFirstOk(endpoints: string[], init: RequestInit, label: string) { + let lastError: unknown = null; + for (const endpoint of endpoints) { + try { + const response = await fetch(endpoint, init); + if (response.ok) return response; + lastError = new Error(`${response.status} ${await response.text()}`); + } catch (error) { + lastError = error; + } + } + + const message = + lastError instanceof Error ? lastError.message : String(lastError || "no endpoints"); + throw new Error(`Failed to ${label}: ${message}`); + } + /** * Fetch Project ID and Tier from loadCodeAssist API */ async loadCodeAssist(accessToken: string) { - const response = await fetch(this.config.loadCodeAssistEndpoint, { - method: "POST", - headers: this.getApiHeaders(accessToken), - body: JSON.stringify({ metadata: this.getMetadata() }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to load code assist: ${errorText}`); - } + const response = await this.fetchFirstOk( + this.getEndpointList("loadCodeAssistEndpoints", "loadCodeAssistEndpoint"), + { + method: "POST", + headers: this.getApiHeaders(accessToken), + body: JSON.stringify({ metadata: this.getMetadata() }), + }, + "load code assist" + ); const data = await response.json(); @@ -139,21 +162,19 @@ export class AntigravityService { /** * Onboard user to enable Gemini Code Assist for the project */ - async onboardUser(accessToken: string, projectId: string, tierId: string) { - const response = await fetch(this.config.onboardUserEndpoint, { - method: "POST", - headers: this.getApiHeaders(accessToken), - body: JSON.stringify({ - tierId, - metadata: this.getMetadata(), - cloudaicompanionProject: projectId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to onboard user: ${errorText}`); - } + async onboardUser(accessToken: string, tierId: string) { + const response = await this.fetchFirstOk( + this.getEndpointList("onboardUserEndpoints", "onboardUserEndpoint"), + { + method: "POST", + headers: this.getApiHeaders(accessToken), + body: JSON.stringify({ + tier_id: tierId, + metadata: this.getMetadata(), + }), + }, + "onboard user" + ); return await response.json(); } @@ -168,7 +189,7 @@ export class AntigravityService { maxRetries = 10 ) { for (let i = 0; i < maxRetries; i++) { - const result = await this.onboardUser(accessToken, projectId, tierId); + const result = await this.onboardUser(accessToken, tierId); if (result.done === true) { // Extract final project ID from response diff --git a/tests/unit/antigravity-discovery-bootstrap.test.ts b/tests/unit/antigravity-discovery-bootstrap.test.ts index f89acfc728..3d0aa82f98 100644 --- a/tests/unit/antigravity-discovery-bootstrap.test.ts +++ b/tests/unit/antigravity-discovery-bootstrap.test.ts @@ -45,13 +45,16 @@ describe("ensureAntigravityProjectAssigned", () => { return new Response("Not Found", { status: 404 }); }; - await ensureAntigravityProjectAssigned("fake-token-1", mockFetch); + const projectId = await ensureAntigravityProjectAssigned("fake-token-1", mockFetch); const loadCalls = calls.filter((u) => u.endsWith(":loadCodeAssist")); assert.ok(loadCalls.length >= 1, ":loadCodeAssist must be called at least once"); - - const cached = getAntigravityProjectFromCache("fake-token-1"); - assert.equal(cached, "proj-from-bootstrap", "project id must be memoized after first call"); + assert.equal(projectId, "proj-from-bootstrap", "project id must be returned"); + assert.equal( + getAntigravityProjectFromCache("fake-token-1"), + "proj-from-bootstrap", + "project id must be memoized after first call" + ); }); test("subsequent calls for the same token skip the network", async () => { @@ -149,7 +152,7 @@ describe("ensureAntigravityProjectAssigned", () => { const mockFetch = async (url: string, _init?: RequestInit): Promise => { hitUrls.push(url); - if (url.includes("sandbox")) { + if (url.includes("daily-cloudcode-pa.googleapis.com")) { // First URL fails return new Response("not found", { status: 404 }); } @@ -160,11 +163,15 @@ describe("ensureAntigravityProjectAssigned", () => { }); }; - await ensureAntigravityProjectAssigned("fallback-token", mockFetch); + const projectId = await ensureAntigravityProjectAssigned("fallback-token", mockFetch); assert.ok(hitUrls.length >= 2, "should try at least two URLs on the first failure"); - const cached = getAntigravityProjectFromCache("fallback-token"); - assert.equal(cached, "proj-fallback", "should cache the project from the successful URL"); + assert.equal(projectId, "proj-fallback", "should return the project from the successful URL"); + assert.equal( + getAntigravityProjectFromCache("fallback-token"), + "proj-fallback", + "should cache the project from the successful URL" + ); }); test("getAntigravityLoadCodeAssistUrls returns URLs matching ANTIGRAVITY_BASE_URLS", () => { diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index da20223f53..542352b96e 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -2,6 +2,8 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + ANTIGRAVITY_PUBLIC_MODELS, + getClientVisibleAntigravityModelName, isUserCallableAntigravityModelId, resolveAntigravityModelId, toClientAntigravityModelId, @@ -9,8 +11,13 @@ import { import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts"; import { openaiToAntigravityRequest } from "../../open-sse/translator/request/openai-to-gemini.ts"; +function getPublicModel(id: string) { + return ANTIGRAVITY_PUBLIC_MODELS.find((model) => model.id === id) as any; +} + test("resolveAntigravityModelId maps the documented Antigravity aliases to upstream IDs", () => { assert.equal(resolveAntigravityModelId("gemini-3-pro-preview"), "gemini-3.1-pro-high"); + assert.equal(resolveAntigravityModelId("gemini-3.5-flash-preview"), "gemini-3-flash-agent"); assert.equal(resolveAntigravityModelId("gemini-3-flash-preview"), "gemini-3-flash"); assert.equal(resolveAntigravityModelId("gemini-3-pro-image-preview"), "gemini-3-pro-image"); assert.equal( @@ -28,6 +35,7 @@ test("resolveAntigravityModelId maps the documented Antigravity aliases to upstr test("toClientAntigravityModelId exposes client-visible aliases for known upstream IDs", () => { assert.equal(toClientAntigravityModelId("gemini-3.1-pro-high"), "gemini-3-pro-preview"); + assert.equal(toClientAntigravityModelId("gemini-3-flash-agent"), "gemini-3.5-flash-preview"); assert.equal(toClientAntigravityModelId("gemini-3-flash"), "gemini-3-flash-preview"); assert.equal(toClientAntigravityModelId("gpt-oss-120b-medium"), "gpt-oss-120b-medium"); assert.equal(toClientAntigravityModelId("claude-sonnet-4-6"), "claude-sonnet-4-6"); @@ -37,12 +45,67 @@ test("toClientAntigravityModelId exposes client-visible aliases for known upstre test("isUserCallableAntigravityModelId only allows public chat-capable model IDs", () => { assert.equal(isUserCallableAntigravityModelId("gemini-3-pro-preview"), true); assert.equal(isUserCallableAntigravityModelId("gemini-3.1-pro-high"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-preview"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-3-flash-agent"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-3.5-flash-low"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-3.1-flash-lite"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-2.5-pro"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash-lite"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-2.5-flash-thinking"), true); + assert.equal(isUserCallableAntigravityModelId("gemini-pro-agent"), true); assert.equal(isUserCallableAntigravityModelId("claude-sonnet-4-6"), true); - assert.equal(isUserCallableAntigravityModelId("gemini-3-flash-agent"), false); assert.equal(isUserCallableAntigravityModelId("tab_flash_lite_preview"), false); assert.equal(isUserCallableAntigravityModelId("unknown-model"), false); }); +test("ANTIGRAVITY_PUBLIC_MODELS exposes captured Antigravity 2.0.1 names and capabilities", () => { + assert.deepEqual(getPublicModel("claude-opus-4-6-thinking"), { + id: "claude-opus-4-6-thinking", + name: "Claude Opus 4.6 (Thinking)", + contextLength: 250000, + maxOutputTokens: 64000, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }); + assert.deepEqual(getPublicModel("gemini-3.5-flash-preview"), { + id: "gemini-3.5-flash-preview", + name: "Gemini 3.5 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }); + assert.equal( + getClientVisibleAntigravityModelName("gemini-3.5-flash-low"), + "Gemini 3.5 Flash (Low)" + ); + assert.equal(getClientVisibleAntigravityModelName("gemini-2.5-flash"), "Gemini 2.5 Flash"); + assert.equal( + getClientVisibleAntigravityModelName("gemini-2.5-flash-lite"), + "Gemini 2.5 Flash Lite" + ); + assert.equal( + getClientVisibleAntigravityModelName("gemini-2.5-flash-thinking"), + "Gemini 2.5 Flash Thinking" + ); + assert.deepEqual(getPublicModel("gpt-oss-120b-medium"), { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B (Medium)", + contextLength: 131072, + maxOutputTokens: 32768, + supportsReasoning: true, + toolCalling: true, + }); + assert.equal(getPublicModel("gemini-3-pro-image-preview").contextLength, undefined); + assert.equal( + getPublicModel("gemini-2.5-computer-use-preview-10-2025").maxOutputTokens, + undefined + ); +}); + test("AntigravityExecutor.transformRequest resolves alias models before dispatching upstream", async () => { const executor = new AntigravityExecutor(); const result = await executor.transformRequest( @@ -60,6 +123,23 @@ test("AntigravityExecutor.transformRequest resolves alias models before dispatch assert.equal(result.model, "gemini-3.1-pro-high"); }); +test("AntigravityExecutor.transformRequest resolves Gemini 3.5 Flash alias upstream", async () => { + const executor = new AntigravityExecutor(); + const result = await executor.transformRequest( + "antigravity/gemini-3.5-flash-preview", + { + request: { + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + }, + }, + true, + { projectId: "project-1" } + ); + + if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); + assert.equal(result.model, "gemini-3-flash-agent"); +}); + test("AntigravityExecutor.transformRequest sends Claude through Gemini-compatible Cloud Code schema", async () => { const executor = new AntigravityExecutor(); const bridged = openaiToAntigravityRequest( diff --git a/tests/unit/autocombo-unification.test.ts b/tests/unit/autocombo-unification.test.ts index 8d6ea937a4..ebbafe62bb 100644 --- a/tests/unit/autocombo-unification.test.ts +++ b/tests/unit/autocombo-unification.test.ts @@ -140,14 +140,19 @@ test("sidebar visibility excludes the removed auto-combo item", async () => { (section) => section.id === "omni-proxy" ); - assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("auto-combo"), false); + assert.equal( + (sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]).includes("auto-combo"), + false + ); assert.ok(omniProxySection); const items = sidebarVisibility.getSectionItems(omniProxySection); assert.equal( - items.some((item) => item.id === "auto-combo"), + items.some((item) => (item.id as string) === "auto-combo"), false ); - assert.deepEqual(sidebarVisibility.normalizeHiddenSidebarItems(["auto-combo", "home"]), ["home"]); + assert.deepEqual(sidebarVisibility.normalizeHiddenSidebarItems(["auto-combo" as any, "home"]), [ + "home", + ]); }); test("intelligent routing helpers normalize config and build provider scores", () => { diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index d8f230f3ca..a60c4c3e6f 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -742,8 +742,9 @@ test("handleImageGeneration sends Antigravity image requests with native image_g ); assert.equal(captured.headers.Authorization, "Bearer ag-token"); assert.equal(captured.headers["x-client-name"], "antigravity"); - assert.equal(captured.headers["x-goog-user-project"], "project-123"); - assert.ok(captured.headers["User-Agent"].startsWith("Antigravity/")); + assert.equal(captured.headers["x-goog-user-project"], undefined); + assert.match(captured.headers["User-Agent"], /^Antigravity\//); + assert.equal(captured.headers["x-goog-api-client"], undefined); assert.equal(captured.body.project, "project-123"); assert.match(captured.body.requestId, /^image_gen\//); assert.equal(captured.body.model, "gemini-3.1-flash-image"); @@ -789,7 +790,7 @@ test("handleImageGeneration rejects Antigravity image requests without projectId } }); -test("handleImageGeneration retries Antigravity image requests without billing project on 403", async () => { +test("handleImageGeneration sends Antigravity image requests without billing project header", async () => { const originalFetch = globalThis.fetch; const calls = []; @@ -800,19 +801,6 @@ test("handleImageGeneration retries Antigravity image requests without billing p body: JSON.parse(String(options.body || "{}")), }); - if (calls.length === 1) { - return new Response( - JSON.stringify({ - error: { - code: 403, - message: "Cloud Code Private API has not been used in project project-123 before.", - status: "PERMISSION_DENIED", - }, - }), - { status: 403, headers: { "content-type": "application/json" } } - ); - } - return new Response( JSON.stringify({ response: { @@ -846,10 +834,9 @@ test("handleImageGeneration retries Antigravity image requests without billing p }); assert.equal(result.success, true); - assert.equal(calls.length, 2); - assert.equal(calls[0].headers["x-goog-user-project"], "project-123"); - assert.equal(calls[1].headers["x-goog-user-project"], undefined); - assert.equal(calls[1].body.project, "project-123"); + assert.equal(calls.length, 1); + assert.equal(calls[0].headers["x-goog-user-project"], undefined); + assert.equal(calls[0].body.project, "project-123"); assert.deepEqual(result.data.data, [ { b64_json: "YmFzZTY0LXJldHJ5", revised_prompt: "painted forest" }, ]); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 74569bb9d7..d3584fe13b 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -17,6 +17,7 @@ Object.assign(process.env, { const providersModule = await import("../../src/lib/oauth/providers/index.ts"); const oauthModule = await import("../../src/lib/oauth/constants/oauth.ts"); const registryModule = await import("../../open-sse/config/providerRegistry.ts"); +const antigravityHeadersModule = await import("../../open-sse/services/antigravityHeaders.ts"); const PROVIDERS = providersModule.default; const { @@ -38,6 +39,7 @@ const { WINDSURF_CONFIG, } = oauthModule; const { REGISTRY } = registryModule; +const { getAntigravityLoadCodeAssistMetadata } = antigravityHeadersModule; const originalFetch = globalThis.fetch; @@ -432,24 +434,32 @@ test("Gemini and Antigravity run mocked browser OAuth exchanges and post-exchang jsonResponse({ cloudaicompanionProject: { id: "gemini-project" } }), jsonResponse({ access_token: "anti-access", refresh_token: "anti-refresh", expires_in: 7200 }), jsonResponse({ email: "anti@example.com" }), - (_url, init = {}) => { + (_url, init: any = {}) => { assert.equal(init.method, "POST"); assert.equal(init.headers.Authorization, "Bearer anti-access"); assert.match(init.headers["User-Agent"], /^vscode\/1\.X\.X \(Antigravity\//); assert.equal(init.headers["X-Goog-Api-Client"], undefined); - assert.equal(init.headers["Client-Metadata"], undefined); - assert.deepEqual(JSON.parse(String(init.body)).metadata, { ideType: "ANTIGRAVITY" }); + assert.deepEqual( + JSON.parse(String(init.body)).metadata, + getAntigravityLoadCodeAssistMetadata() + ); + assert.equal(JSON.parse(String(init.body)).cloudaicompanionProject, undefined); return jsonResponse({ cloudaicompanionProject: { id: "anti-project" }, allowedTiers: [{ id: "tier-default", isDefault: true }], }); }, - (_url, init = {}) => { + (_url, init: any = {}) => { assert.equal(init.method, "POST"); assert.equal(init.headers.Authorization, "Bearer anti-access"); assert.match(init.headers["User-Agent"], /^vscode\/1\.X\.X \(Antigravity\//); assert.equal(init.headers["X-Goog-Api-Client"], undefined); - assert.deepEqual(JSON.parse(String(init.body)).metadata, { ideType: "ANTIGRAVITY" }); + assert.deepEqual( + JSON.parse(String(init.body)).metadata, + getAntigravityLoadCodeAssistMetadata() + ); + assert.equal(JSON.parse(String(init.body)).tier_id, "tier-default"); + assert.equal(JSON.parse(String(init.body)).cloudaicompanionProject, undefined); return jsonResponse({ done: true, response: { cloudaicompanionProject: { id: "anti-project-final" } }, diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index b15540876e..4a1dc9da3e 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -355,7 +355,7 @@ test("provider models route returns the local catalog for embedding and rerank p assert.equal(voyageBody.provider, "voyage-ai"); assert.equal(voyageBody.source, "local_catalog"); assert.ok(voyageBody.models.some((model) => model.id === "voyage-4-large")); - assert.ok(voyageBody.models.some((model) => model.id === "voyage-3-large")); + assert.ok(voyageBody.models.some((model) => model.id === "voyage-code-3")); assert.equal(jinaResponse.status, 200); assert.equal(jinaBody.provider, "jina-ai"); @@ -663,7 +663,8 @@ test("provider models route retries Antigravity discovery endpoints before retur assert.equal(init.method, "POST"); assert.equal(init.headers.Authorization, "Bearer ag-access"); - assert.match(init.headers["User-Agent"], /^Antigravity\//); + assert.match(init.headers["User-Agent"], /^Antigravity\/1\.22\.2 /); + assert.equal(init.headers["x-goog-api-client"], undefined); return Response.json({ models: [{ id: "gemini-3-flash", displayName: "Gemini 3 Flash" }], }); @@ -683,8 +684,8 @@ test("provider models route retries Antigravity discovery endpoints before retur assert.equal(response.status, 200); assert.equal(body.source, "api"); assert.deepEqual(discoveryUrls, [ - "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels", "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", + "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", ]); assert.deepEqual(body.models, [{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash" }]); }); @@ -710,9 +711,9 @@ test("provider models route falls back through all Antigravity discovery endpoin assert.equal(body.source, "local_catalog"); assert.match(body.warning, /local catalog/i); assert.deepEqual(discoveryUrls, [ - "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models", "https://daily-cloudcode-pa.googleapis.com/v1internal:models", "https://cloudcode-pa.googleapis.com/v1internal:models", + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models", ]); assert.ok(body.models.some((model) => model.id === "gemini-3-pro-preview")); }); diff --git a/tests/unit/services-branch-hardening.test.ts b/tests/unit/services-branch-hardening.test.ts index daaef9a576..30d320e764 100644 --- a/tests/unit/services-branch-hardening.test.ts +++ b/tests/unit/services-branch-hardening.test.ts @@ -36,7 +36,7 @@ test.afterEach(() => { comboMetrics.resetAllComboMetrics(); }); -test("gemini thought signature store handles invalid input, TTL expiry and max-size pruning", () => { +test("gemini thought signature store handles invalid input, memory TTL and max-size pruning", () => { signatureStore.storeGeminiThoughtSignature("", "sig"); signatureStore.storeGeminiThoughtSignature("call-empty", ""); assert.equal(signatureStore.getGeminiThoughtSignature(""), null); @@ -49,7 +49,8 @@ test("gemini thought signature store handles invalid input, TTL expiry and max-s assert.equal(signatureStore.getGeminiThoughtSignature("call-live"), "sig-live"); now += 60 * 60 * 1000 + 1; - assert.equal(signatureStore.getGeminiThoughtSignature("call-live"), null); + assert.equal(signatureStore.getGeminiThoughtSignatureMemorySizeForTests(), 0); + assert.equal(signatureStore.getGeminiThoughtSignature("call-live"), "sig-live"); now = 10_000; for (let index = 0; index < 1002; index++) { @@ -57,7 +58,8 @@ test("gemini thought signature store handles invalid input, TTL expiry and max-s now += 1; } - assert.equal(signatureStore.getGeminiThoughtSignature("call-0"), null); + assert.equal(signatureStore.getGeminiThoughtSignatureMemorySizeForTests(), 1000); + assert.equal(signatureStore.getGeminiThoughtSignature("call-0"), "sig-0"); assert.equal(signatureStore.getGeminiThoughtSignature("call-1001"), "sig-1001"); }); diff --git a/tests/unit/signature-cache-bypass.test.ts b/tests/unit/signature-cache-bypass.test.ts index e5e0b52b20..92041745f8 100644 --- a/tests/unit/signature-cache-bypass.test.ts +++ b/tests/unit/signature-cache-bypass.test.ts @@ -2,7 +2,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + clearGeminiThoughtSignatureMemoryForTests, clearGeminiThoughtSignatures, + getGeminiThoughtSignature, getGeminiThoughtSignatureMode, isValidBasicGeminiThoughtSignature, isValidFullGeminiThoughtSignature, @@ -65,3 +67,33 @@ test("bypass-strict mode requires the full protobuf structure before accepting a assert.equal(resolveGeminiThoughtSignature("call-1", BASIC_VALID_SIGNATURE), "stored-signature"); assert.equal(resolveGeminiThoughtSignature("call-1", INVALID_SIGNATURE), "stored-signature"); }); + +test("signatures persist to sqlite database and survive in-memory cache clearing", () => { + storeGeminiThoughtSignature("db-call-1", "sig-12345"); + + // Bypass in-memory cache to verify DB lookup + clearGeminiThoughtSignatureMemoryForTests(); + + assert.equal(getGeminiThoughtSignature("db-call-1"), "sig-12345"); +}); + +test("enabled mode keeps ignoring client signatures after sqlite recovery", () => { + storeGeminiThoughtSignature("db-call-2", "stored-db-signature"); + clearGeminiThoughtSignatureMemoryForTests(); + setGeminiThoughtSignatureMode("enabled"); + + assert.equal( + resolveGeminiThoughtSignature("db-call-2", STRICT_VALID_SIGNATURE), + "stored-db-signature" + ); +}); + +test("persisted signatures stay isolated when callers namespace tool call ids", () => { + storeGeminiThoughtSignature("conn-a:call-shared", "signature-a"); + storeGeminiThoughtSignature("conn-b:call-shared", "signature-b"); + clearGeminiThoughtSignatureMemoryForTests(); + + assert.equal(resolveGeminiThoughtSignature("conn-a:call-shared"), "signature-a"); + assert.equal(resolveGeminiThoughtSignature("conn-b:call-shared"), "signature-b"); + assert.equal(resolveGeminiThoughtSignature("call-shared"), null); +}); diff --git a/tests/unit/stream-handler.test.ts b/tests/unit/stream-handler.test.ts index a960632e11..bb9600a798 100644 --- a/tests/unit/stream-handler.test.ts +++ b/tests/unit/stream-handler.test.ts @@ -59,7 +59,7 @@ test("createDisconnectAwareStream converts upstream errors into SSE error chunks }); test("createDisconnectAwareStream emits Responses API failure events for Responses clients", async () => { - const upstreamError = Object.assign(new Error("responses stream died"), { statusCode: 503 }); + const upstreamError = Object.assign(new Error("responses stream\ndied"), { statusCode: 503 }); const transformStream = { readable: new ReadableStream({ start(controller) { @@ -83,7 +83,7 @@ test("createDisconnectAwareStream emits Responses API failure events for Respons assert.match(text, /event: response\.failed/); assert.match(text, /"type":"response\.failed"/); - assert.match(text, /"message":"responses stream died"/); + assert.match(text, /"message":"responses stream\\ndied"/); assert.match(text, /"type":"server_error"/); assert.match(text, /"code":"server_error"/); assert.doesNotMatch(text, /chat\.completion\.chunk/); @@ -91,6 +91,37 @@ test("createDisconnectAwareStream emits Responses API failure events for Respons assert.doesNotMatch(text, /\[DONE\]/); }); +test("createDisconnectAwareStream keeps newlines escaped inside SSE data fields", async () => { + const upstreamError = Object.assign(new Error("line one\nline two\rline three"), { + statusCode: 400, + }); + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.error(upstreamError); + }, + }), + writable: { + getWriter() { + return { + abort() {}, + }; + }, + }, + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ clientResponseFormat: FORMATS.OPENAI_RESPONSES }) + ); + const text = await readStreamText(stream); + + assert.match(text, /^event: response\.failed\ndata: \{"type":"response\.failed"/); + assert.match(text, /"message":"line one\\nline two\\rline three"/); + assert.doesNotMatch(text, /^line two/m); + assert.doesNotMatch(text, /^line three/m); +}); + test("createDisconnectAwareStream treats legacy OpenAI response format alias as Responses", async () => { const upstreamError = Object.assign(new Error("legacy responses alias died"), { statusCode: 429, @@ -156,6 +187,36 @@ test("createDisconnectAwareStream emits Claude SSE errors for Claude clients", a assert.doesNotMatch(text, /\[DONE\]/); }); +test("createDisconnectAwareStream keeps newlines escaped for Claude SSE errors", async () => { + const upstreamError = Object.assign(new Error("claude line one\nclaude line two"), { + statusCode: 502, + }); + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.error(upstreamError); + }, + }), + writable: { + getWriter() { + return { + abort() {}, + }; + }, + }, + }; + + const stream = createDisconnectAwareStream( + transformStream, + createStreamController({ clientResponseFormat: FORMATS.CLAUDE }) + ); + const text = await readStreamText(stream); + + assert.match(text, /^event: error\ndata: \{"type":"error"/); + assert.match(text, /"message":"claude line one\\nclaude line two"/); + assert.doesNotMatch(text, /^claude line two/m); +}); + test("createDisconnectAwareStream cancel propagates disconnect reason and aborts the writer", async () => { let aborted = false; let disconnectEvent = null; diff --git a/tests/unit/t20-t22-provider-headers.test.ts b/tests/unit/t20-t22-provider-headers.test.ts index 675a78c1e9..6820951020 100644 --- a/tests/unit/t20-t22-provider-headers.test.ts +++ b/tests/unit/t20-t22-provider-headers.test.ts @@ -6,11 +6,12 @@ const { antigravityUserAgent } = await import("../../open-sse/services/antigravi const { geminiCliUserAgent, GEMINI_CLI_VERSION } = await import("../../open-sse/services/geminiCliHeaders.ts"); -test("T20: antigravity config has updated User-Agent and sandbox fallback URL", () => { +test("T20: antigravity config has updated User-Agent and daily Cloud Code first URL", () => { const antigravity = REGISTRY.antigravity; assert.ok(Array.isArray(antigravity.baseUrls)); - assert.equal(antigravity.baseUrls[0], "https://daily-cloudcode-pa.sandbox.googleapis.com"); + assert.equal(antigravity.baseUrls[0], "https://daily-cloudcode-pa.googleapis.com"); assert.equal(antigravity.headers["User-Agent"], antigravityUserAgent()); + assert.match(antigravity.headers["User-Agent"], /^Antigravity\//); }); test("T20: gemini CLI fingerprint uses the current CLI version and normalizes darwin to macos", () => { diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 69f68d3783..daf2b12e61 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -620,6 +620,75 @@ test("OpenAI -> Antigravity wraps Gemini requests in a Cloud Code envelope", () }); }); +test("OpenAI -> Antigravity Gemini stringifies signature-less historical tool calls", () => { + const result = openaiToAntigravityRequest( + "gemini-3.5-flash-low", + { + messages: [ + { role: "user", content: "Update todo" }, + { + role: "assistant", + tool_calls: [ + { + id: "call_synthetic_1", + type: "function", + function: { name: "default_api:todowrite_ide", arguments: '{"todos":[]}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_synthetic_1", + content: "[]", + }, + ], + tools: [ + { + type: "function", + function: { + name: "default_api:todowrite_ide", + parameters: { type: "object", properties: {} }, + }, + }, + ], + }, + false, + { projectId: "proj-antigravity-gemini" } as any + ); + + const modelTurn = result.request.contents.find((content) => content.role === "model"); + assert.ok(modelTurn, "expected a model turn"); + assert.ok( + modelTurn.parts.some( + (part) => + typeof part.text === "string" && + part.text.includes("[Tool call: default_api:todowrite_ide]") + ), + "expected signature-less tool call to be preserved as text" + ); + assert.equal( + modelTurn.parts.some((part) => part.functionCall), + false, + "signature-less historical call must not be emitted as native functionCall" + ); + + const toolTurn = result.request.contents.find( + (content) => + content.role === "user" && + content.parts.some( + (part) => + typeof part.text === "string" && + part.text.includes("[Tool response: default_api:todowrite_ide]") + ) + ); + assert.ok(toolTurn, "expected signature-less tool response to be preserved as text"); + assert.equal( + toolTurn.parts.some((part) => part.functionResponse), + false, + "signature-less historical response must not be emitted as native functionResponse" + ); +}); + test("OpenAI -> Antigravity maps Claude-family models to Gemini-compatible schema", () => { const result = openaiToAntigravityRequest( "claude-3-7-sonnet",