The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.
An issue can no longer be assigned or delegated to g1t, a pull request no longer has an agent panel, messages to the agent, agent reviews or "Resolve with g1t", and a security alert no longer offers "Fix with g1t". Direct messages are between people, an agent's old messages still show under its saved name or as a deleted agent, and a team's lead is a person. The landing page, product tour, pricing (no agent rate or model meters), Home, Code's overview and llms.txt describe connecting your own coding agent instead. What stays: a pull request's Session tab and the repository's Sessions list, fed by the sessions your agent records; Memory, for your agent's recall and remember, now without the review queue; overlaps, the merge queue, required checks and auto-merge; and the setup snippets for connecting a coding agent over MCP. Sudo drops its Models page and model catalogue.
| 281 | 281 | if (!allowances) return lines; | |
| 282 | 282 | if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`); | |
| 283 | 283 | if (allowances.trialMicros != null) lines.push(`Trial: ${usd(allowances.trialMicros)}, outside the monthly pool`); | |
| 284 | − | if (allowances.maxConcurrentAgents != null) { | |
| 285 | − | lines.push(`${allowances.maxConcurrentAgents} agent${allowances.maxConcurrentAgents === 1 ? "" : "s"} at once`); | |
| 286 | − | } | |
| 287 | − | if (allowances.runCapMicros != null) lines.push(`Run cap: ${usd(allowances.runCapMicros)} a run`); | |
| 288 | − | if (allowances.issueCapMicros != null) lines.push(`Issue cap: ${usd(allowances.issueCapMicros)} an issue`); | |
| 289 | 284 | if (allowances.auditRetentionDays != null) lines.push(`Audit log: ${allowances.auditRetentionDays} days, in place of the plan's`); | |
| 290 | 285 | if (allowances.hold) lines.push(`Held: ${allowances.hold}`); | |
| 291 | 286 | return lines; | |
| ⋯ | |||
| 293 | 288 | ||
| 294 | 289 | /** | |
| 295 | 290 | * The g1t plan without its price, the account's share of g1t's pools, and | |
| 296 | − | * staff's overrides of what owners set: agents at once, the run and issue | |
| 297 | − | * caps, the days of audit log kept (such as for an organization that pays | |
| 291 | + | * staff's overrides of what owners set: the days of audit log kept (such as for an organization that pays | |
| 298 | 292 | * for longer), and a hold on new compute. Accounts on a 100% discount have the plan | |
| 299 | 293 | * anyway. | |
| 300 | 294 | */ | |
| ⋯ | |||
| 354 | 348 | </Field> | |
| 355 | 349 | <Field label="Trial $" hint="Each workspace, outside the monthly pool. Blank: the default ($5)."> | |
| 356 | 350 | <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} /> | |
| 357 | − | </Field> | |
| 358 | − | <Field label="Agents at once" hint="Blank: the plan's (2 in the first month or on the trial, then 10)."> | |
| 359 | − | <Input | |
| 360 | − | name="agents" | |
| 361 | − | inputMode="numeric" | |
| 362 | − | pattern="\d{1,3}" | |
| 363 | − | placeholder="Default" | |
| 364 | − | defaultValue={values?.agents ?? (current.maxConcurrentAgents != null ? String(current.maxConcurrentAgents) : "")} | |
| 365 | − | /> | |
| 366 | − | </Field> | |
| 367 | − | <Field label="Run cap $" hint="One run's spend, over the owners'. Blank: theirs, or $2."> | |
| 368 | − | <Input name="runCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.runCap ?? dollarsField(current.runCapMicros)} /> | |
| 369 | − | </Field> | |
| 370 | − | <Field label="Issue cap $" hint="One issue's agents in all, over the owners'. Blank: theirs, or $10."> | |
| 371 | − | <Input name="issueCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.issueCap ?? dollarsField(current.issueCapMicros)} /> | |
| 372 | 351 | </Field> | |
| 373 | 352 | <Field label="Audit log days" hint="Kept, in place of the plan's, longer or shorter, up to 400. Blank: the plan's (7 free, 90 on the plan)."> | |
| 374 | 353 | <Input | |
| 8 | 8 | */ | |
| 9 | 9 | import { | |
| 10 | 10 | Activity, | |
| 11 | − | Bot, | |
| 12 | 11 | Building2, | |
| 13 | 12 | Boxes, | |
| 14 | 13 | ChevronRight, | |
| ⋯ | |||
| 61 | 60 | usage: Gauge, | |
| 62 | 61 | stripe: CreditCard, | |
| 63 | 62 | costs: Cloud, | |
| 64 | − | agents: Bot, | |
| 65 | 63 | abuse: ShieldAlert, | |
| 66 | 64 | incidents: Siren, | |
| 67 | 65 | inbox: Inbox, | |
| 48 | 48 | ||
| 49 | 49 | if (intent === "allowances") { | |
| 50 | 50 | // The plan without its price, the account's share of g1t's pools, and staff's overrides. | |
| 51 | − | const values = fields(form, "plan", "oss", "trial", "agents", "runCap", "issueCap", "hold", "note"); | |
| 51 | + | const values = fields(form, "plan", "oss", "trial", "hold", "note"); | |
| 52 | 52 | if (subject.kind === "workspace" && subject.billedTo) { | |
| 53 | 53 | return failed("allowances", `This workspace is on ${subject.billedTo.name}. Set its plan and pools on the enterprise.`, values); | |
| 54 | 54 | } |
| 16 | 16 | settings: "Guardrails saved. They apply from the next run.", | |
| 17 | 17 | mapping: "Mapping saved. It applies from the next run; read the bill now to see it.", | |
| 18 | 18 | removed: "Mapping removed.", | |
| 19 | − | lifted: "Breaker lifted for the rest of today (UTC). Hosted-model runs start again; it is recorded in the audit log.", | |
| 19 | + | lifted: "Breaker lifted for the rest of today (UTC). Paused work starts again; it is recorded in the audit log.", | |
| 20 | 20 | paused: "Paused across g1t. Every service sees it within 30 seconds; it is recorded in the audit log.", | |
| 21 | 21 | resumed: "Resumed across g1t. Every service sees it within 30 seconds; it is recorded in the audit log.", | |
| 22 | 22 | }; |
| 128 | 128 | export function versionCells(v: { costMicros: number; priceMicros: number; basis?: string }): { cost: string; price: string; note: string | null } { | |
| 129 | 129 | if (v.basis === "weight") { | |
| 130 | 130 | const weight = Number((v.costMicros / 1_000_000).toFixed(6)); | |
| 131 | − | return { cost: "—", price: `×${weight}`, note: "A weight on the agent rate's tokens, not money" }; | |
| 131 | + | return { cost: "—", price: `×${weight}`, note: "A weight, not money (retired agent token pricing)" }; | |
| 132 | 132 | } | |
| 133 | 133 | if (v.basis === "rate") return { cost: "—", price: unitDollars(v.priceMicros), note: "g1t's own rate: no cost behind it" }; | |
| 134 | 134 | return { cost: unitDollars(v.costMicros), price: unitDollars(v.priceMicros), note: null }; | |
| ⋯ | |||
| 278 | 278 | const parts: string[] = []; | |
| 279 | 279 | if (caps.tripped) { | |
| 280 | 280 | parts.push( | |
| 281 | − | `the daily breaker is open (${usd(caps.todayMicros)} of ${usd(caps.dailyCapMicros)} today), so new hosted-model agent runs g1t pays for wait until 00:00 UTC`, | |
| 281 | + | `the daily breaker is open (${usd(caps.todayMicros)} of ${usd(caps.dailyCapMicros)} today), so new work g1t pays for waits until 00:00 UTC`, | |
| 282 | 282 | ); | |
| 283 | 283 | } | |
| 284 | 284 | for (const budget of caps.comped) { | |
| ⋯ | |||
| 327 | 327 | ||
| 328 | 328 | /** What each level of the platform pause stops, for the page and the banner. */ | |
| 329 | 329 | export const PAUSE_LEVELS: { level: PauseLevel; title: string; stops: string }[] = [ | |
| 330 | − | { level: "compute", title: "Compute", stops: "New agent runs, checks, workflow jobs and deploy builds, for every workspace. Runs already going finish." }, | |
| 331 | − | { level: "schedules", title: "Schedules", stops: "Actions' cron-triggered runs, and the runner's sweep that starts queued agents." }, | |
| 330 | + | { level: "compute", title: "Compute", stops: "New checks, workflow jobs and deploy builds, for every workspace. Runs already going finish." }, | |
| 331 | + | { level: "schedules", title: "Schedules", stops: "Actions' cron-triggered runs." }, | |
| 332 | 332 | { level: "indexing", title: "Indexing", stops: "Context embeddings and backfills, and search's backfills (they go on from where they were when resumed)." }, | |
| 333 | 333 | { level: "renders", title: "Renders", stops: "Social card images: a cache miss gets the brand card or the static logo." }, | |
| 334 | 334 | ]; | |
| 136 | 136 | plan: true, | |
| 137 | 137 | ossRepoMicros: 5_000_000, | |
| 138 | 138 | trialMicros: 2_500_000, | |
| 139 | − | maxConcurrentAgents: 20, | |
| 140 | − | runCapMicros: 25_000_000, | |
| 141 | − | issueCapMicros: 500_000_000, | |
| 139 | + | // Retired with g1t's own agents: always cleared, whatever an old form sends. | |
| 140 | + | maxConcurrentAgents: null, | |
| 141 | + | runCapMicros: null, | |
| 142 | + | issueCapMicros: null, | |
| 142 | 143 | auditRetentionDays: 365, | |
| 143 | 144 | hold: "Card disputed; waiting on the bank", | |
| 144 | 145 | }, | |
| ⋯ | |||
| 146 | 147 | ); | |
| 147 | 148 | assert.equal(parseAllowances(form({ oss: "lots" })).ok, false); | |
| 148 | 149 | assert.equal(parseAllowances(form({ trial: "5000" })).ok, false); | |
| 149 | − | assert.equal(parseAllowances(form({ agents: "0" })).ok, false); | |
| 150 | − | assert.equal(parseAllowances(form({ agents: "101" })).ok, false); | |
| 151 | − | assert.equal(parseAllowances(form({ agents: "2.5" })).ok, false); | |
| 152 | − | assert.equal(parseAllowances(form({ runCap: "0.05" })).ok, false); | |
| 153 | − | assert.equal(parseAllowances(form({ runCap: "1000.01" })).ok, false); | |
| 154 | − | assert.equal(parseAllowances(form({ issueCap: "10000.01" })).ok, false); | |
| 155 | 150 | // Audit log days: shorter or longer than the plan's, up to 400. | |
| 156 | 151 | assert.equal((parseAllowances(form({ auditDays: "3" })) as { value: { auditRetentionDays: number } }).value.auditRetentionDays, 3); | |
| 157 | 152 | assert.equal((parseAllowances(form({ auditDays: "400" })) as { value: { auditRetentionDays: number } }).value.auditRetentionDays, 400); | |
| 179 | 179 | ||
| 180 | 180 | /** The most staff can set an account's share of a pool to, against a slipped finger. */ | |
| 181 | 181 | export const MAX_POOL_SHARE_MICROS = 1_000 * MICROS_PER_DOLLAR; | |
| 182 | − | /** The most agents at once staff can allow one account. */ | |
| 183 | − | export const MAX_AGENTS_AT_ONCE = 100; | |
| 184 | − | /** Staff's overrides of the owners' caps: one run, and one issue's agents in all. */ | |
| 185 | − | export const MAX_RUN_CAP_MICROS = 1_000 * MICROS_PER_DOLLAR; | |
| 186 | − | export const MAX_ISSUE_CAP_MICROS = 10_000 * MICROS_PER_DOLLAR; | |
| 187 | 182 | /** | |
| 188 | 183 | * The most days of audit log staff can give one account: billing's | |
| 189 | 184 | * AUDIT_MAX_DAYS. The events service deletes anything older for everyone. | |
| 190 | 185 | */ | |
| 191 | 186 | export const MAX_AUDIT_DAYS = 400; | |
| 192 | − | /** The smallest cap: ten cents, as owners may set. */ | |
| 193 | − | const MIN_CAP_MICROS = 100_000; | |
| 194 | 187 | const MAX_HOLD = 200; | |
| 195 | 188 | ||
| 196 | 189 | /** | |
| 197 | 190 | * Allowances from the plan-and-pools form: the g1t plan without its price, | |
| 198 | 191 | * the account's share of the open-source pool and of trials, and staff's | |
| 199 | − | * overrides of agents at once, the run cap, the issue cap and the days of | |
| 200 | − | * audit log kept. A blank amount means the default (for a cap, no | |
| 201 | − | * override; for the audit log, the plan's). A hold is a line saying why | |
| 202 | − | * new compute is held; blank is no hold. | |
| 192 | + | * override of the days of audit log kept. A blank amount means the default | |
| 193 | + | * (for the audit log, the plan's). A hold is a line saying why new compute | |
| 194 | + | * is held; blank is no hold. The agent overrides (agents at once, the run | |
| 195 | + | * and issue caps) are retired with g1t's own agents: they are always sent | |
| 196 | + | * cleared. | |
| 203 | 197 | */ | |
| 204 | 198 | export function parseAllowances(form: FormData): Parsed<Allowances> { | |
| 205 | 199 | const amount = (name: string, what: string, max: number, maxText: string, min = 0): Parsed<number | null> => { | |
| ⋯ | |||
| 215 | 209 | if (!oss.ok) return oss; | |
| 216 | 210 | const trial = amount("trial", "The trial credit", MAX_POOL_SHARE_MICROS, "$1,000"); | |
| 217 | 211 | if (!trial.ok) return trial; | |
| 218 | − | const runCap = amount("runCap", "The run cap", MAX_RUN_CAP_MICROS, "$1,000", MIN_CAP_MICROS); | |
| 219 | − | if (!runCap.ok) return runCap; | |
| 220 | − | const issueCap = amount("issueCap", "The issue cap", MAX_ISSUE_CAP_MICROS, "$10,000", MIN_CAP_MICROS); | |
| 221 | − | if (!issueCap.ok) return issueCap; | |
| 222 | − | ||
| 223 | − | let maxConcurrentAgents: number | null = null; | |
| 224 | − | const rawAgents = text(form, "agents"); | |
| 225 | − | if (rawAgents) { | |
| 226 | − | if (!/^\d{1,3}$/.test(rawAgents) || Number(rawAgents) < 1 || Number(rawAgents) > MAX_AGENTS_AT_ONCE) { | |
| 227 | − | return { ok: false, error: `Agents at once is a whole number from 1 to ${MAX_AGENTS_AT_ONCE}, or blank for the default.` }; | |
| 228 | − | } | |
| 229 | − | maxConcurrentAgents = Number(rawAgents); | |
| 230 | − | } | |
| 231 | − | ||
| 232 | 212 | let auditRetentionDays: number | null = null; | |
| 233 | 213 | const rawAudit = text(form, "auditDays"); | |
| 234 | 214 | if (rawAudit) { | |
| ⋯ | |||
| 247 | 227 | plan: text(form, "plan") === "on", | |
| 248 | 228 | ossRepoMicros: oss.value, | |
| 249 | 229 | trialMicros: trial.value, | |
| 250 | − | maxConcurrentAgents, | |
| 251 | − | runCapMicros: runCap.value, | |
| 252 | − | issueCapMicros: issueCap.value, | |
| 230 | + | maxConcurrentAgents: null, | |
| 231 | + | runCapMicros: null, | |
| 232 | + | issueCapMicros: null, | |
| 253 | 233 | auditRetentionDays, | |
| 254 | 234 | hold: hold || null, | |
| 255 | 235 | }, | |
| 67 | 67 | */ | |
| 68 | 68 | export function accountPath(account: string | null | undefined): string | null { | |
| 69 | 69 | const id = (account ?? "").trim().toLowerCase(); | |
| 70 | − | // The model catalogue's changes (billing's catalogue.rs). | |
| 71 | − | if (id === "models") return "/agents"; | |
| 72 | 70 | // Shared invite links (identity's shared_invites.rs): `invites` is a reserved name, never a workspace's. | |
| 73 | 71 | if (id === SHARED_INVITES_ACCOUNT) return "/invites?tab=shared"; | |
| 74 | 72 | const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id); | |
| ⋯ | |||
| 84 | 82 | /** What to call an account: a workspace by its slug, an enterprise by its name when known. */ | |
| 85 | 83 | export function accountName(account: string, names: Map<string, string> = new Map()): string { | |
| 86 | 84 | if (names.has(account)) return names.get(account) as string; | |
| 87 | − | if (account === "models") return "Agents & models"; | |
| 85 | + | // The retired model catalogue, in older entries. | |
| 86 | + | if (account === "models") return "Model catalogue (retired)"; | |
| 88 | 87 | if (account === SHARED_INVITES_ACCOUNT) return "Shared invite links"; | |
| 89 | 88 | if (account.startsWith("incident:")) return "Incident"; | |
| 90 | 89 | if (account.startsWith("maintenance:")) return "Maintenance"; | |
| ⋯ | |||
| 149 | 148 | maintenance_cancelled: "Maintenance cancelled", | |
| 150 | 149 | credit_revoked: "Credit revoked", | |
| 151 | 150 | credit_expired: "Credit expired", | |
| 152 | − | // The model catalogue (Agents & models). | |
| 151 | + | // The model catalogue, retired with g1t's own agents: older entries still name these. | |
| 153 | 152 | models_discovered: "Models found or gone", | |
| 154 | 153 | model_approved: "Model approved", | |
| 155 | 154 | model_retired: "Model retired", | |
| 1 | − | import assert from "node:assert/strict"; | |
| 2 | − | import { test } from "node:test"; | |
| 3 | − | ||
| 4 | − | import type { CatalogueModel } from "@g1t/contracts"; | |
| 5 | − | ||
| 6 | − | import { choicesFor, describeDefault, impact, parseApproval, parseDefault, parsePerMillion, perMillion, tokens } from "./models.ts"; | |
| 7 | − | ||
| 8 | − | function model(id: string, name: string, typical: number, extra: Partial<CatalogueModel> = {}): CatalogueModel { | |
| 9 | − | return { | |
| 10 | − | model: id, | |
| 11 | − | name, | |
| 12 | − | provider: "anthropic", | |
| 13 | − | kind: "chat", | |
| 14 | − | inputMicros: 1_000_000, | |
| 15 | − | outputMicros: 5_000_000, | |
| 16 | − | cacheReadMicros: 100_000, | |
| 17 | − | cacheWriteMicros: 1_250_000, | |
| 18 | − | aliases: [], | |
| 19 | − | family: "haiku", | |
| 20 | − | tierHint: "small", | |
| 21 | − | contextWindow: 0, | |
| 22 | − | maxOutput: 0, | |
| 23 | − | capabilities: [], | |
| 24 | − | dimensions: 0, | |
| 25 | − | status: "available", | |
| 26 | − | priced: true, | |
| 27 | − | source: "staff", | |
| 28 | − | firstSeenAt: null, | |
| 29 | − | lastSeenAt: null, | |
| 30 | − | missingSince: null, | |
| 31 | − | approvedBy: null, | |
| 32 | − | approvedAt: null, | |
| 33 | − | note: "", | |
| 34 | − | typicalRunMicros: typical, | |
| 35 | − | ...extra, | |
| 36 | − | }; | |
| 37 | − | } | |
| 38 | − | ||
| 39 | − | const catalogue = [ | |
| 40 | − | model("claude-haiku-5-5", "Claude Haiku 5.5", 76_000), | |
| 41 | − | model("claude-sonnet-5-5", "Claude Sonnet 5.5", 1_340_000, { tierHint: "large" }), | |
| 42 | − | model("claude-haiku-6", "Claude Haiku 6", 0, { status: "new", priced: false }), | |
| 43 | − | model("@cf/openai/gpt-oss-120b", "gpt-oss-120b", 30_000, { provider: "workers-ai" }), | |
| 44 | − | model("claude-haiku-4-5", "Claude Haiku 4.5", 600_000, { status: "retired" }), | |
| 45 | − | ]; | |
| 46 | − | ||
| 47 | − | function form(fields: Record<string, string>): FormData { | |
| 48 | − | const data = new FormData(); | |
| 49 | − | for (const [name, value] of Object.entries(fields)) data.set(name, value); | |
| 50 | − | return data; | |
| 51 | − | } | |
| 52 | − | ||
| 53 | − | test("prices per million are typed in dollars to a millionth", () => { | |
| 54 | − | assert.equal(parsePerMillion("0.125"), 125_000); | |
| 55 | − | assert.equal(parsePerMillion("$2"), 2_000_000); | |
| 56 | − | assert.equal(parsePerMillion("12.50"), 12_500_000); | |
| 57 | − | assert.equal(parsePerMillion("0.000001"), 1); | |
| 58 | − | assert.equal(parsePerMillion(""), 0); | |
| 59 | − | assert.equal(parsePerMillion("-1"), null); | |
| 60 | − | assert.equal(parsePerMillion("0.0000001"), null); | |
| 61 | − | assert.equal(parsePerMillion("abc"), null); | |
| 62 | − | assert.equal(perMillion(125_000), "0.125"); | |
| 63 | − | assert.equal(perMillion(100_000), "0.10"); | |
| 64 | − | assert.equal(perMillion(2_000_000), "2"); | |
| 65 | − | }); | |
| 66 | − | ||
| 67 | − | test("approving a model takes its name, tier, prices and why", () => { | |
| 68 | − | const approved = parseApproval( | |
| 69 | − | form({ name: "Claude Haiku 6", tier_hint: "small", input: "0.08", output: "0.40", cache_read: "0.008", cache_write: "0.10", reason: "Anthropic's price page" }), | |
| 70 | − | "chat", | |
| 71 | − | ); | |
| 72 | − | assert.ok(approved.ok); | |
| 73 | − | assert.equal(approved.value.prices.inputMicros, 80_000); | |
| 74 | − | assert.equal(approved.value.prices.outputMicros, 400_000); | |
| 75 | − | // No hour-long price: what a five-minute write costs. | |
| 76 | − | assert.equal(approved.value.prices.cacheWrite1hMicros, 100_000); | |
| 77 | − | assert.equal(approved.value.prices.threshold, 0); | |
| 78 | − | assert.equal(parseApproval(form({ name: "X", input: "1", output: "", reason: "r" }), "chat").ok, false); | |
| 79 | − | assert.equal(parseApproval(form({ name: "X", input: "0.012", reason: "r" }), "embeddings").ok, true); | |
| 80 | − | assert.deepEqual(parseApproval(form({ name: "X", input: "1", output: "5", reason: "" }), "chat"), { ok: false, error: "Say why, for whoever looks next." }); | |
| 81 | − | assert.equal(parseApproval(form({ name: "X", input: "1", output: "5", over_input: "2", reason: "r" }), "chat").ok, false); | |
| 82 | − | const long = parseApproval(form({ name: "X", input: "1", output: "5", threshold: "200,000", over_input: "2", over_output: "10", reason: "r" }), "chat"); | |
| 83 | − | assert.ok(long.ok); | |
| 84 | − | assert.equal(long.value.prices.threshold, 200_000); | |
| 85 | − | assert.equal(parseApproval(form({ name: "X", tier_hint: "huge", input: "1", output: "5", reason: "r" }), "chat").ok, false); | |
| 86 | − | assert.equal(parseApproval(form({ name: "", input: "1", output: "5", reason: "r" }), "chat").ok, false); | |
| 87 | − | }); | |
| 88 | − | ||
| 89 | − | test("a model purpose takes only an available, priced Claude", () => { | |
| 90 | − | const choices = choicesFor(catalogue); | |
| 91 | − | assert.deepEqual(choices.map((m) => m.model), ["claude-haiku-5-5", "claude-sonnet-5-5"]); | |
| 92 | − | const set = parseDefault(form({ purpose: "tier_small", model: "claude-sonnet-5-5", reason: "testing" }), choices); | |
| 93 | − | assert.deepEqual(set, { ok: true, value: { purpose: "tier_small", model: "claude-sonnet-5-5", tier: null, effort: null, reason: "testing" } }); | |
| 94 | − | assert.equal(parseDefault(form({ purpose: "tier_small", model: "claude-haiku-6", reason: "x" }), choices).ok, false); | |
| 95 | − | assert.equal(parseDefault(form({ purpose: "background", model: "@cf/openai/gpt-oss-120b", reason: "x" }), choices).ok, false); | |
| 96 | − | assert.equal(parseDefault(form({ purpose: "tier_small", model: "claude-haiku-5-5", reason: "" }), choices).ok, false); | |
| 97 | − | assert.equal(parseDefault(form({ purpose: "nonsense", model: "claude-haiku-5-5", reason: "x" }), choices).ok, false); | |
| 98 | − | }); | |
| 99 | − | ||
| 100 | − | test("a job takes a tier and an effort, or the harness's own", () => { | |
| 101 | − | const choices = choicesFor(catalogue); | |
| 102 | − | assert.deepEqual(parseDefault(form({ purpose: "job_plan", tier: "small", effort: "xhigh", reason: "x" }), choices), { | |
| 103 | − | ok: true, | |
| 104 | − | value: { purpose: "job_plan", model: null, tier: "small", effort: "xhigh", reason: "x" }, | |
| 105 | − | }); | |
| 106 | − | const own = parseDefault(form({ purpose: "job_update", tier: "small", effort: "", reason: "x" }), choices); | |
| 107 | − | assert.ok(own.ok); | |
| 108 | − | assert.equal(own.value.effort, null); | |
| 109 | − | // Only a review is sized by the change. | |
| 110 | − | assert.equal(parseDefault(form({ purpose: "job_review", tier: "change", reason: "x" }), choices).ok, true); | |
| 111 | − | assert.equal(parseDefault(form({ purpose: "job_plan", tier: "change", reason: "x" }), choices).ok, false); | |
| 112 | − | assert.equal(parseDefault(form({ purpose: "job_plan", tier: "small", effort: "huge", reason: "x" }), choices).ok, false); | |
| 113 | − | }); | |
| 114 | − | ||
| 115 | − | test("a change shows what a typical run would cost before it is saved", () => { | |
| 116 | − | const before = { purpose: "tier_small", chosen: "claude-haiku-5-5", model: catalogue[0], capabilities: [], note: null }; | |
| 117 | − | assert.equal( | |
| 118 | − | impact(before, catalogue[1], catalogue), | |
| 119 | − | "A typical run: about $1.34 on Claude Sonnet 5.5, 17.6 times $0.076 on Claude Haiku 5.5.", | |
| 120 | − | ); | |
| 121 | − | assert.equal(impact({ ...before, model: catalogue[1] }, catalogue[0], catalogue), "A typical run: about $0.076 on Claude Haiku 5.5, 94% less than $1.34 on Claude Sonnet 5.5."); | |
| 122 | − | assert.equal(impact(before, catalogue[0], catalogue), "No change: Claude Haiku 5.5 already runs it, at about $0.076 a typical run."); | |
| 123 | − | assert.equal(impact(undefined, catalogue[1], catalogue), "A typical run would cost about $1.34 on Claude Sonnet 5.5."); | |
| 124 | − | assert.equal(impact(before, undefined, catalogue), null); | |
| 125 | − | }); | |
| 126 | − | ||
| 127 | − | test("defaults and sizes read plainly", () => { | |
| 128 | − | assert.equal(describeDefault({ model: "claude-haiku-5-5", tier: null, effort: null }, catalogue), "Claude Haiku 5.5"); | |
| 129 | − | assert.equal(describeDefault({ model: null, tier: "small", effort: "high" }, catalogue), "Fast, high effort"); | |
| 130 | − | assert.equal(describeDefault({ model: null, tier: "change", effort: null }, catalogue), "By the change's size, the harness's own effort"); | |
| 131 | − | assert.equal(tokens(1_000_000), "1M"); | |
| 132 | − | assert.equal(tokens(200_000), "200k"); | |
| 133 | − | assert.equal(tokens(0), "—"); | |
| 134 | − | }); |
| 1 | − | /** | |
| 2 | − | * Agents & models, as the page reads its forms and states its figures: | |
| 3 | − | * prices per million tokens, each purpose's default, and what a change | |
| 4 | − | * does to the cost of a typical run. Billing checks every value again. No | |
| 5 | − | * Workers imports, so it can be tested under Node. | |
| 6 | − | */ | |
| 7 | − | import type { CatalogueModel, ModelDefault, ModelPrices, ResolvedModel } from "@g1t/contracts"; | |
| 8 | − | ||
| 9 | − | import type { Parsed } from "./forms.ts"; | |
| 10 | − | ||
| 11 | − | /** The longest reason billing keeps. */ | |
| 12 | − | export const MAX_REASON = 500; | |
| 13 | − | ||
| 14 | − | /** A model purpose: what it is called and what it is for. */ | |
| 15 | − | export type PurposeInfo = { purpose: string; label: string; about: string }; | |
| 16 | − | ||
| 17 | − | /** The model purposes, in the order the page shows them. */ | |
| 18 | − | export const MODEL_PURPOSES: PurposeInfo[] = [ | |
| 19 | − | { purpose: "tier_small", label: "Fast", about: "Auto's fast tier: catching up, answers, plans, small reviews." }, | |
| 20 | − | { purpose: "tier_large", label: "Standard", about: "Auto's standard tier: making and revising changes, most reviews." }, | |
| 21 | − | { purpose: "tier_frontier", label: "Most capable", about: "Auto's top tier: large reviews, architecture, work that failed twice." }, | |
| 22 | − | { purpose: "background", label: "Background", about: "The harness's own small tasks in every run, such as titles and summaries." }, | |
| 23 | − | { | |
| 24 | − | purpose: "gateway_first", | |
| 25 | − | label: "AI Gateway's first Claude", | |
| 26 | − | about: "Listed first by GET /openai/v1/models, the one people start with.", | |
| 27 | − | }, | |
| 28 | − | ]; | |
| 29 | − | ||
| 30 | − | /** Each kind of agent job, as the page names it. */ | |
| 31 | − | export const JOBS: { kind: string; label: string }[] = [ | |
| 32 | − | { kind: "implement", label: "Making a change" }, | |
| 33 | − | { kind: "revise", label: "Revising a change" }, | |
| 34 | − | { kind: "answer", label: "Answering a question" }, | |
| 35 | − | { kind: "review", label: "Reviewing" }, | |
| 36 | − | { kind: "update", label: "Catching up" }, | |
| 37 | − | { kind: "plan", label: "Planning" }, | |
| 38 | − | ]; | |
| 39 | − | ||
| 40 | − | export const TIER_LABELS: Record<string, string> = { | |
| 41 | − | small: "Fast", | |
| 42 | − | large: "Standard", | |
| 43 | − | frontier: "Most capable", | |
| 44 | − | change: "By the change's size", | |
| 45 | − | }; | |
| 46 | − | ||
| 47 | − | export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; | |
| 48 | − | ||
| 49 | − | /** What a purpose is called anywhere on the page or in a notice. */ | |
| 50 | − | export function purposeLabel(purpose: string): string { | |
| 51 | − | const model = MODEL_PURPOSES.find((p) => p.purpose === purpose); | |
| 52 | − | if (model) return model.label; | |
| 53 | − | const job = JOBS.find((j) => `job_${j.kind}` === purpose); | |
| 54 | − | return job ? job.label : purpose; | |
| 55 | − | } | |
| 56 | − | ||
| 57 | − | /** The catalogue models a purpose may be set to: available, priced Claude chat models. */ | |
| 58 | − | export function choicesFor(catalogue: CatalogueModel[]): CatalogueModel[] { | |
| 59 | − | return catalogue.filter((m) => m.status === "available" && m.priced && m.provider === "anthropic" && (m.kind ?? "chat") === "chat"); | |
| 60 | − | } | |
| 61 | − | ||
| 62 | − | /** A price per million tokens, in dollars, to show or to fill a field: `0.125`, `2`, `12.50`. */ | |
| 63 | − | export function perMillion(micros: number | null | undefined): string { | |
| 64 | − | if (micros == null) return ""; | |
| 65 | − | const text = (Math.max(0, micros) / 1_000_000).toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); | |
| 66 | − | const [whole, cents] = text.split("."); | |
| 67 | − | return cents && cents.length === 1 ? `${whole}.${cents}0` : text; | |
| 68 | − | } | |
| 69 | − | ||
| 70 | − | /** | |
| 71 | − | * Micros from a price per million tokens as typed: `0.125`, `$2`, `12.50`. | |
| 72 | − | * Up to six decimal places (a millionth of a dollar per million tokens); | |
| 73 | − | * an empty field is 0. | |
| 74 | − | */ | |
| 75 | − | export function parsePerMillion(input: string): number | null { | |
| 76 | − | const text = input.trim().replace(/^\$/, "").replace(/,/g, ""); | |
| 77 | − | if (text === "") return 0; | |
| 78 | − | const match = /^(\d{1,4})(?:\.(\d{1,6}))?$/.exec(text); | |
| 79 | − | if (!match) return null; | |
| 80 | − | return Number(match[1]) * 1_000_000 + Number((match[2] ?? "").padEnd(6, "0")); | |
| 81 | − | } | |
| 82 | − | ||
| 83 | − | /** The price fields of the approval form, by the name each is posted as. */ | |
| 84 | − | export const PRICE_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [ | |
| 85 | − | { name: "input", key: "inputMicros", label: "Input" }, | |
| 86 | − | { name: "output", key: "outputMicros", label: "Output" }, | |
| 87 | − | { name: "cache_read", key: "cacheReadMicros", label: "Cache read" }, | |
| 88 | − | { name: "cache_write", key: "cacheWriteMicros", label: "Cache write, 5 min" }, | |
| 89 | − | { name: "cache_write_1h", key: "cacheWrite1hMicros", label: "Cache write, 1 h" }, | |
| 90 | − | ]; | |
| 91 | − | ||
| 92 | − | export const OVER_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [ | |
| 93 | − | { name: "over_input", key: "overInputMicros", label: "Input" }, | |
| 94 | − | { name: "over_output", key: "overOutputMicros", label: "Output" }, | |
| 95 | − | { name: "over_cache_read", key: "overCacheReadMicros", label: "Cache read" }, | |
| 96 | − | { name: "over_cache_write", key: "overCacheWriteMicros", label: "Cache write, 5 min" }, | |
| 97 | − | { name: "over_cache_write_1h", key: "overCacheWrite1hMicros", label: "Cache write, 1 h" }, | |
| 98 | − | ]; | |
| 99 | − | ||
| 100 | − | /** A model's prices as the catalogue has them, for the approval form. */ | |
| 101 | − | export function pricesOf(model: CatalogueModel): ModelPrices { | |
| 102 | − | return { | |
| 103 | − | inputMicros: model.inputMicros, | |
| 104 | − | outputMicros: model.outputMicros, | |
| 105 | − | cacheReadMicros: model.cacheReadMicros, | |
| 106 | − | cacheWriteMicros: model.cacheWriteMicros, | |
| 107 | − | cacheWrite1hMicros: model.cacheWrite1hMicros ?? 0, | |
| 108 | − | threshold: model.threshold ?? 0, | |
| 109 | − | overInputMicros: model.overInputMicros ?? 0, | |
| 110 | − | overOutputMicros: model.overOutputMicros ?? 0, | |
| 111 | − | overCacheReadMicros: model.overCacheReadMicros ?? 0, | |
| 112 | − | overCacheWriteMicros: model.overCacheWriteMicros ?? 0, | |
| 113 | − | overCacheWrite1hMicros: model.overCacheWrite1hMicros ?? 0, | |
| 114 | − | }; | |
| 115 | − | } | |
| 116 | − | ||
| 117 | − | /** A reason as the forms take it: required, kept short. */ | |
| 118 | − | export function parseReason(raw: string): Parsed<string> { | |
| 119 | − | const reason = raw.trim(); | |
| 120 | − | if (!reason) return { ok: false, error: "Say why, for whoever looks next." }; | |
| 121 | − | if ([...reason].length > MAX_REASON) return { ok: false, error: `Keep the reason to ${MAX_REASON} characters.` }; | |
| 122 | − | return { ok: true, value: reason }; | |
| 123 | − | } | |
| 124 | − | ||
| 125 | − | export type Approval = { name: string; tierHint: string; prices: ModelPrices; reason: string }; | |
| 126 | − | ||
| 127 | − | /** | |
| 128 | − | * The approval form: the name people see, the tier it suits, its prices | |
| 129 | − | * per million tokens (and above a long-prompt threshold, if it has one), | |
| 130 | − | * and why. Input is required; a chat model needs an output price too. | |
| 131 | − | */ | |
| 132 | − | export function parseApproval(form: FormData, kind: string): Parsed<Approval> { | |
| 133 | − | const get = (name: string) => { | |
| 134 | − | const value = form.get(name); | |
| 135 | − | return typeof value === "string" ? value : ""; | |
| 136 | − | }; | |
| 137 | − | const name = get("name").trim(); | |
| 138 | − | if (!name) return { ok: false, error: "Give the name people see, such as Claude Haiku 6." }; | |
| 139 | − | if (name.length > 120) return { ok: false, error: "Keep the name to 120 characters." }; | |
| 140 | − | const tierHint = get("tier_hint").trim(); | |
| 141 | − | if (tierHint && !["small", "large", "frontier"].includes(tierHint)) return { ok: false, error: "Choose a tier it suits, or none." }; | |
| 142 | − | const prices = {} as ModelPrices; | |
| 143 | − | for (const field of [...PRICE_FIELDS, ...OVER_FIELDS]) { | |
| 144 | − | const micros = parsePerMillion(get(field.name)); | |
| 145 | − | if (micros == null) return { ok: false, error: `${field.label}: a price in dollars per million tokens, such as 0.125.` }; | |
| 146 | − | prices[field.key] = micros; | |
| 147 | − | } | |
| 148 | − | const threshold = get("threshold").trim().replace(/,/g, ""); | |
| 149 | − | if (threshold && !/^\d{1,9}$/.test(threshold)) return { ok: false, error: "The long-prompt threshold is a number of tokens, such as 200000." }; | |
| 150 | − | prices.threshold = threshold ? Number(threshold) : 0; | |
| 151 | − | if (prices.inputMicros === 0) return { ok: false, error: "Give the input price per million tokens." }; | |
| 152 | − | if (kind === "chat" && prices.outputMicros === 0) return { ok: false, error: "Give the output price per million tokens." }; | |
| 153 | − | // An hour-long write with no price of its own costs what a five-minute one does. | |
| 154 | − | if (prices.cacheWrite1hMicros === 0) prices.cacheWrite1hMicros = prices.cacheWriteMicros; | |
| 155 | − | const over = prices.overInputMicros + prices.overOutputMicros + prices.overCacheReadMicros + prices.overCacheWriteMicros; | |
| 156 | − | if (prices.threshold === 0 && over > 0) return { ok: false, error: "Long-prompt prices need the prompt length they start above." }; | |
| 157 | − | if (prices.threshold > 0 && prices.overInputMicros === 0) return { ok: false, error: "With a long-prompt threshold, give the prices above it." }; | |
| 158 | − | if (prices.threshold > 0 && prices.overCacheWrite1hMicros === 0) prices.overCacheWrite1hMicros = prices.overCacheWriteMicros; | |
| 159 | − | const reason = parseReason(get("reason")); | |
| 160 | − | if (!reason.ok) return reason; | |
| 161 | − | return { ok: true, value: { name, tierHint, prices, reason: reason.value } }; | |
| 162 | − | } | |
| 163 | − | ||
| 164 | − | /** A default as the forms post it: a model, or a job's tier and effort. */ | |
| 165 | − | export type DefaultChange = { purpose: string; model: string | null; tier: string | null; effort: string | null; reason: string }; | |
| 166 | − | ||
| 167 | − | /** | |
| 168 | − | * One default's form: a model purpose takes one of `choices`; a job takes | |
| 169 | − | * a tier (`change` only for reviews) and an effort, or none for the | |
| 170 | − | * harness's own. | |
| 171 | − | */ | |
| 172 | − | export function parseDefault(form: FormData, choices: CatalogueModel[]): Parsed<DefaultChange> { | |
| 173 | − | const get = (name: string) => { | |
| 174 | − | const value = form.get(name); | |
| 175 | − | return typeof value === "string" ? value.trim() : ""; | |
| 176 | − | }; | |
| 177 | − | const purpose = get("purpose"); | |
| 178 | − | const reason = parseReason(get("reason")); | |
| 179 | − | if (MODEL_PURPOSES.some((p) => p.purpose === purpose)) { | |
| 180 | − | const model = get("model"); | |
| 181 | − | if (!choices.some((m) => m.model === model)) return { ok: false, error: "Choose an available Claude model." }; | |
| 182 | − | if (!reason.ok) return reason; | |
| 183 | − | return { ok: true, value: { purpose, model, tier: null, effort: null, reason: reason.value } }; | |
| 184 | − | } | |
| 185 | − | const job = JOBS.find((j) => `job_${j.kind}` === purpose); | |
| 186 | − | if (!job) return { ok: false, error: "That is not something a default is chosen for." }; | |
| 187 | − | const tier = get("tier"); | |
| 188 | − | const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"]; | |
| 189 | − | if (!tiers.includes(tier)) return { ok: false, error: `Choose ${job.kind === "review" ? "a tier, or by the change's size" : "a tier"}.` }; | |
| 190 | − | const effort = get("effort"); | |
| 191 | − | if (effort && !(EFFORTS as readonly string[]).includes(effort)) return { ok: false, error: "Effort is low, medium, high, xhigh or max, or the harness's own." }; | |
| 192 | − | if (!reason.ok) return reason; | |
| 193 | − | return { ok: true, value: { purpose, model: null, tier, effort: effort || null, reason: reason.value } }; | |
| 194 | − | } | |
| 195 | − | ||
| 196 | − | /** How a default reads: a model's name, or `Fast, high effort`. */ | |
| 197 | − | export function describeDefault(value: { model: string | null; tier: string | null; effort: string | null }, catalogue: CatalogueModel[]): string { | |
| 198 | − | if (value.model) return catalogue.find((m) => m.model === value.model)?.name ?? value.model; | |
| 199 | − | const tier = TIER_LABELS[value.tier ?? ""] ?? value.tier ?? "none"; | |
| 200 | − | return value.effort ? `${tier}, ${value.effort} effort` : `${tier}, the harness's own effort`; | |
| 201 | − | } | |
| 202 | − | ||
| 203 | − | /** What a change to a default does to a typical run's cost, in a sentence; null for a job. */ | |
| 204 | − | export function impact(before: ResolvedModel | undefined, after: CatalogueModel | undefined, catalogue: CatalogueModel[]): string | null { | |
| 205 | − | if (!after) return null; | |
| 206 | − | const was = before?.model ? catalogue.find((m) => m.model === before.model!.model) : undefined; | |
| 207 | − | const now = after.typicalRunMicros; | |
| 208 | − | const money = (micros: number) => (micros < 1_000_000 ? `$${(micros / 1_000_000).toFixed(3)}` : `$${(micros / 1_000_000).toFixed(2)}`); | |
| 209 | − | if (!was || was.typicalRunMicros <= 0) return `A typical run would cost about ${money(now)} on ${after.name}.`; | |
| 210 | − | if (was.model === after.model) return `No change: ${after.name} already runs it, at about ${money(now)} a typical run.`; | |
| 211 | − | const ratio = now / was.typicalRunMicros; | |
| 212 | − | const change = Math.round((ratio - 1) * 100); | |
| 213 | − | const direction = | |
| 214 | − | ratio >= 2 | |
| 215 | − | ? `${Number(ratio.toFixed(1))} times` | |
| 216 | − | : change === 0 | |
| 217 | − | ? "the same as" | |
| 218 | − | : change > 0 | |
| 219 | − | ? `${change}% more than` | |
| 220 | − | : `${-change}% less than`; | |
| 221 | − | return `A typical run: about ${money(now)} on ${after.name}, ${direction} ${money(was.typicalRunMicros)} on ${was.name}.`; | |
| 222 | − | } | |
| 223 | − | ||
| 224 | − | /** The current default for each purpose, by purpose. */ | |
| 225 | − | export function defaultsByPurpose(defaults: ModelDefault[]): Map<string, ModelDefault> { | |
| 226 | − | return new Map(defaults.map((d) => [d.purpose, d])); | |
| 227 | − | } | |
| 228 | − | ||
| 229 | − | /** A context window or output limit: `1M`, `200k`, or a dash when not known. */ | |
| 230 | − | export function tokens(n: number): string { | |
| 231 | − | if (!n) return "—"; | |
| 232 | − | if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`; | |
| 233 | − | if (n >= 1_000) return `${Math.round(n / 1_000)}k`; | |
| 234 | − | return String(n); | |
| 235 | − | } |
| 40 | 40 | ||
| 41 | 41 | test("the built pages are not marked soon", () => { | |
| 42 | 42 | const built = navItems().filter((item) => !item.soon).map((item) => item.to); | |
| 43 | − | assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/agents", "/abuse", "/incidents", "/audit"]); | |
| 43 | + | assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]); | |
| 44 | 44 | }); | |
| 45 | 45 | ||
| 46 | 46 | test("every soon page says what it will do, why, and what it will have", () => { |
| 20 | 20 | | "usage" | |
| 21 | 21 | | "stripe" | |
| 22 | 22 | | "costs" | |
| 23 | − | | "agents" | |
| 24 | 23 | | "abuse" | |
| 25 | 24 | | "incidents" | |
| 26 | 25 | | "inbox" | |
| ⋯ | |||
| 195 | 194 | about: "What customers used, sliced by meter, model, repository and day.", | |
| 196 | 195 | soon: { | |
| 197 | 196 | summary: [ | |
| 198 | − | "What customers used, across every workspace, sliced any way: by meter, model, repository, task and day. It answers the questions that come up in a sales call or a cost review: who uses the most sandbox time, which model costs most, what a customer's agents spend per pull request.", | |
| 197 | + | "What customers used, across every workspace, sliced any way: by meter, repository and day. It answers the questions that come up in a sales call or a cost review: who uses the most sandbox time, and what a customer spends per pull request.", | |
| 199 | 198 | "Billing records every run and sandbox second with its cost and charge already. This puts it in front of the people who need it, without a query.", | |
| 200 | 199 | ], | |
| 201 | 200 | plans: [ | |
| ⋯ | |||
| 218 | 217 | to: "/costs", | |
| 219 | 218 | icon: "costs", | |
| 220 | 219 | about: "Where g1t's money goes, with what it gave away kept apart; on Bill & pricing, Cloudflare's bill, drift and the price book.", | |
| 221 | − | }, | |
| 222 | − | { | |
| 223 | − | label: "Agents & models", | |
| 224 | − | to: "/agents", | |
| 225 | − | icon: "agents", | |
| 226 | − | about: "Every model g1t can use, new ones to approve, and which model each tier and job uses by default.", | |
| 227 | 220 | }, | |
| 228 | 221 | { | |
| 229 | 222 | label: "Abuse & fraud", | |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | 3 | import { | |
| 4 | − | type ModelDiscoveryApi, | |
| 5 | 4 | type StatusAdminApi, | |
| 6 | 5 | accountsAdminClient, | |
| 7 | 6 | billingAdminClient, | |
| ⋯ | |||
| 30 | 29 | ||
| 31 | 30 | /** The status page's incidents (apps/status's `StatusAdmin` entrypoint). */ | |
| 32 | 31 | export const statusAdmin = env.STATUS as unknown as StatusAdminApi; | |
| 33 | − | ||
| 34 | − | /** "Check for new models": the model proxy's `Discovery` entrypoint (services/models). */ | |
| 35 | − | export const modelDiscovery = env.MODELS as unknown as ModelDiscoveryApi; | |
| 36 | 32 | ||
| 37 | 33 | /** STAFF_EMAILS, for suggesting staff in the incident roles. */ | |
| 38 | 34 | export const staffEmails = (): string => env.STAFF_EMAILS ?? ""; | |
| 20 | 20 | route("velocity", "routes/velocity.tsx"), | |
| 21 | 21 | route("costs", "routes/costs.tsx"), | |
| 22 | 22 | route("costs/bill", "routes/costs-bill.tsx"), | |
| 23 | − | route("agents", "routes/agents.tsx"), | |
| 24 | 23 | route("abuse", "routes/abuse.tsx"), | |
| 25 | 24 | route("invoices", "routes/invoices.tsx"), | |
| 26 | 25 | route("credits", "routes/credits.tsx"), |
| 1 | − | import { Check, RefreshCw } from "lucide-react"; | |
| 2 | − | import { data, redirect } from "react-router"; | |
| 3 | − | ||
| 4 | − | import type { AdminModels, CatalogueModel, DiscoveryResult, ModelStatus } from "@g1t/contracts"; | |
| 5 | − | ||
| 6 | − | import type { Route } from "./+types/agents"; | |
| 7 | − | import { Badge, Button, ButtonLink, EmptyState, Field, Input, Notice, PageHeader, Section, Select, When } from "~/components/ui"; | |
| 8 | − | import { text } from "~/lib/forms"; | |
| 9 | − | import { | |
| 10 | − | EFFORTS, | |
| 11 | − | JOBS, | |
| 12 | − | MAX_REASON, | |
| 13 | − | MODEL_PURPOSES, | |
| 14 | − | OVER_FIELDS, | |
| 15 | − | PRICE_FIELDS, | |
| 16 | − | TIER_LABELS, | |
| 17 | − | type DefaultChange, | |
| 18 | − | choicesFor, | |
| 19 | − | defaultsByPurpose, | |
| 20 | − | describeDefault, | |
| 21 | − | impact, | |
| 22 | − | parseApproval, | |
| 23 | − | parseDefault, | |
| 24 | − | parseReason, | |
| 25 | − | perMillion, | |
| 26 | − | pricesOf, | |
| 27 | − | purposeLabel, | |
| 28 | − | tokens, | |
| 29 | − | } from "~/lib/models"; | |
| 30 | − | import { usd } from "~/lib/money"; | |
| 31 | − | import { admin, modelDiscovery } from "~/lib/services.server"; | |
| 32 | − | import { settle } from "~/lib/settle"; | |
| 33 | − | import { requireStaff } from "~/lib/staff"; | |
| 34 | − | ||
| 35 | − | export const meta: Route.MetaFunction = () => [{ title: "Agents & models · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 36 | − | ||
| 37 | − | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 38 | − | requireStaff(context); | |
| 39 | − | const url = new URL(request.url); | |
| 40 | − | const models = await settle(admin.models()); | |
| 41 | − | const done = url.searchParams.get("done"); | |
| 42 | − | const subject = url.searchParams.get("subject") ?? ""; | |
| 43 | − | const message: Record<string, string> = { | |
| 44 | − | approved: `${subject} is available now: defaults can use it, and the AI Gateway offers it.`, | |
| 45 | − | retired: `${subject} is retired. Any default that chose it uses the next suitable model until you choose another.`, | |
| 46 | − | restored: `${subject} is back where it stood.`, | |
| 47 | − | default: `The default for ${subject} is saved. Runs pick it up within a minute.`, | |
| 48 | − | }; | |
| 49 | − | return { | |
| 50 | − | models: models.ok ? models.value : null, | |
| 51 | − | error: models.ok ? null : models.error, | |
| 52 | − | done: done && message[done] ? message[done] : null, | |
| 53 | − | }; | |
| 54 | − | } | |
| 55 | − | ||
| 56 | − | type Review = { change: DefaultChange; before: string; after: string; impact: string | null }; | |
| 57 | − | ||
| 58 | − | type ActionData = | |
| 59 | − | | { kind: "error"; error: string; target: string; values?: Record<string, string> } | |
| 60 | − | | { kind: "review"; review: Review } | |
| 61 | − | | { kind: "checked"; results: DiscoveryResult[] | null; error: string | null }; | |
| 62 | − | ||
| 63 | − | const back = (done: string, subject: string, anchor: string) => | |
| 64 | − | redirect(`/agents?done=${done}&subject=${encodeURIComponent(subject)}#${anchor}`); | |
| 65 | − | ||
| 66 | − | /** | |
| 67 | − | * Checking for new models, approving, retiring and restoring them, and | |
| 68 | − | * changing a default. Billing checks each again and records it in the | |
| 69 | − | * audit log with the staff member and why; a default shows what it does | |
| 70 | − | * to a typical run's cost before it is saved. | |
| 71 | − | */ | |
| 72 | − | export async function action({ request, context }: Route.ActionArgs) { | |
| 73 | − | const staff = requireStaff(context); | |
| 74 | − | const form = await request.formData(); | |
| 75 | − | const failed = (error: string, target: string, values?: Record<string, string>) => | |
| 76 | − | data<ActionData>({ kind: "error", error, target, values }, { status: 422 }); | |
| 77 | − | const intent = text(form, "intent"); | |
| 78 | − | ||
| 79 | − | if (intent === "check") { | |
| 80 | − | const results = await settle(modelDiscovery.check(staff.email)); | |
| 81 | − | return { kind: "checked", results: results.ok ? results.value : null, error: results.ok ? null : results.error } satisfies ActionData; | |
| 82 | − | } | |
| 83 | − | ||
| 84 | − | const catalogue = await admin.models().then((m) => m.catalogue); | |
| 85 | − | if (intent === "approve" || intent === "retire" || intent === "restore") { | |
| 86 | − | const id = text(form, "model"); | |
| 87 | − | const model = catalogue.find((m) => m.model === id); | |
| 88 | − | if (!model) return failed(`${id} is not in the catalogue.`, `model-${id}`); | |
| 89 | − | if (intent === "approve") { | |
| 90 | − | const approval = parseApproval(form, model.kind ?? "chat"); | |
| 91 | − | if (!approval.ok) return failed(approval.error, `model-${id}`, Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)]))); | |
| 92 | − | const { name, tierHint, prices, reason } = approval.value; | |
| 93 | − | const result = await admin.decideModel(id, "approve", { name, tierHint: tierHint || "", prices }, reason, staff.email); | |
| 94 | − | if (!result.ok) return failed(result.error.message, `model-${id}`); | |
| 95 | − | throw back("approved", result.value.name, "catalogue"); | |
| 96 | − | } | |
| 97 | − | const reason = parseReason(text(form, "reason")); | |
| 98 | − | if (!reason.ok) return failed(reason.error, `model-${id}`); | |
| 99 | − | const result = await admin.decideModel(id, intent, {}, reason.value, staff.email); | |
| 100 | − | if (!result.ok) return failed(result.error.message, `model-${id}`); | |
| 101 | − | throw back(intent === "retire" ? "retired" : "restored", result.value.name, "catalogue"); | |
| 102 | − | } | |
| 103 | − | ||
| 104 | − | if (intent === "default") { | |
| 105 | − | const parsed = parseDefault(form, choicesFor(catalogue)); | |
| 106 | − | const purpose = text(form, "purpose"); | |
| 107 | − | if (!parsed.ok) return failed(parsed.error, `default-${purpose}`, { reason: text(form, "reason") }); | |
| 108 | − | const change = parsed.value; | |
| 109 | − | if (text(form, "confirm") !== "yes") { | |
| 110 | − | const models = await admin.models(); | |
| 111 | − | const current = defaultsByPurpose(models.defaults).get(change.purpose); | |
| 112 | − | const resolved = models.resolved.models.find((m) => m.purpose === change.purpose); | |
| 113 | − | return { | |
| 114 | − | kind: "review", | |
| 115 | − | review: { | |
| 116 | − | change, | |
| 117 | − | before: current ? describeDefault(current, catalogue) : "nothing", | |
| 118 | − | after: describeDefault(change, catalogue), | |
| 119 | − | impact: change.model ? impact(resolved, catalogue.find((m) => m.model === change.model), catalogue) : null, | |
| 120 | − | }, | |
| 121 | − | } satisfies ActionData; | |
| 122 | − | } | |
| 123 | − | const result = await admin.setModelDefault(change.purpose, change, change.reason, staff.email); | |
| 124 | − | if (!result.ok) return failed(result.error.message, `default-${change.purpose}`); | |
| 125 | − | throw back("default", purposeLabel(change.purpose), "defaults"); | |
| 126 | − | } | |
| 127 | − | return data<ActionData>({ kind: "error", error: "Unknown action.", target: "" }, { status: 400 }); | |
| 128 | − | } | |
| 129 | − | ||
| 130 | − | const STATUS: Record<ModelStatus, { label: string; tone: "mint" | "lavender" | "warn" | "danger" }> = { | |
| 131 | − | available: { label: "Available", tone: "mint" }, | |
| 132 | − | new: { label: "New", tone: "lavender" }, | |
| 133 | − | deprecated: { label: "Deprecated", tone: "warn" }, | |
| 134 | − | retired: { label: "Retired", tone: "danger" }, | |
| 135 | − | }; | |
| 136 | − | ||
| 137 | − | const PROVIDER: Record<string, string> = { anthropic: "Anthropic", "workers-ai": "Workers AI" }; | |
| 138 | − | ||
| 139 | − | /** A typical run's cost, with the cents a fast model's run is measured in. */ | |
| 140 | − | function runCost(micros: number): string { | |
| 141 | − | return micros > 0 ? usd(micros) : "—"; | |
| 142 | − | } | |
| 143 | − | ||
| 144 | − | export default function Agents({ loaderData, actionData }: Route.ComponentProps) { | |
| 145 | − | const { models, error, done } = loaderData; | |
| 146 | − | const result = actionData as ActionData | undefined; | |
| 147 | − | const failure = result?.kind === "error" ? result : null; | |
| 148 | − | return ( | |
| 149 | − | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 150 | − | <PageHeader | |
| 151 | − | title="Agents & models" | |
| 152 | − | description="Every model g1t can use, found by listing each provider daily; new ones wait here for their prices to be confirmed. Staff choose which model each tier and job uses. Customers never pick a model: Auto routes their work with these." | |
| 153 | − | actions={ | |
| 154 | − | <form method="post" action="/agents#top"> | |
| 155 | − | <input type="hidden" name="intent" value="check" /> | |
| 156 | − | <Button type="submit" variant="lavender"> | |
| 157 | − | <RefreshCw size={14} /> | |
| 158 | − | Check for new models | |
| 159 | − | </Button> | |
| 160 | − | </form> | |
| 161 | − | } | |
| 162 | − | /> | |
| 163 | − | <div className="mt-6 space-y-3"> | |
| 164 | − | {done && <Notice tone="ok">{done}</Notice>} | |
| 165 | − | {error && <Notice tone="error">Billing did not answer: {error}</Notice>} | |
| 166 | − | {failure && !failure.target && <Notice tone="error">{failure.error}</Notice>} | |
| 167 | − | {result?.kind === "checked" && <Checked results={result.results} error={result.error} />} | |
| 168 | − | </div> | |
| 169 | − | {result?.kind === "review" && <ReviewPanel review={result.review} />} | |
| 170 | − | {models && <Page models={models} failure={failure} />} | |
| 171 | − | </main> | |
| 172 | − | ); | |
| 173 | − | } | |
| 174 | − | ||
| 175 | − | function Checked({ results, error }: { results: DiscoveryResult[] | null; error: string | null }) { | |
| 176 | − | if (!results) return <Notice tone="error">The model proxy did not answer: {error}</Notice>; | |
| 177 | − | return ( | |
| 178 | − | <Notice tone={results.some((r) => r.error) ? "warn" : "ok"}> | |
| 179 | − | <ul className="space-y-1"> | |
| 180 | − | {results.map((r) => ( | |
| 181 | − | <li key={r.provider}> | |
| 182 | − | <span className="font-medium text-fg">{PROVIDER[r.provider] ?? r.provider}</span>:{" "} | |
| 183 | − | {r.error | |
| 184 | − | ? `could not be listed (${r.error}). Nothing changed.` | |
| 185 | − | : `${r.listed} listed; ${r.added.length ? `new: ${r.added.join(", ")}` : "nothing new"}${r.deprecated.length ? `; no longer listed: ${r.deprecated.join(", ")}` : ""}${r.restored.length ? `; listed again: ${r.restored.join(", ")}` : ""}.`} | |
| 186 | − | </li> | |
| 187 | − | ))} | |
| 188 | − | </ul> | |
| 189 | − | </Notice> | |
| 190 | − | ); | |
| 191 | − | } | |
| 192 | − | ||
| 193 | − | function ReviewPanel({ review }: { review: Review }) { | |
| 194 | − | const { change } = review; | |
| 195 | − | return ( | |
| 196 | − | <Section id="review" className="mt-6 border-accent/40" title={`Change ${purposeLabel(change.purpose)}?`} description="Nothing is saved until you confirm."> | |
| 197 | − | <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]"> | |
| 198 | − | <dt className="text-muted">Now</dt> | |
| 199 | − | <dd>{review.before}</dd> | |
| 200 | − | <dt className="text-muted">After</dt> | |
| 201 | − | <dd className="font-medium">{review.after}</dd> | |
| 202 | − | {review.impact && ( | |
| 203 | − | <> | |
| 204 | − | <dt className="text-muted">Cost</dt> | |
| 205 | − | <dd>{review.impact}</dd> | |
| 206 | − | </> | |
| 207 | − | )} | |
| 208 | − | <dt className="text-muted">Why</dt> | |
| 209 | − | <dd className="text-fg-soft">{change.reason}</dd> | |
| 210 | − | </dl> | |
| 211 | − | <form method="post" action="/agents#defaults" className="mt-4 flex flex-wrap gap-2"> | |
| 212 | − | <input type="hidden" name="intent" value="default" /> | |
| 213 | − | <input type="hidden" name="confirm" value="yes" /> | |
| 214 | − | <input type="hidden" name="purpose" value={change.purpose} /> | |
| 215 | − | {change.model && <input type="hidden" name="model" value={change.model} />} | |
| 216 | − | {change.tier && <input type="hidden" name="tier" value={change.tier} />} | |
| 217 | − | <input type="hidden" name="effort" value={change.effort ?? ""} /> | |
| 218 | − | <input type="hidden" name="reason" value={change.reason} /> | |
| 219 | − | <Button type="submit" variant="lavender"> | |
| 220 | − | <Check size={14} /> | |
| 221 | − | Save | |
| 222 | − | </Button> | |
| 223 | − | <ButtonLink to="/agents#defaults" variant="quiet"> | |
| 224 | − | Cancel | |
| 225 | − | </ButtonLink> | |
| 226 | − | </form> | |
| 227 | − | </Section> | |
| 228 | − | ); | |
| 229 | − | } | |
| 230 | − | ||
| 231 | − | type Failure = Extract<ActionData, { kind: "error" }> | null; | |
| 232 | − | ||
| 233 | − | function Page({ models, failure }: { models: AdminModels; failure: Failure }) { | |
| 234 | − | const waiting = models.catalogue.filter((m) => m.status === "new"); | |
| 235 | − | const t = models.typical; | |
| 236 | − | return ( | |
| 237 | − | <> | |
| 238 | − | <Defaults models={models} failure={failure} /> | |
| 239 | − | <Section | |
| 240 | − | id="waiting" | |
| 241 | − | className="mt-6" | |
| 242 | − | title="New models" | |
| 243 | − | description="Found by a check and not used for anything yet. Confirm the name and prices (from the provider's price page) to make one available; until then nothing routes to it, offers it or charges for it." | |
| 244 | − | > | |
| 245 | − | {waiting.length === 0 ? ( | |
| 246 | − | <EmptyState title="Nothing waiting">A model a provider starts listing appears here after the next check.</EmptyState> | |
| 247 | − | ) : ( | |
| 248 | − | <ul className="space-y-4"> | |
| 249 | − | {waiting.map((model) => ( | |
| 250 | − | <Waiting key={model.model} model={model} failure={failure?.target === `model-${model.model}` ? failure : null} /> | |
| 251 | − | ))} | |
| 252 | − | </ul> | |
| 253 | − | )} | |
| 254 | − | </Section> | |
| 255 | − | <Section | |
| 256 | − | id="catalogue" | |
| 257 | − | className="mt-6" | |
| 258 | − | title="Catalogue" | |
| 259 | − | description={`Prices per million tokens. A typical run is ${t.requests} requests of ${t.input.toLocaleString("en-US")} input, ${t.output.toLocaleString("en-US")} output, ${t.cacheRead.toLocaleString("en-US")} cache-read and ${t.cacheWrite.toLocaleString("en-US")} cache-write tokens: an estimate for comparing models, never a charge.`} | |
| 260 | − | > | |
| 261 | − | <Catalogue catalogue={models.catalogue} failure={failure} /> | |
| 262 | − | </Section> | |
| 263 | − | <Section id="checks" className="mt-6" title="Checks" description="Each provider is listed daily at 05:29 UTC, and whenever someone checks from here. Listing models is free; nothing calls a model."> | |
| 264 | − | {models.checks.length === 0 ? ( | |
| 265 | − | <EmptyState title="No checks yet">Check for new models above, or wait for the daily check.</EmptyState> | |
| 266 | − | ) : ( | |
| 267 | − | <ul className="divide-y divide-line text-sm"> | |
| 268 | − | {models.checks.map((check) => ( | |
| 269 | − | <li key={check.id} className="flex flex-col gap-1 py-2.5 first:pt-0 last:pb-0 sm:flex-row sm:items-baseline sm:gap-4"> | |
| 270 | − | <span className="w-28 shrink-0 font-medium">{PROVIDER[check.provider] ?? check.provider}</span> | |
| 271 | − | <span className="w-44 shrink-0 text-muted"> | |
| 272 | − | <When at={check.checkedAt} time /> | |
| 273 | − | </span> | |
| 274 | − | <span className="min-w-0 flex-1 text-fg-soft"> | |
| 275 | − | {check.error ? ( | |
| 276 | − | <span className="text-danger">Failed: {check.error}</span> | |
| 277 | − | ) : ( | |
| 278 | − | <> | |
| 279 | − | {check.listed} listed | |
| 280 | − | {check.added.length > 0 && <>; new: {check.added.join(", ")}</>} | |
| 281 | − | {check.deprecated.length > 0 && <>; gone: {check.deprecated.join(", ")}</>} | |
| 282 | − | </> | |
| 283 | − | )} | |
| 284 | − | </span> | |
| 285 | − | <span className="text-xs text-faint">{check.by === "schedule" ? "Daily check" : check.by}</span> | |
| 286 | − | </li> | |
| 287 | − | ))} | |
| 288 | − | </ul> | |
| 289 | − | )} | |
| 290 | − | </Section> | |
| 291 | − | </> | |
| 292 | − | ); | |
| 293 | − | } | |
| 294 | − | ||
| 295 | − | function Defaults({ models, failure }: { models: AdminModels; failure: Failure }) { | |
| 296 | − | const choices = choicesFor(models.catalogue); | |
| 297 | − | const current = defaultsByPurpose(models.defaults); | |
| 298 | − | return ( | |
| 299 | − | <Section | |
| 300 | − | id="defaults" | |
| 301 | − | className="mt-6" | |
| 302 | − | title="Defaults" | |
| 303 | − | description="What Auto runs each tier on, the harness's background model, the AI Gateway's first Claude, and where each kind of job starts and how hard it thinks. Runs read these within a minute; a model that is retired or no longer listed is never used, and the next suitable one runs instead." | |
| 304 | − | > | |
| 305 | − | <ul className="divide-y divide-line"> | |
| 306 | − | {MODEL_PURPOSES.map((info) => { | |
| 307 | − | const row = current.get(info.purpose); | |
| 308 | − | const resolved = models.resolved.models.find((m) => m.purpose === info.purpose); | |
| 309 | − | const running = resolved?.model ? models.catalogue.find((m) => m.model === resolved.model!.model) : undefined; | |
| 310 | − | const error = failure?.target === `default-${info.purpose}` ? failure : null; | |
| 311 | − | return ( | |
| 312 | − | <li key={info.purpose} id={`default-${info.purpose}`} className="scroll-mt-20 py-4 first:pt-0 last:pb-0"> | |
| 313 | − | <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> | |
| 314 | − | <div> | |
| 315 | − | <h3 className="text-sm font-semibold">{info.label}</h3> | |
| 316 | − | <p className="text-xs text-muted">{info.about}</p> | |
| 317 | − | </div> | |
| 318 | − | <div className="text-right text-sm"> | |
| 319 | − | <span className="font-medium">{running?.name ?? resolved?.chosen ?? "Not set"}</span> | |
| 320 | − | {running && running.typicalRunMicros > 0 && <span className="ml-2 text-xs text-muted">{runCost(running.typicalRunMicros)} a typical run</span>} | |
| 321 | − | </div> | |
| 322 | − | </div> | |
| 323 | − | {resolved?.note && ( | |
| 324 | − | <div className="mt-2"> | |
| 325 | − | <Notice tone="warn">{resolved.note}</Notice> | |
| 326 | − | </div> | |
| 327 | − | )} | |
| 328 | − | {row && ( | |
| 329 | − | <p className="mt-1 text-xs text-faint"> | |
| 330 | − | Set by {row.updatedBy} <When at={row.updatedAt} /> | |
| 331 | − | {row.reason ? `: ${row.reason}` : ""} | |
| 332 | − | </p> | |
| 333 | − | )} | |
| 334 | − | <form method="post" action={`/agents#review`} className="mt-3 grid gap-2 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto] sm:items-end"> | |
| 335 | − | <input type="hidden" name="intent" value="default" /> | |
| 336 | − | <input type="hidden" name="purpose" value={info.purpose} /> | |
| 337 | − | <Select name="model" defaultValue={row?.model ?? ""} aria-label={`Model for ${info.label}`}> | |
| 338 | − | {choices.map((m) => ( | |
| 339 | − | <option key={m.model} value={m.model}> | |
| 340 | − | {m.name} · {runCost(m.typicalRunMicros)} | |
| 341 | − | </option> | |
| 342 | − | ))} | |
| 343 | − | </Select> | |
| 344 | − | <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why change ${info.label}`} defaultValue={error?.values?.reason} /> | |
| 345 | − | <Button type="submit" variant="quiet"> | |
| 346 | − | Review | |
| 347 | − | </Button> | |
| 348 | − | </form> | |
| 349 | − | {error && ( | |
| 350 | − | <div className="mt-2"> | |
| 351 | − | <Notice tone="error">{error.error}</Notice> | |
| 352 | − | </div> | |
| 353 | − | )} | |
| 354 | − | </li> | |
| 355 | − | ); | |
| 356 | − | })} | |
| 357 | − | </ul> | |
| 358 | − | <h3 className="mt-6 border-t border-line pt-4 text-sm font-semibold">Jobs</h3> | |
| 359 | − | <p className="text-xs text-muted"> | |
| 360 | − | Where each kind of job starts. Failures, labels and the repository's own history still move it up or down. Effort applies on models that take | |
| 361 | − | it; on one that does not, the harness's own. | |
| 362 | − | </p> | |
| 363 | − | <ul className="mt-3 divide-y divide-line"> | |
| 364 | − | {JOBS.map((job) => { | |
| 365 | − | const purpose = `job_${job.kind}`; | |
| 366 | − | const row = current.get(purpose); | |
| 367 | − | const error = failure?.target === `default-${purpose}` ? failure : null; | |
| 368 | − | const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"]; | |
| 369 | − | return ( | |
| 370 | − | <li key={job.kind} id={`default-${purpose}`} className="scroll-mt-20 py-3 first:pt-0 last:pb-0"> | |
| 371 | − | <form method="post" action="/agents#review" className="grid gap-2 sm:grid-cols-[10rem_minmax(0,11rem)_minmax(0,9rem)_minmax(0,1fr)_auto] sm:items-center"> | |
| 372 | − | <input type="hidden" name="intent" value="default" /> | |
| 373 | − | <input type="hidden" name="purpose" value={purpose} /> | |
| 374 | − | <div> | |
| 375 | − | <span className="text-sm font-medium">{job.label}</span> | |
| 376 | − | {row && ( | |
| 377 | − | <span className="block text-xs text-faint"> | |
| 378 | − | {row.updatedBy} <When at={row.updatedAt} /> | |
| 379 | − | </span> | |
| 380 | − | )} | |
| 381 | − | </div> | |
| 382 | − | <Select name="tier" defaultValue={row?.tier ?? "large"} aria-label={`Starting tier for ${job.label}`}> | |
| 383 | − | {tiers.map((tier) => ( | |
| 384 | − | <option key={tier} value={tier}> | |
| 385 | − | {TIER_LABELS[tier]} | |
| 386 | − | </option> | |
| 387 | − | ))} | |
| 388 | − | </Select> | |
| 389 | − | <Select name="effort" defaultValue={row?.effort ?? ""} aria-label={`Effort for ${job.label}`}> | |
| 390 | − | <option value="">Harness's own</option> | |
| 391 | − | {EFFORTS.map((effort) => ( | |
| 392 | − | <option key={effort} value={effort}> | |
| 393 | − | {effort} effort | |
| 394 | − | </option> | |
| 395 | − | ))} | |
| 396 | − | </Select> | |
| 397 | − | <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why change ${job.label}`} /> | |
| 398 | − | <Button type="submit" variant="quiet"> | |
| 399 | − | Review | |
| 400 | − | </Button> | |
| 401 | − | </form> | |
| 402 | − | {error && ( | |
| 403 | − | <div className="mt-2"> | |
| 404 | − | <Notice tone="error">{error.error}</Notice> | |
| 405 | − | </div> | |
| 406 | − | )} | |
| 407 | − | </li> | |
| 408 | − | ); | |
| 409 | − | })} | |
| 410 | − | </ul> | |
| 411 | − | </Section> | |
| 412 | − | ); | |
| 413 | − | } | |
| 414 | − | ||
| 415 | − | function Waiting({ model, failure }: { model: CatalogueModel; failure: Failure }) { | |
| 416 | − | const prices = pricesOf(model); | |
| 417 | − | const value = (name: string, fallback: string) => failure?.values?.[name] ?? fallback; | |
| 418 | − | return ( | |
| 419 | − | <li id={`model-${model.model}`} className="scroll-mt-20 rounded-lg border border-line bg-bg p-4 sm:p-5"> | |
| 420 | − | <div className="flex flex-wrap items-center gap-2"> | |
| 421 | − | <span className="font-medium">{model.name}</span> | |
| 422 | − | <code className="text-xs text-muted">{model.model}</code> | |
| 423 | − | <Badge>{PROVIDER[model.provider] ?? model.provider}</Badge> | |
| 424 | − | {!model.priced && <Badge tone="warn">No price known</Badge>} | |
| 425 | − | </div> | |
| 426 | − | <p className="mt-1 text-xs text-muted"> | |
| 427 | − | Found <When at={model.firstSeenAt} /> · {model.kind === "embeddings" ? "Embeddings" : "Chat"} · context {tokens(model.contextWindow)} | |
| 428 | − | {model.capabilities.length > 0 && <> · {model.capabilities.join(", ")}</>} | |
| 429 | − | {model.priced && " · prices filled from the provider; check them"} | |
| 430 | − | </p> | |
| 431 | − | <form method="post" action={`/agents#model-${model.model}`} className="mt-4 space-y-3"> | |
| 432 | − | <input type="hidden" name="intent" value="approve" /> | |
| 433 | − | <input type="hidden" name="model" value={model.model} /> | |
| 434 | − | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]"> | |
| 435 | − | <Field label="Name people see"> | |
| 436 | − | <Input name="name" required maxLength={120} defaultValue={value("name", model.name)} /> | |
| 437 | − | </Field> | |
| 438 | − | <Field label="Suits the tier"> | |
| 439 | − | <Select name="tier_hint" defaultValue={value("tier_hint", model.tierHint)}> | |
| 440 | − | <option value="">None</option> | |
| 441 | − | <option value="small">Fast</option> | |
| 442 | − | <option value="large">Standard</option> | |
| 443 | − | <option value="frontier">Most capable</option> | |
| 444 | − | </Select> | |
| 445 | − | </Field> | |
| 446 | − | </div> | |
| 447 | − | <fieldset> | |
| 448 | − | <legend className="mb-1.5 text-sm font-medium text-muted">Dollars per million tokens</legend> | |
| 449 | − | <div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> | |
| 450 | − | {PRICE_FIELDS.map((field) => ( | |
| 451 | − | <Field key={field.name} label={field.label}> | |
| 452 | − | <Input name={field.name} inputMode="decimal" className="font-mono" defaultValue={value(field.name, model.priced ? perMillion(prices[field.key]) : "")} /> | |
| 453 | − | </Field> | |
| 454 | − | ))} | |
| 455 | − | </div> | |
| 456 | − | </fieldset> | |
| 457 | − | <details className="rounded-md border border-line px-3 py-2" open={prices.threshold > 0}> | |
| 458 | − | <summary className="cursor-pointer text-sm text-muted">Priced by prompt length</summary> | |
| 459 | − | <div className="mt-3 space-y-3"> | |
| 460 | − | <Field label="Above this many prompt tokens" hint="The whole request is charged at the prices below once its prompt (input and cache tokens) is longer."> | |
| 461 | − | <Input name="threshold" inputMode="numeric" className="font-mono" defaultValue={value("threshold", prices.threshold ? String(prices.threshold) : "")} /> | |
| 462 | − | </Field> | |
| 463 | − | <div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> | |
| 464 | − | {OVER_FIELDS.map((field) => ( | |
| 465 | − | <Field key={field.name} label={field.label}> | |
| 466 | − | <Input name={field.name} inputMode="decimal" className="font-mono" defaultValue={value(field.name, prices[field.key] ? perMillion(prices[field.key]) : "")} /> | |
| 467 | − | </Field> | |
| 468 | − | ))} | |
| 469 | − | </div> | |
| 470 | − | </div> | |
| 471 | − | </details> | |
| 472 | − | <Field label="Why" hint="Kept with the model, and in the audit log."> | |
| 473 | − | <Input name="reason" required maxLength={MAX_REASON} placeholder="e.g. Prices from the provider's price page, 2026-10-08." defaultValue={value("reason", "")} /> | |
| 474 | − | </Field> | |
| 475 | − | {failure && <Notice tone="error">{failure.error}</Notice>} | |
| 476 | − | <div className="flex justify-end"> | |
| 477 | − | <Button type="submit" variant="lavender"> | |
| 478 | − | <Check size={14} /> | |
| 479 | − | Approve | |
| 480 | − | </Button> | |
| 481 | − | </div> | |
| 482 | − | </form> | |
| 483 | − | <Decide model={model} intent="retire" label="Retire instead" /> | |
| 484 | − | </li> | |
| 485 | − | ); | |
| 486 | − | } | |
| 487 | − | ||
| 488 | − | /** A retire or restore form, folded away until opened. */ | |
| 489 | − | function Decide({ model, intent, label }: { model: CatalogueModel; intent: "retire" | "restore"; label: string }) { | |
| 490 | − | return ( | |
| 491 | − | <details className="mt-3"> | |
| 492 | − | <summary className="cursor-pointer text-xs text-muted hover:text-fg">{label}</summary> | |
| 493 | − | <form method="post" action={`/agents#model-${model.model}`} className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 494 | − | <input type="hidden" name="intent" value={intent} /> | |
| 495 | − | <input type="hidden" name="model" value={model.model} /> | |
| 496 | − | <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why ${intent} ${model.name}`} /> | |
| 497 | − | <Button type="submit" variant={intent === "retire" ? "danger" : "quiet"}> | |
| 498 | − | {intent === "retire" ? "Retire" : "Restore"} | |
| 499 | − | </Button> | |
| 500 | − | </form> | |
| 501 | − | </details> | |
| 502 | − | ); | |
| 503 | − | } | |
| 504 | − | ||
| 505 | − | function Catalogue({ catalogue, failure }: { catalogue: CatalogueModel[]; failure: Failure }) { | |
| 506 | − | const listed = catalogue.filter((m) => m.status !== "new"); | |
| 507 | − | return ( | |
| 508 | − | <div className="-mx-4 overflow-x-auto sm:-mx-5"> | |
| 509 | − | <table className="w-full min-w-[56rem] text-sm"> | |
| 510 | − | <thead> | |
| 511 | − | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 512 | − | <th className="px-4 py-2 font-medium sm:px-5">Model</th> | |
| 513 | − | <th className="px-4 py-2 font-medium">Status</th> | |
| 514 | − | <th className="px-4 py-2 font-medium">Suits</th> | |
| 515 | − | <th className="px-4 py-2 text-right font-medium">Context</th> | |
| 516 | − | <th className="px-4 py-2 text-right font-medium">In / out</th> | |
| 517 | − | <th className="px-4 py-2 text-right font-medium">Cache read</th> | |
| 518 | − | <th className="px-4 py-2 text-right font-medium">Typical run</th> | |
| 519 | − | <th className="px-4 py-2 font-medium sm:pr-5">Listed</th> | |
| 520 | − | </tr> | |
| 521 | − | </thead> | |
| 522 | − | <tbody> | |
| 523 | − | {listed.map((model) => { | |
| 524 | − | const status = STATUS[model.status] ?? STATUS.available; | |
| 525 | − | const error = failure?.target === `model-${model.model}` ? failure : null; | |
| 526 | − | return ( | |
| 527 | − | <tr key={model.model} id={`model-${model.model}`} className="scroll-mt-20 border-b border-line align-top last:border-0"> | |
| 528 | − | <td className="px-4 py-2.5 sm:px-5"> | |
| 529 | − | <span className="font-medium">{model.name}</span> | |
| 530 | − | <code className="block text-xs text-muted">{model.model}</code> | |
| 531 | − | {model.aliases.length > 0 && <span className="block text-xs text-faint">also {model.aliases.join(", ")}</span>} | |
| 532 | − | <span className="block text-xs text-faint">{PROVIDER[model.provider] ?? model.provider}</span> | |
| 533 | − | {error && <span className="mt-1 block text-xs text-danger">{error.error}</span>} | |
| 534 | − | </td> | |
| 535 | − | <td className="px-4 py-2.5"> | |
| 536 | − | <Badge tone={status.tone}>{status.label}</Badge> | |
| 537 | − | {!model.priced && ( | |
| 538 | − | <span className="mt-1 block"> | |
| 539 | − | <Badge tone="warn">Unpriced</Badge> | |
| 540 | − | </span> | |
| 541 | − | )} | |
| 542 | − | {model.status === "available" ? ( | |
| 543 | − | <Decide model={model} intent="retire" label="Retire" /> | |
| 544 | − | ) : ( | |
| 545 | − | <Decide model={model} intent="restore" label="Restore" /> | |
| 546 | − | )} | |
| 547 | − | </td> | |
| 548 | − | <td className="px-4 py-2.5 text-muted">{model.kind === "embeddings" ? `Embeddings${model.dimensions ? `, ${model.dimensions}` : ""}` : TIER_LABELS[model.tierHint] ?? "—"}</td> | |
| 549 | − | <td className="px-4 py-2.5 text-right font-mono tabular-nums">{tokens(model.contextWindow)}</td> | |
| 550 | − | <td className="px-4 py-2.5 text-right font-mono tabular-nums whitespace-nowrap"> | |
| 551 | − | ${perMillion(model.inputMicros)} | |
| 552 | − | {model.kind !== "embeddings" && <> / ${perMillion(model.outputMicros)}</>} | |
| 553 | − | {model.threshold ? <span className="block text-xs text-faint">over {tokens(model.threshold)}: ${perMillion(model.overInputMicros)} / ${perMillion(model.overOutputMicros)}</span> : null} | |
| 554 | − | </td> | |
| 555 | − | <td className="px-4 py-2.5 text-right font-mono tabular-nums">{model.kind === "embeddings" ? "—" : `$${perMillion(model.cacheReadMicros)}`}</td> | |
| 556 | − | <td className="px-4 py-2.5 text-right font-mono tabular-nums">{runCost(model.typicalRunMicros)}</td> | |
| 557 | − | <td className="px-4 py-2.5 text-xs text-muted sm:pr-5"> | |
| 558 | − | {model.missingSince ? ( | |
| 559 | − | <span className="text-warn"> | |
| 560 | − | Not since <When at={model.missingSince} /> | |
| 561 | − | </span> | |
| 562 | − | ) : model.lastSeenAt ? ( | |
| 563 | − | <When at={model.lastSeenAt} /> | |
| 564 | − | ) : ( | |
| 565 | − | "Not checked yet" | |
| 566 | − | )} | |
| 567 | − | </td> | |
| 568 | − | </tr> | |
| 569 | − | ); | |
| 570 | − | })} | |
| 571 | − | </tbody> | |
| 572 | − | </table> | |
| 573 | − | </div> | |
| 574 | − | ); | |
| 575 | − | } |
| 26 | 26 | export default function Costs({ loaderData, actionData }: Route.ComponentProps) { | |
| 27 | 27 | const { range, report, error, done } = loaderData; | |
| 28 | 28 | const failed = actionData && "error" in actionData ? (actionData as CostsActionResult) : null; | |
| 29 | − | const description = `What g1t cost to run (Cloudflare's bill and the model providers') against what workspaces paid, with what g1t gave away on purpose kept apart. Prices are cost plus ${MARKUP_PERCENT}%, a ${percentLabel(marginOnPrice(MARKUP_PERCENT))} margin: every margin here is a share of the price, not of the cost. The bill itself, drift and the price book are on Bill & pricing.`; | |
| 29 | + | const description = `What g1t cost to run (Cloudflare's bill) against what workspaces paid, with what g1t gave away on purpose kept apart. Prices are cost plus ${MARKUP_PERCENT}%, a ${percentLabel(marginOnPrice(MARKUP_PERCENT))} margin: every margin here is a share of the price, not of the cost. The bill itself, drift and the price book are on Bill & pricing.`; | |
| 30 | 30 | if (!report) { | |
| 31 | 31 | return ( | |
| 32 | 32 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> | |
| ⋯ | |||
| 229 | 229 | const spent = paid.totalMicros; | |
| 230 | 230 | const net = moneyIn - spent; | |
| 231 | 231 | const givenParts = givenBy(o); | |
| 232 | − | const gateway = o.gatewayCostMicros ?? 0; | |
| 233 | 232 | const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [ | |
| 234 | 233 | { | |
| 235 | 234 | title: "Usage sold", | |
| ⋯ | |||
| 298 | 297 | <Stat | |
| 299 | 298 | label="Who g1t paid" | |
| 300 | 299 | value={usd(paid.totalMicros, { cents: true })} | |
| 301 | − | hint={`Cloudflare's usage ${usd(paid.cloudflareMicros, { cents: true })} and subscriptions ${usd(paid.subscriptionsMicros, { cents: true })}, model providers ${usd(paid.modelsMicros, { cents: true })}${gateway > 0 && Math.abs(gateway - paid.modelsMicros) >= 10_000 ? ` (AI Gateway priced them at ${usd(gateway, { cents: true })}; see Drift)` : ""}; the cost in All in`} | |
| 300 | + | hint={`Cloudflare's usage ${usd(paid.cloudflareMicros, { cents: true })} and subscriptions ${usd(paid.subscriptionsMicros, { cents: true })}${paid.modelsMicros > 0 ? `, model providers ${usd(paid.modelsMicros, { cents: true })} (from before agents were retired)` : ""}; the cost in All in`} | |
| 302 | 301 | /> | |
| 303 | 302 | <Stat | |
| 304 | 303 | label="Proposals waiting" | |
| ⋯ | |||
| 393 | 392 | className="mt-6" | |
| 394 | 393 | id="spend" | |
| 395 | 394 | title="g1t's own spend" | |
| 396 | − | description="What g1t pays for itself this calendar month, today included: accounts on a 100% discount, the trial and open-source pools, free workspaces' overruns, and usage charged without real money behind it. Each is what the work cost g1t, never its price, counted as each charge settled: the model providers' cost, and the price book's cost of sandboxes and builds. Two caps hold it: each 100%-discount account's monthly budget, and a daily breaker on all of it that pauses new hosted-model agent runs g1t would pay for." | |
| 395 | + | description="What g1t pays for itself this calendar month, today included: accounts on a 100% discount, the trial and open-source pools, free workspaces' overruns, and usage charged without real money behind it. Each is what the work cost g1t, never its price, counted as each charge settled: the price book's cost of sandboxes and builds. Two caps hold it: each 100%-discount account's monthly budget, and a daily breaker on all of it that pauses new work g1t would pay for." | |
| 397 | 396 | > | |
| 398 | 397 | <div className="grid gap-3 lg:grid-cols-2"> | |
| 399 | 398 | <CapMeter | |
| ⋯ | |||
| 403 | 402 | hint={ | |
| 404 | 403 | caps.tripped ? ( | |
| 405 | 404 | <span className="text-danger"> | |
| 406 | − | Breaker open{caps.trippedAt ? <> since <When at={caps.trippedAt} time /></> : null}: new hosted-model runs g1t pays for wait until 00:00 UTC. | |
| 405 | + | Breaker open{caps.trippedAt ? <> since <When at={caps.trippedAt} time /></> : null}: new work g1t pays for waits until 00:00 UTC. | |
| 407 | 406 | </span> | |
| 408 | 407 | ) : caps.liftedBy ? ( | |
| 409 | 408 | <> | |
| 535 | 535 | ["Card check", e.trialVerified ? "Done" : "Not yet"], | |
| 536 | 536 | ["Trial left", usd(e.trialMicrosLeft)], | |
| 537 | 537 | ["First month", e.firstMonth ? "Yes" : "No"], | |
| 538 | − | ["Agents at once", String(e.maxConcurrentAgents)], | |
| 539 | 538 | ["Longest run", e.maxRunMinutes ? `${e.maxRunMinutes} min` : "The guardrails'"], | |
| 540 | − | ["Run cap", usd(e.runCapMicros)], | |
| 541 | − | ["Issue cap", usd(e.issueCapMicros)], | |
| 542 | 539 | ["Ceiling", e.ceilingMicros >= 1e15 ? "None" : usd(e.ceilingMicros)], | |
| 543 | 540 | ["Not yet paid", usd(e.exposureMicros)], | |
| 544 | 541 | ["Held by reservations", usd(e.heldMicros ?? 0)], | |
| ⋯ | |||
| 589 | 586 | </div> | |
| 590 | 587 | ))} | |
| 591 | 588 | </dl> | |
| 592 | − | <p className="mt-3 text-xs text-faint">Agents at once, the run and issue caps, and holds are set under Plan, pools and caps below.</p> | |
| 589 | + | <p className="mt-3 text-xs text-faint">Holds are set under Plan, pools and caps below.</p> | |
| 593 | 590 | </Section> | |
| 594 | 591 | ); | |
| 595 | 592 | } | |
| 8 | 8 | EVENTS: ServiceBinding; | |
| 9 | 9 | /** apps/status's `StatusAdmin` entrypoint: see `StatusAdminApi`. */ | |
| 10 | 10 | STATUS: Fetcher; | |
| 11 | − | /** services/models's `Discovery` entrypoint: see `ModelDiscoveryApi`. */ | |
| 12 | − | MODELS: Fetcher; | |
| 13 | 11 | ASSETS: Fetcher; | |
| 14 | 12 | ACCESS_TEAM_DOMAIN: string; | |
| 15 | 13 | ACCESS_AUD: string; |
| 23 | 23 | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 24 | 24 | // The status page's staff-only entrypoint: posting incidents to | |
| 25 | 25 | // status.g1t.sh (apps/status). Only bindings reach it. | |
| 26 | − | { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" }, | |
| 27 | − | // "Check for new models" on Agents & models: the model proxy's | |
| 28 | − | // `Discovery` entrypoint lists each provider's models now and records | |
| 29 | − | // them with billing's catalogue. Only bindings reach it. | |
| 30 | − | { "binding": "MODELS", "service": "g1t-models", "entrypoint": "Discovery" } | |
| 26 | + | { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" } | |
| 31 | 27 | ], | |
| 32 | 28 | "vars": { | |
| 33 | 29 | // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`. |
| 179 | 179 | } | |
| 180 | 180 | ||
| 181 | 181 | /* | |
| 182 | − | * An agent's face (components/agent-face.tsx). Its colours are the face's | |
| 183 | − | * own body colour (set on the element, a light-dark() pair) and these: the | |
| 184 | − | * ink of its eyes and mouth, a second ink for what sits outside the head, | |
| 185 | − | * the rim around its head, a highlight, the glow of a visor or a working | |
| 186 | − | * face, and two accents for a bow and a spark. It moves with transforms and opacity only: a blink and a breath | |
| 187 | − | * on its own beat (the delays are set per face from its seed), a pulse | |
| 188 | − | * while it works, a bounce when it is happy, a z when it sleeps. The | |
| 189 | − | * reduced-motion rule below stops every one of them, and the state's | |
| 190 | − | * still pose (narrowed or closed eyes) is a plain transform, so it holds. | |
| 191 | − | */ | |
| 192 | − | .agent-face { | |
| 193 | − | --face-ink: #1b1b22; | |
| 194 | − | /* What sits outside the head (antenna, hat, headphones, a z) reads on the page, not the body. */ | |
| 195 | − | --face-ink-out: light-dark(#1b1b22, #e9e9ee); | |
| 196 | − | --face-rim: color-mix(in oklab, var(--face-body), #000 24%); | |
| 197 | − | --face-light: #ffffff; | |
| 198 | − | --face-glow: light-dark(#0d7a52, #86efc4); | |
| 199 | − | --face-ribbon: light-dark(#6b52c8, #b6a8ff); | |
| 200 | − | --face-spark: light-dark(#d4a90f, #f2dc72); | |
| 201 | − | display: inline-block; | |
| 202 | − | vertical-align: middle; | |
| 203 | − | } | |
| 204 | − | .agent-face .face-bob, | |
| 205 | − | .agent-face .face-eyes, | |
| 206 | − | .agent-face .face-pupils, | |
| 207 | − | .agent-face .face-glow, | |
| 208 | − | .agent-face .face-bulb, | |
| 209 | − | .agent-face .face-spark, | |
| 210 | − | .agent-face .face-z { | |
| 211 | − | transform-box: fill-box; | |
| 212 | − | transform-origin: center; | |
| 213 | − | } | |
| 214 | − | .agent-face .face-bob { | |
| 215 | − | animation: face-bob 4.2s ease-in-out infinite; | |
| 216 | − | animation-delay: var(--face-bob-delay, 0s); | |
| 217 | − | } | |
| 218 | − | .agent-face .face-eyes { | |
| 219 | − | animation: face-blink var(--face-blink-period, 5s) ease-in-out infinite; | |
| 220 | − | animation-delay: var(--face-blink-delay, 0s); | |
| 221 | − | } | |
| 222 | − | .agent-face .face-pupils { | |
| 223 | − | transition: transform 0.25s ease-out; | |
| 224 | − | } | |
| 225 | − | /* A glance toward the pointer, on hover only. */ | |
| 226 | − | .agent-face:hover .face-pupils, | |
| 227 | − | a:hover > .agent-face .face-pupils, | |
| 228 | − | button:hover > .agent-face .face-pupils { | |
| 229 | − | transform: translateX(2.5px); | |
| 230 | − | } | |
| 231 | − | .agent-face .face-glow { | |
| 232 | − | opacity: 0; | |
| 233 | − | } | |
| 234 | − | .agent-face .face-spark { | |
| 235 | − | animation: face-twinkle 2.8s ease-in-out infinite; | |
| 236 | − | animation-delay: var(--face-bob-delay, 0s); | |
| 237 | − | } | |
| 238 | − | /* Working: eyes narrowed, no blink, a soft pulse around the head and in the bulb. */ | |
| 239 | − | .agent-face[data-state="working"] .face-eyes { | |
| 240 | − | animation: none; | |
| 241 | − | transform: scaleY(0.55); | |
| 242 | − | } | |
| 243 | − | .agent-face[data-state="working"] .face-glow { | |
| 244 | − | animation: face-pulse 1.6s ease-in-out infinite; | |
| 245 | − | } | |
| 246 | − | .agent-face[data-state="working"] .face-bulb { | |
| 247 | − | animation: face-bulb 1.6s ease-in-out infinite; | |
| 248 | − | } | |
| 249 | − | /* Asleep: eyes shut, a slower breath, a z drifting up. */ | |
| 250 | − | .agent-face[data-state="asleep"] .face-eyes { | |
| 251 | − | animation: none; | |
| 252 | − | transform: scaleY(0.1); | |
| 253 | − | } | |
| 254 | − | .agent-face[data-state="asleep"] .face-bob { | |
| 255 | − | animation-duration: 6s; | |
| 256 | − | } | |
| 257 | − | .agent-face[data-state="asleep"] .face-z { | |
| 258 | − | animation: face-zz 2.6s ease-in-out infinite; | |
| 259 | − | } | |
| 260 | − | .agent-face[data-state="asleep"] .face-z-2 { | |
| 261 | − | animation-delay: 1.3s; | |
| 262 | − | } | |
| 263 | − | /* Happy: a bounce, twice, then still. */ | |
| 264 | − | .agent-face[data-state="happy"] .face-bob { | |
| 265 | − | animation: face-happy 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) 2; | |
| 266 | − | } | |
| 267 | − | @keyframes face-blink { | |
| 268 | − | 0%, | |
| 269 | − | 91%, | |
| 270 | − | 100% { | |
| 271 | − | transform: scaleY(1); | |
| 272 | − | } | |
| 273 | − | 94%, | |
| 274 | − | 96% { | |
| 275 | − | transform: scaleY(0.08); | |
| 276 | − | } | |
| 277 | − | } | |
| 278 | − | @keyframes face-bob { | |
| 279 | − | 0%, | |
| 280 | − | 100% { | |
| 281 | − | transform: translateY(0); | |
| 282 | − | } | |
| 283 | − | 50% { | |
| 284 | − | transform: translateY(-1.6px); | |
| 285 | − | } | |
| 286 | − | } | |
| 287 | − | @keyframes face-pulse { | |
| 288 | − | 0%, | |
| 289 | − | 100% { | |
| 290 | − | opacity: 0.15; | |
| 291 | − | } | |
| 292 | − | 50% { | |
| 293 | − | opacity: 0.55; | |
| 294 | − | } | |
| 295 | − | } | |
| 296 | − | @keyframes face-bulb { | |
| 297 | − | 0%, | |
| 298 | − | 100% { | |
| 299 | − | opacity: 0.6; | |
| 300 | − | transform: scale(1); | |
| 301 | − | } | |
| 302 | − | 50% { | |
| 303 | − | opacity: 1; | |
| 304 | − | transform: scale(1.15); | |
| 305 | − | } | |
| 306 | − | } | |
| 307 | − | @keyframes face-twinkle { | |
| 308 | − | 0%, | |
| 309 | − | 100% { | |
| 310 | − | opacity: 0.75; | |
| 311 | − | transform: scale(0.9) rotate(0deg); | |
| 312 | − | } | |
| 313 | − | 50% { | |
| 314 | − | opacity: 1; | |
| 315 | − | transform: scale(1.1) rotate(20deg); | |
| 316 | − | } | |
| 317 | − | } | |
| 318 | − | @keyframes face-zz { | |
| 319 | − | 0% { | |
| 320 | − | opacity: 0; | |
| 321 | − | transform: translate(0, 4px); | |
| 322 | − | } | |
| 323 | − | 30% { | |
| 324 | − | opacity: 1; | |
| 325 | − | } | |
| 326 | − | 100% { | |
| 327 | − | opacity: 0; | |
| 328 | − | transform: translate(5px, -9px); | |
| 329 | − | } | |
| 330 | − | } | |
| 331 | − | @keyframes face-happy { | |
| 332 | − | 0%, | |
| 333 | − | 100% { | |
| 334 | − | transform: translateY(0) scale(1); | |
| 335 | − | } | |
| 336 | − | 40% { | |
| 337 | − | transform: translateY(-6px) scale(1.06); | |
| 338 | − | } | |
| 339 | − | } | |
| 340 | − | ||
| 341 | − | /* | |
| 342 | 182 | * Appearance (lib/theme.ts): with no choice, or Auto, the page follows | |
| 343 | 183 | * the system; a choice of Light or Dark is data-theme on <html>. | |
| 344 | 184 | */ |
| 1 | − | import { | |
| 2 | − | CircleCheck, | |
| 3 | − | CircleDot, | |
| 4 | − | CircleSlash, | |
| 5 | − | GitMerge, | |
| 6 | − | GitPullRequest, | |
| 7 | − | MessageCircleQuestion, | |
| 8 | − | MessageSquare, | |
| 9 | − | Play, | |
| 10 | − | Terminal, | |
| 11 | − | } from "lucide-react"; | |
| 12 | − | import type { ReactNode } from "react"; | |
| 13 | − | import { Link } from "react-router"; | |
| 14 | − | ||
| 15 | − | import type { AgentMessage, G1tEvent } from "@g1t/contracts"; | |
| 16 | − | ||
| 17 | − | import { TimeAgo } from "./ui"; | |
| 18 | − | import { Avatar } from "./ui/avatar"; | |
| 19 | − | ||
| 20 | − | /** Whether an actor is g1t itself: its agent or its machinery. */ | |
| 21 | − | const isG1t = (actor: string | null): actor is string => actor === "g1t"; | |
| 22 | − | ||
| 23 | − | type Line = { icon: ReactNode; tone: string; actor: string | null; text: ReactNode; coordination?: boolean }; | |
| 24 | − | ||
| 25 | − | /** One event as a sentence, or null for those not worth a line. */ | |
| 26 | − | function line(event: G1tEvent, base: string): Line | null { | |
| 27 | − | const ref = (number: number) => ( | |
| 28 | − | <Link to={`${base}/issues/${number}`} prefetch="intent" className="font-medium text-fg hover:underline"> | |
| 29 | − | #{number} | |
| 30 | − | </Link> | |
| 31 | − | ); | |
| 32 | − | const actor = event.actor; | |
| 33 | − | switch (event.type) { | |
| 34 | − | case "issue.opened": | |
| 35 | − | return { | |
| 36 | − | icon: <CircleDot size={14} />, | |
| 37 | − | tone: isG1t(actor) ? "text-accent" : "text-muted", | |
| 38 | − | actor, | |
| 39 | − | text: isG1t(actor) ? <>filed {ref(event.data.number)} for something it found</> : <>opened {ref(event.data.number)}</>, | |
| 40 | − | coordination: isG1t(actor), | |
| 41 | − | }; | |
| 42 | − | case "pull.opened": | |
| 43 | − | return { | |
| 44 | − | icon: <Play size={14} />, | |
| 45 | − | tone: "text-accent", | |
| 46 | − | actor: event.data.agent, | |
| 47 | − | text: ( | |
| 48 | − | <> | |
| 49 | − | started on {event.data.issue != null ? ref(event.data.issue) : "a change"} in {ref(event.data.number)} | |
| 50 | − | </> | |
| 51 | − | ), | |
| 52 | − | }; | |
| 53 | − | case "pull.ready": | |
| 54 | − | return { icon: <GitPullRequest size={14} />, tone: "text-info", actor, text: <>marked {ref(event.data.number)} ready for review</> }; | |
| 55 | − | case "checks.completed": | |
| 56 | − | return { | |
| 57 | − | icon: <Terminal size={14} />, | |
| 58 | − | tone: event.data.status === "passed" ? "text-success" : "text-danger", | |
| 59 | − | actor: null, | |
| 60 | − | text: ( | |
| 61 | − | <> | |
| 62 | − | checks {event.data.status} on {ref(event.data.number)} | |
| 63 | − | </> | |
| 64 | − | ), | |
| 65 | − | }; | |
| 66 | − | case "review.completed": | |
| 67 | − | return { | |
| 68 | − | icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />, | |
| 69 | − | tone: event.data.verdict === "approve" ? "text-success" : "text-warn", | |
| 70 | − | actor: "g1t", | |
| 71 | − | text: ( | |
| 72 | − | <> | |
| 73 | − | {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)} | |
| 74 | − | </> | |
| 75 | − | ), | |
| 76 | − | }; | |
| 77 | − | case "comment.created": | |
| 78 | − | // One of the workspace's agents, as itself: named, never the person it acted for. | |
| 79 | − | if (event.data.agent) { | |
| 80 | − | const said = event.data.verdict === "approve" ? "approved" : event.data.verdict === "request_changes" ? "asked for changes on" : event.data.advisory ? "reviewed" : "commented on"; | |
| 81 | − | return { | |
| 82 | − | icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : event.data.verdict ? <CircleSlash size={14} /> : <MessageSquare size={14} />, | |
| 83 | − | tone: "text-muted", | |
| 84 | − | actor: `${event.data.agent.displayName} (agent)`, | |
| 85 | − | text: ( | |
| 86 | − | <> | |
| 87 | − | {said} {ref(event.data.number)} | |
| 88 | − | {event.data.advisory && " (advisory)"} | |
| 89 | − | </> | |
| 90 | − | ), | |
| 91 | − | }; | |
| 92 | − | } | |
| 93 | − | if (event.data.verdict) { | |
| 94 | − | return { | |
| 95 | − | icon: event.data.verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />, | |
| 96 | − | tone: event.data.verdict === "approve" ? "text-success" : "text-warn", | |
| 97 | − | actor, | |
| 98 | − | text: ( | |
| 99 | − | <> | |
| 100 | − | {event.data.verdict === "approve" ? "approved" : "asked for changes on"} {ref(event.data.number)} | |
| 101 | − | </> | |
| 102 | − | ), | |
| 103 | − | }; | |
| 104 | − | } | |
| 105 | − | return { | |
| 106 | − | icon: <MessageSquare size={14} />, | |
| 107 | − | tone: isG1t(actor) ? "text-accent" : "text-muted", | |
| 108 | − | actor, | |
| 109 | − | text: <>commented on {ref(event.data.number)}</>, | |
| 110 | − | coordination: isG1t(actor), | |
| 111 | − | }; | |
| 112 | − | case "pull.merged": | |
| 113 | − | return { icon: <GitMerge size={14} />, tone: "text-accent", actor, text: <>landed {ref(event.data.number)} on main</> }; | |
| 114 | − | case "agent.asked": | |
| 115 | − | return { icon: <MessageCircleQuestion size={14} />, tone: "text-accent", actor, text: <>asked the agent on {ref(event.data.number)}, and g1t woke it to answer</> }; | |
| 116 | − | case "pull.merge_requested": | |
| 117 | − | return { icon: <GitMerge size={14} />, tone: "text-muted", actor, text: <>is bringing {ref(event.data.number)} up to date</> }; | |
| 118 | − | default: | |
| 119 | − | return null; | |
| 120 | − | } | |
| 121 | − | } | |
| 122 | − | ||
| 123 | − | /** | |
| 124 | − | * What happened across an outcome's issues and pull requests, newest | |
| 125 | − | * first. Agents talking to each other through the forge stand out. | |
| 126 | − | */ | |
| 127 | − | export function Activity({ events, base }: { events: G1tEvent[]; base: string }) { | |
| 128 | − | const lines = events.map((event) => ({ event, line: line(event, base) })).filter((item) => item.line != null); | |
| 129 | − | if (lines.length === 0) return null; | |
| 130 | − | return ( | |
| 131 | − | <ol className="space-y-0.5"> | |
| 132 | − | {lines.map(({ event, line }) => ( | |
| 133 | − | <li | |
| 134 | − | key={event.id} | |
| 135 | − | className={`flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm ${ | |
| 136 | − | line!.coordination ? "bg-accent/[0.07] ring-1 ring-accent/25" : "" | |
| 137 | − | }`} | |
| 138 | − | > | |
| 139 | − | <span className={`shrink-0 ${line!.tone}`}>{line!.icon}</span> | |
| 140 | − | {line!.actor && ( | |
| 141 | − | <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-fg-soft"> | |
| 142 | − | <Avatar name={line!.actor} size={14} /> | |
| 143 | − | {line!.actor} | |
| 144 | − | </span> | |
| 145 | − | )} | |
| 146 | − | <span className="min-w-0 grow truncate text-muted">{line!.text}</span> | |
| 147 | − | <span className="shrink-0 text-xs text-faint"> | |
| 148 | − | <TimeAgo at={event.time} /> | |
| 149 | − | </span> | |
| 150 | − | </li> | |
| 151 | − | ))} | |
| 152 | − | </ol> | |
| 153 | − | ); | |
| 154 | − | } | |
| 155 | − | ||
| 156 | − | /** | |
| 157 | − | * Questions and handoffs between the agents on an outcome's pull requests, | |
| 158 | − | * each with where it stands: asked, read, answered or declined. | |
| 159 | − | */ | |
| 160 | − | export function Exchanges({ exchanges, base }: { exchanges: AgentMessage[]; base: string }) { | |
| 161 | − | if (exchanges.length === 0) return null; | |
| 162 | − | const pull = (number: number) => ( | |
| 163 | − | <Link to={`${base}/pull/${number}`} prefetch="intent" className="font-mono text-fg hover:underline"> | |
| 164 | − | #{number} | |
| 165 | − | </Link> | |
| 166 | − | ); | |
| 167 | − | return ( | |
| 168 | − | <ul className="space-y-3"> | |
| 169 | − | {exchanges.map((exchange) => { | |
| 170 | − | const state = exchange.answer | |
| 171 | − | ? exchange.declined | |
| 172 | − | ? { label: "Declined", tone: "text-warn ring-warn/40" } | |
| 173 | − | : { label: exchange.kind === "handoff" ? "Taken on" : "Answered", tone: "text-success ring-success/40" } | |
| 174 | − | : exchange.deliveredAt | |
| 175 | − | ? { label: "Read", tone: "text-info ring-info/40" } | |
| 176 | − | : { label: "Waiting to be read", tone: "text-faint ring-line" }; | |
| 177 | − | return ( | |
| 178 | − | <li key={exchange.id} className="rounded-2xl bg-accent/[0.05] p-4 ring-1 ring-accent/25"> | |
| 179 | − | <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted"> | |
| 180 | − | <Avatar name="g1t" size={14} /> | |
| 181 | − | <span> | |
| 182 | − | The agent on {exchange.fromNumber != null ? pull(exchange.fromNumber) : exchange.author}{" "} | |
| 183 | − | {exchange.kind === "handoff" ? "handed work to" : "asked"} the agent on {pull(exchange.toNumber)} | |
| 184 | − | </span> | |
| 185 | − | <span className={`ml-auto rounded-full px-2 py-0.5 ring-1 ${state.tone}`}>{state.label}</span> | |
| 186 | − | </p> | |
| 187 | − | <p className="mt-2 text-sm">{exchange.body}</p> | |
| 188 | − | {exchange.answer && ( | |
| 189 | − | <p className="mt-2 border-l-2 border-accent/40 pl-3 text-sm text-fg-soft">{exchange.answer}</p> | |
| 190 | − | )} | |
| 191 | − | </li> | |
| 192 | − | ); | |
| 193 | − | })} | |
| 194 | − | </ul> | |
| 195 | − | ); | |
| 196 | − | } |
| 1 | − | import type { AgentLook, AgentStatus } from "@g1t/contracts"; | |
| 2 | − | ||
| 3 | − | import { type FaceState, faceStateOf } from "./agent-face"; | |
| 4 | 1 | import { Avatar } from "./ui/avatar"; | |
| 5 | 2 | ||
| 6 | − | /** What an agent's face is drawn from. */ | |
| 3 | + | /** | |
| 4 | + | * Who an agent's avatar is drawn for: g1t itself, or the author of an old | |
| 5 | + | * message from an agent that no longer exists. Extra fields older callers | |
| 6 | + | * pass (a face, a status) are accepted and not drawn. | |
| 7 | + | */ | |
| 7 | 8 | export type AgentLike = { | |
| 8 | 9 | id?: string; | |
| 9 | 10 | /** Its handle, or a member's name. */ | |
| ⋯ | |||
| 11 | 12 | name?: string; | |
| 12 | 13 | avatar?: string | null; | |
| 13 | 14 | avatar_seed?: string | null; | |
| 14 | − | /** Its chosen face; null or missing, the seed's. */ | |
| 15 | − | look?: AgentLook | null; | |
| 15 | + | look?: unknown; | |
| 16 | 16 | builtin?: boolean; | |
| 17 | − | /** Its status, when the place it shows should show it on its face. */ | |
| 18 | − | status?: AgentStatus | null; | |
| 17 | + | status?: unknown; | |
| 19 | 18 | }; | |
| 20 | − | ||
| 21 | − | /** The seed an agent's face grows from: its own, else its id, else its handle. */ | |
| 22 | − | export function seedOf(agent: AgentLike): string { | |
| 23 | − | return agent.avatar_seed || agent.id || agent.handle || agent.name || "agent"; | |
| 24 | − | } | |
| 25 | 19 | ||
| 26 | 20 | /** | |
| 27 | − | * An agent's face wherever it appears: the shared Avatar with `agent` set | |
| 28 | − | * (components/ui/avatar.tsx), so it is an uploaded picture, g1t's pixel 1, | |
| 29 | − | * or its bot face, always with the agent marker. `state` says what the | |
| 30 | − | * face is doing; left out, the agent's status decides, if it is known. | |
| 21 | + | * An agent's avatar wherever it appears: the shared Avatar with `agent` | |
| 22 | + | * set (components/ui/avatar.tsx), so it is g1t's pixel 1, an uploaded | |
| 23 | + | * picture, or its initial on a tile, always with the agent marker. | |
| 31 | 24 | */ | |
| 32 | − | export function AgentAvatar({ agent, size = 20, state, ring }: { agent: AgentLike; size?: number; state?: FaceState; ring?: string }) { | |
| 25 | + | export function AgentAvatar({ agent, size = 20, ring }: { agent: AgentLike; size?: number; state?: unknown; ring?: string }) { | |
| 33 | 26 | const g1t = agent.builtin || agent.handle === "g1t" || agent.name === "g1t"; | |
| 34 | − | return ( | |
| 35 | − | <Avatar | |
| 36 | − | name={agent.handle ?? agent.name ?? "agent"} | |
| 37 | − | image={agent.avatar} | |
| 38 | − | system={g1t} | |
| 39 | − | size={size} | |
| 40 | − | ring={ring} | |
| 41 | − | agent={{ look: agent.look ?? null, seed: seedOf(agent), state: state ?? faceStateOf(agent.status) }} | |
| 42 | − | /> | |
| 43 | − | ); | |
| 27 | + | return <Avatar name={agent.handle ?? agent.name ?? "agent"} image={agent.avatar} system={g1t} size={size} ring={ring} agent />; | |
| 44 | 28 | } | |
| 1 | − | import { ArrowUpRight, LoaderCircle, Sparkles } from "lucide-react"; | |
| 2 | − | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 3 | − | import { Link, useFetcher } from "react-router"; | |
| 4 | − | ||
| 5 | − | import type { RepoPath } from "@g1t/contracts"; | |
| 6 | − | ||
| 7 | − | import { cn } from "../lib/cn"; | |
| 8 | − | import type { NotStarted } from "../lib/delegate"; | |
| 9 | − | ||
| 10 | − | import { Avatar } from "./ui/avatar"; | |
| 11 | − | import { Button } from "./ui/button"; | |
| 12 | − | import { CONTROL } from "./ui/input"; | |
| 13 | − | import { SelectField } from "./ui/select"; | |
| 14 | − | ||
| 15 | − | /** What the composer's action said back: why nothing opened, or the issue that opened without its agent. */ | |
| 16 | − | export type ComposerResult = { error: string | null; notStarted: NotStarted | null } | null; | |
| 17 | − | ||
| 18 | − | /** | |
| 19 | − | * "Put an agent on it": a compact composer that opens an issue in one of | |
| 20 | − | * the viewer's projects and puts g1t on it, in one step. Posts to | |
| 21 | − | * Mission control's action, which lands on the issue with the agent | |
| 22 | − | * running. A native `<details>`, so it opens and posts without script. | |
| 23 | − | */ | |
| 24 | − | export function AgentComposer({ | |
| 25 | − | repos, | |
| 26 | − | open: openAtFirst, | |
| 27 | − | result, | |
| 28 | − | note, | |
| 29 | − | action = "/?index", | |
| 30 | − | children, | |
| 31 | − | }: { | |
| 32 | − | repos: RepoPath[]; | |
| 33 | − | /** Open from the start: asked for by the address, or a post came back. */ | |
| 34 | − | open: boolean; | |
| 35 | − | /** What a post without script came back with. */ | |
| 36 | − | result: ComposerResult; | |
| 37 | − | /** Said under the form, such as that agents need a model first. */ | |
| 38 | − | note?: ReactNode; | |
| 39 | − | action?: string; | |
| 40 | − | /** The button that opens it. */ | |
| 41 | − | children: ReactNode; | |
| 42 | − | }) { | |
| 43 | − | const fetcher = useFetcher<ComposerResult>(); | |
| 44 | − | const ref = useRef<HTMLDetailsElement>(null); | |
| 45 | − | const [open, setOpen] = useState(openAtFirst); | |
| 46 | − | const busy = fetcher.state !== "idle"; | |
| 47 | − | const said = fetcher.data ?? result; | |
| 48 | − | ||
| 49 | − | // Closed by Escape or by a click outside it, as a menu is. | |
| 50 | − | useEffect(() => { | |
| 51 | − | if (!open) return; | |
| 52 | − | // A select's list, open inside it, takes its own Escape and clicks. | |
| 53 | − | const onKey = (event: KeyboardEvent) => event.key === "Escape" && !event.defaultPrevented && setOpen(false); | |
| 54 | − | const onClick = (event: MouseEvent) => { | |
| 55 | − | if ((event.target as Element | null)?.closest?.("[data-radix-popper-content-wrapper]")) return; | |
| 56 | − | if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); | |
| 57 | − | }; | |
| 58 | − | document.addEventListener("keydown", onKey); | |
| 59 | − | document.addEventListener("mousedown", onClick); | |
| 60 | − | return () => { | |
| 61 | − | document.removeEventListener("keydown", onKey); | |
| 62 | − | document.removeEventListener("mousedown", onClick); | |
| 63 | − | }; | |
| 64 | − | }, [open]); | |
| 65 | − | ||
| 66 | − | return ( | |
| 67 | − | <details | |
| 68 | − | ref={ref} | |
| 69 | − | open={open} | |
| 70 | − | onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)} | |
| 71 | − | className="group/composer relative" | |
| 72 | − | > | |
| 73 | − | <summary className="list-none [&::-webkit-details-marker]:hidden">{children}</summary> | |
| 74 | − | <div className="fixed inset-x-4 top-20 z-40 sm:absolute sm:inset-x-auto sm:top-full sm:left-0 sm:mt-2 sm:w-[28rem]"> | |
| 75 | − | <fetcher.Form | |
| 76 | − | method="post" | |
| 77 | − | action={action} | |
| 78 | − | className="rounded-xl border border-line-strong bg-raised p-4 shadow-2xl shadow-black/50" | |
| 79 | − | aria-label="Put an agent on it" | |
| 80 | − | > | |
| 81 | − | <input type="hidden" name="intent" value="delegate" /> | |
| 82 | − | <p className="flex items-center gap-2 text-sm font-semibold"> | |
| 83 | − | <Sparkles size={15} className="text-accent" /> | |
| 84 | − | Put an agent on it | |
| 85 | − | </p> | |
| 86 | − | <p className="mt-1 text-xs leading-5 text-muted"> | |
| 87 | − | Opens an issue and assigns g1t at once. It makes the change in a sandbox and sees it through checks | |
| 88 | − | and review. | |
| 89 | − | </p> | |
| 90 | − | ||
| 91 | − | <div className="mt-4 space-y-3"> | |
| 92 | − | <label className="block"> | |
| 93 | − | <span className="mb-1.5 block text-xs font-medium text-muted">Project</span> | |
| 94 | − | <SelectField | |
| 95 | − | name="repo" | |
| 96 | − | required | |
| 97 | − | defaultValue={repos[0] ? `${repos[0].namespace}/${repos[0].name}` : undefined} | |
| 98 | − | placeholder="Choose a project" | |
| 99 | − | options={repos.map((repo) => ({ value: `${repo.namespace}/${repo.name}`, label: repo.name }))} | |
| 100 | − | /> | |
| 101 | − | </label> | |
| 102 | − | <label className="block"> | |
| 103 | − | <span className="mb-1.5 block text-xs font-medium text-muted">Title</span> | |
| 104 | − | <input | |
| 105 | − | name="title" | |
| 106 | − | required | |
| 107 | − | maxLength={200} | |
| 108 | − | autoComplete="off" | |
| 109 | − | data-1p-ignore | |
| 110 | − | placeholder="Retry failed webhook deliveries" | |
| 111 | − | className={cn(CONTROL, "h-9")} | |
| 112 | − | /> | |
| 113 | − | </label> | |
| 114 | − | <label className="block"> | |
| 115 | − | <span className="mb-1.5 block text-xs font-medium text-muted">What you want done</span> | |
| 116 | − | <textarea | |
| 117 | − | name="body" | |
| 118 | − | rows={4} | |
| 119 | − | placeholder="In plain words: what is wrong or wanted, and anything the agent cannot see for itself." | |
| 120 | − | className={cn(CONTROL, "min-h-24 resize-y leading-relaxed")} | |
| 121 | − | /> | |
| 122 | − | </label> | |
| 123 | − | <p className="text-xs text-faint"> | |
| 124 | − | Its pull request runs the project's workflows as its checks, and merges once the ones the default branch | |
| 125 | − | requires pass. Say what done looks like in your own words, if it helps. | |
| 126 | − | </p> | |
| 127 | − | </div> | |
| 128 | − | ||
| 129 | − | {said?.error && <p className="mt-3 text-sm text-danger">{said.error}</p>} | |
| 130 | − | {said?.notStarted && ( | |
| 131 | − | <div className="mt-3 rounded-lg border border-warn/30 bg-warn/[0.06] p-3 text-sm"> | |
| 132 | − | <p className="text-fg-soft"> | |
| 133 | − | Opened <Link to={said.notStarted.to} className="font-medium text-fg hover:underline">#{said.notStarted.number}</Link>, but | |
| 134 | − | g1t did not start. {said.notStarted.message} | |
| 135 | − | </p> | |
| 136 | − | <p className="mt-2 flex flex-wrap gap-2"> | |
| 137 | − | {said.notStarted.fix && ( | |
| 138 | − | <Link | |
| 139 | − | to={said.notStarted.fix.to} | |
| 140 | − | className="inline-flex items-center gap-1 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg hover:bg-fg-hover" | |
| 141 | − | > | |
| 142 | − | {said.notStarted.fix.label} <ArrowUpRight size={12} /> | |
| 143 | − | </Link> | |
| 144 | − | )} | |
| 145 | − | <Link to={said.notStarted.to} className="inline-flex items-center rounded-md border border-line-strong px-2.5 py-1 text-xs font-medium text-fg/90 hover:bg-bg"> | |
| 146 | − | Open the issue | |
| 147 | − | </Link> | |
| 148 | − | </p> | |
| 149 | − | </div> | |
| 150 | − | )} | |
| 151 | − | {note && <p className="mt-3 text-xs leading-5 text-muted">{note}</p>} | |
| 152 | − | ||
| 153 | − | <div className="mt-4 flex items-center justify-between gap-3"> | |
| 154 | − | <span className="flex min-w-0 items-center gap-1.5 text-xs text-faint"> | |
| 155 | − | <Avatar name="g1t" size={16} /> | |
| 156 | − | <span className="truncate">g1t · no model to choose</span> | |
| 157 | − | </span> | |
| 158 | − | <Button | |
| 159 | − | type="submit" | |
| 160 | − | disabled={busy || repos.length === 0} | |
| 161 | − | variant="accent" | |
| 162 | − | className="gap-1.5 font-semibold disabled:opacity-60" | |
| 163 | − | > | |
| 164 | − | {busy ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />} | |
| 165 | − | {busy ? "Starting…" : "Put an agent on it"} | |
| 166 | − | </Button> | |
| 167 | − | </div> | |
| 168 | − | </fetcher.Form> | |
| 169 | − | </div> | |
| 170 | − | </details> | |
| 171 | − | ); | |
| 172 | − | } |
More files changed than one page lists: these are the first 300.