diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index d8a1133..d9ca67e 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { getCliProviderModels } from "@/lib/providers/cli-models"; import { MODEL_PROVIDERS } from "@/lib/providers/model-config"; import { getSettings } from "@/lib/storage/settings-store"; @@ -139,6 +140,34 @@ export async function GET(req: NextRequest) { break; } + case "codex-cli": { + if (type === "embedding") { + models = []; + break; + } + const fallback = MODEL_PROVIDERS["codex-cli"]?.models || []; + try { + models = await getCliProviderModels("codex-cli", fallback); + } catch { + models = [...fallback]; + } + break; + } + + case "gemini-cli": { + if (type === "embedding") { + models = []; + break; + } + const fallback = MODEL_PROVIDERS["gemini-cli"]?.models || []; + try { + models = await getCliProviderModels("gemini-cli", fallback); + } catch { + models = [...fallback]; + } + break; + } + default: { const providerConfig = MODEL_PROVIDERS[provider]; if (providerConfig) { diff --git a/src/app/api/provider-auth/connect/route.ts b/src/app/api/provider-auth/connect/route.ts new file mode 100644 index 0000000..21960f3 --- /dev/null +++ b/src/app/api/provider-auth/connect/route.ts @@ -0,0 +1,67 @@ +import { NextRequest } from "next/server"; +import { + connectProviderAuth, + type CliProvider, +} from "@/lib/providers/provider-auth"; +import type { ChatAuthMethod } from "@/lib/types"; + +function isCliProvider(value: string): value is CliProvider { + return value === "codex-cli" || value === "gemini-cli"; +} + +function isAuthMethod(value: string): value is ChatAuthMethod { + return value === "api_key" || value === "oauth"; +} + +export async function POST(req: NextRequest) { + try { + const body = (await req.json()) as { + provider?: string; + method?: string; + apiKey?: string; + }; + + const provider = (body.provider || "").trim(); + const method = (body.method || "").trim(); + + if (!isCliProvider(provider)) { + return Response.json( + { error: "provider must be one of: codex-cli, gemini-cli" }, + { status: 400 } + ); + } + if (!isAuthMethod(method)) { + return Response.json( + { error: "method must be one of: api_key, oauth" }, + { status: 400 } + ); + } + if (method !== "oauth") { + return Response.json( + { + error: + provider === "codex-cli" + ? "codex-cli supports only oauth in Eggent settings" + : "gemini-cli supports only oauth in Eggent settings", + }, + { status: 400 } + ); + } + + const result = await connectProviderAuth({ + provider, + method, + apiKey: body.apiKey, + }); + + return Response.json(result); + } catch (error) { + return Response.json( + { + error: + error instanceof Error ? error.message : "Failed to connect provider auth", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/provider-auth/status/route.ts b/src/app/api/provider-auth/status/route.ts new file mode 100644 index 0000000..e9acb38 --- /dev/null +++ b/src/app/api/provider-auth/status/route.ts @@ -0,0 +1,68 @@ +import { NextRequest } from "next/server"; +import { + checkProviderAuthStatus, + type CliProvider, +} from "@/lib/providers/provider-auth"; +import type { ChatAuthMethod } from "@/lib/types"; + +function isCliProvider(value: string): value is CliProvider { + return value === "codex-cli" || value === "gemini-cli"; +} + +function isAuthMethod(value: string): value is ChatAuthMethod { + return value === "api_key" || value === "oauth"; +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const provider = searchParams.get("provider") || ""; + const method = searchParams.get("method") || ""; + const hasApiKeyRaw = searchParams.get("hasApiKey"); + + if (!isCliProvider(provider)) { + return Response.json( + { error: "provider must be one of: codex-cli, gemini-cli" }, + { status: 400 } + ); + } + if (!isAuthMethod(method)) { + return Response.json( + { error: "method must be one of: api_key, oauth" }, + { status: 400 } + ); + } + if (method !== "oauth") { + return Response.json( + { + error: + provider === "codex-cli" + ? "codex-cli supports only oauth in Eggent settings" + : "gemini-cli supports only oauth in Eggent settings", + }, + { status: 400 } + ); + } + + const hasApiKey = + hasApiKeyRaw === "1" || + hasApiKeyRaw === "true" || + hasApiKeyRaw === "yes"; + + const status = await checkProviderAuthStatus({ + provider, + method, + hasApiKey, + }); + + return Response.json(status); + } catch (error) { + return Response.json( + { + error: + error instanceof Error ? error.message : "Failed to check status", + }, + { status: 500 } + ); + } +} diff --git a/src/components/settings/model-wizards.tsx b/src/components/settings/model-wizards.tsx index 04ce6e5..85c71a0 100644 --- a/src/components/settings/model-wizards.tsx +++ b/src/components/settings/model-wizards.tsx @@ -1,11 +1,17 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { AlertCircle, Check, ChevronDown, Loader2 } from "lucide-react"; +import { + AlertCircle, + Check, + ChevronDown, + Loader2, + RefreshCw, +} from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { MODEL_PROVIDERS } from "@/lib/providers/model-config"; -import type { AppSettings } from "@/lib/types"; +import type { AppSettings, ChatAuthMethod } from "@/lib/types"; export type UpdateSettingsFn = (path: string, value: unknown) => void; @@ -204,22 +210,25 @@ export function ChatModelWizard({ const apiKey = settings.chatModel.apiKey || ""; const model = settings.chatModel.model; const providerConfig = MODEL_PROVIDERS[provider]; - const requiresApiKey = providerConfig?.requiresApiKey ?? true; - + const availableAuthMethods = providerConfig?.authMethods || ["api_key"]; + const selectedAuthMethod = ( + availableAuthMethods.includes((settings.chatModel.authMethod || "") as ChatAuthMethod) + ? settings.chatModel.authMethod + : providerConfig?.defaultAuthMethod || availableAuthMethods[0] + ) as ChatAuthMethod; + const requiresApiKey = + selectedAuthMethod === "api_key" && (providerConfig?.requiresApiKey ?? true); + const isCliProvider = provider === "codex-cli" || provider === "gemini-cli"; const hasProvider = !!provider; - const hasApiKey = !requiresApiKey || !!apiKey; const hasModel = !!model; - const currentStep = !hasProvider - ? 1 - : !hasApiKey - ? 2 - : !hasModel - ? requiresApiKey - ? 3 - : 2 - : requiresApiKey - ? 4 - : 3; + + const [connectionLoading, setConnectionLoading] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<{ + connected: boolean; + message: string; + detail?: string; + } | null>(null); + const [connectionError, setConnectionError] = useState(null); const { models, loading, error } = useModels( provider, @@ -229,20 +238,105 @@ export function ChatModelWizard({ settings.chatModel.baseUrl ); + const checkConnection = useCallback(async () => { + if (!isCliProvider) { + setConnectionStatus(null); + setConnectionError(null); + return; + } + + setConnectionLoading(true); + setConnectionError(null); + try { + const params = new URLSearchParams({ + provider, + method: selectedAuthMethod, + }); + if (apiKey.trim()) { + params.set("hasApiKey", "1"); + } + const response = await fetch(`/api/provider-auth/status?${params.toString()}`, { + method: "GET", + }); + const payload = (await response.json()) as { + connected?: boolean; + message?: string; + detail?: string; + error?: string; + }; + if (!response.ok) { + throw new Error(payload.error || "Failed to check connection"); + } + setConnectionStatus({ + connected: Boolean(payload.connected), + message: payload.message || "Status updated", + detail: payload.detail, + }); + } catch (cause) { + setConnectionStatus(null); + setConnectionError( + cause instanceof Error ? cause.message : "Failed to check connection" + ); + } finally { + setConnectionLoading(false); + } + }, [apiKey, isCliProvider, provider, selectedAuthMethod]); + + useEffect(() => { + if (!provider) { + setConnectionStatus(null); + setConnectionError(null); + return; + } + if (!isCliProvider) { + setConnectionStatus(null); + setConnectionError(null); + return; + } + void checkConnection(); + }, [checkConnection, isCliProvider, provider, selectedAuthMethod]); + + useEffect(() => { + if (!provider) return; + if (settings.chatModel.authMethod === selectedAuthMethod) return; + updateSettings("chatModel.authMethod", selectedAuthMethod); + }, [ + provider, + selectedAuthMethod, + settings.chatModel.authMethod, + updateSettings, + ]); + + const apiKeyConnectionReady = !requiresApiKey || !!apiKey.trim(); + const hasConnection = isCliProvider + ? Boolean(connectionStatus?.connected) + : selectedAuthMethod === "oauth" + ? Boolean(connectionStatus?.connected) + : apiKeyConnectionReady; + const currentStep = !hasProvider + ? 1 + : !selectedAuthMethod + ? 2 + : !hasConnection + ? 3 + : !hasModel + ? 4 + : 5; + const showApiKeyInput = selectedAuthMethod === "api_key" && requiresApiKey; + const connectionHelp = + selectedAuthMethod === "oauth" + ? providerConfig?.connectionHelp?.oauth + : providerConfig?.connectionHelp?.apiKey; + return (

Chat Model

- {requiresApiKey && ( - - )} - + + +
@@ -256,13 +350,24 @@ export function ChatModelWizard({ const nextProvider = event.target.value; updateSettings("chatModel.provider", nextProvider); updateSettings("chatModel.model", ""); + const nextProviderConfig = MODEL_PROVIDERS[nextProvider]; + const nextAuthMethod = + nextProviderConfig?.defaultAuthMethod || + nextProviderConfig?.authMethods?.[0] || + "api_key"; + updateSettings("chatModel.authMethod", nextAuthMethod); if (nextProvider === "ollama") { updateSettings("chatModel.baseUrl", "http://localhost:11434/v1"); updateSettings("chatModel.apiKey", ""); + } else if (nextProvider === "codex-cli" || nextProvider === "gemini-cli") { + updateSettings("chatModel.baseUrl", ""); } else { updateSettings("chatModel.baseUrl", ""); } + + setConnectionStatus(null); + setConnectionError(null); }} className="w-full rounded-md border bg-background px-3 py-2 text-sm" > @@ -279,44 +384,147 @@ export function ChatModelWizard({
- updateSettings("chatModel.apiKey", event.target.value)} - placeholder={ - providerConfig?.envKey - ? `Enter key or set ${providerConfig.envKey} in .env` - : "sk-..." - } + +
+ +
+ + + {showApiKeyInput ? ( +
+ updateSettings("chatModel.apiKey", event.target.value)} + placeholder={ + providerConfig?.envKey + ? `Enter key or set ${providerConfig.envKey} in .env` + : "sk-..." + } + disabled={!hasProvider} + /> + {providerConfig?.envKey && ( +

+ Or set{" "} + + {providerConfig.envKey} + {" "} + as an environment variable +

+ )} +
+ ) : ( +
+ API key input is not required for this connection mode. +
+ )} + + {connectionHelp && ( +
+

{connectionHelp.title}

+
    + {connectionHelp.steps.map((step, idx) => ( +
  • {step}
  • + ))} +
+ {connectionHelp.command && ( +

+ Command:{" "} + + {connectionHelp.command} + +

+ )} +
+ )} + + {isCliProvider && ( +
+ OAuth tokens are read from local CLI auth files. +
+ )} + + {isCliProvider && ( + + )} + + {provider === "ollama" && selectedAuthMethod === "api_key" && ( +
+ + API Key not required — connecting to local Ollama +
+ )} + + {isCliProvider && connectionStatus && ( +
+

{connectionStatus.message}

+ {connectionStatus.detail && ( +

{connectionStatus.detail}

+ )} +
+ )} + + {connectionError && ( +
+ + {connectionError} +
)}
- {hasProvider && !requiresApiKey && provider === "ollama" && ( -
- - API Key not required — connecting to local Ollama -
- )} - - {(provider === "custom" || provider === "ollama") && ( + {(provider === "custom" || provider === "ollama") && selectedAuthMethod === "api_key" && (
)}
updateSettings("chatModel.model", value)} placeholder="Select model..." /> diff --git a/src/lib/agent/agent.ts b/src/lib/agent/agent.ts index b12fa7d..e96a6c8 100644 --- a/src/lib/agent/agent.ts +++ b/src/lib/agent/agent.ts @@ -24,6 +24,18 @@ const MAX_TOOL_STEPS_SUBORDINATE = 15; const POLL_NO_PROGRESS_BLOCK_THRESHOLD = 16; const POLL_BACKOFF_SCHEDULE_MS = [5000, 10000, 30000, 60000] as const; +function resolveModelProviderOptions(provider: string) { + if (provider === "codex-cli") { + return { + openai: { + store: false as const, + instructions: "You are Eggent, an AI coding assistant.", + }, + }; + } + return undefined; +} + function asRecord(value: unknown): Record | null { if (value == null || typeof value !== "object" || Array.isArray(value)) { return null; @@ -617,7 +629,11 @@ export async function runAgent(options: { agentNumber?: number; }) { const settings = await getSettings(); - const model = createModel(settings.chatModel); + const providerOptions = resolveModelProviderOptions(settings.chatModel.provider); + const model = createModel(settings.chatModel, { + projectId: options.projectId, + currentPath: options.currentPath, + }); // Build context const context: AgentContext = { @@ -690,6 +706,7 @@ export async function runAgent(options: { model, system: systemPrompt, messages, + providerOptions, tools, stopWhen: [stepCountIs(MAX_TOOL_STEPS_PER_TURN), hasToolCall("response")], temperature: settings.chatModel.temperature ?? 0.7, @@ -719,6 +736,7 @@ export async function runAgent(options: { "Output only the continuation text, without repeating earlier content.", }, ], + providerOptions, temperature: settings.chatModel.temperature ?? 0.7, maxOutputTokens: Math.min(settings.chatModel.maxTokens ?? 4096, 1200), }); @@ -805,7 +823,11 @@ export async function runAgentText(options: { runtimeData?: Record; }): Promise { const settings = await getSettings(); - const model = createModel(settings.chatModel); + const providerOptions = resolveModelProviderOptions(settings.chatModel.provider); + const model = createModel(settings.chatModel, { + projectId: options.projectId, + currentPath: options.currentPath, + }); const context: AgentContext = { chatId: options.chatId, @@ -869,6 +891,7 @@ export async function runAgentText(options: { model, system: systemPrompt, messages, + providerOptions, tools, stopWhen: [stepCountIs(MAX_TOOL_STEPS_PER_TURN), hasToolCall("response")], temperature: settings.chatModel.temperature ?? 0.7, @@ -940,7 +963,10 @@ export async function runSubordinateAgent(options: { parentHistory: ModelMessage[]; }): Promise { const settings = await getSettings(); - const model = createModel(settings.chatModel); + const providerOptions = resolveModelProviderOptions(settings.chatModel.provider); + const model = createModel(settings.chatModel, { + projectId: options.projectId, + }); const context: AgentContext = { chatId: `subordinate-${Date.now()}`, @@ -1000,6 +1026,7 @@ export async function runSubordinateAgent(options: { model, system: systemPrompt, messages, + providerOptions, tools, stopWhen: [stepCountIs(MAX_TOOL_STEPS_SUBORDINATE), hasToolCall("response")], temperature: settings.chatModel.temperature ?? 0.7, diff --git a/src/lib/providers/cli-models.ts b/src/lib/providers/cli-models.ts new file mode 100644 index 0000000..0ac53dc --- /dev/null +++ b/src/lib/providers/cli-models.ts @@ -0,0 +1,242 @@ +import { spawn } from "child_process"; + +export type CliProviderName = "codex-cli" | "gemini-cli"; + +interface CommandResult { + code: number | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +interface CachedModels { + expiresAt: number; + models: { id: string; name: string }[]; +} + +const CACHE_TTL_MS = 60_000; +const cache = new Map(); + +function commandForProvider(provider: CliProviderName): string { + if (provider === "codex-cli") { + return process.env.CODEX_COMMAND || "codex"; + } + return process.env.GEMINI_CLI_COMMAND || "gemini"; +} + +function runCommand( + command: string, + args: string[], + timeoutMs = 2_500 +): Promise { + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let finished = false; + let timedOut = false; + + let child; + try { + child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + }); + } catch (error) { + resolve({ + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + timedOut: false, + }); + return; + } + + const timer = setTimeout(() => { + if (!finished) { + timedOut = true; + child.kill("SIGKILL"); + } + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + if (stdout.length > 200_000) stdout = stdout.slice(-200_000); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + if (stderr.length > 200_000) stderr = stderr.slice(-200_000); + }); + + child.on("error", (error) => { + if (finished) return; + finished = true; + clearTimeout(timer); + resolve({ + code: 1, + stdout, + stderr: `${stderr}\n${error instanceof Error ? error.message : String(error)}`.trim(), + timedOut, + }); + }); + + child.on("close", (code) => { + if (finished) return; + finished = true; + clearTimeout(timer); + resolve({ code, stdout, stderr, timedOut }); + }); + }); +} + +function uniqueSorted(values: string[]): string[] { + return [...new Set(values.map((v) => v.trim()).filter(Boolean))].sort((a, b) => + a.localeCompare(b) + ); +} + +function collectJsonLikeModels( + value: unknown, + matcher: (token: string) => boolean, + out: Set +): void { + if (typeof value === "string") { + const trimmed = value.trim(); + if (matcher(trimmed)) out.add(trimmed); + return; + } + + if (Array.isArray(value)) { + for (const entry of value) { + collectJsonLikeModels(entry, matcher, out); + } + return; + } + + if (!value || typeof value !== "object") return; + const record = value as Record; + for (const [key, nested] of Object.entries(record)) { + if ( + typeof nested === "string" && + (key === "id" || key === "model" || key === "name") + ) { + const trimmed = nested.trim(); + if (matcher(trimmed)) out.add(trimmed); + } + collectJsonLikeModels(nested, matcher, out); + } +} + +function parseFromJsonOutput(raw: string, matcher: (token: string) => boolean): string[] { + const models = new Set(); + const trimmed = raw.trim(); + if (!trimmed) return []; + + try { + const parsed = JSON.parse(trimmed) as unknown; + collectJsonLikeModels(parsed, matcher, models); + } catch { + // Try JSONL as a fallback. + for (const line of trimmed.split(/\r?\n/)) { + const row = line.trim(); + if (!row || !(row.startsWith("{") || row.startsWith("["))) continue; + try { + const parsed = JSON.parse(row) as unknown; + collectJsonLikeModels(parsed, matcher, models); + } catch { + // Ignore malformed JSON lines. + } + } + } + + return uniqueSorted([...models]); +} + +function parseWithRegex(raw: string, regex: RegExp): string[] { + const found: string[] = []; + for (const match of raw.matchAll(regex)) { + const token = match[0]?.trim(); + if (token) found.push(token); + } + return uniqueSorted(found); +} + +function matchesCodexModel(token: string): boolean { + return /^gpt-[a-z0-9][a-z0-9._-]*$/i.test(token) || /^o[134](?:-[a-z0-9._-]+)?$/i.test(token); +} + +function matchesGeminiModel(token: string): boolean { + return /^gemini-[a-z0-9][a-z0-9._-]*$/i.test(token); +} + +function parseCodexModels(raw: string): string[] { + const fromJson = parseFromJsonOutput(raw, matchesCodexModel); + if (fromJson.length > 0) return fromJson; + return parseWithRegex(raw, /\b(?:gpt-[a-z0-9][a-z0-9._-]*|o[134](?:-[a-z0-9._-]+)?)\b/gi); +} + +function parseGeminiModels(raw: string): string[] { + const fromJson = parseFromJsonOutput(raw, matchesGeminiModel); + if (fromJson.length > 0) return fromJson; + return parseWithRegex(raw, /\bgemini-[a-z0-9][a-z0-9._-]*\b/gi); +} + +function commandCandidates(provider: CliProviderName): string[][] { + if (provider === "codex-cli") { + return [ + ["models", "--json"], + ["models"], + ]; + } + + return [ + ["models", "--json"], + ["models", "list", "--json"], + ["models"], + ["models", "list"], + ]; +} + +function toModelOptions(models: string[]): { id: string; name: string }[] { + return models.map((id) => ({ id, name: id })); +} + +export async function getCliProviderModels( + provider: CliProviderName, + fallbackModels: { id: string; name: string }[] +): Promise<{ id: string; name: string }[]> { + const now = Date.now(); + const cached = cache.get(provider); + if (cached && cached.expiresAt > now) { + return cached.models; + } + + const command = commandForProvider(provider); + const parse = provider === "codex-cli" ? parseCodexModels : parseGeminiModels; + const discovered = new Set(); + + for (const args of commandCandidates(provider)) { + const result = await runCommand(command, args); + if (result.timedOut) continue; + + const combined = `${result.stdout}\n${result.stderr}`.trim(); + if (!combined) continue; + + for (const model of parse(combined)) { + discovered.add(model); + } + + if (discovered.size > 0) break; + } + + const models = + discovered.size > 0 + ? toModelOptions(uniqueSorted([...discovered])) + : [...fallbackModels]; + + cache.set(provider, { + models, + expiresAt: now + CACHE_TTL_MS, + }); + + return models; +} diff --git a/src/lib/providers/llm-provider.ts b/src/lib/providers/llm-provider.ts index ac6c7e8..d4c478e 100644 --- a/src/lib/providers/llm-provider.ts +++ b/src/lib/providers/llm-provider.ts @@ -2,7 +2,26 @@ import { createOpenAI } from "@ai-sdk/openai"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createGoogleGenerativeAI } from "@ai-sdk/google"; import type { LanguageModel } from "ai"; -import { ModelConfig } from "@/lib/types"; +import path from "path"; +import type { + LanguageModelV3, + LanguageModelV3CallOptions, + LanguageModelV3GenerateResult, + LanguageModelV3StreamPart, + LanguageModelV3StreamResult, + LanguageModelV3Usage, +} from "@ai-sdk/provider"; +import { spawn } from "child_process"; +import { + ModelConfig, + type McpServerConfig, + type ProjectMcpConfig, +} from "@/lib/types"; +import { + getWorkDir, + loadProjectMcpServers, +} from "@/lib/storage/project-store"; +import { resolveCliOAuthCredentialSync } from "@/lib/providers/provider-auth"; type OpenAICompatibleSettings = { providerName: string; @@ -15,6 +34,1443 @@ type OpenAICompatibleSettings = { const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); +type CliProviderName = "codex-cli" | "gemini-cli"; +const CODEX_BACKEND_BASE_URL = "https://chatgpt.com/backend-api/codex"; +const GEMINI_CODE_ASSIST_BASE_URL = "https://cloudcode-pa.googleapis.com"; +const GEMINI_CODE_ASSIST_API_VERSION = "v1internal"; +const GEMINI_CODE_ASSIST_LOAD_ENDPOINTS = [ + "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", + "https://autopush-cloudcode-pa.sandbox.googleapis.com", +]; +const GEMINI_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/9.15.1"; +const DEFAULT_CODEX_INSTRUCTIONS = "You are Eggent, an AI coding assistant."; +const CODEX_UNSUPPORTED_FIELDS = new Set(["max_output_tokens"]); +const GEMINI_CODE_ASSIST_SCHEMA_BLOCKLIST = new Set([ + "$id", + "$schema", + "$defs", + "definitions", + "$ref", + "examples", + "minLength", + "maxLength", + "minimum", + "maximum", + "multipleOf", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", + "minProperties", + "maxProperties", + "allOf", + "anyOf", + "oneOf", + "not", + "if", + "then", + "else", + "dependentRequired", + "dependentSchemas", + "patternProperties", + "propertyNames", + "unevaluatedProperties", + "unevaluatedItems", + "contains", + "prefixItems", +]); +const GEMINI_FREE_TIER_ID = "free-tier"; +const GEMINI_ONBOARD_MAX_POLLS = 8; +const GEMINI_ONBOARD_POLL_DELAY_MS = 1500; +const geminiProjectIdCache = new Map(); +const geminiSessionIdCache = new Map(); + +function extractCodexUnsupportedParameter(errorBody: string): string | null { + const match = errorBody.match(/unsupported parameter:\s*([a-zA-Z0-9_.-]+)/i); + const candidate = match?.[1]?.trim(); + return candidate || null; +} + +export interface ModelRuntimeContext { + projectId?: string; + currentPath?: string; +} + +interface CliCommandResult { + code: number | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +const ENABLE_SUBPROCESS_CLI_FALLBACK = process.env.EGGENT_USE_SUBPROCESS_CLI === "1"; + +const EMPTY_USAGE: LanguageModelV3Usage = { + inputTokens: { + total: undefined, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, + outputTokens: { + total: undefined, + text: undefined, + reasoning: undefined, + }, +}; + +function collectPromptText(options: LanguageModelV3CallOptions): string { + const chunks: string[] = []; + + for (const message of options.prompt) { + if (!Array.isArray(message.content)) continue; + for (const part of message.content) { + if ( + part && + typeof part === "object" && + "type" in part && + part.type === "text" && + "text" in part && + typeof (part as { text?: unknown }).text === "string" + ) { + const text = (part as { text: string }).text; + if (text.trim()) { + chunks.push(text); + } + } + } + } + + return chunks.join("\n\n").trim(); +} + +function runCliCommand( + command: string, + args: string[], + options?: { stdinText?: string; timeoutMs?: number; cwd?: string } +): Promise { + const timeoutMs = options?.timeoutMs ?? 180000; + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let done = false; + let timedOut = false; + + let child; + try { + child = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + cwd: options?.cwd, + }); + } catch (error) { + resolve({ + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + timedOut: false, + }); + return; + } + + const timer = setTimeout(() => { + if (!done) { + timedOut = true; + child.kill("SIGKILL"); + } + }, timeoutMs); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + if (stdout.length > 200000) { + stdout = stdout.slice(-200000); + } + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + if (stderr.length > 200000) { + stderr = stderr.slice(-200000); + } + }); + + child.on("error", (error) => { + if (done) return; + done = true; + clearTimeout(timer); + resolve({ + code: 1, + stdout, + stderr: `${stderr}\n${error instanceof Error ? error.message : String(error)}`.trim(), + timedOut, + }); + }); + + child.on("close", (code) => { + if (done) return; + done = true; + clearTimeout(timer); + resolve({ code, stdout, stderr, timedOut }); + }); + + if (options?.stdinText) { + child.stdin.write(options.stdinText); + } + child.stdin.end(); + }); +} + +function toEnvRecord(value: Record | undefined): Record { + if (!value) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + const key = k.trim(); + if (!key) continue; + out[key] = String(v); + } + return out; +} + +function tomlQuote(value: string): string { + let out = "\""; + for (const char of value) { + switch (char) { + case "\\": + out += "\\\\"; + break; + case "\"": + out += "\\\""; + break; + case "\n": + out += "\\n"; + break; + case "\r": + out += "\\r"; + break; + case "\t": + out += "\\t"; + break; + default: + out += char; + } + } + out += "\""; + return out; +} + +function stableCodexServerKey(id: string, used: Set): string { + let base = id + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, ""); + if (!base) base = "mcp"; + if (!used.has(base)) { + used.add(base); + return base; + } + let index = 2; + while (used.has(`${base}_${index}`)) index += 1; + const withIndex = `${base}_${index}`; + used.add(withIndex); + return withIndex; +} + +function pushCodexStdioOverrides(overrides: string[], key: string, server: McpServerConfig): void { + if (server.transport !== "stdio") return; + const command = server.command?.trim(); + if (!command) return; + + overrides.push(`mcp_servers.${key}.command=${tomlQuote(command)}`); + + const args = (server.args || []).map((arg) => arg.trim()).filter(Boolean); + if (args.length > 0) { + overrides.push( + `mcp_servers.${key}.args=[${args.map((arg) => tomlQuote(arg)).join(", ")}]` + ); + } + + const env = toEnvRecord(server.env); + const envEntries = Object.entries(env) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, value]) => `${tomlQuote(name)} = ${tomlQuote(value)}`); + if (envEntries.length > 0) { + overrides.push(`mcp_servers.${key}.env={${envEntries.join(", ")}}`); + } + + if (server.cwd?.trim()) { + overrides.push(`mcp_servers.${key}.cwd=${tomlQuote(server.cwd.trim())}`); + } +} + +function pushCodexHttpOverrides(overrides: string[], key: string, server: McpServerConfig): void { + if (server.transport !== "http") return; + const url = server.url?.trim(); + if (!url) return; + + overrides.push(`mcp_servers.${key}.url=${tomlQuote(url)}`); + + const headers = toEnvRecord(server.headers); + const headerEntries = Object.entries(headers) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, value]) => `${tomlQuote(name)} = ${tomlQuote(value)}`); + if (headerEntries.length > 0) { + overrides.push(`mcp_servers.${key}.headers={${headerEntries.join(", ")}}`); + } +} + +function buildCodexMcpOverrides(config: ProjectMcpConfig | null): string[] { + if (!config?.servers?.length) return []; + const overrides: string[] = []; + const used = new Set(); + + for (const server of config.servers) { + const key = stableCodexServerKey(server.id, used); + pushCodexStdioOverrides(overrides, key, server); + pushCodexHttpOverrides(overrides, key, server); + } + + return overrides; +} + +async function resolveCodexMcpOverrides(projectId: string | undefined): Promise { + if (!projectId) return []; + try { + const config = await loadProjectMcpServers(projectId); + return buildCodexMcpOverrides(config); + } catch { + return []; + } +} + +function resolveCliWorkingDirectory(runtime: ModelRuntimeContext | undefined): string { + const projectId = runtime?.projectId; + if (!projectId) { + return process.cwd(); + } + + const root = path.resolve(getWorkDir(projectId)); + const rawCurrentPath = (runtime.currentPath || "").trim(); + if (!rawCurrentPath) return root; + + const candidate = path.resolve(root, rawCurrentPath); + if (candidate === root || candidate.startsWith(`${root}${path.sep}`)) { + return candidate; + } + + return root; +} + +function parseCodexOutput(rawStdout: string, rawStderr: string): string { + const lines = rawStdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const texts: string[] = []; + let explicitError = ""; + + for (const line of lines) { + try { + const parsed = JSON.parse(line) as Record; + const eventType = typeof parsed.type === "string" ? parsed.type : ""; + + if (eventType === "item.completed") { + const item = parsed.item as Record | undefined; + const itemType = typeof item?.type === "string" ? item.type : ""; + const text = typeof item?.text === "string" ? item.text : ""; + if (itemType === "agent_message" && text.trim()) { + texts.push(text.trim()); + } + } + + if (eventType === "error") { + const message = + typeof parsed.message === "string" + ? parsed.message + : typeof parsed.error === "string" + ? parsed.error + : ""; + if (message.trim()) { + explicitError = message.trim(); + } + } + } catch { + // Ignore non-JSON lines. + } + } + + if (texts.length > 0) { + return texts.join("\n\n"); + } + + if (explicitError) { + return explicitError; + } + + const fallback = `${rawStdout}\n${rawStderr}`.trim(); + return fallback || "Codex CLI returned no output."; +} + +function parseGeminiOutput(rawStdout: string, rawStderr: string): string { + const lines = rawStdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + let text = ""; + let explicitError = ""; + + for (const line of lines) { + try { + const parsed = JSON.parse(line) as Record; + const eventType = typeof parsed.type === "string" ? parsed.type : ""; + + if (eventType === "message") { + const role = typeof parsed.role === "string" ? parsed.role : ""; + const content = typeof parsed.content === "string" ? parsed.content : ""; + if (role === "assistant" && content) { + text += content; + } + } + + if (eventType === "error") { + const message = + typeof parsed.message === "string" + ? parsed.message + : typeof parsed.error === "string" + ? parsed.error + : ""; + if (message.trim()) { + explicitError = message.trim(); + } + } + } catch { + // Ignore non-JSON lines. + } + } + + if (text.trim()) { + return text.trim(); + } + if (explicitError) { + return explicitError; + } + + const fallback = `${rawStdout}\n${rawStderr}`.trim(); + return fallback || "Gemini CLI returned no output."; +} + +async function runCliModel( + provider: CliProviderName, + model: string, + prompt: string, + runtime: ModelRuntimeContext | undefined +): Promise { + const cwd = resolveCliWorkingDirectory(runtime); + + if (provider === "codex-cli") { + const command = process.env.CODEX_COMMAND || "codex"; + const args = ["exec", "--json", "--full-auto", "--skip-git-repo-check"]; + const codexMcpOverrides = await resolveCodexMcpOverrides(runtime?.projectId); + for (const override of codexMcpOverrides) { + args.push("-c", override); + } + if (model) { + args.push("-m", model); + } + args.push("-"); + + const result = await runCliCommand(command, args, { + stdinText: `${prompt}\n`, + timeoutMs: 240000, + cwd, + }); + + if (result.timedOut) { + throw new Error("Codex CLI timed out."); + } + if (result.code !== 0 && !result.stdout.trim()) { + throw new Error((result.stderr || "Codex CLI execution failed.").trim()); + } + + return parseCodexOutput(result.stdout, result.stderr); + } + + const command = process.env.GEMINI_CLI_COMMAND || "gemini"; + const args = ["-m", model, "-p", prompt, "--output-format", "stream-json", "--yolo"]; + const result = await runCliCommand(command, args, { timeoutMs: 240000, cwd }); + + if (result.timedOut) { + throw new Error("Gemini CLI timed out."); + } + if (result.code !== 0 && !result.stdout.trim()) { + throw new Error((result.stderr || "Gemini CLI execution failed.").trim()); + } + + return parseGeminiOutput(result.stdout, result.stderr); +} + +function createCliLanguageModel( + provider: CliProviderName, + config: ModelConfig, + runtime: ModelRuntimeContext | undefined +): LanguageModel { + const modelId = config.model || (provider === "codex-cli" ? "gpt-5.2-codex" : "gemini-2.5-pro"); + + const generate = async ( + options: LanguageModelV3CallOptions + ): Promise => { + const prompt = collectPromptText(options); + const text = await runCliModel( + provider, + modelId, + prompt || "Continue.", + runtime + ); + + return { + content: [{ type: "text", text }], + finishReason: { unified: "stop", raw: "stop" }, + usage: EMPTY_USAGE, + warnings: [], + request: { + body: { + provider, + model: modelId, + promptLength: prompt.length, + }, + }, + }; + }; + + const model: LanguageModelV3 = { + specificationVersion: "v3", + provider, + modelId, + supportedUrls: {}, + doGenerate: generate, + async doStream(options: LanguageModelV3CallOptions): Promise { + const generated = await generate(options); + const textPart = generated.content.find( + (part): part is { type: "text"; text: string } => part.type === "text" + ); + const text = textPart?.text || ""; + const id = crypto.randomUUID(); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue({ type: "stream-start", warnings: [] }); + controller.enqueue({ type: "text-start", id }); + if (text) { + controller.enqueue({ type: "text-delta", id, delta: text }); + } + controller.enqueue({ type: "text-end", id }); + controller.enqueue({ + type: "finish", + finishReason: generated.finishReason, + usage: generated.usage, + }); + controller.close(); + }, + }); + + return { stream }; + }, + }; + + return model as unknown as LanguageModel; +} + +function createCodexOauthFetch(credential: { + accessToken: string; + accountId?: string; +}) { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = new Request(input, init); + const headers = new Headers(request.headers); + headers.set("authorization", `Bearer ${credential.accessToken}`); + headers.set("accept", "application/json"); + if (credential.accountId) { + headers.set("chatgpt-account-id", credential.accountId); + } + + if (request.method.toUpperCase() !== "POST") { + return fetch(new Request(request, { headers })); + } + + const rawBody = await request.text(); + if (!rawBody.trim()) { + return fetch(new Request(request, { headers })); + } + + try { + const parsed = JSON.parse(rawBody) as Record; + if (parsed.store !== false) { + parsed.store = false; + } + if (typeof parsed.instructions !== "string" || !parsed.instructions.trim()) { + parsed.instructions = DEFAULT_CODEX_INSTRUCTIONS; + } + for (const key of CODEX_UNSUPPORTED_FIELDS) { + if (key in parsed) { + delete parsed[key]; + } + } + let response = await fetch( + new Request(request, { + headers, + body: JSON.stringify(parsed), + }) + ); + if (response.status === 400) { + const errorBody = await response.clone().text().catch(() => ""); + const unsupportedField = extractCodexUnsupportedParameter(errorBody); + if (unsupportedField && unsupportedField in parsed) { + delete parsed[unsupportedField]; + response = await fetch( + new Request(request, { + headers, + body: JSON.stringify(parsed), + }) + ); + } + } + return response; + } catch { + return fetch( + new Request(request, { + headers, + body: rawBody, + }) + ); + } + }; +} + +function resolveGeminiCodeAssistPlatform(): "WINDOWS" | "MACOS" | "PLATFORM_UNSPECIFIED" { + if (process.platform === "win32") { + return "WINDOWS"; + } + if (process.platform === "darwin") { + return "MACOS"; + } + return "PLATFORM_UNSPECIFIED"; +} + +function getGeminiEnvProjectId(): string | undefined { + const candidate = (process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || "").trim(); + return candidate || undefined; +} + +function extractGeminiCodeAssistProjectId(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return undefined; + } + + const record = payload as Record; + const direct = record.cloudaicompanionProject; + if (typeof direct === "string" && direct.trim()) { + return direct.trim(); + } + if ( + direct && + typeof direct === "object" && + !Array.isArray(direct) && + typeof (direct as Record).id === "string" + ) { + const directId = (direct as Record).id as string; + if (directId.trim()) { + return directId.trim(); + } + } + + const nested = record.response; + if ( + nested && + typeof nested === "object" && + !Array.isArray(nested) && + (nested as Record).cloudaicompanionProject && + typeof (nested as Record).cloudaicompanionProject === "object" + ) { + const nestedProject = ((nested as Record).cloudaicompanionProject as Record).id; + if (typeof nestedProject === "string" && nestedProject.trim()) { + return nestedProject.trim(); + } + } + + return undefined; +} + +function normalizeEndpoint(endpoint: string): string { + return endpoint.trim().replace(/\/+$/, ""); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function extractGeminiCurrentTierId(payload: unknown): string | undefined { + if (!isPlainRecord(payload)) { + return undefined; + } + const currentTier = payload.currentTier; + if (!isPlainRecord(currentTier)) { + return undefined; + } + const id = currentTier.id; + if (typeof id === "string" && id.trim()) { + return id.trim(); + } + return undefined; +} + +function extractGeminiDefaultAllowedTierId(payload: unknown): string | undefined { + if (!isPlainRecord(payload)) { + return undefined; + } + const allowed = payload.allowedTiers; + if (!Array.isArray(allowed)) { + return undefined; + } + + for (const entry of allowed) { + if (!isPlainRecord(entry)) { + continue; + } + const isDefault = entry.isDefault === true; + const id = typeof entry.id === "string" ? entry.id.trim() : ""; + if (isDefault && id) { + return id; + } + } + + for (const entry of allowed) { + if (!isPlainRecord(entry)) { + continue; + } + const id = typeof entry.id === "string" ? entry.id.trim() : ""; + if (id) { + return id; + } + } + + return undefined; +} + +function extractGeminiOperationName(payload: unknown): string | undefined { + if (!isPlainRecord(payload)) { + return undefined; + } + const name = payload.name; + if (typeof name === "string" && name.trim()) { + return name.trim(); + } + return undefined; +} + +async function pollGeminiOnboardOperation(params: { + endpoint: string; + operationName: string; + accessToken: string; + apiClient: string; +}): Promise { + const operationPath = params.operationName.replace(/^\/+/, ""); + for (let attempt = 0; attempt < GEMINI_ONBOARD_MAX_POLLS; attempt += 1) { + if (attempt > 0) { + await sleep(GEMINI_ONBOARD_POLL_DELAY_MS); + } + try { + const response = await fetch( + `${params.endpoint}/${GEMINI_CODE_ASSIST_API_VERSION}/${operationPath}`, + { + method: "GET", + headers: { + authorization: `Bearer ${params.accessToken}`, + accept: "application/json", + "content-type": "application/json", + "x-goog-api-client": params.apiClient, + "user-agent": GEMINI_CODE_ASSIST_USER_AGENT, + }, + } + ); + if (!response.ok) { + continue; + } + const payload = (await response.json().catch(() => null)) as unknown; + if (!isPlainRecord(payload)) { + continue; + } + if (payload.done === true || payload.response !== undefined) { + return payload; + } + } catch { + // Ignore poll failures; caller handles fallback. + } + } + return undefined; +} + +async function resolveGeminiCodeAssistProjectId(params: { + accessToken: string; + apiClient: string; + preferredEndpoint?: string; +}): Promise { + const envProject = getGeminiEnvProjectId(); + if (envProject) { + return envProject; + } + + const cached = geminiProjectIdCache.get(params.accessToken); + if (cached !== undefined) { + return cached || undefined; + } + + const endpoints = [ + params.preferredEndpoint, + ...GEMINI_CODE_ASSIST_LOAD_ENDPOINTS, + ] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .map((value) => normalizeEndpoint(value)); + + const deduped = [...new Set(endpoints)]; + const metadataPayload = { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }; + + for (const endpoint of deduped) { + const loadBody = { + ...(envProject ? { cloudaicompanionProject: envProject } : {}), + metadata: { + ...metadataPayload, + ...(envProject ? { duetProject: envProject } : {}), + }, + }; + + try { + const response = await fetch(`${endpoint}/${GEMINI_CODE_ASSIST_API_VERSION}:loadCodeAssist`, { + method: "POST", + headers: { + authorization: `Bearer ${params.accessToken}`, + accept: "application/json", + "content-type": "application/json", + "x-goog-api-client": params.apiClient, + "user-agent": GEMINI_CODE_ASSIST_USER_AGENT, + }, + body: JSON.stringify(loadBody), + }); + + if (!response.ok) { + continue; + } + + const payload = (await response.json().catch(() => null)) as unknown; + const projectId = extractGeminiCodeAssistProjectId(payload); + if (projectId) { + geminiProjectIdCache.set(params.accessToken, projectId); + return projectId; + } + + if (extractGeminiCurrentTierId(payload)) { + continue; + } + + const tierId = extractGeminiDefaultAllowedTierId(payload) || GEMINI_FREE_TIER_ID; + const onboardBody: Record = { + tierId, + metadata: { + ...metadataPayload, + ...(envProject && tierId !== GEMINI_FREE_TIER_ID ? { duetProject: envProject } : {}), + }, + }; + if (envProject && tierId !== GEMINI_FREE_TIER_ID) { + onboardBody.cloudaicompanionProject = envProject; + } + + const onboardResponse = await fetch(`${endpoint}/${GEMINI_CODE_ASSIST_API_VERSION}:onboardUser`, { + method: "POST", + headers: { + authorization: `Bearer ${params.accessToken}`, + accept: "application/json", + "content-type": "application/json", + "x-goog-api-client": params.apiClient, + "user-agent": GEMINI_CODE_ASSIST_USER_AGENT, + }, + body: JSON.stringify(onboardBody), + }); + if (!onboardResponse.ok) { + continue; + } + + const onboardPayload = (await onboardResponse.json().catch(() => null)) as unknown; + const onboardProjectId = extractGeminiCodeAssistProjectId(onboardPayload); + if (onboardProjectId) { + geminiProjectIdCache.set(params.accessToken, onboardProjectId); + return onboardProjectId; + } + + const operationName = extractGeminiOperationName(onboardPayload); + if (!operationName) { + continue; + } + + const finalPayload = await pollGeminiOnboardOperation({ + endpoint, + operationName, + accessToken: params.accessToken, + apiClient: params.apiClient, + }); + const finalProjectId = extractGeminiCodeAssistProjectId(finalPayload); + if (finalProjectId) { + geminiProjectIdCache.set(params.accessToken, finalProjectId); + return finalProjectId; + } + } catch { + // Ignore discovery errors; we'll proceed without project if needed. + } + } + + return undefined; +} + +function parseGeminiModelMethod(pathname: string): { + modelId: string; + method: "generateContent" | "streamGenerateContent"; +} | null { + const marker = "/models/"; + const markerIndex = pathname.lastIndexOf(marker); + if (markerIndex < 0) { + return null; + } + + const tail = pathname.slice(markerIndex + marker.length); + const methodIndex = tail.lastIndexOf(":"); + if (methodIndex <= 0) { + return null; + } + + const method = tail.slice(methodIndex + 1); + if (method !== "generateContent" && method !== "streamGenerateContent") { + return null; + } + + const modelId = decodeURIComponent(tail.slice(0, methodIndex)).trim(); + if (!modelId) { + return null; + } + + return { + modelId, + method, + }; +} + +function isPlainRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function sanitizeGeminiCodeAssistSchema(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((item) => sanitizeGeminiCodeAssistSchema(item)); + } + if (!isPlainRecord(schema)) { + return schema; + } + + const out: Record = {}; + for (const [key, value] of Object.entries(schema)) { + if (GEMINI_CODE_ASSIST_SCHEMA_BLOCKLIST.has(key)) { + continue; + } + if (key === "const") { + out.enum = [value]; + continue; + } + if ( + key === "type" && + Array.isArray(value) && + value.every((entry) => typeof entry === "string") + ) { + const normalizedTypes = value.filter((entry) => entry !== "null"); + out.type = normalizedTypes.length === 1 ? normalizedTypes[0] : normalizedTypes; + continue; + } + if (key === "additionalProperties" && value !== false) { + continue; + } + out[key] = sanitizeGeminiCodeAssistSchema(value); + } + return out; +} + +function sanitizeGeminiCodeAssistRequest(requestBody: unknown): Record { + if (!isPlainRecord(requestBody)) { + return {}; + } + + const sanitized: Record = { ...requestBody }; + const tools = sanitized.tools; + if (!Array.isArray(tools)) { + return sanitized; + } + + sanitized.tools = tools.map((tool) => { + if (!isPlainRecord(tool)) { + return tool; + } + const functionDeclarations = tool.functionDeclarations; + if (!Array.isArray(functionDeclarations)) { + return tool; + } + return { + ...tool, + functionDeclarations: functionDeclarations.map((declaration) => { + if (!isPlainRecord(declaration)) { + return declaration; + } + + const result: Record = { ...declaration }; + if ("parameters" in result) { + result.parameters = sanitizeGeminiCodeAssistSchema(result.parameters); + } + if ("parametersJsonSchema" in result) { + result.parametersJsonSchema = sanitizeGeminiCodeAssistSchema(result.parametersJsonSchema); + } + return result; + }), + }; + }); + + return sanitized; +} + +function buildGeminiCodeAssistRequestBody(params: { + requestBody: unknown; + modelId: string; + projectId?: string; + sessionId?: string; +}): Record { + const body = + params.requestBody && + typeof params.requestBody === "object" && + !Array.isArray(params.requestBody) + ? (params.requestBody as Record) + : {}; + + const requestBody = { + ...body, + ...(typeof body.session_id === "string" && body.session_id.trim() + ? {} + : params.sessionId + ? { session_id: params.sessionId } + : {}), + }; + + return { + model: params.modelId, + ...(params.projectId ? { project: params.projectId } : {}), + user_prompt_id: crypto.randomUUID(), + request: requestBody, + }; +} + +function shouldRetryGeminiCodeAssist(response: Response, body: string): boolean { + if (response.status !== 500) { + return false; + } + const normalized = body.toLowerCase(); + return ( + normalized.includes("internal error encountered") || normalized.includes("\"status\": \"internal\"") + ); +} + +function buildGeminiCodeAssistFallbackBodies(requestBody: Record): Array> { + const candidates: Array> = []; + const seen = new Set(); + const pushUnique = (value: Record) => { + const signature = JSON.stringify(value); + if (!seen.has(signature)) { + seen.add(signature); + candidates.push(value); + } + }; + + if ("toolConfig" in requestBody) { + const withoutToolConfig = { ...requestBody }; + delete withoutToolConfig.toolConfig; + pushUnique(withoutToolConfig); + } + + if ("tools" in requestBody || "toolConfig" in requestBody) { + const withoutTools = { ...requestBody }; + delete withoutTools.toolConfig; + delete withoutTools.tools; + pushUnique(withoutTools); + } + + if ( + "generationConfig" in requestBody || + "safetySettings" in requestBody || + "labels" in requestBody || + "cachedContent" in requestBody + ) { + const reducedConfig = { ...requestBody }; + delete reducedConfig.generationConfig; + delete reducedConfig.safetySettings; + delete reducedConfig.labels; + delete reducedConfig.cachedContent; + pushUnique(reducedConfig); + } + + const minimal: Record = {}; + if ("contents" in requestBody) { + minimal.contents = requestBody.contents; + } + if ("systemInstruction" in requestBody) { + minimal.systemInstruction = requestBody.systemInstruction; + } + if (Object.keys(minimal).length > 0) { + pushUnique(minimal); + } + + return candidates; +} + +function logGeminiCodeAssistAttemptFailure(params: { + status: number; + stage: string; + method: "generateContent" | "streamGenerateContent"; + modelId: string; + hasProject: boolean; + body: string; +}): void { + if (params.status !== 500) { + return; + } + const condensedBody = params.body.replace(/\s+/g, " ").trim().slice(0, 220); + console.warn( + `[gemini-cli] Code Assist ${params.method} failed at stage=${params.stage} ` + + `model=${params.modelId} project=${params.hasProject ? "set" : "missing"} ` + + `status=${params.status} body=${condensedBody}` + ); +} + +function unwrapGeminiCodeAssistResponse(payload: unknown): unknown { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return payload; + } + + const record = payload as Record; + const response = record.response; + if (response !== undefined) { + return response; + } + + return payload; +} + +function rewriteGeminiCodeAssistEventData(rawData: string): string { + const trimmed = rawData.trim(); + if (!trimmed || trimmed === "[DONE]") { + return rawData; + } + + try { + const parsed = JSON.parse(trimmed) as unknown; + const unwrapped = unwrapGeminiCodeAssistResponse(parsed); + return JSON.stringify(unwrapped); + } catch { + return rawData; + } +} + +function rewriteGeminiCodeAssistSseStream( + stream: ReadableStream +): ReadableStream { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + + return new ReadableStream({ + async start(controller) { + const reader = stream.getReader(); + let pendingLine = ""; + let eventDataLines: string[] = []; + + const flushEvent = () => { + if (eventDataLines.length === 0) { + return; + } + + const data = eventDataLines.join("\n"); + eventDataLines = []; + controller.enqueue( + encoder.encode(`data: ${rewriteGeminiCodeAssistEventData(data)}\n\n`) + ); + }; + + const processLine = (rawLine: string) => { + let line = rawLine; + if (line.endsWith("\r")) { + line = line.slice(0, -1); + } + + if (line.startsWith("data:")) { + eventDataLines.push(line.slice(5).trimStart()); + return; + } + + if (line.length === 0) { + flushEvent(); + return; + } + + controller.enqueue(encoder.encode(`${line}\n`)); + }; + + try { + while (true) { + const { value, done } = await reader.read(); + if (done) { + break; + } + + pendingLine += decoder.decode(value, { stream: true }); + while (true) { + const newlineIdx = pendingLine.indexOf("\n"); + if (newlineIdx === -1) { + break; + } + const line = pendingLine.slice(0, newlineIdx); + pendingLine = pendingLine.slice(newlineIdx + 1); + processLine(line); + } + } + + pendingLine += decoder.decode(); + if (pendingLine.length > 0) { + processLine(pendingLine); + } + flushEvent(); + controller.close(); + } catch (error) { + controller.error(error); + } finally { + reader.releaseLock(); + } + }, + }); +} + +function createGeminiOauthFetch(accessToken: string) { + const apiClient = `gl-node/${process.versions.node}`; + const sessionId = + geminiSessionIdCache.get(accessToken) || (() => { + const value = crypto.randomUUID(); + geminiSessionIdCache.set(accessToken, value); + return value; + })(); + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = new Request(input, init); + const headers = new Headers(request.headers); + headers.delete("x-goog-api-key"); + headers.set("authorization", `Bearer ${accessToken}`); + headers.set("x-goog-api-client", apiClient); + headers.set("user-agent", GEMINI_CODE_ASSIST_USER_AGENT); + + if (request.method.toUpperCase() !== "POST") { + return fetch(new Request(request, { headers })); + } + + const requestUrl = new URL(request.url); + const parsed = parseGeminiModelMethod(requestUrl.pathname); + if (!parsed) { + return fetch(new Request(request, { headers })); + } + + const rawBody = await request.text(); + if (!rawBody.trim()) { + return fetch(new Request(request, { headers })); + } + + let requestBody: unknown; + try { + requestBody = JSON.parse(rawBody) as unknown; + } catch { + return fetch( + new Request(request, { + headers, + body: rawBody, + }) + ); + } + + const endpoint = normalizeEndpoint(`${requestUrl.protocol}//${requestUrl.host}`); + const projectId = await resolveGeminiCodeAssistProjectId({ + accessToken, + apiClient, + preferredEndpoint: endpoint, + }); + const sanitizedRequestBody = sanitizeGeminiCodeAssistRequest(requestBody); + const targetUrl = `${endpoint}/${GEMINI_CODE_ASSIST_API_VERSION}:${parsed.method}${ + parsed.method === "streamGenerateContent" ? "?alt=sse" : "" + }`; + const postCodeAssist = async (innerRequest: Record) => { + const wrappedBody = buildGeminiCodeAssistRequestBody({ + requestBody: innerRequest, + modelId: parsed.modelId, + projectId, + sessionId, + }); + return fetch(targetUrl, { + method: "POST", + headers, + body: JSON.stringify(wrappedBody), + signal: request.signal, + }); + }; + + let response = await postCodeAssist(sanitizedRequestBody); + if (!response.ok) { + const firstErrorText = await response.clone().text().catch(() => ""); + logGeminiCodeAssistAttemptFailure({ + status: response.status, + stage: "initial", + method: parsed.method, + modelId: parsed.modelId, + hasProject: typeof projectId === "string" && projectId.length > 0, + body: firstErrorText, + }); + if (shouldRetryGeminiCodeAssist(response, firstErrorText)) { + const fallbacks = buildGeminiCodeAssistFallbackBodies(sanitizedRequestBody); + for (let i = 0; i < fallbacks.length; i += 1) { + const fallbackRequest = fallbacks[i]; + const retryResponse = await postCodeAssist(fallbackRequest); + if (retryResponse.ok) { + response = retryResponse; + break; + } + response = retryResponse; + const retryErrorText = await retryResponse.clone().text().catch(() => ""); + logGeminiCodeAssistAttemptFailure({ + status: retryResponse.status, + stage: `fallback-${i + 1}`, + method: parsed.method, + modelId: parsed.modelId, + hasProject: typeof projectId === "string" && projectId.length > 0, + body: retryErrorText, + }); + if (!shouldRetryGeminiCodeAssist(retryResponse, retryErrorText)) { + break; + } + } + } + } + + if (!response.ok) { + return response; + } + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-length"); + + if (parsed.method === "streamGenerateContent") { + if (!response.body) { + return response; + } + return new Response(rewriteGeminiCodeAssistSseStream(response.body), { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } + + const responseText = await response.text(); + if (!responseText.trim()) { + return new Response(responseText, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } + + try { + const parsedResponse = JSON.parse(responseText) as unknown; + return new Response( + JSON.stringify(unwrapGeminiCodeAssistResponse(parsedResponse)), + { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + } + ); + } catch { + return new Response(responseText, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } + }; +} + +function createCodexNativeOauthModel(config: ModelConfig): LanguageModel { + const credential = resolveCliOAuthCredentialSync("codex-cli"); + const baseURL = + normalizeBaseUrl(config.baseUrl, { + providerName: "codex-cli", + fallbackBaseUrl: CODEX_BACKEND_BASE_URL, + defaultPath: "/backend-api/codex", + }) ?? CODEX_BACKEND_BASE_URL; + const sanitizedBaseURL = baseURL.replace(/\/responses\/?$/, ""); + const provider = createOpenAI({ + apiKey: credential.accessToken, + baseURL: sanitizedBaseURL, + headers: credential.accountId + ? { + "ChatGPT-Account-Id": credential.accountId, + } + : undefined, + fetch: createCodexOauthFetch(credential), + name: "openai-codex", + }); + const modelId = config.model || "gpt-5.3-codex"; + return provider.responses(modelId); +} + +function createGeminiNativeOauthModel(config: ModelConfig): LanguageModel { + const credential = resolveCliOAuthCredentialSync("gemini-cli"); + const metadata = JSON.stringify({ + ideType: "ANTIGRAVITY", + platform: resolveGeminiCodeAssistPlatform(), + pluginType: "GEMINI", + }); + const baseURL = + normalizeBaseUrl(config.baseUrl, { + providerName: "gemini-cli", + fallbackBaseUrl: GEMINI_CODE_ASSIST_BASE_URL, + defaultPath: "/v1beta", + }) ?? GEMINI_CODE_ASSIST_BASE_URL; + const provider = createGoogleGenerativeAI({ + apiKey: "__oauth__", + baseURL, + headers: { + Authorization: `Bearer ${credential.accessToken}`, + "X-Goog-Api-Client": `gl-node/${process.versions.node}`, + "Client-Metadata": metadata, + }, + fetch: createGeminiOauthFetch(credential.accessToken), + name: "google-gemini-cli", + }); + const modelId = config.model || "gemini-2.5-pro"; + return provider(modelId); +} + +function describeError(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + function normalizeBaseUrl(rawBaseUrl: string | undefined, settings: { providerName: string; fallbackBaseUrl?: string; @@ -84,7 +1540,10 @@ function createOpenAICompatibleEmbeddingModel(config: { /** * Create an AI SDK language model from our ModelConfig */ -export function createModel(config: ModelConfig): LanguageModel { +export function createModel( + config: ModelConfig, + runtime?: ModelRuntimeContext +): LanguageModel { switch (config.provider) { case "openai": { return createOpenAICompatibleChatModel(config, { @@ -94,17 +1553,27 @@ export function createModel(config: ModelConfig): LanguageModel { } case "anthropic": { + const baseURL = normalizeBaseUrl(config.baseUrl, { + providerName: "anthropic", + fallbackBaseUrl: "https://api.anthropic.com", + defaultPath: "/v1", + }); const anthropic = createAnthropic({ apiKey: config.apiKey || process.env.ANTHROPIC_API_KEY || "", - baseURL: config.baseUrl, + baseURL, }); return anthropic(config.model); } case "google": { + const baseURL = normalizeBaseUrl(config.baseUrl, { + providerName: "google", + fallbackBaseUrl: "https://generativelanguage.googleapis.com", + defaultPath: "/v1beta", + }); const google = createGoogleGenerativeAI({ apiKey: config.apiKey || process.env.GOOGLE_API_KEY || "", - baseURL: config.baseUrl, + baseURL, }); return google(config.model); } @@ -135,6 +1604,32 @@ export function createModel(config: ModelConfig): LanguageModel { }); } + case "codex-cli": { + try { + return createCodexNativeOauthModel(config); + } catch (cause) { + if (ENABLE_SUBPROCESS_CLI_FALLBACK) { + return createCliLanguageModel("codex-cli", config, runtime); + } + throw new Error( + `Codex OAuth transport is not ready: ${describeError(cause)}` + ); + } + } + + case "gemini-cli": { + try { + return createGeminiNativeOauthModel(config); + } catch (cause) { + if (ENABLE_SUBPROCESS_CLI_FALLBACK) { + return createCliLanguageModel("gemini-cli", config, runtime); + } + throw new Error( + `Gemini OAuth transport is not ready: ${describeError(cause)}` + ); + } + } + default: throw new Error(`Unknown provider: ${config.provider}`); } @@ -180,9 +1675,14 @@ export function createEmbeddingModel(config: { }); case "google": { + const baseURL = normalizeBaseUrl(config.baseUrl, { + providerName: "google", + fallbackBaseUrl: "https://generativelanguage.googleapis.com", + defaultPath: "/v1beta", + }); const google = createGoogleGenerativeAI({ apiKey: config.apiKey || process.env.GOOGLE_API_KEY || "", - baseURL: config.baseUrl, + baseURL, }); return google.embedding(config.model); } diff --git a/src/lib/providers/model-config.ts b/src/lib/providers/model-config.ts index 5c4d463..d3ed9cd 100644 --- a/src/lib/providers/model-config.ts +++ b/src/lib/providers/model-config.ts @@ -2,6 +2,8 @@ * Available model providers and their models */ +import type { ChatAuthMethod } from "@/lib/types"; + export interface ProviderConfig { name: string; models: { id: string; name: string }[]; @@ -9,6 +11,20 @@ export interface ProviderConfig { envKey?: string; baseUrl?: string; requiresApiKey: boolean; + authMethods?: ChatAuthMethod[]; + defaultAuthMethod?: ChatAuthMethod; + connectionHelp?: { + apiKey?: { + title: string; + steps: string[]; + command?: string; + }; + oauth?: { + title: string; + steps: string[]; + command?: string; + }; + }; } export const MODEL_PROVIDERS: Record = { @@ -21,24 +37,32 @@ export const MODEL_PROVIDERS: Record = { ], envKey: "OPENAI_API_KEY", requiresApiKey: true, + authMethods: ["api_key"], + defaultAuthMethod: "api_key", }, anthropic: { name: "Anthropic", models: [], envKey: "ANTHROPIC_API_KEY", requiresApiKey: true, + authMethods: ["api_key"], + defaultAuthMethod: "api_key", }, google: { name: "Google", models: [], envKey: "GOOGLE_API_KEY", requiresApiKey: true, + authMethods: ["api_key"], + defaultAuthMethod: "api_key", }, openrouter: { name: "OpenRouter", models: [], envKey: "OPENROUTER_API_KEY", requiresApiKey: true, + authMethods: ["api_key"], + defaultAuthMethod: "api_key", }, ollama: { name: "Ollama", @@ -49,5 +73,58 @@ export const MODEL_PROVIDERS: Record = { ], baseUrl: "http://localhost:11434", requiresApiKey: false, + authMethods: ["api_key"], + defaultAuthMethod: "api_key", + }, + "codex-cli": { + name: "Codex CLI", + models: [ + { id: "gpt-5.3-codex", name: "gpt-5.3-codex" }, + { id: "gpt-5.4", name: "gpt-5.4" }, + { id: "gpt-5.2-codex", name: "gpt-5.2-codex" }, + { id: "gpt-5.1-codex-max", name: "gpt-5.1-codex-max" }, + { id: "gpt-5.2", name: "gpt-5.2" }, + { id: "gpt-5.1-codex-mini", name: "gpt-5.1-codex-mini" }, + ], + requiresApiKey: false, + authMethods: ["oauth"], + defaultAuthMethod: "oauth", + connectionHelp: { + oauth: { + title: "Connect with OAuth via Codex CLI", + command: "codex login", + steps: [ + "Install Codex CLI if needed: npm i -g @openai/codex", + "Run codex login in your terminal.", + "Complete browser authorization.", + "Return here and click Check connection.", + ], + }, + }, + }, + "gemini-cli": { + name: "Gemini CLI", + models: [ + { id: "gemini-3.1-pro-preview", name: "gemini-3.1-pro-preview" }, + { id: "gemini-3-flash-preview", name: "gemini-3-flash-preview" }, + { id: "gemini-2.5-pro", name: "gemini-2.5-pro" }, + { id: "gemini-2.5-flash", name: "gemini-2.5-flash" }, + { id: "gemini-2.5-flash-lite", name: "gemini-2.5-flash-lite" }, + ], + requiresApiKey: false, + authMethods: ["oauth"], + defaultAuthMethod: "oauth", + connectionHelp: { + oauth: { + title: "Connect with OAuth via Gemini CLI", + command: "gemini", + steps: [ + "Install Gemini CLI if needed: npm i -g @google/gemini-cli", + "Run gemini in your terminal.", + "Choose Login with Google and complete browser authorization.", + "Return here and click Check connection.", + ], + }, + }, }, }; diff --git a/src/lib/providers/provider-auth.ts b/src/lib/providers/provider-auth.ts new file mode 100644 index 0000000..a1f351f --- /dev/null +++ b/src/lib/providers/provider-auth.ts @@ -0,0 +1,380 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import type { ChatAuthMethod } from "@/lib/types"; + +export type CliProvider = "codex-cli" | "gemini-cli"; + +export interface ProviderAuthStatus { + provider: CliProvider; + method: ChatAuthMethod; + connected: boolean; + message: string; + detail?: string; +} + +export interface ProviderAuthConnectResult extends ProviderAuthStatus { + started?: boolean; + command?: string; +} + +export interface ResolvedCliOAuthCredential { + provider: CliProvider; + accessToken: string; + refreshToken?: string; + expiresAt?: number; + accountId?: string; +} + +interface CodexAuthFile { + auth_mode?: unknown; + tokens?: { + access_token?: unknown; + refresh_token?: unknown; + account_id?: unknown; + }; + last_refresh?: unknown; +} + +interface GeminiOauthCreds { + access_token?: unknown; + refresh_token?: unknown; + expiry_date?: unknown; +} + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function asEpochMs(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + +function readJsonObject(filePath: string): Record | null { + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +function readCodexAuth(): { path: string; parsed: CodexAuthFile | null } { + const authPath = path.join(os.homedir(), ".codex", "auth.json"); + const parsed = readJsonObject(authPath) as CodexAuthFile | null; + return { path: authPath, parsed }; +} + +function resolveCodexCredential(): ResolvedCliOAuthCredential { + const { parsed } = readCodexAuth(); + if (!parsed) { + throw new Error("Codex OAuth file is missing. Run `codex login`."); + } + + const authMode = asNonEmptyString(parsed.auth_mode)?.toLowerCase() || ""; + const accessToken = asNonEmptyString(parsed.tokens?.access_token); + const refreshToken = asNonEmptyString(parsed.tokens?.refresh_token); + const accountId = asNonEmptyString(parsed.tokens?.account_id) || undefined; + + if (authMode !== "chatgpt") { + throw new Error("Codex CLI is not in OAuth mode (`auth_mode=chatgpt` required)."); + } + if (!accessToken || !refreshToken) { + throw new Error("Codex OAuth tokens are missing. Run `codex login`."); + } + + return { + provider: "codex-cli", + accessToken, + refreshToken, + accountId, + }; +} + +function checkCodexOauthStatus(): ProviderAuthStatus { + const { path: authPath, parsed } = readCodexAuth(); + + if (!parsed) { + return { + provider: "codex-cli", + method: "oauth", + connected: false, + message: "Codex CLI OAuth token file was not found.", + detail: `Expected: ${authPath}`, + }; + } + + const authMode = asNonEmptyString(parsed.auth_mode)?.toLowerCase() || ""; + const accessToken = asNonEmptyString(parsed.tokens?.access_token); + const refreshToken = asNonEmptyString(parsed.tokens?.refresh_token); + const accountId = asNonEmptyString(parsed.tokens?.account_id); + const lastRefresh = asEpochMs(parsed.last_refresh); + + if (authMode !== "chatgpt") { + return { + provider: "codex-cli", + method: "oauth", + connected: false, + message: "Codex CLI is not in OAuth mode.", + detail: authMode + ? `auth_mode=${authMode}. Run \`codex login\` with ChatGPT.` + : "auth_mode is missing in ~/.codex/auth.json", + }; + } + + if (!accessToken || !refreshToken) { + return { + provider: "codex-cli", + method: "oauth", + connected: false, + message: "Codex CLI OAuth tokens are missing.", + detail: "Run `codex login` and complete browser authorization.", + }; + } + + const detailParts: string[] = []; + if (accountId) detailParts.push(`account_id=${accountId}`); + if (lastRefresh) detailParts.push(`last_refresh=${new Date(lastRefresh).toISOString()}`); + + return { + provider: "codex-cli", + method: "oauth", + connected: true, + message: "Codex CLI OAuth is configured.", + detail: detailParts.length > 0 ? detailParts.join("; ") : undefined, + }; +} + +function readGeminiSettings(): Record | null { + const settingsPath = path.join(os.homedir(), ".gemini", "settings.json"); + return readJsonObject(settingsPath); +} + +function readGeminiOauthCreds(): { path: string; parsed: GeminiOauthCreds | null } { + const credsPath = path.join(os.homedir(), ".gemini", "oauth_creds.json"); + const parsed = readJsonObject(credsPath) as GeminiOauthCreds | null; + return { path: credsPath, parsed }; +} + +function resolveGeminiCredential(): ResolvedCliOAuthCredential { + const { parsed: creds } = readGeminiOauthCreds(); + const settings = readGeminiSettings(); + if (!creds) { + throw new Error("Gemini OAuth file is missing. Run `gemini` and login with Google."); + } + + const selectedType = ( + (settings?.security as Record | undefined)?.auth as + | Record + | undefined + )?.selectedType; + + const selectedTypeValue = + typeof selectedType === "string" ? selectedType.trim().toLowerCase() : ""; + const selectedOauth = + selectedTypeValue === "oauth-personal" || + selectedTypeValue === "login_with_google" || + selectedTypeValue.startsWith("oauth"); + + if (!selectedOauth) { + throw new Error("Gemini CLI is not in OAuth mode. Switch auth to OAuth in Gemini CLI."); + } + + const accessToken = asNonEmptyString(creds.access_token); + const refreshToken = asNonEmptyString(creds.refresh_token) || undefined; + const expiresAt = asEpochMs(creds.expiry_date) ?? undefined; + const isExpired = typeof expiresAt === "number" && Date.now() >= expiresAt; + + if (!accessToken) { + throw new Error("Gemini OAuth access token is missing. Re-login with `gemini`."); + } + + if (isExpired) { + throw new Error("Gemini OAuth access token is expired. Re-login with `gemini`."); + } + + return { + provider: "gemini-cli", + accessToken, + refreshToken, + expiresAt, + }; +} + +function checkGeminiOauthStatus(): ProviderAuthStatus { + const { path: credsPath, parsed: creds } = readGeminiOauthCreds(); + const settings = readGeminiSettings(); + + if (!creds) { + return { + provider: "gemini-cli", + method: "oauth", + connected: false, + message: "Gemini CLI OAuth token file was not found.", + detail: `Expected: ${credsPath}`, + }; + } + + const selectedType = ( + (settings?.security as Record | undefined)?.auth as + | Record + | undefined + )?.selectedType; + + const selectedTypeValue = + typeof selectedType === "string" ? selectedType.trim().toLowerCase() : ""; + const selectedOauth = + selectedTypeValue === "oauth-personal" || + selectedTypeValue === "login_with_google" || + selectedTypeValue.startsWith("oauth"); + + const accessToken = asNonEmptyString(creds.access_token); + const refreshToken = asNonEmptyString(creds.refresh_token); + const expiresAt = asEpochMs(creds.expiry_date); + const isExpired = typeof expiresAt === "number" && Date.now() >= expiresAt; + + if (!selectedOauth) { + return { + provider: "gemini-cli", + method: "oauth", + connected: false, + message: "Gemini CLI is not in OAuth mode.", + detail: selectedTypeValue + ? `selectedType=${selectedTypeValue}; switch to OAuth in Gemini CLI` + : "selectedType is missing in ~/.gemini/settings.json", + }; + } + + if (!accessToken && !refreshToken) { + return { + provider: "gemini-cli", + method: "oauth", + connected: false, + message: "Gemini CLI OAuth tokens are missing.", + detail: "Run `gemini` and complete Login with Google.", + }; + } + + if (isExpired && !refreshToken) { + return { + provider: "gemini-cli", + method: "oauth", + connected: false, + message: "Gemini OAuth token is expired and cannot be refreshed.", + detail: "Run `gemini` and complete Login with Google again.", + }; + } + + const detailParts: string[] = []; + if (typeof expiresAt === "number") { + detailParts.push( + `expires_at=${new Date(expiresAt).toISOString()}${isExpired ? " (expired)" : ""}` + ); + } + if (refreshToken) { + detailParts.push("refresh_token=present"); + } + + return { + provider: "gemini-cli", + method: "oauth", + connected: !isExpired, + message: isExpired + ? "Gemini OAuth token is expired." + : "Gemini CLI OAuth is configured.", + detail: detailParts.length > 0 ? detailParts.join("; ") : undefined, + }; +} + +export function resolveCliOAuthCredentialSync( + provider: CliProvider +): ResolvedCliOAuthCredential { + if (provider === "codex-cli") { + return resolveCodexCredential(); + } + return resolveGeminiCredential(); +} + +function unsupportedMethodStatus(provider: CliProvider, method: ChatAuthMethod): ProviderAuthStatus { + return { + provider, + method, + connected: false, + message: "Only OAuth is supported for this CLI provider in Eggent.", + detail: + provider === "codex-cli" + ? "Use provider OpenAI for API key mode, or Codex CLI with OAuth." + : "Use provider Google for API key mode, or Gemini CLI with OAuth.", + }; +} + +export async function checkProviderAuthStatus(input: { + provider: CliProvider; + method: ChatAuthMethod; + hasApiKey?: boolean; +}): Promise { + const { provider, method } = input; + + if (method !== "oauth") { + return unsupportedMethodStatus(provider, method); + } + + if (provider === "codex-cli") { + return checkCodexOauthStatus(); + } + return checkGeminiOauthStatus(); +} + +export async function connectProviderAuth(input: { + provider: CliProvider; + method: ChatAuthMethod; + apiKey?: string; +}): Promise { + const { provider, method } = input; + + if (method !== "oauth") { + return { + ...unsupportedMethodStatus(provider, method), + started: false, + }; + } + + if (provider === "codex-cli") { + return { + provider, + method, + connected: false, + started: false, + message: "OAuth must be completed in your terminal.", + command: "codex login", + detail: + "Run `codex login`, complete browser authorization, then click Check connection.", + }; + } + + return { + provider, + method, + connected: false, + started: false, + message: "OAuth must be completed in your terminal.", + command: "gemini", + detail: + "Run `gemini`, choose Login with Google, complete browser flow, then click Check connection.", + }; +} diff --git a/src/lib/storage/settings-store.ts b/src/lib/storage/settings-store.ts index 016f636..ce95f73 100644 --- a/src/lib/storage/settings-store.ts +++ b/src/lib/storage/settings-store.ts @@ -18,6 +18,7 @@ export const DEFAULT_SETTINGS: AppSettings = { chatModel: { provider: "openai", model: "gpt-4o", + authMethod: "api_key", temperature: 0.7, maxTokens: 4096, }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 2c1b0ba..ff5a98b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -4,10 +4,21 @@ // --- Settings --- +export type ChatAuthMethod = "api_key" | "oauth"; + export interface ModelConfig { - provider: "openai" | "anthropic" | "google" | "openrouter" | "ollama" | "custom"; + provider: + | "openai" + | "anthropic" + | "google" + | "openrouter" + | "ollama" + | "custom" + | "codex-cli" + | "gemini-cli"; model: string; apiKey?: string; + authMethod?: ChatAuthMethod; baseUrl?: string; temperature?: number; maxTokens?: number;