diff --git a/src/auth/antigravity/constants.ts b/src/auth/antigravity/constants.ts index 6961549..0a71f49 100644 --- a/src/auth/antigravity/constants.ts +++ b/src/auth/antigravity/constants.ts @@ -57,7 +57,10 @@ export const ANTIGRAVITY_HEADERS = { } as const // Default Project ID (fallback when loadCodeAssist API fails) -export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "" +// From opencode-antigravity-auth reference implementation +export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc" + + // Google OAuth endpoints export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" @@ -66,3 +69,6 @@ export const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinf // Token refresh buffer (refresh 60 seconds before expiry) export const ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS = 60_000 + +// Default thought signature to skip validation (CLIProxyAPI approach) +export const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" diff --git a/src/auth/antigravity/fetch.ts b/src/auth/antigravity/fetch.ts index 7d6a58c..29267ab 100644 --- a/src/auth/antigravity/fetch.ts +++ b/src/auth/antigravity/fetch.ts @@ -17,10 +17,11 @@ * Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable. */ -import { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants" +import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants" import { fetchProjectContext, clearProjectContextCache } from "./project" import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token" import { transformRequest } from "./request" +import { convertRequestBody, hasOpenAIMessages } from "./message-converter" import { transformResponse, transformStreamingResponse, @@ -110,6 +111,16 @@ async function attemptFetch( } } + debugLog(`[BODY] Keys: ${Object.keys(parsedBody).join(", ")}`) + debugLog(`[BODY] Has contents: ${!!parsedBody.contents}, Has messages: ${!!parsedBody.messages}`) + if (parsedBody.contents) { + const contents = parsedBody.contents as Array> + debugLog(`[BODY] contents length: ${contents.length}`) + contents.forEach((c, i) => { + debugLog(`[BODY] contents[${i}].role: ${c.role}, parts: ${JSON.stringify(c.parts).substring(0, 200)}`) + }) + } + if (parsedBody.tools && Array.isArray(parsedBody.tools)) { const normalizedTools = normalizeToolsForGemini(parsedBody.tools as OpenAITool[]) if (normalizedTools) { @@ -117,6 +128,12 @@ async function attemptFetch( } } + if (hasOpenAIMessages(parsedBody)) { + debugLog(`[CONVERT] Converting OpenAI messages to Gemini contents`) + parsedBody = convertRequestBody(parsedBody, thoughtSignature) + debugLog(`[CONVERT] After conversion - Has contents: ${!!parsedBody.contents}`) + } + const transformed = transformRequest({ url, body: parsedBody, @@ -208,20 +225,27 @@ async function transformResponseWithThinking( try { const text = await result.response.clone().text() + debugLog(`[TSIG][RESP] Response text length: ${text.length}`) if (streaming) { const signature = extractSignatureFromSsePayload(text) + debugLog(`[TSIG][RESP] SSE signature extracted: ${signature ? "yes" : "no"}`) if (signature) { setThoughtSignature(fetchInstanceId, signature) - debugLog(`[STREAMING] Stored thought signature for instance ${fetchInstanceId}`) + debugLog(`[TSIG][STORE] Stored signature for ${fetchInstanceId}: ${signature.substring(0, 30)}...`) } } else { const parsed = JSON.parse(text) as GeminiResponseBody + debugLog(`[TSIG][RESP] Parsed keys: ${Object.keys(parsed).join(", ")}`) + debugLog(`[TSIG][RESP] Has candidates: ${!!parsed.candidates}, count: ${parsed.candidates?.length ?? 0}`) const signature = extractSignatureFromResponse(parsed) + debugLog(`[TSIG][RESP] Signature extracted: ${signature ? signature.substring(0, 30) + "..." : "NONE"}`) if (signature) { setThoughtSignature(fetchInstanceId, signature) - debugLog(`Stored thought signature for instance ${fetchInstanceId}`) + debugLog(`[TSIG][STORE] Stored signature for ${fetchInstanceId}`) + } else { + debugLog(`[TSIG][WARN] No signature found in response!`) } if (shouldIncludeThinking(modelName)) { @@ -349,14 +373,15 @@ export function createAntigravityFetch( } } - // Get project context + // Fetch project ID via loadCodeAssist (CLIProxyAPI approach) if (!cachedProjectId) { const projectContext = await fetchProjectContext(cachedTokens.access_token) cachedProjectId = projectContext.cloudaicompanionProject || "" + debugLog(`[PROJECT] Fetched project ID: "${cachedProjectId}"`) } - // Use project ID from refresh token if available, otherwise use fetched context - const projectId = refreshParts.projectId || cachedProjectId + const projectId = cachedProjectId + debugLog(`[PROJECT] Using project ID: "${projectId}"`) // Extract model name from request body let modelName: string | undefined diff --git a/src/auth/antigravity/index.ts b/src/auth/antigravity/index.ts index 8d2d4d3..147c4d5 100644 --- a/src/auth/antigravity/index.ts +++ b/src/auth/antigravity/index.ts @@ -8,5 +8,6 @@ export * from "./response" export * from "./tools" export * from "./thinking" export * from "./thought-signature-store" +export * from "./message-converter" export * from "./fetch" export * from "./plugin" diff --git a/src/auth/antigravity/message-converter.ts b/src/auth/antigravity/message-converter.ts new file mode 100644 index 0000000..6a51a81 --- /dev/null +++ b/src/auth/antigravity/message-converter.ts @@ -0,0 +1,206 @@ +/** + * OpenAI → Gemini message format converter + * + * Converts OpenAI-style messages to Gemini contents format, + * injecting thoughtSignature into functionCall parts. + */ + +import { SKIP_THOUGHT_SIGNATURE_VALIDATOR } from "./constants" + +function debugLog(message: string): void { + if (process.env.ANTIGRAVITY_DEBUG === "1") { + console.log(`[antigravity-converter] ${message}`) + } +} + +interface OpenAIMessage { + role: "system" | "user" | "assistant" | "tool" + content?: string | OpenAIContentPart[] + tool_calls?: OpenAIToolCall[] + tool_call_id?: string + name?: string +} + +interface OpenAIContentPart { + type: string + text?: string + image_url?: { url: string } + [key: string]: unknown +} + +interface OpenAIToolCall { + id: string + type: "function" + function: { + name: string + arguments: string + } +} + +interface GeminiPart { + text?: string + functionCall?: { + name: string + args: Record + } + functionResponse?: { + name: string + response: Record + } + inlineData?: { + mimeType: string + data: string + } + thought_signature?: string + [key: string]: unknown +} + +interface GeminiContent { + role: "user" | "model" + parts: GeminiPart[] +} + +export function convertOpenAIToGemini( + messages: OpenAIMessage[], + thoughtSignature?: string +): GeminiContent[] { + debugLog(`Converting ${messages.length} messages, signature: ${thoughtSignature ? "present" : "none"}`) + + const contents: GeminiContent[] = [] + + for (const msg of messages) { + if (msg.role === "system") { + contents.push({ + role: "user", + parts: [{ text: typeof msg.content === "string" ? msg.content : "" }], + }) + continue + } + + if (msg.role === "user") { + const parts = convertContentToParts(msg.content) + contents.push({ role: "user", parts }) + continue + } + + if (msg.role === "assistant") { + const parts: GeminiPart[] = [] + + if (msg.content) { + parts.push(...convertContentToParts(msg.content)) + } + + if (msg.tool_calls && msg.tool_calls.length > 0) { + for (const toolCall of msg.tool_calls) { + let args: Record = {} + try { + args = JSON.parse(toolCall.function.arguments) + } catch { + args = {} + } + + const part: GeminiPart = { + functionCall: { + name: toolCall.function.name, + args, + }, + } + + // Always inject signature: use provided or default to skip validator (CLIProxyAPI approach) + part.thoughtSignature = thoughtSignature || SKIP_THOUGHT_SIGNATURE_VALIDATOR + debugLog(`Injected signature into functionCall: ${toolCall.function.name} (${thoughtSignature ? "provided" : "default"})`) + + parts.push(part) + } + } + + if (parts.length > 0) { + contents.push({ role: "model", parts }) + } + continue + } + + if (msg.role === "tool") { + let response: Record = {} + try { + response = typeof msg.content === "string" + ? JSON.parse(msg.content) + : { result: msg.content } + } catch { + response = { result: msg.content } + } + + const toolName = msg.name || "unknown" + + contents.push({ + role: "user", + parts: [{ + functionResponse: { + name: toolName, + response, + }, + }], + }) + continue + } + } + + debugLog(`Converted to ${contents.length} content blocks`) + return contents +} + +function convertContentToParts(content: string | OpenAIContentPart[] | undefined): GeminiPart[] { + if (!content) { + return [{ text: "" }] + } + + if (typeof content === "string") { + return [{ text: content }] + } + + const parts: GeminiPart[] = [] + for (const part of content) { + if (part.type === "text" && part.text) { + parts.push({ text: part.text }) + } else if (part.type === "image_url" && part.image_url?.url) { + const url = part.image_url.url + if (url.startsWith("data:")) { + const match = url.match(/^data:([^;]+);base64,(.+)$/) + if (match) { + parts.push({ + inlineData: { + mimeType: match[1], + data: match[2], + }, + }) + } + } + } + } + + return parts.length > 0 ? parts : [{ text: "" }] +} + +export function hasOpenAIMessages(body: Record): boolean { + return Array.isArray(body.messages) && body.messages.length > 0 +} + +export function convertRequestBody( + body: Record, + thoughtSignature?: string +): Record { + if (!hasOpenAIMessages(body)) { + debugLog("No messages array found, returning body as-is") + return body + } + + const messages = body.messages as OpenAIMessage[] + const contents = convertOpenAIToGemini(messages, thoughtSignature) + + const converted = { ...body } + delete converted.messages + converted.contents = contents + + debugLog(`Converted body: messages → contents (${contents.length} blocks)`) + return converted +} diff --git a/src/auth/antigravity/project.ts b/src/auth/antigravity/project.ts index f4a74a7..4637fa5 100644 --- a/src/auth/antigravity/project.ts +++ b/src/auth/antigravity/project.ts @@ -116,7 +116,6 @@ async function callLoadCodeAssistAPI( /** * Fetch project context from Google's loadCodeAssist API. * Extracts the cloudaicompanionProject from the response. - * Falls back to ANTIGRAVITY_DEFAULT_PROJECT_ID if API fails or returns empty. * * @param accessToken - Valid OAuth access token * @returns Project context with cloudaicompanionProject ID @@ -124,26 +123,20 @@ async function callLoadCodeAssistAPI( export async function fetchProjectContext( accessToken: string ): Promise { - // Check cache first const cached = projectContextCache.get(accessToken) if (cached) { return cached } - // Call the API const response = await callLoadCodeAssistAPI(accessToken) - - // Extract project ID from response const projectId = response ? extractProjectId(response.cloudaicompanionProject) : undefined - // Build result with fallback const result: AntigravityProjectContext = { - cloudaicompanionProject: projectId || ANTIGRAVITY_DEFAULT_PROJECT_ID, + cloudaicompanionProject: projectId || "", } - // Cache the result if (projectId) { projectContextCache.set(accessToken, result) } diff --git a/src/auth/antigravity/request.ts b/src/auth/antigravity/request.ts index dda38cd..d35eb04 100644 --- a/src/auth/antigravity/request.ts +++ b/src/auth/antigravity/request.ts @@ -8,6 +8,7 @@ import { ANTIGRAVITY_HEADERS, ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_API_VERSION, + SKIP_THOUGHT_SIGNATURE_VALIDATOR, } from "./constants" import type { AntigravityRequestBody } from "./types" @@ -175,18 +176,18 @@ export function injectThoughtSignatureIntoFunctionCalls( body: Record, signature: string | undefined ): Record { - debugLog(`[TSIG][INJECT] signature=${signature ? signature.substring(0, 20) + "..." : "none"}`) - - if (!signature) { - return body - } + // Always use skip validator as fallback (CLIProxyAPI approach) + const effectiveSignature = signature || SKIP_THOUGHT_SIGNATURE_VALIDATOR + debugLog(`[TSIG][INJECT] signature=${effectiveSignature.substring(0, 30)}... (${signature ? "provided" : "default"})`) + debugLog(`[TSIG][INJECT] body keys: ${Object.keys(body).join(", ")}`) const contents = body.contents as ContentBlock[] | undefined if (!contents || !Array.isArray(contents)) { - debugLog(`[TSIG][INJECT] no contents array found`) + debugLog(`[TSIG][INJECT] No contents array! Has messages: ${!!body.messages}`) return body } + debugLog(`[TSIG][INJECT] Found ${contents.length} content blocks`) let injectedCount = 0 const modifiedContents = contents.map((content) => { if (!content.parts || !Array.isArray(content.parts)) { @@ -198,7 +199,7 @@ export function injectThoughtSignatureIntoFunctionCalls( injectedCount++ return { ...part, - thoughtSignature: signature, + thoughtSignature: effectiveSignature, } } return part diff --git a/src/auth/antigravity/tools.ts b/src/auth/antigravity/tools.ts index f70c91f..5a10355 100644 --- a/src/auth/antigravity/tools.ts +++ b/src/auth/antigravity/tools.ts @@ -96,34 +96,31 @@ export function normalizeToolsForGemini( const functionDeclarations: GeminiFunctionDeclaration[] = [] for (const tool of tools) { - // Handle function type tools - if (tool.type === "function" && tool.function) { + if (!tool || typeof tool !== "object") { + continue + } + + const toolType = tool.type ?? "function" + if (toolType === "function" && tool.function) { const declaration: GeminiFunctionDeclaration = { name: tool.function.name, } - // Include description if present if (tool.function.description) { declaration.description = tool.function.description } - // Include parameters if present, default to empty object schema if (tool.function.parameters) { declaration.parameters = tool.function.parameters } else { - // Gemini requires parameters field, use empty object as default declaration.parameters = { type: "object", properties: {} } } functionDeclarations.push(declaration) - } else { - // Log warning for unsupported tool types (debug only) - if (process.env.ANTIGRAVITY_DEBUG === "1") { - console.warn( - `[antigravity-tools] Unsupported tool type: "${tool.type}". ` + - `Only "function" type tools are supported for Gemini. Tool will be skipped.` - ) - } + } else if (toolType !== "function" && process.env.ANTIGRAVITY_DEBUG === "1") { + console.warn( + `[antigravity-tools] Unsupported tool type: "${toolType}". Tool will be skipped.` + ) } }