g1t/apps/web/app/components/model-providers.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| A catalogue of model providers, and settings that feel like settings | 1 | /** |
| 2 | * A workspace's model providers: the catalogue people choose from, the | |
| 3 | * fields each one asks for, and the routing of each kind of work to a | |
| 4 | * provider and model. | |
| 5 | */ | |
| Workspace names and icons, and a component kit for every control | 6 | import { Bot, ChevronRight, Sparkles, Webhook } from "lucide-react"; |
| A catalogue of model providers, and settings that feel like settings | 7 | import { type ReactNode, useState } from "react"; |
| 8 | import { Form, Link } from "react-router"; | |
| 9 | ||
| 10 | import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts"; | |
| 11 | ||
| 12 | import { Button, Field, Input } from "./ui"; | |
| Workspace names and icons, and a component kit for every control | 13 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; |
| A catalogue of model providers, and settings that feel like settings | 14 | |
| 15 | type Group = "labs" | "platforms" | "any"; | |
| 16 | ||
| 17 | type CatalogEntry = { | |
| 18 | group: Group; | |
| 19 | blurb: string; | |
| 20 | /** The provider's colour, for its mark. */ | |
| 21 | color: string; | |
| 22 | /** What the mark shows: an initial or two. */ | |
| 23 | mark: string; | |
| 24 | /** Where to get a key. */ | |
| 25 | keyUrl?: string; | |
| 26 | keyPlaceholder?: string; | |
| 27 | /** A model to suggest. */ | |
| 28 | model?: string; | |
| 29 | }; | |
| 30 | ||
| 31 | /** Every model provider, as people are shown them. */ | |
| 32 | export const MODEL_CATALOG: Partial<Record<Provider, CatalogEntry>> = { | |
| 33 | anthropic: { | |
| 34 | group: "labs", | |
| 35 | blurb: "Claude, on your own Anthropic key.", | |
| 36 | color: "#d97757", | |
| 37 | mark: "A", | |
| 38 | keyUrl: "https://console.anthropic.com/settings/keys", | |
| 39 | keyPlaceholder: "sk-ant-…", | |
| 40 | }, | |
| 41 | openai: { | |
| 42 | group: "labs", | |
| 43 | blurb: "GPT and the o-series, on your own OpenAI key.", | |
| 44 | color: "#10a37f", | |
| 45 | mark: "O", | |
| 46 | keyUrl: "https://platform.openai.com/api-keys", | |
| 47 | keyPlaceholder: "sk-…", | |
| 48 | model: "gpt-5", | |
| 49 | }, | |
| 50 | gemini: { | |
| 51 | group: "labs", | |
| 52 | blurb: "Gemini, on your own Google AI Studio key.", | |
| 53 | color: "#4285f4", | |
| 54 | mark: "G", | |
| 55 | keyUrl: "https://aistudio.google.com/apikey", | |
| 56 | model: "gemini-2.5-pro", | |
| 57 | }, | |
| 58 | xai: { | |
| 59 | group: "labs", | |
| 60 | blurb: "Grok, on your own xAI key.", | |
| 61 | color: "#e5e5e5", | |
| 62 | mark: "x", | |
| 63 | keyUrl: "https://console.x.ai", | |
| 64 | keyPlaceholder: "xai-…", | |
| 65 | model: "grok-4", | |
| 66 | }, | |
| 67 | mistral: { | |
| 68 | group: "labs", | |
| 69 | blurb: "Mistral and Codestral, on your own key.", | |
| 70 | color: "#fa520f", | |
| 71 | mark: "M", | |
| 72 | keyUrl: "https://console.mistral.ai/api-keys", | |
| 73 | model: "mistral-large-latest", | |
| 74 | }, | |
| 75 | deepseek: { | |
| 76 | group: "labs", | |
| 77 | blurb: "DeepSeek's chat and reasoning models.", | |
| 78 | color: "#4d6bfe", | |
| 79 | mark: "D", | |
| 80 | keyUrl: "https://platform.deepseek.com/api_keys", | |
| 81 | keyPlaceholder: "sk-…", | |
| 82 | model: "deepseek-chat", | |
| 83 | }, | |
| 84 | azure_openai: { | |
| 85 | group: "platforms", | |
| 86 | blurb: "OpenAI's models on your own Azure resource and contract.", | |
| 87 | color: "#0078d4", | |
| 88 | mark: "Az", | |
| 89 | }, | |
| 90 | openrouter: { | |
| 91 | group: "platforms", | |
| 92 | blurb: "Hundreds of models from every lab, on one key.", | |
| 93 | color: "#7c7ff5", | |
| 94 | mark: "OR", | |
| 95 | keyUrl: "https://openrouter.ai/keys", | |
| 96 | keyPlaceholder: "sk-or-…", | |
| 97 | model: "anthropic/claude-sonnet-4.5", | |
| 98 | }, | |
| 99 | groq: { | |
| 100 | group: "platforms", | |
| 101 | blurb: "Open models at very high speed.", | |
| 102 | color: "#f55036", | |
| 103 | mark: "Gq", | |
| 104 | keyUrl: "https://console.groq.com/keys", | |
| 105 | keyPlaceholder: "gsk_…", | |
| 106 | model: "moonshotai/kimi-k2-instruct", | |
| 107 | }, | |
| 108 | together: { | |
| 109 | group: "platforms", | |
| 110 | blurb: "Open models: Llama, Qwen, DeepSeek, Kimi.", | |
| 111 | color: "#0f6fff", | |
| 112 | mark: "T", | |
| 113 | keyUrl: "https://api.together.ai/settings/api-keys", | |
| 114 | model: "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", | |
| 115 | }, | |
| 116 | fireworks: { | |
| 117 | group: "platforms", | |
| 118 | blurb: "Fast open models, and your own fine-tunes.", | |
| 119 | color: "#7c3aed", | |
| 120 | mark: "F", | |
| 121 | keyUrl: "https://fireworks.ai/account/api-keys", | |
| 122 | model: "accounts/fireworks/models/kimi-k2-instruct", | |
| 123 | }, | |
| 124 | cerebras: { | |
| 125 | group: "platforms", | |
| 126 | blurb: "Open models on wafer-scale chips.", | |
| 127 | color: "#f05a28", | |
| 128 | mark: "C", | |
| 129 | keyUrl: "https://cloud.cerebras.ai", | |
| 130 | keyPlaceholder: "csk-…", | |
| 131 | model: "qwen-3-coder-480b", | |
| 132 | }, | |
| 133 | anthropic_endpoint: { | |
| 134 | group: "any", | |
| 135 | blurb: "Anything that speaks Anthropic's API: your own AI Gateway, LiteLLM, Bedrock or Vertex behind a proxy.", | |
| 136 | color: "#a1a1aa", | |
| 137 | mark: "", | |
| 138 | }, | |
| 139 | openai_endpoint: { | |
| 140 | group: "any", | |
| 141 | blurb: "Anything that speaks OpenAI's API: vLLM, Ollama behind a tunnel, LiteLLM, a gateway.", | |
| 142 | color: "#a1a1aa", | |
| 143 | mark: "", | |
| 144 | }, | |
| 145 | }; | |
| 146 | ||
| 147 | const OTHER_COLORS: Partial<Record<Provider, string>> = { | |
| 148 | sentry: "#a78bfa", | |
| 149 | datadog: "#a26ce6", | |
| 150 | webhook: "#5eead4", | |
| 151 | jira: "#5b8def", | |
| 152 | linear: "#8b8ff0", | |
| 153 | }; | |
| 154 | ||
| 155 | /** A provider's mark: its initials in its colour, or an icon for the generic ones. */ | |
| 156 | export function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) { | |
| 157 | const entry = MODEL_CATALOG[provider]; | |
| 158 | const color = entry?.color ?? OTHER_COLORS[provider] ?? "#a1a1aa"; | |
| 159 | const content = | |
| 160 | provider === "webhook" ? ( | |
| 161 | <Webhook size={size * 0.5} /> | |
| 162 | ) : provider.endsWith("_endpoint") ? ( | |
| 163 | <Bot size={size * 0.5} /> | |
| 164 | ) : ( | |
| 165 | entry?.mark || PROVIDERS[provider].label[0] | |
| 166 | ); | |
| 167 | return ( | |
| 168 | <span | |
| 169 | aria-hidden="true" | |
| 170 | className="inline-flex shrink-0 items-center justify-center font-semibold tracking-tight" | |
| 171 | style={{ | |
| 172 | width: size, | |
| 173 | height: size, | |
| 174 | borderRadius: size * 0.28, | |
| 175 | fontSize: size * (String(content).length > 1 ? 0.34 : 0.42), | |
| 176 | background: `color-mix(in srgb, ${color} 16%, transparent)`, | |
| 177 | color, | |
| 178 | boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`, | |
| 179 | }} | |
| 180 | > | |
| 181 | {content} | |
| 182 | </span> | |
| 183 | ); | |
| 184 | } | |
| 185 | ||
| 186 | const GROUPS: { group: Group; title: string }[] = [ | |
| 187 | { group: "labs", title: "Labs" }, | |
| 188 | { group: "platforms", title: "Platforms" }, | |
| 189 | { group: "any", title: "Any endpoint" }, | |
| 190 | ]; | |
| 191 | ||
| 192 | /** Providers to choose from, as compact tiles that open their form. */ | |
| 193 | export function ProviderTiles({ | |
| 194 | providers, | |
| 195 | slug, | |
| 196 | adding, | |
| 197 | blurb, | |
| 198 | }: { | |
| 199 | providers: Provider[]; | |
| 200 | slug: string; | |
| 201 | adding: Provider | null; | |
| 202 | blurb: (provider: Provider) => string; | |
| 203 | }) { | |
| 204 | return ( | |
| 205 | <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 206 | {providers.map((provider) => ( | |
| 207 | <Link | |
| 208 | key={provider} | |
| 209 | to={`/${slug}/-/integrations?add=${provider}#add`} | |
| 210 | preventScrollReset | |
| 211 | className={`group flex items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${ | |
| 212 | adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface" | |
| 213 | }`} | |
| 214 | > | |
| 215 | <ProviderMark provider={provider} size={28} /> | |
| 216 | <span className="min-w-0 grow"> | |
| 217 | <span className="block truncate text-sm font-medium">{PROVIDERS[provider].label}</span> | |
| 218 | <span className="block truncate text-xs text-muted" title={blurb(provider)}> | |
| 219 | {blurb(provider)} | |
| 220 | </span> | |
| 221 | </span> | |
| 222 | <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> | |
| 223 | </Link> | |
| 224 | ))} | |
| 225 | </div> | |
| 226 | ); | |
| 227 | } | |
| 228 | ||
| 229 | /** The model providers to choose from, grouped. */ | |
| 230 | export function ModelCatalog({ slug, adding }: { slug: string; adding: Provider | null }) { | |
| 231 | return ( | |
| 232 | <div className="mt-4 space-y-4"> | |
| 233 | {GROUPS.map(({ group, title }) => ( | |
| 234 | <div key={group}> | |
| 235 | <p className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">{title}</p> | |
| 236 | <ProviderTiles | |
| 237 | providers={(Object.keys(MODEL_CATALOG) as Provider[]).filter((provider) => MODEL_CATALOG[provider]!.group === group)} | |
| 238 | slug={slug} | |
| 239 | adding={adding} | |
| 240 | blurb={(provider) => MODEL_CATALOG[provider]!.blurb} | |
| 241 | /> | |
| 242 | </div> | |
| 243 | ))} | |
| 244 | </div> | |
| 245 | ); | |
| 246 | } | |
| 247 | ||
| Workspace names and icons, and a component kit for every control | 248 | /** Radix Select items cannot be "": the routing value for "same as everything". */ |
| 249 | const SAME = "same"; | |
| 250 | ||
| A catalogue of model providers, and settings that feel like settings | 251 | const SELECT = |
| 252 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim disabled:opacity-60"; | |
| 253 | ||
| 254 | const GATEWAY_TOKEN = ( | |
| 255 | <Field label="Cloudflare AI Gateway token" hint="Only for an authenticated AI Gateway: sent as cf-aig-authorization."> | |
| 256 | <Input name="signingSecret" type="password" /> | |
| 257 | </Field> | |
| 258 | ); | |
| 259 | ||
| 260 | /** What connecting a model provider asks for. */ | |
| 261 | export function ModelProviderFields({ provider }: { provider: Provider }): ReactNode { | |
| 262 | const entry = MODEL_CATALOG[provider]; | |
| 263 | if (provider === "azure_openai") { | |
| 264 | return ( | |
| 265 | <> | |
| 266 | <Field label="Endpoint" hint="Your resource's endpoint, from the Azure portal: Keys and Endpoint."> | |
| 267 | <Input name="baseUrl" type="url" required placeholder="https://acme.openai.azure.com" /> | |
| 268 | </Field> | |
| 269 | <Field label="Key" hint="KEY 1 or KEY 2 of the resource."> | |
| 270 | <Input name="secret" type="password" required /> | |
| 271 | </Field> | |
| 272 | <Field label="Deployment" hint="The name of the model deployment to use. Add a connection for each deployment you want to route to."> | |
| 273 | <Input name="model" required placeholder="gpt-5" /> | |
| 274 | </Field> | |
| 275 | </> | |
| 276 | ); | |
| 277 | } | |
| 278 | if (provider === "anthropic_endpoint" || provider === "openai_endpoint") { | |
| 279 | const anthropic = provider === "anthropic_endpoint"; | |
| 280 | return ( | |
| 281 | <> | |
| 282 | <Field | |
| 283 | label="Base URL" | |
| 284 | hint={ | |
| 285 | anthropic | |
| 286 | ? "Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic" | |
| 287 | : "Up to and including the version, such as https://llm.acme.dev/v1. g1t calls /chat/completions under it." | |
| 288 | } | |
| 289 | > | |
| 290 | <Input name="baseUrl" type="url" required placeholder={anthropic ? "https://llm.acme.dev" : "https://llm.acme.dev/v1"} /> | |
| 291 | </Field> | |
| 292 | <Field label="Key" hint="Optional, if the endpoint needs one."> | |
| 293 | <Input name="secret" type="password" /> | |
| 294 | </Field> | |
| 295 | <Field label="Send the key as"> | |
| Workspace names and icons, and a component kit for every control | 296 | <Select name="authHeader" defaultValue={anthropic ? "x-api-key" : "authorization"}> |
| 297 | <SelectTrigger aria-label="Send the key as" className="font-mono text-[0.8125rem]"> | |
| 298 | <SelectValue /> | |
| 299 | </SelectTrigger> | |
| 300 | <SelectContent> | |
| 301 | <SelectItem value="x-api-key" description="Anthropic's header" className="font-mono text-[0.8125rem]"> | |
| 302 | x-api-key | |
| 303 | </SelectItem> | |
| 304 | <SelectItem value="authorization" description="OpenAI's header, and most others" className="font-mono text-[0.8125rem]"> | |
| 305 | Authorization: Bearer | |
| 306 | </SelectItem> | |
| 307 | </SelectContent> | |
| 308 | </Select> | |
| A catalogue of model providers, and settings that feel like settings | 309 | </Field> |
| 310 | {GATEWAY_TOKEN} | |
| 311 | <Field | |
| 312 | label={anthropic ? "Model" : "Default model"} | |
| 313 | hint={ | |
| 314 | anthropic | |
| 315 | ? "Optional. Empty uses g1t's choice of Claude for each kind of work; set it if your endpoint names models its own way." | |
| 316 | : "The model to use unless routing says otherwise. g1t also lists the endpoint's models if it offers a list." | |
| 317 | } | |
| 318 | > | |
| 319 | <Input name="model" required={!anthropic} placeholder={anthropic ? "claude-sonnet-5-5" : "qwen3-coder"} /> | |
| 320 | </Field> | |
| 321 | </> | |
| 322 | ); | |
| 323 | } | |
| 324 | return ( | |
| 325 | <> | |
| 326 | <Field | |
| 327 | label="API key" | |
| 328 | hint={`${entry?.keyUrl ? `Get one at ${new URL(entry.keyUrl).host}. ` : ""}Sealed when saved: nobody sees it again, and agents never do.`} | |
| 329 | > | |
| 330 | <Input name="secret" type="password" required placeholder={entry?.keyPlaceholder} /> | |
| 331 | </Field> | |
| 332 | {provider !== "anthropic" && ( | |
| 333 | <Field label="Default model" hint="Optional. g1t lists the key's models when you connect; you choose per kind of work after."> | |
| 334 | <Input name="model" placeholder={entry?.model} /> | |
| 335 | </Field> | |
| 336 | )} | |
| 337 | </> | |
| 338 | ); | |
| 339 | } | |
| 340 | ||
| 341 | const TASK_LABELS: Record<ModelTask, { label: string; hint: string }> = { | |
| 342 | default: { label: "Everything", hint: "Unless a kind of work below says otherwise." }, | |
| 343 | implement: { label: "Making changes", hint: "Writing the change for an issue, and revising it." }, | |
| 344 | review: { label: "Reviewing", hint: "The second agent that reviews each change." }, | |
| 345 | plan: { label: "Planning", hint: "Turning an outcome into issues." }, | |
| 346 | update: { label: "Catching up", hint: "Bringing a change up to date with main." }, | |
| 347 | }; | |
| 348 | ||
| 349 | type Choice = { target: string; model: string }; | |
| 350 | ||
| 351 | function choiceOf(route: ModelRoute | undefined): Choice { | |
| 352 | if (!route) return { target: "", model: "" }; | |
| 353 | return { target: route.connectionId ?? "g1t", model: route.model ?? "" }; | |
| 354 | } | |
| 355 | ||
| 356 | /** Each kind of work, and the provider and model it goes to. */ | |
| 357 | export function Routing({ | |
| 358 | connections, | |
| 359 | routes, | |
| 360 | hostedOpen, | |
| 361 | marginPercent, | |
| 362 | owner, | |
| 363 | busy, | |
| 364 | saved, | |
| 365 | }: { | |
| 366 | connections: Connection[]; | |
| 367 | routes: ModelRoute[]; | |
| 368 | hostedOpen: boolean; | |
| 369 | marginPercent: number; | |
| 370 | owner: boolean; | |
| 371 | busy: boolean; | |
| 372 | saved: boolean; | |
| 373 | }) { | |
| 374 | const fallback: Choice = hostedOpen || connections.length === 0 ? { target: "g1t", model: "" } : { target: connections[0].id, model: "" }; | |
| 375 | const [choices, setChoices] = useState<Record<ModelTask, Choice>>(() => { | |
| 376 | const initial = {} as Record<ModelTask, Choice>; | |
| 377 | for (const task of MODEL_TASKS) { | |
| 378 | const chosen = choiceOf(routes.find((route) => route.task === task)); | |
| 379 | initial[task] = task === "default" && !chosen.target ? fallback : chosen; | |
| 380 | } | |
| 381 | return initial; | |
| 382 | }); | |
| 383 | const set = (task: ModelTask, choice: Partial<Choice>) => setChoices((all) => ({ ...all, [task]: { ...all[task], ...choice } })); | |
| 384 | ||
| 385 | return ( | |
| 386 | <Form method="post" className="rounded-xl border border-line bg-surface"> | |
| 387 | <input type="hidden" name="intent" value="routes" /> | |
| 388 | {connections.map((connection) => ( | |
| 389 | <datalist key={connection.id} id={`models-${connection.id}`}> | |
| 390 | {connection.models.map((model) => ( | |
| 391 | <option key={model} value={model} /> | |
| 392 | ))} | |
| 393 | </datalist> | |
| 394 | ))} | |
| 395 | <div className="border-b border-line px-4 py-3"> | |
| 396 | <p className="text-sm font-medium">Which model does which work</p> | |
| 397 | <p className="text-xs text-muted"> | |
| 398 | Each kind of work can go to g1t's models, or to any of your providers on the model you choose. | |
| 399 | </p> | |
| 400 | </div> | |
| 401 | <ul className="divide-y divide-line"> | |
| 402 | {MODEL_TASKS.map((task) => { | |
| 403 | const choice = choices[task]; | |
| 404 | const connection = connections.find((c) => c.id === choice.target); | |
| 405 | const speaksAnthropic = connection && PROVIDERS[connection.provider] && (connection.provider === "anthropic" || connection.provider === "anthropic_endpoint"); | |
| 406 | const needsModel = connection && !speaksAnthropic && !choice.model && !connection.config.model; | |
| 407 | const value = !choice.target ? "" : choice.target === "g1t" ? "g1t" : `${choice.target}::${choice.model}`; | |
| 408 | return ( | |
| 409 | <li key={task} className="grid items-start gap-2 px-4 py-3 md:grid-cols-[11rem_1fr_1fr]"> | |
| 410 | <div className="pt-1.5"> | |
| 411 | <p className="text-sm font-medium">{TASK_LABELS[task].label}</p> | |
| 412 | <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p> | |
| 413 | </div> | |
| 414 | <input type="hidden" name={`route-${task}`} value={value} /> | |
| Workspace names and icons, and a component kit for every control | 415 | <Select |
| A catalogue of model providers, and settings that feel like settings | 416 | disabled={!owner} |
| Workspace names and icons, and a component kit for every control | 417 | value={choice.target || SAME} |
| 418 | onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })} | |
| A catalogue of model providers, and settings that feel like settings | 419 | > |
| Workspace names and icons, and a component kit for every control | 420 | <SelectTrigger aria-label={`${TASK_LABELS[task].label}: provider`}> |
| 421 | <SelectValue /> | |
| 422 | </SelectTrigger> | |
| 423 | <SelectContent> | |
| 424 | {task !== "default" && ( | |
| 425 | <> | |
| 426 | <SelectItem value={SAME} description="Follows the choice for everything"> | |
| 427 | Same as everything | |
| 428 | </SelectItem> | |
| 429 | <SelectSeparator /> | |
| 430 | </> | |
| 431 | )} | |
| 432 | <SelectItem | |
| 433 | value="g1t" | |
| 434 | disabled={!hostedOpen} | |
| 435 | icon={<Sparkles />} | |
| 436 | description={hostedOpen ? `Credit at cost + ${marginPercent}%` : "Not open to this workspace yet"} | |
| 437 | > | |
| 438 | g1t's models | |
| 439 | </SelectItem> | |
| 440 | {connections.length > 0 && ( | |
| 441 | <SelectGroup> | |
| 442 | <SelectLabel>Your providers</SelectLabel> | |
| 443 | {connections.map((c) => ( | |
| 444 | <SelectItem key={c.id} value={c.id} icon={<Bot />} description={PROVIDERS[c.provider]?.label}> | |
| 445 | {c.name} | |
| 446 | </SelectItem> | |
| 447 | ))} | |
| 448 | </SelectGroup> | |
| 449 | )} | |
| 450 | </SelectContent> | |
| 451 | </Select> | |
| A catalogue of model providers, and settings that feel like settings | 452 | <div> |
| 453 | <input | |
| 454 | aria-label={`${TASK_LABELS[task].label}: model`} | |
| 455 | list={connection ? `models-${connection.id}` : undefined} | |
| 456 | disabled={!owner || !connection} | |
| 457 | value={connection ? choice.model : ""} | |
| 458 | onChange={(event) => set(task, { model: event.target.value })} | |
| 459 | placeholder={ | |
| 460 | !choice.target | |
| 461 | ? "Follows everything" | |
| 462 | : !connection | |
| 463 | ? "g1t's choice for this work" | |
| 464 | : speaksAnthropic | |
| 465 | ? connection.config.model ?? "g1t's choice of Claude" | |
| 466 | : connection.config.model ?? `Search ${connection.models.length} models` | |
| 467 | } | |
| 468 | className={`${SELECT} font-mono text-[0.8125rem] ${needsModel ? "border-warn/60" : ""}`} | |
| 469 | autoComplete="off" | |
| 470 | /> | |
| 471 | {needsModel && <p className="mt-1 text-xs text-warn">Choose a model.</p>} | |
| 472 | </div> | |
| 473 | </li> | |
| 474 | ); | |
| 475 | })} | |
| 476 | </ul> | |
| 477 | {owner && ( | |
| 478 | <div className="flex items-center gap-3 border-t border-line px-4 py-3"> | |
| 479 | <Button type="submit" variant="quiet" disabled={busy}> | |
| 480 | Save routing | |
| 481 | </Button> | |
| 482 | {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>} | |
| 483 | </div> | |
| 484 | )} | |
| 485 | </Form> | |
| 486 | ); | |
| 487 | } |