fix(antigravity): implement FREE tier onboarding via onboardUser API

- Removed random project ID generation (doesn't work for FREE tier)
- Added onboardManagedProject() to call onboardUser API for server-assigned managed project ID
- Updated type definitions: AntigravityUserTier, AntigravityOnboardUserPayload
- FREE tier users now get proper project IDs from Google instead of PERMISSION_DENIED errors
- Reference: https://github.com/shekohex/opencode-google-antigravity-auth

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2025-12-16 21:02:38 +09:00
parent 4112be7ad5
commit ed233d7f2a
2 changed files with 135 additions and 91 deletions

View File

@@ -1,6 +1,8 @@
/** /**
* Antigravity project context management. * Antigravity project context management.
* Handles fetching GCP project ID via Google's loadCodeAssist API. * Handles fetching GCP project ID via Google's loadCodeAssist API.
* For FREE tier users, onboards via onboardUser API to get server-assigned managed project ID.
* Reference: https://github.com/shekohex/opencode-google-antigravity-auth
*/ */
import { import {
@@ -11,53 +13,26 @@ import {
import type { import type {
AntigravityProjectContext, AntigravityProjectContext,
AntigravityLoadCodeAssistResponse, AntigravityLoadCodeAssistResponse,
AntigravityOnboardUserPayload,
AntigravityUserTier,
} from "./types" } from "./types"
// CLIProxyAPI-compatible random project ID generation
// https://github.com/anthropics/anthropic-quickstarts/blob/main/internal/runtime/executor/antigravity_executor.go#L784-L791
const PROJECT_ID_ADJECTIVES = ["useful", "bright", "swift", "calm", "bold"] as const
const PROJECT_ID_NOUNS = ["fuze", "wave", "spark", "flow", "core"] as const
function generateRandomProjectId(): string {
const adj = PROJECT_ID_ADJECTIVES[Math.floor(Math.random() * PROJECT_ID_ADJECTIVES.length)]
const noun = PROJECT_ID_NOUNS[Math.floor(Math.random() * PROJECT_ID_NOUNS.length)]
const randomPart = crypto.randomUUID().slice(0, 5).toLowerCase()
return `${adj}-${noun}-${randomPart}`
}
const projectContextCache = new Map<string, AntigravityProjectContext>() const projectContextCache = new Map<string, AntigravityProjectContext>()
/**
* Client metadata for loadCodeAssist API request.
* Matches cliproxyapi implementation.
*/
const CODE_ASSIST_METADATA = { const CODE_ASSIST_METADATA = {
ideType: "IDE_UNSPECIFIED", ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI", pluginType: "GEMINI",
} as const } as const
/**
* Extracts the project ID from a cloudaicompanionProject field.
* Handles both string and object formats.
*
* @param project - The cloudaicompanionProject value from API response
* @returns Extracted project ID string, or undefined if not found
*/
function extractProjectId( function extractProjectId(
project: string | { id: string } | undefined project: string | { id: string } | undefined
): string | undefined { ): string | undefined {
if (!project) { if (!project) return undefined
return undefined
}
// Handle string format
if (typeof project === "string") { if (typeof project === "string") {
const trimmed = project.trim() const trimmed = project.trim()
return trimmed || undefined return trimmed || undefined
} }
// Handle object format { id: string }
if (typeof project === "object" && "id" in project) { if (typeof project === "object" && "id" in project) {
const id = project.id const id = project.id
if (typeof id === "string") { if (typeof id === "string") {
@@ -65,22 +40,70 @@ function extractProjectId(
return trimmed || undefined return trimmed || undefined
} }
} }
return undefined return undefined
} }
/** function getDefaultTierId(allowedTiers?: AntigravityUserTier[]): string | undefined {
* Calls the loadCodeAssist API to get project context. if (!allowedTiers || allowedTiers.length === 0) return undefined
* Tries each endpoint in the fallback list until one succeeds. for (const tier of allowedTiers) {
* if (tier?.isDefault) return tier.id
* @param accessToken - Valid OAuth access token }
* @returns API response or null if all endpoints fail return allowedTiers[0]?.id
*/ }
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function callLoadCodeAssistAPI( async function callLoadCodeAssistAPI(
accessToken: string accessToken: string,
projectId?: string
): Promise<AntigravityLoadCodeAssistResponse | null> { ): Promise<AntigravityLoadCodeAssistResponse | null> {
const requestBody = { const metadata: Record<string, string> = { ...CODE_ASSIST_METADATA }
metadata: CODE_ASSIST_METADATA, if (projectId) metadata.duetProject = projectId
const requestBody: Record<string, unknown> = { metadata }
if (projectId) requestBody.cloudaicompanionProject = projectId
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
}
for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
const url = `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`
try {
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
})
if (!response.ok) continue
return (await response.json()) as AntigravityLoadCodeAssistResponse
} catch {
continue
}
}
return null
}
async function onboardManagedProject(
accessToken: string,
tierId: string,
projectId?: string,
attempts = 10,
delayMs = 5000
): Promise<string | undefined> {
const metadata: Record<string, string> = { ...CODE_ASSIST_METADATA }
if (projectId) metadata.duetProject = projectId
const requestBody: Record<string, unknown> = { tierId, metadata }
if (tierId !== "FREE") {
if (!projectId) return undefined
requestBody.cloudaicompanionProject = projectId
} }
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -91,70 +114,80 @@ async function callLoadCodeAssistAPI(
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"], "Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
} }
// Try each endpoint in the fallback list for (let attempt = 0; attempt < attempts; attempt++) {
for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
const url = `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist` const url = `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:onboardUser`
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}) })
if (!response.ok) continue
if (!response.ok) { const payload = (await response.json()) as AntigravityOnboardUserPayload
// Try next endpoint on failure const managedProjectId = payload.response?.cloudaicompanionProject?.id
continue if (payload.done && managedProjectId) return managedProjectId
} if (payload.done && projectId) return projectId
const data =
(await response.json()) as AntigravityLoadCodeAssistResponse
return data
} catch { } catch {
// Network or parsing error, try next endpoint
continue continue
} }
} }
if (attempt < attempts - 1) await wait(delayMs)
// All endpoints failed }
return null return undefined
} }
/**
* Fetch project context from Google's loadCodeAssist API.
* Extracts the cloudaicompanionProject from the response.
*
* @param accessToken - Valid OAuth access token
* @returns Project context with cloudaicompanionProject ID
*/
export async function fetchProjectContext( export async function fetchProjectContext(
accessToken: string accessToken: string
): Promise<AntigravityProjectContext> { ): Promise<AntigravityProjectContext> {
const cached = projectContextCache.get(accessToken) const cached = projectContextCache.get(accessToken)
if (cached) { if (cached) return cached
return cached
}
const response = await callLoadCodeAssistAPI(accessToken) const loadPayload = await callLoadCodeAssistAPI(accessToken)
const projectId = response
? extractProjectId(response.cloudaicompanionProject)
: undefined
const result: AntigravityProjectContext = {
cloudaicompanionProject: projectId || generateRandomProjectId(),
}
// If loadCodeAssist returns a project ID, use it directly
if (loadPayload?.cloudaicompanionProject) {
const projectId = extractProjectId(loadPayload.cloudaicompanionProject)
if (projectId) {
const result: AntigravityProjectContext = { cloudaicompanionProject: projectId }
projectContextCache.set(accessToken, result) projectContextCache.set(accessToken, result)
return result
}
}
// No project ID from loadCodeAssist - check tier and onboard if FREE
if (!loadPayload) {
return { cloudaicompanionProject: "" }
}
const currentTierId = loadPayload.currentTier?.id
if (currentTierId && currentTierId !== "FREE") {
// PAID tier requires user-provided project ID
return { cloudaicompanionProject: "" }
}
const defaultTierId = getDefaultTierId(loadPayload.allowedTiers)
const tierId = defaultTierId ?? "FREE"
if (tierId !== "FREE") {
return { cloudaicompanionProject: "" }
}
// FREE tier - onboard to get server-assigned managed project ID
const managedProjectId = await onboardManagedProject(accessToken, tierId)
if (managedProjectId) {
const result: AntigravityProjectContext = {
cloudaicompanionProject: managedProjectId,
managedProjectId,
}
projectContextCache.set(accessToken, result)
return result return result
} }
/** return { cloudaicompanionProject: "" }
* Clear the project context cache. }
* Call this when tokens are refreshed or invalidated.
*
* @param accessToken - Optional specific token to clear, or clears all if not provided
*/
export function clearProjectContextCache(accessToken?: string): void { export function clearProjectContextCache(accessToken?: string): void {
if (accessToken) { if (accessToken) {
projectContextCache.delete(accessToken) projectContextCache.delete(accessToken)

View File

@@ -56,12 +56,23 @@ export interface AntigravityLoadCodeAssistRequest {
metadata: AntigravityClientMetadata metadata: AntigravityClientMetadata
} }
/** export interface AntigravityUserTier {
* Response from loadCodeAssist API id?: string
*/ isDefault?: boolean
userDefinedCloudaicompanionProject?: boolean
}
export interface AntigravityLoadCodeAssistResponse { export interface AntigravityLoadCodeAssistResponse {
/** Project ID - can be string or object with id field */
cloudaicompanionProject?: string | { id: string } cloudaicompanionProject?: string | { id: string }
currentTier?: { id?: string }
allowedTiers?: AntigravityUserTier[]
}
export interface AntigravityOnboardUserPayload {
done?: boolean
response?: {
cloudaicompanionProject?: { id?: string }
}
} }
/** /**