diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 96d1424e..94f96663 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -26,7 +26,7 @@ import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; import { listOnboardingModels } from '@x/core/dist/models/models-dev.js'; -import { testModelConnection, generateOneShot } from '@x/core/dist/models/models.js'; +import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js'; import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js'; import { isSignedIn } from '@x/core/dist/account/account.js'; import { listGatewayModels } from '@x/core/dist/models/gateway.js'; @@ -853,6 +853,15 @@ export function setupIpcHandlers() { 'models:test': async (_event, args) => { return await testModelConnection(args.provider, args.model); }, + 'models:listForProvider': async (_event, args) => { + try { + const models = await listModelsForProvider(args.provider); + return { success: true, models }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to list models'; + return { success: false, error: message }; + } + }, 'llm:getDefaultModel': async () => { return await getDefaultModelAndProvider(); }, diff --git a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx index 44e842c3..3c27ff31 100644 --- a/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx +++ b/apps/x/apps/renderer/src/components/onboarding/steps/llm-setup-step.tsx @@ -2,13 +2,6 @@ import { Loader2, CheckCircle2, ArrowLeft, X, Lightbulb } from "lucide-react" import { motion } from "motion/react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { cn } from "@/lib/utils" import { OpenAIIcon, @@ -40,16 +33,22 @@ const moreProviders: Array<{ id: LlmProviderFlavor; name: string; description: s export function LlmSetupStep({ state }: LlmSetupStepProps) { const { - llmProvider, setLlmProvider, modelsCatalog, modelsLoading, modelsError, + llmProvider, setLlmProvider, modelsLoading, modelsError, activeConfig, testState, setTestState, showApiKey, - showBaseURL, isLocalProvider, canTest, showMoreProviders, setShowMoreProviders, + showBaseURL, canTest, showMoreProviders, setShowMoreProviders, updateProviderConfig, handleTestAndSaveLlmConfig, handleBack, upsellDismissed, setUpsellDismissed, handleSwitchToRowboat, } = state const isMoreProvider = moreProviders.some(p => p.id === llmProvider) - const modelsForProvider = modelsCatalog[llmProvider] || [] - const showModelInput = isLocalProvider || modelsForProvider.length === 0 + // Hosted providers (openai/anthropic/google) get a default model, so we only + // ask for a model on providers that truly need one (local/custom/gateway), + // or as a fallback if no model is set yet. + // Hosted providers (openai/anthropic/google) fetch their models from the API + // key on test, so they never need a manual model field. Only local/custom/ + // gateway providers, where the user must specify a model, show the input. + const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"] + const showModelInput = !hostedProviders.includes(llmProvider) const renderProviderCard = (provider: typeof primaryProviders[0], index: number) => { const isSelected = llmProvider === provider.id @@ -87,7 +86,7 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) {
{/* Title */}

- Choose your model + Choose your provider

Select a provider and configure your API key @@ -145,153 +144,33 @@ export function LlmSetupStep({ state }: LlmSetupStepProps) { {/* Separator */}

- {/* Model configuration */} + {/* Provider configuration */}
-

Model Configuration

- -
-
+ {/* Cloud providers get a default model auto-selected; only local/custom + providers (no catalog) need a model here. Users can pick any of the + provider's models later in the chat view. */} + {showModelInput && ( +
{modelsLoading ? (
Loading...
- ) : showModelInput ? ( + ) : ( updateProviderConfig(llmProvider, { model: e.target.value })} placeholder="Enter model" /> - ) : ( - )} {modelsError && (
{modelsError}
)}
- -
- - {modelsLoading ? ( -
- - Loading... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { knowledgeGraphModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} - /> - ) : ( - - )} -
- -
- - {modelsLoading ? ( -
- - Loading... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { meetingNotesModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} - /> - ) : ( - - )} -
- -
- - {modelsLoading ? ( -
- - Loading... -
- ) : showModelInput ? ( - updateProviderConfig(llmProvider, { liveNoteAgentModel: e.target.value })} - placeholder={activeConfig.model || "Enter model"} - /> - ) : ( - - )} -
-
+ )} {showApiKey && (
diff --git a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts index 82913074..681f3957 100644 --- a/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts +++ b/apps/x/apps/renderer/src/components/onboarding/use-onboarding-state.ts @@ -98,7 +98,6 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway" const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible" const canTest = - activeConfig.model.trim().length > 0 && (!requiresApiKey || activeConfig.apiKey.trim().length > 0) && (!requiresBaseURL || activeConfig.baseURL.trim().length > 0) @@ -416,37 +415,45 @@ export function useOnboardingState(open: boolean, onComplete: () => void) { try { const apiKey = activeConfig.apiKey.trim() || undefined const baseURL = activeConfig.baseURL.trim() || undefined - const model = activeConfig.model.trim() - const knowledgeGraphModel = activeConfig.knowledgeGraphModel.trim() || undefined - const meetingNotesModel = activeConfig.meetingNotesModel.trim() || undefined - const liveNoteAgentModel = activeConfig.liveNoteAgentModel.trim() || undefined - const providerConfig = { - provider: { - flavor: llmProvider, - apiKey, - baseURL, - }, - model, - knowledgeGraphModel, - meetingNotesModel, - liveNoteAgentModel, + const provider = { + flavor: llmProvider, + apiKey, + baseURL, } - const result = await window.ipc.invoke("models:test", providerConfig) - if (result.success) { - setTestState({ status: "success" }) - await window.ipc.invoke("models:saveConfig", providerConfig) - window.dispatchEvent(new Event('models-config-changed')) - handleNext() - } else { + + // Fetch the provider's models from the key — this both validates the + // credentials and gives us the list to populate the chat picker. + const result = await window.ipc.invoke("models:listForProvider", { provider }) + if (!result.success) { setTestState({ status: "error", error: result.error }) toast.error(result.error || "Connection test failed") + return } + + const models: string[] = result.models ?? [] + const preferred = preferredDefaults[llmProvider] + const model = + (preferred && models.includes(preferred) && preferred) || + models[0] || + activeConfig.model.trim() || + "" + + const providerConfig = { + provider, + model, + models, + } + + setTestState({ status: "success" }) + await window.ipc.invoke("models:saveConfig", providerConfig) + window.dispatchEvent(new Event('models-config-changed')) + handleNext() } catch (error) { console.error("Connection test failed:", error) setTestState({ status: "error", error: "Connection test failed" }) toast.error("Connection test failed") } - }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, activeConfig.knowledgeGraphModel, activeConfig.meetingNotesModel, activeConfig.liveNoteAgentModel, canTest, llmProvider, handleNext]) + }, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider, handleNext]) // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index d8dad590..1939f7b7 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -99,6 +99,71 @@ export async function testModelConnection( } } +export async function listModelsForProvider( + providerConfig: z.infer, + timeoutMs = 8000, +): Promise { + const { flavor, apiKey, baseURL } = providerConfig; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + let url = ""; + const headers: Record = {}; + + switch (flavor) { + case "openai": + url = "https://api.openai.com/v1/models"; + headers["Authorization"] = `Bearer ${apiKey}`; + break; + case "anthropic": + url = "https://api.anthropic.com/v1/models"; + headers["x-api-key"] = apiKey ?? ""; + headers["anthropic-version"] = "2023-06-01"; + break; + case "google": + url = `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey ?? ""}`; + break; + case "openrouter": + url = "https://openrouter.ai/api/v1/models"; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + break; + case "ollama": + url = `${(baseURL ?? "http://localhost:11434").replace(/\/$/, "")}/api/tags`; + break; + case "openai-compatible": + case "aigateway": + url = `${(baseURL ?? "").replace(/\/$/, "")}/models`; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + break; + default: + throw new Error(`Unsupported provider flavor: ${flavor}`); + } + + const res = await fetch(url, { headers, signal: controller.signal }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Failed to list models (${res.status}): ${body.slice(0, 200)}`); + } + const data = await res.json(); + + // Normalize each provider's response shape into a flat list of model id strings. + let ids: string[] = []; + if (flavor === "google") { + // { models: [{ name: "models/gemini-..." }] } + ids = (data.models ?? []).map((m: { name: string }) => m.name.replace(/^models\//, "")); + } else if (flavor === "ollama") { + // { models: [{ name: "llama3:latest" }] } + ids = (data.models ?? []).map((m: { name: string }) => m.name); + } else { + // OpenAI-shaped: { data: [{ id: "..." }] } + ids = (data.data ?? []).map((m: { id: string }) => m.id); + } + return ids.filter((id: string) => typeof id === "string" && id.length > 0); + } finally { + clearTimeout(timeout); + } +} + export interface GenerateTextOptions { prompt: string; system?: string; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 202e0713..e0d7cf19 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js'; import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; -import { LlmModelConfig } from './models.js'; +import { LlmModelConfig, LlmProvider } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; @@ -403,6 +403,16 @@ const ipcSchemas = { error: z.string().optional(), }), }, + 'models:listForProvider': { + req: z.object({ + provider: LlmProvider, + }), + res: z.object({ + success: z.boolean(), + models: z.array(z.string()).optional(), + error: z.string().optional(), + }), + }, 'llm:getDefaultModel': { req: z.null(), res: z.object({