diff --git a/packages/opencode-config/opencode.jsonc b/packages/opencode-config/opencode.jsonc index c3eb6a5..b6272bd 100644 --- a/packages/opencode-config/opencode.jsonc +++ b/packages/opencode-config/opencode.jsonc @@ -1,3 +1,204 @@ { - "$schema": "https://opencode.ai/config.json" + "$schema": "https://opencode.ai/config.json", + // Antigravity plugin for Google OAuth (Gemini + Claude models via Antigravity) + "plugin": [ + "opencode-antigravity-auth@1.2.6" + ], + "provider": { + // Antigravity models (via Google OAuth) + "google": { + "models": { + // Gemini Models + "gemini-3-pro-low": { + "name": "Gemini 3 Pro Low (Antigravity)", + "limit": { + "context": 1048576, + "output": 65535 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "gemini-3-pro-high": { + "name": "Gemini 3 Pro High (Antigravity)", + "limit": { + "context": 1048576, + "output": 65535 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "gemini-3-flash": { + "name": "Gemini 3 Flash (Antigravity)", + "limit": { + "context": 1048576, + "output": 65536 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + // Claude Models (via Antigravity) + "claude-sonnet-4-5": { + "name": "Claude Sonnet 4.5 (Antigravity)", + "limit": { + "context": 200000, + "output": 64000 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "claude-sonnet-4-5-thinking-low": { + "name": "Claude Sonnet 4.5 Thinking Low (Antigravity)", + "limit": { + "context": 200000, + "output": 64000 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "claude-sonnet-4-5-thinking-medium": { + "name": "Claude Sonnet 4.5 Thinking Medium (Antigravity)", + "limit": { + "context": 200000, + "output": 64000 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "claude-sonnet-4-5-thinking-high": { + "name": "Claude Sonnet 4.5 Thinking High (Antigravity)", + "limit": { + "context": 200000, + "output": 64000 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "claude-opus-4-5-thinking-low": { + "name": "Claude Opus 4.5 Thinking Low (Antigravity)", + "limit": { + "context": 200000, + "output": 64000 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "claude-opus-4-5-thinking-medium": { + "name": "Claude Opus 4.5 Thinking Medium (Antigravity)", + "limit": { + "context": 200000, + "output": 64000 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + "claude-opus-4-5-thinking-high": { + "name": "Claude Opus 4.5 Thinking High (Antigravity)", + "limit": { + "context": 200000, + "output": 64000 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + }, + // Other Models + "gpt-oss-120b-medium": { + "name": "GPT-OSS 120B Medium (Antigravity)", + "limit": { + "context": 131072, + "output": 32768 + }, + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + } + } + } + } + } } \ No newline at end of file diff --git a/packages/server/src/integrations/antigravity.ts b/packages/server/src/integrations/antigravity.ts new file mode 100644 index 0000000..1eb70e3 --- /dev/null +++ b/packages/server/src/integrations/antigravity.ts @@ -0,0 +1,495 @@ +/** + * Antigravity API Integration for Binary-Free Mode + * Provides direct access to Antigravity models (Gemini, Claude, GPT-OSS) via Google OAuth + * Based on the opencode-antigravity-auth plugin: https://github.com/NoeFabris/opencode-antigravity-auth + * + * This integration enables access to: + * - Gemini 3 Pro/Flash models + * - Claude Sonnet 4.5 / Opus 4.5 (with thinking variants) + * - GPT-OSS 120B + * + * Uses Google OAuth credentials stored via the Antigravity OAuth flow + */ + +import { z } from "zod" + +// Configuration schema for Antigravity +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" + ]), + apiKey: z.string().optional() +}) + +export type AntigravityConfig = z.infer + +// Antigravity Model schema +export const AntigravityModelSchema = z.object({ + id: z.string(), + name: z.string(), + family: z.enum(["gemini", "claude", "gpt-oss"]).optional(), + reasoning: z.boolean().optional(), + tool_call: z.boolean().optional(), + limit: z.object({ + context: z.number(), + output: z.number() + }).optional() +}) + +export type AntigravityModel = z.infer + +// Chat message schema (OpenAI-compatible) +export const ChatMessageSchema = z.object({ + role: z.enum(["user", "assistant", "system", "tool"]), + content: z.string().optional(), + tool_calls: z.array(z.object({ + id: z.string(), + type: z.literal("function"), + function: z.object({ + name: z.string(), + arguments: z.string() + }) + })).optional(), + tool_call_id: z.string().optional() +}) + +export type ChatMessage = z.infer + +// Tool Definition Schema +export const ToolDefinitionSchema = z.object({ + type: z.literal("function"), + function: z.object({ + name: z.string(), + description: z.string(), + parameters: z.object({ + type: z.literal("object"), + properties: z.record(z.any()), + required: z.array(z.string()).optional() + }) + }) +}) + +export type ToolDefinition = z.infer + +// Chat request schema +export const ChatRequestSchema = z.object({ + model: z.string(), + messages: z.array(ChatMessageSchema), + stream: z.boolean().default(true), + temperature: z.number().optional(), + max_tokens: z.number().optional(), + tools: z.array(ToolDefinitionSchema).optional(), + tool_choice: z.union([ + z.literal("auto"), + z.literal("none"), + z.object({ + type: z.literal("function"), + function: z.object({ name: z.string() }) + }) + ]).optional(), + workspacePath: z.string().optional(), + enableTools: z.boolean().optional() +}) + +export type ChatRequest = z.infer + +// Chat response chunk schema +export const ChatChunkSchema = z.object({ + id: z.string().optional(), + object: z.string().optional(), + created: z.number().optional(), + model: z.string().optional(), + choices: z.array(z.object({ + index: z.number(), + delta: z.object({ + role: z.string().optional(), + content: z.string().optional(), + reasoning_content: z.string().optional() + }).optional(), + message: z.object({ + role: z.string(), + content: z.string() + }).optional(), + finish_reason: z.string().nullable().optional() + })) +}) + +export type ChatChunk = z.infer + +// Available Antigravity models with their specifications +export const ANTIGRAVITY_MODELS: AntigravityModel[] = [ + // Gemini Models + { + id: "gemini-3-pro-low", + name: "Gemini 3 Pro Low (Antigravity)", + family: "gemini", + reasoning: true, + tool_call: true, + limit: { context: 1048576, output: 65535 } + }, + { + id: "gemini-3-pro-high", + name: "Gemini 3 Pro High (Antigravity)", + family: "gemini", + reasoning: true, + tool_call: true, + limit: { context: 1048576, output: 65535 } + }, + { + id: "gemini-3-flash", + name: "Gemini 3 Flash (Antigravity)", + family: "gemini", + reasoning: false, + tool_call: true, + limit: { context: 1048576, output: 65536 } + }, + // Claude Models + { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5 (Antigravity)", + family: "claude", + reasoning: false, + tool_call: true, + limit: { context: 200000, output: 64000 } + }, + { + id: "claude-sonnet-4-5-thinking-low", + name: "Claude Sonnet 4.5 Thinking Low (Antigravity)", + family: "claude", + reasoning: true, + tool_call: true, + limit: { context: 200000, output: 64000 } + }, + { + id: "claude-sonnet-4-5-thinking-medium", + name: "Claude Sonnet 4.5 Thinking Medium (Antigravity)", + family: "claude", + reasoning: true, + tool_call: true, + limit: { context: 200000, output: 64000 } + }, + { + id: "claude-sonnet-4-5-thinking-high", + name: "Claude Sonnet 4.5 Thinking High (Antigravity)", + family: "claude", + reasoning: true, + tool_call: true, + limit: { context: 200000, output: 64000 } + }, + { + id: "claude-opus-4-5-thinking-low", + name: "Claude Opus 4.5 Thinking Low (Antigravity)", + family: "claude", + reasoning: true, + tool_call: true, + limit: { context: 200000, output: 64000 } + }, + { + id: "claude-opus-4-5-thinking-medium", + name: "Claude Opus 4.5 Thinking Medium (Antigravity)", + family: "claude", + reasoning: true, + tool_call: true, + limit: { context: 200000, output: 64000 } + }, + { + id: "claude-opus-4-5-thinking-high", + name: "Claude Opus 4.5 Thinking High (Antigravity)", + family: "claude", + reasoning: true, + tool_call: true, + limit: { context: 200000, output: 64000 } + }, + // Other Models + { + id: "gpt-oss-120b-medium", + name: "GPT-OSS 120B Medium (Antigravity)", + family: "gpt-oss", + reasoning: true, + tool_call: true, + limit: { context: 131072, output: 32768 } + } +] + +// Token storage key for Antigravity OAuth +const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token" + +export interface AntigravityToken { + access_token: string + refresh_token?: string + expires_in: number + created_at: number + project_id?: string +} + +export class AntigravityClient { + private config: AntigravityConfig + private currentEndpointIndex: number = 0 + private modelsCache: AntigravityModel[] | null = null + private modelsCacheTime: number = 0 + private readonly CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes + + constructor(config?: Partial) { + this.config = AntigravityConfigSchema.parse(config || {}) + } + + /** + * Get the current active endpoint with automatic fallback + */ + private getEndpoint(): string { + const endpoints = this.config.endpoints + return endpoints[this.currentEndpointIndex] || endpoints[0] + } + + /** + * Rotate to next endpoint on failure + */ + private rotateEndpoint(): void { + this.currentEndpointIndex = (this.currentEndpointIndex + 1) % this.config.endpoints.length + console.log(`Antigravity: Rotating to endpoint ${this.getEndpoint()}`) + } + + /** + * Get stored OAuth token from localStorage (browser context) + */ + getStoredToken(): AntigravityToken | null { + if (typeof window === "undefined") return null + try { + const raw = window.localStorage.getItem(ANTIGRAVITY_TOKEN_KEY) + if (!raw) return null + return JSON.parse(raw) + } catch { + return null + } + } + + /** + * Check if the stored token is still valid + */ + isTokenValid(token: AntigravityToken | null): boolean { + if (!token) return false + const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at + const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 // 5 min buffer + return Date.now() < expiresAt + } + + /** + * Get authorization headers for API requests + */ + private getAuthHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": "NomadArch/1.0" + } + + // Try OAuth token first + const token = this.getStoredToken() + if (token && this.isTokenValid(token)) { + headers["Authorization"] = `Bearer ${token.access_token}` + } else if (this.config.apiKey) { + headers["Authorization"] = `Bearer ${this.config.apiKey}` + } + + return headers + } + + /** + * Check if the client is authenticated + */ + isAuthenticated(): boolean { + const token = this.getStoredToken() + return this.isTokenValid(token) || Boolean(this.config.apiKey) + } + + /** + * Get available Antigravity models + */ + async getModels(): Promise { + // Return cached models if still valid + const now = Date.now() + if (this.modelsCache && (now - this.modelsCacheTime) < this.CACHE_TTL_MS) { + return this.modelsCache + } + + // If authenticated, return full model list + if (this.isAuthenticated()) { + this.modelsCache = ANTIGRAVITY_MODELS + this.modelsCacheTime = now + return ANTIGRAVITY_MODELS + } + + // Not authenticated - return empty list + return [] + } + + /** + * Test connection to Antigravity API + */ + async testConnection(): Promise { + if (!this.isAuthenticated()) { + return false + } + + try { + // Try a simple models list request to verify connectivity + const response = await fetch(`${this.getEndpoint()}/models`, { + headers: this.getAuthHeaders(), + signal: AbortSignal.timeout(10000) + }) + return response.ok + } catch (error) { + console.warn("Antigravity connection test failed:", error) + // Try next endpoint + this.rotateEndpoint() + return false + } + } + + /** + * Chat completion (streaming) with automatic endpoint fallback + */ + async *chatStream(request: ChatRequest): AsyncGenerator { + if (!this.isAuthenticated()) { + throw new Error("Antigravity: Not authenticated. Please sign in with Google OAuth.") + } + + let lastError: Error | null = null + const maxRetries = this.config.endpoints.length + + for (let retry = 0; retry < maxRetries; retry++) { + try { + const endpoint = this.getEndpoint() + const response = await fetch(`${endpoint}/chat/completions`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ + ...request, + stream: true + }) + }) + + if (!response.ok) { + const errorText = await response.text() + if (response.status === 401 || response.status === 403) { + throw new Error(`Antigravity authentication failed: ${errorText}`) + } + // Try next endpoint for other errors + this.rotateEndpoint() + lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`) + continue + } + + if (!response.body) { + throw new Error("Response body is missing") + } + + 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") + } + + /** + * Chat completion (non-streaming) + */ + async chat(request: ChatRequest): Promise { + 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: false + }) + }) + + if (!response.ok) { + const errorText = await response.text() + if (response.status === 401 || response.status === 403) { + throw new Error(`Antigravity authentication failed: ${errorText}`) + } + this.rotateEndpoint() + lastError = new Error(`Antigravity API error (${response.status}): ${errorText}`) + continue + } + + return await response.json() + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + if (error instanceof Error && error.message.includes("authentication")) { + throw error + } + this.rotateEndpoint() + } + } + + throw lastError || new Error("Antigravity: All endpoints failed") + } +} + +export function getDefaultAntigravityConfig(): AntigravityConfig { + return { + enabled: true, + endpoints: [ + "https://daily.antigravity.dev/v1beta", + "https://autopush.antigravity.dev/v1beta", + "https://antigravity.dev/v1beta" + ] + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 4343c7f..c2791a0 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -22,6 +22,7 @@ import { registerOllamaRoutes } from "./routes/ollama" import { registerQwenRoutes } from "./routes/qwen" import { registerZAIRoutes } from "./routes/zai" import { registerOpenCodeZenRoutes } from "./routes/opencode-zen" +import { registerAntigravityRoutes } from "./routes/antigravity" import { registerSkillsRoutes } from "./routes/skills" import { registerContextEngineRoutes } from "./routes/context-engine" import { registerNativeSessionsRoutes } from "./routes/native-sessions" @@ -131,6 +132,7 @@ export function createHttpServer(deps: HttpServerDeps) { registerQwenRoutes(app, { logger: deps.logger }) registerZAIRoutes(app, { logger: deps.logger }) registerOpenCodeZenRoutes(app, { logger: deps.logger }) + registerAntigravityRoutes(app, { logger: deps.logger }) registerSkillsRoutes(app) registerContextEngineRoutes(app) diff --git a/packages/server/src/server/routes/antigravity.ts b/packages/server/src/server/routes/antigravity.ts new file mode 100644 index 0000000..b2ac70b --- /dev/null +++ b/packages/server/src/server/routes/antigravity.ts @@ -0,0 +1,336 @@ +import { FastifyInstance } from "fastify" +import { AntigravityClient, type ChatRequest, getDefaultAntigravityConfig, type ChatMessage } from "../../integrations/antigravity" +import { Logger } from "../../logger" +import { CORE_TOOLS, executeTools, type ToolCall, type ToolResult } from "../../tools/executor" +import { getMcpManager } from "../../mcp/client" + +interface AntigravityRouteDeps { + logger: Logger +} + +// Maximum number of tool execution loops +const MAX_TOOL_LOOPS = 10 + +export async function registerAntigravityRoutes( + app: FastifyInstance, + deps: AntigravityRouteDeps +) { + const logger = deps.logger.child({ component: "antigravity-routes" }) + + // Create shared client + const client = new AntigravityClient(getDefaultAntigravityConfig()) + + // List available Antigravity models + app.get('/api/antigravity/models', async (request, reply) => { + try { + const models = await client.getModels() + + return { + models: models.map(m => ({ + id: m.id, + name: m.name, + family: m.family, + provider: "antigravity", + free: false, // These require Google OAuth + reasoning: m.reasoning, + tool_call: m.tool_call, + limit: m.limit + })) + } + } catch (error) { + logger.error({ error }, "Failed to list Antigravity models") + return reply.status(500).send({ error: "Failed to list models" }) + } + }) + + // Check authentication status + app.get('/api/antigravity/auth-status', async (request, reply) => { + try { + const authenticated = client.isAuthenticated() + return { authenticated } + } catch (error) { + logger.error({ error }, "Antigravity auth status check failed") + return reply.status(500).send({ error: "Auth status check failed" }) + } + }) + + // Test connection + app.get('/api/antigravity/test', async (request, reply) => { + try { + const connected = await client.testConnection() + return { connected } + } catch (error) { + logger.error({ error }, "Antigravity connection test failed") + return reply.status(500).send({ error: "Connection test failed" }) + } + }) + + // Chat completion endpoint WITH MCP TOOL SUPPORT + app.post('/api/antigravity/chat', async (request, reply) => { + try { + const chatRequest = request.body as ChatRequest & { + workspacePath?: string + enableTools?: boolean + } + + // Extract workspace path for tool execution + const workspacePath = chatRequest.workspacePath || process.cwd() + const enableTools = chatRequest.enableTools !== false + + logger.info({ + workspacePath, + receivedWorkspacePath: chatRequest.workspacePath, + enableTools, + model: chatRequest.model + }, "Antigravity chat request received") + + // Handle streaming with tool loop + if (chatRequest.stream) { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }) + + try { + await streamWithToolLoop( + client, + chatRequest, + workspacePath, + enableTools, + reply.raw, + logger + ) + reply.raw.end() + } catch (streamError) { + logger.error({ error: streamError }, "Antigravity streaming failed") + reply.raw.write(`data: ${JSON.stringify({ error: String(streamError) })}\n\n`) + reply.raw.end() + } + } else { + // Non-streaming with tool loop + const response = await chatWithToolLoop( + client, + chatRequest, + workspacePath, + enableTools, + logger + ) + return response + } + } catch (error) { + logger.error({ error }, "Antigravity chat request failed") + return reply.status(500).send({ error: "Chat request failed" }) + } + }) + + logger.info("Antigravity routes registered with MCP tool support - Google OAuth required!") +} + +/** + * Streaming chat with tool execution loop + */ +async function streamWithToolLoop( + client: AntigravityClient, + request: ChatRequest, + workspacePath: string, + enableTools: boolean, + rawResponse: any, + logger: Logger +): Promise { + let messages = [...request.messages] + let loopCount = 0 + + // Load MCP tools from workspace config + let allTools = [...CORE_TOOLS] + if (enableTools && workspacePath) { + try { + const mcpManager = getMcpManager() + await mcpManager.loadConfig(workspacePath) + const mcpTools = await mcpManager.getToolsAsOpenAIFormat() + allTools = [...CORE_TOOLS, ...mcpTools] + if (mcpTools.length > 0) { + logger.info({ mcpToolCount: mcpTools.length }, "Loaded MCP tools for Antigravity") + } + } catch (mcpError) { + logger.warn({ error: mcpError }, "Failed to load MCP tools") + } + } + + // Inject tools if enabled + const requestWithTools: ChatRequest = { + ...request, + tools: enableTools ? allTools : undefined, + tool_choice: enableTools ? "auto" : undefined + } + + while (loopCount < MAX_TOOL_LOOPS) { + loopCount++ + + // Accumulate tool calls from stream + let accumulatedToolCalls: { [index: number]: { id: string; name: string; arguments: string } } = {} + let hasToolCalls = false + let textContent = "" + + // Stream response + for await (const chunk of client.chatStream({ ...requestWithTools, messages })) { + // Write chunk to client + rawResponse.write(`data: ${JSON.stringify(chunk)}\n\n`) + + const choice = chunk.choices[0] + if (!choice) continue + + // Accumulate text content + if (choice.delta?.content) { + textContent += choice.delta.content + } + + // Accumulate tool calls from delta (if API supports it) + const deltaToolCalls = (choice.delta as any)?.tool_calls + if (deltaToolCalls) { + hasToolCalls = true + for (const tc of deltaToolCalls) { + const idx = tc.index ?? 0 + if (!accumulatedToolCalls[idx]) { + accumulatedToolCalls[idx] = { id: tc.id || "", name: "", arguments: "" } + } + if (tc.id) accumulatedToolCalls[idx].id = tc.id + if (tc.function?.name) accumulatedToolCalls[idx].name += tc.function.name + if (tc.function?.arguments) accumulatedToolCalls[idx].arguments += tc.function.arguments + } + } + + // Check if we should stop + if (choice.finish_reason === "stop") { + rawResponse.write('data: [DONE]\n\n') + return + } + } + + // If no tool calls, we're done + if (!hasToolCalls || !enableTools) { + rawResponse.write('data: [DONE]\n\n') + return + } + + // Convert accumulated tool calls + const toolCalls: ToolCall[] = Object.values(accumulatedToolCalls).map(tc => ({ + id: tc.id, + type: "function" as const, + function: { + name: tc.name, + arguments: tc.arguments + } + })) + + if (toolCalls.length === 0) { + rawResponse.write('data: [DONE]\n\n') + return + } + + logger.info({ toolCalls: toolCalls.map(tc => tc.function.name) }, "Executing tool calls") + + // Add assistant message with tool calls + const assistantMessage: ChatMessage = { + role: "assistant", + content: textContent || undefined, + tool_calls: toolCalls + } + messages.push(assistantMessage) + + // Execute tools + const toolResults = await executeTools(workspacePath, toolCalls) + + // Notify client about tool execution via special event + for (const result of toolResults) { + const toolEvent = { + type: "tool_result", + tool_call_id: result.tool_call_id, + content: result.content + } + rawResponse.write(`data: ${JSON.stringify(toolEvent)}\n\n`) + } + + // Add tool results to messages + for (const result of toolResults) { + const toolMessage: ChatMessage = { + role: "tool", + content: result.content, + tool_call_id: result.tool_call_id + } + messages.push(toolMessage) + } + + logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete") + } + + logger.warn({ loopCount }, "Max tool loops reached") + rawResponse.write('data: [DONE]\n\n') +} + +/** + * Non-streaming chat with tool execution loop + */ +async function chatWithToolLoop( + client: AntigravityClient, + request: ChatRequest, + workspacePath: string, + enableTools: boolean, + logger: Logger +): Promise { + let messages = [...request.messages] + let loopCount = 0 + let lastResponse: any = null + + // Inject tools if enabled + const requestWithTools: ChatRequest = { + ...request, + tools: enableTools ? CORE_TOOLS : undefined, + tool_choice: enableTools ? "auto" : undefined + } + + while (loopCount < MAX_TOOL_LOOPS) { + loopCount++ + + const response = await client.chat({ ...requestWithTools, messages, stream: false }) + lastResponse = response + + const choice = response.choices[0] + if (!choice) break + + const toolCalls = (choice.message as any)?.tool_calls + + // If no tool calls, return + if (!toolCalls || toolCalls.length === 0 || !enableTools) { + return response + } + + logger.info({ toolCalls: toolCalls.map((tc: any) => tc.function.name) }, "Executing tool calls") + + // Add assistant message + const assistantMessage: ChatMessage = { + role: "assistant", + content: (choice.message as any).content || undefined, + tool_calls: toolCalls + } + messages.push(assistantMessage) + + // Execute tools + const toolResults = await executeTools(workspacePath, toolCalls) + + // Add tool results + for (const result of toolResults) { + const toolMessage: ChatMessage = { + role: "tool", + content: result.content, + tool_call_id: result.tool_call_id + } + messages.push(toolMessage) + } + + logger.info({ loopCount, toolsExecuted: toolResults.length }, "Tool loop iteration complete") + } + + logger.warn({ loopCount }, "Max tool loops reached") + return lastResponse +} diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index c746145..951e9ed 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -68,6 +68,8 @@ export class WorkspaceManager { } async create(folder: string, name?: string): Promise { + // Special constant for Native mode (no OpenCode binary) + const NATIVE_MODE_PATH = "__nomadarch_native__" const id = `${Date.now().toString(36)}` const binary = this.options.binaryRegistry.resolveDefault() @@ -75,7 +77,15 @@ export class WorkspaceManager { const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) clearWorkspaceSearchCache(workspacePath) - this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace") + // Check if we're in native mode + const isNativeMode = resolvedBinaryPath === NATIVE_MODE_PATH || binary.path === NATIVE_MODE_PATH + + this.options.logger.info({ + workspaceId: id, + folder: workspacePath, + binary: resolvedBinaryPath, + isNativeMode + }, "Creating workspace") const proxyPath = `/workspaces/${id}/instance` @@ -87,13 +97,14 @@ export class WorkspaceManager { status: "starting", proxyPath, binaryId: resolvedBinaryPath, - binaryLabel: binary.label, - binaryVersion: binary.version, + binaryLabel: isNativeMode ? "NomadArch Native" : binary.label, + binaryVersion: isNativeMode ? "Native" : binary.version, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } - if (!descriptor.binaryVersion) { + // Native mode doesn't need binary version detection + if (!isNativeMode && !descriptor.binaryVersion) { descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath) } @@ -102,6 +113,31 @@ export class WorkspaceManager { this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) + // In native mode, we don't launch a binary - the workspace is immediately ready + // and uses native session management APIs instead + if (isNativeMode) { + this.options.logger.info({ workspaceId: id }, "Starting native mode workspace (no binary)") + + // Native mode is immediately ready - no process to launch + descriptor.status = "ready" + descriptor.updatedAt = new Date().toISOString() + // No pid or port for native mode - it uses the server's own APIs + + this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor }) + this.options.logger.info({ workspaceId: id }, "Native mode workspace ready") + + // Trigger Context-Engine indexing (non-blocking) + const contextEngine = getContextEngineService() + if (contextEngine) { + contextEngine.indexPath(workspacePath).catch((error) => { + this.options.logger.warn({ workspaceId: id, error }, "Context-Engine indexing failed") + }) + } + + return descriptor + } + + // SDK/binary mode - launch the OpenCode process const preferences = this.options.configStore.get().preferences ?? {} const userEnvironment = preferences.environmentVariables ?? {} const opencodeConfigDir = ensureWorkspaceOpencodeConfig(id) diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index e5a9734..6f518c9 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -33,6 +33,12 @@ export class WorkspaceRuntime { async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { this.validateFolder(options.folder) + // Native mode should not use the runtime - it uses native session management instead + const NATIVE_MODE_PATH = "__nomadarch_native__" + if (options.binaryPath === NATIVE_MODE_PATH) { + throw new Error(`Native mode does not use binary launches. Use native session management APIs instead.`) + } + // Check if binary exists before attempting to launch try { accessSync(options.binaryPath, constants.F_OK) diff --git a/packages/ui/src/components/advanced-settings-modal.tsx b/packages/ui/src/components/advanced-settings-modal.tsx index ba8c3eb..477c2ed 100644 --- a/packages/ui/src/components/advanced-settings-modal.tsx +++ b/packages/ui/src/components/advanced-settings-modal.tsx @@ -6,6 +6,7 @@ import OllamaCloudSettings from "./settings/OllamaCloudSettings" import QwenCodeSettings from "./settings/QwenCodeSettings" import ZAISettings from "./settings/ZAISettings" import OpenCodeZenSettings from "./settings/OpenCodeZenSettings" +import AntigravitySettings from "./settings/AntigravitySettings" interface AdvancedSettingsModalProps { open: boolean @@ -75,6 +76,15 @@ const AdvancedSettingsModal: Component = (props) => > Z.AI + @@ -115,6 +125,10 @@ const AdvancedSettingsModal: Component = (props) => + + + +
diff --git a/packages/ui/src/components/settings/AntigravitySettings.tsx b/packages/ui/src/components/settings/AntigravitySettings.tsx new file mode 100644 index 0000000..032888c --- /dev/null +++ b/packages/ui/src/components/settings/AntigravitySettings.tsx @@ -0,0 +1,428 @@ +import { Component, createSignal, onMount, For, Show } from 'solid-js' +import { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield } from 'lucide-solid' +import { getUserScopedKey } from '../../lib/user-storage' + +interface AntigravityModel { + id: string + name: string + family?: string + reasoning?: boolean + tool_call?: boolean + limit?: { + context: number + output: number + } +} + +interface AntigravityToken { + access_token: string + refresh_token?: string + expires_in: number + created_at: number +} + +const ANTIGRAVITY_TOKEN_KEY = "antigravity_oauth_token" +const GOOGLE_OAUTH_CLIENT_ID = "782068742485-pf45b4gldtk7q847g3v5ercqfl31nkud.apps.googleusercontent.com" // Antigravity/Gemini CLI client + +const AntigravitySettings: Component = () => { + const [models, setModels] = createSignal([]) + const [isLoading, setIsLoading] = createSignal(true) + const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle') + const [authStatus, setAuthStatus] = createSignal<'unknown' | 'authenticated' | 'unauthenticated'>('unknown') + const [isAuthenticating, setIsAuthenticating] = createSignal(false) + const [error, setError] = createSignal(null) + + // Check stored token on mount + onMount(async () => { + checkAuthStatus() + await loadModels() + await testConnection() + }) + + const getStoredToken = (): AntigravityToken | null => { + if (typeof window === "undefined") return null + try { + const raw = window.localStorage.getItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY)) + if (!raw) return null + return JSON.parse(raw) + } catch { + return null + } + } + + const isTokenValid = (token: AntigravityToken | null): boolean => { + if (!token) return false + const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at + const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 // 5 min buffer + return Date.now() < expiresAt + } + + const checkAuthStatus = () => { + const token = getStoredToken() + if (isTokenValid(token)) { + setAuthStatus('authenticated') + } else { + setAuthStatus('unauthenticated') + } + } + + const loadModels = async () => { + setIsLoading(true) + try { + const response = await fetch('/api/antigravity/models') + if (response.ok) { + const data = await response.json() + setModels(data.models || []) + setError(null) + } else { + throw new Error('Failed to load models') + } + } catch (err) { + console.error('Failed to load Antigravity models:', err) + setError('Failed to load models') + } finally { + setIsLoading(false) + } + } + + const testConnection = async () => { + setConnectionStatus('testing') + try { + const response = await fetch('/api/antigravity/test') + if (response.ok) { + const data = await response.json() + setConnectionStatus(data.connected ? 'connected' : 'failed') + } else { + setConnectionStatus('failed') + } + } catch (err) { + setConnectionStatus('failed') + } + } + + const startGoogleOAuth = async () => { + setIsAuthenticating(true) + setError(null) + + try { + // Open Google OAuth in a new window + const redirectUri = `${window.location.origin}/auth/antigravity/callback` + const scope = encodeURIComponent("openid email profile https://www.googleapis.com/auth/cloud-platform") + const state = crypto.randomUUID() + + // Store state for verification + window.localStorage.setItem('antigravity_oauth_state', state) + + const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + + `client_id=${GOOGLE_OAUTH_CLIENT_ID}&` + + `redirect_uri=${encodeURIComponent(redirectUri)}&` + + `response_type=token&` + + `scope=${scope}&` + + `state=${state}&` + + `prompt=consent` + + // Open popup + const width = 500 + const height = 600 + const left = (window.screen.width - width) / 2 + const top = (window.screen.height - height) / 2 + + const popup = window.open( + authUrl, + 'antigravity-oauth', + `width=${width},height=${height},left=${left},top=${top}` + ) + + if (!popup) { + throw new Error('Failed to open authentication window. Please allow popups.') + } + + // Poll for token in URL hash + const checkClosed = setInterval(() => { + try { + if (popup.closed) { + clearInterval(checkClosed) + setIsAuthenticating(false) + checkAuthStatus() + loadModels() + } else { + // Check if we can access the popup location (same origin after redirect) + const hash = popup.location.hash + if (hash && hash.includes('access_token')) { + const params = new URLSearchParams(hash.substring(1)) + const accessToken = params.get('access_token') + const expiresIn = parseInt(params.get('expires_in') || '3600', 10) + + if (accessToken) { + const token: AntigravityToken = { + access_token: accessToken, + expires_in: expiresIn, + created_at: Date.now() + } + + window.localStorage.setItem( + getUserScopedKey(ANTIGRAVITY_TOKEN_KEY), + JSON.stringify(token) + ) + + popup.close() + clearInterval(checkClosed) + setIsAuthenticating(false) + setAuthStatus('authenticated') + loadModels() + } + } + } + } catch (e) { + // Cross-origin error - popup is on Google's domain, still waiting + } + }, 500) + + // Cleanup after 5 minutes + setTimeout(() => { + clearInterval(checkClosed) + if (!popup.closed) { + popup.close() + } + setIsAuthenticating(false) + }, 300000) + + } catch (err: any) { + console.error('OAuth error:', err) + setError(err.message || 'Authentication failed') + setIsAuthenticating(false) + } + } + + const signOut = () => { + window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY)) + setAuthStatus('unauthenticated') + setModels([]) + } + + const formatNumber = (num: number): string => { + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M` + if (num >= 1000) return `${(num / 1000).toFixed(0)}K` + return num.toString() + } + + const getModelFamily = (model: AntigravityModel): { label: string; color: string } => { + if (model.id.startsWith('gemini')) return { label: 'Gemini', color: 'bg-blue-500/20 text-blue-400' } + if (model.id.startsWith('claude')) return { label: 'Claude', color: 'bg-orange-500/20 text-orange-400' } + if (model.id.startsWith('gpt')) return { label: 'GPT', color: 'bg-green-500/20 text-green-400' } + return { label: model.family || 'Other', color: 'bg-zinc-700 text-zinc-400' } + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Antigravity

+

Premium models via Google OAuth

+
+
+ +
+ {connectionStatus() === 'testing' && ( + + + Testing... + + )} + {connectionStatus() === 'connected' && ( + + + Connected + + )} + {connectionStatus() === 'failed' && ( + + + Offline + + )} +
+
+ + {/* Info Banner */} +
+
+ +
+

Premium AI Models via Google

+

+ Antigravity provides access to Gemini 3 Pro/Flash, Claude Sonnet 4.5, Claude Opus 4.5, + and GPT-OSS 120B through Google's rate limits. Sign in with your Google account to get started. +

+
+
+
+ + {/* Authentication Section */} +
+
+
+ +
+

Google OAuth Authentication

+

+ {authStatus() === 'authenticated' + ? 'You are signed in and can use Antigravity models' + : 'Sign in with Google to access premium models'} +

+
+
+ + + + + + +
+ + + Authenticated + + +
+
+
+
+ + {/* Error Display */} + +
+ {error()} +
+
+ + {/* Models Grid */} +
+
+

Available Models

+ +
+ + +
+
+ + Loading models... +
+
+
+ + 0}> +
+ + {(model) => { + const family = getModelFamily(model) + return ( +
+
+
+

+ {model.name} +

+

{model.id}

+
+ + {family.label} + +
+ +
+ {model.reasoning && ( + + Thinking + + )} + {model.tool_call && ( + + Tool Use + + )} +
+ + {model.limit && ( +
+ Context: {formatNumber(model.limit.context)} + Output: {formatNumber(model.limit.output)} +
+ )} +
+ ) + }} +
+
+
+ + +
+

No models available at this time.

+ +
+
+ + +
+

Sign in with Google to see available models.

+
+
+
+ + {/* Usage Info */} +
+

How to Use

+
    +
  • • Sign in with your Google account above
  • +
  • • Select any Antigravity model from the model picker in chat
  • +
  • • Models include Gemini 3, Claude Sonnet/Opus 4.5, and GPT-OSS
  • +
  • • Thinking-enabled models show step-by-step reasoning
  • +
  • • Uses Google's rate limits for maximum throughput
  • +
+
+
+ ) +} + +export default AntigravitySettings diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index d44940c..a9627a9 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1116,6 +1116,144 @@ async function streamZAIChat( }) } +async function streamAntigravityChat( + instanceId: string, + sessionId: string, + providerId: string, + modelId: string, + messages: ExternalChatMessage[], + messageId: string, + assistantMessageId: string, + assistantPartId: string, +): Promise { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS) + + // Get workspace path for tool execution + const instance = instances().get(instanceId) + const workspacePath = instance?.folder || "" + + const response = await fetch("/api/antigravity/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: controller.signal, + body: JSON.stringify({ + model: modelId, + messages, + stream: true, + workspacePath, + enableTools: true, + }), + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => "") + throw new Error(errorText || `Antigravity chat failed (${response.status})`) + } + + const store = messageStoreBus.getOrCreate(instanceId) + store.beginStreamingUpdate() + let fullText = "" + let lastUpdateAt = 0 + + try { + await readSseStream(response, (data) => { + try { + const chunk = JSON.parse(data) + if (chunk?.error) throw new Error(chunk.error) + + // Handle tool execution results (special events from backend) + if (chunk?.type === "tool_result") { + const toolResult = `\n\nāœ… **Tool Executed:** ${chunk.content}\n\n` + fullText += toolResult + store.applyPartUpdate({ + messageId: assistantMessageId, + part: { id: assistantPartId, type: "text", text: fullText } as any, + }) + + // Dispatch file change event to refresh sidebar + if (typeof window !== "undefined") { + console.log(`[EVENT] Dispatching FILE_CHANGE_EVENT for ${instanceId}`); + window.dispatchEvent(new CustomEvent(FILE_CHANGE_EVENT, { detail: { instanceId } })) + } + + // Auto-trigger preview for HTML file writes + const content = chunk.content || "" + if (content.includes("Successfully wrote") && + (content.includes(".html") || content.includes("index.") || content.includes(".htm"))) { + if (typeof window !== "undefined") { + const htmlMatch = content.match(/to\s+([^\s]+\.html?)/) + if (htmlMatch) { + const relativePath = htmlMatch[1] + const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000" + const apiOrigin = origin.replace(":3000", ":9898") + const previewUrl = `${apiOrigin}/api/workspaces/${instanceId}/serve/${relativePath}` + + console.log(`[EVENT] Auto-preview triggered for ${previewUrl}`); + window.dispatchEvent(new CustomEvent(BUILD_PREVIEW_EVENT, { + detail: { url: previewUrl, instanceId } + })) + } + } + } + + return + } + + const delta = + chunk?.choices?.[0]?.delta?.content ?? + chunk?.choices?.[0]?.message?.content + if (typeof delta !== "string" || delta.length === 0) return + fullText += delta + + const now = Date.now() + if (now - lastUpdateAt > 40) { // Limit to ~25 updates per second + lastUpdateAt = now + store.applyPartUpdate({ + messageId: assistantMessageId, + part: { id: assistantPartId, type: "text", text: fullText } as any, + }) + } + } catch (e) { + if (e instanceof Error) throw e + } + }) + + // Always apply final text update + store.applyPartUpdate({ + messageId: assistantMessageId, + part: { id: assistantPartId, type: "text", text: fullText } as any, + }) + } finally { + clearTimeout(timeoutId) + store.endStreamingUpdate() + } + + store.upsertMessage({ + id: assistantMessageId, + sessionId, + role: "assistant", + status: "complete", + updatedAt: Date.now(), + isEphemeral: false, + }) + store.setMessageInfo(assistantMessageId, { + id: assistantMessageId, + role: "assistant", + providerID: providerId, + modelID: modelId, + time: { created: store.getMessageInfo(assistantMessageId)?.time?.created ?? Date.now(), completed: Date.now() }, + } as any) + store.upsertMessage({ + id: messageId, + sessionId, + role: "user", + status: "sent", + updatedAt: Date.now(), + isEphemeral: false, + }) +} + async function sendMessage( instanceId: string, sessionId: string, @@ -1264,7 +1402,7 @@ async function sendMessage( addDebugLog(`Merge System Instructions: ${Math.round(tPre2 - tPre1)}ms`, "warn") } - if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai") { + if (providerId === "ollama-cloud" || providerId === "qwen-oauth" || providerId === "opencode-zen" || providerId === "zai" || providerId === "antigravity") { const store = messageStoreBus.getOrCreate(instanceId) const now = Date.now() const assistantMessageId = createId("msg") @@ -1347,6 +1485,17 @@ async function sendMessage( assistantMessageId, assistantPartId, ) + } else if (providerId === "antigravity") { + await streamAntigravityChat( + instanceId, + sessionId, + providerId, + effectiveModel.modelId, + externalMessages, + messageId, + assistantMessageId, + assistantPartId, + ) } else { const qwenManager = new QwenOAuthManager() const token = await qwenManager.getValidToken() @@ -1428,7 +1577,9 @@ async function sendMessage( ? "Z.AI request failed" : providerId === "opencode-zen" ? "OpenCode Zen request failed" - : "Qwen request failed", + : providerId === "antigravity" + ? "Antigravity request failed" + : "Qwen request failed", message: error?.message || "Request failed", variant: "error", duration: 8000, diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 201a304..c961cd5 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -249,14 +249,83 @@ async function fetchZAIProvider(): Promise { } } +function getStoredAntigravityToken(): + | { access_token: string; expires_in: number; created_at: number } + | null { + if (typeof window === "undefined") return null + try { + const raw = window.localStorage.getItem(getUserScopedKey("antigravity_oauth_token")) + if (!raw) return null + return JSON.parse(raw) + } catch { + return null + } +} + +function isAntigravityTokenValid(token: { expires_in: number; created_at: number } | null): boolean { + if (!token) return false + const createdAt = token.created_at > 1e12 ? Math.floor(token.created_at / 1000) : token.created_at + const expiresAt = (createdAt + token.expires_in) * 1000 - 300000 + return Date.now() < expiresAt +} + +async function fetchAntigravityProvider(): Promise { + // Check if user is authenticated with Antigravity (Google OAuth) + const token = getStoredAntigravityToken() + if (!isAntigravityTokenValid(token)) { + // Not authenticated - try to fetch models anyway (they show as available but require auth) + try { + const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }>( + "/api/antigravity/models", + ) + const models = Array.isArray(data?.models) ? data?.models ?? [] : [] + if (models.length === 0) return null + + return { + id: "antigravity", + name: "Antigravity (Google OAuth)", + models: models.map((model) => ({ + id: model.id, + name: model.name, + providerId: "antigravity", + limit: model.limit, + })), + defaultModelId: "gemini-3-pro-high", + } + } catch { + return null + } + } + + // User is authenticated - fetch full model list + const data = await fetchJson<{ models?: Array<{ id: string; name: string; limit?: Model["limit"] }> }>( + "/api/antigravity/models", + ) + const models = Array.isArray(data?.models) ? data?.models ?? [] : [] + if (models.length === 0) return null + + return { + id: "antigravity", + name: "Antigravity (Google OAuth)", + models: models.map((model) => ({ + id: model.id, + name: model.name, + providerId: "antigravity", + limit: model.limit, + })), + defaultModelId: "gemini-3-pro-high", + } +} + async function fetchExtraProviders(): Promise { - const [ollama, zen, qwen, zai] = await Promise.all([ + const [ollama, zen, qwen, zai, antigravity] = await Promise.all([ fetchOllamaCloudProvider(), fetchOpenCodeZenProvider(), fetchQwenOAuthProvider(), fetchZAIProvider(), + fetchAntigravityProvider(), ]) - return [ollama, zen, qwen, zai].filter((provider): provider is Provider => Boolean(provider)) + return [ollama, zen, qwen, zai, antigravity].filter((provider): provider is Provider => Boolean(provider)) } function removeDuplicateProviders(base: Provider[], extras: Provider[]): Provider[] {