flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

Commit

A catalogue of model providers, and settings that feel like settings

Model providers: xAI, Mistral, DeepSeek, Azure OpenAI, OpenRouter, Groq, Together, Fireworks and Cerebras join Anthropic, OpenAI and Gemini, each connected with just a key. One table in the contracts describes every provider (its API, address and key header) instead of a match per property. The proxy meets each provider's quirks: DeepSeek's and Groq's output limits, Mistral's tool choice and its refusal of stream options, Azure's api-key header, and Gemini's thought signatures, carried through the harness inside tool call ids. Routing picks a provider, then searches its models. Settings: the workspace's big header is only on its overview. Its other pages get a plain title. Settings is one entry in the sidebar, which slides over to the settings (General, Members, Billing, Integrations, Access tokens) the moment it is clicked, with an arrow back; nothing in the settings is repeated in the main menu.

syntaqxcommitted Parentca7abc1Browse files
22 files+939−4290/22 viewed
+4−6
423423 "A workspace's integrations: its own model provider, the alert sources that open issues (Sentry, Datadog, webhooks), and the trackers whose tickets agents can read (Jira, Linear). Secrets are never returned. Members only."
424424 }
425425 Op::ConnectIntegration => {
426− "Connect a workspace to an outside system. provider is a model provider (anthropic, openai, gemini, anthropic_endpoint or openai_endpoint: your own key, billed by that provider, with g1t charging a flat orchestration fee per run; a workspace can connect several and route each kind of work with set_model_routes), or sentry, datadog, webhook, jira or linear. config holds the settings each needs; secret is the API key or token. For datadog and webhook, g1t makes the signing secret and returns it once. Owners only."
426+ "Connect a workspace to an outside system. provider is a model provider (anthropic, openai, gemini, xai, mistral, deepseek, azure_openai, openrouter, groq, together, fireworks, cerebras, anthropic_endpoint or openai_endpoint: your own key, billed by that provider, with g1t charging a flat orchestration fee per run; a workspace can connect several and route each kind of work with set_model_routes), or sentry, datadog, webhook, jira or linear. config holds the settings each needs; secret is the API key or token. For datadog and webhook, g1t makes the signing secret and returns it once. Owners only."
427427 }
428428 Op::DisconnectIntegration => {
429429 "Remove an integration and its secrets. Agents already running on a model provider being removed stop reaching it. Owners only."
768768 "workspace": workspace_schema(),
769769 "provider": {
770770 "type": "string",
771− "enum": ["anthropic", "openai", "gemini", "anthropic_endpoint", "openai_endpoint", "sentry", "datadog", "webhook", "jira", "linear"],
771+ "enum": g1t_contracts::integrations::Provider::all().map(|provider| provider.name()).collect::<Vec<_>>(),
772772 },
773773 "name": { "type": "string", "description": "What to call it. The provider's name if left out." },
774774 "config": {
13351335 Op::ConnectIntegration => {
13361336 let provider = text(input, "provider");
13371337 if g1t_contracts::integrations::Provider::parse(&provider).is_none() {
1338− return failed(
1339− FailureCode::Invalid,
1340− "provider must be anthropic, openai, gemini, anthropic_endpoint, openai_endpoint, sentry, datadog, webhook, jira or linear.",
1341− );
1338+ let names: Vec<&str> = g1t_contracts::integrations::Provider::all().map(|provider| provider.name()).collect();
1339+ return failed(FailureCode::Invalid, &format!("provider must be one of: {}.", names.join(", ")));
13421340 }
13431341 pass(
13441342 integrations,
+1−1
1212 | [Alerts](#alerts) | Sentry, Datadog, a signed webhook | A problem opens an issue, once however often it fires, and an agent can start on it at once. |
1313 | [Trackers](#trackers) | Jira, Linear | Agents read the tickets that work mentions, people import tickets as issues, and tickets hear back when the work lands. |
1414
15−Open the workspace's **Integrations** page from the sidebar. Every member
15+Open the workspace's **Settings → Integrations**. Every member
1616 can see the connections; only owners can add, test or remove them.
1717
1818 Secrets are sealed when they are saved and never shown again, to anyone.
+25−11
1616
1717 ## Providers
1818
19−| Provider | What you give | Speaks |
19+Labs and platforms need only a key; g1t knows where they are.
20+
21+| | Provider | What you give |
2022 | --- | --- | --- |
21−| **Anthropic** | An API key | Anthropic's API |
22−| **OpenAI** | An API key | OpenAI's API |
23−| **Google Gemini** | An API key from Google AI Studio | OpenAI's API, through Gemini's compatible endpoint |
24−| **Anthropic-compatible endpoint** | A base URL, a key if it needs one | Anthropic's API: your own Cloudflare AI Gateway, LiteLLM, Bedrock or Vertex behind a proxy |
25−| **OpenAI-compatible endpoint** | A base URL including its version, a key if it needs one | OpenAI's API: Azure OpenAI, OpenRouter, Groq, Together, vLLM, Ollama |
23+| Labs | **Anthropic** | An API key |
24+| | **OpenAI** | An API key |
25+| | **Google Gemini** | An API key from Google AI Studio |
26+| | **xAI** (Grok) | An API key |
27+| | **Mistral** | An API key |
28+| | **DeepSeek** | An API key |
29+| Platforms | **Azure OpenAI** | Your resource's endpoint, its key, and a deployment name |
30+| | **OpenRouter** | An API key: hundreds of models from every lab |
31+| | **Groq** | An API key |
32+| | **Together AI** | An API key |
33+| | **Fireworks AI** | An API key |
34+| | **Cerebras** | An API key |
35+| Any endpoint | **Anthropic-compatible** | A base URL, and a key if it needs one: your own Cloudflare AI Gateway, LiteLLM, Bedrock or Vertex behind a proxy |
36+| | **OpenAI-compatible** | A base URL including its version, a model, and a key if it needs one: vLLM, Ollama behind a tunnel, LiteLLM |
2637
2738 An endpoint behind an authenticated Cloudflare AI Gateway also takes the
2839 gateway's token, sent as `cf-aig-authorization`.
2940
30−g1t's agents run Claude Code, which speaks Anthropic's API. For a provider
31−that speaks OpenAI's, g1t's model proxy translates each request, and the
32−streamed answer back, tool calls included. Agents work the same either way.
41+g1t's agents run Claude Code, which speaks Anthropic's API. Every provider
42+but Anthropic speaks OpenAI's, so g1t's model proxy translates each
43+request, and the streamed answer back, tool calls included, and meets each
44+provider's quirks: the token limits DeepSeek and Groq set, how Mistral
45+names a required tool, Azure's `api-key` header, and the thought signatures
46+Gemini needs back with each tool call. Agents work the same either way.
3347 How well they work depends on the model: it has to be good at using tools
3448 over many steps.
3549
3650 ## Connect a provider
3751
38−1. Open the workspace's **Integrations** page. You need to be an owner.
52+1. Open the workspace's **Settings → Integrations**. You need to be an owner.
3953 2. Under **Model providers**, choose one, and give its key (and address, for
4054 an endpoint).
4155 3. **Connect**. g1t checks the key at once and lists the provider's models.
100114
101115 | Tool | Route |
102116 | --- | --- |
103−| `connect_integration` | `POST /workspaces/{workspace}/integrations` with `provider` `anthropic`, `openai`, `gemini`, `anthropic_endpoint` or `openai_endpoint` |
117+| `connect_integration` | `POST /workspaces/{workspace}/integrations` with `provider` one of `anthropic`, `openai`, `gemini`, `xai`, `mistral`, `deepseek`, `azure_openai`, `openrouter`, `groq`, `together`, `fireworks`, `cerebras`, `anthropic_endpoint`, `openai_endpoint` |
104118 | `get_model_routes` | `GET /workspaces/{workspace}/model-routes` |
105119 | `set_model_routes` | `PUT /workspaces/{workspace}/model-routes` |
106120
+1−1
3636
3737 Only an owner of the workspace can add credit.
3838
39−1. Open the workspace's **Billing** page, `g1t.sh/<workspace>/-/billing`.
39+1. Open the workspace's **Settings → Billing**, `g1t.sh/<workspace>/-/billing`.
4040 2. Under **Add credit by card**, choose an amount: $10, $25, $50 or $100.
4141 3. Pay on the card page you are sent to.
4242
+14−9
4545 | Owner | Everything a member can, and manage members, the workspace's access tokens, its details, and add credit. |
4646
4747 Whoever creates a workspace is its owner. An owner adds people on the
48−workspace's **People** page by their g1t username; they join as members. An
49−owner can also remove a member there.
48+workspace's **Settings → Members** page by their g1t username; they join as
49+members. An owner can also remove a member there.
5050
5151 ## The workspace's pages
5252
5353 A workspace's page, `g1t.sh/<workspace>`, shows its repositories and the
54−pull requests in progress across them. Its members also have:
54+pull requests in progress across them. In the sidebar, its members also
55+have **Usage**, what g1t agents have cost (see
56+[usage and billing](/guides/usage-and-billing/)), and **Settings**, which
57+slides the sidebar over to the workspace's settings:
5558
56−| Page | Who | |
59+| Settings | Who | |
5760 | --- | --- | --- |
58−| **People** | Members | Who belongs, and their roles. Owners add and remove people. |
59−| **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
60−| **Usage** | Members | What g1t agents have cost. See [usage and billing](/guides/usage-and-billing/). |
61+| **General** | Owners | The display name and a one-line description. |
62+| **Members** | Members | Who belongs, and their roles. Owners add and remove people. |
6163 | **Billing** | Members | The balance and statement. Owners add credit. |
62−| **Settings** | Owners | The display name and a one-line description. |
64+| **Integrations** | Members | [Model providers, alerts and trackers](/guides/integrations/). Owners connect and remove them. |
65+| **Access tokens** | Members | The workspace's own tokens. Owners create and delete them. |
66+
67+The arrow at the top of the settings goes back.
6368
6469 ## Workspace access tokens
6570
7580 | Can reach | Every workspace you belong to | That workspace only |
7681 | Can do | Everything you can | What a member can; it cannot manage people, tokens or workspaces |
7782 | When its creator leaves | Stops working | Keeps working |
78−| Created by | You, in [Settings](https://g1t.sh/settings) | An owner, under **Access tokens** on the workspace's page |
83+| Created by | You, in [Settings](https://g1t.sh/settings) | An owner, under the workspace's **Settings → Access tokens** |
7984
8085 They are the same kind of token and are sent the same way; see
8186 [access tokens](/guides/authentication/#access-tokens). With git, any
+454−0
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, 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+
14+type Group = "labs" | "platforms" | "any";
15+
16+type 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. */
31+export 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+
146+const 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. */
155+export 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+
185+const 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. */
192+export 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. */
229+export 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+
247+const 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+
250+const 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. */
257+export 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+
328+const 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+
336+type Choice = { target: string; model: string };
337+
338+function 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. */
344+export 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+}
+74−22
11 import {
2+ ArrowLeft,
23 BarChart3,
34 BookMarked,
45 BookOpen,
240241 );
241242 }
242243
244+/** A workspace's settings pages, which the sidebar slides over to. */
245+const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations)(\/|$)/;
246+
247+/** How the sidebar's two menus slide past each other. */
248+const SLIDE = "absolute inset-0 overflow-y-auto px-2 pb-4 transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none";
249+
250+/** A workspace's settings, as the sidebar shows them in place of everything else. */
251+function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
252+ return (
253+ <nav
254+ aria-label="Workspace settings"
255+ inert={!open}
256+ className={`${SLIDE} ${open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}
257+ >
258+ <Link
259+ to={`/${slug}`}
260+ className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
261+ >
262+ <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
263+ <span className="truncate">{slug}</span>
264+ </Link>
265+ <SidebarGroup title="Settings">
266+ {owner && (
267+ <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
268+ General
269+ </SidebarLink>
270+ )}
271+ <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
272+ Members
273+ </SidebarLink>
274+ <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
275+ Billing
276+ </SidebarLink>
277+ <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
278+ Integrations
279+ </SidebarLink>
280+ <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
281+ Access tokens
282+ </SidebarLink>
283+ </SidebarGroup>
284+ </nav>
285+ );
286+}
287+
243288 function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
244289 const ws = shell.workspace;
290+ const { pathname } = useLocation();
291+ const going = useNavigation().location?.pathname;
292+ // On a workspace's settings page, or on the way to one, its settings take
293+ // the sidebar over.
294+ const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
245295 const active = shell.repo;
246296 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
247297 // The repository being looked at is listed even when it is someone else's.
272322 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
273323 </button>
274324 </div>
275− <nav className="min-h-0 grow overflow-y-auto px-2 pb-4">
325+ <div className="relative min-h-0 grow overflow-hidden">
326+ <nav
327+ aria-label="g1t"
328+ inert={inSettings}
329+ className={`${SLIDE} ${inSettings ? "pointer-events-none -translate-x-1/3 opacity-0" : "translate-x-0 opacity-100"}`}
330+ >
276331 <div className="mt-3 space-y-px">
277332 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
278333 Mission control
286341 <SidebarGroup title="Workspace">
287342 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}>
288343 Overview
289− </SidebarLink>
290− <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
291− People
292344 </SidebarLink>
293− <SidebarLink to={`/${ws.slug}/-/tokens`} icon={<KeyRound size={15} />}>
294− Access tokens
295− </SidebarLink>
296345 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
297346 Usage
298347 </SidebarLink>
299− <SidebarLink to={`/${ws.slug}/-/billing`} icon={<CreditCard size={15} />}>
300− Billing
301− </SidebarLink>
302− <SidebarLink to={`/${ws.slug}/-/integrations`} icon={<Plug size={15} />}>
303− Integrations
348+ {/* Everything else about the workspace lives in its settings, and only there. */}
349+ <SidebarLink
350+ to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
351+ icon={<Settings size={15} />}
352+ >
353+ Settings
304354 </SidebarLink>
305− {ws.role === "owner" && (
306− <SidebarLink to={`/${ws.slug}/-/settings`} icon={<Settings size={15} />}>
307− Settings
308− </SidebarLink>
309− )}
310355 </SidebarGroup>
311356 )}
312357
392437 })}
393438 </SidebarGroup>
394439 </nav>
440+ {ws && <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />}
441+ </div>
395442
396443 <div className="space-y-2 p-2">
397444 {ws && <UsageCard slug={ws.slug} shell={shell} />}
409456 commits: "Commits",
410457 plans: "Plan",
411458 settings: "Settings",
412− people: "People",
459+ people: "Members",
413460 tokens: "Access tokens",
414461 usage: "Usage",
415462 billing: "Billing",
463+ integrations: "Integrations",
416464 tree: "Code",
417465 blob: "Code",
418466 };
435483 const [owner, second, third, fourth] = parts;
436484 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
437485 if (second === "-") {
438− if (third) trail.push({ label: SECTIONS[third] ?? third, to: `/${owner}/-/${third}` });
486+ const page = `/${owner}/-/${third}`;
487+ if (third && SETTINGS_PAGE.test(page)) {
488+ trail.push({ label: "Settings", to: `/${owner}/-/settings` });
489+ trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
490+ } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
439491 } else if (second) {
440492 const repo = `/${owner}/${second}`;
441493 trail.push({ label: second, to: repo, mono: true });
495547 commands.push(
496548 { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
497549 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
498− { label: "Billing", hint: membership.slug, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
499− { label: "Access tokens", hint: membership.slug, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
500− { label: "Integrations", hint: membership.slug, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
550+ { label: "Billing", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
551+ { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
552+ { label: "Integrations", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
501553 );
502554 }
503555 for (const listed of shell.repos) {
+27−258
11 import {
22 AlertTriangle,
3− Bot,
43 CheckCircle2,
54 ChevronRight,
65 Cpu,
7− Plug,
86 Siren,
97 Ticket,
10− Webhook,
118 X,
129 } from "lucide-react";
1310 import { env } from "cloudflare:workers";
2017 type Delivery,
2118 MODEL_TASKS,
2219 type ModelRoute,
23− type ModelTask,
2420 type Provider,
2521 type ProviderKind,
2622 PROVIDERS,
2723 } from "@g1t/contracts";
2824
2925 import type { Route } from "./+types/integrations";
30−import { Button, CopyLine, ErrorText, Field, Input, Pill, TimeAgo } from "../../components/ui";
26+import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
27+import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
3128 import { billing, integrations, repos } from "../../lib/services.server";
3229 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
3330
164161 },
165162 };
166163
167−const PROVIDER_BLURB: Record<Provider, string> = {
168− anthropic: "Claude models on your own Anthropic key. Anthropic bills you.",
169− openai: "GPT models on your own OpenAI key. OpenAI bills you.",
170− gemini: "Gemini models on your own Google AI key. Google bills you.",
171− anthropic_endpoint: "Your own AI Gateway, LiteLLM, Bedrock or Vertex behind a proxy: anything that speaks Anthropic's API.",
172− openai_endpoint: "Azure OpenAI, OpenRouter, Groq, Together, vLLM, Ollama: anything that speaks OpenAI's API.",
164+const PROVIDER_BLURB: Partial<Record<Provider, string>> = {
173165 sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.",
174166 datadog: "Monitors that trigger open issues. Recoveries are noted on them.",
175167 webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.",
176168 jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.",
177169 linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.",
178170 };
179−
180−/** A small mark for each provider: its initial, in a tile. */
181−function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) {
182− const hue: Record<Provider, number> = {
183− anthropic: 40,
184− openai: 160,
185− gemini: 230,
186− anthropic_endpoint: 280,
187− openai_endpoint: 140,
188− sentry: 300,
189− datadog: 290,
190− webhook: 200,
191− jira: 250,
192− linear: 265,
193− };
194− const icon =
195− provider === "webhook" ? (
196− <Webhook size={size * 0.5} />
197− ) : provider === "anthropic_endpoint" || provider === "openai_endpoint" ? (
198− <Bot size={size * 0.5} />
199− ) : null;
200− return (
201− <span
202− aria-hidden="true"
203− className="inline-flex shrink-0 items-center justify-center font-semibold"
204− style={{
205− width: size,
206− height: size,
207− borderRadius: size * 0.28,
208− fontSize: size * 0.42,
209− background: `oklch(0.3 0.06 ${hue[provider]})`,
210− color: `oklch(0.9 0.08 ${hue[provider]})`,
211− }}
212− >
213− {icon ?? PROVIDERS[provider].label[0]}
214− </span>
215− );
216−}
217171
218172 function dollars(micros: number): string {
219173 return `$${(micros / 1_000_000).toFixed(2)}`;
230184 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
231185
232186 return (
233− <div className="max-w-4xl">
234− <div className="flex items-start gap-3">
235− <span className="mt-0.5 rounded-lg bg-surface p-2 text-muted ring-1 ring-line">
236− <Plug size={18} />
237− </span>
238− <div>
239− <h2 className="text-lg font-semibold tracking-tight">Integrations</h2>
240− <p className="mt-1 max-w-2xl text-sm text-muted">
241− Connect {slug} to the systems your work already lives in. g1t keeps secrets sealed and never
242− shows them again; agents never see them at all.
243− </p>
244− </div>
245− </div>
246−
187+ <div>
247188 {justConnected && <Connected connected={justConnected} />}
248189 <div className="mt-4">
249190 <ErrorText>{error}</ErrorText>
250191 </div>
251192
252193 {/* Models -------------------------------------------------------------- */}
253− <Section kind="models">
194+ <Section kind="models" first>
254195 {!hostedOpen && modelConnections.length === 0 && (
255196 <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4">
256197 <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30">
264205 </div>
265206 )}
266207 <Connections list={modelConnections} owner={owner} busy={busy} tested={tested} deliveries={deliveries} />
208+ {modelConnections.length === 0 && hostedOpen && (
209+ <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">
210+ <CheckCircle2 size={15} className="shrink-0 text-merged" />
211+ All work runs on g1t's models, charged to your credit at cost plus {marginPercent}%. Connect a provider of
212+ your own to choose models and pay for them there.
213+ </p>
214+ )}
215+ {modelConnections.length > 0 && (
267216 <Routing
268217 connections={modelConnections}
269218 routes={routes}
273222 busy={busy}
274223 saved={actionData != null && "routed" in actionData}
275224 />
225+ )}
276226 <p className="mt-3 text-xs text-faint">
277227 On your own providers, their bills are yours and g1t charges {dollars(feeMicros)} a run for the
278228 sandbox and orchestration. Keys go only from g1t's model proxy to the provider: the agent's
279229 sandbox holds a token that dies with the run.
280230 </p>
281− {owner && <Choices kind="models" slug={slug} adding={adding} />}
231+ {owner && <ModelCatalog slug={slug} adding={adding} />}
282232 {adding && PROVIDERS[adding].kind === "models" && owner && (
283233 <AddForm
284234 provider={adding}
335285 {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>}
336286 </div>
337287 );
338−}
339−
340−const TASK_LABELS: Record<ModelTask, { label: string; hint: string }> = {
341− default: { label: "Everything", hint: "Unless a kind of work below says otherwise." },
342− implement: { label: "Making changes", hint: "Writing the change for an issue, and revising it." },
343− review: { label: "Reviewing", hint: "The second agent that reviews each change." },
344− plan: { label: "Planning", hint: "Turning an outcome into issues." },
345− update: { label: "Catching up", hint: "Bringing a change up to date with main." },
346−};
347−
348−/** What a route is, as the value of a select. */
349−function routeValue(route: ModelRoute | undefined): string {
350− if (!route) return "";
351− return route.connectionId == null ? "g1t" : `${route.connectionId}::${route.model ?? ""}`;
352288 }
353289
354−/** Each kind of work, and the provider and model it goes to. */
355−function Routing({
356− connections,
357− routes,
358− hostedOpen,
359− marginPercent,
360− owner,
361− busy,
362− saved,
363−}: {
364− connections: Connection[];
365− routes: ModelRoute[];
366− hostedOpen: boolean;
367− marginPercent: number;
368− owner: boolean;
369− busy: boolean;
370− saved: boolean;
371−}) {
372− const options = (
373− <>
374− <option value="g1t" disabled={!hostedOpen}>
375− g1t's models{hostedOpen ? ` (g1t's choice, your credit at cost + ${marginPercent}%)` : " (not open to this workspace yet)"}
376− </option>
377− {connections.map((connection) => {
378− const models = [...new Set([...(connection.config.model ? [connection.config.model] : []), ...connection.models])];
379− return (
380− <optgroup key={connection.id} label={connection.name}>
381− {PROVIDERS[connection.provider].kind === "models" &&
382− (connection.provider === "anthropic" || connection.provider === "anthropic_endpoint") && (
383− <option value={`${connection.id}::`}>{connection.name}: g1t's choice of Claude model</option>
384− )}
385− {models.map((model) => (
386− <option key={model} value={`${connection.id}::${model}`}>
387− {connection.name}: {model}
388− </option>
389− ))}
390− </optgroup>
391− );
392− })}
393− </>
394− );
395− const fallback = hostedOpen ? "g1t" : connections[0] ? `${connections[0].id}::${connections[0].config.model ?? ""}` : "g1t";
396− return (
397− <Form method="post" className="rounded-xl border border-line bg-surface">
398− <input type="hidden" name="intent" value="routes" />
399− <div className="border-b border-line px-4 py-3">
400− <p className="text-sm font-medium">Which model does which work</p>
401− <p className="text-xs text-muted">
402− Each kind of work can go to g1t's models or to any of your providers, on the model you choose.
403− </p>
404− </div>
405− <ul className="divide-y divide-line">
406− {MODEL_TASKS.map((task) => {
407− const route = routes.find((r) => r.task === task);
408− return (
409− <li key={task} className="grid items-center gap-2 px-4 py-3 sm:grid-cols-[12rem_1fr]">
410− <div>
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− <select
415− name={`route-${task}`}
416− disabled={!owner}
417− defaultValue={task === "default" ? routeValue(route) || fallback : routeValue(route)}
418− className="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-70"
419− >
420− {task !== "default" && <option value="">Same as everything</option>}
421− {options}
422− </select>
423− </li>
424− );
425− })}
426− </ul>
427− {owner && (
428− <div className="flex items-center gap-3 border-t border-line px-4 py-3">
429− <Button type="submit" variant="quiet" disabled={busy}>
430− Save routing
431− </Button>
432− {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>}
433− </div>
434− )}
435− </Form>
436− );
437−}
438−
439−function Section({ kind, children }: { kind: ProviderKind; children: ReactNode }) {
290+function Section({ kind, first, children }: { kind: ProviderKind; first?: boolean; children: ReactNode }) {
440291 const info = KIND_INFO[kind];
441292 return (
442− <section className="mt-10">
293+ <section className={first ? "" : "mt-12"}>
443294 <h3 className="flex items-center gap-2 text-sm font-medium">
444295 <span className="text-muted">{info.icon}</span>
445296 {info.title}
606457 );
607458 }
608459
460+/** What a provider is for, in a line. */
461+function blurb(provider: Provider): string {
462+ return PROVIDER_BLURB[provider] ?? MODEL_CATALOG[provider]?.blurb ?? "";
463+}
464+
609465 function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) {
610466 const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind);
611467 return (
612− <div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
613− {providers.map((provider) => (
614− <Link
615− key={provider}
616− to={`/${slug}/-/integrations?add=${provider}#add`}
617− preventScrollReset
618− className={`group flex items-start gap-3 rounded-xl border p-3.5 transition-colors ${
619− adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
620− }`}
621− >
622− <ProviderMark provider={provider} size={28} />
623− <span className="min-w-0 grow">
624− <span className="flex items-center gap-1 text-sm font-medium">
625− {PROVIDERS[provider].label}
626− <ChevronRight size={13} className="text-faint transition-transform group-hover:translate-x-0.5" />
627− </span>
628− <span className="mt-0.5 block text-xs leading-relaxed text-muted">{PROVIDER_BLURB[provider]}</span>
629− </span>
630− </Link>
631− ))}
468+ <div className="mt-3">
469+ <ProviderTiles providers={providers} slug={slug} adding={adding} blurb={blurb} />
632470 </div>
633471 );
634472 }
681519 busy: boolean;
682520 error: string | null;
683521 }) {
684− const fields: Record<Provider, ReactNode> = {
685− anthropic: (
686− <Field label="API key" hint="From console.anthropic.com. Sealed when saved; nobody sees it again.">
687− <Input name="secret" type="password" required placeholder="sk-ant-…" />
688− </Field>
689− ),
690− openai: (
691− <>
692− <Field label="API key" hint="From platform.openai.com → API keys. Sealed when saved; nobody sees it again.">
693− <Input name="secret" type="password" required placeholder="sk-…" />
694− </Field>
695− <Field label="Default model" hint="Optional. g1t lists the key's models when you connect, and you choose per kind of work below.">
696− <Input name="model" placeholder="gpt-5" />
697− </Field>
698− </>
699− ),
700− gemini: (
701− <>
702− <Field label="API key" hint="From aistudio.google.com → Get API key.">
703− <Input name="secret" type="password" required />
704− </Field>
705− <Field label="Default model" hint="Optional. g1t lists the key's models when you connect.">
706− <Input name="model" placeholder="gemini-2.5-pro" />
707− </Field>
708− </>
709− ),
710− openai_endpoint: (
711− <>
712− <Field label="Base URL" hint="Up to and including the version, such as https://openrouter.ai/api/v1. g1t calls /chat/completions under it.">
713− <Input name="baseUrl" type="url" required placeholder="https://openrouter.ai/api/v1" />
714− </Field>
715− <Field label="Key" hint="Optional, if the endpoint needs one.">
716− <Input name="secret" type="password" />
717− </Field>
718− <Field label="Send the key as">
719− <select name="authHeader" className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
720− <option value="authorization">Authorization: Bearer</option>
721− <option value="x-api-key">x-api-key</option>
722− </select>
723− </Field>
724− <Field label="Cloudflare AI Gateway token" hint="Only for an authenticated AI Gateway: sent as cf-aig-authorization.">
725− <Input name="signingSecret" type="password" />
726− </Field>
727− <Field label="Default model" hint="The model to use. g1t also lists the endpoint's models if it offers a list.">
728− <Input name="model" placeholder="anthropic/claude-sonnet-4.5" />
729− </Field>
730− </>
731− ),
732− anthropic_endpoint: (
733− <>
734− <Field label="Base URL" hint="Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic">
735− <Input name="baseUrl" type="url" required placeholder="https://llm.example.com" />
736− </Field>
737− <Field label="Key" hint="Optional, if the endpoint needs one.">
738− <Input name="secret" type="password" />
739− </Field>
740− <Field label="Send the key as">
741− <select name="authHeader" className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
742− <option value="x-api-key">x-api-key</option>
743− <option value="authorization">Authorization: Bearer</option>
744− </select>
745− </Field>
746− <Field label="Cloudflare AI Gateway token" hint="Only for an authenticated AI Gateway: sent as cf-aig-authorization.">
747− <Input name="signingSecret" type="password" />
748− </Field>
749− <Field label="Model" hint="Optional. Leave empty to use g1t's choice for each kind of work; set it if your endpoint names models its own way.">
750− <Input name="model" placeholder="claude-sonnet-5-5" />
751− </Field>
752− </>
753− ),
522+ const fields: Partial<Record<Provider, ReactNode>> = {
754523 sentry: (
755524 <>
756525 <Field label="Organization" hint="Its slug, as in acme.sentry.io.">
813582 <h3 className="font-medium">
814583 Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label}
815584 </h3>
816− <p className="text-xs text-muted">{PROVIDER_BLURB[provider]}</p>
585+ <p className="text-xs text-muted">{blurb(provider)}</p>
817586 </div>
818587 <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
819588 <X size={16} />
821590 </div>
822591 <Form method="post" className="mt-5 grid max-w-xl gap-4">
823592 <input type="hidden" name="provider" value={provider} />
824− {fields[provider]}
593+ {fields[provider] ?? <ModelProviderFields provider={provider} />}
825594 <ErrorText>{error}</ErrorText>
826595 <div>
827596 <Button type="submit" disabled={busy}>
+34−1
11 import { CreditCard, KeyRound, LayoutGrid, Plug, Plus, Settings, Users } from "lucide-react";
2−import { Outlet, data, useRouteLoaderData } from "react-router";
2+import { Outlet, data, useLocation, useRouteLoaderData } from "react-router";
33
44 import type { Route } from "./+types/layout";
55 import { Avatar, ButtonLink, Pill, TabLink } from "../../components/ui";
1616 return { workspace, role: roleIn(getViewer(context), workspace.slug) };
1717 }
1818
19+/** A workspace's own pages, each with its title and what it is for. */
20+const PAGES: Record<string, { title: string; about: string }> = {
21+ settings: { title: "General", about: "The workspace's name, address and description." },
22+ people: {
23+ title: "Members",
24+ about: "Members create repositories, push, manage issues and merge pull requests. Owners also manage members, tokens, billing and integrations.",
25+ },
26+ tokens: {
27+ title: "Access tokens",
28+ about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.",
29+ },
30+ usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." },
31+ billing: { title: "Billing", about: "Agent credit, and every charge against it." },
32+ integrations: {
33+ title: "Integrations",
34+ about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.",
35+ },
36+};
37+
1938 export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
2039 const { workspace, role } = loaderData;
2140 const base = `/${workspace.slug}`;
2241 const signedIn = useRouteLoaderData("root")?.user != null;
42+ // The sidebar finds the workspace's pages for someone signed in, so its
43+ // pages need a title, not the workspace's whole header again.
44+ const page = PAGES[useLocation().pathname.split("/-/")[1]?.split("/")[0] ?? ""];
45+ if (signedIn && page) {
46+ return (
47+ <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
48+ <header className="mb-8 border-b border-line pb-6">
49+ <h1 className="text-2xl font-semibold tracking-tight">{page.title}</h1>
50+ <p className="mt-1.5 text-sm text-muted">{page.about}</p>
51+ </header>
52+ <Outlet />
53+ </div>
54+ );
55+ }
2356 return (
2457 <>
2558 {/* The workspace's own header band, under the site header. */}
+1−7
3939 const { role, members } = loaderData;
4040 return (
4141 <div className="max-w-2xl">
42− <h2 className="font-medium">People</h2>
43− <p className="mt-1 text-sm text-muted">
44− Members create repositories, push, manage issues and merge pull
45− requests. Owners also manage people, access tokens and the
46− workspace's details.
47− </p>
48− <ul className="mt-5 divide-y divide-line rounded-xl border border-line">
42+ <ul className="divide-y divide-line rounded-xl border border-line">
4943 {members.map((member) => (
5044 <li key={member.username} className="flex items-center gap-3 px-4 py-3">
5145 <Avatar name={member.username} size={28} />
+0−6
6060 return (
6161 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
6262 <div className="min-w-0">
63− <h2 className="font-medium">Access tokens</h2>
64− <p className="mt-1 max-w-2xl text-sm text-muted">
65− These belong to <span className="font-mono text-fg">{slug}</span>,
66− not to a person. Use them for CI, integrations and agents that work
67− for the whole team, instead of creating an account to share.
68− </p>
6963
7064 {created && (
7165 <div className="mt-5 rounded-xl border border-accent/40 bg-surface p-4">
+1−8
193193 const runway = perDay > 0 ? Math.floor(account.balanceMicros / perDay) : null;
194194 return (
195195 <div className="space-y-8">
196− <div className="flex flex-wrap items-end justify-between gap-4">
197− <div>
198− <h2 className="text-xl font-semibold tracking-tight">Usage</h2>
199− <p className="mt-1 max-w-2xl text-sm text-muted">
200− What this workspace's agents cost: every run, charged at what the model provider charges
201− plus {account.marginPercent}%, from the workspace's credit.
202− </p>
203− </div>
196+ <div className="flex flex-wrap items-end justify-end gap-4">
204197 <nav className="flex rounded-lg bg-surface p-1 ring-1 ring-line">
205198 {(Object.keys(PERIODS) as Period[]).map((key) => (
206199 <Link
+4−2
201201 A workspace's owners connect it to outside systems on its **Integrations**
202202 page, or with `POST /workspaces/{workspace}/integrations`:
203203
204−- **Its own model providers** (`anthropic`, `openai`, `gemini`,
205− `anthropic_endpoint`, `openai_endpoint`), as many as it uses, with each
204+- **Its own model providers** (`anthropic`, `openai`, `gemini`, `xai`,
205+ `mistral`, `deepseek`, `azure_openai`, `openrouter`, `groq`, `together`,
206+ `fireworks`, `cerebras`, `anthropic_endpoint`, `openai_endpoint`), as many
207+ as it uses, with each
206208 kind of work routed to one of them or to g1t's hosted models
207209 (`PUT /workspaces/{workspace}/model-routes`). Those providers bill the
208210 workspace and g1t charges $0.10 a run. Sandboxes never hold a key.
+132−59
2525 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
2626 #[serde(rename_all = "snake_case")]
2727 pub enum Provider {
28− /// The workspace's own Anthropic API key.
28+ // Model providers: the labs.
2929 Anthropic,
30+ Openai,
31+ /// Through Gemini's OpenAI-compatible endpoint.
32+ Gemini,
33+ Xai,
34+ Mistral,
35+ Deepseek,
36+ // Model providers: platforms that serve many labs' models.
37+ /// A deployment on the workspace's own Azure OpenAI resource.
38+ AzureOpenai,
39+ Openrouter,
40+ Groq,
41+ Together,
42+ Fireworks,
43+ Cerebras,
44+ // Model providers: anything else.
3045 /// Any endpoint that speaks Anthropic's Messages API: the workspace's
3146 /// own Cloudflare AI Gateway, LiteLLM, a proxy in front of Bedrock or
3247 /// Vertex, or a self-hosted model.
3348 AnthropicEndpoint,
34− /// The workspace's own OpenAI API key.
35− Openai,
36− /// The workspace's own Google Gemini API key, through Gemini's
37− /// OpenAI-compatible endpoint.
38− Gemini,
39− /// Any endpoint that speaks OpenAI's Chat Completions API: Azure
40− /// OpenAI, OpenRouter, Groq, Together, vLLM, Ollama.
49+ /// Any endpoint that speaks OpenAI's Chat Completions API: vLLM,
50+ /// Ollama behind a tunnel, LiteLLM, a gateway.
4151 OpenaiEndpoint,
52+ // Alerts.
4253 Sentry,
4354 Datadog,
4455 /// Anything that can send a signed JSON request.
4556 Webhook,
57+ // Trackers.
4658 Jira,
4759 Linear,
4860 }
4961
62+/// What g1t knows about a provider.
63+pub struct Spec {
64+ pub provider: Provider,
65+ pub name: &'static str,
66+ pub label: &'static str,
67+ pub kind: ProviderKind,
68+ /// For a model provider: the API it speaks, `anthropic` or `openai`.
69+ pub api: &'static str,
70+ /// For a model provider with a fixed address: where its API is, with
71+ /// the version for OpenAI's API and without it for Anthropic's. Empty
72+ /// when the connection gives its own.
73+ pub base_url: &'static str,
74+ /// The header the key goes in; `authorization` means `Bearer <key>`.
75+ pub auth_header: &'static str,
76+}
77+
78+const fn model(provider: Provider, name: &'static str, label: &'static str, api: &'static str, base_url: &'static str, auth_header: &'static str) -> Spec {
79+ Spec {
80+ provider,
81+ name,
82+ label,
83+ kind: ProviderKind::Models,
84+ api,
85+ base_url,
86+ auth_header,
87+ }
88+}
89+
90+const fn other(provider: Provider, name: &'static str, label: &'static str, kind: ProviderKind) -> Spec {
91+ Spec {
92+ provider,
93+ name,
94+ label,
95+ kind,
96+ api: "",
97+ base_url: "",
98+ auth_header: "",
99+ }
100+}
101+
102+/// Every provider, in the order people are shown them.
103+pub const PROVIDERS: [Spec; 19] = [
104+ model(Provider::Anthropic, "anthropic", "Anthropic", "anthropic", "https://api.anthropic.com", "x-api-key"),
105+ model(Provider::Openai, "openai", "OpenAI", "openai", "https://api.openai.com/v1", "authorization"),
106+ model(Provider::Gemini, "gemini", "Google Gemini", "openai", "https://generativelanguage.googleapis.com/v1beta/openai", "authorization"),
107+ model(Provider::Xai, "xai", "xAI", "openai", "https://api.x.ai/v1", "authorization"),
108+ model(Provider::Mistral, "mistral", "Mistral", "openai", "https://api.mistral.ai/v1", "authorization"),
109+ model(Provider::Deepseek, "deepseek", "DeepSeek", "openai", "https://api.deepseek.com/v1", "authorization"),
110+ model(Provider::AzureOpenai, "azure_openai", "Azure OpenAI", "openai", "", "api-key"),
111+ model(Provider::Openrouter, "openrouter", "OpenRouter", "openai", "https://openrouter.ai/api/v1", "authorization"),
112+ model(Provider::Groq, "groq", "Groq", "openai", "https://api.groq.com/openai/v1", "authorization"),
113+ model(Provider::Together, "together", "Together AI", "openai", "https://api.together.xyz/v1", "authorization"),
114+ model(Provider::Fireworks, "fireworks", "Fireworks AI", "openai", "https://api.fireworks.ai/inference/v1", "authorization"),
115+ model(Provider::Cerebras, "cerebras", "Cerebras", "openai", "https://api.cerebras.ai/v1", "authorization"),
116+ model(Provider::AnthropicEndpoint, "anthropic_endpoint", "Anthropic-compatible endpoint", "anthropic", "", "x-api-key"),
117+ model(Provider::OpenaiEndpoint, "openai_endpoint", "OpenAI-compatible endpoint", "openai", "", "authorization"),
118+ other(Provider::Sentry, "sentry", "Sentry", ProviderKind::Alerts),
119+ other(Provider::Datadog, "datadog", "Datadog", ProviderKind::Alerts),
120+ other(Provider::Webhook, "webhook", "Webhook", ProviderKind::Alerts),
121+ other(Provider::Jira, "jira", "Jira", ProviderKind::Tracker),
122+ other(Provider::Linear, "linear", "Linear", ProviderKind::Tracker),
123+];
124+
50125 impl Provider {
51− pub const ALL: [Provider; 10] = [
52− Provider::Anthropic,
53− Provider::AnthropicEndpoint,
54− Provider::Openai,
55− Provider::Gemini,
56− Provider::OpenaiEndpoint,
57− Provider::Sentry,
58− Provider::Datadog,
59− Provider::Webhook,
60− Provider::Jira,
61− Provider::Linear,
62− ];
126+ pub fn spec(self) -> &'static Spec {
127+ PROVIDERS
128+ .iter()
129+ .find(|spec| spec.provider == self)
130+ .expect("every provider is in the catalogue")
131+ }
132+
133+ pub fn all() -> impl Iterator<Item = Provider> {
134+ PROVIDERS.iter().map(|spec| spec.provider)
135+ }
63136
64137 pub fn name(self) -> &'static str {
65− match self {
66− Provider::Anthropic => "anthropic",
67− Provider::AnthropicEndpoint => "anthropic_endpoint",
68− Provider::Openai => "openai",
69− Provider::Gemini => "gemini",
70− Provider::OpenaiEndpoint => "openai_endpoint",
71− Provider::Sentry => "sentry",
72− Provider::Datadog => "datadog",
73− Provider::Webhook => "webhook",
74− Provider::Jira => "jira",
75− Provider::Linear => "linear",
76− }
138+ self.spec().name
77139 }
78140
79141 pub fn parse(name: &str) -> Option<Provider> {
80− Provider::ALL.into_iter().find(|provider| provider.name() == name)
142+ PROVIDERS.iter().find(|spec| spec.name == name).map(|spec| spec.provider)
81143 }
82144
83145 /// What people call it.
84146 pub fn label(self) -> &'static str {
85− match self {
86− Provider::Anthropic => "Anthropic",
87− Provider::AnthropicEndpoint => "Anthropic-compatible endpoint",
88− Provider::Openai => "OpenAI",
89− Provider::Gemini => "Google Gemini",
90− Provider::OpenaiEndpoint => "OpenAI-compatible endpoint",
91− Provider::Sentry => "Sentry",
92− Provider::Datadog => "Datadog",
93− Provider::Webhook => "Webhook",
94− Provider::Jira => "Jira",
95− Provider::Linear => "Linear",
96− }
147+ self.spec().label
97148 }
98149
99150 pub fn kind(self) -> ProviderKind {
100− match self {
101− Provider::Anthropic
102− | Provider::AnthropicEndpoint
103− | Provider::Openai
104− | Provider::Gemini
105− | Provider::OpenaiEndpoint => ProviderKind::Models,
106− Provider::Sentry | Provider::Datadog | Provider::Webhook => ProviderKind::Alerts,
107− Provider::Jira | Provider::Linear => ProviderKind::Tracker,
108− }
151+ self.spec().kind
109152 }
110153
111154 /// Whether it sends g1t requests, at the connection's own address.
112155 pub fn receives(self) -> bool {
113− matches!(self, Provider::Sentry | Provider::Datadog | Provider::Webhook)
156+ self.kind() == ProviderKind::Alerts
114157 }
115158
116159 /// For a model provider, the API it speaks: `anthropic` or `openai`.
117160 pub fn api(self) -> &'static str {
118− match self {
119− Provider::Anthropic | Provider::AnthropicEndpoint => "anthropic",
120− _ => "openai",
121− }
161+ self.spec().api
162+ }
163+
164+ /// Whether the connection gives the address, rather than g1t knowing it.
165+ pub fn own_address(self) -> bool {
166+ self.kind() == ProviderKind::Models && self.spec().base_url.is_empty()
122167 }
123168 }
124169
324369 pub model: Option<String>,
325370 /// For `openai`: OpenAI's own API, which shapes requests its own way.
326371 pub official: bool,
372+ /// Which provider it is, by name, so the proxy can meet its quirks.
373+ #[serde(default)]
374+ pub provider: String,
327375 pub workspace: String,
328376 pub repo: String,
329377 pub number: u32,
535583 pub struct ModelProviderArgs {
536584 pub workspace: String,
537585 }
586+
587+#[cfg(test)]
588+mod tests {
589+ use super::*;
590+
591+ #[test]
592+ fn every_provider_is_named_once_and_found_again() {
593+ let mut names: Vec<&str> = PROVIDERS.iter().map(|spec| spec.name).collect();
594+ for provider in Provider::all() {
595+ assert_eq!(Provider::parse(provider.name()), Some(provider));
596+ }
597+ names.sort();
598+ names.dedup();
599+ assert_eq!(names.len(), PROVIDERS.len());
600+ }
601+
602+ #[test]
603+ fn model_providers_say_how_to_reach_them() {
604+ for spec in PROVIDERS.iter().filter(|spec| spec.kind == ProviderKind::Models) {
605+ assert!(spec.api == "anthropic" || spec.api == "openai", "{}", spec.name);
606+ assert!(!spec.auth_header.is_empty(), "{}", spec.name);
607+ assert!(spec.base_url.is_empty() || spec.base_url.starts_with("https://"), "{}", spec.name);
608+ }
609+ }
610+}
+22−2
88 */
99 export type Provider =
1010 | "anthropic"
11− | "anthropic_endpoint"
1211 | "openai"
1312 | "gemini"
13+ | "xai"
14+ | "mistral"
15+ | "deepseek"
16+ | "azure_openai"
17+ | "openrouter"
18+ | "groq"
19+ | "together"
20+ | "fireworks"
21+ | "cerebras"
22+ | "anthropic_endpoint"
1423 | "openai_endpoint"
1524 | "sentry"
1625 | "datadog"
123132 model: string | null;
124133 /** OpenAI's own API. */
125134 official: boolean;
135+ /** Which provider, by name, for its quirks. */
136+ provider: string;
126137 workspace: string;
127138 repo: string;
128139 number: number;
177188 /** What each provider is for, as people choose between them. */
178189 export const PROVIDERS: Record<Provider, { label: string; kind: ProviderKind }> = {
179190 anthropic: { label: "Anthropic", kind: "models" },
180− anthropic_endpoint: { label: "Anthropic-compatible endpoint", kind: "models" },
181191 openai: { label: "OpenAI", kind: "models" },
182192 gemini: { label: "Google Gemini", kind: "models" },
193+ xai: { label: "xAI", kind: "models" },
194+ mistral: { label: "Mistral", kind: "models" },
195+ deepseek: { label: "DeepSeek", kind: "models" },
196+ azure_openai: { label: "Azure OpenAI", kind: "models" },
197+ openrouter: { label: "OpenRouter", kind: "models" },
198+ groq: { label: "Groq", kind: "models" },
199+ together: { label: "Together AI", kind: "models" },
200+ fireworks: { label: "Fireworks AI", kind: "models" },
201+ cerebras: { label: "Cerebras", kind: "models" },
202+ anthropic_endpoint: { label: "Anthropic-compatible endpoint", kind: "models" },
183203 openai_endpoint: { label: "OpenAI-compatible endpoint", kind: "models" },
184204 sentry: { label: "Sentry", kind: "alerts" },
185205 datadog: { label: "Datadog", kind: "alerts" },
+11−10
185185 }
186186 let needs = |present: bool, what: &str| if present { Ok(()) } else { Err(what.to_owned()) };
187187 match provider {
188− Provider::Anthropic => needs(secrets.secret.is_some(), "Paste an Anthropic API key."),
189− Provider::Openai => needs(secrets.secret.is_some(), "Paste an OpenAI API key."),
190− Provider::Gemini => needs(secrets.secret.is_some(), "Paste a Gemini API key."),
188+ Provider::AzureOpenai => {
189+ needs(config.base_url.is_some(), "Give your Azure OpenAI resource's endpoint, such as https://acme.openai.azure.com.")?;
190+ needs(config.model.is_some(), "Give the name of the deployment to use.")?;
191+ needs(secrets.secret.is_some(), "Paste the resource's key.")
192+ }
191193 Provider::AnthropicEndpoint | Provider::OpenaiEndpoint => {
192194 needs(config.base_url.is_some(), "Give the endpoint's address.")?;
193195 if let Some(header) = &config.auth_header
212214 needs(secrets.secret.is_some(), "Paste a Jira API token.")
213215 }
214216 Provider::Linear => needs(secrets.secret.is_some(), "Paste a Linear API key."),
217+ // Every other model provider is at a known address and needs only a key.
218+ _ => needs(secrets.secret.is_some(), &format!("Paste a {} API key.", provider.label())),
215219 }
216220 }
217221
503507 }));
504508 }
505509 let tested = match provider {
506− Provider::Anthropic
507− | Provider::AnthropicEndpoint
508− | Provider::Openai
509− | Provider::Gemini
510− | Provider::OpenaiEndpoint => unreachable!("checked above"),
511510 Provider::Sentry => match key {
512511 Some(token) => sentry::test(&config, token).await?,
513512 None => Ok("Sentry can send alerts. Add an auth token so g1t can read stack traces and resolve issues.".to_owned()),
514513 },
515514 Provider::Jira => trackers::jira_test(&config, key.unwrap_or_default()).await?,
516515 Provider::Linear => trackers::linear_test(key.unwrap_or_default()).await?,
517− Provider::Datadog | Provider::Webhook => Ok(format!(
516+ _ => Ok(format!(
518517 "Ready. Requests to its address that carry the secret open issues in {}.",
519518 config.repo.as_deref().unwrap_or("its repository")
520519 )),
12441243 api: "anthropic".to_owned(),
12451244 model: None,
12461245 official: false,
1246+ provider: "g1t".to_owned(),
12471247 workspace: session.workspace,
12481248 repo: session.repo,
12491249 number: session.number,
12661266 route: if provider == Provider::Anthropic { "anthropic" } else { "endpoint" }.to_owned(),
12671267 api: provider.api().to_owned(),
12681268 model: session.model,
1269− official: provider == Provider::Openai,
1269+ official: matches!(provider, Provider::Openai | Provider::AzureOpenai),
1270+ provider: provider.name().to_owned(),
12701271 base_url: Some(models::base_url(provider, &config)),
12711272 api_key: self.secrets(&row).secret,
12721273 auth_header: Some(models::auth_header(provider, &config)),
+21−12
1010 /// Where requests go: without `/v1` for Anthropic's API, with the version
1111 /// for OpenAI's (`…/v1`, or Gemini's `…/v1beta/openai`).
1212 pub fn base_url(provider: Provider, config: &ConnectionConfig) -> String {
13− let given = || config.base_url.as_deref().unwrap_or_default().trim_end_matches('/').to_owned();
13+ let given = config.base_url.as_deref().unwrap_or_default().trim_end_matches('/');
1414 match provider {
15− Provider::AnthropicEndpoint => given().trim_end_matches("/v1").to_owned(),
16− Provider::Openai => "https://api.openai.com/v1".to_owned(),
17− Provider::Gemini => "https://generativelanguage.googleapis.com/v1beta/openai".to_owned(),
18− Provider::OpenaiEndpoint => given(),
19− _ => "https://api.anthropic.com".to_owned(),
15+ Provider::AnthropicEndpoint => given.trim_end_matches("/v1").to_owned(),
16+ Provider::OpenaiEndpoint => given.to_owned(),
17+ // Azure's v1 API, on the workspace's own resource.
18+ Provider::AzureOpenai => format!("{}/openai/v1", given.trim_end_matches("/openai/v1").trim_end_matches("/openai")),
19+ _ => provider.spec().base_url.to_owned(),
2020 }
2121 }
2222
23−/// The header the key goes in.
23+/// The header the key goes in; `authorization` means `Bearer <key>`.
2424 pub fn auth_header(provider: Provider, config: &ConnectionConfig) -> String {
2525 match provider {
26− Provider::Anthropic => "x-api-key".to_owned(),
27− Provider::AnthropicEndpoint => config.auth_header.clone().unwrap_or_else(|| "x-api-key".to_owned()),
28− _ => config.auth_header.clone().unwrap_or_else(|| "authorization".to_owned()),
26+ Provider::AnthropicEndpoint | Provider::OpenaiEndpoint => config
27+ .auth_header
28+ .clone()
29+ .unwrap_or_else(|| provider.spec().auth_header.to_owned()),
30+ _ => provider.spec().auth_header.to_owned(),
2931 }
3032 }
3133
5860 if header == "authorization" {
5961 headers.push(("authorization", bearer.as_deref().unwrap_or_default()));
6062 } else {
61− headers.push(("x-api-key", key));
63+ headers.push((header.as_str(), key));
6264 }
6365 }
6466 let gateway = gateway_token.map(|token| format!("Bearer {token}"));
8082 })
8183 .unwrap_or_default();
8284 models.sort();
83− models.truncate(200);
85+ models.truncate(1000);
8486 let message = match models.len() {
8587 0 => format!("{system} accepted the key."),
8688 count => format!("{system} accepted the key and offers {count} models."),
113115 assert_eq!(base_url(Provider::AnthropicEndpoint, &config), "https://llm.acme.dev");
114116 assert_eq!(auth_header(Provider::Gemini, &config), "authorization");
115117 assert_eq!(auth_header(Provider::Anthropic, &config), "x-api-key");
118+ assert_eq!(auth_header(Provider::AzureOpenai, &config), "api-key");
119+ assert_eq!(base_url(Provider::Groq, &config), "https://api.groq.com/openai/v1");
120+ let azure = ConnectionConfig {
121+ base_url: Some("https://acme.openai.azure.com/".to_owned()),
122+ ..ConnectionConfig::default()
123+ };
124+ assert_eq!(base_url(Provider::AzureOpenai, &azure), "https://acme.openai.azure.com/openai/v1");
116125 }
117126
118127 #[test]
+4−3
5555 const headers = new Headers({ "content-type": "application/json" });
5656 if (upstream.gatewayToken) headers.set("cf-aig-authorization", `Bearer ${upstream.gatewayToken}`);
5757 if (upstream.apiKey) {
58− if (upstream.authHeader === "x-api-key") headers.set("x-api-key", upstream.apiKey);
59− else headers.set("authorization", `Bearer ${upstream.apiKey}`);
58+ // `authorization` means a bearer token; any other header takes the key as it is.
59+ const header = upstream.authHeader ?? "authorization";
60+ headers.set(header, header === "authorization" ? `Bearer ${upstream.apiKey}` : upstream.apiKey);
6061 }
6162 const answer = await fetch(`${(upstream.baseUrl ?? "").replace(/\/+$/, "")}/chat/completions`, {
6263 method: "POST",
6364 headers,
64− body: JSON.stringify(toChat(body, model, { official: upstream.official })),
65+ body: JSON.stringify(toChat(body, model, { official: upstream.official, provider: upstream.provider })),
6566 });
6667 if (!answer.ok) {
6768 return Response.json(errorFromChat(answer.status, await answer.text()), { status: answer.status });
+35−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { StreamTranslator, errorFromChat, fromChat, toChat } from "./openai.ts";
4+import { StreamTranslator, carryId, errorFromChat, fromChat, toChat, uncarryId } from "./openai.ts";
55
66 test("a conversation with tool calls becomes a chat completion", () => {
77 const body = toChat(
138138 const error = errorFromChat(429, JSON.stringify({ error: { message: "Rate limit reached" } }));
139139 assert.deepEqual(error, { type: "error", error: { type: "rate_limit_error", message: "The provider said: Rate limit reached" } });
140140 });
141+
142+test("each provider's quirks are met", () => {
143+ const ask = { messages: [{ role: "user" as const, content: "hi" }], max_tokens: 32000, stream: true, tool_choice: { type: "any" as const }, tools: [{ name: "Bash", input_schema: { type: "object" } }] };
144+ const deepseek = toChat(ask, "deepseek-chat", { official: false, provider: "deepseek" });
145+ assert.equal(deepseek.max_tokens, 8192);
146+ assert.equal(deepseek.tool_choice, "required");
147+ const mistral = toChat(ask, "mistral-large-latest", { official: false, provider: "mistral" });
148+ assert.equal(mistral.tool_choice, "any");
149+ assert.equal(mistral.stream_options, undefined);
150+});
151+
152+test("what a provider attaches to a tool call comes back with it", () => {
153+ const signature = { google: { thought_signature: "c2lnbmF0dXJl+/==" } };
154+ const id = carryId("call_1", signature);
155+ assert.match(id, /^call_1__g1t_[A-Za-z0-9_-]+$/);
156+ assert.deepEqual(uncarryId(id), { id: "call_1", extra: signature });
157+ assert.deepEqual(uncarryId("toolu_plain"), { id: "toolu_plain", extra: undefined });
158+
159+ const body = toChat(
160+ {
161+ messages: [
162+ { role: "user", content: "go" },
163+ { role: "assistant", content: [{ type: "tool_use", id, name: "Bash", input: {} }] },
164+ { role: "user", content: [{ type: "tool_result", tool_use_id: id, content: "ok" }] },
165+ ],
166+ },
167+ "gemini-3-pro",
168+ { official: false, provider: "gemini" },
169+ );
170+ const messages = body.messages as Record<string, unknown>[];
171+ assert.deepEqual((messages[1].tool_calls as Record<string, unknown>[])[0].extra_content, signature);
172+ assert.equal((messages[1].tool_calls as Record<string, unknown>[])[0].id, "call_1");
173+ assert.equal(messages[2].tool_call_id, "call_1");
174+});
+71−7
4444 export type Dialect = {
4545 /** OpenAI's own API: `max_completion_tokens`, and no temperature for its reasoning models. */
4646 official: boolean;
47+ /** Which provider, by name, for the quirks of each. */
48+ provider?: string;
4749 };
4850
51+/** The most output tokens a provider accepts in one answer, where it caps them below what the harness asks. */
52+const MAX_OUTPUT: Record<string, number> = { deepseek: 8192, groq: 32768, cerebras: 32768 };
53+
54+/**
55+ * Some providers attach data to a tool call that has to come back with it
56+ * on the next turn, such as Gemini's thought signatures. The harness keeps
57+ * nothing but the call's id, so the data rides in the id.
58+ */
59+const CARRIED = "__g1t_";
60+
61+function toBase64Url(text: string): string {
62+ let binary = "";
63+ for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
64+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
65+}
66+
67+function fromBase64Url(encoded: string): string {
68+ const binary = atob(encoded.replace(/-/g, "+").replace(/_/g, "/"));
69+ return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
70+}
71+
72+/** A tool call's id, carrying whatever the provider attached to the call. */
73+export function carryId(id: string, extra: unknown): string {
74+ return extra == null ? id : `${id}${CARRIED}${toBase64Url(JSON.stringify(extra))}`;
75+}
76+
77+/** The provider's own id, and what it attached, from a carried id. */
78+export function uncarryId(id: string): { id: string; extra: unknown } {
79+ const at = id.indexOf(CARRIED);
80+ if (at < 0) return { id, extra: undefined };
81+ try {
82+ return { id: id.slice(0, at), extra: JSON.parse(fromBase64Url(id.slice(at + CARRIED.length))) };
83+ } catch {
84+ return { id: id.slice(0, at), extra: undefined };
85+ }
86+}
87+
4988 function text(content: string | AnthropicBlock[] | undefined): string {
5089 if (content == null) return "";
5190 if (typeof content === "string") return content;
68107 const said = blocks.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join("");
69108 const calls = blocks
70109 .filter((b): b is Extract<AnthropicBlock, { type: "tool_use" }> => b.type === "tool_use")
71− .map((b) => ({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) } }));
110+ .map((b) => {
111+ const { id, extra } = uncarryId(b.id);
112+ return {
113+ id,
114+ type: "function",
115+ function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
116+ ...(extra === undefined ? {} : { extra_content: extra }),
117+ };
118+ });
72119 messages.push({ role: "assistant", content: said || null, ...(calls.length ? { tool_calls: calls } : {}) });
73120 continue;
74121 }
77124 for (const block of blocks) {
78125 if (block.type !== "tool_result") continue;
79126 const result = text(block.content);
80− messages.push({ role: "tool", tool_call_id: block.tool_use_id, content: block.is_error ? `Error: ${result}` : result });
127+ messages.push({
128+ role: "tool",
129+ tool_call_id: uncarryId(block.tool_use_id).id,
130+ content: block.is_error ? `Error: ${result}` : result,
131+ });
81132 }
82133 const parts: Json[] = [];
83134 for (const block of blocks) {
92143 }
93144
94145 const body: Json = { model, messages, stream: request.stream === true };
95− if (request.stream) body.stream_options = { include_usage: true };
96− if (request.max_tokens) body[dialect.official ? "max_completion_tokens" : "max_tokens"] = request.max_tokens;
146+ // Usage at the end of a stream; Mistral refuses the option.
147+ if (request.stream && dialect.provider !== "mistral") body.stream_options = { include_usage: true };
148+ const cap = MAX_OUTPUT[dialect.provider ?? ""];
149+ const maxTokens = request.max_tokens && cap ? Math.min(request.max_tokens, cap) : request.max_tokens;
150+ if (maxTokens) body[dialect.official ? "max_completion_tokens" : "max_tokens"] = maxTokens;
97151 if (!dialect.official && request.temperature != null) body.temperature = request.temperature;
98152 if (!dialect.official && request.top_p != null) body.top_p = request.top_p;
99153 if (request.stop_sequences?.length) body.stop = request.stop_sequences.slice(0, 4);
106160 function: { name: tool.name, description: tool.description ?? "", parameters: tool.input_schema },
107161 }));
108162 const choice = request.tool_choice;
109− if (choice?.type === "any") body.tool_choice = "required";
163+ if (choice?.type === "any") body.tool_choice = dialect.provider === "mistral" ? "any" : "required";
110164 else if (choice?.type === "tool" && choice.name) body.tool_choice = { type: "function", function: { name: choice.name } };
111165 else if (choice?.type === "none") body.tool_choice = "none";
112166 }
137191 if (typeof message.content === "string" && message.content) content.push({ type: "text", text: message.content });
138192 for (const call of (message.tool_calls as Json[] | undefined) ?? []) {
139193 const fn = call.function as Json;
140− content.push({ type: "tool_use", id: call.id, name: fn.name, input: parseArguments(String(fn.arguments ?? "")) });
194+ content.push({
195+ type: "tool_use",
196+ id: carryId(String(call.id), call.extra_content),
197+ name: fn.name,
198+ input: parseArguments(String(fn.arguments ?? "")),
199+ });
141200 }
142201 const usage = (completion.usage as Json | undefined) ?? {};
143202 return {
227286 this.open = { kind: "tool", slot };
228287 out += event("content_block_start", {
229288 index: this.index,
230− content_block: { type: "tool_use", id: String(call.id ?? `call_${this.index}`), name: String(fn.name ?? ""), input: {} },
289+ content_block: {
290+ type: "tool_use",
291+ id: carryId(String(call.id ?? `call_${this.index}`), call.extra_content),
292+ name: String(fn.name ?? ""),
293+ input: {},
294+ },
231295 });
232296 }
233297 if (typeof fn.arguments === "string" && fn.arguments) {
+1−1
55
66 import { presentedToken, upstreamRequest } from "./route.ts";
77
8−const run = { workspace: "acme", repo: "acme/web", number: 7, task: "implement", baseUrl: null, apiKey: null, authHeader: null, api: "anthropic" as const, model: null, official: false };
8+const run = { workspace: "acme", repo: "acme/web", number: 7, task: "implement", baseUrl: null, apiKey: null, authHeader: null, api: "anthropic" as const, model: null, official: false, provider: "g1t" };
99 const hosted = { AI_GATEWAY_ID: "g1t", CLOUDFLARE_ACCOUNT_ID: "acct", AI_GATEWAY_TOKEN: "gw-token" };
1010
1111 function incoming(): Headers {
+2−2
6666 }
6767 const key = upstream.apiKey;
6868 if (key) {
69− if (upstream.authHeader === "authorization") headers.set("authorization", `Bearer ${key}`);
70− else headers.set("x-api-key", key);
69+ const header = upstream.authHeader ?? "x-api-key";
70+ headers.set(header, header === "authorization" ? `Bearer ${key}` : key);
7171 }
7272 if (upstream.gatewayToken) headers.set("cf-aig-authorization", `Bearer ${upstream.gatewayToken}`);
7373 const base = (upstream.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");