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

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