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