flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

487 lines18,119 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
10import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts";
11
12import { Button, Field, Input } 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)`,
177 color,
178 boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`,
179 }}
180 >
181 {content}
182 </span>
183 );
184}
185
186const GROUPS: { group: Group; title: string }[] = [
187 { group: "labs", title: "Labs" },
188 { group: "platforms", title: "Platforms" },
189 { group: "any", title: "Any endpoint" },
190];
191
192/** Providers to choose from, as compact tiles that open their form. */
193export function ProviderTiles({
194 providers,
195 slug,
196 adding,
197 blurb,
198}: {
199 providers: Provider[];
200 slug: string;
201 adding: Provider | null;
202 blurb: (provider: Provider) => string;
203}) {
204 return (
205 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
206 {providers.map((provider) => (
207 <Link
208 key={provider}
209 to={`/${slug}/-/integrations?add=${provider}#add`}
210 preventScrollReset
211 className={`group flex items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${
212 adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
213 }`}
214 >
215 <ProviderMark provider={provider} size={28} />
216 <span className="min-w-0 grow">
217 <span className="block truncate text-sm font-medium">{PROVIDERS[provider].label}</span>
218 <span className="block truncate text-xs text-muted" title={blurb(provider)}>
219 {blurb(provider)}
220 </span>
221 </span>
222 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
223 </Link>
224 ))}
225 </div>
226 );
227}
228
229/** The model providers to choose from, grouped. */
230export function ModelCatalog({ slug, adding }: { slug: string; adding: Provider | null }) {
231 return (
232 <div className="mt-4 space-y-4">
233 {GROUPS.map(({ group, title }) => (
234 <div key={group}>
235 <p className="mb-2 text-xs font-medium tracking-wide text-faint uppercase">{title}</p>
236 <ProviderTiles
237 providers={(Object.keys(MODEL_CATALOG) as Provider[]).filter((provider) => MODEL_CATALOG[provider]!.group === group)}
238 slug={slug}
239 adding={adding}
240 blurb={(provider) => MODEL_CATALOG[provider]!.blurb}
241 />
242 </div>
243 ))}
244 </div>
245 );
246}
247
Workspace names and icons, and a component kit for every control248/** Radix Select items cannot be "": the routing value for "same as everything". */
249const SAME = "same";
250
A catalogue of model providers, and settings that feel like settings251const SELECT =
252 "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";
253
254const GATEWAY_TOKEN = (
255 <Field label="Cloudflare AI Gateway token" hint="Only for an authenticated AI Gateway: sent as cf-aig-authorization.">
256 <Input name="signingSecret" type="password" />
257 </Field>
258);
259
260/** What connecting a model provider asks for. */
261export function ModelProviderFields({ provider }: { provider: Provider }): ReactNode {
262 const entry = MODEL_CATALOG[provider];
263 if (provider === "azure_openai") {
264 return (
265 <>
266 <Field label="Endpoint" hint="Your resource's endpoint, from the Azure portal: Keys and Endpoint.">
267 <Input name="baseUrl" type="url" required placeholder="https://acme.openai.azure.com" />
268 </Field>
269 <Field label="Key" hint="KEY 1 or KEY 2 of the resource.">
270 <Input name="secret" type="password" required />
271 </Field>
272 <Field label="Deployment" hint="The name of the model deployment to use. Add a connection for each deployment you want to route to.">
273 <Input name="model" required placeholder="gpt-5" />
274 </Field>
275 </>
276 );
277 }
278 if (provider === "anthropic_endpoint" || provider === "openai_endpoint") {
279 const anthropic = provider === "anthropic_endpoint";
280 return (
281 <>
282 <Field
283 label="Base URL"
284 hint={
285 anthropic
286 ? "Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic"
287 : "Up to and including the version, such as https://llm.acme.dev/v1. g1t calls /chat/completions under it."
288 }
289 >
290 <Input name="baseUrl" type="url" required placeholder={anthropic ? "https://llm.acme.dev" : "https://llm.acme.dev/v1"} />
291 </Field>
292 <Field label="Key" hint="Optional, if the endpoint needs one.">
293 <Input name="secret" type="password" />
294 </Field>
295 <Field label="Send the key as">
Workspace names and icons, and a component kit for every control296 <Select name="authHeader" defaultValue={anthropic ? "x-api-key" : "authorization"}>
297 <SelectTrigger aria-label="Send the key as" className="font-mono text-[0.8125rem]">
298 <SelectValue />
299 </SelectTrigger>
300 <SelectContent>
301 <SelectItem value="x-api-key" description="Anthropic's header" className="font-mono text-[0.8125rem]">
302 x-api-key
303 </SelectItem>
304 <SelectItem value="authorization" description="OpenAI's header, and most others" className="font-mono text-[0.8125rem]">
305 Authorization: Bearer
306 </SelectItem>
307 </SelectContent>
308 </Select>
A catalogue of model providers, and settings that feel like settings309 </Field>
310 {GATEWAY_TOKEN}
311 <Field
312 label={anthropic ? "Model" : "Default model"}
313 hint={
314 anthropic
315 ? "Optional. Empty uses g1t's choice of Claude for each kind of work; set it if your endpoint names models its own way."
316 : "The model to use unless routing says otherwise. g1t also lists the endpoint's models if it offers a list."
317 }
318 >
319 <Input name="model" required={!anthropic} placeholder={anthropic ? "claude-sonnet-5-5" : "qwen3-coder"} />
320 </Field>
321 </>
322 );
323 }
324 return (
325 <>
326 <Field
327 label="API key"
328 hint={`${entry?.keyUrl ? `Get one at ${new URL(entry.keyUrl).host}. ` : ""}Sealed when saved: nobody sees it again, and agents never do.`}
329 >
330 <Input name="secret" type="password" required placeholder={entry?.keyPlaceholder} />
331 </Field>
332 {provider !== "anthropic" && (
333 <Field label="Default model" hint="Optional. g1t lists the key's models when you connect; you choose per kind of work after.">
334 <Input name="model" placeholder={entry?.model} />
335 </Field>
336 )}
337 </>
338 );
339}
340
341const TASK_LABELS: Record<ModelTask, { label: string; hint: string }> = {
342 default: { label: "Everything", hint: "Unless a kind of work below says otherwise." },
343 implement: { label: "Making changes", hint: "Writing the change for an issue, and revising it." },
344 review: { label: "Reviewing", hint: "The second agent that reviews each change." },
345 plan: { label: "Planning", hint: "Turning an outcome into issues." },
346 update: { label: "Catching up", hint: "Bringing a change up to date with main." },
347};
348
349type Choice = { target: string; model: string };
350
351function choiceOf(route: ModelRoute | undefined): Choice {
352 if (!route) return { target: "", model: "" };
353 return { target: route.connectionId ?? "g1t", model: route.model ?? "" };
354}
355
356/** Each kind of work, and the provider and model it goes to. */
357export function Routing({
358 connections,
359 routes,
360 hostedOpen,
361 marginPercent,
362 owner,
363 busy,
364 saved,
365}: {
366 connections: Connection[];
367 routes: ModelRoute[];
368 hostedOpen: boolean;
369 marginPercent: number;
370 owner: boolean;
371 busy: boolean;
372 saved: boolean;
373}) {
374 const fallback: Choice = hostedOpen || connections.length === 0 ? { target: "g1t", model: "" } : { target: connections[0].id, model: "" };
375 const [choices, setChoices] = useState<Record<ModelTask, Choice>>(() => {
376 const initial = {} as Record<ModelTask, Choice>;
377 for (const task of MODEL_TASKS) {
378 const chosen = choiceOf(routes.find((route) => route.task === task));
379 initial[task] = task === "default" && !chosen.target ? fallback : chosen;
380 }
381 return initial;
382 });
383 const set = (task: ModelTask, choice: Partial<Choice>) => setChoices((all) => ({ ...all, [task]: { ...all[task], ...choice } }));
384
385 return (
386 <Form method="post" className="rounded-xl border border-line bg-surface">
387 <input type="hidden" name="intent" value="routes" />
388 {connections.map((connection) => (
389 <datalist key={connection.id} id={`models-${connection.id}`}>
390 {connection.models.map((model) => (
391 <option key={model} value={model} />
392 ))}
393 </datalist>
394 ))}
395 <div className="border-b border-line px-4 py-3">
396 <p className="text-sm font-medium">Which model does which work</p>
397 <p className="text-xs text-muted">
398 Each kind of work can go to g1t's models, or to any of your providers on the model you choose.
399 </p>
400 </div>
401 <ul className="divide-y divide-line">
402 {MODEL_TASKS.map((task) => {
403 const choice = choices[task];
404 const connection = connections.find((c) => c.id === choice.target);
405 const speaksAnthropic = connection && PROVIDERS[connection.provider] && (connection.provider === "anthropic" || connection.provider === "anthropic_endpoint");
406 const needsModel = connection && !speaksAnthropic && !choice.model && !connection.config.model;
407 const value = !choice.target ? "" : choice.target === "g1t" ? "g1t" : `${choice.target}::${choice.model}`;
408 return (
409 <li key={task} className="grid items-start gap-2 px-4 py-3 md:grid-cols-[11rem_1fr_1fr]">
410 <div className="pt-1.5">
411 <p className="text-sm font-medium">{TASK_LABELS[task].label}</p>
412 <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p>
413 </div>
414 <input type="hidden" name={`route-${task}`} value={value} />
Workspace names and icons, and a component kit for every control415 <Select
A catalogue of model providers, and settings that feel like settings416 disabled={!owner}
Workspace names and icons, and a component kit for every control417 value={choice.target || SAME}
418 onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })}
A catalogue of model providers, and settings that feel like settings419 >
Workspace names and icons, and a component kit for every control420 <SelectTrigger aria-label={`${TASK_LABELS[task].label}: provider`}>
421 <SelectValue />
422 </SelectTrigger>
423 <SelectContent>
424 {task !== "default" && (
425 <>
426 <SelectItem value={SAME} description="Follows the choice for everything">
427 Same as everything
428 </SelectItem>
429 <SelectSeparator />
430 </>
431 )}
432 <SelectItem
433 value="g1t"
434 disabled={!hostedOpen}
435 icon={<Sparkles />}
436 description={hostedOpen ? `Credit at cost + ${marginPercent}%` : "Not open to this workspace yet"}
437 >
438 g1t's models
439 </SelectItem>
440 {connections.length > 0 && (
441 <SelectGroup>
442 <SelectLabel>Your providers</SelectLabel>
443 {connections.map((c) => (
444 <SelectItem key={c.id} value={c.id} icon={<Bot />} description={PROVIDERS[c.provider]?.label}>
445 {c.name}
446 </SelectItem>
447 ))}
448 </SelectGroup>
449 )}
450 </SelectContent>
451 </Select>
A catalogue of model providers, and settings that feel like settings452 <div>
453 <input
454 aria-label={`${TASK_LABELS[task].label}: model`}
455 list={connection ? `models-${connection.id}` : undefined}
456 disabled={!owner || !connection}
457 value={connection ? choice.model : ""}
458 onChange={(event) => set(task, { model: event.target.value })}
459 placeholder={
460 !choice.target
461 ? "Follows everything"
462 : !connection
463 ? "g1t's choice for this work"
464 : speaksAnthropic
465 ? connection.config.model ?? "g1t's choice of Claude"
466 : connection.config.model ?? `Search ${connection.models.length} models`
467 }
468 className={`${SELECT} font-mono text-[0.8125rem] ${needsModel ? "border-warn/60" : ""}`}
469 autoComplete="off"
470 />
471 {needsModel && <p className="mt-1 text-xs text-warn">Choose a model.</p>}
472 </div>
473 </li>
474 );
475 })}
476 </ul>
477 {owner && (
478 <div className="flex items-center gap-3 border-t border-line px-4 py-3">
479 <Button type="submit" variant="quiet" disabled={busy}>
480 Save routing
481 </Button>
482 {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>}
483 </div>
484 )}
485 </Form>
486 );
487}