A catalogue of model providers, and settings that feel like settings #3
Mergedsyntaqx merged model-catalog into main
22 files+939−4290/22 viewed
| 423 | 423 | "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." | |
| 424 | 424 | } | |
| 425 | 425 | 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." | |
| 427 | 427 | } | |
| 428 | 428 | Op::DisconnectIntegration => { | |
| 429 | 429 | "Remove an integration and its secrets. Agents already running on a model provider being removed stop reaching it. Owners only." | |
| 768 | 768 | "workspace": workspace_schema(), | |
| 769 | 769 | "provider": { | |
| 770 | 770 | "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<_>>(), | |
| 772 | 772 | }, | |
| 773 | 773 | "name": { "type": "string", "description": "What to call it. The provider's name if left out." }, | |
| 774 | 774 | "config": { | |
| 1335 | 1335 | Op::ConnectIntegration => { | |
| 1336 | 1336 | let provider = text(input, "provider"); | |
| 1337 | 1337 | 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(", "))); | |
| 1342 | 1340 | } | |
| 1343 | 1341 | pass( | |
| 1344 | 1342 | integrations, |
| 12 | 12 | | [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. | | |
| 13 | 13 | | [Trackers](#trackers) | Jira, Linear | Agents read the tickets that work mentions, people import tickets as issues, and tickets hear back when the work lands. | | |
| 14 | 14 | ||
| 15 | − | Open the workspace's **Integrations** page from the sidebar. Every member | |
| 15 | + | Open the workspace's **Settings → Integrations**. Every member | |
| 16 | 16 | can see the connections; only owners can add, test or remove them. | |
| 17 | 17 | ||
| 18 | 18 | Secrets are sealed when they are saved and never shown again, to anyone. |
| 16 | 16 | ||
| 17 | 17 | ## Providers | |
| 18 | 18 | ||
| 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 | | |
| 20 | 22 | | --- | --- | --- | | |
| 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 | | |
| 26 | 37 | ||
| 27 | 38 | An endpoint behind an authenticated Cloudflare AI Gateway also takes the | |
| 28 | 39 | gateway's token, sent as `cf-aig-authorization`. | |
| 29 | 40 | ||
| 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. | |
| 33 | 47 | How well they work depends on the model: it has to be good at using tools | |
| 34 | 48 | over many steps. | |
| 35 | 49 | ||
| 36 | 50 | ## Connect a provider | |
| 37 | 51 | ||
| 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. | |
| 39 | 53 | 2. Under **Model providers**, choose one, and give its key (and address, for | |
| 40 | 54 | an endpoint). | |
| 41 | 55 | 3. **Connect**. g1t checks the key at once and lists the provider's models. | |
| 100 | 114 | ||
| 101 | 115 | | Tool | Route | | |
| 102 | 116 | | --- | --- | | |
| 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` | | |
| 104 | 118 | | `get_model_routes` | `GET /workspaces/{workspace}/model-routes` | | |
| 105 | 119 | | `set_model_routes` | `PUT /workspaces/{workspace}/model-routes` | | |
| 106 | 120 |
| 36 | 36 | ||
| 37 | 37 | Only an owner of the workspace can add credit. | |
| 38 | 38 | ||
| 39 | − | 1. Open the workspace's **Billing** page, `g1t.sh/<workspace>/-/billing`. | |
| 39 | + | 1. Open the workspace's **Settings → Billing**, `g1t.sh/<workspace>/-/billing`. | |
| 40 | 40 | 2. Under **Add credit by card**, choose an amount: $10, $25, $50 or $100. | |
| 41 | 41 | 3. Pay on the card page you are sent to. | |
| 42 | 42 |
| 45 | 45 | | Owner | Everything a member can, and manage members, the workspace's access tokens, its details, and add credit. | | |
| 46 | 46 | ||
| 47 | 47 | 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. | |
| 50 | 50 | ||
| 51 | 51 | ## The workspace's pages | |
| 52 | 52 | ||
| 53 | 53 | 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: | |
| 55 | 58 | ||
| 56 | − | | Page | Who | | | |
| 59 | + | | Settings | Who | | | |
| 57 | 60 | | --- | --- | --- | | |
| 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. | | |
| 61 | 63 | | **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. | |
| 63 | 68 | ||
| 64 | 69 | ## Workspace access tokens | |
| 65 | 70 | ||
| 75 | 80 | | Can reach | Every workspace you belong to | That workspace only | | |
| 76 | 81 | | Can do | Everything you can | What a member can; it cannot manage people, tokens or workspaces | | |
| 77 | 82 | | 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** | | |
| 79 | 84 | ||
| 80 | 85 | They are the same kind of token and are sent the same way; see | |
| 81 | 86 | [access tokens](/guides/authentication/#access-tokens). With git, any |
| 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 | + | } |
| 1 | 1 | import { | |
| 2 | + | ArrowLeft, | |
| 2 | 3 | BarChart3, | |
| 3 | 4 | BookMarked, | |
| 4 | 5 | BookOpen, | |
| 240 | 241 | ); | |
| 241 | 242 | } | |
| 242 | 243 | ||
| 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 | + | ||
| 243 | 288 | function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) { | |
| 244 | 289 | 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; | |
| 245 | 295 | const active = shell.repo; | |
| 246 | 296 | const repoBase = active ? `/${active.namespace}/${active.name}` : null; | |
| 247 | 297 | // The repository being looked at is listed even when it is someone else's. | |
| 272 | 322 | <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd> | |
| 273 | 323 | </button> | |
| 274 | 324 | </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 | + | > | |
| 276 | 331 | <div className="mt-3 space-y-px"> | |
| 277 | 332 | <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}> | |
| 278 | 333 | Mission control | |
| 286 | 341 | <SidebarGroup title="Workspace"> | |
| 287 | 342 | <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}> | |
| 288 | 343 | Overview | |
| 289 | − | </SidebarLink> | |
| 290 | − | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}> | |
| 291 | − | People | |
| 292 | 344 | </SidebarLink> | |
| 293 | − | <SidebarLink to={`/${ws.slug}/-/tokens`} icon={<KeyRound size={15} />}> | |
| 294 | − | Access tokens | |
| 295 | − | </SidebarLink> | |
| 296 | 345 | <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}> | |
| 297 | 346 | Usage | |
| 298 | 347 | </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 | |
| 304 | 354 | </SidebarLink> | |
| 305 | − | {ws.role === "owner" && ( | |
| 306 | − | <SidebarLink to={`/${ws.slug}/-/settings`} icon={<Settings size={15} />}> | |
| 307 | − | Settings | |
| 308 | − | </SidebarLink> | |
| 309 | − | )} | |
| 310 | 355 | </SidebarGroup> | |
| 311 | 356 | )} | |
| 312 | 357 | ||
| 392 | 437 | })} | |
| 393 | 438 | </SidebarGroup> | |
| 394 | 439 | </nav> | |
| 440 | + | {ws && <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />} | |
| 441 | + | </div> | |
| 395 | 442 | ||
| 396 | 443 | <div className="space-y-2 p-2"> | |
| 397 | 444 | {ws && <UsageCard slug={ws.slug} shell={shell} />} | |
| 409 | 456 | commits: "Commits", | |
| 410 | 457 | plans: "Plan", | |
| 411 | 458 | settings: "Settings", | |
| 412 | − | people: "People", | |
| 459 | + | people: "Members", | |
| 413 | 460 | tokens: "Access tokens", | |
| 414 | 461 | usage: "Usage", | |
| 415 | 462 | billing: "Billing", | |
| 463 | + | integrations: "Integrations", | |
| 416 | 464 | tree: "Code", | |
| 417 | 465 | blob: "Code", | |
| 418 | 466 | }; | |
| 435 | 483 | const [owner, second, third, fourth] = parts; | |
| 436 | 484 | const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }]; | |
| 437 | 485 | 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 }); | |
| 439 | 491 | } else if (second) { | |
| 440 | 492 | const repo = `/${owner}/${second}`; | |
| 441 | 493 | trail.push({ label: second, to: repo, mono: true }); | |
| 495 | 547 | commands.push( | |
| 496 | 548 | { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> }, | |
| 497 | 549 | { 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} /> }, | |
| 501 | 553 | ); | |
| 502 | 554 | } | |
| 503 | 555 | for (const listed of shell.repos) { |
| 1 | 1 | import { | |
| 2 | 2 | AlertTriangle, | |
| 3 | − | Bot, | |
| 4 | 3 | CheckCircle2, | |
| 5 | 4 | ChevronRight, | |
| 6 | 5 | Cpu, | |
| 7 | − | Plug, | |
| 8 | 6 | Siren, | |
| 9 | 7 | Ticket, | |
| 10 | − | Webhook, | |
| 11 | 8 | X, | |
| 12 | 9 | } from "lucide-react"; | |
| 13 | 10 | import { env } from "cloudflare:workers"; | |
| 20 | 17 | type Delivery, | |
| 21 | 18 | MODEL_TASKS, | |
| 22 | 19 | type ModelRoute, | |
| 23 | − | type ModelTask, | |
| 24 | 20 | type Provider, | |
| 25 | 21 | type ProviderKind, | |
| 26 | 22 | PROVIDERS, | |
| 27 | 23 | } from "@g1t/contracts"; | |
| 28 | 24 | ||
| 29 | 25 | 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"; | |
| 31 | 28 | import { billing, integrations, repos } from "../../lib/services.server"; | |
| 32 | 29 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 33 | 30 | ||
| 164 | 161 | }, | |
| 165 | 162 | }; | |
| 166 | 163 | ||
| 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>> = { | |
| 173 | 165 | sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.", | |
| 174 | 166 | datadog: "Monitors that trigger open issues. Recoveries are noted on them.", | |
| 175 | 167 | webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.", | |
| 176 | 168 | jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.", | |
| 177 | 169 | linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.", | |
| 178 | 170 | }; | |
| 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 | − | } | |
| 217 | 171 | ||
| 218 | 172 | function dollars(micros: number): string { | |
| 219 | 173 | return `$${(micros / 1_000_000).toFixed(2)}`; | |
| 230 | 184 | const error = (actionData && "error" in actionData ? actionData.error : null) ?? null; | |
| 231 | 185 | ||
| 232 | 186 | 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> | |
| 247 | 188 | {justConnected && <Connected connected={justConnected} />} | |
| 248 | 189 | <div className="mt-4"> | |
| 249 | 190 | <ErrorText>{error}</ErrorText> | |
| 250 | 191 | </div> | |
| 251 | 192 | ||
| 252 | 193 | {/* Models -------------------------------------------------------------- */} | |
| 253 | − | <Section kind="models"> | |
| 194 | + | <Section kind="models" first> | |
| 254 | 195 | {!hostedOpen && modelConnections.length === 0 && ( | |
| 255 | 196 | <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4"> | |
| 256 | 197 | <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30"> | |
| 264 | 205 | </div> | |
| 265 | 206 | )} | |
| 266 | 207 | <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 && ( | |
| 267 | 216 | <Routing | |
| 268 | 217 | connections={modelConnections} | |
| 269 | 218 | routes={routes} | |
| 273 | 222 | busy={busy} | |
| 274 | 223 | saved={actionData != null && "routed" in actionData} | |
| 275 | 224 | /> | |
| 225 | + | )} | |
| 276 | 226 | <p className="mt-3 text-xs text-faint"> | |
| 277 | 227 | On your own providers, their bills are yours and g1t charges {dollars(feeMicros)} a run for the | |
| 278 | 228 | sandbox and orchestration. Keys go only from g1t's model proxy to the provider: the agent's | |
| 279 | 229 | sandbox holds a token that dies with the run. | |
| 280 | 230 | </p> | |
| 281 | − | {owner && <Choices kind="models" slug={slug} adding={adding} />} | |
| 231 | + | {owner && <ModelCatalog slug={slug} adding={adding} />} | |
| 282 | 232 | {adding && PROVIDERS[adding].kind === "models" && owner && ( | |
| 283 | 233 | <AddForm | |
| 284 | 234 | provider={adding} | |
| 335 | 285 | {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>} | |
| 336 | 286 | </div> | |
| 337 | 287 | ); | |
| 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 ?? ""}`; | |
| 352 | 288 | } | |
| 353 | 289 | ||
| 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 }) { | |
| 440 | 291 | const info = KIND_INFO[kind]; | |
| 441 | 292 | return ( | |
| 442 | − | <section className="mt-10"> | |
| 293 | + | <section className={first ? "" : "mt-12"}> | |
| 443 | 294 | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 444 | 295 | <span className="text-muted">{info.icon}</span> | |
| 445 | 296 | {info.title} | |
| 606 | 457 | ); | |
| 607 | 458 | } | |
| 608 | 459 | ||
| 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 | + | ||
| 609 | 465 | function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) { | |
| 610 | 466 | const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind); | |
| 611 | 467 | 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} /> | |
| 632 | 470 | </div> | |
| 633 | 471 | ); | |
| 634 | 472 | } | |
| 681 | 519 | busy: boolean; | |
| 682 | 520 | error: string | null; | |
| 683 | 521 | }) { | |
| 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>> = { | |
| 754 | 523 | sentry: ( | |
| 755 | 524 | <> | |
| 756 | 525 | <Field label="Organization" hint="Its slug, as in acme.sentry.io."> | |
| 813 | 582 | <h3 className="font-medium"> | |
| 814 | 583 | Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label} | |
| 815 | 584 | </h3> | |
| 816 | − | <p className="text-xs text-muted">{PROVIDER_BLURB[provider]}</p> | |
| 585 | + | <p className="text-xs text-muted">{blurb(provider)}</p> | |
| 817 | 586 | </div> | |
| 818 | 587 | <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close"> | |
| 819 | 588 | <X size={16} /> | |
| 821 | 590 | </div> | |
| 822 | 591 | <Form method="post" className="mt-5 grid max-w-xl gap-4"> | |
| 823 | 592 | <input type="hidden" name="provider" value={provider} /> | |
| 824 | − | {fields[provider]} | |
| 593 | + | {fields[provider] ?? <ModelProviderFields provider={provider} />} | |
| 825 | 594 | <ErrorText>{error}</ErrorText> | |
| 826 | 595 | <div> | |
| 827 | 596 | <Button type="submit" disabled={busy}> |
| 1 | 1 | 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"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/layout"; | |
| 5 | 5 | import { Avatar, ButtonLink, Pill, TabLink } from "../../components/ui"; | |
| 16 | 16 | return { workspace, role: roleIn(getViewer(context), workspace.slug) }; | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 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 | + | ||
| 19 | 38 | export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) { | |
| 20 | 39 | const { workspace, role } = loaderData; | |
| 21 | 40 | const base = `/${workspace.slug}`; | |
| 22 | 41 | 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 | + | } | |
| 23 | 56 | return ( | |
| 24 | 57 | <> | |
| 25 | 58 | {/* The workspace's own header band, under the site header. */} |
| 39 | 39 | const { role, members } = loaderData; | |
| 40 | 40 | return ( | |
| 41 | 41 | <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"> | |
| 49 | 43 | {members.map((member) => ( | |
| 50 | 44 | <li key={member.username} className="flex items-center gap-3 px-4 py-3"> | |
| 51 | 45 | <Avatar name={member.username} size={28} /> |
| 60 | 60 | return ( | |
| 61 | 61 | <div className="grid gap-10 lg:grid-cols-[1fr_20rem]"> | |
| 62 | 62 | <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> | |
| 69 | 63 | ||
| 70 | 64 | {created && ( | |
| 71 | 65 | <div className="mt-5 rounded-xl border border-accent/40 bg-surface p-4"> |
| 193 | 193 | const runway = perDay > 0 ? Math.floor(account.balanceMicros / perDay) : null; | |
| 194 | 194 | return ( | |
| 195 | 195 | <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"> | |
| 204 | 197 | <nav className="flex rounded-lg bg-surface p-1 ring-1 ring-line"> | |
| 205 | 198 | {(Object.keys(PERIODS) as Period[]).map((key) => ( | |
| 206 | 199 | <Link |
| 201 | 201 | A workspace's owners connect it to outside systems on its **Integrations** | |
| 202 | 202 | page, or with `POST /workspaces/{workspace}/integrations`: | |
| 203 | 203 | ||
| 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 | |
| 206 | 208 | kind of work routed to one of them or to g1t's hosted models | |
| 207 | 209 | (`PUT /workspaces/{workspace}/model-routes`). Those providers bill the | |
| 208 | 210 | workspace and g1t charges $0.10 a run. Sandboxes never hold a key. |
| 25 | 25 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 26 | 26 | #[serde(rename_all = "snake_case")] | |
| 27 | 27 | pub enum Provider { | |
| 28 | − | /// The workspace's own Anthropic API key. | |
| 28 | + | // Model providers: the labs. | |
| 29 | 29 | 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. | |
| 30 | 45 | /// Any endpoint that speaks Anthropic's Messages API: the workspace's | |
| 31 | 46 | /// own Cloudflare AI Gateway, LiteLLM, a proxy in front of Bedrock or | |
| 32 | 47 | /// Vertex, or a self-hosted model. | |
| 33 | 48 | 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. | |
| 41 | 51 | OpenaiEndpoint, | |
| 52 | + | // Alerts. | |
| 42 | 53 | Sentry, | |
| 43 | 54 | Datadog, | |
| 44 | 55 | /// Anything that can send a signed JSON request. | |
| 45 | 56 | Webhook, | |
| 57 | + | // Trackers. | |
| 46 | 58 | Jira, | |
| 47 | 59 | Linear, | |
| 48 | 60 | } | |
| 49 | 61 | ||
| 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 | + | ||
| 50 | 125 | 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 | + | } | |
| 63 | 136 | ||
| 64 | 137 | 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 | |
| 77 | 139 | } | |
| 78 | 140 | ||
| 79 | 141 | 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) | |
| 81 | 143 | } | |
| 82 | 144 | ||
| 83 | 145 | /// What people call it. | |
| 84 | 146 | 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 | |
| 97 | 148 | } | |
| 98 | 149 | ||
| 99 | 150 | 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 | |
| 109 | 152 | } | |
| 110 | 153 | ||
| 111 | 154 | /// Whether it sends g1t requests, at the connection's own address. | |
| 112 | 155 | pub fn receives(self) -> bool { | |
| 113 | − | matches!(self, Provider::Sentry | Provider::Datadog | Provider::Webhook) | |
| 156 | + | self.kind() == ProviderKind::Alerts | |
| 114 | 157 | } | |
| 115 | 158 | ||
| 116 | 159 | /// For a model provider, the API it speaks: `anthropic` or `openai`. | |
| 117 | 160 | 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() | |
| 122 | 167 | } | |
| 123 | 168 | } | |
| 124 | 169 | ||
| 324 | 369 | pub model: Option<String>, | |
| 325 | 370 | /// For `openai`: OpenAI's own API, which shapes requests its own way. | |
| 326 | 371 | pub official: bool, | |
| 372 | + | /// Which provider it is, by name, so the proxy can meet its quirks. | |
| 373 | + | #[serde(default)] | |
| 374 | + | pub provider: String, | |
| 327 | 375 | pub workspace: String, | |
| 328 | 376 | pub repo: String, | |
| 329 | 377 | pub number: u32, | |
| 535 | 583 | pub struct ModelProviderArgs { | |
| 536 | 584 | pub workspace: String, | |
| 537 | 585 | } | |
| 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 | + | } |
| 8 | 8 | */ | |
| 9 | 9 | export type Provider = | |
| 10 | 10 | | "anthropic" | |
| 11 | − | | "anthropic_endpoint" | |
| 12 | 11 | | "openai" | |
| 13 | 12 | | "gemini" | |
| 13 | + | | "xai" | |
| 14 | + | | "mistral" | |
| 15 | + | | "deepseek" | |
| 16 | + | | "azure_openai" | |
| 17 | + | | "openrouter" | |
| 18 | + | | "groq" | |
| 19 | + | | "together" | |
| 20 | + | | "fireworks" | |
| 21 | + | | "cerebras" | |
| 22 | + | | "anthropic_endpoint" | |
| 14 | 23 | | "openai_endpoint" | |
| 15 | 24 | | "sentry" | |
| 16 | 25 | | "datadog" | |
| 123 | 132 | model: string | null; | |
| 124 | 133 | /** OpenAI's own API. */ | |
| 125 | 134 | official: boolean; | |
| 135 | + | /** Which provider, by name, for its quirks. */ | |
| 136 | + | provider: string; | |
| 126 | 137 | workspace: string; | |
| 127 | 138 | repo: string; | |
| 128 | 139 | number: number; | |
| 177 | 188 | /** What each provider is for, as people choose between them. */ | |
| 178 | 189 | export const PROVIDERS: Record<Provider, { label: string; kind: ProviderKind }> = { | |
| 179 | 190 | anthropic: { label: "Anthropic", kind: "models" }, | |
| 180 | − | anthropic_endpoint: { label: "Anthropic-compatible endpoint", kind: "models" }, | |
| 181 | 191 | openai: { label: "OpenAI", kind: "models" }, | |
| 182 | 192 | 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" }, | |
| 183 | 203 | openai_endpoint: { label: "OpenAI-compatible endpoint", kind: "models" }, | |
| 184 | 204 | sentry: { label: "Sentry", kind: "alerts" }, | |
| 185 | 205 | datadog: { label: "Datadog", kind: "alerts" }, |
| 185 | 185 | } | |
| 186 | 186 | let needs = |present: bool, what: &str| if present { Ok(()) } else { Err(what.to_owned()) }; | |
| 187 | 187 | 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 | + | } | |
| 191 | 193 | Provider::AnthropicEndpoint | Provider::OpenaiEndpoint => { | |
| 192 | 194 | needs(config.base_url.is_some(), "Give the endpoint's address.")?; | |
| 193 | 195 | if let Some(header) = &config.auth_header | |
| 212 | 214 | needs(secrets.secret.is_some(), "Paste a Jira API token.") | |
| 213 | 215 | } | |
| 214 | 216 | 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())), | |
| 215 | 219 | } | |
| 216 | 220 | } | |
| 217 | 221 | ||
| 503 | 507 | })); | |
| 504 | 508 | } | |
| 505 | 509 | let tested = match provider { | |
| 506 | − | Provider::Anthropic | |
| 507 | − | | Provider::AnthropicEndpoint | |
| 508 | − | | Provider::Openai | |
| 509 | − | | Provider::Gemini | |
| 510 | − | | Provider::OpenaiEndpoint => unreachable!("checked above"), | |
| 511 | 510 | Provider::Sentry => match key { | |
| 512 | 511 | Some(token) => sentry::test(&config, token).await?, | |
| 513 | 512 | None => Ok("Sentry can send alerts. Add an auth token so g1t can read stack traces and resolve issues.".to_owned()), | |
| 514 | 513 | }, | |
| 515 | 514 | Provider::Jira => trackers::jira_test(&config, key.unwrap_or_default()).await?, | |
| 516 | 515 | Provider::Linear => trackers::linear_test(key.unwrap_or_default()).await?, | |
| 517 | − | Provider::Datadog | Provider::Webhook => Ok(format!( | |
| 516 | + | _ => Ok(format!( | |
| 518 | 517 | "Ready. Requests to its address that carry the secret open issues in {}.", | |
| 519 | 518 | config.repo.as_deref().unwrap_or("its repository") | |
| 520 | 519 | )), | |
| 1244 | 1243 | api: "anthropic".to_owned(), | |
| 1245 | 1244 | model: None, | |
| 1246 | 1245 | official: false, | |
| 1246 | + | provider: "g1t".to_owned(), | |
| 1247 | 1247 | workspace: session.workspace, | |
| 1248 | 1248 | repo: session.repo, | |
| 1249 | 1249 | number: session.number, | |
| 1266 | 1266 | route: if provider == Provider::Anthropic { "anthropic" } else { "endpoint" }.to_owned(), | |
| 1267 | 1267 | api: provider.api().to_owned(), | |
| 1268 | 1268 | model: session.model, | |
| 1269 | − | official: provider == Provider::Openai, | |
| 1269 | + | official: matches!(provider, Provider::Openai | Provider::AzureOpenai), | |
| 1270 | + | provider: provider.name().to_owned(), | |
| 1270 | 1271 | base_url: Some(models::base_url(provider, &config)), | |
| 1271 | 1272 | api_key: self.secrets(&row).secret, | |
| 1272 | 1273 | auth_header: Some(models::auth_header(provider, &config)), |
| 10 | 10 | /// Where requests go: without `/v1` for Anthropic's API, with the version | |
| 11 | 11 | /// for OpenAI's (`…/v1`, or Gemini's `…/v1beta/openai`). | |
| 12 | 12 | 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('/'); | |
| 14 | 14 | 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(), | |
| 20 | 20 | } | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | − | /// The header the key goes in. | |
| 23 | + | /// The header the key goes in; `authorization` means `Bearer <key>`. | |
| 24 | 24 | pub fn auth_header(provider: Provider, config: &ConnectionConfig) -> String { | |
| 25 | 25 | 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(), | |
| 29 | 31 | } | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 58 | 60 | if header == "authorization" { | |
| 59 | 61 | headers.push(("authorization", bearer.as_deref().unwrap_or_default())); | |
| 60 | 62 | } else { | |
| 61 | − | headers.push(("x-api-key", key)); | |
| 63 | + | headers.push((header.as_str(), key)); | |
| 62 | 64 | } | |
| 63 | 65 | } | |
| 64 | 66 | let gateway = gateway_token.map(|token| format!("Bearer {token}")); | |
| 80 | 82 | }) | |
| 81 | 83 | .unwrap_or_default(); | |
| 82 | 84 | models.sort(); | |
| 83 | − | models.truncate(200); | |
| 85 | + | models.truncate(1000); | |
| 84 | 86 | let message = match models.len() { | |
| 85 | 87 | 0 => format!("{system} accepted the key."), | |
| 86 | 88 | count => format!("{system} accepted the key and offers {count} models."), | |
| 113 | 115 | assert_eq!(base_url(Provider::AnthropicEndpoint, &config), "https://llm.acme.dev"); | |
| 114 | 116 | assert_eq!(auth_header(Provider::Gemini, &config), "authorization"); | |
| 115 | 117 | 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"); | |
| 116 | 125 | } | |
| 117 | 126 | ||
| 118 | 127 | #[test] |
| 55 | 55 | const headers = new Headers({ "content-type": "application/json" }); | |
| 56 | 56 | if (upstream.gatewayToken) headers.set("cf-aig-authorization", `Bearer ${upstream.gatewayToken}`); | |
| 57 | 57 | 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); | |
| 60 | 61 | } | |
| 61 | 62 | const answer = await fetch(`${(upstream.baseUrl ?? "").replace(/\/+$/, "")}/chat/completions`, { | |
| 62 | 63 | method: "POST", | |
| 63 | 64 | headers, | |
| 64 | − | body: JSON.stringify(toChat(body, model, { official: upstream.official })), | |
| 65 | + | body: JSON.stringify(toChat(body, model, { official: upstream.official, provider: upstream.provider })), | |
| 65 | 66 | }); | |
| 66 | 67 | if (!answer.ok) { | |
| 67 | 68 | return Response.json(errorFromChat(answer.status, await answer.text()), { status: answer.status }); |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { StreamTranslator, errorFromChat, fromChat, toChat } from "./openai.ts"; | |
| 4 | + | import { StreamTranslator, carryId, errorFromChat, fromChat, toChat, uncarryId } from "./openai.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("a conversation with tool calls becomes a chat completion", () => { | |
| 7 | 7 | const body = toChat( | |
| 138 | 138 | const error = errorFromChat(429, JSON.stringify({ error: { message: "Rate limit reached" } })); | |
| 139 | 139 | assert.deepEqual(error, { type: "error", error: { type: "rate_limit_error", message: "The provider said: Rate limit reached" } }); | |
| 140 | 140 | }); | |
| 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 | + | }); |
| 44 | 44 | export type Dialect = { | |
| 45 | 45 | /** OpenAI's own API: `max_completion_tokens`, and no temperature for its reasoning models. */ | |
| 46 | 46 | official: boolean; | |
| 47 | + | /** Which provider, by name, for the quirks of each. */ | |
| 48 | + | provider?: string; | |
| 47 | 49 | }; | |
| 48 | 50 | ||
| 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 | + | ||
| 49 | 88 | function text(content: string | AnthropicBlock[] | undefined): string { | |
| 50 | 89 | if (content == null) return ""; | |
| 51 | 90 | if (typeof content === "string") return content; | |
| 68 | 107 | const said = blocks.filter((b) => b.type === "text").map((b) => (b as { text: string }).text).join(""); | |
| 69 | 108 | const calls = blocks | |
| 70 | 109 | .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 | + | }); | |
| 72 | 119 | messages.push({ role: "assistant", content: said || null, ...(calls.length ? { tool_calls: calls } : {}) }); | |
| 73 | 120 | continue; | |
| 74 | 121 | } | |
| 77 | 124 | for (const block of blocks) { | |
| 78 | 125 | if (block.type !== "tool_result") continue; | |
| 79 | 126 | 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 | + | }); | |
| 81 | 132 | } | |
| 82 | 133 | const parts: Json[] = []; | |
| 83 | 134 | for (const block of blocks) { | |
| 92 | 143 | } | |
| 93 | 144 | ||
| 94 | 145 | 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; | |
| 97 | 151 | if (!dialect.official && request.temperature != null) body.temperature = request.temperature; | |
| 98 | 152 | if (!dialect.official && request.top_p != null) body.top_p = request.top_p; | |
| 99 | 153 | if (request.stop_sequences?.length) body.stop = request.stop_sequences.slice(0, 4); | |
| 106 | 160 | function: { name: tool.name, description: tool.description ?? "", parameters: tool.input_schema }, | |
| 107 | 161 | })); | |
| 108 | 162 | 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"; | |
| 110 | 164 | else if (choice?.type === "tool" && choice.name) body.tool_choice = { type: "function", function: { name: choice.name } }; | |
| 111 | 165 | else if (choice?.type === "none") body.tool_choice = "none"; | |
| 112 | 166 | } | |
| 137 | 191 | if (typeof message.content === "string" && message.content) content.push({ type: "text", text: message.content }); | |
| 138 | 192 | for (const call of (message.tool_calls as Json[] | undefined) ?? []) { | |
| 139 | 193 | 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 | + | }); | |
| 141 | 200 | } | |
| 142 | 201 | const usage = (completion.usage as Json | undefined) ?? {}; | |
| 143 | 202 | return { | |
| 227 | 286 | this.open = { kind: "tool", slot }; | |
| 228 | 287 | out += event("content_block_start", { | |
| 229 | 288 | 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 | + | }, | |
| 231 | 295 | }); | |
| 232 | 296 | } | |
| 233 | 297 | if (typeof fn.arguments === "string" && fn.arguments) { |
| 5 | 5 | ||
| 6 | 6 | import { presentedToken, upstreamRequest } from "./route.ts"; | |
| 7 | 7 | ||
| 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" }; | |
| 9 | 9 | const hosted = { AI_GATEWAY_ID: "g1t", CLOUDFLARE_ACCOUNT_ID: "acct", AI_GATEWAY_TOKEN: "gw-token" }; | |
| 10 | 10 | ||
| 11 | 11 | function incoming(): Headers { |
| 66 | 66 | } | |
| 67 | 67 | const key = upstream.apiKey; | |
| 68 | 68 | 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); | |
| 71 | 71 | } | |
| 72 | 72 | if (upstream.gatewayToken) headers.set("cf-aig-authorization", `Bearer ${upstream.gatewayToken}`); | |
| 73 | 73 | const base = (upstream.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, ""); |