diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx index 022086f3a..5f1ccf0e7 100644 --- a/apps/web/src/onboarding/provider-connect-step.tsx +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -20,6 +20,8 @@ import type { FormEvent } from "react"; import type { ModelProviderPlugin } from "@intx/types"; +import { PROVIDER_MODELS } from "@/settings/provider-models"; + import { fetchOllamaTags } from "./ollama-tags"; import "./provider-rows.css"; @@ -45,6 +47,8 @@ export type ProviderOption = { readonly customURL?: boolean; }; +const CUSTOM_MODEL = "__custom__"; + // https everywhere; plain http only for a server on this machine. const baseUrlSchema = type("string").narrow((value, ctx) => { let url: URL; @@ -229,6 +233,8 @@ export function ProviderConnectStep({ const [apiKey, setApiKey] = useState(""); const [baseURL, setBaseURL] = useState(""); const [modelName, setModelName] = useState(""); + // "" = the provider's default (first listed); CUSTOM_MODEL = typed in. + const [modelChoice, setModelChoice] = useState(""); const [submitting, setSubmitting] = useState(false); const [loginId, setLoginId] = useState(null); @@ -238,6 +244,20 @@ export function ProviderConnectStep({ const oauthProvider = option?.oauthProvider; const typedModel = option?.typedModel === true; const customURL = option?.customURL === true; + const models = option === undefined ? [] : (PROVIDER_MODELS[option.id] ?? []); + const listed = models.length > 0; + const choice = modelChoice === "" ? (models[0]?.id ?? CUSTOM_MODEL) : modelChoice; + const typedChoice = choice === CUSTOM_MODEL; + // The model this option will mint: a listed pick, else the typed name. + const resolvedModel = + option === undefined + ? "" + : listed && !typedChoice + ? choice + : isLocal || typedModel || listed + ? modelName.trim() + : option.canonicalName; + const resolvedLabel = models.find((model) => model.id === resolvedModel)?.label ?? resolvedModel; const urlCheck = baseUrlSchema(baseURL); const urlError = customURL && baseURL.trim() !== "" && urlCheck instanceof type.errors; @@ -257,13 +277,12 @@ export function ProviderConnectStep({ const ready = option !== undefined && + resolvedModel !== "" && (oauthProvider !== undefined || (isLocal ? baseURL.trim().length > 0 && modelName.trim().length > 0 && modelKnown : typedModel - ? apiKey.trim().length > 0 && - modelName.trim().length > 0 && - (!customURL || !(urlCheck instanceof type.errors)) + ? apiKey.trim().length > 0 && (!customURL || !(urlCheck instanceof type.errors)) : apiKey.trim().length > 0)); function fail(cause: unknown, operation: string) { @@ -286,6 +305,7 @@ export function ProviderConnectStep({ setSelected(id); setBaseURL(next?.local === true ? next.baseURL : ""); setModelName(""); + setModelChoice(""); } // Starting a login is the hub's job end to end: it runs the loopback PKCE @@ -323,8 +343,8 @@ export function ProviderConnectStep({ const state = await readProviderLogin(tenantId, loginId); if (state.status !== "completed") return state; const created = await shadowOffering(tenantId, { - canonicalName: option.canonicalName, - modelDisplayName: option.modelDisplayName, + canonicalName: resolvedModel, + modelDisplayName: resolvedLabel, providerName: option.label, plugin: option.plugin, baseURL: option.baseURL, @@ -333,7 +353,7 @@ export function ProviderConnectStep({ }); return { status: "connected" as const, - offering: offeringFromOption(option, option.canonicalName, created.id), + offering: offeringFromOption(option, resolvedModel, created.id), }; }, refetchInterval: (query) => (query.state.data?.status === "pending" ? 2000 : false), @@ -365,11 +385,10 @@ export function ProviderConnectStep({ } setSubmitting(true); try { - const typed = isLocal || typedModel; - const canonicalName = typed ? modelName.trim() : option.canonicalName; + const canonicalName = resolvedModel; const created = await shadowOffering(tenantId, { canonicalName, - modelDisplayName: typed ? modelName.trim() : option.modelDisplayName, + modelDisplayName: resolvedLabel, providerName: option.label, plugin: option.plugin, baseURL: isLocal || customURL ? baseURL.trim() : option.baseURL, @@ -394,6 +413,47 @@ export function ProviderConnectStep({ ? "Connecting…" : "Connect"; + const modelPicker = listed ? ( + <> + + {typedChoice ? ( + + ) : null} + + ) : ( + + ); + return (
void handleSubmit(event)}> {option === undefined ? null : oauthProvider !== undefined ? ( -

- {waiting - ? "Finish signing in on the tab that opened, then come back here." - : "A new tab opens to sign in. Your workbench stores the result; no key to paste."} -

+ <> + {modelPicker} +

+ {waiting + ? "Finish signing in on the tab that opened, then come back here." + : "A new tab opens to sign in. Your workbench stores the result; no key to paste."} +

+ ) : isLocal ? ( <> - {typedModel ? ( - - ) : null} + {typedModel || listed ? modelPicker : null} )}
diff --git a/apps/web/src/settings/provider-models.ts b/apps/web/src/settings/provider-models.ts new file mode 100644 index 000000000..21a865907 --- /dev/null +++ b/apps/web/src/settings/provider-models.ts @@ -0,0 +1,31 @@ +// Models offered per provider kind, by `ProviderOption.id`. The first entry is +// the preselected default; an empty list means the model is typed in. This +// moves to a package later. +export type ProviderModel = { readonly id: string; readonly label: string }; + +export const PROVIDER_MODELS: Readonly> = { + anthropic: [ + { id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, + { id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" }, + { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, + { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" }, + ], + openai: [ + { id: "gpt-5", label: "GPT-5" }, + { id: "gpt-5-mini", label: "GPT-5 mini" }, + { id: "gpt-4.1", label: "GPT-4.1" }, + ], + openrouter: [ + { id: "anthropic/claude-sonnet-4.5", label: "Claude Sonnet 4.5" }, + { id: "openai/gpt-5", label: "GPT-5" }, + { id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "x-ai/grok-4", label: "Grok 4" }, + ], + xai: [ + { id: "grok-4.6", label: "Grok 4.6" }, + { id: "grok-4", label: "Grok 4" }, + { id: "grok-4-fast", label: "Grok 4 Fast" }, + ], + codex: [{ id: "gpt-5.5", label: "GPT-5.5" }], + custom: [], +};