feat(providers): add OAuth-native codex/gemini transport and provider auth flows

This commit is contained in:
ilya-bov
2026-03-06 15:53:04 +03:00
parent 19b7dfeb92
commit 75773cc872
11 changed files with 2676 additions and 66 deletions

View File

@@ -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) {

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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<string | null>(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 (
<section className="border rounded-xl p-5 bg-card space-y-5 transition-all duration-300">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-lg">Chat Model</h3>
<div className="flex items-center gap-4">
<StepIndicator step={1} currentStep={currentStep} label="Provider" />
{requiresApiKey && (
<StepIndicator step={2} currentStep={currentStep} label="API Key" />
)}
<StepIndicator
step={requiresApiKey ? 3 : 2}
currentStep={currentStep}
label="Model"
/>
<StepIndicator step={2} currentStep={currentStep} label="Method" />
<StepIndicator step={3} currentStep={currentStep} label="Connect" />
<StepIndicator step={4} currentStep={currentStep} label="Model" />
</div>
</div>
@@ -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({
<div
className={`space-y-2 transition-all duration-300 ${
!hasProvider ? "opacity-40 pointer-events-none" : ""
} ${!requiresApiKey ? "hidden" : ""}`}
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Step 2 API Key
Step 2 Connection Method
</Label>
<Input
type="password"
value={apiKey}
onChange={(event) => updateSettings("chatModel.apiKey", event.target.value)}
placeholder={
providerConfig?.envKey
? `Enter key or set ${providerConfig.envKey} in .env`
: "sk-..."
}
<select
value={selectedAuthMethod}
onChange={(event) => {
const method = event.target.value as ChatAuthMethod;
updateSettings("chatModel.authMethod", method);
updateSettings("chatModel.model", "");
if (method === "oauth") {
updateSettings("chatModel.apiKey", "");
}
setConnectionStatus(null);
setConnectionError(null);
}}
disabled={!hasProvider}
/>
{providerConfig?.envKey && (
<p className="text-xs text-muted-foreground">
Or set{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[11px]">
{providerConfig.envKey}
</code>{" "}
as an environment variable
</p>
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
{availableAuthMethods.map((authMethod) => (
<option key={authMethod} value={authMethod}>
{authMethod === "oauth" ? "OAuth" : "API key"}
</option>
))}
</select>
</div>
<div
className={`space-y-3 transition-all duration-300 ${
!hasProvider || !selectedAuthMethod ? "opacity-40 pointer-events-none" : ""
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Step 3 Connection
</Label>
{showApiKeyInput ? (
<div className="space-y-2">
<Input
type="password"
value={apiKey}
onChange={(event) => updateSettings("chatModel.apiKey", event.target.value)}
placeholder={
providerConfig?.envKey
? `Enter key or set ${providerConfig.envKey} in .env`
: "sk-..."
}
disabled={!hasProvider}
/>
{providerConfig?.envKey && (
<p className="text-xs text-muted-foreground">
Or set{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[11px]">
{providerConfig.envKey}
</code>{" "}
as an environment variable
</p>
)}
</div>
) : (
<div className="text-sm text-muted-foreground rounded-lg border bg-muted/30 p-3">
API key input is not required for this connection mode.
</div>
)}
{connectionHelp && (
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
<p className="text-sm font-medium">{connectionHelp.title}</p>
<ul className="list-disc pl-5 space-y-1 text-sm text-muted-foreground">
{connectionHelp.steps.map((step, idx) => (
<li key={`${provider}-${selectedAuthMethod}-help-${idx}`}>{step}</li>
))}
</ul>
{connectionHelp.command && (
<p className="text-sm">
Command:{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[11px]">
{connectionHelp.command}
</code>
</p>
)}
</div>
)}
{isCliProvider && (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300">
OAuth tokens are read from local CLI auth files.
</div>
)}
{isCliProvider && (
<button
type="button"
onClick={() => void checkConnection()}
disabled={connectionLoading}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-60"
>
{connectionLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
Check connection
</button>
)}
{provider === "ollama" && selectedAuthMethod === "api_key" && (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/30 rounded-lg px-3 py-2">
<Check className="size-4" />
API Key not required connecting to local Ollama
</div>
)}
{isCliProvider && connectionStatus && (
<div
className={`rounded-lg border px-3 py-2 text-sm ${
connectionStatus.connected
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-300"
: "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-300"
}`}
>
<p>{connectionStatus.message}</p>
{connectionStatus.detail && (
<p className="mt-1 text-xs opacity-80">{connectionStatus.detail}</p>
)}
</div>
)}
{connectionError && (
<div className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="size-3" />
{connectionError}
</div>
)}
</div>
{hasProvider && !requiresApiKey && provider === "ollama" && (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/30 rounded-lg px-3 py-2">
<Check className="size-4" />
API Key not required connecting to local Ollama
</div>
)}
{(provider === "custom" || provider === "ollama") && (
{(provider === "custom" || provider === "ollama") && selectedAuthMethod === "api_key" && (
<div
className={`space-y-2 transition-all duration-300 ${
!hasApiKey ? "opacity-40 pointer-events-none" : ""
!apiKeyConnectionReady ? "opacity-40 pointer-events-none" : ""
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
@@ -330,25 +538,25 @@ export function ChatModelWizard({
? "http://localhost:11434/v1"
: "https://api.example.com/v1"
}
disabled={!hasApiKey}
disabled={!apiKeyConnectionReady}
/>
</div>
)}
<div
className={`space-y-2 transition-all duration-300 ${
!hasApiKey ? "opacity-40 pointer-events-none" : ""
!hasConnection ? "opacity-40 pointer-events-none" : ""
}`}
>
<Label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{requiresApiKey ? "Step 3" : "Step 2"} Model
Step 4 Model
</Label>
<ModelSelect
value={model}
models={models}
loading={loading}
error={error}
disabled={!hasApiKey}
disabled={!hasConnection}
onChange={(value) => updateSettings("chatModel.model", value)}
placeholder="Select model..."
/>

View File

@@ -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<string, unknown> | 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<string, unknown>;
}): Promise<string> {
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<string> {
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,

View File

@@ -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<CliProviderName, CachedModels>();
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<CommandResult> {
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<string>
): 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<string, unknown>;
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<string>();
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<string>();
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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, ProviderConfig> = {
@@ -21,24 +37,32 @@ export const MODEL_PROVIDERS: Record<string, ProviderConfig> = {
],
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<string, ProviderConfig> = {
],
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.",
],
},
},
},
};

View File

@@ -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<string, unknown> | 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<string, unknown>;
} 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<string, unknown> | 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<string, unknown> | undefined)?.auth as
| Record<string, unknown>
| 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<string, unknown> | undefined)?.auth as
| Record<string, unknown>
| 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<ProviderAuthStatus> {
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<ProviderAuthConnectResult> {
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.",
};
}

View File

@@ -18,6 +18,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
chatModel: {
provider: "openai",
model: "gpt-4o",
authMethod: "api_key",
temperature: 0.7,
maxTokens: 4096,
},

View File

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