fix(antigravity): use loadCodeAssist project ID and add OpenAI message conversion

- Add message-converter.ts for OpenAI messages to Gemini contents conversion
- Use SKIP_THOUGHT_SIGNATURE_VALIDATOR as default signature (CLIProxyAPI approach)
- Restore loadCodeAssist API call to get user's actual project ID
- Improve debug logging for troubleshooting
- Fix tool normalization edge cases

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-13 04:24:24 +09:00
parent 7fe85a11da
commit abd90bbc9c
7 changed files with 264 additions and 35 deletions

View File

@@ -57,7 +57,10 @@ export const ANTIGRAVITY_HEADERS = {
} as const } as const
// Default Project ID (fallback when loadCodeAssist API fails) // 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 // Google OAuth endpoints
export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" 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) // Token refresh buffer (refresh 60 seconds before expiry)
export const ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS = 60_000 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"

View File

@@ -17,10 +17,11 @@
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable. * 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 { fetchProjectContext, clearProjectContextCache } from "./project"
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token" import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token"
import { transformRequest } from "./request" import { transformRequest } from "./request"
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
import { import {
transformResponse, transformResponse,
transformStreamingResponse, 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<Record<string, unknown>>
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)) { if (parsedBody.tools && Array.isArray(parsedBody.tools)) {
const normalizedTools = normalizeToolsForGemini(parsedBody.tools as OpenAITool[]) const normalizedTools = normalizeToolsForGemini(parsedBody.tools as OpenAITool[])
if (normalizedTools) { 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({ const transformed = transformRequest({
url, url,
body: parsedBody, body: parsedBody,
@@ -208,20 +225,27 @@ async function transformResponseWithThinking(
try { try {
const text = await result.response.clone().text() const text = await result.response.clone().text()
debugLog(`[TSIG][RESP] Response text length: ${text.length}`)
if (streaming) { if (streaming) {
const signature = extractSignatureFromSsePayload(text) const signature = extractSignatureFromSsePayload(text)
debugLog(`[TSIG][RESP] SSE signature extracted: ${signature ? "yes" : "no"}`)
if (signature) { if (signature) {
setThoughtSignature(fetchInstanceId, signature) setThoughtSignature(fetchInstanceId, signature)
debugLog(`[STREAMING] Stored thought signature for instance ${fetchInstanceId}`) debugLog(`[TSIG][STORE] Stored signature for ${fetchInstanceId}: ${signature.substring(0, 30)}...`)
} }
} else { } else {
const parsed = JSON.parse(text) as GeminiResponseBody 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) const signature = extractSignatureFromResponse(parsed)
debugLog(`[TSIG][RESP] Signature extracted: ${signature ? signature.substring(0, 30) + "..." : "NONE"}`)
if (signature) { if (signature) {
setThoughtSignature(fetchInstanceId, 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)) { if (shouldIncludeThinking(modelName)) {
@@ -349,14 +373,15 @@ export function createAntigravityFetch(
} }
} }
// Get project context // Fetch project ID via loadCodeAssist (CLIProxyAPI approach)
if (!cachedProjectId) { if (!cachedProjectId) {
const projectContext = await fetchProjectContext(cachedTokens.access_token) const projectContext = await fetchProjectContext(cachedTokens.access_token)
cachedProjectId = projectContext.cloudaicompanionProject || "" cachedProjectId = projectContext.cloudaicompanionProject || ""
debugLog(`[PROJECT] Fetched project ID: "${cachedProjectId}"`)
} }
// Use project ID from refresh token if available, otherwise use fetched context const projectId = cachedProjectId
const projectId = refreshParts.projectId || cachedProjectId debugLog(`[PROJECT] Using project ID: "${projectId}"`)
// Extract model name from request body // Extract model name from request body
let modelName: string | undefined let modelName: string | undefined

View File

@@ -8,5 +8,6 @@ export * from "./response"
export * from "./tools" export * from "./tools"
export * from "./thinking" export * from "./thinking"
export * from "./thought-signature-store" export * from "./thought-signature-store"
export * from "./message-converter"
export * from "./fetch" export * from "./fetch"
export * from "./plugin" export * from "./plugin"

View File

@@ -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<string, unknown>
}
functionResponse?: {
name: string
response: Record<string, unknown>
}
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<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown>): boolean {
return Array.isArray(body.messages) && body.messages.length > 0
}
export function convertRequestBody(
body: Record<string, unknown>,
thoughtSignature?: string
): Record<string, unknown> {
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
}

View File

@@ -116,7 +116,6 @@ async function callLoadCodeAssistAPI(
/** /**
* Fetch project context from Google's loadCodeAssist API. * Fetch project context from Google's loadCodeAssist API.
* Extracts the cloudaicompanionProject from the response. * 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 * @param accessToken - Valid OAuth access token
* @returns Project context with cloudaicompanionProject ID * @returns Project context with cloudaicompanionProject ID
@@ -124,26 +123,20 @@ async function callLoadCodeAssistAPI(
export async function fetchProjectContext( export async function fetchProjectContext(
accessToken: string accessToken: string
): Promise<AntigravityProjectContext> { ): Promise<AntigravityProjectContext> {
// Check cache first
const cached = projectContextCache.get(accessToken) const cached = projectContextCache.get(accessToken)
if (cached) { if (cached) {
return cached return cached
} }
// Call the API
const response = await callLoadCodeAssistAPI(accessToken) const response = await callLoadCodeAssistAPI(accessToken)
// Extract project ID from response
const projectId = response const projectId = response
? extractProjectId(response.cloudaicompanionProject) ? extractProjectId(response.cloudaicompanionProject)
: undefined : undefined
// Build result with fallback
const result: AntigravityProjectContext = { const result: AntigravityProjectContext = {
cloudaicompanionProject: projectId || ANTIGRAVITY_DEFAULT_PROJECT_ID, cloudaicompanionProject: projectId || "",
} }
// Cache the result
if (projectId) { if (projectId) {
projectContextCache.set(accessToken, result) projectContextCache.set(accessToken, result)
} }

View File

@@ -8,6 +8,7 @@ import {
ANTIGRAVITY_HEADERS, ANTIGRAVITY_HEADERS,
ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_API_VERSION, ANTIGRAVITY_API_VERSION,
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
} from "./constants" } from "./constants"
import type { AntigravityRequestBody } from "./types" import type { AntigravityRequestBody } from "./types"
@@ -175,18 +176,18 @@ export function injectThoughtSignatureIntoFunctionCalls(
body: Record<string, unknown>, body: Record<string, unknown>,
signature: string | undefined signature: string | undefined
): Record<string, unknown> { ): Record<string, unknown> {
debugLog(`[TSIG][INJECT] signature=${signature ? signature.substring(0, 20) + "..." : "none"}`) // Always use skip validator as fallback (CLIProxyAPI approach)
const effectiveSignature = signature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
if (!signature) { debugLog(`[TSIG][INJECT] signature=${effectiveSignature.substring(0, 30)}... (${signature ? "provided" : "default"})`)
return body debugLog(`[TSIG][INJECT] body keys: ${Object.keys(body).join(", ")}`)
}
const contents = body.contents as ContentBlock[] | undefined const contents = body.contents as ContentBlock[] | undefined
if (!contents || !Array.isArray(contents)) { if (!contents || !Array.isArray(contents)) {
debugLog(`[TSIG][INJECT] no contents array found`) debugLog(`[TSIG][INJECT] No contents array! Has messages: ${!!body.messages}`)
return body return body
} }
debugLog(`[TSIG][INJECT] Found ${contents.length} content blocks`)
let injectedCount = 0 let injectedCount = 0
const modifiedContents = contents.map((content) => { const modifiedContents = contents.map((content) => {
if (!content.parts || !Array.isArray(content.parts)) { if (!content.parts || !Array.isArray(content.parts)) {
@@ -198,7 +199,7 @@ export function injectThoughtSignatureIntoFunctionCalls(
injectedCount++ injectedCount++
return { return {
...part, ...part,
thoughtSignature: signature, thoughtSignature: effectiveSignature,
} }
} }
return part return part

View File

@@ -96,34 +96,31 @@ export function normalizeToolsForGemini(
const functionDeclarations: GeminiFunctionDeclaration[] = [] const functionDeclarations: GeminiFunctionDeclaration[] = []
for (const tool of tools) { for (const tool of tools) {
// Handle function type tools if (!tool || typeof tool !== "object") {
if (tool.type === "function" && tool.function) { continue
}
const toolType = tool.type ?? "function"
if (toolType === "function" && tool.function) {
const declaration: GeminiFunctionDeclaration = { const declaration: GeminiFunctionDeclaration = {
name: tool.function.name, name: tool.function.name,
} }
// Include description if present
if (tool.function.description) { if (tool.function.description) {
declaration.description = tool.function.description declaration.description = tool.function.description
} }
// Include parameters if present, default to empty object schema
if (tool.function.parameters) { if (tool.function.parameters) {
declaration.parameters = tool.function.parameters declaration.parameters = tool.function.parameters
} else { } else {
// Gemini requires parameters field, use empty object as default
declaration.parameters = { type: "object", properties: {} } declaration.parameters = { type: "object", properties: {} }
} }
functionDeclarations.push(declaration) functionDeclarations.push(declaration)
} else { } else if (toolType !== "function" && process.env.ANTIGRAVITY_DEBUG === "1") {
// Log warning for unsupported tool types (debug only) console.warn(
if (process.env.ANTIGRAVITY_DEBUG === "1") { `[antigravity-tools] Unsupported tool type: "${toolType}". Tool will be skipped.`
console.warn( )
`[antigravity-tools] Unsupported tool type: "${tool.type}". ` +
`Only "function" type tools are supported for Gemini. Tool will be skipped.`
)
}
} }
} }