Skip to content

g1t/apps/web/app/components/model-providers.tsx

522 lines19,815 bytesCodeBlame

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 settings1/**
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 control6import { Bot, ChevronRight, Sparkles, Webhook } from "lucide-react";
A catalogue of model providers, and settings that feel like settings7import { type ReactNode, useState } from "react";
8import { Form, Link } from "react-router";
9
Merge branch 'model-routing'10import { 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 settings11
Merge branch 'worktree-agent-a985c01f43538af01'12import { Field, Input, SubmitButton } from "./ui";
Workspace names and icons, and a component kit for every control13import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
A catalogue of model providers, and settings that feel like settings14
15type Group = "labs" | "platforms" | "any";
16
17type 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. */
32export 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
147const 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. */
156export 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 look177 // 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 settings179 boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`,
180 }}
181 >
182 {content}
183 </span>
184 );
185}
186
187const 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. */
194export 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. */
231export 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 control249/** Radix Select items cannot be "": the routing value for "same as everything". */
250const SAME = "same";
251
A catalogue of model providers, and settings that feel like settings252const 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
255const 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. */
262export 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 control297 <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 settings310 </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
342const 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
350type Choice = { target: string; model: string };
351
Merge branch 'model-routing'352/** g1t's models, as a route can choose them: Auto, or one tier. */
353const AUTO = "auto";
354const 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 settings360function 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. */
366export 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 settings404 </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 settings420 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 control427 <Select
A catalogue of model providers, and settings that feel like settings428 disabled={!owner}
Workspace names and icons, and a component kit for every control429 value={choice.target || SAME}
430 onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })}
A catalogue of model providers, and settings that feel like settings431 >
Workspace names and icons, and a component kit for every control432 <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 control449 >
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 settings486 <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 settings498 : 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 settings508 </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 settings515 Save routing
Merge branch 'worktree-agent-a985c01f43538af01'516 </SubmitButton>
A catalogue of model providers, and settings that feel like settings517 {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.