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

486 lines18,250 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, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts";
11
12import { Field, Input, SubmitButton } from "./ui";
13import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
14
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)`,
177 // Lifted toward white, so the small letters read on the dark tile.
178 color: `color-mix(in srgb, ${color} 72%, white)`,
179 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
249/** Radix Select items cannot be "": the routing value for "same as everything". */
250const SAME = "same";
251
252const 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">
297 <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>
310 </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
352function choiceOf(route: ModelRoute | undefined): Choice {
353 if (!route) return { target: "", model: "" };
354 return { target: route.connectionId ?? "g1t", model: route.model ?? "" };
355}
356
357/** Each kind of work, and the provider and model it goes to. */
358export function Routing({
359 connections,
360 routes,
361 hostedOpen,
362 marginPercent,
363 owner,
364 saved,
365}: {
366 connections: Connection[];
367 routes: ModelRoute[];
368 hostedOpen: boolean;
369 marginPercent: number;
370 owner: boolean;
371 saved: boolean;
372}) {
373 const fallback: Choice = hostedOpen || connections.length === 0 ? { target: "g1t", model: "" } : { target: connections[0].id, model: "" };
374 const [choices, setChoices] = useState<Record<ModelTask, Choice>>(() => {
375 const initial = {} as Record<ModelTask, Choice>;
376 for (const task of MODEL_TASKS) {
377 const chosen = choiceOf(routes.find((route) => route.task === task));
378 initial[task] = task === "default" && !chosen.target ? fallback : chosen;
379 }
380 return initial;
381 });
382 const set = (task: ModelTask, choice: Partial<Choice>) => setChoices((all) => ({ ...all, [task]: { ...all[task], ...choice } }));
383
384 return (
385 <Form method="post" className="rounded-xl border border-line bg-surface">
386 <input type="hidden" name="intent" value="routes" />
387 {connections.map((connection) => (
388 <datalist key={connection.id} id={`models-${connection.id}`}>
389 {connection.models.map((model) => (
390 <option key={model} value={model} />
391 ))}
392 </datalist>
393 ))}
394 <div className="border-b border-line px-4 py-3">
395 <p className="text-sm font-medium">Which model does which work</p>
396 <p className="text-xs text-muted">
397 Each kind of work can go to g1t's models, or to any of your providers on the model you choose.
398 </p>
399 </div>
400 <ul className="divide-y divide-line">
401 {MODEL_TASKS.map((task) => {
402 const choice = choices[task];
403 const connection = connections.find((c) => c.id === choice.target);
404 const speaksAnthropic = connection && PROVIDERS[connection.provider] && (connection.provider === "anthropic" || connection.provider === "anthropic_endpoint");
405 const needsModel = connection && !speaksAnthropic && !choice.model && !connection.config.model;
406 const value = !choice.target ? "" : choice.target === "g1t" ? "g1t" : `${choice.target}::${choice.model}`;
407 return (
408 <li key={task} className="grid items-start gap-2 px-4 py-3 md:grid-cols-[11rem_1fr_1fr]">
409 <div className="pt-1.5">
410 <p className="text-sm font-medium">{TASK_LABELS[task].label}</p>
411 <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p>
412 </div>
413 <input type="hidden" name={`route-${task}`} value={value} />
414 <Select
415 disabled={!owner}
416 value={choice.target || SAME}
417 onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })}
418 >
419 <SelectTrigger aria-label={`${TASK_LABELS[task].label}: provider`}>
420 <SelectValue />
421 </SelectTrigger>
422 <SelectContent>
423 {task !== "default" && (
424 <>
425 <SelectItem value={SAME} description="Follows the choice for everything">
426 Same as everything
427 </SelectItem>
428 <SelectSeparator />
429 </>
430 )}
431 <SelectItem
432 value="g1t"
433 disabled={!hostedOpen}
434 icon={<Sparkles />}
435 description={hostedOpen ? `Credit at cost + ${marginPercent}%` : "Not open to this workspace yet"}
436 >
437 g1t's models
438 </SelectItem>
439 {connections.length > 0 && (
440 <SelectGroup>
441 <SelectLabel>Your providers</SelectLabel>
442 {connections.map((c) => (
443 <SelectItem key={c.id} value={c.id} icon={<Bot />} description={PROVIDERS[c.provider]?.label}>
444 {c.name}
445 </SelectItem>
446 ))}
447 </SelectGroup>
448 )}
449 </SelectContent>
450 </Select>
451 <div>
452 <input
453 aria-label={`${TASK_LABELS[task].label}: model`}
454 list={connection ? `models-${connection.id}` : undefined}
455 disabled={!owner || !connection}
456 value={connection ? choice.model : ""}
457 onChange={(event) => set(task, { model: event.target.value })}
458 placeholder={
459 !choice.target
460 ? "Follows everything"
461 : !connection
462 ? "g1t's choice for this work"
463 : speaksAnthropic
464 ? connection.config.model ?? "g1t's choice of Claude"
465 : connection.config.model ?? `Search ${connection.models.length} models`
466 }
467 className={`${SELECT} font-mono text-[0.8125rem] ${needsModel ? "border-warn/60" : ""}`}
468 autoComplete="off"
469 />
470 {needsModel && <p className="mt-1 text-xs text-warn">Choose a model.</p>}
471 </div>
472 </li>
473 );
474 })}
475 </ul>
476 {owner && (
477 <div className="flex items-center gap-3 border-t border-line px-4 py-3">
478 <SubmitButton variant="quiet" match={{ intent: "routes" }} pending="Saving…">
479 Save routing
480 </SubmitButton>
481 {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>}
482 </div>
483 )}
484 </Form>
485 );
486}