Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 78 additions & 25 deletions apps/web/src/onboarding/provider-connect-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down Expand Up @@ -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<string | null>(null);

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

Expand All @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -394,6 +413,47 @@ export function ProviderConnectStep({
? "Connecting…"
: "Connect";

const modelPicker = listed ? (
<>
<label>
Model
<Select
value={choice}
aria-label="Model"
onChange={(event) => setModelChoice(event.target.value)}
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.label}
</option>
))}
<option value={CUSTOM_MODEL}>Custom…</option>
</Select>
</label>
{typedChoice ? (
<label>
Custom model id
<Input
autoComplete="off"
value={modelName}
onChange={(event) => setModelName(event.target.value)}
required
/>
</label>
) : null}
</>
) : (
<label>
Model
<Input
autoComplete="off"
value={modelName}
onChange={(event) => setModelName(event.target.value)}
required
/>
</label>
);

return (
<form className="onboarding-credential-form" onSubmit={(event) => void handleSubmit(event)}>
<RadioGroup
Expand Down Expand Up @@ -426,11 +486,14 @@ export function ProviderConnectStep({
})}
</RadioGroup>
{option === undefined ? null : oauthProvider !== undefined ? (
<p>
{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."}
</p>
<>
{modelPicker}
<p>
{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."}
</p>
</>
) : isLocal ? (
<>
<label>
Expand Down Expand Up @@ -507,17 +570,7 @@ export function ProviderConnectStep({
required
/>
</label>
{typedModel ? (
<label>
Model
<Input
autoComplete="off"
value={modelName}
onChange={(event) => setModelName(event.target.value)}
required
/>
</label>
) : null}
{typedModel || listed ? modelPicker : null}
</>
)}
<div className="onboarding-actions">
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/settings/provider-models.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, readonly ProviderModel[]>> = {
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: [],
};
Loading