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