Skip to content

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

572 lines21,526 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, gatewayPatterns } 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 to suggest a provider's AI Gateway models be, as typed. */
263const GATEWAY_EXAMPLES: Partial<Record<Provider, string>> = {
264 openai: "gpt-*",
265 gemini: "gemini-*",
266 xai: "grok-*",
267 mistral: "mistral-* codestral-*",
268 deepseek: "deepseek-*",
269 openrouter: "openrouter/*",
270 groq: "groq/*",
271 together: "together/*",
272 fireworks: "fireworks/*",
273 cerebras: "cerebras/*",
274 openai_endpoint: "ollama/*",
275 anthropic_endpoint: "claude-*",
276 azure_openai: "azure/*",
277};
278
279/**
280 * Which of the AI Gateway's requests go to a model provider, by the model
281 * they name: for connecting one, and for changing it after.
282 */
283export function GatewayModelsField({ provider, value }: { provider: Provider; value?: string[] }) {
284 const defaults = gatewayPatterns(provider, {});
285 return (
286 <Field
287 label="AI Gateway models"
288 hint="Your own code's requests to the AI Gateway that name one of these models come here, counted and never charged by g1t. Model ids, or prefixes ending in *; a prefix ending in /* is taken off before sending, so ollama/llama3.3 arrives as llama3.3. Separate them with spaces. Empty: none."
289 >
290 <Input
291 name="gatewayModels"
292 defaultValue={(value ?? defaults).join(" ")}
293 placeholder={GATEWAY_EXAMPLES[provider] ?? "model-id or prefix-*"}
294 className="font-mono text-[0.8125rem]"
295 autoComplete="off"
296 spellCheck={false}
297 />
298 </Field>
299 );
300}
301
302/** What connecting a model provider asks for. */
303export function ModelProviderFields({ provider }: { provider: Provider }): ReactNode {
304 return (
305 <>
306 <ModelProviderKeyFields provider={provider} />
307 <GatewayModelsField provider={provider} />
308 </>
309 );
310}
311
312function ModelProviderKeyFields({ provider }: { provider: Provider }): ReactNode {
313 const entry = MODEL_CATALOG[provider];
314 if (provider === "azure_openai") {
315 return (
316 <>
317 <Field label="Endpoint" hint="Your resource's endpoint, from the Azure portal: Keys and Endpoint.">
318 <Input name="baseUrl" type="url" required placeholder="https://acme.openai.azure.com" />
319 </Field>
320 <Field label="Key" hint="KEY 1 or KEY 2 of the resource.">
321 <Input name="secret" type="password" required />
322 </Field>
323 <Field label="Deployment" hint="The name of the model deployment to use. Add a connection for each deployment you want to route to.">
324 <Input name="model" required placeholder="gpt-5" />
325 </Field>
326 </>
327 );
328 }
329 if (provider === "anthropic_endpoint" || provider === "openai_endpoint") {
330 const anthropic = provider === "anthropic_endpoint";
331 return (
332 <>
333 <Field
334 label="Base URL"
335 hint={
336 anthropic
337 ? "Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic"
338 : "Up to and including the version, such as https://llm.acme.dev/v1. g1t calls /chat/completions under it."
339 }
340 >
341 <Input name="baseUrl" type="url" required placeholder={anthropic ? "https://llm.acme.dev" : "https://llm.acme.dev/v1"} />
342 </Field>
343 <Field label="Key" hint="Optional, if the endpoint needs one.">
344 <Input name="secret" type="password" />
345 </Field>
346 <Field label="Send the key as">
347 <Select name="authHeader" defaultValue={anthropic ? "x-api-key" : "authorization"}>
348 <SelectTrigger aria-label="Send the key as" className="font-mono text-[0.8125rem]">
349 <SelectValue />
350 </SelectTrigger>
351 <SelectContent>
352 <SelectItem value="x-api-key" description="Anthropic's header" className="font-mono text-[0.8125rem]">
353 x-api-key
354 </SelectItem>
355 <SelectItem value="authorization" description="OpenAI's header, and most others" className="font-mono text-[0.8125rem]">
356 Authorization: Bearer
357 </SelectItem>
358 </SelectContent>
359 </Select>
360 </Field>
361 {GATEWAY_TOKEN}
362 <Field
363 label={anthropic ? "Model" : "Default model"}
364 hint={
365 anthropic
366 ? "Optional. Empty uses g1t's choice of Claude for each kind of work; set it if your endpoint names models its own way."
367 : "The model to use unless routing says otherwise. g1t also lists the endpoint's models if it offers a list."
368 }
369 >
370 <Input name="model" required={!anthropic} placeholder={anthropic ? "claude-sonnet-5-5" : "qwen3-coder"} />
371 </Field>
372 </>
373 );
374 }
375 return (
376 <>
377 <Field
378 label="API key"
379 hint={`${entry?.keyUrl ? `Get one at ${new URL(entry.keyUrl).host}. ` : ""}Sealed when saved: nobody sees it again, and agents never do.`}
380 >
381 <Input name="secret" type="password" required placeholder={entry?.keyPlaceholder} />
382 </Field>
383 {provider !== "anthropic" && (
384 <Field label="Default model" hint="Optional. g1t lists the key's models when you connect; you choose per kind of work after.">
385 <Input name="model" placeholder={entry?.model} />
386 </Field>
387 )}
388 </>
389 );
390}
391
392const TASK_LABELS: Record<ModelTask, { label: string; hint: string }> = {
393 default: { label: "Everything", hint: "Unless a kind of work below says otherwise." },
394 implement: { label: "Making changes", hint: "Writing the change for an issue, and revising it." },
395 review: { label: "Reviewing", hint: "The second agent that reviews each change." },
396 plan: { label: "Planning", hint: "Turning an outcome into issues." },
397 update: { label: "Catching up", hint: "Bringing a change up to date with main." },
398};
399
400type Choice = { target: string; model: string };
401
402/** g1t's models, as a route can choose them: Auto, or one tier. */
403const AUTO = "auto";
404const TIER_CHOICES: Record<ModelTier, { label: string; hint: string }> = {
405 small: { label: "Fast", hint: "Cheapest; simple work" },
406 large: { label: "Standard", hint: "Most changes" },
407 frontier: { label: "Most capable", hint: "Hard work; costs the most" },
408};
409
410function choiceOf(route: ModelRoute | undefined): Choice {
411 if (!route) return { target: "", model: "" };
412 return { target: route.connectionId ?? "g1t", model: route.model ?? "" };
413}
414
415/** Each kind of work, and the provider and model it goes to. */
416export function Routing({
417 connections,
418 routes,
419 hostedOpen,
420 owner,
421 saved,
422}: {
423 connections: Connection[];
424 routes: ModelRoute[];
425 hostedOpen: boolean;
426 owner: boolean;
427 saved: boolean;
428}) {
429 const fallback: Choice = hostedOpen || connections.length === 0 ? { target: "g1t", model: "" } : { target: connections[0].id, model: "" };
430 const [choices, setChoices] = useState<Record<ModelTask, Choice>>(() => {
431 const initial = {} as Record<ModelTask, Choice>;
432 for (const task of MODEL_TASKS) {
433 const chosen = choiceOf(routes.find((route) => route.task === task));
434 initial[task] = task === "default" && !chosen.target ? fallback : chosen;
435 }
436 return initial;
437 });
438 const set = (task: ModelTask, choice: Partial<Choice>) => setChoices((all) => ({ ...all, [task]: { ...all[task], ...choice } }));
439
440 return (
441 <Form method="post" className="rounded-xl border border-line bg-surface">
442 <input type="hidden" name="intent" value="routes" />
443 {connections.map((connection) => (
444 <datalist key={connection.id} id={`models-${connection.id}`}>
445 {connection.models.map((model) => (
446 <option key={model} value={model} />
447 ))}
448 </datalist>
449 ))}
450 <div className="border-b border-line px-4 py-3">
451 <p className="text-sm font-medium">Which model does which work</p>
452 <p className="text-xs text-muted">
453 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.
454 </p>
455 </div>
456 <ul className="divide-y divide-line">
457 {MODEL_TASKS.map((task) => {
458 const choice = choices[task];
459 const connection = connections.find((c) => c.id === choice.target);
460 const speaksAnthropic = connection && PROVIDERS[connection.provider] && (connection.provider === "anthropic" || connection.provider === "anthropic_endpoint");
461 const needsModel = connection && !speaksAnthropic && !choice.model && !connection.config.model;
462 const hosted = choice.target === "g1t";
463 const value = !choice.target
464 ? ""
465 : hosted
466 ? choice.model
467 ? `g1t::${choice.model}`
468 : "g1t"
469 : `${choice.target}::${choice.model}`;
470 return (
471 <li key={task} className="grid items-start gap-2 px-4 py-3 md:grid-cols-[11rem_1fr_1fr]">
472 <div className="pt-1.5">
473 <p className="text-sm font-medium">{TASK_LABELS[task].label}</p>
474 <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p>
475 </div>
476 <input type="hidden" name={`route-${task}`} value={value} />
477 <Select
478 disabled={!owner}
479 value={choice.target || SAME}
480 onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })}
481 >
482 <SelectTrigger aria-label={`${TASK_LABELS[task].label}: provider`}>
483 <SelectValue />
484 </SelectTrigger>
485 <SelectContent>
486 {task !== "default" && (
487 <>
488 <SelectItem value={SAME} description="Follows the choice for everything">
489 Same as everything
490 </SelectItem>
491 <SelectSeparator />
492 </>
493 )}
494 <SelectItem
495 value="g1t"
496 disabled={!hostedOpen}
497 icon={<Sparkles />}
498 description={hostedOpen ? "The provider's price, plus the agent rate" : "Not open to this workspace yet"}
499 >
500 g1t's models
501 </SelectItem>
502 {connections.length > 0 && (
503 <SelectGroup>
504 <SelectLabel>Your providers</SelectLabel>
505 {connections.map((c) => (
506 <SelectItem key={c.id} value={c.id} icon={<Bot />} description={PROVIDERS[c.provider]?.label}>
507 {c.name}
508 </SelectItem>
509 ))}
510 </SelectGroup>
511 )}
512 </SelectContent>
513 </Select>
514 {hosted ? (
515 <Select
516 disabled={!owner}
517 value={choice.model || AUTO}
518 onValueChange={(model) => set(task, { model: model === AUTO ? "" : model })}
519 >
520 <SelectTrigger aria-label={`${TASK_LABELS[task].label}: g1t's model`}>
521 <SelectValue />
522 </SelectTrigger>
523 <SelectContent>
524 <SelectItem value={AUTO} description="g1t picks per job, and says why">
525 Auto
526 </SelectItem>
527 <SelectSeparator />
528 {MODEL_TIERS.map((tier) => (
529 <SelectItem key={tier} value={tier} description={TIER_CHOICES[tier].hint}>
530 {TIER_CHOICES[tier].label}
531 </SelectItem>
532 ))}
533 </SelectContent>
534 </Select>
535 ) : (
536 <div>
537 <input
538 aria-label={`${TASK_LABELS[task].label}: model`}
539 list={connection ? `models-${connection.id}` : undefined}
540 disabled={!owner || !connection}
541 value={connection ? choice.model : ""}
542 onChange={(event) => set(task, { model: event.target.value })}
543 placeholder={
544 !choice.target
545 ? "Follows everything"
546 : !connection
547 ? "Auto"
548 : speaksAnthropic
549 ? connection.config.model ?? "g1t's choice of Claude"
550 : connection.config.model ?? `Search ${connection.models.length} models`
551 }
552 className={`${SELECT} font-mono text-[0.8125rem] ${needsModel ? "border-warn/60" : ""}`}
553 autoComplete="off"
554 />
555 {needsModel && <p className="mt-1 text-xs text-warn">Choose a model.</p>}
556 </div>
557 )}
558 </li>
559 );
560 })}
561 </ul>
562 {owner && (
563 <div className="flex items-center gap-3 border-t border-line px-4 py-3">
564 <SubmitButton variant="quiet" match={{ intent: "routes" }} pending="Saving…">
565 Save routing
566 </SubmitButton>
567 {saved && <span className="text-sm text-success">Saved. The next runs use it.</span>}
568 </div>
569 )}
570 </Form>
571 );
572}