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.
| Sidebar: the panels really slide | 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 | */ | |
| Workspace names and icons, and a component kit for every control | 6 | import { Bot, ChevronRight, Sparkles, Webhook } from "lucide-react"; |
| Sidebar: the panels really slide | 7 | import { type ReactNode, useState } from "react"; |
| 8 | import { Form, Link } from "react-router"; | |
| 9 | ||
| AI Gateway: OpenAI's format, open models, and your own providers | 10 | import { type Connection, MODEL_TASKS, MODEL_TIERS, type ModelRoute, type ModelTask, type ModelTier, type Provider, PROVIDERS, gatewayPatterns } from "@g1t/contracts"; |
| Sidebar: the panels really slide | 11 | |
| Integrations are a directory of connectors, for a workspace and for each person | 12 | import { integrationsSection } from "../lib/integration-sections"; |
| Merge branch 'worktree-agent-a985c01f43538af01' | 13 | import { Field, Input, SubmitButton } from "./ui"; |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 14 | import { Hint } from "./ui/hint"; |
| Workspace names and icons, and a component kit for every control | 15 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; |
| Sidebar: the panels really slide | 16 | |
| 17 | type Group = "labs" | "platforms" | "any"; | |
| 18 | ||
| 19 | type 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. */ | |
| 34 | export 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 | ||
| 149 | const 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. */ | |
| 158 | export 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)`, | |
| The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how. | 179 | // Toward the text colour, so the letters read on the tile in both themes. |
| 180 | color: `color-mix(in srgb, ${color} 60%, var(--color-fg))`, | |
| Sidebar: the panels really slide | 181 | boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`, |
| 182 | }} | |
| 183 | > | |
| 184 | {content} | |
| 185 | </span> | |
| 186 | ); | |
| 187 | } | |
| 188 | ||
| 189 | const 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. */ | |
| 196 | export 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 person | 212 | to={`/${slug}/-/integrations/${integrationsSection(PROVIDERS[provider].kind)}?add=${provider}#add`} |
| Sidebar: the panels really slide | 213 | 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> | |
| Sidebar: the panels really slide | 224 | </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. */ | |
| 233 | export 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 control | 251 | /** Radix Select items cannot be "": the routing value for "same as everything". */ |
| 252 | const SAME = "same"; | |
| 253 | ||
| Sidebar: the panels really slide | 254 | const 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 | ||
| 257 | const 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 providers | 263 | /** What to suggest a provider's AI Gateway models be, as typed. */ |
| 264 | const 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 | */ | |
| 284 | export 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 | ||
| Sidebar: the panels really slide | 303 | /** What connecting a model provider asks for. */ |
| 304 | export function ModelProviderFields({ provider }: { provider: Provider }): ReactNode { | |
| AI Gateway: OpenAI's format, open models, and your own providers | 305 | return ( |
| 306 | <> | |
| 307 | <ModelProviderKeyFields provider={provider} /> | |
| 308 | <GatewayModelsField provider={provider} /> | |
| 309 | </> | |
| 310 | ); | |
| 311 | } | |
| 312 | ||
| 313 | function ModelProviderKeyFields({ provider }: { provider: Provider }): ReactNode { | |
| Sidebar: the panels really slide | 314 | 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 control | 348 | <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> | |
| Sidebar: the panels really slide | 361 | </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 | ||
| 393 | const 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 | ||
| 401 | type Choice = { target: string; model: string }; | |
| 402 | ||
| Merge branch 'model-routing' | 403 | /** g1t's models, as a route can choose them: Auto, or one tier. */ |
| 404 | const AUTO = "auto"; | |
| 405 | const 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 | ||
| Sidebar: the panels really slide | 411 | function 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. */ | |
| 417 | export 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. |
| Sidebar: the panels really slide | 455 | </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}`; | |
| Sidebar: the panels really slide | 471 | 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 control | 478 | <Select |
| Sidebar: the panels really slide | 479 | disabled={!owner} |
| Workspace names and icons, and a component kit for every control | 480 | value={choice.target || SAME} |
| 481 | onValueChange={(target) => set(task, { target: target === SAME ? "" : target, model: "" })} | |
| Sidebar: the panels really slide | 482 | > |
| Workspace names and icons, and a component kit for every control | 483 | <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 control | 500 | > |
| 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 | ) : ( | |
| Sidebar: the panels really slide | 537 | <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" |
| Sidebar: the panels really slide | 549 | : 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 | )} |
| Sidebar: the panels really slide | 559 | </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…"> |
| Sidebar: the panels really slide | 566 | 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>} |
| Sidebar: the panels really slide | 569 | </div> |
| 570 | )} | |
| 571 | </Form> | |
| 572 | ); | |
| 573 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.