Skip to content
573 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.

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

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