Skip to content

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

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