@stigmer/react 3.6.0 → 3.7.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 (121) hide show
  1. package/billing/CreditLedgerTable.d.ts.map +1 -1
  2. package/billing/CreditLedgerTable.js +2 -7
  3. package/billing/CreditLedgerTable.js.map +1 -1
  4. package/composer/ComposerToolbar.d.ts +6 -1
  5. package/composer/ComposerToolbar.d.ts.map +1 -1
  6. package/composer/ComposerToolbar.js +2 -2
  7. package/composer/ComposerToolbar.js.map +1 -1
  8. package/composer/SessionComposer.d.ts +9 -0
  9. package/composer/SessionComposer.d.ts.map +1 -1
  10. package/composer/SessionComposer.js +13 -2
  11. package/composer/SessionComposer.js.map +1 -1
  12. package/execution/useCreateAgentExecution.d.ts +14 -0
  13. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  14. package/execution/useCreateAgentExecution.js +5 -0
  15. package/execution/useCreateAgentExecution.js.map +1 -1
  16. package/index.d.ts +2 -2
  17. package/index.d.ts.map +1 -1
  18. package/index.js +6 -5
  19. package/index.js.map +1 -1
  20. package/internal/Pagination.d.ts +15 -0
  21. package/internal/Pagination.d.ts.map +1 -0
  22. package/internal/Pagination.js +20 -0
  23. package/internal/Pagination.js.map +1 -0
  24. package/models/ModelSelector.d.ts +26 -1
  25. package/models/ModelSelector.d.ts.map +1 -1
  26. package/models/ModelSelector.js +27 -6
  27. package/models/ModelSelector.js.map +1 -1
  28. package/models/index.d.ts +2 -0
  29. package/models/index.d.ts.map +1 -1
  30. package/models/index.js +1 -0
  31. package/models/index.js.map +1 -1
  32. package/models/registry.d.ts +10 -0
  33. package/models/registry.d.ts.map +1 -1
  34. package/models/registry.js +3 -0
  35. package/models/registry.js.map +1 -1
  36. package/models/service-tier.d.ts +27 -0
  37. package/models/service-tier.d.ts.map +1 -0
  38. package/models/service-tier.js +34 -0
  39. package/models/service-tier.js.map +1 -0
  40. package/package.json +4 -4
  41. package/schedule/ScheduleDetailView.d.ts +31 -8
  42. package/schedule/ScheduleDetailView.d.ts.map +1 -1
  43. package/schedule/ScheduleDetailView.js +441 -29
  44. package/schedule/ScheduleDetailView.js.map +1 -1
  45. package/schedule/ScheduleForm.d.ts +7 -4
  46. package/schedule/ScheduleForm.d.ts.map +1 -1
  47. package/schedule/ScheduleForm.js +93 -8
  48. package/schedule/ScheduleForm.js.map +1 -1
  49. package/schedule/ScheduleRunsTable.d.ts +72 -0
  50. package/schedule/ScheduleRunsTable.d.ts.map +1 -0
  51. package/schedule/ScheduleRunsTable.js +161 -0
  52. package/schedule/ScheduleRunsTable.js.map +1 -0
  53. package/schedule/index.d.ts +6 -0
  54. package/schedule/index.d.ts.map +1 -1
  55. package/schedule/index.js +3 -0
  56. package/schedule/index.js.map +1 -1
  57. package/schedule/useScheduleRuns.d.ts +44 -0
  58. package/schedule/useScheduleRuns.d.ts.map +1 -0
  59. package/schedule/useScheduleRuns.js +48 -0
  60. package/schedule/useScheduleRuns.js.map +1 -0
  61. package/schedule/useTriggerSchedule.d.ts +23 -10
  62. package/schedule/useTriggerSchedule.d.ts.map +1 -1
  63. package/schedule/useTriggerSchedule.js +28 -9
  64. package/schedule/useTriggerSchedule.js.map +1 -1
  65. package/schedule/useUpdateScheduleSpec.d.ts +49 -0
  66. package/schedule/useUpdateScheduleSpec.d.ts.map +1 -0
  67. package/schedule/useUpdateScheduleSpec.js +70 -0
  68. package/schedule/useUpdateScheduleSpec.js.map +1 -0
  69. package/session/useNewSessionFlow.d.ts.map +1 -1
  70. package/session/useNewSessionFlow.js +1 -0
  71. package/session/useNewSessionFlow.js.map +1 -1
  72. package/session/useSessionConversation.d.ts +11 -0
  73. package/session/useSessionConversation.d.ts.map +1 -1
  74. package/session/useSessionConversation.js +1 -0
  75. package/session/useSessionConversation.js.map +1 -1
  76. package/session/useSessionPageFlow.d.ts.map +1 -1
  77. package/session/useSessionPageFlow.js +1 -0
  78. package/session/useSessionPageFlow.js.map +1 -1
  79. package/src/billing/CreditLedgerTable.tsx +3 -42
  80. package/src/composer/ComposerToolbar.tsx +9 -0
  81. package/src/composer/SessionComposer.tsx +24 -1
  82. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +156 -0
  83. package/src/execution/useCreateAgentExecution.ts +18 -0
  84. package/src/index.ts +9 -4
  85. package/src/internal/Pagination.tsx +72 -0
  86. package/src/models/ModelSelector.tsx +100 -3
  87. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +172 -0
  88. package/src/models/__tests__/useModelRegistry.test.tsx +15 -0
  89. package/src/models/index.ts +2 -0
  90. package/src/models/registry.ts +16 -0
  91. package/src/models/service-tier.ts +49 -0
  92. package/src/schedule/ScheduleDetailView.tsx +1179 -111
  93. package/src/schedule/ScheduleForm.tsx +217 -7
  94. package/src/schedule/ScheduleRunsTable.tsx +424 -0
  95. package/src/schedule/__tests__/ScheduleDetailView.test.tsx +431 -8
  96. package/src/schedule/__tests__/scheduleCreation.test.tsx +197 -0
  97. package/src/schedule/__tests__/scheduleHooks.test.tsx +94 -7
  98. package/src/schedule/index.ts +12 -0
  99. package/src/schedule/useScheduleRuns.ts +93 -0
  100. package/src/schedule/useTriggerSchedule.ts +39 -14
  101. package/src/schedule/useUpdateScheduleSpec.ts +108 -0
  102. package/src/session/useNewSessionFlow.ts +1 -0
  103. package/src/session/useSessionConversation.ts +12 -0
  104. package/src/session/useSessionPageFlow.ts +1 -0
  105. package/src/workflow/__tests__/inspector-forms.test.tsx +119 -2
  106. package/src/workflow/inspector/forms/AgentCallForm.tsx +260 -37
  107. package/src/workflow/inspector/tabs/RuntimeTab.tsx +72 -33
  108. package/src/workflow/starter-workflow-yaml.ts +2 -1
  109. package/styles.css +1 -1
  110. package/workflow/inspector/forms/AgentCallForm.d.ts +18 -2
  111. package/workflow/inspector/forms/AgentCallForm.d.ts.map +1 -1
  112. package/workflow/inspector/forms/AgentCallForm.js +87 -9
  113. package/workflow/inspector/forms/AgentCallForm.js.map +1 -1
  114. package/workflow/inspector/tabs/RuntimeTab.d.ts +2 -1
  115. package/workflow/inspector/tabs/RuntimeTab.d.ts.map +1 -1
  116. package/workflow/inspector/tabs/RuntimeTab.js +31 -5
  117. package/workflow/inspector/tabs/RuntimeTab.js.map +1 -1
  118. package/workflow/starter-workflow-yaml.d.ts +1 -1
  119. package/workflow/starter-workflow-yaml.d.ts.map +1 -1
  120. package/workflow/starter-workflow-yaml.js +2 -1
  121. package/workflow/starter-workflow-yaml.js.map +1 -1
@@ -7,6 +7,7 @@ import { useStigmerPortalContainer } from "../portal-container.js";
7
7
  import { useModelRegistry } from "./useModelRegistry.js";
8
8
  import type { ModelInfo, CostTier, SpeedTier } from "./registry.js";
9
9
  import { HARNESS_META, HARNESS_OPTIONS, type HarnessOption } from "./harness.js";
10
+ import { FAST_SERVICE_TIER, type ServiceTierOption } from "./service-tier.js";
10
11
 
11
12
  const COST_TIER_LABEL: Record<CostTier, string> = {
12
13
  economy: "$",
@@ -61,6 +62,31 @@ export interface ModelSelectorProps {
61
62
  readonly className?: string;
62
63
  /** When true, disables the selector. */
63
64
  readonly disabled?: boolean;
65
+ /**
66
+ * Trigger label shown while `value` is empty, instead of falling back
67
+ * to the registry's default model. Lets a form distinguish "nothing
68
+ * pinned — the platform default applies" from an actual selection
69
+ * (the schedule form's contract: an unset model inherits the
70
+ * surface's platform default). The harness prefix is hidden while
71
+ * the placeholder shows, since no engine is pinned either.
72
+ */
73
+ readonly placeholderLabel?: string;
74
+
75
+ /**
76
+ * Current service tier for the selected model (stigmer/stigmer#357).
77
+ * Defaults to "standard". Only meaningful together with
78
+ * {@link onServiceTierChange}.
79
+ */
80
+ readonly serviceTier?: ServiceTierOption;
81
+ /**
82
+ * Called when the user toggles the fast tier. Providing this enables the
83
+ * tier toggle, which renders ONLY when the selected model prices a fast
84
+ * variant ({@link ModelInfo.serviceTiers}) — no dead controls. Selecting
85
+ * a model without a fast tier while "fast" is active resets the tier to
86
+ * "standard" through this callback, so an unsupported combination can
87
+ * never be submitted.
88
+ */
89
+ readonly onServiceTierChange?: (tier: ServiceTierOption) => void;
64
90
 
65
91
  /**
66
92
  * @deprecated Use {@link onHarnessChange} instead.
@@ -104,6 +130,9 @@ export function ModelSelector({
104
130
  compact = false,
105
131
  className,
106
132
  disabled,
133
+ placeholderLabel,
134
+ serviceTier = "standard",
135
+ onServiceTierChange,
107
136
  }: ModelSelectorProps) {
108
137
  const portalContainer = useStigmerPortalContainer();
109
138
 
@@ -216,9 +245,19 @@ export function ModelSelector({
216
245
  const selectModel = useCallback(
217
246
  (model: ModelInfo) => {
218
247
  onValueChange(model.modelId);
248
+ // Fail-safe: the fast tier is per-model; carrying it onto a model
249
+ // that does not price it would be refused at create time (#357), so
250
+ // reset here instead of surfacing a dead selection.
251
+ if (
252
+ serviceTier === "fast"
253
+ && onServiceTierChange
254
+ && !model.serviceTiers.includes(FAST_SERVICE_TIER)
255
+ ) {
256
+ onServiceTierChange("standard");
257
+ }
219
258
  setOpen(false);
220
259
  },
221
- [onValueChange],
260
+ [onValueChange, serviceTier, onServiceTierChange],
222
261
  );
223
262
 
224
263
  const scrollHighlightIntoView = useCallback((idx: number) => {
@@ -265,8 +304,23 @@ export function ModelSelector({
265
304
 
266
305
  const showShowAllButton = !isSearching && !showAll && featuredModels.length > 0 && featuredModels.length < models.length;
267
306
 
268
- const triggerLabel = selectedModel?.displayName ?? "Select model";
269
- const triggerHarness = !isHarnessLocked ? HARNESS_META[activeHarness].label : undefined;
307
+ // The tier toggle exists only where it applies: a consumer opted in AND
308
+ // the selected model prices a fast variant (recognition over recall —
309
+ // never a dead control on models with no tier choice).
310
+ const showServiceTierToggle =
311
+ onServiceTierChange !== undefined
312
+ && selectedModel !== undefined
313
+ && selectedModel.serviceTiers.includes(FAST_SERVICE_TIER);
314
+ const fastActive = serviceTier === "fast";
315
+
316
+ const usingPlaceholder = !value && placeholderLabel !== undefined;
317
+ const triggerLabel = usingPlaceholder
318
+ ? placeholderLabel
319
+ : (selectedModel?.displayName ?? "Select model");
320
+ const triggerHarness =
321
+ !isHarnessLocked && !usingPlaceholder
322
+ ? HARNESS_META[activeHarness].label
323
+ : undefined;
270
324
 
271
325
  return (
272
326
  <Popover.Root open={open} onOpenChange={setOpen}>
@@ -288,6 +342,16 @@ export function ModelSelector({
288
342
  <span className="shrink-0 text-border" aria-hidden>·</span>
289
343
  )}
290
344
  <span className="truncate">{triggerLabel}</span>
345
+ {showServiceTierToggle && fastActive && (
346
+ <span
347
+ className={cn(
348
+ "shrink-0 rounded-sm border border-border px-1 py-px",
349
+ "text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground",
350
+ )}
351
+ >
352
+ Fast
353
+ </span>
354
+ )}
291
355
  <ChevronIcon />
292
356
  </Popover.Trigger>
293
357
 
@@ -471,6 +535,39 @@ export function ModelSelector({
471
535
  </button>
472
536
  )}
473
537
  </div>
538
+
539
+ {/* Service tier toggle — only for models that price a fast variant (#357) */}
540
+ {showServiceTierToggle && (
541
+ <div className="flex items-center justify-between border-t border-border px-3 py-2">
542
+ <div className="flex flex-col">
543
+ <span className="text-xs text-foreground">Fast tier</span>
544
+ <span className="text-[0.65rem] text-muted-foreground">
545
+ Faster responses at higher per-token rates
546
+ </span>
547
+ </div>
548
+ <button
549
+ type="button"
550
+ role="switch"
551
+ aria-checked={fastActive}
552
+ aria-label="Fast tier"
553
+ className={cn(
554
+ "relative h-4.5 w-8 shrink-0 rounded-full transition-colors cursor-pointer",
555
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
556
+ fastActive ? "bg-primary" : "bg-muted",
557
+ )}
558
+ onClick={() =>
559
+ onServiceTierChange?.(fastActive ? "standard" : "fast")
560
+ }
561
+ >
562
+ <span
563
+ className={cn(
564
+ "absolute top-0.5 size-3.5 rounded-full bg-background shadow-sm transition-transform",
565
+ fastActive ? "translate-x-4" : "translate-x-0.5",
566
+ )}
567
+ />
568
+ </button>
569
+ </div>
570
+ )}
474
571
  </Popover.Popup>
475
572
  </Popover.Positioner>
476
573
  </Popover.Portal>
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
2
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3
+ import { ModelSelector } from "../ModelSelector";
4
+ import { ModelRegistryContext } from "../ModelRegistryContext";
5
+ import type { ModelInfo } from "../registry";
6
+
7
+ // Portal into document.body so the popover mounts under happy-dom, and shim
8
+ // ResizeObserver for Base UI's positioner (the scheduleCreation.test.tsx
9
+ // pattern).
10
+ vi.mock("../../portal-container", () => ({
11
+ useStigmerPortalContainer: () => document.body,
12
+ }));
13
+
14
+ beforeAll(() => {
15
+ if (!("ResizeObserver" in globalThis)) {
16
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
17
+ class {
18
+ observe() {}
19
+ unobserve() {}
20
+ disconnect() {}
21
+ };
22
+ }
23
+ });
24
+
25
+ /**
26
+ * Service-tier toggle gating (stigmer/stigmer#357).
27
+ *
28
+ * The toggle is capability-gated: it renders only when the consumer opted in
29
+ * (onServiceTierChange) AND the selected model prices a fast variant
30
+ * (ModelInfo.serviceTiers). Switching to a model without a fast tier resets
31
+ * an active fast selection — a stale tier would be refused at create time.
32
+ */
33
+
34
+ const FAST_CAPABLE: ModelInfo = {
35
+ modelId: "composer-2.5",
36
+ provider: "cursor",
37
+ displayName: "Composer 2.5",
38
+ shortDescription: "Fast agentic model",
39
+ speedTier: "fastest",
40
+ costTier: "economy",
41
+ harness: "cursor",
42
+ featured: true,
43
+ serviceTiers: ["fast"],
44
+ };
45
+
46
+ const NO_FAST_TIER: ModelInfo = {
47
+ modelId: "claude-haiku-4-5",
48
+ provider: "anthropic",
49
+ displayName: "Haiku 4.5",
50
+ shortDescription: "Small fast model",
51
+ speedTier: "fastest",
52
+ costTier: "economy",
53
+ harness: "cursor",
54
+ featured: true,
55
+ serviceTiers: [],
56
+ };
57
+
58
+ function renderSelector(
59
+ props?: Partial<React.ComponentProps<typeof ModelSelector>>,
60
+ ) {
61
+ const onValueChange = vi.fn();
62
+ const onServiceTierChange = vi.fn();
63
+
64
+ const result = render(
65
+ <ModelRegistryContext.Provider
66
+ value={{
67
+ models: [FAST_CAPABLE, NO_FAST_TIER],
68
+ isLoading: false,
69
+ error: null,
70
+ refetch: vi.fn(),
71
+ }}
72
+ >
73
+ <ModelSelector
74
+ value="composer-2.5"
75
+ onValueChange={onValueChange}
76
+ harness="cursor"
77
+ serviceTier="standard"
78
+ onServiceTierChange={onServiceTierChange}
79
+ {...props}
80
+ />
81
+ </ModelRegistryContext.Provider>,
82
+ );
83
+
84
+ return { ...result, onValueChange, onServiceTierChange };
85
+ }
86
+
87
+ /**
88
+ * Open the popover via its trigger and wait for the portaled popup to
89
+ * mount (Base UI portals render asynchronously in jsdom). The search
90
+ * input is the popup's always-present landmark.
91
+ */
92
+ async function openPopover(triggerLabel: string) {
93
+ fireEvent.click(screen.getByRole("button", { name: new RegExp(triggerLabel) }));
94
+ await screen.findByPlaceholderText("Search models…");
95
+ }
96
+
97
+ /** A model row inside the popup list (never matches the trigger). */
98
+ function modelOption(displayName: string): HTMLElement {
99
+ const option = screen
100
+ .getAllByText(displayName)
101
+ .map((el) => el.closest<HTMLElement>("[data-model-option]"))
102
+ .find((el) => el != null);
103
+ if (!option) throw new Error(`No popup option for "${displayName}"`);
104
+ return option;
105
+ }
106
+
107
+ afterEach(cleanup);
108
+
109
+ describe("ModelSelector — service tier toggle", () => {
110
+ it("renders the toggle for a fast-capable model", async () => {
111
+ renderSelector();
112
+ await openPopover("Composer 2.5");
113
+
114
+ const toggle = screen.getByRole("switch", { name: "Fast tier" });
115
+ expect(toggle.getAttribute("aria-checked")).toBe("false");
116
+ });
117
+
118
+ it("does not render the toggle for a model without a fast tier", async () => {
119
+ renderSelector({ value: "claude-haiku-4-5" });
120
+ await openPopover("Haiku 4.5");
121
+
122
+ expect(screen.queryByRole("switch", { name: "Fast tier" })).toBeNull();
123
+ });
124
+
125
+ it("does not render the toggle when the consumer did not opt in", async () => {
126
+ renderSelector({ onServiceTierChange: undefined });
127
+ await openPopover("Composer 2.5");
128
+
129
+ expect(screen.queryByRole("switch", { name: "Fast tier" })).toBeNull();
130
+ });
131
+
132
+ it("toggling fires onServiceTierChange with the flipped tier", async () => {
133
+ const { onServiceTierChange } = renderSelector();
134
+ await openPopover("Composer 2.5");
135
+
136
+ fireEvent.click(screen.getByRole("switch", { name: "Fast tier" }));
137
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
138
+ expect(onServiceTierChange).toHaveBeenCalledWith("fast");
139
+ });
140
+
141
+ it("shows the Fast badge on the trigger when the fast tier is active", () => {
142
+ renderSelector({ serviceTier: "fast" });
143
+
144
+ expect(screen.getByText("Fast")).toBeTruthy();
145
+ });
146
+
147
+ it("resets an active fast tier when selecting a model without one", async () => {
148
+ const { onValueChange, onServiceTierChange } = renderSelector({
149
+ serviceTier: "fast",
150
+ });
151
+ await openPopover("Composer 2.5");
152
+
153
+ fireEvent.click(modelOption("Haiku 4.5"));
154
+
155
+ expect(onValueChange).toHaveBeenCalledOnce();
156
+ expect(onValueChange).toHaveBeenCalledWith("claude-haiku-4-5");
157
+ expect(onServiceTierChange).toHaveBeenCalledOnce();
158
+ expect(onServiceTierChange).toHaveBeenCalledWith("standard");
159
+ });
160
+
161
+ it("keeps an active fast tier when selecting another fast-capable model", async () => {
162
+ const { onServiceTierChange } = renderSelector({
163
+ serviceTier: "fast",
164
+ value: "claude-haiku-4-5",
165
+ });
166
+ await openPopover("Haiku 4.5");
167
+
168
+ fireEvent.click(modelOption("Composer 2.5"));
169
+
170
+ expect(onServiceTierChange).not.toHaveBeenCalled();
171
+ });
172
+ });
@@ -73,6 +73,9 @@ const TEST_REGISTRY_JSON = {
73
73
  costTier: "standard",
74
74
  featured: false,
75
75
  pricing: { inputPricePerMillion: 3, outputPricePerMillion: 15, cacheWritePricePerMillion: 3.75, cacheReadPricePerMillion: 0.3 },
76
+ pricingVariants: {
77
+ fast: { inputPricePerMillion: 18, outputPricePerMillion: 90 },
78
+ },
76
79
  },
77
80
  {
78
81
  id: "ollama-local",
@@ -103,6 +106,18 @@ function createWrapper(models: readonly ModelInfo[] = TEST_MODELS) {
103
106
  };
104
107
  }
105
108
 
109
+ describe("parseRegistryJson service tiers (#357)", () => {
110
+ it("surfaces pricingVariants keys as serviceTiers", () => {
111
+ const withVariants = TEST_MODELS.find((m) => m.modelId === "claude-4.6-sonnet");
112
+ expect(withVariants?.serviceTiers).toEqual(["fast"]);
113
+ });
114
+
115
+ it("yields an empty serviceTiers list for models without variants", () => {
116
+ const withoutVariants = TEST_MODELS.find((m) => m.modelId === "claude-sonnet-4.6");
117
+ expect(withoutVariants?.serviceTiers).toEqual([]);
118
+ });
119
+ });
120
+
106
121
  describe("useModelRegistry", () => {
107
122
  describe("unified mode (no harness)", () => {
108
123
  it("excludes DISABLED_PROVIDERS from models", () => {
@@ -11,3 +11,5 @@ export { HarnessSelector } from "./HarnessSelector.js";
11
11
  export type { HarnessSelectorProps } from "./HarnessSelector.js";
12
12
  export { DEFAULT_HARNESS, HARNESS_LABELS, HARNESS_META, HARNESS_OPTIONS, toProtoHarness, fromProtoHarness } from "./harness.js";
13
13
  export type { HarnessOption, HarnessDisplayInfo } from "./harness.js";
14
+ export { FAST_SERVICE_TIER, toProtoServiceTier, fromProtoServiceTier } from "./service-tier.js";
15
+ export type { ServiceTierOption } from "./service-tier.js";
@@ -89,6 +89,16 @@ export interface ModelInfo {
89
89
  * before "Show All Models" is expanded or search is used).
90
90
  */
91
91
  readonly featured: boolean;
92
+ /**
93
+ * Pricing-variant keys the registry prices for this model (e.g.
94
+ * `["fast"]`). A priced variant is a *selectable* service tier
95
+ * (stigmer/stigmer#357): the tier toggle renders only for models whose
96
+ * `serviceTiers` includes it. Empty for models with no variants.
97
+ *
98
+ * Distinct from {@link speedTier}, which is a static latency badge,
99
+ * not a selectable option.
100
+ */
101
+ readonly serviceTiers: readonly string[];
92
102
  }
93
103
 
94
104
  /**
@@ -153,6 +163,8 @@ interface RegistryJsonEntry {
153
163
  cacheWritePricePerMillion: number;
154
164
  cacheReadPricePerMillion: number;
155
165
  };
166
+ /** Variant-key → variant pricing block; only the key set matters here. */
167
+ pricingVariants?: Record<string, unknown>;
156
168
  $comment?: string;
157
169
  }
158
170
 
@@ -192,6 +204,10 @@ export function parseRegistryJson(data: unknown): ModelInfo[] {
192
204
  costTier: m.costTier as CostTier,
193
205
  harness: m.harness as HarnessOption,
194
206
  featured: m.featured ?? false,
207
+ serviceTiers:
208
+ m.pricingVariants && typeof m.pricingVariants === "object"
209
+ ? Object.keys(m.pricingVariants).sort()
210
+ : [],
195
211
  }));
196
212
  }
197
213
 
@@ -0,0 +1,49 @@
1
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
2
+
3
+ /**
4
+ * String form of the service tier for component props and hook inputs
5
+ * (stigmer/stigmer#357). Mirrors {@link HarnessOption}: components speak
6
+ * strings, execution creation speaks proto.
7
+ *
8
+ * "standard" is the model's base-priced configuration; "fast" is the
9
+ * provider's fast variant at the registry's fast rates — selectable only
10
+ * for models whose {@link ModelInfo.serviceTiers} includes "fast".
11
+ */
12
+ export type ServiceTierOption = "standard" | "fast";
13
+
14
+ /** The tier key a model must price for the fast toggle to render. */
15
+ export const FAST_SERVICE_TIER = "fast";
16
+
17
+ /**
18
+ * Convert a {@link ServiceTierOption} string to the proto enum.
19
+ */
20
+ export function toProtoServiceTier(tier: ServiceTierOption): ServiceTier {
21
+ switch (tier) {
22
+ case "fast":
23
+ return ServiceTier.FAST;
24
+ case "standard":
25
+ default:
26
+ return ServiceTier.STANDARD;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Convert a proto {@link ServiceTier} enum to its string form.
32
+ *
33
+ * `UNSPECIFIED` (and any unknown value) maps to `undefined` so callers can
34
+ * decide their own default — matching the proto contract (`UNSPECIFIED`
35
+ * resolves to `SERVICE_TIER_STANDARD` in the runner, never the provider
36
+ * account default).
37
+ */
38
+ export function fromProtoServiceTier(
39
+ tier: ServiceTier | undefined,
40
+ ): ServiceTierOption | undefined {
41
+ switch (tier) {
42
+ case ServiceTier.STANDARD:
43
+ return "standard";
44
+ case ServiceTier.FAST:
45
+ return "fast";
46
+ default:
47
+ return undefined;
48
+ }
49
+ }