Skip to content
487 linesCodeBlameRaw

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
Merge branch 'worktree-agent-a985c01f43538af01'12import { Field, Input, SubmitButton } from "./ui";
Hover hints in components are the site's tooltip, not the browser's title13import { Hint } from "./ui/hint";
Workspace names and icons, and a component kit for every control14import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
A catalogue of model providers, and settings that feel like settings15
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)`,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look178 // Lifted toward white, so the small letters read on the dark tile.
179 color: `color-mix(in srgb, ${color} 72%, white)`,
A catalogue of model providers, and settings that feel like settings180 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>
Hover hints in components are the site's tooltip, not the browser's title220 <Hint label={blurb(provider)}>
221 <span className="block truncate text-xs text-muted">{blurb(provider)}</span>
222 </Hint>
A catalogue of model providers, and settings that feel like settings223 </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
Workspace names and icons, and a component kit for every control250/** Radix Select items cannot be "": the routing value for "same as everything". */
251const SAME = "same";
252
A catalogue of model providers, and settings that feel like settings253const 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 connecting a model provider asks for. */
263export function ModelProviderFields({ provider }: { provider: Provider }): ReactNode {
264 const entry = MODEL_CATALOG[provider];
265 if (provider === "azure_openai") {
266 return (
267 <>
268 <Field label="Endpoint" hint="Your resource's endpoint, from the Azure portal: Keys and Endpoint.">
269 <Input name="baseUrl" type="url" required placeholder="https://acme.openai.azure.com" />
270 </Field>
271 <Field label="Key" hint="KEY 1 or KEY 2 of the resource.">
272 <Input name="secret" type="password" required />
273 </Field>
274 <Field label="Deployment" hint="The name of the model deployment to use. Add a connection for each deployment you want to route to.">
275 <Input name="model" required placeholder="gpt-5" />
276 </Field>
277 </>
278 );
279 }
280 if (provider === "anthropic_endpoint" || provider === "openai_endpoint") {
281 const anthropic = provider === "anthropic_endpoint";
282 return (
283 <>
284 <Field
285 label="Base URL"
286 hint={
287 anthropic
288 ? "Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic"
289 : "Up to and including the version, such as https://llm.acme.dev/v1. g1t calls /chat/completions under it."
290 }
291 >
292 <Input name="baseUrl" type="url" required placeholder={anthropic ? "https://llm.acme.dev" : "https://llm.acme.dev/v1"} />
293 </Field>
294 <Field label="Key" hint="Optional, if the endpoint needs one.">
295 <Input name="secret" type="password" />
296 </Field>
297 <Field label="Send the key as">
Workspace names and icons, and a component kit for every control298 <Select name="authHeader" defaultValue={anthropic ? "x-api-key" : "authorization"}>
299 <SelectTrigger aria-label="Send the key as" className="font-mono text-[0.8125rem]">
300 <SelectValue />
301 </SelectTrigger>
302 <SelectContent>
303 <SelectItem value="x-api-key" description="Anthropic's header" className="font-mono text-[0.8125rem]">
304 x-api-key
305 </SelectItem>
306 <SelectItem value="authorization" description="OpenAI's header, and most others" className="font-mono text-[0.8125rem]">
307 Authorization: Bearer
308 </SelectItem>
309 </SelectContent>
310 </Select>
A catalogue of model providers, and settings that feel like settings311 </Field>
312 {GATEWAY_TOKEN}
313 <Field
314 label={anthropic ? "Model" : "Default model"}
315 hint={
316 anthropic
317 ? "Optional. Empty uses g1t's choice of Claude for each kind of work; set it if your endpoint names models its own way."
318 : "The model to use unless routing says otherwise. g1t also lists the endpoint's models if it offers a list."
319 }
320 >
321 <Input name="model" required={!anthropic} placeholder={anthropic ? "claude-sonnet-5-5" : "qwen3-coder"} />
322 </Field>
323 </>
324 );
325 }
326 return (
327 <>
328 <Field
329 label="API key"
330 hint={`${entry?.keyUrl ? `Get one at ${new URL(entry.keyUrl).host}. ` : ""}Sealed when saved: nobody sees it again, and agents never do.`}
331 >
332 <Input name="secret" type="password" required placeholder={entry?.keyPlaceholder} />
333 </Field>
334 {provider !== "anthropic" && (
335 <Field label="Default model" hint="Optional. g1t lists the key's models when you connect; you choose per kind of work after.">
336 <Input name="model" placeholder={entry?.model} />
337 </Field>
338 )}
339 </>
340 );
341}
342
343const TASK_LABELS: Record<ModelTask, { label: string; hint: string }> = {
344 default: { label: "Everything", hint: "Unless a kind of work below says otherwise." },
345 implement: { label: "Making changes", hint: "Writing the change for an issue, and revising it." },
346 review: { label: "Reviewing", hint: "The second agent that reviews each change." },
347 plan: { label: "Planning", hint: "Turning an outcome into issues." },
348 update: { label: "Catching up", hint: "Bringing a change up to date with main." },
349};
350
351type Choice = { target: string; model: string };
352
353function choiceOf(route: ModelRoute | undefined): Choice {
354 if (!route) return { target: "", model: "" };
355 return { target: route.connectionId ?? "g1t", model: route.model ?? "" };
356}
357
358/** Each kind of work, and the provider and model it goes to. */
359export function Routing({
360 connections,
361 routes,
362 hostedOpen,
363 marginPercent,
364 owner,
365 saved,
366}: {
367 connections: Connection[];
368 routes: ModelRoute[];
369 hostedOpen: boolean;
370 marginPercent: number;
371 owner: 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">
Merge branch 'worktree-agent-a985c01f43538af01'479 <SubmitButton variant="quiet" match={{ intent: "routes" }} pending="Saving…">
A catalogue of model providers, and settings that feel like settings480 Save routing
Merge branch 'worktree-agent-a985c01f43538af01'481 </SubmitButton>
A catalogue of model providers, and settings that feel like settings482 {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>}
483 </div>
484 )}
485 </Form>
486 );
487}

This file's history is long; its oldest lines are credited to the oldest commit read.