g1t/apps/web/app/routes/workspace/integrations.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Integrations: your own model provider, alerts that open issues, tickets agents read | 1 | import { |
| 2 | AlertTriangle, | |
| 3 | Bot, | |
| 4 | CheckCircle2, | |
| 5 | ChevronRight, | |
| 6 | Cpu, | |
| 7 | Plug, | |
| 8 | Siren, | |
| 9 | Ticket, | |
| 10 | Webhook, | |
| 11 | X, | |
| 12 | } from "lucide-react"; | |
| Models per workspace: several providers, routed by kind of work | 13 | import { env } from "cloudflare:workers"; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 14 | import type { ReactNode } from "react"; |
| 15 | import { Form, Link, useNavigation } from "react-router"; | |
| 16 | ||
| 17 | import { | |
| 18 | type Connection, | |
| 19 | type ConnectionConfig, | |
| 20 | type Delivery, | |
| Models per workspace: several providers, routed by kind of work | 21 | MODEL_TASKS, |
| 22 | type ModelRoute, | |
| 23 | type ModelTask, | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 24 | type Provider, |
| 25 | type ProviderKind, | |
| 26 | PROVIDERS, | |
| 27 | } from "@g1t/contracts"; | |
| 28 | ||
| 29 | import type { Route } from "./+types/integrations"; | |
| 30 | import { Button, CopyLine, ErrorText, Field, Input, Pill, TimeAgo } from "../../components/ui"; | |
| 31 | import { billing, integrations, repos } from "../../lib/services.server"; | |
| 32 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 33 | ||
| 34 | export function meta({ params }: Route.MetaArgs) { | |
| 35 | return [{ title: `Integrations · ${params.owner} · g1t` }]; | |
| 36 | } | |
| 37 | ||
| 38 | const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS; | |
| 39 | ||
| 40 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 41 | const viewer = getViewer(context); | |
| 42 | const role = roleIn(viewer, params.owner); | |
| 43 | if (!role) throw new Response(null, { status: 404 }); | |
| 44 | const slug = params.owner.toLowerCase(); | |
| Models per workspace: several providers, routed by kind of work | 45 | const [connections, listed, account, access, routes] = await Promise.all([ |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 46 | integrations.list(slug, viewer), |
| 47 | repos.list(viewer, { namespace: slug }), | |
| 48 | billing.account(slug, viewer), | |
| Models per workspace: several providers, routed by kind of work | 49 | env.RUNNER.modelAccess(slug), |
| 50 | integrations.routes(slug, viewer), | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 51 | ]); |
| 52 | const all = unwrap(connections); | |
| 53 | // What each alert source has sent lately, to see it is wired up. | |
| 54 | const deliveries: Record<string, Delivery[]> = {}; | |
| 55 | await Promise.all( | |
| 56 | all | |
| 57 | .filter((connection) => connection.kind === "alerts") | |
| 58 | .map(async (connection) => { | |
| 59 | const found = await integrations.deliveries(slug, viewer, connection.id); | |
| 60 | deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : []; | |
| 61 | }), | |
| 62 | ); | |
| 63 | const adding = new URL(request.url).searchParams.get("add"); | |
| 64 | return { | |
| 65 | slug, | |
| 66 | role, | |
| 67 | connections: all, | |
| 68 | deliveries, | |
| 69 | repos: listed.map((repo) => `${repo.namespace}/${repo.name}`), | |
| 70 | adding: isProvider(adding) ? adding : null, | |
| Models per workspace: several providers, routed by kind of work | 71 | hostedOpen: access.hosted, |
| 72 | routes: routes.ok ? routes.value : [], | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 73 | feeMicros: account.ok ? account.value.orchestrationFeeMicros : 100_000, |
| 74 | marginPercent: account.ok ? account.value.marginPercent : 20, | |
| 75 | }; | |
| 76 | } | |
| 77 | ||
| 78 | function text(form: FormData, name: string): string | undefined { | |
| 79 | const value = String(form.get(name) ?? "").trim(); | |
| 80 | return value || undefined; | |
| 81 | } | |
| 82 | ||
| 83 | /** The settings a form describes, for the provider it is for. */ | |
| 84 | function configFrom(form: FormData): ConnectionConfig { | |
| 85 | const keys = text(form, "keys"); | |
| 86 | return { | |
| 87 | repo: text(form, "repo"), | |
| 88 | assign: form.get("assign") === "on", | |
| 89 | label: text(form, "label"), | |
| 90 | writeBack: form.get("writeBack") !== "off", | |
| 91 | organization: text(form, "organization"), | |
| 92 | site: text(form, "site"), | |
| 93 | email: text(form, "email"), | |
| 94 | keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [], | |
| 95 | baseUrl: text(form, "baseUrl"), | |
| 96 | authHeader: text(form, "authHeader"), | |
| 97 | model: text(form, "model"), | |
| 98 | }; | |
| 99 | } | |
| 100 | ||
| 101 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 102 | assertSameOrigin(request); | |
| 103 | const user = requireUser(context, request); | |
| 104 | const slug = params.owner.toLowerCase(); | |
| 105 | const form = await request.formData(); | |
| 106 | const intent = form.get("intent"); | |
| 107 | const id = String(form.get("id") ?? ""); | |
| 108 | if (intent === "disconnect") { | |
| 109 | const removed = await integrations.disconnect(user, slug, id); | |
| 110 | return { error: removed.ok ? null : removed.error.message }; | |
| 111 | } | |
| 112 | if (intent === "test") { | |
| 113 | const tested = await integrations.test(user, slug, id); | |
| 114 | return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message }; | |
| 115 | } | |
| Models per workspace: several providers, routed by kind of work | 116 | if (intent === "routes") { |
| 117 | const routes: ModelRoute[] = []; | |
| 118 | for (const task of MODEL_TASKS) { | |
| 119 | const choice = String(form.get(`route-${task}`) ?? ""); | |
| 120 | if (!choice) continue; | |
| 121 | if (choice === "g1t") routes.push({ task, connectionId: null, model: null }); | |
| 122 | else { | |
| 123 | const [connectionId, model] = choice.split("::"); | |
| 124 | routes.push({ task, connectionId, model: model || null }); | |
| 125 | } | |
| 126 | } | |
| 127 | const saved = await integrations.setRoutes(user, slug, routes); | |
| 128 | return saved.ok ? { routed: true } : { error: saved.error.message }; | |
| 129 | } | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 130 | if (intent === "update") { |
| 131 | const updated = await integrations.update(user, slug, id, { | |
| 132 | signingSecret: text(form, "signingSecret"), | |
| 133 | secret: text(form, "secret"), | |
| 134 | }); | |
| 135 | return { error: updated.ok ? null : updated.error.message }; | |
| 136 | } | |
| 137 | const provider = String(form.get("provider") ?? ""); | |
| 138 | if (!isProvider(provider)) return { error: "Choose what to connect." }; | |
| 139 | const connected = await integrations.connect(user, slug, { | |
| 140 | provider, | |
| 141 | name: text(form, "name"), | |
| 142 | config: configFrom(form), | |
| 143 | secret: text(form, "secret"), | |
| 144 | signingSecret: text(form, "signingSecret"), | |
| 145 | }); | |
| 146 | return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider }; | |
| 147 | } | |
| 148 | ||
| 149 | const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = { | |
| 150 | models: { | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 151 | title: "Model providers", |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 152 | icon: <Cpu size={16} />, |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 153 | blurb: "Connect as many as you use, then choose which model does which work, and so who pays for it.", |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 154 | }, |
| 155 | alerts: { | |
| 156 | title: "Alerts", | |
| 157 | icon: <Siren size={16} />, | |
| 158 | blurb: "Problems your monitoring finds become issues, and agents can start on them before anyone looks.", | |
| 159 | }, | |
| 160 | tracker: { | |
| 161 | title: "Trackers", | |
| 162 | icon: <Ticket size={16} />, | |
| 163 | blurb: "Tickets agents can read when work mentions them, that people can import as issues, and that hear back when the work lands.", | |
| 164 | }, | |
| 165 | }; | |
| 166 | ||
| 167 | const PROVIDER_BLURB: Record<Provider, string> = { | |
| Models per workspace: several providers, routed by kind of work | 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.", | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 173 | sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.", |
| 174 | datadog: "Monitors that trigger open issues. Recoveries are noted on them.", | |
| 175 | webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.", | |
| 176 | jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.", | |
| 177 | linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.", | |
| 178 | }; | |
| 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, | |
| Models per workspace: several providers, routed by kind of work | 184 | openai: 160, |
| 185 | gemini: 230, | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 186 | anthropic_endpoint: 280, |
| Models per workspace: several providers, routed by kind of work | 187 | openai_endpoint: 140, |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 188 | sentry: 300, |
| 189 | datadog: 290, | |
| 190 | webhook: 200, | |
| 191 | jira: 250, | |
| 192 | linear: 265, | |
| 193 | }; | |
| 194 | const icon = | |
| Models per workspace: several providers, routed by kind of work | 195 | provider === "webhook" ? ( |
| 196 | <Webhook size={size * 0.5} /> | |
| 197 | ) : provider === "anthropic_endpoint" || provider === "openai_endpoint" ? ( | |
| 198 | <Bot size={size * 0.5} /> | |
| 199 | ) : null; | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 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 | ||
| 218 | function dollars(micros: number): string { | |
| 219 | return `$${(micros / 1_000_000).toFixed(2)}`; | |
| 220 | } | |
| 221 | ||
| 222 | export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) { | |
| Models per workspace: several providers, routed by kind of work | 223 | const { slug, role, connections, deliveries, repos: repoNames, adding, feeMicros, marginPercent, hostedOpen, routes } = |
| 224 | loaderData; | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 225 | const owner = role === "owner"; |
| 226 | const busy = useNavigation().state === "submitting"; | |
| Models per workspace: several providers, routed by kind of work | 227 | const modelConnections = connections.filter((connection) => connection.kind === "models"); |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 228 | const justConnected = actionData && "connected" in actionData ? actionData.connected : null; |
| 229 | const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null; | |
| 230 | const error = (actionData && "error" in actionData ? actionData.error : null) ?? null; | |
| 231 | ||
| 232 | 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 | ||
| 247 | {justConnected && <Connected connected={justConnected} />} | |
| 248 | <div className="mt-4"> | |
| 249 | <ErrorText>{error}</ErrorText> | |
| 250 | </div> | |
| 251 | ||
| 252 | {/* Models -------------------------------------------------------------- */} | |
| 253 | <Section kind="models"> | |
| Models per workspace: several providers, routed by kind of work | 254 | {!hostedOpen && modelConnections.length === 0 && ( |
| 255 | <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4"> | |
| 256 | <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30"> | |
| 257 | <AlertTriangle size={16} /> | |
| 258 | </span> | |
| 259 | <p className="text-sm text-muted"> | |
| 260 | <span className="font-medium text-fg">Choose how your agents reach a model.</span> g1t's hosted models | |
| 261 | are not open to {slug} yet. Connect a provider of your own below and your agents start at once, | |
| 262 | billed by that provider. | |
| 263 | </p> | |
| 264 | </div> | |
| 265 | )} | |
| 266 | <Connections list={modelConnections} owner={owner} busy={busy} tested={tested} deliveries={deliveries} /> | |
| 267 | <Routing | |
| 268 | connections={modelConnections} | |
| 269 | routes={routes} | |
| 270 | hostedOpen={hostedOpen} | |
| 271 | marginPercent={marginPercent} | |
| 272 | owner={owner} | |
| 273 | busy={busy} | |
| 274 | saved={actionData != null && "routed" in actionData} | |
| 275 | /> | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 276 | <p className="mt-3 text-xs text-faint"> |
| Models per workspace: several providers, routed by kind of work | 277 | On your own providers, their bills are yours and g1t charges {dollars(feeMicros)} a run for the |
| 278 | sandbox and orchestration. Keys go only from g1t's model proxy to the provider: the agent's | |
| 279 | sandbox holds a token that dies with the run. | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 280 | </p> |
| Models per workspace: several providers, routed by kind of work | 281 | {owner && <Choices kind="models" slug={slug} adding={adding} />} |
| Integrations: connect forms open under the tile that was chosen | 282 | {adding && PROVIDERS[adding].kind === "models" && owner && ( |
| 283 | <AddForm | |
| 284 | provider={adding} | |
| 285 | slug={slug} | |
| 286 | repos={repoNames} | |
| 287 | busy={busy} | |
| 288 | error={actionData && "provider" in actionData ? error : null} | |
| 289 | /> | |
| 290 | )} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 291 | </Section> |
| 292 | ||
| 293 | {/* Alerts -------------------------------------------------------------- */} | |
| 294 | <Section kind="alerts"> | |
| 295 | <Connections | |
| 296 | list={connections.filter((c) => c.kind === "alerts")} | |
| 297 | owner={owner} | |
| 298 | busy={busy} | |
| 299 | tested={tested} | |
| 300 | deliveries={deliveries} | |
| 301 | /> | |
| 302 | {owner && <Choices kind="alerts" slug={slug} adding={adding} />} | |
| Integrations: connect forms open under the tile that was chosen | 303 | {adding && PROVIDERS[adding].kind === "alerts" && owner && ( |
| 304 | <AddForm | |
| 305 | provider={adding} | |
| 306 | slug={slug} | |
| 307 | repos={repoNames} | |
| 308 | busy={busy} | |
| 309 | error={actionData && "provider" in actionData ? error : null} | |
| 310 | /> | |
| 311 | )} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 312 | </Section> |
| 313 | ||
| 314 | {/* Trackers ------------------------------------------------------------ */} | |
| 315 | <Section kind="tracker"> | |
| 316 | <Connections | |
| 317 | list={connections.filter((c) => c.kind === "tracker")} | |
| 318 | owner={owner} | |
| 319 | busy={busy} | |
| 320 | tested={tested} | |
| 321 | deliveries={deliveries} | |
| 322 | /> | |
| 323 | {owner && <Choices kind="tracker" slug={slug} adding={adding} />} | |
| Integrations: connect forms open under the tile that was chosen | 324 | {adding && PROVIDERS[adding].kind === "tracker" && owner && ( |
| 325 | <AddForm | |
| 326 | provider={adding} | |
| 327 | slug={slug} | |
| 328 | repos={repoNames} | |
| 329 | busy={busy} | |
| 330 | error={actionData && "provider" in actionData ? error : null} | |
| 331 | /> | |
| 332 | )} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 333 | </Section> |
| 334 | ||
| 335 | {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>} | |
| 336 | </div> | |
| 337 | ); | |
| 338 | } | |
| 339 | ||
| Models per workspace: several providers, routed by kind of work | 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 | } | |
| 353 | ||
| 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 | ||
| Integrations: your own model provider, alerts that open issues, tickets agents read | 439 | function Section({ kind, children }: { kind: ProviderKind; children: ReactNode }) { |
| 440 | const info = KIND_INFO[kind]; | |
| 441 | return ( | |
| 442 | <section className="mt-10"> | |
| 443 | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 444 | <span className="text-muted">{info.icon}</span> | |
| 445 | {info.title} | |
| 446 | </h3> | |
| 447 | <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p> | |
| 448 | {children} | |
| 449 | </section> | |
| 450 | ); | |
| 451 | } | |
| 452 | ||
| 453 | function Connections({ | |
| 454 | list, | |
| 455 | owner, | |
| 456 | busy, | |
| 457 | tested, | |
| 458 | deliveries, | |
| 459 | }: { | |
| 460 | list: Connection[]; | |
| 461 | owner: boolean; | |
| 462 | busy: boolean; | |
| 463 | tested: { id: string; ok: boolean; message: string } | null; | |
| 464 | deliveries: Record<string, Delivery[]>; | |
| 465 | }) { | |
| 466 | if (list.length === 0) return null; | |
| 467 | return ( | |
| 468 | <ul className="mb-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 469 | {list.map((connection) => ( | |
| 470 | <li key={connection.id} className="p-4"> | |
| 471 | <ConnectionRow | |
| 472 | connection={connection} | |
| 473 | owner={owner} | |
| 474 | busy={busy} | |
| 475 | tested={tested} | |
| 476 | deliveries={deliveries[connection.id] ?? []} | |
| 477 | /> | |
| 478 | </li> | |
| 479 | ))} | |
| 480 | </ul> | |
| 481 | ); | |
| 482 | } | |
| 483 | ||
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 484 | /** Just the host of an address, which is what tells connections apart. */ |
| 485 | function host(url: string | undefined): string | undefined { | |
| 486 | if (!url) return undefined; | |
| 487 | try { | |
| 488 | return new URL(url).host; | |
| 489 | } catch { | |
| 490 | return url; | |
| 491 | } | |
| 492 | } | |
| 493 | ||
| Integrations: your own model provider, alerts that open issues, tickets agents read | 494 | function ConnectionRow({ |
| 495 | connection, | |
| 496 | owner, | |
| 497 | busy, | |
| 498 | tested, | |
| 499 | deliveries, | |
| 500 | }: { | |
| 501 | connection: Connection; | |
| 502 | owner: boolean; | |
| 503 | busy: boolean; | |
| 504 | tested: { id: string; ok: boolean; message: string } | null; | |
| 505 | deliveries: Delivery[]; | |
| 506 | }) { | |
| 507 | const { config } = connection; | |
| 508 | const facts = [ | |
| 509 | PROVIDERS[connection.provider].label, | |
| 510 | config.repo && `issues in ${config.repo}`, | |
| 511 | config.assign && "agents start at once", | |
| 512 | config.organization, | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 513 | host(config.site), |
| 514 | host(config.baseUrl), | |
| Models per workspace: several providers, routed by kind of work | 515 | config.model && `default ${config.model}`, |
| 516 | connection.models.length > 0 && `${connection.models.length} models`, | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 517 | config.keys?.length ? config.keys.join(", ") : null, |
| 518 | connection.secretHint && `key ${connection.secretHint}`, | |
| 519 | ].filter(Boolean); | |
| 520 | const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt; | |
| 521 | return ( | |
| 522 | <div> | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 523 | <div className="flex items-center gap-3"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 524 | <ProviderMark provider={connection.provider} /> |
| 525 | <div className="min-w-0 grow"> | |
| 526 | <p className="truncate text-sm font-medium">{connection.name}</p> | |
| 527 | <p className="truncate text-xs text-muted">{facts.join(" · ")}</p> | |
| 528 | </div> | |
| 529 | {connection.lastUsedAt && ( | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 530 | <span className="hidden shrink-0 text-xs text-faint sm:inline"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 531 | Used <TimeAgo at={connection.lastUsedAt} /> |
| 532 | </span> | |
| 533 | )} | |
| 534 | {owner && ( | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 535 | <Form method="post" className="flex shrink-0 gap-2"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 536 | <input type="hidden" name="id" value={connection.id} /> |
| 537 | <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}> | |
| 538 | Test | |
| 539 | </Button> | |
| 540 | <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect"> | |
| 541 | <X size={14} /> | |
| 542 | </Button> | |
| 543 | </Form> | |
| 544 | )} | |
| 545 | </div> | |
| 546 | {tested?.id === connection.id && ( | |
| 547 | <p className={`mt-3 text-sm ${tested.ok ? "text-accent" : "text-danger"}`}>{tested.message}</p> | |
| 548 | )} | |
| 549 | {connection.lastError && ( | |
| 550 | <p className="mt-3 flex items-start gap-2 text-xs text-warn"> | |
| 551 | <AlertTriangle size={13} className="mt-0.5 shrink-0" /> | |
| 552 | {connection.lastError} | |
| 553 | </p> | |
| 554 | )} | |
| 555 | {connection.webhookUrl && ( | |
| 556 | <div className="mt-3"> | |
| 557 | <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p> | |
| 558 | <CopyLine text={connection.webhookUrl} /> | |
| 559 | </div> | |
| 560 | )} | |
| 561 | {owner && waitingForSecret && ( | |
| 562 | <Form method="post" className="mt-3 flex flex-wrap items-end gap-2"> | |
| 563 | <input type="hidden" name="intent" value="update" /> | |
| 564 | <input type="hidden" name="id" value={connection.id} /> | |
| 565 | <div className="min-w-64 grow"> | |
| 566 | <Field label="Client secret" hint="From the internal integration's page in Sentry, once it has this address. Leave it if it is already saved."> | |
| 567 | <Input name="signingSecret" type="password" placeholder="Paste to replace" /> | |
| 568 | </Field> | |
| 569 | </div> | |
| 570 | <Button type="submit" variant="quiet" disabled={busy}> | |
| 571 | Save | |
| 572 | </Button> | |
| 573 | </Form> | |
| 574 | )} | |
| 575 | {deliveries.length > 0 && ( | |
| 576 | <ul className="mt-3 space-y-1 text-xs"> | |
| 577 | {deliveries.map((delivery) => ( | |
| 578 | <li key={delivery.id} className="flex items-center gap-2 text-muted"> | |
| 579 | <span | |
| 580 | className={`size-1.5 shrink-0 rounded-full ${ | |
| 581 | delivery.outcome === "refused" | |
| 582 | ? "bg-danger" | |
| 583 | : delivery.outcome === "ignored" | |
| 584 | ? "bg-faint" | |
| 585 | : "bg-accent" | |
| 586 | }`} | |
| 587 | /> | |
| 588 | <span className="font-mono text-faint">{delivery.event}</span> | |
| 589 | <span className="min-w-0 truncate">{delivery.detail}</span> | |
| 590 | {delivery.issue && ( | |
| 591 | <Link | |
| 592 | to={`/${delivery.issue.replace("#", "/issues/")}`} | |
| 593 | className="shrink-0 font-mono text-fg hover:underline" | |
| 594 | > | |
| 595 | {delivery.issue} | |
| 596 | </Link> | |
| 597 | )} | |
| 598 | <span className="ml-auto shrink-0 text-faint"> | |
| 599 | <TimeAgo at={delivery.receivedAt} /> | |
| 600 | </span> | |
| 601 | </li> | |
| 602 | ))} | |
| 603 | </ul> | |
| 604 | )} | |
| 605 | </div> | |
| 606 | ); | |
| 607 | } | |
| 608 | ||
| 609 | function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) { | |
| 610 | const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind); | |
| 611 | 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 | ))} | |
| 632 | </div> | |
| 633 | ); | |
| 634 | } | |
| 635 | ||
| 636 | function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) { | |
| 637 | return ( | |
| 638 | <Field label="Repository" hint={hint}> | |
| 639 | <select | |
| 640 | name="repo" | |
| 641 | required={required} | |
| 642 | defaultValue={repos[0] ?? ""} | |
| 643 | 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" | |
| 644 | > | |
| 645 | {!required && <option value="">None</option>} | |
| 646 | {repos.map((repo) => ( | |
| 647 | <option key={repo} value={repo}> | |
| 648 | {repo} | |
| 649 | </option> | |
| 650 | ))} | |
| 651 | </select> | |
| 652 | </Field> | |
| 653 | ); | |
| 654 | } | |
| 655 | ||
| 656 | function AssignField() { | |
| 657 | return ( | |
| 658 | <label className="flex items-start gap-2.5 text-sm"> | |
| 659 | <input type="checkbox" name="assign" className="mt-1 accent-[var(--color-accent)]" /> | |
| 660 | <span> | |
| 661 | <span className="font-medium">Put a g1t agent on each new issue</span> | |
| 662 | <span className="block text-xs text-muted"> | |
| 663 | It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has | |
| 664 | to look. Agents run only where g1t agents are enabled. | |
| 665 | </span> | |
| 666 | </span> | |
| 667 | </label> | |
| 668 | ); | |
| 669 | } | |
| 670 | ||
| 671 | function AddForm({ | |
| 672 | provider, | |
| 673 | slug, | |
| 674 | repos, | |
| 675 | busy, | |
| 676 | error, | |
| 677 | }: { | |
| 678 | provider: Provider; | |
| 679 | slug: string; | |
| 680 | repos: string[]; | |
| 681 | busy: boolean; | |
| 682 | error: string | null; | |
| 683 | }) { | |
| 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 | ), | |
| Models per workspace: several providers, routed by kind of work | 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> | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 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> | |
| Models per workspace: several providers, routed by kind of work | 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 | ), | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 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> | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 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> | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 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 | ), | |
| 754 | sentry: ( | |
| 755 | <> | |
| 756 | <Field label="Organization" hint="Its slug, as in acme.sentry.io."> | |
| 757 | <Input name="organization" required placeholder="acme" /> | |
| 758 | </Field> | |
| 759 | <RepoField repos={repos} required hint="Where issues are opened." /> | |
| 760 | <Field label="Auth token" hint="The internal integration's token, with Issue & Event read and write. Lets g1t read stack traces and resolve issues. Optional, but recommended."> | |
| 761 | <Input name="secret" type="password" /> | |
| 762 | </Field> | |
| 763 | <Field label="Client secret" hint="You get this from Sentry after giving it this connection's address; you can add it on the next step."> | |
| 764 | <Input name="signingSecret" type="password" /> | |
| 765 | </Field> | |
| 766 | <AssignField /> | |
| 767 | </> | |
| 768 | ), | |
| 769 | datadog: ( | |
| 770 | <> | |
| 771 | <RepoField repos={repos} required hint="Where issues are opened." /> | |
| 772 | <AssignField /> | |
| 773 | </> | |
| 774 | ), | |
| 775 | webhook: ( | |
| 776 | <> | |
| 777 | <RepoField repos={repos} required hint="Where issues are opened." /> | |
| 778 | <AssignField /> | |
| 779 | </> | |
| 780 | ), | |
| 781 | jira: ( | |
| 782 | <> | |
| 783 | <Field label="Site" hint="Your Jira's address."> | |
| 784 | <Input name="site" type="url" required placeholder="https://acme.atlassian.net" /> | |
| 785 | </Field> | |
| 786 | <Field label="Email" hint="The account the API token belongs to."> | |
| 787 | <Input name="email" type="email" required autoComplete="off" /> | |
| 788 | </Field> | |
| 789 | <Field label="API token" hint="From id.atlassian.com → Security → API tokens."> | |
| 790 | <Input name="secret" type="password" required /> | |
| 791 | </Field> | |
| 792 | <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key."> | |
| 793 | <Input name="keys" placeholder="TECH, OPS" /> | |
| 794 | </Field> | |
| 795 | </> | |
| 796 | ), | |
| 797 | linear: ( | |
| 798 | <> | |
| 799 | <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys."> | |
| 800 | <Input name="secret" type="password" required placeholder="lin_api_…" /> | |
| 801 | </Field> | |
| 802 | <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key."> | |
| 803 | <Input name="keys" placeholder="ENG" /> | |
| 804 | </Field> | |
| 805 | </> | |
| 806 | ), | |
| 807 | }; | |
| 808 | return ( | |
| Integrations: connect forms open under the tile that was chosen | 809 | <section id="add" className="mt-4 scroll-mt-20 rounded-xl border border-line bg-surface p-5"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 810 | <div className="flex items-center gap-3"> |
| 811 | <ProviderMark provider={provider} /> | |
| 812 | <div className="grow"> | |
| Integrations: connect forms open under the tile that was chosen | 813 | <h3 className="font-medium"> |
| Models per workspace: several providers, routed by kind of work | 814 | Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label} |
| Integrations: connect forms open under the tile that was chosen | 815 | </h3> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 816 | <p className="text-xs text-muted">{PROVIDER_BLURB[provider]}</p> |
| 817 | </div> | |
| 818 | <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close"> | |
| 819 | <X size={16} /> | |
| 820 | </Link> | |
| 821 | </div> | |
| 822 | <Form method="post" className="mt-5 grid max-w-xl gap-4"> | |
| 823 | <input type="hidden" name="provider" value={provider} /> | |
| 824 | {fields[provider]} | |
| 825 | <ErrorText>{error}</ErrorText> | |
| 826 | <div> | |
| 827 | <Button type="submit" disabled={busy}> | |
| 828 | {busy ? "Connecting…" : "Connect"} | |
| 829 | </Button> | |
| 830 | </div> | |
| 831 | </Form> | |
| 832 | </section> | |
| 833 | ); | |
| 834 | } | |
| 835 | ||
| 836 | /** What to do next, right after connecting: shown once. */ | |
| 837 | function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) { | |
| 838 | const { connection, signingSecret } = connected; | |
| 839 | const url = connection.webhookUrl; | |
| 840 | return ( | |
| 841 | <div className="mt-6 rounded-xl border border-accent-dim/60 bg-accent/5 p-5"> | |
| 842 | <p className="flex items-center gap-2 font-medium"> | |
| 843 | <CheckCircle2 size={16} className="text-accent" /> | |
| 844 | {connection.name} is connected. | |
| 845 | </p> | |
| 846 | {connection.provider === "sentry" && url && ( | |
| 847 | <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted"> | |
| 848 | <li> | |
| 849 | In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal | |
| 850 | integration. | |
| 851 | </li> | |
| 852 | <li> | |
| 853 | Set its webhook URL to <CopyLine text={url} /> | |
| 854 | </li> | |
| 855 | <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li> | |
| 856 | <li>Give it Issue & Event read and write, save, and paste its client secret below, on the connection.</li> | |
| 857 | </ol> | |
| 858 | )} | |
| 859 | {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && ( | |
| 860 | <div className="mt-3 space-y-3 text-sm text-muted"> | |
| 861 | <p>This secret is shown once. Copy it now.</p> | |
| 862 | <CopyLine text={signingSecret} /> | |
| 863 | {connection.provider === "datadog" ? ( | |
| 864 | <> | |
| 865 | <p> | |
| 866 | In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL: | |
| 867 | </p> | |
| 868 | <CopyLine text={url} /> | |
| 869 | <p>Custom headers:</p> | |
| 870 | <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} /> | |
| 871 | <p>Payload:</p> | |
| 872 | <CopyLine | |
| 873 | text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`} | |
| 874 | /> | |
| 875 | <p> | |
| 876 | Then mention <code>@webhook-g1t</code> in any monitor's message. | |
| 877 | </p> | |
| 878 | </> | |
| 879 | ) : ( | |
| 880 | <> | |
| 881 | <p>Send JSON with at least a title to:</p> | |
| 882 | <CopyLine text={url} /> | |
| 883 | <p> | |
| 884 | with <code>Authorization: Bearer <secret></code>, or an HMAC-SHA256 of the body in{" "} | |
| 885 | <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue. | |
| 886 | </p> | |
| 887 | </> | |
| 888 | )} | |
| 889 | </div> | |
| 890 | )} | |
| 891 | {connection.kind === "tracker" && ( | |
| 892 | <p className="mt-2 text-sm text-muted"> | |
| 893 | Agents now read tickets that work mentions, and you can import one from any repository's new | |
| 894 | issue page. Use Test to check the token. | |
| 895 | </p> | |
| 896 | )} | |
| 897 | {connection.kind === "models" && ( | |
| 898 | <p className="mt-2 text-sm text-muted"> | |
| Models per workspace: several providers, routed by kind of work | 899 | {connection.lastError |
| 900 | ? `It was saved, but the check failed: ${connection.lastError}` | |
| 901 | : connection.models.length > 0 | |
| 902 | ? `It offers ${connection.models.length} models. Choose which work goes to it under “Which model does which work”.` | |
| 903 | : "Choose which work goes to it under “Which model does which work”."} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 904 | </p> |
| 905 | )} | |
| 906 | </div> | |
| 907 | ); | |
| 908 | } |