fix: prevent duplicate AI models in selector and fix TypeScript errors
Some checks failed
Release Binaries / release (push) Has been cancelled

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
This commit is contained in:
Gemini AI
2025-12-28 03:27:31 +04:00
Unverified
parent babce0e0a9
commit 38cb8bcb0c
6 changed files with 777 additions and 305 deletions

View File

@@ -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<AntigravityConfig>) {
@@ -280,10 +350,17 @@ export class AntigravityClient {
/**
* Get authorization headers for API requests
*/
private getAuthHeaders(): Record<string, string> {
private getAuthHeaders(accessToken?: string): Record<string, string> {
const headers: Record<string, string> = {
"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<string, string> {
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<AntigravityModel[]> {
// 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<string> {
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<boolean> {
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<ChatChunk> {
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<string, string>): Promise<string | null> => {
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<string, string> = { ...LOAD_ASSIST_METADATA }
if (requestedProjectId) {
baseMetadata.duetProject = requestedProjectId
resolvedProjectId = await tryLoad(baseMetadata)
} else {
resolvedProjectId = await tryLoad(baseMetadata)
if (!resolvedProjectId) {
const fallbackMetadata: Record<string, string> = {
...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<ChatChunk> {
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<string> {
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<string, unknown> = {}
if (typeof request.temperature === "number") {
generationConfig.temperature = request.temperature
}
if (typeof request.max_tokens === "number") {
generationConfig.maxOutputTokens = request.max_tokens
}
const requestPayload: Record<string, unknown> = { 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<AntigravityModel[]> {
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<string, string>): Promise<boolean> => {
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<string, string> = { ...LOAD_ASSIST_METADATA }
if (requestedProjectId) {
baseMetadata.duetProject = requestedProjectId
}
let success = await tryLoad(baseMetadata)
if (!success && !requestedProjectId) {
const fallbackMetadata: Record<string, string> = {
...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<ChatChunk> {
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<ChatChunk> {
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"
]
}
}

View File

@@ -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<string, {
deviceCode: string
userCode: string
verificationUrl: string
const AUTH_SESSION_TTL_MS = 10 * 60 * 1000
const DEFAULT_POLL_INTERVAL_SEC = 5
const callbackUrl = new URL(GOOGLE_OAUTH_CONFIG.redirectUri)
const callbackPath = callbackUrl.pathname || "/oauth-callback"
const callbackPort = Number(callbackUrl.port || "0") || (callbackUrl.protocol === "https:" ? 443 : 80)
type OAuthSession = {
verifier: string
createdAt: number
expiresAt: number
interval: number
}>()
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<string, OAuthSession>()
let oauthCallbackServer: ReturnType<typeof createServer> | 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("<h2>Sign-in cancelled.</h2><p>You can close this window.</p>")
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("<h2>Sign-in complete.</h2><p>You can close this window and return to the app.</p>")
} 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]

View File

@@ -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"

View File

@@ -36,6 +36,7 @@ import {
getSessionInfo,
sessions,
setActiveSession,
setActiveParentSession,
executeCustomCommand,
sendMessage,
runShellCommand,

View File

@@ -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<AntigravityModel[]>([])
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<string | null>(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<string, string> = {}
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' && (
<span class="flex items-center gap-2 text-sm text-red-400">
<XCircle class="w-4 h-4" />
Offline
{offlineLabel()}
</span>
)}
</div>
@@ -386,21 +470,30 @@ const AntigravitySettings: Component = () => {
<Show when={deviceAuthSession()}>
<div class="bg-purple-500/10 border border-purple-500/30 rounded-lg p-4 space-y-4">
<div class="text-center">
<p class="text-sm text-zinc-300 mb-3">
Enter this code on the Google sign-in page:
</p>
<div class="flex items-center justify-center gap-3">
<code class="px-6 py-3 bg-zinc-900 rounded-lg text-2xl font-mono font-bold text-white tracking-widest">
{deviceAuthSession()?.userCode}
</code>
<button
onClick={copyCode}
class="p-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
title="Copy code"
>
{copied() ? <CheckCircle class="w-5 h-5 text-emerald-400" /> : <Copy class="w-5 h-5" />}
</button>
</div>
<Show
when={Boolean(deviceAuthSession()?.userCode)}
fallback={
<p class="text-sm text-zinc-300">
Complete the sign-in in the browser window.
</p>
}
>
<p class="text-sm text-zinc-300 mb-3">
Enter this code on the Google sign-in page:
</p>
<div class="flex items-center justify-center gap-3">
<code class="px-6 py-3 bg-zinc-900 rounded-lg text-2xl font-mono font-bold text-white tracking-widest">
{deviceAuthSession()?.userCode}
</code>
<button
onClick={copyCode}
class="p-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
title="Copy code"
>
{copied() ? <CheckCircle class="w-5 h-5 text-emerald-400" /> : <Copy class="w-5 h-5" />}
</button>
</div>
</Show>
</div>
<div class="flex items-center justify-center gap-2 text-sm text-purple-300">
@@ -428,6 +521,38 @@ const AntigravitySettings: Component = () => {
</div>
</Show>
</Show>
<div class="mt-4 flex flex-col gap-2 text-sm text-zinc-400">
<label class="text-xs uppercase tracking-wide text-zinc-500">Project ID (optional)</label>
<input
type="text"
value={projectId()}
onInput={(event) => {
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"
/>
<p class="text-xs text-zinc-500">
Set this only if your account is tied to a specific Code Assist project.
</p>
<button
type="button"
onClick={() => testConnection()}
class="w-fit px-3 py-1.5 text-xs bg-zinc-800 hover:bg-zinc-700 rounded-lg text-zinc-200"
>
Re-check connection
</button>
</div>
</div>
{/* Error Display */}
@@ -437,6 +562,23 @@ const AntigravitySettings: Component = () => {
</div>
</Show>
<Show when={connectionIssue()}>
<div class="p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg text-amber-200 text-sm space-y-2">
<div class="font-semibold">{connectionIssue()?.title}</div>
<div>{connectionIssue()?.message}</div>
<Show when={connectionIssue()?.link}>
<a
href={connectionIssue()?.link}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-amber-300 hover:text-amber-200 underline"
>
Learn more
</a>
</Show>
</div>
</Show>
{/* Models Grid */}
<div class="space-y-4">
<div class="flex items-center justify-between">

View File

@@ -331,20 +331,32 @@ async function fetchExtraProviders(): Promise<Provider[]> {
}
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