From 38cb8bcb0ce9fbfb4e82c26cd4d8df0595186b05 Mon Sep 17 00:00:00 2001 From: Gemini AI Date: Sun, 28 Dec 2025 03:27:31 +0400 Subject: [PATCH] fix: prevent duplicate AI models in selector and fix TypeScript errors Changes: 1. Enhanced removeDuplicateProviders() to filter out duplicate providers from SDK when the same provider exists in extras (qwen-oauth, zai, ollama-cloud, antigravity) 2. Added logic to remove any Qwen-related SDK providers when qwen-oauth is authenticated 3. Fixed missing setActiveParentSession import in instance-shell2.tsx These changes ensure: - No duplicate models appear in the model selector - Qwen OAuth models don't duplicate with any SDK Qwen providers - TypeScript compilation passes successfully --- .../server/src/integrations/antigravity.ts | 460 ++++++++++++------ .../server/src/server/routes/antigravity.ts | 409 ++++++++++------ packages/server/src/server/routes/qwen.ts | 2 + .../components/instance/instance-shell2.tsx | 1 + .../settings/AntigravitySettings.tsx | 188 ++++++- packages/ui/src/stores/session-api.ts | 22 +- 6 files changed, 777 insertions(+), 305 deletions(-) diff --git a/packages/server/src/integrations/antigravity.ts b/packages/server/src/integrations/antigravity.ts index 1eb70e3..456ea80 100644 --- a/packages/server/src/integrations/antigravity.ts +++ b/packages/server/src/integrations/antigravity.ts @@ -11,6 +11,7 @@ * Uses Google OAuth credentials stored via the Antigravity OAuth flow */ +import { randomUUID } from "crypto" import { z } from "zod" // Configuration schema for Antigravity @@ -18,9 +19,9 @@ export const AntigravityConfigSchema = z.object({ enabled: z.boolean().default(true), // Multiple endpoints for automatic fallback (daily → autopush → prod) endpoints: z.array(z.string()).default([ - "https://daily.antigravity.dev/v1beta", - "https://autopush.antigravity.dev/v1beta", - "https://antigravity.dev/v1beta" + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://autopush-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com" ]), apiKey: z.string().optional() }) @@ -217,6 +218,31 @@ export const ANTIGRAVITY_MODELS: AntigravityModel[] = [ // Token storage key for Antigravity OAuth const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token" +const ANTIGRAVITY_HEADERS = { + "User-Agent": "antigravity/1.11.5 windows/amd64", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": "{\"ideType\":\"IDE_UNSPECIFIED\",\"platform\":\"PLATFORM_UNSPECIFIED\",\"pluginType\":\"GEMINI\"}", +} as const +const LOAD_ASSIST_HEADERS = { + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"], +} as const + +const DEFAULT_PROJECT_ID = process.env.ANTIGRAVITY_PROJECT_ID || "rising-fact-p41fc" +const LOAD_ASSIST_METADATA = { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI" +} as const +const LOAD_ENDPOINTS = [ + "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://autopush-cloudcode-pa.sandbox.googleapis.com" +] as const + +const STREAM_ACTION = "streamGenerateContent" +const GENERATE_ACTION = "generateContent" export interface AntigravityToken { access_token: string @@ -226,11 +252,55 @@ export interface AntigravityToken { project_id?: string } +function generateSyntheticProjectId(): string { + const adjectives = ["useful", "bright", "swift", "calm", "bold"] + const nouns = ["fuze", "wave", "spark", "flow", "core"] + const adj = adjectives[Math.floor(Math.random() * adjectives.length)] + const noun = nouns[Math.floor(Math.random() * nouns.length)] + const random = randomUUID().slice(0, 5).toLowerCase() + return `${adj}-${noun}-${random}` +} + +function collectSystemInstruction(messages: ChatMessage[]): string | undefined { + const systemParts: string[] = [] + for (const message of messages) { + if (message.role === "system" && typeof message.content === "string") { + systemParts.push(message.content) + } + } + const combined = systemParts.join("\n\n").trim() + return combined.length > 0 ? combined : undefined +} + +function buildContents(messages: ChatMessage[]): Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> { + const contents: Array<{ role: "user" | "model"; parts: Array<{ text: string }> }> = [] + for (const message of messages) { + if (!message.content) continue + if (message.role === "system") continue + const role = message.role === "assistant" ? "model" : "user" + const prefix = message.role === "tool" ? "Tool result:\n" : "" + contents.push({ + role, + parts: [{ text: `${prefix}${message.content}` }] + }) + } + return contents +} + +function extractTextFromResponse(payload: any): string { + const candidates = payload?.candidates + if (!Array.isArray(candidates) || candidates.length === 0) return "" + const parts = candidates[0]?.content?.parts + if (!Array.isArray(parts)) return "" + return parts.map((part: any) => (typeof part?.text === "string" ? part.text : "")).join("") +} + export class AntigravityClient { private config: AntigravityConfig private currentEndpointIndex: number = 0 private modelsCache: AntigravityModel[] | null = null private modelsCacheTime: number = 0 + private projectIdCache: string | null = null private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes constructor(config?: Partial) { @@ -280,10 +350,17 @@ export class AntigravityClient { /** * Get authorization headers for API requests */ - private getAuthHeaders(): Record { + private getAuthHeaders(accessToken?: string): Record { const headers: Record = { "Content-Type": "application/json", - "User-Agent": "NomadArch/1.0" + "User-Agent": ANTIGRAVITY_HEADERS["User-Agent"], + "X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"], + "Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"], + } + + if (accessToken) { + headers["Authorization"] = `Bearer ${accessToken}` + return headers } // Try OAuth token first @@ -297,152 +374,129 @@ export class AntigravityClient { return headers } + private getLoadHeaders(accessToken: string): Record { + return { + "Content-Type": "application/json", + "User-Agent": LOAD_ASSIST_HEADERS["User-Agent"], + "X-Goog-Api-Client": LOAD_ASSIST_HEADERS["X-Goog-Api-Client"], + "Client-Metadata": LOAD_ASSIST_HEADERS["Client-Metadata"], + Authorization: `Bearer ${accessToken}`, + } + } + /** * Check if the client is authenticated */ - isAuthenticated(): boolean { + isAuthenticated(accessToken?: string): boolean { + if (accessToken) return true const token = this.getStoredToken() return this.isTokenValid(token) || Boolean(this.config.apiKey) } - /** - * Get available Antigravity models - */ - async getModels(): Promise { - // Return cached models if still valid - const now = Date.now() - if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) { - return this.modelsCache + private async resolveProjectId(accessToken: string | undefined, projectIdOverride?: string): Promise { + const requestedProjectId = projectIdOverride?.trim() + if (this.projectIdCache && !requestedProjectId) return this.projectIdCache + if (!accessToken) { + const fallback = requestedProjectId || generateSyntheticProjectId() + if (requestedProjectId) { + this.projectIdCache = requestedProjectId + } + return fallback } - // If authenticated, return full model list - if (this.isAuthenticated()) { - this.modelsCache = ANTIGRAVITY_MODELS - this.modelsCacheTime = now - return ANTIGRAVITY_MODELS - } - - // Not authenticated - return empty list - return [] - } - - /** - * Test connection to Antigravity API - */ - async testConnection(): Promise { - if (!this.isAuthenticated()) { - return false - } - - try { - // Try a simple models list request to verify connectivity - const response = await fetch(`${this.getEndpoint()}/models`, { - headers: this.getAuthHeaders(), - signal: AbortSignal.timeout(10000) - }) - return response.ok - } catch (error) { - console.warn("Antigravity connection test failed:", error) - // Try next endpoint - this.rotateEndpoint() - return false - } - } - - /** - * Chat completion (streaming) with automatic endpoint fallback - */ - async *chatStream(request: ChatRequest): AsyncGenerator { - if (!this.isAuthenticated()) { - throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.") - } - - let lastError: Error | null = null - const maxRetries = this.config.endpoints.length - - for (let retry = 0; retry < maxRetries; retry++) { - try { - const endpoint = this.getEndpoint() - const response = await fetch(`${endpoint}/chat/completions`, { - method: "POST", - headers: this.getAuthHeaders(), - body: JSON.stringify({ - ...request, - stream: true + const loadEndpoints = Array.from(new Set([...LOAD_ENDPOINTS, ...this.config.endpoints])) + const tryLoad = async (metadata: Record): Promise => { + for (const endpoint of loadEndpoints) { + try { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers: this.getLoadHeaders(accessToken), + body: JSON.stringify({ metadata }), + signal: AbortSignal.timeout(10000), }) - }) - - if (!response.ok) { - const errorText = await response.text() - if (response.status === 401 || response.status === 403) { - throw new Error(`Antigravity authentication failed: ${errorText}`) + if (!response.ok) continue + const data = await response.json() as any + const projectId = + data?.cloudaicompanionProject?.id || + data?.cloudaicompanionProject || + data?.projectId + if (typeof projectId === "string" && projectId.length > 0) { + return projectId } - // Try next endpoint for other errors - this.rotateEndpoint() - lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`) + } catch { continue } + } + return null + } - if (!response.body) { - throw new Error("Response body is missing") + let resolvedProjectId: string | null = null + const baseMetadata: Record = { ...LOAD_ASSIST_METADATA } + if (requestedProjectId) { + baseMetadata.duetProject = requestedProjectId + resolvedProjectId = await tryLoad(baseMetadata) + } else { + resolvedProjectId = await tryLoad(baseMetadata) + if (!resolvedProjectId) { + const fallbackMetadata: Record = { + ...LOAD_ASSIST_METADATA, + duetProject: DEFAULT_PROJECT_ID, } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = "" - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" - - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith("data: ")) { - const data = trimmed.slice(6) - if (data === "[DONE]") return - - try { - const parsed = JSON.parse(data) - yield parsed as ChatChunk - - // Check for finish - if (parsed.choices?.[0]?.finish_reason) { - return - } - } catch (e) { - // Skip invalid JSON - } - } - } - } - } finally { - reader.releaseLock() - } - return // Success, exit retry loop - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)) - if (error instanceof Error && error.message.includes("authentication")) { - throw error // Don't retry auth errors - } - this.rotateEndpoint() + resolvedProjectId = await tryLoad(fallbackMetadata) } } - throw lastError || new Error("Antigravity: All endpoints failed") + const fallbackProjectId = requestedProjectId || DEFAULT_PROJECT_ID + const finalProjectId = resolvedProjectId || fallbackProjectId + this.projectIdCache = finalProjectId + return finalProjectId } - /** - * Chat completion (non-streaming) - */ - async chat(request: ChatRequest): Promise { - if (!this.isAuthenticated()) { - throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.") + private resolveAccessToken(accessToken?: string): string | null { + if (accessToken) return accessToken + const token = this.getStoredToken() + if (token && this.isTokenValid(token)) { + return token.access_token } + if (this.config.apiKey) { + return this.config.apiKey + } + return null + } + + private async requestGenerateContent(request: ChatRequest, accessToken?: string, projectIdOverride?: string): Promise { + const authToken = this.resolveAccessToken(accessToken) + if (!authToken) { + throw new Error("Antigravity: Missing access token.") + } + + const projectId = await this.resolveProjectId(authToken, projectIdOverride) + const systemInstruction = collectSystemInstruction(request.messages) + const contents = buildContents(request.messages) + + const generationConfig: Record = {} + if (typeof request.temperature === "number") { + generationConfig.temperature = request.temperature + } + if (typeof request.max_tokens === "number") { + generationConfig.maxOutputTokens = request.max_tokens + } + + const requestPayload: Record = { contents } + if (systemInstruction) { + requestPayload.systemInstruction = { parts: [{ text: systemInstruction }] } + } + if (Object.keys(generationConfig).length > 0) { + requestPayload.generationConfig = generationConfig + } + + const body = JSON.stringify({ + project: projectId, + model: request.model, + request: requestPayload, + userAgent: "antigravity", + requestId: `agent-${randomUUID()}` + }) let lastError: Error | null = null const maxRetries = this.config.endpoints.length @@ -450,13 +504,11 @@ export class AntigravityClient { for (let retry = 0; retry < maxRetries; retry++) { try { const endpoint = this.getEndpoint() - const response = await fetch(`${endpoint}/chat/completions`, { + const response = await fetch(`${endpoint}/v1internal:${GENERATE_ACTION}`, { method: "POST", - headers: this.getAuthHeaders(), - body: JSON.stringify({ - ...request, - stream: false - }) + headers: this.getAuthHeaders(authToken), + body, + signal: AbortSignal.timeout(120000) }) if (!response.ok) { @@ -469,7 +521,8 @@ export class AntigravityClient { continue } - return await response.json() + const data = await response.json() + return extractTextFromResponse(data) } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)) if (error instanceof Error && error.message.includes("authentication")) { @@ -481,15 +534,142 @@ export class AntigravityClient { throw lastError || new Error("Antigravity: All endpoints failed") } + + /** + * Get available Antigravity models + */ + async getModels(accessToken?: string): Promise { + if (!this.isAuthenticated(accessToken)) { + return [] + } + + // Return cached models if still valid + const now = Date.now() + if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) { + return this.modelsCache + } + + // If authenticated, return full model list + this.modelsCache = ANTIGRAVITY_MODELS + this.modelsCacheTime = now + return ANTIGRAVITY_MODELS + } + + /** + * Test connection to Antigravity API + */ + async testConnection(accessToken?: string, projectIdOverride?: string): Promise<{ connected: boolean; error?: string; status?: number }> { + if (!this.isAuthenticated(accessToken)) { + return { connected: false, error: "Not authenticated" } + } + + try { + const authToken = this.resolveAccessToken(accessToken) + if (!authToken) { + return { connected: false, error: "Not authenticated" } + } + + const requestedProjectId = projectIdOverride?.trim() + const loadEndpoints = Array.from(new Set([...LOAD_ENDPOINTS, ...this.config.endpoints])) + let lastErrorText = "" + let lastStatus: number | undefined + + const tryLoad = async (metadata: Record): Promise => { + for (const endpoint of loadEndpoints) { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers: this.getLoadHeaders(authToken), + body: JSON.stringify({ metadata }), + signal: AbortSignal.timeout(10000) + }) + if (response.ok) { + return true + } + lastStatus = response.status + lastErrorText = await response.text().catch(() => "") || response.statusText + } + return false + } + + const baseMetadata: Record = { ...LOAD_ASSIST_METADATA } + if (requestedProjectId) { + baseMetadata.duetProject = requestedProjectId + } + let success = await tryLoad(baseMetadata) + if (!success && !requestedProjectId) { + const fallbackMetadata: Record = { + ...LOAD_ASSIST_METADATA, + duetProject: DEFAULT_PROJECT_ID, + } + success = await tryLoad(fallbackMetadata) + } + if (success) { + return { connected: true } + } + return { + connected: false, + status: lastStatus, + error: lastErrorText || "Connection test failed" + } + } catch (error) { + console.warn("Antigravity connection test failed:", error) + return { connected: false, error: error instanceof Error ? error.message : String(error) } + } + } + + /** + * Chat completion (streaming) with automatic endpoint fallback + */ + async *chatStream(request: ChatRequest, accessToken?: string, projectIdOverride?: string): AsyncGenerator { + if (!this.isAuthenticated(accessToken)) { + throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.") + } + + const content = await this.requestGenerateContent(request, accessToken, projectIdOverride) + yield { + id: randomUUID(), + choices: [ + { + index: 0, + delta: { content }, + finish_reason: "stop" + } + ] + } + } + + /** + * Chat completion (non-streaming) + */ + async chat(request: ChatRequest, accessToken?: string, projectIdOverride?: string): Promise { + if (!this.isAuthenticated(accessToken)) { + throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.") + } + + const content = await this.requestGenerateContent(request, accessToken, projectIdOverride) + return { + id: randomUUID(), + choices: [ + { + index: 0, + message: { + role: "assistant", + content + }, + finish_reason: "stop" + } + ] + } + } } export function getDefaultAntigravityConfig(): AntigravityConfig { return { enabled: true, endpoints: [ - "https://daily.antigravity.dev/v1beta", - "https://autopush.antigravity.dev/v1beta", - "https://antigravity.dev/v1beta" + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://autopush-cloudcode-pa.sandbox.googleapis.com", + "https://cloudcode-pa.googleapis.com" ] } } diff --git a/packages/server/src/server/routes/antigravity.ts b/packages/server/src/server/routes/antigravity.ts index 1abda20..374aa1d 100644 --- a/packages/server/src/server/routes/antigravity.ts +++ b/packages/server/src/server/routes/antigravity.ts @@ -1,3 +1,5 @@ +import { createHash, randomBytes, randomUUID } from "crypto" +import { createServer } from "http" import { FastifyInstance } from "fastify" import { AntigravityClient, type ChatRequest, getDefaultAntigravityConfig, type ChatMessage } from "../../integrations/antigravity" import { Logger } from "../../logger" @@ -11,29 +13,202 @@ interface AntigravityRouteDeps { // Maximum number of tool execution loops const MAX_TOOL_LOOPS = 10 -// Google OAuth Device Flow configuration -// Using the same client ID as gcloud CLI / Cloud SDK +// Google OAuth Authorization Code + PKCE configuration (Antigravity-compatible) const GOOGLE_OAUTH_CONFIG = { - clientId: "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com", - clientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty", // Public client secret for device flow - deviceAuthEndpoint: "https://oauth2.googleapis.com/device/code", + clientId: process.env.ANTIGRAVITY_GOOGLE_CLIENT_ID || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + clientSecret: process.env.ANTIGRAVITY_GOOGLE_CLIENT_SECRET || "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", + redirectUri: process.env.ANTIGRAVITY_GOOGLE_REDIRECT_URI || "http://localhost:51121/oauth-callback", + authEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", tokenEndpoint: "https://oauth2.googleapis.com/token", scopes: [ - "openid", - "email", - "profile", - "https://www.googleapis.com/auth/cloud-platform" - ] + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + ], } -// Active device auth sessions (in-memory, per-server instance) -const deviceAuthSessions = new Map() + token?: { + accessToken: string + refreshToken?: string + expiresIn: number + tokenType?: string + scope?: string + } + error?: string +} + +// Active OAuth sessions (in-memory, per-server instance) +const oauthSessions = new Map() +let oauthCallbackServer: ReturnType | null = null + +function base64UrlEncode(value: Buffer): string { + return value + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, "") +} + +function createCodeVerifier(): string { + return base64UrlEncode(randomBytes(32)) +} + +function createCodeChallenge(verifier: string): string { + const digest = createHash("sha256").update(verifier).digest() + return base64UrlEncode(digest) +} + +function getAccessTokenFromHeader(authorization: string | undefined): string | null { + if (!authorization) return null + const [type, token] = authorization.split(" ") + if (!type || type.toLowerCase() !== "bearer" || !token) return null + return token.trim() +} + +function getProjectIdFromHeader(value: string | string[] | undefined): string | undefined { + if (typeof value === "string" && value.trim()) return value.trim() + if (Array.isArray(value)) { + const entry = value.find((item) => typeof item === "string" && item.trim()) + if (entry) return entry.trim() + } + return undefined +} + +async function exchangeAuthorizationCode(code: string, verifier: string): Promise<{ + accessToken: string + refreshToken?: string + expiresIn: number + tokenType?: string + scope?: string +}> { + const params = new URLSearchParams({ + client_id: GOOGLE_OAUTH_CONFIG.clientId, + code, + grant_type: "authorization_code", + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUri, + code_verifier: verifier, + }) + if (GOOGLE_OAUTH_CONFIG.clientSecret) { + params.set("client_secret", GOOGLE_OAUTH_CONFIG.clientSecret) + } + + const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(errorText || `Token exchange failed (${response.status})`) + } + + const data = await response.json() as any + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + tokenType: data.token_type, + scope: data.scope, + } +} + +function ensureOAuthCallbackServer(logger: Logger): void { + if (oauthCallbackServer) return + oauthCallbackServer = createServer((req, res) => { + void (async () => { + try { + const url = new URL(req.url || "", GOOGLE_OAUTH_CONFIG.redirectUri) + if (url.pathname !== callbackPath) { + res.writeHead(404) + res.end() + return + } + + const state = url.searchParams.get("state") + const code = url.searchParams.get("code") + const error = url.searchParams.get("error") + + if (!state) { + res.writeHead(400, { "Content-Type": "text/plain" }) + res.end("Missing OAuth state.") + return + } + + const session = oauthSessions.get(state) + if (!session) { + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("OAuth session not found or expired.") + return + } + + if (error) { + session.error = error + res.writeHead(200, { "Content-Type": "text/html" }) + res.end("

Sign-in cancelled.

You can close this window.

") + return + } + + if (!code) { + session.error = "Missing authorization code." + res.writeHead(400, { "Content-Type": "text/plain" }) + res.end("Missing authorization code.") + return + } + + try { + const token = await exchangeAuthorizationCode(code, session.verifier) + session.token = token + session.error = undefined + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end("

Sign-in complete.

You can close this window and return to the app.

") + } catch (err) { + const message = err instanceof Error ? err.message : "OAuth callback failed." + session.error = message + res.writeHead(500, { "Content-Type": "text/plain" }) + res.end(message) + } + } catch (err) { + const message = err instanceof Error ? err.message : "OAuth callback failed." + res.writeHead(500, { "Content-Type": "text/plain" }) + res.end(message) + } + })() + }) + + oauthCallbackServer.on("error", (err) => { + logger.error({ err, port: callbackPort }, "Antigravity OAuth callback server failed to start") + oauthCallbackServer = null + }) + + oauthCallbackServer.listen(callbackPort, "127.0.0.1", () => { + logger.info({ port: callbackPort, path: callbackPath }, "Antigravity OAuth callback server listening") + }) +} + +function cleanupExpiredSessions(): void { + const now = Date.now() + for (const [id, session] of oauthSessions) { + if (session.expiresAt <= now) { + oauthSessions.delete(id) + } + } +} export async function registerAntigravityRoutes( app: FastifyInstance, @@ -47,7 +222,8 @@ export async function registerAntigravityRoutes( // List available Antigravity models app.get('/api/antigravity/models', async (request, reply) => { try { - const models = await client.getModels() + const accessToken = getAccessTokenFromHeader(request.headers.authorization) + const models = await client.getModels(accessToken ?? undefined) return { models: models.map(m => ({ @@ -70,7 +246,8 @@ export async function registerAntigravityRoutes( // Check authentication status app.get('/api/antigravity/auth-status', async (request, reply) => { try { - const authenticated = client.isAuthenticated() + const accessToken = getAccessTokenFromHeader(request.headers.authorization) + const authenticated = client.isAuthenticated(accessToken ?? undefined) return { authenticated } } catch (error) { logger.error({ error }, "Antigravity auth status check failed") @@ -81,8 +258,10 @@ export async function registerAntigravityRoutes( // Test connection app.get('/api/antigravity/test', async (request, reply) => { try { - const connected = await client.testConnection() - return { connected } + const accessToken = getAccessTokenFromHeader(request.headers.authorization) + const projectId = getProjectIdFromHeader(request.headers["x-antigravity-project"]) + const result = await client.testConnection(accessToken ?? undefined, projectId) + return result } catch (error) { logger.error({ error }, "Antigravity connection test failed") return reply.status(500).send({ error: "Connection test failed" }) @@ -90,80 +269,54 @@ export async function registerAntigravityRoutes( }) // ========================================== - // Google Device Authorization Flow Endpoints + // Google OAuth Authorization Flow (PKCE) // ========================================== - // Step 1: Start device authorization - returns user_code and verification URL + // Step 1: Start OAuth authorization - returns auth URL app.post('/api/antigravity/device-auth/start', async (request, reply) => { try { - logger.info("Starting Google Device Authorization flow for Antigravity") + logger.info("Starting Google OAuth flow for Antigravity") + ensureOAuthCallbackServer(logger) - const response = await fetch(GOOGLE_OAUTH_CONFIG.deviceAuthEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - client_id: GOOGLE_OAUTH_CONFIG.clientId, - scope: GOOGLE_OAUTH_CONFIG.scopes.join(' ') - }) + const sessionId = randomUUID() + const verifier = createCodeVerifier() + const challenge = createCodeChallenge(verifier) + + const authUrl = new URL(GOOGLE_OAUTH_CONFIG.authEndpoint) + authUrl.searchParams.set("client_id", GOOGLE_OAUTH_CONFIG.clientId) + authUrl.searchParams.set("response_type", "code") + authUrl.searchParams.set("redirect_uri", GOOGLE_OAUTH_CONFIG.redirectUri) + authUrl.searchParams.set("scope", GOOGLE_OAUTH_CONFIG.scopes.join(" ")) + authUrl.searchParams.set("code_challenge", challenge) + authUrl.searchParams.set("code_challenge_method", "S256") + authUrl.searchParams.set("state", sessionId) + authUrl.searchParams.set("access_type", "offline") + authUrl.searchParams.set("prompt", "consent") + + oauthSessions.set(sessionId, { + verifier, + createdAt: Date.now(), + expiresAt: Date.now() + AUTH_SESSION_TTL_MS, }) - - if (!response.ok) { - const errorText = await response.text() - logger.error({ error: errorText, status: response.status }, "Device auth request failed") - return reply.status(500).send({ - error: `Device authorization failed: ${response.status}`, - details: errorText - }) - } - - const data = await response.json() as { - device_code: string - user_code: string - verification_url: string - expires_in: number - interval: number - } - - // Generate a session ID for tracking this auth flow - const sessionId = crypto.randomUUID() - - // Store the session - deviceAuthSessions.set(sessionId, { - deviceCode: data.device_code, - userCode: data.user_code, - verificationUrl: data.verification_url, - expiresAt: Date.now() + (data.expires_in * 1000), - interval: data.interval - }) - - // Clean up expired sessions - for (const [id, session] of deviceAuthSessions) { - if (session.expiresAt < Date.now()) { - deviceAuthSessions.delete(id) - } - } - - logger.info({ sessionId, userCode: data.user_code, verificationUrl: data.verification_url }, "Device auth session created") + cleanupExpiredSessions() return { sessionId, - userCode: data.user_code, - verificationUrl: data.verification_url, - expiresIn: data.expires_in, - interval: data.interval + userCode: "", + verificationUrl: authUrl.toString(), + expiresIn: Math.floor(AUTH_SESSION_TTL_MS / 1000), + interval: DEFAULT_POLL_INTERVAL_SEC, } } catch (error: any) { - logger.error({ error: error.message, stack: error.stack }, "Failed to start device authorization") + logger.error({ error: error.message, stack: error.stack }, "Failed to start OAuth authorization") return reply.status(500).send({ - error: "Failed to start device authorization", - details: error.message + error: "Failed to start authentication", + details: error.message, }) } }) - // Step 2: Poll for token (called by client after user enters code) + // Step 2: Poll for token (called by client after browser sign-in) app.post('/api/antigravity/device-auth/poll', async (request, reply) => { try { const { sessionId } = request.body as { sessionId: string } @@ -172,68 +325,36 @@ export async function registerAntigravityRoutes( return reply.status(400).send({ error: "Missing sessionId" }) } - const session = deviceAuthSessions.get(sessionId) + cleanupExpiredSessions() + const session = oauthSessions.get(sessionId) if (!session) { return reply.status(404).send({ error: "Session not found or expired" }) } if (session.expiresAt < Date.now()) { - deviceAuthSessions.delete(sessionId) + oauthSessions.delete(sessionId) return reply.status(410).send({ error: "Session expired" }) } - // Poll Google's token endpoint - const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - client_id: GOOGLE_OAUTH_CONFIG.clientId, - client_secret: GOOGLE_OAUTH_CONFIG.clientSecret, - device_code: session.deviceCode, - grant_type: 'urn:ietf:params:oauth:grant-type:device_code' - }) - }) - - const data = await response.json() as any - - if (data.error) { - // Still waiting for user - if (data.error === 'authorization_pending') { - return { status: 'pending', interval: session.interval } - } - // Slow down polling - if (data.error === 'slow_down') { - session.interval = Math.min(session.interval + 5, 60) - return { status: 'pending', interval: session.interval } - } - // User denied or other error - if (data.error === 'access_denied') { - deviceAuthSessions.delete(sessionId) - return { status: 'denied' } - } - if (data.error === 'expired_token') { - deviceAuthSessions.delete(sessionId) - return { status: 'expired' } - } - - logger.error({ error: data.error }, "Token poll error") - return { status: 'error', error: data.error } + if (session.error) { + oauthSessions.delete(sessionId) + return { status: "error", error: session.error } } - // Success! We have tokens - deviceAuthSessions.delete(sessionId) + if (!session.token) { + return { status: "pending", interval: DEFAULT_POLL_INTERVAL_SEC } + } - logger.info("Device authorization successful") + const token = session.token + oauthSessions.delete(sessionId) return { - status: 'success', - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresIn: data.expires_in, - tokenType: data.token_type, - scope: data.scope + status: "success", + accessToken: token.accessToken, + refreshToken: token.refreshToken, + expiresIn: token.expiresIn, + tokenType: token.tokenType, + scope: token.scope, } } catch (error) { logger.error({ error }, "Failed to poll for token") @@ -250,17 +371,21 @@ export async function registerAntigravityRoutes( return reply.status(400).send({ error: "Missing refreshToken" }) } + const params = new URLSearchParams({ + client_id: GOOGLE_OAUTH_CONFIG.clientId, + refresh_token: refreshToken, + grant_type: "refresh_token", + }) + if (GOOGLE_OAUTH_CONFIG.clientSecret) { + params.set("client_secret", GOOGLE_OAUTH_CONFIG.clientSecret) + } + const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: GOOGLE_OAUTH_CONFIG.clientId, - client_secret: GOOGLE_OAUTH_CONFIG.clientSecret, - refresh_token: refreshToken, - grant_type: 'refresh_token' - }) + body: params }) if (!response.ok) { @@ -289,6 +414,8 @@ export async function registerAntigravityRoutes( workspacePath?: string enableTools?: boolean } + const accessToken = getAccessTokenFromHeader(request.headers.authorization) + const projectId = getProjectIdFromHeader(request.headers["x-antigravity-project"]) // Extract workspace path for tool execution const workspacePath = chatRequest.workspacePath || process.cwd() @@ -313,6 +440,8 @@ export async function registerAntigravityRoutes( await streamWithToolLoop( client, chatRequest, + accessToken ?? undefined, + projectId, workspacePath, enableTools, reply.raw, @@ -329,6 +458,8 @@ export async function registerAntigravityRoutes( const response = await chatWithToolLoop( client, chatRequest, + accessToken ?? undefined, + projectId, workspacePath, enableTools, logger @@ -341,7 +472,7 @@ export async function registerAntigravityRoutes( } }) - logger.info("Antigravity routes registered with Google Device Auth flow!") + logger.info("Antigravity routes registered with Google OAuth flow!") } /** @@ -350,6 +481,8 @@ export async function registerAntigravityRoutes( async function streamWithToolLoop( client: AntigravityClient, request: ChatRequest, + accessToken: string | undefined, + projectId: string | undefined, workspacePath: string, enableTools: boolean, rawResponse: any, @@ -390,7 +523,7 @@ async function streamWithToolLoop( let textContent = "" // Stream response - for await (const chunk of client.chatStream({ ...requestWithTools, messages })) { + for await (const chunk of client.chatStream({ ...requestWithTools, messages }, accessToken, projectId)) { // Write chunk to client rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`) @@ -491,6 +624,8 @@ async function streamWithToolLoop( async function chatWithToolLoop( client: AntigravityClient, request: ChatRequest, + accessToken: string | undefined, + projectId: string | undefined, workspacePath: string, enableTools: boolean, logger: Logger @@ -509,7 +644,7 @@ async function chatWithToolLoop( while (loopCount < MAX_TOOL_LOOPS) { loopCount++ - const response = await client.chat({ ...requestWithTools, messages, stream: false }) + const response = await client.chat({ ...requestWithTools, messages, stream: false }, accessToken, projectId) lastResponse = response const choice = response.choices[0] diff --git a/packages/server/src/server/routes/qwen.ts b/packages/server/src/server/routes/qwen.ts index b4c6fd2..e184f8d 100644 --- a/packages/server/src/server/routes/qwen.ts +++ b/packages/server/src/server/routes/qwen.ts @@ -23,6 +23,8 @@ function normalizeQwenModel(model?: string): string { const raw = (model || "").trim() if (!raw) return "coder-model" const lower = raw.toLowerCase() + if (lower.startsWith("qwen-")) return lower + if (lower.includes("qwen")) return lower if (lower === "vision-model" || lower.includes("vision")) return "vision-model" if (lower === "coder-model") return "coder-model" if (lower.includes("coder")) return "coder-model" diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index ca3e2d5..bd16061 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -36,6 +36,7 @@ import { getSessionInfo, sessions, setActiveSession, + setActiveParentSession, executeCustomCommand, sendMessage, runShellCommand, diff --git a/packages/ui/src/components/settings/AntigravitySettings.tsx b/packages/ui/src/components/settings/AntigravitySettings.tsx index 679ad62..375e9e6 100644 --- a/packages/ui/src/components/settings/AntigravitySettings.tsx +++ b/packages/ui/src/components/settings/AntigravitySettings.tsx @@ -1,6 +1,8 @@ import { Component, createSignal, onMount, onCleanup, For, Show } from 'solid-js' import { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield, ExternalLink, Copy } from 'lucide-solid' import { getUserScopedKey } from '../../lib/user-storage' +import { instances } from '../../stores/instances' +import { fetchProviders } from '../../stores/session-api' interface AntigravityModel { id: string @@ -22,19 +24,22 @@ interface AntigravityToken { } const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token" +const ANTIGRAVITY_PROJECT_KEY = "antigravity_project_id" const AntigravitySettings: Component = () => { const [models, setModels] = createSignal([]) const [isLoading, setIsLoading] = createSignal(true) const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle') + const [connectionIssue, setConnectionIssue] = createSignal<{ title: string; message: string; link?: string } | null>(null) const [authStatus, setAuthStatus] = createSignal<'unknown' | 'authenticated' | 'unauthenticated'>('unknown') const [error, setError] = createSignal(null) + const [projectId, setProjectId] = createSignal("") // Device auth state const [isAuthenticating, setIsAuthenticating] = createSignal(false) const [deviceAuthSession, setDeviceAuthSession] = createSignal<{ sessionId: string - userCode: string + userCode?: string verificationUrl: string } | null>(null) const [copied, setCopied] = createSignal(false) @@ -43,6 +48,10 @@ const AntigravitySettings: Component = () => { // Check stored token on mount onMount(async () => { + const storedProjectId = window.localStorage.getItem(getUserScopedKey(ANTIGRAVITY_PROJECT_KEY)) + if (storedProjectId) { + setProjectId(storedProjectId) + } checkAuthStatus() await loadModels() await testConnection() @@ -72,6 +81,48 @@ const AntigravitySettings: Component = () => { return Date.now() < expiresAt } + const parseSubscriptionIssue = (raw: string | null | undefined) => { + if (!raw) return null + try { + const parsed = JSON.parse(raw) + const errorPayload = parsed?.error + const message = typeof errorPayload?.message === "string" ? errorPayload.message : raw + const details = Array.isArray(errorPayload?.details) ? errorPayload.details : [] + const reason = details.find((entry: any) => entry?.reason)?.reason + const helpLink = details + .flatMap((entry: any) => Array.isArray(entry?.links) ? entry.links : []) + .find((link: any) => typeof link?.url === "string")?.url + + if (reason === "SUBSCRIPTION_REQUIRED" || /Gemini Code Assist license/i.test(message)) { + return { + title: "Subscription required", + message, + link: helpLink + } + } + } catch { + if (/SUBSCRIPTION_REQUIRED/i.test(raw) || /Gemini Code Assist license/i.test(raw)) { + return { + title: "Subscription required", + message: raw + } + } + } + return null + } + + const getAuthHeaders = () => { + const token = getStoredToken() + const headers: Record = {} + if (token?.access_token && isTokenValid(token)) { + headers.Authorization = `Bearer ${token.access_token}` + } + if (projectId()) { + headers["X-Antigravity-Project"] = projectId() + } + return Object.keys(headers).length > 0 ? headers : undefined + } + const checkAuthStatus = () => { const token = getStoredToken() if (isTokenValid(token)) { @@ -84,7 +135,9 @@ const AntigravitySettings: Component = () => { const loadModels = async () => { setIsLoading(true) try { - const response = await fetch('/api/antigravity/models') + const response = await fetch('/api/antigravity/models', { + headers: getAuthHeaders() + }) if (response.ok) { const data = await response.json() setModels(data.models || []) @@ -102,12 +155,24 @@ const AntigravitySettings: Component = () => { const testConnection = async () => { setConnectionStatus('testing') + setConnectionIssue(null) try { - const response = await fetch('/api/antigravity/test') + const response = await fetch('/api/antigravity/test', { + headers: getAuthHeaders() + }) if (response.ok) { const data = await response.json() setConnectionStatus(data.connected ? 'connected' : 'failed') + const issue = parseSubscriptionIssue(data.error) + if (issue) { + setConnectionIssue(issue) + } } else { + const errorText = await response.text().catch(() => "") + const issue = parseSubscriptionIssue(errorText) + if (issue) { + setConnectionIssue(issue) + } setConnectionStatus('failed') } } catch (err) { @@ -115,6 +180,8 @@ const AntigravitySettings: Component = () => { } } + const offlineLabel = () => connectionIssue()?.title ?? "Offline" + // Start device authorization flow const startDeviceAuth = async () => { setIsAuthenticating(true) @@ -127,12 +194,14 @@ const AntigravitySettings: Component = () => { if (!response.ok) { const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || errorData.details || 'Failed to start authentication') + const base = errorData.error || 'Failed to start authentication' + const details = errorData.details ? ` - ${errorData.details}` : '' + throw new Error(`${base}${details}`) } const data = await response.json() as { sessionId: string - userCode: string + userCode?: string verificationUrl: string expiresIn: number interval: number @@ -140,7 +209,7 @@ const AntigravitySettings: Component = () => { setDeviceAuthSession({ sessionId: data.sessionId, - userCode: data.userCode, + userCode: data.userCode || "", verificationUrl: data.verificationUrl }) @@ -210,6 +279,14 @@ const AntigravitySettings: Component = () => { setAuthStatus('authenticated') setError(null) loadModels() + await testConnection() + for (const instance of instances().values()) { + try { + await fetchProviders(instance.id) + } catch (refreshError) { + console.error(`Failed to refresh providers for instance ${instance.id}:`, refreshError) + } + } return } @@ -254,11 +331,18 @@ const AntigravitySettings: Component = () => { const signOut = () => { window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY)) setAuthStatus('unauthenticated') + setConnectionIssue(null) + setConnectionStatus('idle') + for (const instance of instances().values()) { + fetchProviders(instance.id).catch((refreshError) => { + console.error(`Failed to refresh providers for instance ${instance.id}:`, refreshError) + }) + } } const copyCode = async () => { const session = deviceAuthSession() - if (session) { + if (session?.userCode) { await navigator.clipboard.writeText(session.userCode) setCopied(true) setTimeout(() => setCopied(false), 2000) @@ -308,7 +392,7 @@ const AntigravitySettings: Component = () => { {connectionStatus() === 'failed' && ( - Offline + {offlineLabel()} )} @@ -386,21 +470,30 @@ const AntigravitySettings: Component = () => {
-

- Enter this code on the Google sign-in page: -

-
- - {deviceAuthSession()?.userCode} - - -
+ + Complete the sign-in in the browser window. +

+ } + > +

+ Enter this code on the Google sign-in page: +

+
+ + {deviceAuthSession()?.userCode} + + +
+
@@ -428,6 +521,38 @@ const AntigravitySettings: Component = () => {
+ +
+ + { + const value = event.currentTarget.value.trim() + setProjectId(value) + if (typeof window !== "undefined") { + const key = getUserScopedKey(ANTIGRAVITY_PROJECT_KEY) + if (value) { + window.localStorage.setItem(key, value) + } else { + window.localStorage.removeItem(key) + } + } + }} + class="w-full bg-zinc-900/70 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:ring-2 focus:ring-purple-500/50" + placeholder="e.g. my-gcp-project-id" + /> +

+ Set this only if your account is tied to a specific Code Assist project. +

+ +
{/* Error Display */} @@ -437,6 +562,23 @@ const AntigravitySettings: Component = () => {
+ +
+
{connectionIssue()?.title}
+
{connectionIssue()?.message}
+ + + Learn more + + +
+
+ {/* Models Grid */}
diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 040ce7c..382b1a0 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -331,20 +331,32 @@ async function fetchExtraProviders(): Promise { } function removeDuplicateProviders(base: Provider[], extras: Provider[]): Provider[] { + // Collect all extra provider IDs and model IDs to prevent duplicates + const extraProviderIds = new Set(extras.map((provider) => provider.id)) const extraModelIds = new Set(extras.flatMap((provider) => provider.models.map((model) => model.id))) - if (!extras.some((provider) => provider.id === "opencode-zen")) { - return base - } return base.filter((provider) => { - if (provider.id === "opencode-zen") return false - if (provider.id === "opencode" && provider.models.every((model) => extraModelIds.has(model.id))) { + // Remove base providers that have the same ID as an extra provider + // This prevents qwen-oauth, zai, ollama-cloud, antigravity duplicates + if (extraProviderIds.has(provider.id)) { + return false + } + // Special case: remove opencode if opencode-zen is present and covers all models + if (provider.id === "opencode" && extraProviderIds.has("opencode-zen") && + provider.models.every((model) => extraModelIds.has(model.id))) { + return false + } + // Remove any qwen-related SDK providers when qwen-oauth is present + if (extraProviderIds.has("qwen-oauth") && + (provider.id.toLowerCase().includes("qwen") || + provider.models.some((m) => m.id.toLowerCase().includes("qwen")))) { return false } return true }) } + interface SessionForkResponse { id: string title?: string