Skip to content

Commit abd90bb

Browse files
committed
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)
1 parent 7fe85a1 commit abd90bb

File tree

7 files changed

+264
-35
lines changed

7 files changed

+264
-35
lines changed

src/auth/antigravity/constants.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ export const ANTIGRAVITY_HEADERS = {
5757
} as const
5858

5959
// Default Project ID (fallback when loadCodeAssist API fails)
60-
export const ANTIGRAVITY_DEFAULT_PROJECT_ID = ""
60+
// From opencode-antigravity-auth reference implementation
61+
export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
62+
63+
6164

6265
// Google OAuth endpoints
6366
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
6669

6770
// Token refresh buffer (refresh 60 seconds before expiry)
6871
export const ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS = 60_000
72+
73+
// Default thought signature to skip validation (CLIProxyAPI approach)
74+
export const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"

src/auth/antigravity/fetch.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@
1717
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
1818
*/
1919

20-
import { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants"
20+
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants"
2121
import { fetchProjectContext, clearProjectContextCache } from "./project"
2222
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token"
2323
import { transformRequest } from "./request"
24+
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
2425
import {
2526
transformResponse,
2627
transformStreamingResponse,
@@ -110,13 +111,29 @@ async function attemptFetch(
110111
}
111112
}
112113

114+
debugLog(`[BODY] Keys: ${Object.keys(parsedBody).join(", ")}`)
115+
debugLog(`[BODY] Has contents: ${!!parsedBody.contents}, Has messages: ${!!parsedBody.messages}`)
116+
if (parsedBody.contents) {
117+
const contents = parsedBody.contents as Array<Record<string, unknown>>
118+
debugLog(`[BODY] contents length: ${contents.length}`)
119+
contents.forEach((c, i) => {
120+
debugLog(`[BODY] contents[${i}].role: ${c.role}, parts: ${JSON.stringify(c.parts).substring(0, 200)}`)
121+
})
122+
}
123+
113124
if (parsedBody.tools && Array.isArray(parsedBody.tools)) {
114125
const normalizedTools = normalizeToolsForGemini(parsedBody.tools as OpenAITool[])
115126
if (normalizedTools) {
116127
parsedBody.tools = normalizedTools
117128
}
118129
}
119130

131+
if (hasOpenAIMessages(parsedBody)) {
132+
debugLog(`[CONVERT] Converting OpenAI messages to Gemini contents`)
133+
parsedBody = convertRequestBody(parsedBody, thoughtSignature)
134+
debugLog(`[CONVERT] After conversion - Has contents: ${!!parsedBody.contents}`)
135+
}
136+
120137
const transformed = transformRequest({
121138
url,
122139
body: parsedBody,
@@ -208,20 +225,27 @@ async function transformResponseWithThinking(
208225

209226
try {
210227
const text = await result.response.clone().text()
228+
debugLog(`[TSIG][RESP] Response text length: ${text.length}`)
211229

212230
if (streaming) {
213231
const signature = extractSignatureFromSsePayload(text)
232+
debugLog(`[TSIG][RESP] SSE signature extracted: ${signature ? "yes" : "no"}`)
214233
if (signature) {
215234
setThoughtSignature(fetchInstanceId, signature)
216-
debugLog(`[STREAMING] Stored thought signature for instance ${fetchInstanceId}`)
235+
debugLog(`[TSIG][STORE] Stored signature for ${fetchInstanceId}: ${signature.substring(0, 30)}...`)
217236
}
218237
} else {
219238
const parsed = JSON.parse(text) as GeminiResponseBody
239+
debugLog(`[TSIG][RESP] Parsed keys: ${Object.keys(parsed).join(", ")}`)
240+
debugLog(`[TSIG][RESP] Has candidates: ${!!parsed.candidates}, count: ${parsed.candidates?.length ?? 0}`)
220241

221242
const signature = extractSignatureFromResponse(parsed)
243+
debugLog(`[TSIG][RESP] Signature extracted: ${signature ? signature.substring(0, 30) + "..." : "NONE"}`)
222244
if (signature) {
223245
setThoughtSignature(fetchInstanceId, signature)
224-
debugLog(`Stored thought signature for instance ${fetchInstanceId}`)
246+
debugLog(`[TSIG][STORE] Stored signature for ${fetchInstanceId}`)
247+
} else {
248+
debugLog(`[TSIG][WARN] No signature found in response!`)
225249
}
226250

227251
if (shouldIncludeThinking(modelName)) {
@@ -349,14 +373,15 @@ export function createAntigravityFetch(
349373
}
350374
}
351375

352-
// Get project context
376+
// Fetch project ID via loadCodeAssist (CLIProxyAPI approach)
353377
if (!cachedProjectId) {
354378
const projectContext = await fetchProjectContext(cachedTokens.access_token)
355379
cachedProjectId = projectContext.cloudaicompanionProject || ""
380+
debugLog(`[PROJECT] Fetched project ID: "${cachedProjectId}"`)
356381
}
357382

358-
// Use project ID from refresh token if available, otherwise use fetched context
359-
const projectId = refreshParts.projectId || cachedProjectId
383+
const projectId = cachedProjectId
384+
debugLog(`[PROJECT] Using project ID: "${projectId}"`)
360385

361386
// Extract model name from request body
362387
let modelName: string | undefined

src/auth/antigravity/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export * from "./response"
88
export * from "./tools"
99
export * from "./thinking"
1010
export * from "./thought-signature-store"
11+
export * from "./message-converter"
1112
export * from "./fetch"
1213
export * from "./plugin"
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* OpenAI → Gemini message format converter
3+
*
4+
* Converts OpenAI-style messages to Gemini contents format,
5+
* injecting thoughtSignature into functionCall parts.
6+
*/
7+
8+
import { SKIP_THOUGHT_SIGNATURE_VALIDATOR } from "./constants"
9+
10+
function debugLog(message: string): void {
11+
if (process.env.ANTIGRAVITY_DEBUG === "1") {
12+
console.log(`[antigravity-converter] ${message}`)
13+
}
14+
}
15+
16+
interface OpenAIMessage {
17+
role: "system" | "user" | "assistant" | "tool"
18+
content?: string | OpenAIContentPart[]
19+
tool_calls?: OpenAIToolCall[]
20+
tool_call_id?: string
21+
name?: string
22+
}
23+
24+
interface OpenAIContentPart {
25+
type: string
26+
text?: string
27+
image_url?: { url: string }
28+
[key: string]: unknown
29+
}
30+
31+
interface OpenAIToolCall {
32+
id: string
33+
type: "function"
34+
function: {
35+
name: string
36+
arguments: string
37+
}
38+
}
39+
40+
interface GeminiPart {
41+
text?: string
42+
functionCall?: {
43+
name: string
44+
args: Record<string, unknown>
45+
}
46+
functionResponse?: {
47+
name: string
48+
response: Record<string, unknown>
49+
}
50+
inlineData?: {
51+
mimeType: string
52+
data: string
53+
}
54+
thought_signature?: string
55+
[key: string]: unknown
56+
}
57+
58+
interface GeminiContent {
59+
role: "user" | "model"
60+
parts: GeminiPart[]
61+
}
62+
63+
export function convertOpenAIToGemini(
64+
messages: OpenAIMessage[],
65+
thoughtSignature?: string
66+
): GeminiContent[] {
67+
debugLog(`Converting ${messages.length} messages, signature: ${thoughtSignature ? "present" : "none"}`)
68+
69+
const contents: GeminiContent[] = []
70+
71+
for (const msg of messages) {
72+
if (msg.role === "system") {
73+
contents.push({
74+
role: "user",
75+
parts: [{ text: typeof msg.content === "string" ? msg.content : "" }],
76+
})
77+
continue
78+
}
79+
80+
if (msg.role === "user") {
81+
const parts = convertContentToParts(msg.content)
82+
contents.push({ role: "user", parts })
83+
continue
84+
}
85+
86+
if (msg.role === "assistant") {
87+
const parts: GeminiPart[] = []
88+
89+
if (msg.content) {
90+
parts.push(...convertContentToParts(msg.content))
91+
}
92+
93+
if (msg.tool_calls && msg.tool_calls.length > 0) {
94+
for (const toolCall of msg.tool_calls) {
95+
let args: Record<string, unknown> = {}
96+
try {
97+
args = JSON.parse(toolCall.function.arguments)
98+
} catch {
99+
args = {}
100+
}
101+
102+
const part: GeminiPart = {
103+
functionCall: {
104+
name: toolCall.function.name,
105+
args,
106+
},
107+
}
108+
109+
// Always inject signature: use provided or default to skip validator (CLIProxyAPI approach)
110+
part.thoughtSignature = thoughtSignature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
111+
debugLog(`Injected signature into functionCall: ${toolCall.function.name} (${thoughtSignature ? "provided" : "default"})`)
112+
113+
parts.push(part)
114+
}
115+
}
116+
117+
if (parts.length > 0) {
118+
contents.push({ role: "model", parts })
119+
}
120+
continue
121+
}
122+
123+
if (msg.role === "tool") {
124+
let response: Record<string, unknown> = {}
125+
try {
126+
response = typeof msg.content === "string"
127+
? JSON.parse(msg.content)
128+
: { result: msg.content }
129+
} catch {
130+
response = { result: msg.content }
131+
}
132+
133+
const toolName = msg.name || "unknown"
134+
135+
contents.push({
136+
role: "user",
137+
parts: [{
138+
functionResponse: {
139+
name: toolName,
140+
response,
141+
},
142+
}],
143+
})
144+
continue
145+
}
146+
}
147+
148+
debugLog(`Converted to ${contents.length} content blocks`)
149+
return contents
150+
}
151+
152+
function convertContentToParts(content: string | OpenAIContentPart[] | undefined): GeminiPart[] {
153+
if (!content) {
154+
return [{ text: "" }]
155+
}
156+
157+
if (typeof content === "string") {
158+
return [{ text: content }]
159+
}
160+
161+
const parts: GeminiPart[] = []
162+
for (const part of content) {
163+
if (part.type === "text" && part.text) {
164+
parts.push({ text: part.text })
165+
} else if (part.type === "image_url" && part.image_url?.url) {
166+
const url = part.image_url.url
167+
if (url.startsWith("data:")) {
168+
const match = url.match(/^data:([^;]+);base64,(.+)$/)
169+
if (match) {
170+
parts.push({
171+
inlineData: {
172+
mimeType: match[1],
173+
data: match[2],
174+
},
175+
})
176+
}
177+
}
178+
}
179+
}
180+
181+
return parts.length > 0 ? parts : [{ text: "" }]
182+
}
183+
184+
export function hasOpenAIMessages(body: Record<string, unknown>): boolean {
185+
return Array.isArray(body.messages) && body.messages.length > 0
186+
}
187+
188+
export function convertRequestBody(
189+
body: Record<string, unknown>,
190+
thoughtSignature?: string
191+
): Record<string, unknown> {
192+
if (!hasOpenAIMessages(body)) {
193+
debugLog("No messages array found, returning body as-is")
194+
return body
195+
}
196+
197+
const messages = body.messages as OpenAIMessage[]
198+
const contents = convertOpenAIToGemini(messages, thoughtSignature)
199+
200+
const converted = { ...body }
201+
delete converted.messages
202+
converted.contents = contents
203+
204+
debugLog(`Converted body: messages → contents (${contents.length} blocks)`)
205+
return converted
206+
}

src/auth/antigravity/project.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,34 +116,27 @@ async function callLoadCodeAssistAPI(
116116
/**
117117
* Fetch project context from Google's loadCodeAssist API.
118118
* Extracts the cloudaicompanionProject from the response.
119-
* Falls back to ANTIGRAVITY_DEFAULT_PROJECT_ID if API fails or returns empty.
120119
*
121120
* @param accessToken - Valid OAuth access token
122121
* @returns Project context with cloudaicompanionProject ID
123122
*/
124123
export async function fetchProjectContext(
125124
accessToken: string
126125
): Promise<AntigravityProjectContext> {
127-
// Check cache first
128126
const cached = projectContextCache.get(accessToken)
129127
if (cached) {
130128
return cached
131129
}
132130

133-
// Call the API
134131
const response = await callLoadCodeAssistAPI(accessToken)
135-
136-
// Extract project ID from response
137132
const projectId = response
138133
? extractProjectId(response.cloudaicompanionProject)
139134
: undefined
140135

141-
// Build result with fallback
142136
const result: AntigravityProjectContext = {
143-
cloudaicompanionProject: projectId || ANTIGRAVITY_DEFAULT_PROJECT_ID,
137+
cloudaicompanionProject: projectId || "",
144138
}
145139

146-
// Cache the result
147140
if (projectId) {
148141
projectContextCache.set(accessToken, result)
149142
}

0 commit comments

Comments
 (0)