| 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 | } |