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 | ||
| Merge branch 'model-routing' | 10 | import { type Connection, MODEL_TASKS, MODEL_TIERS, type ModelRoute, type ModelTask, type ModelTier, type Provider, PROVIDERS } from "@g1t/contracts"; |
| A catalogue of model providers, and settings that feel like settings | 11 | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 12 | import { Field, Input, SubmitButton } 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)`, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 177 | // Lifted toward white, so the small letters read on the dark tile. |
| 178 | color: `color-mix(in srgb, ${color} 72%, white)`, | |
| A catalogue of model providers, and settings that feel like settings | 179 | boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`, |
| 180 | }} | |
| 181 | > | |
| 182 | {content} | |
| 183 | </span> | |
| 184 | ); | |
| 185 | } | |
| 186 | ||
| 187 | const GROUPS: { group: Group; title: string }[] = [ | |
| 188 | { group: "labs", title: "Labs" }, | |
| 189 | { group: "platforms", title: "Platforms" }, | |
| 190 | { group: "any", title: "Any endpoint" }, | |
| 191 | ]; | |
| 192 | ||
| 193 | /** Providers to choose from, as compact tiles that open their form. */ | |
| 194 | export function ProviderTiles({ | |
| 195 | providers, | |
| 196 | slug, | |
| 197 | adding, | |
| 198 | blurb, | |
| 199 | }: { | |
| 200 | providers: Provider[]; | |
| 201 | slug: string; | |
| 202 | adding: Provider | null; | |
| 203 | blurb: (provider: Provider) => string; | |
| 204 | }) { | |
| 205 | return ( | |
| 206 | <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 207 | {providers.map((provider) => ( | |
| 208 | <Link | |
| 209 | key={provider} | |
| 210 | to={`/${slug}/-/integrations?add=${provider}#add`} | |
| 211 | preventScrollReset | |
| 212 | className={`group flex items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${ | |
| 213 | adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface" | |
| 214 | }`} | |
| 215 | > | |
| 216 | <ProviderMark provider={provider} size={28} /> | |
| 217 | <span className="min-w-0 grow"> | |
| 218 | <span className="block truncate text-sm font-medium">{PROVIDERS[provider].label}</span> | |
| 219 | <span className="block truncate text-xs text-muted" title={blurb(provider)}> | |
| 220 | {blurb(provider)} | |
| 221 | </span> | |
| 222 | </span> | |
| 223 | <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> | |
| 224 | </Link> | |
| 225 | ))} | |
| 226 | </div> | |
| 227 | ); | |
| 228 | } | |
| 229 | ||
| 230 | /** The model providers to choose from, grouped. */ | |
| 231 | export function ModelCatalog({ slug, adding }: { slug: string; adding: Provider | null }) { | |
| 232 | return ( | |
| 233 | <div className="mt-4 space-y-4"> | |
| 234 | {GROUPS.map(({ group, title }) => ( | |
| 235 | <div key={group}> | |
| 236 | <p className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">{title}</p> | |
| 237 | <ProviderTiles | |
| 238 | providers={(Object.keys(MODEL_CATALOG) as Provider[]).filter((provider) => MODEL_CATALOG[provider]!.group === group)} | |
| 239 | slug={slug} | |
| 240 | adding={adding} | |
| 241 | blurb={(provider) => MODEL_CATALOG[provider]!.blurb} | |
| 242 | /> | |
| 243 | </div> | |
| 244 | ))} | |
| 245 | </div> | |
| 246 | ); | |
| 247 | } | |
| 248 | ||
| Workspace names and icons, and a component kit for every control | 249 | /** Radix Select items cannot be "": the routing value for "same as everything". */ |
| 250 | const SAME = "same"; | |
| 251 | ||
| A catalogue of model providers, and settings that feel like settings | 252 | const SELECT = |
| 253 | "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"; | |
| 254 | ||
| 255 | const GATEWAY_TOKEN = ( | |
| 256 | <Field label="Cloudflare AI Gateway token" hint="Only for an authenticated AI Gateway: sent as cf-aig-authorization."> | |
| 257 | <Input name="signingSecret" type="password" /> | |
| 258 | </Field> | |
| 259 | ); | |
| 260 | ||
| 261 | /** What connecting a model provider asks for. */ | |
| 262 | export function ModelProviderFields({ provider }: { provider: Provider }): ReactNode { | |
| 263 | const entry = MODEL_CATALOG[provider]; | |
| 264 | if (provider === "azure_openai") { | |
| 265 | return ( | |
| 266 | <> | |
| 267 | <Field label="Endpoint" hint="Your resource's endpoint, from the Azure portal: Keys and Endpoint."> | |
| 268 | <Input name="baseUrl" type="url" required placeholder="https://acme.openai.azure.com" /> | |
| 269 | </Field> | |
| 270 | <Field label="Key" hint="KEY 1 or KEY 2 of the resource."> | |
| 271 | <Input name="secret" type="password" required /> | |
| 272 | </Field> | |
| 273 | <Field label="Deployment" hint="The name of the model deployment to use. Add a connection for each deployment you want to route to."> | |
| 274 | <Input name="model" required placeholder="gpt-5" /> | |
| 275 | </Field> | |
| 276 | </> | |
| 277 | ); | |
| 278 | } | |
| 279 | if (provider === "anthropic_endpoint" || provider === "openai_endpoint") { | |
| 280 | const anthropic = provider === "anthropic_endpoint"; | |
| 281 | return ( | |
| 282 | <> | |
| 283 | <Field | |
| 284 | label="Base URL" | |
| 285 | hint={ | |
| 286 | anthropic | |
| 287 | ? "Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic" | |
| 288 | : "Up to and including the version, such as https://llm.acme.dev/v1. g1t calls /chat/completions under it." | |
| 289 | } | |
| 290 | > | |
| 291 | <Input name="baseUrl" type="url" required placeholder={anthropic ? "https://llm.acme.dev" : "https://llm.acme.dev/v1"} /> | |
| 292 | </Field> | |
| 293 | <Field label="Key" hint="Optional, if the endpoint needs one."> | |
| 294 | <Input name="secret" type="password" /> | |
| 295 | </Field> | |
| 296 | <Field label="Send the key as"> | |
| Workspace names and icons, and a component kit for every control | 297 | <Select name="authHeader" defaultValue={anthropic ? "x-api-key" : "authorization"}> |
| 298 | <SelectTrigger aria-label="Send the key as" className="font-mono text-[0.8125rem]"> | |
| 299 | <SelectValue /> | |
| 300 | </SelectTrigger> | |
| 301 | <SelectContent> | |
| 302 | <SelectItem value="x-api-key" description="Anthropic's header" className="font-mono text-[0.8125rem]"> | |
| 303 | x-api-key | |
| 304 | </SelectItem> | |
| 305 | <SelectItem value="authorization" description="OpenAI's header, and most others" className="font-mono text-[0.8125rem]"> | |
| 306 | Authorization: Bearer | |
| 307 | </SelectItem> | |
| 308 | </SelectContent> | |
| 309 | </Select> | |
| A catalogue of model providers, and settings that feel like settings | 310 | </Field> |
| 311 | {GATEWAY_TOKEN} | |
| 312 | <Field | |
| 313 | label={anthropic ? "Model" : "Default model"} | |
| 314 | hint={ | |
| 315 | anthropic | |
| 316 | ? "Optional. Empty uses g1t's choice of Claude for each kind of work; set it if your endpoint names models its own way." | |
| 317 | : "The model to use unless routing says otherwise. g1t also lists the endpoint's models if it offers a list." | |
| 318 | } | |
| 319 | > | |
| 320 | <Input name="model" required={!anthropic} placeholder={anthropic ? "claude-sonnet-5-5" : "qwen3-coder"} /> | |
| 321 | </Field> | |
| 322 | </> | |
| 323 | ); | |
| 324 | } | |
| 325 | return ( | |
| 326 | <> | |
| 327 | <Field | |
| 328 | label="API key" | |
| 329 | hint={`${entry?.keyUrl ? `Get one at ${new URL(entry.keyUrl).host}. ` : ""}Sealed when saved: nobody sees it again, and agents never do.`} | |
| 330 | > | |
| 331 | <Input name="secret" type="password" required placeholder={entry?.keyPlaceholder} /> | |
| 332 | </Field> | |
| 333 | {provider !== "anthropic" && ( | |
| 334 | <Field label="Default model" hint="Optional. g1t lists the key's models when you connect; you choose per kind of work after."> | |
| 335 | <Input name="model" placeholder={entry?.model} /> | |
| 336 | </Field> | |
| 337 | )} | |
| 338 | </> | |
| 339 | ); | |
| 340 | } | |
| 341 | ||
| 342 | const TASK_LABELS: Record<ModelTask, { label: string; hint: string }> = { | |
| 343 | default: { label: "Everything", hint: "Unless a kind of work below says otherwise." }, | |
| 344 | implement: { label: "Making changes", hint: "Writing the change for an issue, and revising it." }, | |
| 345 | review: { label: "Reviewing", hint: "The second agent that reviews each change." }, | |
| 346 | plan: { label: "Planning", hint: "Turning an outcome into issues." }, | |
| 347 | update: { label: "Catching up", hint: "Bringing a change up to date with main." }, | |
| 348 | }; | |
| 349 | ||
| 350 | type Choice = { target: string; model: string }; | |
| 351 | ||
| Merge branch 'model-routing' | 352 | /** g1t's models, as a route can choose them: Auto, or one tier. */ |
| 353 | const AUTO = "auto"; | |
| 354 | const TIER_CHOICES: Record<ModelTier, { label: string; hint: string }> = { | |
| 355 | small: { label: "Fast", hint: "Cheapest; simple work" }, | |
| 356 | large: { label: "Standard", hint: "Most changes" }, | |
| 357 | frontier: { label: "Most capable", hint: "Hard work; costs the most" }, | |
| 358 | }; | |
| 359 | ||
| A catalogue of model providers, and settings that feel like settings | 360 | function choiceOf(route: ModelRoute | undefined): Choice { |
| 361 | if (!route) return { target: "", model: "" }; | |
| 362 | return { target: route.connectionId ?? "g1t", model: route.model ?? "" }; | |
| 363 | } | |
| 364 | ||
| 365 | /** Each kind of work, and the provider and model it goes to. */ | |
| 366 | export function Routing({ | |
| 367 | connections, | |
| 368 | routes, | |
| 369 | hostedOpen, | |
| 370 | owner, | |
| 371 | saved, | |
| 372 | }: { | |
| 373 | connections: Connection[]; | |
| 374 | routes: ModelRoute[]; | |
| 375 | hostedOpen: boolean; | |
| 376 | owner: boolean; | |
| 377 | saved: boolean; | |
| 378 | }) { | |
| 379 | const fallback: Choice = hostedOpen || connections.length === 0 ? { target: "g1t", model: "" } : { target: connections[0].id, model: "" }; | |
| 380 | const [choices, setChoices] = useState<Record<ModelTask, Choice>>(() => { | |
| 381 | const initial = {} as Record<ModelTask, Choice>; | |
| 382 | for (const task of MODEL_TASKS) { | |
| 383 | const chosen = choiceOf(routes.find((route) => route.task === task)); | |
| 384 | initial[task] = task === "default" && !chosen.target ? fallback : chosen; | |
| 385 | } | |
| 386 | return initial; | |
| 387 | }); | |
| 388 | const set = (task: ModelTask, choice: Partial<Choice>) => setChoices((all) => ({ ...all, [task]: { ...all[task], ...choice } })); | |
| 389 | ||
| 390 | return ( | |
| 391 | <Form method="post" className="rounded-xl border border-line bg-surface"> | |
| 392 | <input type="hidden" name="intent" value="routes" /> | |
| 393 | {connections.map((connection) => ( | |
| 394 | <datalist key={connection.id} id={`models-${connection.id}`}> | |
| 395 | {connection.models.map((model) => ( | |
| 396 | <option key={model} value={model} /> | |
| 397 | ))} | |
| 398 | </datalist> | |
| 399 | ))} | |
| 400 | <div className="border-b border-line px-4 py-3"> | |
| 401 | <p className="text-sm font-medium">Which model does which work</p> | |
| 402 | <p className="text-xs text-muted"> | |
| Merge branch 'model-routing' | 403 | Each kind of work can go to g1t's models, or to any of your providers on the model you choose. On g1t's models, Auto picks the cheapest model that can do each job and says why on the run. |
| A catalogue of model providers, and settings that feel like settings | 404 | </p> |
| 405 | </div> | |
| 406 | <ul className="divide-y divide-line"> | |
| 407 | {MODEL_TASKS.map((task) => { | |
| 408 | const choice = choices[task]; | |
| 409 | const connection = connections.find((c) => c.id === choice.target); | |
| 410 | const speaksAnthropic = connection && PROVIDERS[connection.provider] && (connection.provider === "anthropic" || connection.provider === "anthropic_endpoint"); | |
| 411 | const needsModel = connection && !speaksAnthropic && !choice.model && !connection.config.model; | |
| Merge branch 'model-routing' | 412 | const hosted = choice.target === "g1t"; |
| 413 | const value = !choice.target | |
| 414 | ? "" | |
| 415 | : hosted | |
| 416 | ? choice.model | |
| 417 | ? `g1t::${choice.model}` | |
| 418 | : "g1t" | |
| 419 | : `${choice.target}::${choice.model}`; | |
| A catalogue of model providers, and settings that feel like settings | 420 | return ( |
| 421 | <li key={task} className="grid items-start gap-2 px-4 py-3 md:grid-cols-[11rem_1fr_1fr]"> | |
| 422 | <div className="pt-1.5"> | |
| 423 | <p className="text-sm font-medium">{TASK_LABELS[task].label}</p> | |
| 424 | <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p> | |
| 425 | </div> | |
| 426 | <input type="hidden" name={`route-${task}`} value={value} /> | |
| Workspace names and icons, and a component kit for every control | 427 | <Select |
| A catalogue of model providers, and settings that feel like settings | 428 | disabled={!owner} |
| Workspace names and icons, and a component kit for every control | 429 | value={choice.target || SAME} |
| 430 | onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })} | |
| A catalogue of model providers, and settings that feel like settings | 431 | > |
| Workspace names and icons, and a component kit for every control | 432 | <SelectTrigger aria-label={`${TASK_LABELS[task].label}: provider`}> |
| 433 | <SelectValue /> | |
| 434 | </SelectTrigger> | |
| 435 | <SelectContent> | |
| 436 | {task !== "default" && ( | |
| 437 | <> | |
| 438 | <SelectItem value={SAME} description="Follows the choice for everything"> | |
| 439 | Same as everything | |
| 440 | </SelectItem> | |
| 441 | <SelectSeparator /> | |
| 442 | </> | |
| 443 | )} | |
| 444 | <SelectItem | |
| 445 | value="g1t" | |
| 446 | disabled={!hostedOpen} | |
| 447 | icon={<Sparkles />} | |
| Merge branch 'model-routing' | 448 | description={hostedOpen ? "The provider's price, plus the agent rate" : "Not open to this workspace yet"} |
| Workspace names and icons, and a component kit for every control | 449 | > |
| 450 | g1t's models | |
| 451 | </SelectItem> | |
| 452 | {connections.length > 0 && ( | |
| 453 | <SelectGroup> | |
| 454 | <SelectLabel>Your providers</SelectLabel> | |
| 455 | {connections.map((c) => ( | |
| 456 | <SelectItem key={c.id} value={c.id} icon={<Bot />} description={PROVIDERS[c.provider]?.label}> | |
| 457 | {c.name} | |
| 458 | </SelectItem> | |
| 459 | ))} | |
| 460 | </SelectGroup> | |
| 461 | )} | |
| 462 | </SelectContent> | |
| 463 | </Select> | |
| Merge branch 'model-routing' | 464 | {hosted ? ( |
| 465 | <Select | |
| 466 | disabled={!owner} | |
| 467 | value={choice.model || AUTO} | |
| 468 | onValueChange={(model) => set(task, { model: model === AUTO ? "" : model })} | |
| 469 | > | |
| 470 | <SelectTrigger aria-label={`${TASK_LABELS[task].label}: g1t's model`}> | |
| 471 | <SelectValue /> | |
| 472 | </SelectTrigger> | |
| 473 | <SelectContent> | |
| 474 | <SelectItem value={AUTO} description="g1t picks per job, and says why"> | |
| 475 | Auto | |
| 476 | </SelectItem> | |
| 477 | <SelectSeparator /> | |
| 478 | {MODEL_TIERS.map((tier) => ( | |
| 479 | <SelectItem key={tier} value={tier} description={TIER_CHOICES[tier].hint}> | |
| 480 | {TIER_CHOICES[tier].label} | |
| 481 | </SelectItem> | |
| 482 | ))} | |
| 483 | </SelectContent> | |
| 484 | </Select> | |
| 485 | ) : ( | |
| A catalogue of model providers, and settings that feel like settings | 486 | <div> |
| 487 | <input | |
| 488 | aria-label={`${TASK_LABELS[task].label}: model`} | |
| 489 | list={connection ? `models-${connection.id}` : undefined} | |
| 490 | disabled={!owner || !connection} | |
| 491 | value={connection ? choice.model : ""} | |
| 492 | onChange={(event) => set(task, { model: event.target.value })} | |
| 493 | placeholder={ | |
| 494 | !choice.target | |
| 495 | ? "Follows everything" | |
| 496 | : !connection | |
| Merge branch 'model-routing' | 497 | ? "Auto" |
| A catalogue of model providers, and settings that feel like settings | 498 | : speaksAnthropic |
| 499 | ? connection.config.model ?? "g1t's choice of Claude" | |
| 500 | : connection.config.model ?? `Search ${connection.models.length} models` | |
| 501 | } | |
| 502 | className={`${SELECT} font-mono text-[0.8125rem] ${needsModel ? "border-warn/60" : ""}`} | |
| 503 | autoComplete="off" | |
| 504 | /> | |
| 505 | {needsModel && <p className="mt-1 text-xs text-warn">Choose a model.</p>} | |
| 506 | </div> | |
| Merge branch 'model-routing' | 507 | )} |
| A catalogue of model providers, and settings that feel like settings | 508 | </li> |
| 509 | ); | |
| 510 | })} | |
| 511 | </ul> | |
| 512 | {owner && ( | |
| 513 | <div className="flex items-center gap-3 border-t border-line px-4 py-3"> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 514 | <SubmitButton variant="quiet" match={{ intent: "routes" }} pending="Saving…"> |
| A catalogue of model providers, and settings that feel like settings | 515 | Save routing |
| Merge branch 'worktree-agent-a985c01f43538af01' | 516 | </SubmitButton> |
| A catalogue of model providers, and settings that feel like settings | 517 | {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>} |
| 518 | </div> | |
| 519 | )} | |
| 520 | </Form> | |
| 521 | ); | |
| 522 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.