fix: prevent duplicate AI models in selector and fix TypeScript errors
Some checks failed
Release Binaries / release (push) Has been cancelled
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:
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getSessionInfo,
|
||||
sessions,
|
||||
setActiveSession,
|
||||
setActiveParentSession,
|
||||
executeCustomCommand,
|
||||
sendMessage,
|
||||
runShellCommand,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user