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
|
* Uses Google OAuth credentials stored via the Antigravity OAuth flow
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
// Configuration schema for Antigravity
|
// Configuration schema for Antigravity
|
||||||
@@ -18,9 +19,9 @@ export const AntigravityConfigSchema = z.object({
|
|||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
// Multiple endpoints for automatic fallback (daily → autopush → prod)
|
// Multiple endpoints for automatic fallback (daily → autopush → prod)
|
||||||
endpoints: z.array(z.string()).default([
|
endpoints: z.array(z.string()).default([
|
||||||
"https://daily.antigravity.dev/v1beta",
|
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||||
"https://autopush.antigravity.dev/v1beta",
|
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||||
"https://antigravity.dev/v1beta"
|
"https://cloudcode-pa.googleapis.com"
|
||||||
]),
|
]),
|
||||||
apiKey: z.string().optional()
|
apiKey: z.string().optional()
|
||||||
})
|
})
|
||||||
@@ -217,6 +218,31 @@ export const ANTIGRAVITY_MODELS: AntigravityModel[] = [
|
|||||||
|
|
||||||
// Token storage key for Antigravity OAuth
|
// Token storage key for Antigravity OAuth
|
||||||
const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token"
|
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 {
|
export interface AntigravityToken {
|
||||||
access_token: string
|
access_token: string
|
||||||
@@ -226,11 +252,55 @@ export interface AntigravityToken {
|
|||||||
project_id?: string
|
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 {
|
export class AntigravityClient {
|
||||||
private config: AntigravityConfig
|
private config: AntigravityConfig
|
||||||
private currentEndpointIndex: number = 0
|
private currentEndpointIndex: number = 0
|
||||||
private modelsCache: AntigravityModel[] | null = null
|
private modelsCache: AntigravityModel[] | null = null
|
||||||
private modelsCacheTime: number = 0
|
private modelsCacheTime: number = 0
|
||||||
|
private projectIdCache: string | null = null
|
||||||
private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
constructor(config?: Partial<AntigravityConfig>) {
|
constructor(config?: Partial<AntigravityConfig>) {
|
||||||
@@ -280,10 +350,17 @@ export class AntigravityClient {
|
|||||||
/**
|
/**
|
||||||
* Get authorization headers for API requests
|
* Get authorization headers for API requests
|
||||||
*/
|
*/
|
||||||
private getAuthHeaders(): Record<string, string> {
|
private getAuthHeaders(accessToken?: string): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"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
|
// Try OAuth token first
|
||||||
@@ -297,152 +374,129 @@ export class AntigravityClient {
|
|||||||
return headers
|
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
|
* Check if the client is authenticated
|
||||||
*/
|
*/
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(accessToken?: string): boolean {
|
||||||
|
if (accessToken) return true
|
||||||
const token = this.getStoredToken()
|
const token = this.getStoredToken()
|
||||||
return this.isTokenValid(token) || Boolean(this.config.apiKey)
|
return this.isTokenValid(token) || Boolean(this.config.apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async resolveProjectId(accessToken: string | undefined, projectIdOverride?: string): Promise<string> {
|
||||||
* Get available Antigravity models
|
const requestedProjectId = projectIdOverride?.trim()
|
||||||
*/
|
if (this.projectIdCache && !requestedProjectId) return this.projectIdCache
|
||||||
async getModels(): Promise<AntigravityModel[]> {
|
if (!accessToken) {
|
||||||
// Return cached models if still valid
|
const fallback = requestedProjectId || generateSyntheticProjectId()
|
||||||
const now = Date.now()
|
if (requestedProjectId) {
|
||||||
if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) {
|
this.projectIdCache = requestedProjectId
|
||||||
return this.modelsCache
|
}
|
||||||
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// If authenticated, return full model list
|
const loadEndpoints = Array.from(new Set([...LOAD_ENDPOINTS, ...this.config.endpoints]))
|
||||||
if (this.isAuthenticated()) {
|
const tryLoad = async (metadata: Record<string, string>): Promise<string | null> => {
|
||||||
this.modelsCache = ANTIGRAVITY_MODELS
|
for (const endpoint of loadEndpoints) {
|
||||||
this.modelsCacheTime = now
|
try {
|
||||||
return ANTIGRAVITY_MODELS
|
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||||
}
|
method: "POST",
|
||||||
|
headers: this.getLoadHeaders(accessToken),
|
||||||
// Not authenticated - return empty list
|
body: JSON.stringify({ metadata }),
|
||||||
return []
|
signal: AbortSignal.timeout(10000),
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
})
|
})
|
||||||
})
|
if (!response.ok) continue
|
||||||
|
const data = await response.json() as any
|
||||||
if (!response.ok) {
|
const projectId =
|
||||||
const errorText = await response.text()
|
data?.cloudaicompanionProject?.id ||
|
||||||
if (response.status === 401 || response.status === 403) {
|
data?.cloudaicompanionProject ||
|
||||||
throw new Error(`Antigravity authentication failed: ${errorText}`)
|
data?.projectId
|
||||||
|
if (typeof projectId === "string" && projectId.length > 0) {
|
||||||
|
return projectId
|
||||||
}
|
}
|
||||||
// Try next endpoint for other errors
|
} catch {
|
||||||
this.rotateEndpoint()
|
|
||||||
lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.body) {
|
let resolvedProjectId: string | null = null
|
||||||
throw new Error("Response body is missing")
|
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,
|
||||||
}
|
}
|
||||||
|
resolvedProjectId = await tryLoad(fallbackMetadata)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastError || new Error("Antigravity: All endpoints failed")
|
const fallbackProjectId = requestedProjectId || DEFAULT_PROJECT_ID
|
||||||
|
const finalProjectId = resolvedProjectId || fallbackProjectId
|
||||||
|
this.projectIdCache = finalProjectId
|
||||||
|
return finalProjectId
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private resolveAccessToken(accessToken?: string): string | null {
|
||||||
* Chat completion (non-streaming)
|
if (accessToken) return accessToken
|
||||||
*/
|
const token = this.getStoredToken()
|
||||||
async chat(request: ChatRequest): Promise<ChatChunk> {
|
if (token && this.isTokenValid(token)) {
|
||||||
if (!this.isAuthenticated()) {
|
return token.access_token
|
||||||
throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.")
|
|
||||||
}
|
}
|
||||||
|
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
|
let lastError: Error | null = null
|
||||||
const maxRetries = this.config.endpoints.length
|
const maxRetries = this.config.endpoints.length
|
||||||
@@ -450,13 +504,11 @@ export class AntigravityClient {
|
|||||||
for (let retry = 0; retry < maxRetries; retry++) {
|
for (let retry = 0; retry < maxRetries; retry++) {
|
||||||
try {
|
try {
|
||||||
const endpoint = this.getEndpoint()
|
const endpoint = this.getEndpoint()
|
||||||
const response = await fetch(`${endpoint}/chat/completions`, {
|
const response = await fetch(`${endpoint}/v1internal:${GENERATE_ACTION}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: this.getAuthHeaders(),
|
headers: this.getAuthHeaders(authToken),
|
||||||
body: JSON.stringify({
|
body,
|
||||||
...request,
|
signal: AbortSignal.timeout(120000)
|
||||||
stream: false
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -469,7 +521,8 @@ export class AntigravityClient {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json()
|
const data = await response.json()
|
||||||
|
return extractTextFromResponse(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error : new Error(String(error))
|
lastError = error instanceof Error ? error : new Error(String(error))
|
||||||
if (error instanceof Error && error.message.includes("authentication")) {
|
if (error instanceof Error && error.message.includes("authentication")) {
|
||||||
@@ -481,15 +534,142 @@ export class AntigravityClient {
|
|||||||
|
|
||||||
throw lastError || new Error("Antigravity: All endpoints failed")
|
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 {
|
export function getDefaultAntigravityConfig(): AntigravityConfig {
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
endpoints: [
|
endpoints: [
|
||||||
"https://daily.antigravity.dev/v1beta",
|
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||||
"https://autopush.antigravity.dev/v1beta",
|
"https://autopush-cloudcode-pa.sandbox.googleapis.com",
|
||||||
"https://antigravity.dev/v1beta"
|
"https://cloudcode-pa.googleapis.com"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { createHash, randomBytes, randomUUID } from "crypto"
|
||||||
|
import { createServer } from "http"
|
||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { AntigravityClient, type ChatRequest, getDefaultAntigravityConfig, type ChatMessage } from "../../integrations/antigravity"
|
import { AntigravityClient, type ChatRequest, getDefaultAntigravityConfig, type ChatMessage } from "../../integrations/antigravity"
|
||||||
import { Logger } from "../../logger"
|
import { Logger } from "../../logger"
|
||||||
@@ -11,29 +13,202 @@ interface AntigravityRouteDeps {
|
|||||||
// Maximum number of tool execution loops
|
// Maximum number of tool execution loops
|
||||||
const MAX_TOOL_LOOPS = 10
|
const MAX_TOOL_LOOPS = 10
|
||||||
|
|
||||||
// Google OAuth Device Flow configuration
|
// Google OAuth Authorization Code + PKCE configuration (Antigravity-compatible)
|
||||||
// Using the same client ID as gcloud CLI / Cloud SDK
|
|
||||||
const GOOGLE_OAUTH_CONFIG = {
|
const GOOGLE_OAUTH_CONFIG = {
|
||||||
clientId: "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
|
clientId: process.env.ANTIGRAVITY_GOOGLE_CLIENT_ID || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||||
clientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty", // Public client secret for device flow
|
clientSecret: process.env.ANTIGRAVITY_GOOGLE_CLIENT_SECRET || "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",
|
||||||
deviceAuthEndpoint: "https://oauth2.googleapis.com/device/code",
|
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",
|
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||||
scopes: [
|
scopes: [
|
||||||
"openid",
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
"email",
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
"profile",
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
"https://www.googleapis.com/auth/cloud-platform"
|
"https://www.googleapis.com/auth/cclog",
|
||||||
]
|
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active device auth sessions (in-memory, per-server instance)
|
const AUTH_SESSION_TTL_MS = 10 * 60 * 1000
|
||||||
const deviceAuthSessions = new Map<string, {
|
const DEFAULT_POLL_INTERVAL_SEC = 5
|
||||||
deviceCode: string
|
const callbackUrl = new URL(GOOGLE_OAUTH_CONFIG.redirectUri)
|
||||||
userCode: string
|
const callbackPath = callbackUrl.pathname || "/oauth-callback"
|
||||||
verificationUrl: string
|
const callbackPort = Number(callbackUrl.port || "0") || (callbackUrl.protocol === "https:" ? 443 : 80)
|
||||||
|
|
||||||
|
type OAuthSession = {
|
||||||
|
verifier: string
|
||||||
|
createdAt: number
|
||||||
expiresAt: 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(
|
export async function registerAntigravityRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -47,7 +222,8 @@ export async function registerAntigravityRoutes(
|
|||||||
// List available Antigravity models
|
// List available Antigravity models
|
||||||
app.get('/api/antigravity/models', async (request, reply) => {
|
app.get('/api/antigravity/models', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const models = await client.getModels()
|
const accessToken = getAccessTokenFromHeader(request.headers.authorization)
|
||||||
|
const models = await client.getModels(accessToken ?? undefined)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
models: models.map(m => ({
|
models: models.map(m => ({
|
||||||
@@ -70,7 +246,8 @@ export async function registerAntigravityRoutes(
|
|||||||
// Check authentication status
|
// Check authentication status
|
||||||
app.get('/api/antigravity/auth-status', async (request, reply) => {
|
app.get('/api/antigravity/auth-status', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const authenticated = client.isAuthenticated()
|
const accessToken = getAccessTokenFromHeader(request.headers.authorization)
|
||||||
|
const authenticated = client.isAuthenticated(accessToken ?? undefined)
|
||||||
return { authenticated }
|
return { authenticated }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, "Antigravity auth status check failed")
|
logger.error({ error }, "Antigravity auth status check failed")
|
||||||
@@ -81,8 +258,10 @@ export async function registerAntigravityRoutes(
|
|||||||
// Test connection
|
// Test connection
|
||||||
app.get('/api/antigravity/test', async (request, reply) => {
|
app.get('/api/antigravity/test', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const connected = await client.testConnection()
|
const accessToken = getAccessTokenFromHeader(request.headers.authorization)
|
||||||
return { connected }
|
const projectId = getProjectIdFromHeader(request.headers["x-antigravity-project"])
|
||||||
|
const result = await client.testConnection(accessToken ?? undefined, projectId)
|
||||||
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, "Antigravity connection test failed")
|
logger.error({ error }, "Antigravity connection test failed")
|
||||||
return reply.status(500).send({ error: "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) => {
|
app.post('/api/antigravity/device-auth/start', async (request, reply) => {
|
||||||
try {
|
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, {
|
const sessionId = randomUUID()
|
||||||
method: 'POST',
|
const verifier = createCodeVerifier()
|
||||||
headers: {
|
const challenge = createCodeChallenge(verifier)
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
},
|
const authUrl = new URL(GOOGLE_OAUTH_CONFIG.authEndpoint)
|
||||||
body: new URLSearchParams({
|
authUrl.searchParams.set("client_id", GOOGLE_OAUTH_CONFIG.clientId)
|
||||||
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
authUrl.searchParams.set("response_type", "code")
|
||||||
scope: GOOGLE_OAUTH_CONFIG.scopes.join(' ')
|
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,
|
||||||
})
|
})
|
||||||
|
cleanupExpiredSessions()
|
||||||
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")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
userCode: data.user_code,
|
userCode: "",
|
||||||
verificationUrl: data.verification_url,
|
verificationUrl: authUrl.toString(),
|
||||||
expiresIn: data.expires_in,
|
expiresIn: Math.floor(AUTH_SESSION_TTL_MS / 1000),
|
||||||
interval: data.interval
|
interval: DEFAULT_POLL_INTERVAL_SEC,
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} 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({
|
return reply.status(500).send({
|
||||||
error: "Failed to start device authorization",
|
error: "Failed to start authentication",
|
||||||
details: error.message
|
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) => {
|
app.post('/api/antigravity/device-auth/poll', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const { sessionId } = request.body as { sessionId: string }
|
const { sessionId } = request.body as { sessionId: string }
|
||||||
@@ -172,68 +325,36 @@ export async function registerAntigravityRoutes(
|
|||||||
return reply.status(400).send({ error: "Missing sessionId" })
|
return reply.status(400).send({ error: "Missing sessionId" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = deviceAuthSessions.get(sessionId)
|
cleanupExpiredSessions()
|
||||||
|
const session = oauthSessions.get(sessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return reply.status(404).send({ error: "Session not found or expired" })
|
return reply.status(404).send({ error: "Session not found or expired" })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.expiresAt < Date.now()) {
|
if (session.expiresAt < Date.now()) {
|
||||||
deviceAuthSessions.delete(sessionId)
|
oauthSessions.delete(sessionId)
|
||||||
return reply.status(410).send({ error: "Session expired" })
|
return reply.status(410).send({ error: "Session expired" })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll Google's token endpoint
|
if (session.error) {
|
||||||
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
|
oauthSessions.delete(sessionId)
|
||||||
method: 'POST',
|
return { status: "error", error: session.error }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success! We have tokens
|
if (!session.token) {
|
||||||
deviceAuthSessions.delete(sessionId)
|
return { status: "pending", interval: DEFAULT_POLL_INTERVAL_SEC }
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("Device authorization successful")
|
const token = session.token
|
||||||
|
oauthSessions.delete(sessionId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'success',
|
status: "success",
|
||||||
accessToken: data.access_token,
|
accessToken: token.accessToken,
|
||||||
refreshToken: data.refresh_token,
|
refreshToken: token.refreshToken,
|
||||||
expiresIn: data.expires_in,
|
expiresIn: token.expiresIn,
|
||||||
tokenType: data.token_type,
|
tokenType: token.tokenType,
|
||||||
scope: data.scope
|
scope: token.scope,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, "Failed to poll for token")
|
logger.error({ error }, "Failed to poll for token")
|
||||||
@@ -250,17 +371,21 @@ export async function registerAntigravityRoutes(
|
|||||||
return reply.status(400).send({ error: "Missing refreshToken" })
|
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, {
|
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: params
|
||||||
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
|
||||||
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
grant_type: 'refresh_token'
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -289,6 +414,8 @@ export async function registerAntigravityRoutes(
|
|||||||
workspacePath?: string
|
workspacePath?: string
|
||||||
enableTools?: boolean
|
enableTools?: boolean
|
||||||
}
|
}
|
||||||
|
const accessToken = getAccessTokenFromHeader(request.headers.authorization)
|
||||||
|
const projectId = getProjectIdFromHeader(request.headers["x-antigravity-project"])
|
||||||
|
|
||||||
// Extract workspace path for tool execution
|
// Extract workspace path for tool execution
|
||||||
const workspacePath = chatRequest.workspacePath || process.cwd()
|
const workspacePath = chatRequest.workspacePath || process.cwd()
|
||||||
@@ -313,6 +440,8 @@ export async function registerAntigravityRoutes(
|
|||||||
await streamWithToolLoop(
|
await streamWithToolLoop(
|
||||||
client,
|
client,
|
||||||
chatRequest,
|
chatRequest,
|
||||||
|
accessToken ?? undefined,
|
||||||
|
projectId,
|
||||||
workspacePath,
|
workspacePath,
|
||||||
enableTools,
|
enableTools,
|
||||||
reply.raw,
|
reply.raw,
|
||||||
@@ -329,6 +458,8 @@ export async function registerAntigravityRoutes(
|
|||||||
const response = await chatWithToolLoop(
|
const response = await chatWithToolLoop(
|
||||||
client,
|
client,
|
||||||
chatRequest,
|
chatRequest,
|
||||||
|
accessToken ?? undefined,
|
||||||
|
projectId,
|
||||||
workspacePath,
|
workspacePath,
|
||||||
enableTools,
|
enableTools,
|
||||||
logger
|
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(
|
async function streamWithToolLoop(
|
||||||
client: AntigravityClient,
|
client: AntigravityClient,
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
|
accessToken: string | undefined,
|
||||||
|
projectId: string | undefined,
|
||||||
workspacePath: string,
|
workspacePath: string,
|
||||||
enableTools: boolean,
|
enableTools: boolean,
|
||||||
rawResponse: any,
|
rawResponse: any,
|
||||||
@@ -390,7 +523,7 @@ async function streamWithToolLoop(
|
|||||||
let textContent = ""
|
let textContent = ""
|
||||||
|
|
||||||
// Stream response
|
// 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
|
// Write chunk to client
|
||||||
rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||||
|
|
||||||
@@ -491,6 +624,8 @@ async function streamWithToolLoop(
|
|||||||
async function chatWithToolLoop(
|
async function chatWithToolLoop(
|
||||||
client: AntigravityClient,
|
client: AntigravityClient,
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
|
accessToken: string | undefined,
|
||||||
|
projectId: string | undefined,
|
||||||
workspacePath: string,
|
workspacePath: string,
|
||||||
enableTools: boolean,
|
enableTools: boolean,
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -509,7 +644,7 @@ async function chatWithToolLoop(
|
|||||||
while (loopCount < MAX_TOOL_LOOPS) {
|
while (loopCount < MAX_TOOL_LOOPS) {
|
||||||
loopCount++
|
loopCount++
|
||||||
|
|
||||||
const response = await client.chat({ ...requestWithTools, messages, stream: false })
|
const response = await client.chat({ ...requestWithTools, messages, stream: false }, accessToken, projectId)
|
||||||
lastResponse = response
|
lastResponse = response
|
||||||
|
|
||||||
const choice = response.choices[0]
|
const choice = response.choices[0]
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function normalizeQwenModel(model?: string): string {
|
|||||||
const raw = (model || "").trim()
|
const raw = (model || "").trim()
|
||||||
if (!raw) return "coder-model"
|
if (!raw) return "coder-model"
|
||||||
const lower = raw.toLowerCase()
|
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 === "vision-model" || lower.includes("vision")) return "vision-model"
|
||||||
if (lower === "coder-model") return "coder-model"
|
if (lower === "coder-model") return "coder-model"
|
||||||
if (lower.includes("coder")) return "coder-model"
|
if (lower.includes("coder")) return "coder-model"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
sessions,
|
sessions,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
|
setActiveParentSession,
|
||||||
executeCustomCommand,
|
executeCustomCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
runShellCommand,
|
runShellCommand,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, createSignal, onMount, onCleanup, For, Show } from 'solid-js'
|
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 { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield, ExternalLink, Copy } from 'lucide-solid'
|
||||||
import { getUserScopedKey } from '../../lib/user-storage'
|
import { getUserScopedKey } from '../../lib/user-storage'
|
||||||
|
import { instances } from '../../stores/instances'
|
||||||
|
import { fetchProviders } from '../../stores/session-api'
|
||||||
|
|
||||||
interface AntigravityModel {
|
interface AntigravityModel {
|
||||||
id: string
|
id: string
|
||||||
@@ -22,19 +24,22 @@ interface AntigravityToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token"
|
const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token"
|
||||||
|
const ANTIGRAVITY_PROJECT_KEY = "antigravity_project_id"
|
||||||
|
|
||||||
const AntigravitySettings: Component = () => {
|
const AntigravitySettings: Component = () => {
|
||||||
const [models, setModels] = createSignal<AntigravityModel[]>([])
|
const [models, setModels] = createSignal<AntigravityModel[]>([])
|
||||||
const [isLoading, setIsLoading] = createSignal(true)
|
const [isLoading, setIsLoading] = createSignal(true)
|
||||||
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
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 [authStatus, setAuthStatus] = createSignal<'unknown' | 'authenticated' | 'unauthenticated'>('unknown')
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [projectId, setProjectId] = createSignal("")
|
||||||
|
|
||||||
// Device auth state
|
// Device auth state
|
||||||
const [isAuthenticating, setIsAuthenticating] = createSignal(false)
|
const [isAuthenticating, setIsAuthenticating] = createSignal(false)
|
||||||
const [deviceAuthSession, setDeviceAuthSession] = createSignal<{
|
const [deviceAuthSession, setDeviceAuthSession] = createSignal<{
|
||||||
sessionId: string
|
sessionId: string
|
||||||
userCode: string
|
userCode?: string
|
||||||
verificationUrl: string
|
verificationUrl: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
@@ -43,6 +48,10 @@ const AntigravitySettings: Component = () => {
|
|||||||
|
|
||||||
// Check stored token on mount
|
// Check stored token on mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
const storedProjectId = window.localStorage.getItem(getUserScopedKey(ANTIGRAVITY_PROJECT_KEY))
|
||||||
|
if (storedProjectId) {
|
||||||
|
setProjectId(storedProjectId)
|
||||||
|
}
|
||||||
checkAuthStatus()
|
checkAuthStatus()
|
||||||
await loadModels()
|
await loadModels()
|
||||||
await testConnection()
|
await testConnection()
|
||||||
@@ -72,6 +81,48 @@ const AntigravitySettings: Component = () => {
|
|||||||
return Date.now() < expiresAt
|
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 checkAuthStatus = () => {
|
||||||
const token = getStoredToken()
|
const token = getStoredToken()
|
||||||
if (isTokenValid(token)) {
|
if (isTokenValid(token)) {
|
||||||
@@ -84,7 +135,9 @@ const AntigravitySettings: Component = () => {
|
|||||||
const loadModels = async () => {
|
const loadModels = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/antigravity/models')
|
const response = await fetch('/api/antigravity/models', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setModels(data.models || [])
|
setModels(data.models || [])
|
||||||
@@ -102,12 +155,24 @@ const AntigravitySettings: Component = () => {
|
|||||||
|
|
||||||
const testConnection = async () => {
|
const testConnection = async () => {
|
||||||
setConnectionStatus('testing')
|
setConnectionStatus('testing')
|
||||||
|
setConnectionIssue(null)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/antigravity/test')
|
const response = await fetch('/api/antigravity/test', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setConnectionStatus(data.connected ? 'connected' : 'failed')
|
setConnectionStatus(data.connected ? 'connected' : 'failed')
|
||||||
|
const issue = parseSubscriptionIssue(data.error)
|
||||||
|
if (issue) {
|
||||||
|
setConnectionIssue(issue)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const errorText = await response.text().catch(() => "")
|
||||||
|
const issue = parseSubscriptionIssue(errorText)
|
||||||
|
if (issue) {
|
||||||
|
setConnectionIssue(issue)
|
||||||
|
}
|
||||||
setConnectionStatus('failed')
|
setConnectionStatus('failed')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -115,6 +180,8 @@ const AntigravitySettings: Component = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offlineLabel = () => connectionIssue()?.title ?? "Offline"
|
||||||
|
|
||||||
// Start device authorization flow
|
// Start device authorization flow
|
||||||
const startDeviceAuth = async () => {
|
const startDeviceAuth = async () => {
|
||||||
setIsAuthenticating(true)
|
setIsAuthenticating(true)
|
||||||
@@ -127,12 +194,14 @@ const AntigravitySettings: Component = () => {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
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 {
|
const data = await response.json() as {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
userCode: string
|
userCode?: string
|
||||||
verificationUrl: string
|
verificationUrl: string
|
||||||
expiresIn: number
|
expiresIn: number
|
||||||
interval: number
|
interval: number
|
||||||
@@ -140,7 +209,7 @@ const AntigravitySettings: Component = () => {
|
|||||||
|
|
||||||
setDeviceAuthSession({
|
setDeviceAuthSession({
|
||||||
sessionId: data.sessionId,
|
sessionId: data.sessionId,
|
||||||
userCode: data.userCode,
|
userCode: data.userCode || "",
|
||||||
verificationUrl: data.verificationUrl
|
verificationUrl: data.verificationUrl
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -210,6 +279,14 @@ const AntigravitySettings: Component = () => {
|
|||||||
setAuthStatus('authenticated')
|
setAuthStatus('authenticated')
|
||||||
setError(null)
|
setError(null)
|
||||||
loadModels()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,11 +331,18 @@ const AntigravitySettings: Component = () => {
|
|||||||
const signOut = () => {
|
const signOut = () => {
|
||||||
window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
|
window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
|
||||||
setAuthStatus('unauthenticated')
|
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 copyCode = async () => {
|
||||||
const session = deviceAuthSession()
|
const session = deviceAuthSession()
|
||||||
if (session) {
|
if (session?.userCode) {
|
||||||
await navigator.clipboard.writeText(session.userCode)
|
await navigator.clipboard.writeText(session.userCode)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setTimeout(() => setCopied(false), 2000)
|
||||||
@@ -308,7 +392,7 @@ const AntigravitySettings: Component = () => {
|
|||||||
{connectionStatus() === 'failed' && (
|
{connectionStatus() === 'failed' && (
|
||||||
<span class="flex items-center gap-2 text-sm text-red-400">
|
<span class="flex items-center gap-2 text-sm text-red-400">
|
||||||
<XCircle class="w-4 h-4" />
|
<XCircle class="w-4 h-4" />
|
||||||
Offline
|
{offlineLabel()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -386,21 +470,30 @@ const AntigravitySettings: Component = () => {
|
|||||||
<Show when={deviceAuthSession()}>
|
<Show when={deviceAuthSession()}>
|
||||||
<div class="bg-purple-500/10 border border-purple-500/30 rounded-lg p-4 space-y-4">
|
<div class="bg-purple-500/10 border border-purple-500/30 rounded-lg p-4 space-y-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-zinc-300 mb-3">
|
<Show
|
||||||
Enter this code on the Google sign-in page:
|
when={Boolean(deviceAuthSession()?.userCode)}
|
||||||
</p>
|
fallback={
|
||||||
<div class="flex items-center justify-center gap-3">
|
<p class="text-sm text-zinc-300">
|
||||||
<code class="px-6 py-3 bg-zinc-900 rounded-lg text-2xl font-mono font-bold text-white tracking-widest">
|
Complete the sign-in in the browser window.
|
||||||
{deviceAuthSession()?.userCode}
|
</p>
|
||||||
</code>
|
}
|
||||||
<button
|
>
|
||||||
onClick={copyCode}
|
<p class="text-sm text-zinc-300 mb-3">
|
||||||
class="p-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
|
Enter this code on the Google sign-in page:
|
||||||
title="Copy code"
|
</p>
|
||||||
>
|
<div class="flex items-center justify-center gap-3">
|
||||||
{copied() ? <CheckCircle class="w-5 h-5 text-emerald-400" /> : <Copy class="w-5 h-5" />}
|
<code class="px-6 py-3 bg-zinc-900 rounded-lg text-2xl font-mono font-bold text-white tracking-widest">
|
||||||
</button>
|
{deviceAuthSession()?.userCode}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-2 text-sm text-purple-300">
|
<div class="flex items-center justify-center gap-2 text-sm text-purple-300">
|
||||||
@@ -428,6 +521,38 @@ const AntigravitySettings: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
@@ -437,6 +562,23 @@ const AntigravitySettings: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</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 */}
|
{/* Models Grid */}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -331,20 +331,32 @@ async function fetchExtraProviders(): Promise<Provider[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeDuplicateProviders(base: Provider[], extras: Provider[]): 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)))
|
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) => {
|
return base.filter((provider) => {
|
||||||
if (provider.id === "opencode-zen") return false
|
// Remove base providers that have the same ID as an extra provider
|
||||||
if (provider.id === "opencode" && provider.models.every((model) => extraModelIds.has(model.id))) {
|
// 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 false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface SessionForkResponse {
|
interface SessionForkResponse {
|
||||||
id: string
|
id: string
|
||||||
title?: string
|
title?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user