@anchrd/intel-ui 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +3 -2
  2. package/src/agent/agent-calendar/agent-calendar.tsx +32 -14
  3. package/src/agent/agent-chat/agent-chat.tsx +3 -1
  4. package/src/agent/agent-cron/agent-cron.ts +49 -113
  5. package/src/agent/agent-definition/agent-definition.ts +18 -9
  6. package/src/agent/agent-entry-title/agent-entry-title.ts +3 -1
  7. package/src/agent/agent-log/agent-log.tsx +6 -3
  8. package/src/agent/agent-models/agent-models.ts +142 -3
  9. package/src/agent/agent-models/agent-models.types.ts +18 -0
  10. package/src/agent/agent-profile/agent-profile.tsx +114 -31
  11. package/src/agent/agent.tsx +17 -9
  12. package/src/app/app-sidebar/app-sidebar.tsx +2 -2
  13. package/src/app/app-tree/app-tree.tsx +5 -3
  14. package/src/app/app.tsx +2 -2
  15. package/src/app/header-search/header-search.tsx +3 -1
  16. package/src/app/settings-dialog/settings-dialog.tsx +133 -0
  17. package/src/app/tree-move/tree-move.tsx +3 -1
  18. package/src/app/user-footer/user-footer.tsx +25 -2
  19. package/src/app/view-toggle/view-toggle.tsx +2 -2
  20. package/src/archive/archive.tsx +3 -1
  21. package/src/entry-picker/entry-picker.tsx +3 -1
  22. package/src/flow-runs/flow-runs.tsx +6 -3
  23. package/src/flows/flows.tsx +13 -9
  24. package/src/flows/node-palette/node-palette.tsx +2 -2
  25. package/src/folder-contents/folder-contents.tsx +3 -1
  26. package/src/graph-pane/graph-pane.tsx +2 -2
  27. package/src/i18n/de.json +402 -0
  28. package/src/i18n/en.json +17 -5
  29. package/src/i18n/es.json +402 -0
  30. package/src/i18n/i18n-context.tsx +65 -0
  31. package/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  32. package/src/i18n/i18n.ts +15 -11
  33. package/src/i18n/i18n.types.ts +43 -0
  34. package/src/main.tsx +33 -12
  35. package/src/modal/modal.tsx +2 -2
  36. package/src/node-table/node-table.tsx +3 -1
  37. package/src/nodes/nodes.tsx +7 -3
  38. package/src/resource-menu/resource-menu.tsx +8 -4
  39. package/src/router/router.types.ts +3 -2
  40. package/src/save-button/save-button.tsx +3 -3
  41. package/src/sign-in-refused/sign-in-refused.tsx +3 -2
  42. package/src/theme/theme-context.tsx +89 -0
  43. package/src/theme/theme.ts +51 -21
  44. package/src/timezone/timezone-context.tsx +45 -0
  45. package/src/timezone/timezone.ts +60 -0
  46. package/src/tools/tools.tsx +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@anchrd/intel-contract": "^0.7.0",
32
+ "@anchrd/intel-contract": "^0.8.0",
33
33
  "@assistant-ui/react": "^0.15.4",
34
34
  "@assistant-ui/react-ai-sdk": "^1.4.4",
35
35
  "@blocknote/core": "^0.52.1",
@@ -45,6 +45,7 @@
45
45
  "clsx": "^2.1.1",
46
46
  "cmdk": "^1.1.1",
47
47
  "colorjs.io": "^0.7.1",
48
+ "croner": "^10.0.1",
48
49
  "fflate": "^0.8.3",
49
50
  "graphology": "^0.26.0",
50
51
  "lucide-react": "^1.25.0",
@@ -1,7 +1,7 @@
1
1
  import type { AgentDefinition } from "@anchrd/intel-contract";
2
2
  import { CalendarClock } from "lucide-react";
3
3
  import { nextCronFires } from "@/agent/agent-cron/agent-cron.ts";
4
- import { useIntelRouterContext } from "@/router/router-context.ts";
4
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
5
 
6
6
  // How far ahead one schedule is shown. Enough to recognise a rhythm — daily, weekly, the first of
7
7
  // the month — and not so much that the page becomes a year planner nobody asked for.
@@ -14,9 +14,11 @@ const PerSchedule = 5;
14
14
  * calendar, so these times are this page's own reading of the same cron expressions — by the same
15
15
  * rules, pinned by `agent-cron.unit.ts` against the runtime's test.
16
16
  *
17
- * ⚠️ UTC, and it says so. The definition carries no timezone, so the runtime fires in UTC; showing
18
- * these in the reader's local zone would be a different hour from the one the agent keeps, and the
19
- * two would only be noticed when a schedule "ran at the wrong time".
17
+ * ⚠️ Each entry is drawn in ITS OWN schedule's timezone and names it (#228). Not in the reader's:
18
+ * the agent fires at the hour its schedule says, and an instant shown in the zone of whoever is
19
+ * looking would be a different hour from the one the agent keeps — a difference that only ever
20
+ * surfaces as "it ran at the wrong time". And not one zone for the whole list either, because two
21
+ * schedules of one agent may sit in different ones.
20
22
  */
21
23
  export function AgentCalendar({
22
24
  definition,
@@ -27,26 +29,39 @@ export function AgentCalendar({
27
29
  // does not have to travel in time to assert on it.
28
30
  now: Date;
29
31
  }) {
30
- const { i18n } = useIntelRouterContext();
32
+ const i18n = useI18n();
31
33
  const schedules = definition?.schedules ?? [];
32
34
  const upcoming = schedules
33
35
  .flatMap((schedule) =>
34
- nextCronFires(schedule.cron, now, PerSchedule).map((at) => ({ at, schedule })),
36
+ nextCronFires(schedule.cron, now, PerSchedule, schedule.timezone).map((at) => ({
37
+ at,
38
+ schedule,
39
+ })),
35
40
  )
36
41
  .sort((left, right) => left.at.getTime() - right.at.getTime());
37
42
 
38
- const format = new Intl.DateTimeFormat(i18n.locale, {
39
- dateStyle: "full",
40
- timeStyle: "short",
41
- timeZone: "UTC",
42
- });
43
+ // One formatter per zone rather than one per row: a DateTimeFormat is expensive to build, and a
44
+ // calendar of five entries per schedule would otherwise build one for each of them.
45
+ const formatters = new Map<string, Intl.DateTimeFormat>();
46
+ const formatIn = (zone: string, at: Date) => {
47
+ let format = formatters.get(zone);
48
+ if (!format) {
49
+ format = new Intl.DateTimeFormat(i18n.locale, {
50
+ dateStyle: "full",
51
+ timeStyle: "short",
52
+ timeZone: zone,
53
+ });
54
+ formatters.set(zone, format);
55
+ }
56
+ return format.format(at);
57
+ };
43
58
 
44
59
  return (
45
60
  <section
46
61
  aria-label={i18n.t("agent.calendar")}
47
62
  className="min-h-0 flex-1 overflow-y-auto px-6 py-5"
48
63
  >
49
- <p className="text-xs text-muted-foreground">{i18n.t("agent.calendarUtc")}</p>
64
+ <p className="text-xs text-muted-foreground">{i18n.t("agent.calendarZones")}</p>
50
65
  {schedules.length === 0 ? (
51
66
  <p className="mt-4 text-sm text-muted-foreground">{i18n.t("agent.scheduleEmpty")}</p>
52
67
  ) : upcoming.length === 0 ? (
@@ -59,13 +74,16 @@ export function AgentCalendar({
59
74
  <ol className="mt-4 space-y-2">
60
75
  {upcoming.map((entry) => (
61
76
  <li
62
- key={`${entry.schedule.cron}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
77
+ key={`${entry.schedule.cron}@${entry.schedule.timezone}:${entry.schedule.target.id}:${entry.at.toISOString()}`}
63
78
  className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm"
64
79
  >
65
80
  <CalendarClock aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
66
81
  <time dateTime={entry.at.toISOString()} className="font-medium">
67
- {format.format(entry.at)}
82
+ {formatIn(entry.schedule.timezone, entry.at)}
68
83
  </time>
84
+ {/* The zone stands next to the time, not once above the list: without it the same
85
+ wall-clock hour from two schedules reads as a duplicate. */}
86
+ <span className="text-xs text-muted-foreground">{entry.schedule.timezone}</span>
69
87
  <code className="rounded bg-muted px-2 py-0.5 text-xs">{entry.schedule.cron}</code>
70
88
  <span className="text-xs text-muted-foreground">
71
89
  {i18n.t(`node.kind.${entry.schedule.target.kind}`)}
@@ -8,6 +8,7 @@ import {
8
8
  import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
9
9
  import { ArrowDown, Send } from "lucide-react";
10
10
  import { useMemo } from "react";
11
+ import { useI18n } from "@/i18n/i18n-context.tsx";
11
12
  import { useIntelRouterContext } from "@/router/router-context.ts";
12
13
 
13
14
  /**
@@ -25,7 +26,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
25
26
  * rather than from a bearer this page would have had to hold.
26
27
  */
27
28
  export function AgentChat({ agentId }: { agentId: string }) {
28
- const { data, i18n } = useIntelRouterContext();
29
+ const { data } = useIntelRouterContext();
30
+ const i18n = useI18n();
29
31
  // ⚠️ Built once per agent. A transport rebuilt on every render restarts the runtime underneath
30
32
  // assistant-ui and takes the open thread with it.
31
33
  const transport = useMemo(
@@ -1,132 +1,68 @@
1
+ import { Cron } from "croner";
2
+
1
3
  /**
2
- * When a five-field cron expression fires next, in UTC.
4
+ * When a five-field cron expression fires next, read in the schedule's own timezone.
3
5
  *
4
- * ⚠️ This is a second copy of `packages/agent/src/schedule/cron/cron.ts`, on purpose and with a
5
- * cost. The runtime imports nothing from the contract and this package cannot import a Worker
6
- * package, so there is no third place the matcher could live without inventing one for two callers.
7
- * What must never happen is the two disagreeing: a calendar that promises 08:00 while the alarm
8
- * fires at 09:00 is worse than a calendar that says nothing. `agent-cron.unit.ts` therefore pins
9
- * exactly the cases the runtime's own test pins — including the day-field OR rule, which is the
10
- * one an independent reimplementation gets wrong.
6
+ * ⚠️ This is still a second copy of `packages/agent/src/schedule/cron/cron.ts` the runtime imports
7
+ * nothing from the contract and this package cannot import a Worker package, so there remains no
8
+ * third place for it. What changed with #228 is WHAT is duplicated: both files are now a thin
9
+ * wrapper over croner, and the part that could actually disagree the matching, DST, the day-field
10
+ * OR rule is one implementation in the library.
11
11
  *
12
- * ⚠️ UTC, with no timezone option, because the definition carries none: `schedules[{cron, target}]`
13
- * and nothing else. Reading these in local time here would show a person a different hour from the
14
- * one the agent runs at.
12
+ * That was the real risk. The old comment put it plainly: "a calendar that promises 08:00 while the
13
+ * alarm fires at 09:00 is worse than a calendar that says nothing" and two independent
14
+ * reimplementations of timezone arithmetic is exactly how that happens.
15
15
  */
16
- interface Field {
17
- values: Set<number>;
18
- restricted: boolean;
19
- }
20
-
21
- function parseField(source: string, min: number, max: number): Field | null {
22
- const values = new Set<number>();
23
- let restricted = false;
24
- for (const part of source.split(",")) {
25
- const [range, stepText] = part.split("/");
26
- if (range === undefined || range === "") return null;
27
- const step = stepText === undefined ? 1 : Number(stepText);
28
- if (!Number.isInteger(step) || step < 1) return null;
29
- let from: number;
30
- let to: number;
31
- if (range === "*") {
32
- from = min;
33
- to = max;
34
- if (step !== 1) restricted = true;
35
- } else if (range.includes("-")) {
36
- const [fromText, toText] = range.split("-");
37
- from = Number(fromText);
38
- to = Number(toText);
39
- restricted = true;
40
- } else {
41
- from = Number(range);
42
- to = from;
43
- restricted = true;
44
- }
45
- if (!Number.isInteger(from) || !Number.isInteger(to) || from < min || to > max || from > to) {
46
- return null;
47
- }
48
- for (let value = from; value <= to; value += step) values.add(value);
49
- }
50
- return values.size > 0 ? { values, restricted } : null;
51
- }
52
16
 
53
- export interface CronExpression {
54
- minute: Field;
55
- hour: Field;
56
- dayOfMonth: Field;
57
- month: Field;
58
- dayOfWeek: Field;
59
- }
17
+ // One year is the search bound. An expression that matches nothing within a year — "31st of
18
+ // February" — has no next fire time, and saying so beats stepping forever.
19
+ const MaxYearsAhead = 1;
60
20
 
61
- export function parseCron(expression: string): CronExpression | null {
62
- const parts = expression.trim().split(/\s+/);
63
- if (parts.length !== 5) return null;
64
- // Sunday is 0 in the standard and 7 in several dialects; both are accepted and normalised below.
65
- const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [
66
- string,
67
- string,
68
- string,
69
- string,
70
- string,
71
- ];
72
- const fields = {
73
- minute: parseField(minute, 0, 59),
74
- hour: parseField(hour, 0, 23),
75
- dayOfMonth: parseField(dayOfMonth, 1, 31),
76
- month: parseField(month, 1, 12),
77
- dayOfWeek: parseField(dayOfWeek, 0, 7),
78
- };
79
- if (!fields.minute || !fields.hour || !fields.dayOfMonth || !fields.month || !fields.dayOfWeek) {
21
+ function schedule(expression: string, timezone: string): Cron | null {
22
+ try {
23
+ const job = new Cron(expression.trim(), { timezone, paused: true });
24
+ // croner also accepts six fields (with seconds). Intel's contract is five, and so is the hint
25
+ // shown next to the input a six-field expression must not quietly mean something else.
26
+ return expression.trim().split(/\s+/).length === 5 ? job : null;
27
+ } catch {
80
28
  return null;
81
29
  }
82
- if (fields.dayOfWeek.values.has(7)) fields.dayOfWeek.values.add(0);
83
- return fields as CronExpression;
84
30
  }
85
31
 
86
- export function matches(cron: CronExpression, date: Date): boolean {
87
- if (!cron.minute.values.has(date.getUTCMinutes())) return false;
88
- if (!cron.hour.values.has(date.getUTCHours())) return false;
89
- if (!cron.month.values.has(date.getUTCMonth() + 1)) return false;
90
- const dayOfMonth = cron.dayOfMonth.values.has(date.getUTCDate());
91
- const dayOfWeek = cron.dayOfWeek.values.has(date.getUTCDay());
92
- // ⚠️ The standard's oddity, kept on purpose: when both day fields are restricted they are ORed,
93
- // not ANDed. "0 0 1 * 1" is the first of the month AND every Monday, and a matcher that ANDs them
94
- // silently lists about four times fewer runs than the agent will actually make.
95
- if (cron.dayOfMonth.restricted && cron.dayOfWeek.restricted) return dayOfMonth || dayOfWeek;
96
- return dayOfMonth && dayOfWeek;
32
+ // ⚠️ The croner call is inside the try, not only the construction. An unknown timezone can surface
33
+ // either place depending on when croner first converts a date and an exception escaping here ends
34
+ // up inside a Durable Object alarm, which is the one place nobody is watching.
35
+ function runs(expression: string, after: Date, count: number, timezone: string): Date[] {
36
+ const job = schedule(expression, timezone);
37
+ if (!job) return [];
38
+ try {
39
+ return job.nextRuns(count, after);
40
+ } catch {
41
+ return [];
42
+ }
97
43
  }
98
44
 
99
- // One year of minutes is the search bound. An expression that matches nothing within a year — "31st
100
- // of February" has no next fire time, and saying so beats stepping forever.
101
- const MaxMinutesAhead = 366 * 24 * 60;
102
-
103
- export function nextCronFire(expression: string, after: Date): Date | null {
104
- const cron = parseCron(expression);
105
- if (!cron) return null;
106
- // Strictly after: a schedule that just fired must not match the same minute again.
107
- const start = Math.floor(after.getTime() / 60_000) * 60_000 + 60_000;
108
- for (let step = 0; step < MaxMinutesAhead; step += 1) {
109
- const candidate = new Date(start + step * 60_000);
110
- if (matches(cron, candidate)) return candidate;
111
- }
112
- return null;
45
+ export function nextCronFire(expression: string, after: Date, timezone: string): Date | null {
46
+ // Strictly after: a schedule that just fired must not match the same minute again. croner's
47
+ // `nextRuns(…, from)` is already exclusive of `from`.
48
+ const next = runs(expression, after, 1, timezone)[0];
49
+ if (!next) return null;
50
+ const limit = new Date(after.getTime());
51
+ limit.setUTCFullYear(limit.getUTCFullYear() + MaxYearsAhead);
52
+ return next > limit ? null : next;
113
53
  }
114
54
 
115
55
  /**
116
56
  * The next `count` fire times of one expression.
117
57
  *
118
- * ⚠️ Stepping from each hit rather than scanning once, so an expression that fires every minute
119
- * costs `count` iterations and not a year of them. An unparseable or never-matching expression
120
- * yields an empty list — the calendar says "nothing" rather than guessing.
58
+ * An unparseable or never-matching expression yields an empty list the caller says "nothing"
59
+ * rather than guessing.
121
60
  */
122
- export function nextCronFires(expression: string, after: Date, count: number): Date[] {
123
- const fires: Date[] = [];
124
- let cursor = after;
125
- for (let index = 0; index < count; index += 1) {
126
- const next = nextCronFire(expression, cursor);
127
- if (!next) break;
128
- fires.push(next);
129
- cursor = next;
130
- }
131
- return fires;
61
+ export function nextCronFires(
62
+ expression: string,
63
+ after: Date,
64
+ count: number,
65
+ timezone: string,
66
+ ): Date[] {
67
+ return runs(expression, after, count, timezone);
132
68
  }
@@ -70,18 +70,27 @@ export function useAgentDefinition(nodeId: string): AgentDefinitionHandle {
70
70
  }
71
71
 
72
72
  /**
73
- * When this agent next does something on its own, across all of its schedules.
73
+ * When this agent next does something on its own, across all of its schedules — and in which zone
74
+ * that time is meant.
74
75
  *
75
76
  * ⚠️ Derived from the cron expressions, never asked. The runtime arms one alarm at a time and does
76
- * not publish it, so this is a reading of the same expressions by the same rules — see
77
- * `agent-cron.ts` for why that duplication is pinned by a test rather than left to agree by luck.
77
+ * not publish it, so this is a reading of the same expressions by the same rules — since #228 both
78
+ * sides read them through croner, so the reading and the alarm cannot drift apart.
79
+ *
80
+ * ⚠️ The zone travels WITH the time. Two schedules of one agent may sit in different zones, so a
81
+ * single "all times are X" line above the screen would be a lie for one of them; the caller formats
82
+ * each instant in the zone it came back with.
78
83
  */
79
- export function nextRunAt(definition: AgentDefinition | null, now: Date): Date | null {
80
- const fires = (definition?.schedules ?? []).flatMap(
81
- (schedule) => nextCronFires(schedule.cron, now, 1)[0] ?? [],
82
- );
83
- return fires.reduce<Date | null>(
84
- (earliest, fire) => (earliest === null || fire < earliest ? fire : earliest),
84
+ export function nextRunAt(
85
+ definition: AgentDefinition | null,
86
+ now: Date,
87
+ ): { at: Date; timezone: string } | null {
88
+ const fires = (definition?.schedules ?? []).flatMap((schedule) => {
89
+ const at = nextCronFires(schedule.cron, now, 1, schedule.timezone)[0];
90
+ return at ? [{ at, timezone: schedule.timezone }] : [];
91
+ });
92
+ return fires.reduce<{ at: Date; timezone: string } | null>(
93
+ (earliest, fire) => (earliest === null || fire.at < earliest.at ? fire : earliest),
85
94
  null,
86
95
  );
87
96
  }
@@ -1,4 +1,5 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
+ import { useI18n } from "@/i18n/i18n-context.tsx";
2
3
  import { useIntelRouterContext } from "@/router/router-context.ts";
3
4
 
4
5
  /**
@@ -13,7 +14,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
13
14
  * "run now" menu in the page head, which has to name the targets it offers.
14
15
  */
15
16
  export function useEntryTitle(entryId: string, flow = false): { title: string; known: boolean } {
16
- const { data, i18n } = useIntelRouterContext();
17
+ const { data } = useIntelRouterContext();
18
+ const i18n = useI18n();
17
19
  const graph = useQuery({
18
20
  queryKey: ["node-graph"],
19
21
  queryFn: () => data.getNodeGraph(),
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
2
2
  import { ChevronDown, ChevronRight } from "lucide-react";
3
3
  import { useState } from "react";
4
4
  import type { AgentRun } from "@/data/agent-runtime/agent-runtime.ts";
5
+ import { useI18n } from "@/i18n/i18n-context.tsx";
5
6
  import { useIntelRouterContext } from "@/router/router-context.ts";
6
7
 
7
8
  /**
@@ -16,7 +17,8 @@ import { useIntelRouterContext } from "@/router/router-context.ts";
16
17
  * correspondent, and the runtime deliberately stores neither where this list could reach it.
17
18
  */
18
19
  export function AgentLog({ agentId }: { agentId: string }) {
19
- const { data, i18n } = useIntelRouterContext();
20
+ const { data } = useIntelRouterContext();
21
+ const i18n = useI18n();
20
22
  const [openRunId, setOpenRunId] = useState<string | null>(null);
21
23
  const runs = useQuery({
22
24
  queryKey: ["agent-runs", agentId],
@@ -71,7 +73,7 @@ function RunRow({
71
73
  open: boolean;
72
74
  toggle(): void;
73
75
  }) {
74
- const { i18n } = useIntelRouterContext();
76
+ const i18n = useI18n();
75
77
  const failed = run.status === "failed";
76
78
  const Chevron = open ? ChevronDown : ChevronRight;
77
79
  return (
@@ -113,7 +115,8 @@ function RunRow({
113
115
  // The operational log of one run: what it tried, in order. Fetched only when a row is opened —
114
116
  // every run carries its own entries and loading all of them would be the whole log for one screen.
115
117
  function RunDetail({ agentId, runId }: { agentId: string; runId: string }) {
116
- const { data, i18n } = useIntelRouterContext();
118
+ const { data } = useIntelRouterContext();
119
+ const i18n = useI18n();
117
120
  const detail = useQuery({
118
121
  queryKey: ["agent-run", agentId, runId],
119
122
  queryFn: () => data.getAgentRun(agentId, runId),
@@ -1,5 +1,6 @@
1
1
  import { AgentModel } from "@anchrd/intel-contract";
2
2
  import { z } from "zod";
3
+ import type { ModelFacts, ModelPrice } from "./agent-models.types.ts";
3
4
 
4
5
  /**
5
6
  * Which models this installation offers.
@@ -7,8 +8,7 @@ import { z } from "zod";
7
8
  * ⚠️ There is no endpoint to ask. The runtime's registry answers one question — "is this provider
8
9
  * configured" — and only when a run is already starting; nothing anywhere lists what a deployment
9
10
  * pays for. Rather than invent an API for one screen, the list is build configuration
10
- * (`VITE_AGENT_MODELS`, a JSON array of `{provider, model}`), with the two the repository's own
11
- * fixtures use as the default.
11
+ * (`VITE_AGENT_MODELS`, a JSON array of `{provider, model}`), with the offering below as the default.
12
12
  *
13
13
  * ⚠️ The definition's current model is always offered, even when it is not in the list. A select
14
14
  * that silently dropped it would turn "look at this agent" into "change this agent" for anyone who
@@ -16,10 +16,105 @@ import { z } from "zod";
16
16
  */
17
17
  const ConfiguredModels = z.array(AgentModel);
18
18
 
19
- const firstFallback: AgentModel = { provider: "anthropic", model: "claude-sonnet-4" };
19
+ /**
20
+ * Context window and price per model, collected 2026-08-06 from
21
+ * `GET /accounts/{id}/ai/models/search` (Workers AI, every entry carrying `function_calling`) and
22
+ * from Anthropic's model list. Both figures are shown beside the select, so this table is the one
23
+ * place to correct when they move.
24
+ *
25
+ * ⚠️ It is a copy and it ages silently. Cloudflare changes context windows and prices without
26
+ * notice, and a wrong price in the interface is worse than none — re-read the endpoint above rather
27
+ * than editing a number from memory. Asking live from the browser is not the way out: the account
28
+ * token does not belong in a SPA.
29
+ *
30
+ * ⚠️ Workers AI models that do not carry `function_calling` are deliberately absent. An agent is a
31
+ * tool loop; a model that cannot call a tool cannot run one, and offering it would only produce a
32
+ * broken agent for whoever picked it.
33
+ */
34
+ const catalog: Record<string, { name: string; contextTokens: number; price: ModelPrice }> = {
35
+ "anthropic:claude-opus-5": price("Opus 5", 1_000_000, 5, 25),
36
+ "anthropic:claude-sonnet-5": price("Sonnet 5", 1_000_000, 3, 15),
37
+ "anthropic:claude-sonnet-4": price("Sonnet 4", 200_000, 3, 15),
38
+ "anthropic:claude-haiku-4-5": price("Haiku 4.5", 200_000, 1, 5),
39
+ "workers-ai:@cf/openai/gpt-oss-120b": price("GPT-OSS 120B", 128_000, 0.35, 0.75),
40
+ "workers-ai:@cf/openai/gpt-oss-20b": price("GPT-OSS 20B", 128_000, 0.2, 0.3),
41
+ "workers-ai:@cf/moonshotai/kimi-k2.6": price("Kimi K2.6", 262_144, 0.95, 4),
42
+ "workers-ai:@cf/moonshotai/kimi-k2.7-code": price("Kimi K2.7 Code", 262_144, 0.95, 4),
43
+ "workers-ai:@cf/zai-org/glm-5.2": price("GLM 5.2", 262_144, 1.4, 4.4),
44
+ "workers-ai:@cf/zai-org/glm-4.7-flash": price("GLM 4.7 Flash", 131_072, 0.0605, 0.4),
45
+ "workers-ai:@cf/google/gemma-4-26b-a4b-it": price("Gemma 4 26B", 256_000, 0.1, 0.3),
46
+ "workers-ai:@cf/nvidia/nemotron-3-120b-a12b": price("Nemotron 3 120B", 256_000, 0.5, 1.5),
47
+ "workers-ai:@cf/meta/llama-4-scout-17b-16e-instruct": price("Llama 4 Scout", 131_000, 0.27, 0.85),
48
+ "workers-ai:@cf/meta/llama-3.3-70b-instruct-fp8-fast": price(
49
+ "Llama 3.3 70B Fast",
50
+ 24_000,
51
+ 0.293,
52
+ 2.253,
53
+ ),
54
+ "workers-ai:@cf/mistralai/mistral-small-3.1-24b-instruct": price(
55
+ "Small 3.1 24B",
56
+ 128_000,
57
+ 0.351,
58
+ 0.555,
59
+ ),
60
+ "workers-ai:@cf/ibm-granite/granite-4.0-h-micro": price(
61
+ "Granite 4.0 Micro",
62
+ 131_000,
63
+ 0.017,
64
+ 0.112,
65
+ ),
66
+ "workers-ai:@cf/qwen/qwen3-30b-a3b-fp8": price("Qwen3 30B", 32_768, 0.0509, 0.335),
67
+ };
68
+
69
+ function price(
70
+ name: string,
71
+ contextTokens: number,
72
+ inputPerMillion: number,
73
+ outputPerMillion: number,
74
+ ): { name: string; contextTokens: number; price: ModelPrice } {
75
+ return { name, contextTokens, price: { inputPerMillion, outputPerMillion } };
76
+ }
77
+
78
+ /**
79
+ * ⚠️ The brand comes from the model id, never from `provider`. `provider` says who serves the model,
80
+ * so every Workers AI entry would read "Cloudflare" — thirteen identical labels in a list whose whole
81
+ * job is to tell them apart. The name a reader looks for sits in the id, as its second segment:
82
+ * `@cf/openai/gpt-oss-120b` is OpenAI's.
83
+ */
84
+ const brands: Record<string, string> = {
85
+ aisingapore: "AI Singapore",
86
+ "deepseek-ai": "DeepSeek",
87
+ google: "Google",
88
+ "ibm-granite": "IBM",
89
+ meta: "Meta",
90
+ "meta-llama": "Meta",
91
+ mistralai: "Mistral",
92
+ moonshotai: "Moonshot",
93
+ nvidia: "NVIDIA",
94
+ openai: "OpenAI",
95
+ qwen: "Qwen",
96
+ "zai-org": "Z.ai",
97
+ };
98
+
99
+ const firstFallback: AgentModel = { provider: "anthropic", model: "claude-sonnet-5" };
20
100
  const fallback: [AgentModel, ...AgentModel[]] = [
21
101
  firstFallback,
102
+ { provider: "anthropic", model: "claude-opus-5" },
103
+ { provider: "anthropic", model: "claude-haiku-4-5" },
104
+ { provider: "anthropic", model: "claude-sonnet-4" },
105
+ { provider: "workers-ai", model: "@cf/openai/gpt-oss-120b" },
106
+ { provider: "workers-ai", model: "@cf/openai/gpt-oss-20b" },
107
+ { provider: "workers-ai", model: "@cf/moonshotai/kimi-k2.6" },
108
+ { provider: "workers-ai", model: "@cf/moonshotai/kimi-k2.7-code" },
109
+ { provider: "workers-ai", model: "@cf/zai-org/glm-5.2" },
110
+ { provider: "workers-ai", model: "@cf/zai-org/glm-4.7-flash" },
111
+ { provider: "workers-ai", model: "@cf/google/gemma-4-26b-a4b-it" },
112
+ { provider: "workers-ai", model: "@cf/nvidia/nemotron-3-120b-a12b" },
113
+ { provider: "workers-ai", model: "@cf/meta/llama-4-scout-17b-16e-instruct" },
22
114
  { provider: "workers-ai", model: "@cf/meta/llama-3.3-70b-instruct-fp8-fast" },
115
+ { provider: "workers-ai", model: "@cf/mistralai/mistral-small-3.1-24b-instruct" },
116
+ { provider: "workers-ai", model: "@cf/ibm-granite/granite-4.0-h-micro" },
117
+ { provider: "workers-ai", model: "@cf/qwen/qwen3-30b-a3b-fp8" },
23
118
  ];
24
119
 
25
120
  export function modelKey(model: AgentModel): string {
@@ -36,6 +131,50 @@ export function parseModelKey(key: string): AgentModel | null {
36
131
  return parsed.success ? parsed.data : null;
37
132
  }
38
133
 
134
+ // How a model is written for a person: brand first, then the model's own name, then the two numbers
135
+ // somebody actually chooses on. A model outside the catalog keeps its own id as the name and reports
136
+ // no figures at all.
137
+ export function modelFacts(model: AgentModel): ModelFacts {
138
+ const known = catalog[modelKey(model)];
139
+ if (known) {
140
+ return {
141
+ brand: brandOf(model),
142
+ name: known.name,
143
+ contextTokens: known.contextTokens,
144
+ price: known.price,
145
+ };
146
+ }
147
+ return { brand: brandOf(model), name: unknownName(model), contextTokens: null, price: null };
148
+ }
149
+
150
+ function brandOf(model: AgentModel): string {
151
+ if (model.provider === "anthropic") return "Claude";
152
+ const vendor = model.model.split("/")[1];
153
+ if (!vendor) return "Workers AI";
154
+ return brands[vendor] ?? vendor;
155
+ }
156
+
157
+ // The id itself, not a prettified guess. "claude-opus-4-1" would become "Opus 4 1" under any rule
158
+ // simple enough to write here, and a wrong name is harder to recognise than a raw one.
159
+ function unknownName(model: AgentModel): string {
160
+ if (model.provider === "anthropic") return model.model.replace(/^claude-/, "");
161
+ const segments = model.model.split("/");
162
+ return segments[segments.length - 1] ?? model.model;
163
+ }
164
+
165
+ export function formatContextTokens(tokens: number): string {
166
+ if (tokens >= 1_000_000) return `${Math.round(tokens / 1_000_000)}M`;
167
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
168
+ return String(tokens);
169
+ }
170
+
171
+ // ⚠️ Two decimals at least, three at most, and always per one million tokens. Granite costs $0.017
172
+ // per million in: rounded to two decimals it reads $0.02, which is the same price as a model that
173
+ // costs seventeen percent more.
174
+ export function formatPricePerMillion(dollars: number, locale: string): string {
175
+ return dollars.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 3 });
176
+ }
177
+
39
178
  export function configuredModels(): AgentModel[] {
40
179
  const raw = import.meta.env.VITE_AGENT_MODELS;
41
180
  if (!raw) return fallback;
@@ -0,0 +1,18 @@
1
+ /** What a reader needs in order to pick a model: whose it is, what it is called, and what it costs. */
2
+ export interface ModelFacts {
3
+ brand: string;
4
+ name: string;
5
+ /**
6
+ * ⚠️ Both are null for a model this installation has no figures for, and the display then shows
7
+ * nothing rather than a zero. A definition is written by MCP and by other installations too, so an
8
+ * unknown model is normal — and an invented "0" would be a false statement about money.
9
+ */
10
+ contextTokens: number | null;
11
+ price: ModelPrice | null;
12
+ }
13
+
14
+ /** Always US dollars per one million tokens — never per 1k, never a single blended number. */
15
+ export interface ModelPrice {
16
+ inputPerMillion: number;
17
+ outputPerMillion: number;
18
+ }