Skip to content

g1t/apps/sudo/app/routes/agents.tsx

575 lines28,608 bytesCodeBlame
1import { Check, RefreshCw } from "lucide-react";
2import { data, redirect } from "react-router";
3
4import type { AdminModels, CatalogueModel, DiscoveryResult, ModelStatus } from "@g1t/contracts";
5
6import type { Route } from "./+types/agents";
7import { Badge, Button, ButtonLink, EmptyState, Field, Input, Notice, PageHeader, Section, Select, When } from "~/components/ui";
8import { text } from "~/lib/forms";
9import {
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";
30import { usd } from "~/lib/money";
31import { admin, modelDiscovery } from "~/lib/services.server";
32import { settle } from "~/lib/settle";
33import { requireStaff } from "~/lib/staff";
34
35export const meta: Route.MetaFunction = () => [{ title: "Agents & models · sudo" }, { name: "robots", content: "noindex, nofollow" }];
36
37export 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
56type Review = { change: DefaultChange; before: string; after: string; impact: string | null };
57
58type 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
63const 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 */
72export 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
130const 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
137const 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. */
140function runCost(micros: number): string {
141 return micros > 0 ? usd(micros) : "—";
142}
143
144export 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
175function 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
193function 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
231type Failure = Extract<ActionData, { kind: "error" }> | null;
232
233function 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
295function 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
415function 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. */
489function 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
505function 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}