g1t/apps/web/app/components/model-providers.tsx
| 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 | */ |
| 6 | import { Bot, ChevronRight, Sparkles, Webhook } from "lucide-react"; |
| 7 | import { type ReactNode, useState } from "react"; |
| 8 | import { Form, Link } from "react-router"; |
| 9 | |
| 10 | import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts"; |
| 11 | |
| 12 | import { Button, Field, Input } from "./ui"; |
| 13 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; |
| 14 | |
| 15 | type Group = "labs" | "platforms" | "any"; |
| 16 | |
| 17 | type 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. */ |
| 32 | export 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 | |
| 147 | const 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. */ |
| 156 | export 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 | |
| 186 | const 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. */ |
| 193 | export 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. */ |
| 230 | export 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 | |
| 248 | /** Radix Select items cannot be "": the routing value for "same as everything". */ |
| 249 | const SAME = "same"; |
| 250 | |
| 251 | const 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 | |
| 254 | const 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. */ |
| 261 | export 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"> |
| 296 | <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> |
| 309 | </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 | |
| 341 | const 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 | |
| 349 | type Choice = { target: string; model: string }; |
| 350 | |
| 351 | function 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. */ |
| 357 | export 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} /> |
| 415 | <Select |
| 416 | disabled={!owner} |
| 417 | value={choice.target || SAME} |
| 418 | onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })} |
| 419 | > |
| 420 | <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> |
| 452 | <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 | } |