mirror of
https://github.com/eggent-ai/eggent.git
synced 2026-03-07 18:13:07 +00:00
feat(providers): add OAuth-native codex/gemini transport and provider auth flows
This commit is contained in:
@@ -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) {
|
||||
|
||||
67
src/app/api/provider-auth/connect/route.ts
Normal file
67
src/app/api/provider-auth/connect/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/app/api/provider-auth/status/route.ts
Normal file
68
src/app/api/provider-auth/status/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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..."
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
242
src/lib/providers/cli-models.ts
Normal file
242
src/lib/providers/cli-models.ts
Normal 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
@@ -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.",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
380
src/lib/providers/provider-auth.ts
Normal file
380
src/lib/providers/provider-auth.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
chatModel: {
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
authMethod: "api_key",
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user