@flamingo-stack/openframe-frontend-core 0.0.723 → 0.0.725

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 (118) hide show
  1. package/dist/{chunk-2VOPQJNR.js → chunk-2MMZTACD.js} +2 -2
  2. package/dist/{chunk-OHC2MPUO.cjs → chunk-2SO3OJFY.cjs} +35 -35
  3. package/dist/{chunk-YQOJOW2Z.cjs → chunk-2VOZ37BL.cjs} +64 -64
  4. package/dist/{chunk-WSMOTZRJ.cjs → chunk-4ZZ4TNCY.cjs} +11 -11
  5. package/dist/{chunk-YUSA7IPD.js → chunk-5FVWDDX6.js} +3 -3
  6. package/dist/{chunk-PKS2QQJU.js → chunk-6EFNWQRY.js} +2 -1
  7. package/dist/{chunk-NESUUHPV.cjs → chunk-772242QR.cjs} +76 -76
  8. package/dist/{chunk-3RIKO2O2.cjs → chunk-7V5KFBY2.cjs} +31 -31
  9. package/dist/{chunk-MWLVYYRX.cjs → chunk-AI3CLDZO.cjs} +2580 -2120
  10. package/dist/{chunk-YGIUWQEN.js → chunk-ASHRHL4W.js} +7 -7
  11. package/dist/{chunk-A7JPMNHF.js → chunk-ASIXPRHV.js} +6 -6
  12. package/dist/{chunk-7JXCLUU7.js → chunk-AX4F35C2.js} +2 -2
  13. package/dist/{chunk-TDSLKKX7.cjs → chunk-B7234BE2.cjs} +16 -16
  14. package/dist/{chunk-SBCJFJ6Q.js → chunk-F56GNKX2.js} +4 -4
  15. package/dist/{chunk-VIFVSFYY.js → chunk-F77J34W2.js} +1 -1
  16. package/dist/{chunk-JENNI7XU.cjs → chunk-HEQ4GPYE.cjs} +5 -4
  17. package/dist/{chunk-LV6UJD2K.cjs → chunk-IITB5TJR.cjs} +10 -2
  18. package/dist/{chunk-WVA7SCLY.js → chunk-IOTAUWWF.js} +1 -1
  19. package/dist/{chunk-OVG6MV3X.js → chunk-JAKG6BY7.js} +6 -6
  20. package/dist/{chunk-F6JAGSKV.js → chunk-JVHP7Y4S.js} +5 -5
  21. package/dist/{chunk-WY4SLTMZ.js → chunk-JXJFUGJY.js} +1 -1
  22. package/dist/{chunk-GYUFKNOQ.cjs → chunk-KLCOSY7B.cjs} +4 -4
  23. package/dist/{chunk-P7MDECYI.cjs → chunk-KNLRL63O.cjs} +41 -41
  24. package/dist/{chunk-VGXAZEQP.cjs → chunk-LR6DJY4Z.cjs} +17 -17
  25. package/dist/{chunk-KSADTFNZ.cjs → chunk-LYQYLPPV.cjs} +32 -32
  26. package/dist/{chunk-QBXEF7D5.js → chunk-MGTZ2BKY.js} +4 -4
  27. package/dist/{chunk-IIBTYCEN.js → chunk-MLFS5G3V.js} +89 -14
  28. package/dist/{chunk-GRDJR2Q7.js → chunk-MSPXMSMY.js} +1 -1
  29. package/dist/{chunk-FDWYBJYD.cjs → chunk-NJV3LIDE.cjs} +3 -3
  30. package/dist/{chunk-JL7AHSFE.js → chunk-OBMWSUBK.js} +6 -6
  31. package/dist/{chunk-N4VCU6XP.js → chunk-PUADVXJD.js} +1 -1
  32. package/dist/{chunk-KY4WQYYG.cjs → chunk-QV4GO3GW.cjs} +19 -19
  33. package/dist/{chunk-OWRD2GZU.cjs → chunk-RU6BTELI.cjs} +687 -612
  34. package/dist/{chunk-Y5H3M7OM.cjs → chunk-SE4O4HCD.cjs} +23 -23
  35. package/dist/{chunk-2MC4DRSC.cjs → chunk-SEVKFMKR.cjs} +3 -3
  36. package/dist/{chunk-UYMTAFHG.js → chunk-TU5LEF6A.js} +1 -1
  37. package/dist/{chunk-NFABAOMD.cjs → chunk-U6LRRG2E.cjs} +15 -15
  38. package/dist/{chunk-G6YNPBWV.js → chunk-W2DXHR2R.js} +3045 -2585
  39. package/dist/{chunk-KSZRALRL.cjs → chunk-YPEILGVZ.cjs} +3 -3
  40. package/dist/{chunk-EWED6ISQ.js → chunk-YQDKVGKO.js} +9 -1
  41. package/dist/{chunk-6QTGLBJH.cjs → chunk-ZSXZE4FJ.cjs} +3 -3
  42. package/dist/{chunk-XTG7W75B.js → chunk-ZZRUKEN2.js} +4 -4
  43. package/dist/{cloud-tenants-3TVX7U23.cjs → cloud-tenants-SMULXU54.cjs} +7 -7
  44. package/dist/{cloud-tenants-N6KV2T7X.js → cloud-tenants-US63E2HV.js} +6 -6
  45. package/dist/components/case-studies/index.cjs +20 -20
  46. package/dist/components/case-studies/index.js +7 -7
  47. package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
  48. package/dist/components/chat/index.cjs +5 -5
  49. package/dist/components/chat/index.js +4 -4
  50. package/dist/components/chat/utils/icon-library.d.ts +2 -0
  51. package/dist/components/chat/utils/icon-library.d.ts.map +1 -1
  52. package/dist/components/contact/index.cjs +8 -8
  53. package/dist/components/contact/index.js +7 -7
  54. package/dist/components/docs/index.cjs +11 -11
  55. package/dist/components/docs/index.js +10 -10
  56. package/dist/components/embeds/index.cjs +8 -8
  57. package/dist/components/embeds/index.js +7 -7
  58. package/dist/components/faq/index.cjs +8 -8
  59. package/dist/components/faq/index.js +7 -7
  60. package/dist/components/features/index.cjs +23 -7
  61. package/dist/components/features/index.d.ts +1 -0
  62. package/dist/components/features/index.d.ts.map +1 -1
  63. package/dist/components/features/index.js +22 -6
  64. package/dist/components/features/model-token-rates.d.ts +179 -0
  65. package/dist/components/features/model-token-rates.d.ts.map +1 -0
  66. package/dist/components/help-center-pages/index.cjs +106 -106
  67. package/dist/components/help-center-pages/index.js +18 -18
  68. package/dist/components/index.cjs +71 -55
  69. package/dist/components/index.js +34 -18
  70. package/dist/components/meeting-scheduler/index.cjs +32 -32
  71. package/dist/components/meeting-scheduler/index.js +6 -6
  72. package/dist/components/navigation/index.cjs +10 -10
  73. package/dist/components/navigation/index.js +9 -9
  74. package/dist/components/onboarding-guides/index.cjs +10 -10
  75. package/dist/components/onboarding-guides/index.js +9 -9
  76. package/dist/components/product-demo/index.cjs +7 -7
  77. package/dist/components/product-demo/index.js +7 -7
  78. package/dist/components/related-content/index.cjs +4 -4
  79. package/dist/components/related-content/index.js +3 -3
  80. package/dist/components/tickets/index.cjs +11 -11
  81. package/dist/components/tickets/index.js +10 -10
  82. package/dist/components/ui/index.cjs +7 -7
  83. package/dist/components/ui/index.js +6 -6
  84. package/dist/{devices-MAALZ7DX.cjs → devices-6ZURNFRB.cjs} +8 -8
  85. package/dist/{devices-275UZZUS.js → devices-JWHGWKLK.js} +6 -6
  86. package/dist/index.cjs +45 -11
  87. package/dist/index.js +44 -10
  88. package/dist/{knowledge-YMMULPPK.cjs → knowledge-CY66JZ54.cjs} +8 -8
  89. package/dist/{knowledge-THXC2KC3.js → knowledge-TEDPLCFX.js} +6 -6
  90. package/dist/{logs-IBUKQOHS.cjs → logs-MO357RE7.cjs} +15 -15
  91. package/dist/{logs-GM2XBHYT.js → logs-S46THPPD.js} +6 -6
  92. package/dist/{policies-NKMRK6RR.js → policies-BUXT7DKF.js} +6 -6
  93. package/dist/{policies-J5GJXD6G.cjs → policies-GSH4K5YP.cjs} +11 -11
  94. package/dist/{remote-session-Z2YCUPHU.cjs → remote-session-CSX6ESSG.cjs} +5 -5
  95. package/dist/{remote-session-WCLR47RN.js → remote-session-ZBCWD5A5.js} +3 -3
  96. package/dist/{software-update-JHC4T4SE.cjs → software-update-2ETT35NM.cjs} +11 -11
  97. package/dist/{software-update-EKKMKDWI.js → software-update-YKS6KLX2.js} +6 -6
  98. package/dist/utils/ai-providers.d.ts +29 -0
  99. package/dist/utils/ai-providers.d.ts.map +1 -0
  100. package/dist/utils/index.cjs +76 -2
  101. package/dist/utils/index.d.ts +2 -0
  102. package/dist/utils/index.d.ts.map +1 -1
  103. package/dist/utils/index.js +68 -3
  104. package/dist/utils/list-url.d.ts.map +1 -1
  105. package/dist/utils/model-token-rates.d.ts +55 -0
  106. package/dist/utils/model-token-rates.d.ts.map +1 -0
  107. package/dist/utils/source-icons.d.ts.map +1 -1
  108. package/package.json +1 -1
  109. package/src/components/chat/entity-cards/dispatch.tsx +3 -0
  110. package/src/components/chat/utils/icon-library.tsx +14 -3
  111. package/src/components/chat/utils/source-row-cta.ts +3 -3
  112. package/src/components/features/index.ts +1 -0
  113. package/src/components/features/model-token-rates.tsx +696 -0
  114. package/src/utils/ai-providers.ts +46 -0
  115. package/src/utils/index.ts +2 -0
  116. package/src/utils/list-url.ts +1 -0
  117. package/src/utils/model-token-rates.ts +103 -0
  118. package/src/utils/source-icons.ts +3 -0
@@ -0,0 +1,696 @@
1
+ 'use client';
2
+
3
+ import { type ReactNode, useId, useState } from 'react';
4
+ import { aiProvider } from '../../utils/ai-providers';
5
+ import { cn } from '../../utils/cn';
6
+ import { formatPrice } from '../../utils/format';
7
+ import {
8
+ formatTokenAmount,
9
+ formatTokenRate,
10
+ groupTokenRatesByProvider,
11
+ type ModelTokenRate,
12
+ type TokenPrice,
13
+ tokenCost,
14
+ tokenRateProviderLabel,
15
+ tokensForBalance,
16
+ } from '../../utils/model-token-rates';
17
+ import { findIcon } from '../chat/utils/icon-library';
18
+ import { AlertTriangleIcon } from '../icons-v2-generated/interface/alert-triangle-icon';
19
+ import { Refresh02VrIcon } from '../icons-v2-generated/media-playback/refresh-02-vr-icon';
20
+ import { QuestionCircleIcon } from '../icons-v2-generated/signs-and-symbols/question-circle-icon';
21
+ import { XmarkCircleIcon } from '../icons-v2-generated/signs-and-symbols/xmark-circle-icon';
22
+ import { Autocomplete, type AutocompleteOption } from '../ui/autocomplete';
23
+ import { Button } from '../ui/button';
24
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu';
25
+ import { Skeleton } from '../ui/skeleton';
26
+ import { Slider } from '../ui/slider';
27
+ import { TabSelector } from '../ui/tab-selector';
28
+ import { PushButtonSelector } from './push-button-selector';
29
+
30
+ /**
31
+ * The per-model AI token exchange rates, in the two places they are shown:
32
+ *
33
+ * - `ModelTokenRates` (+ `ModelTokenRatesPopover`): a small table behind a
34
+ * question mark, for the product's cards that count in tokens.
35
+ * - `ModelTokenExchange`: the whole idea as three steps with a figure each
36
+ * (buy OpenFrame tokens, use a frontier model, its tokens are charged at an
37
+ * exchange rate). For a page (the website's pricing page).
38
+ *
39
+ * Both state a rate with the same functions (`utils/model-token-rates`,
40
+ * server-safe), so a rate reads the same wherever it is stated. Neither
41
+ * fetches: a host reads the rates its own way and hands them in, with `status`
42
+ * saying where that read stands. Each holds ONE size loading, loaded, failed or
43
+ * empty.
44
+ *
45
+ * A rate is the number of OpenFrame tokens ONE token of the model charges to
46
+ * the balance.
47
+ */
48
+
49
+ /** Where the host's read of the rates stands. */
50
+ export type ModelTokenRatesStatus = 'loading' | 'error' | 'ready';
51
+
52
+ /** The wording of both views, stated once. */
53
+ export const MODEL_TOKEN_RATES_COPY = {
54
+ trigger: 'Per-model token rates',
55
+ unit: 'OpenFrame tokens used per model token',
56
+ model: 'Model',
57
+ input: 'Input',
58
+ output: 'Output',
59
+ /** The exchange: three steps, each with its own figure. */
60
+ exchange: {
61
+ buy: {
62
+ title: 'You buy OpenFrame tokens',
63
+ /** Under the price: "buys 1M OpenFrame tokens" */
64
+ words: (tokens: string) => `buys ${tokens} OpenFrame tokens`,
65
+ /** Under the tokens, where no price is known. */
66
+ unit: 'OpenFrame tokens',
67
+ /** The amount, where no price is known: "1M OpenFrame tokens" */
68
+ tokens: (tokens: string) => `${tokens} OpenFrame tokens`,
69
+ /** The slider's label. */
70
+ amount: 'How many tokens',
71
+ /** Over the one-tap amounts. */
72
+ presets: 'Included in your plan',
73
+ /** A one-tap amount: "10M every month" */
74
+ preset: (tokens: string, label: string) => `${tokens} ${label}`,
75
+ },
76
+ use: {
77
+ title: 'You use a frontier AI model',
78
+ model: 'Model',
79
+ search: 'Search models',
80
+ noMatch: 'No model matches',
81
+ },
82
+ charge: {
83
+ title: 'Its tokens are charged at an exchange rate',
84
+ /** Over the two rates: the unit, said once. */
85
+ unit: 'OpenFrame tokens charged',
86
+ input: 'per token read',
87
+ output: 'per token written',
88
+ /** Over the answer: "So $10.00 on Claude Opus 5.5 buys" */
89
+ so: (balance: string, model: string) => `So ${balance} on ${model} buys`,
90
+ /** Beside the answer's number: "750K tokens read" */
91
+ read: 'tokens read',
92
+ /** Under it: "or 150K tokens written" */
93
+ orWritten: (output: string) => `or ${output} tokens written`,
94
+ },
95
+ },
96
+ autoTopUp: { on: 'Auto Top Up Enabled', off: 'Auto Top Up Disabled' },
97
+ unavailable: {
98
+ title: 'Rates unavailable',
99
+ body: "We couldn't load the per-model token rates. They're a reference only: your plan and its price are unaffected.",
100
+ },
101
+ empty: 'No token rates are in effect right now.',
102
+ } as const;
103
+
104
+ /**
105
+ * A provider's mark, found in the icon set BY NAME (the name the host's server
106
+ * stated, else the shared provider list's): nothing here imports a provider's
107
+ * icon. The monochrome cut takes the text colour, so no brand colour competes
108
+ * with the figures. A provider the set holds no mark for is shown by name.
109
+ */
110
+ function providerMark(providerType: string, stated: string | null | undefined, className: string): ReactNode {
111
+ const Mark = findIcon(aiProvider(providerType, { icon: stated }).icon);
112
+ return Mark ? <Mark className={className} /> : undefined;
113
+ }
114
+
115
+ const PAD_X = 'px-[var(--spacing-system-s)]';
116
+ const ROW = 'flex items-center gap-[var(--spacing-system-s)]';
117
+ /** Fixed width, so the figures of separate rows line up as columns. */
118
+ const NUMBER_CELL = 'w-16 shrink-0 whitespace-nowrap text-right tabular-nums';
119
+
120
+ /** The provider shown: the one picked while the rates still hold it, else the first. */
121
+ function useProviderGroup(rates: readonly ModelTokenRate[]) {
122
+ const groups = groupTokenRatesByProvider(rates);
123
+ const [picked, setPicked] = useState<string | null>(null);
124
+ return { groups, group: groups.find(candidate => candidate.providerType === picked) ?? groups[0], setPicked };
125
+ }
126
+
127
+ /** The provider tabs. Tabs keep their own width and the row scrolls, so one more provider never squeezes the rest. */
128
+ function ProviderTabs({
129
+ groups,
130
+ value,
131
+ onValueChange,
132
+ className,
133
+ }: {
134
+ groups: { providerType: string; rates: readonly ModelTokenRate[] }[];
135
+ value: string;
136
+ onValueChange: (providerType: string) => void;
137
+ className?: string;
138
+ }) {
139
+ return (
140
+ <TabSelector
141
+ variant="secondary"
142
+ scrollable
143
+ value={value}
144
+ onValueChange={onValueChange}
145
+ items={groups.map(candidate => ({
146
+ id: candidate.providerType,
147
+ label: tokenRateProviderLabel(candidate.providerType, candidate.rates[0]?.providerLabel),
148
+ icon: providerMark(candidate.providerType, candidate.rates[0]?.providerIcon, 'size-full'),
149
+ }))}
150
+ className={className}
151
+ />
152
+ );
153
+ }
154
+
155
+ /** The tabs' box while the rates load (the selector's own heights). */
156
+ const TABS_SKELETON_CLASS = 'h-11 w-full max-w-sm content-md:h-12';
157
+
158
+ // ─── The table (a popover) ───────────────────────────────────────────────────
159
+
160
+ export interface ModelTokenRatesProps {
161
+ /** The rates, once read. */
162
+ rates?: readonly ModelTokenRate[];
163
+ /** Where the read stands. Default `ready`. */
164
+ status?: ModelTokenRatesStatus;
165
+ /**
166
+ * Whether the balance these rates draw on refills itself: the panel's first
167
+ * line on a billing page. Left out where there is no balance to have that state.
168
+ */
169
+ autoTopUpEnabled?: boolean;
170
+ className?: string;
171
+ }
172
+
173
+ /** How many rows the table's skeleton draws: about what the panel's height shows. */
174
+ const TABLE_SKELETON_ROWS = 8;
175
+
176
+ /**
177
+ * The table in its frame, at ONE height in every state. The frame and the auto
178
+ * top-up line stay put while the rates load or fail to: that line is a fact
179
+ * about the subscription, not about the rates.
180
+ */
181
+ export function ModelTokenRates({ rates = [], status = 'ready', autoTopUpEnabled, className }: ModelTokenRatesProps) {
182
+ return (
183
+ <div
184
+ className={cn(
185
+ 'flex h-[min(60vh,420px)] w-[340px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[6px] border border-ods-border bg-ods-card',
186
+ className,
187
+ )}
188
+ >
189
+ {autoTopUpEnabled != null && <AutoTopUpLine enabled={autoTopUpEnabled} />}
190
+ {status === 'loading' ? (
191
+ <ModelTokenRatesSkeleton />
192
+ ) : status === 'error' ? (
193
+ <ModelTokenRatesUnavailable className="flex-1" />
194
+ ) : (
195
+ <ModelTokenRatesTable rates={rates} />
196
+ )}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * The question-mark button that opens the rates, for every card that counts in
203
+ * tokens. One trigger, so two cards cannot open two different panels. The panel
204
+ * is rendered only while open, so a host that reads the rates lazily (`children`)
205
+ * reads them on open.
206
+ */
207
+ export function ModelTokenRatesPopover({
208
+ children,
209
+ triggerClassName,
210
+ ...panel
211
+ }: ModelTokenRatesProps & {
212
+ /** The panel, when the host renders it itself (a lazy read behind its own boundaries). Default: `ModelTokenRates` with these props. */
213
+ children?: ReactNode;
214
+ triggerClassName?: string;
215
+ }) {
216
+ return (
217
+ <DropdownMenu>
218
+ <DropdownMenuTrigger asChild>
219
+ <button
220
+ type="button"
221
+ aria-label={MODEL_TOKEN_RATES_COPY.trigger}
222
+ className={cn(
223
+ 'shrink-0 text-ods-text-secondary transition-colors hover:text-ods-text-primary',
224
+ triggerClassName,
225
+ )}
226
+ >
227
+ <QuestionCircleIcon className="size-6" />
228
+ </button>
229
+ </DropdownMenuTrigger>
230
+ <DropdownMenuContent align="end" sideOffset={8} className="border-0 bg-transparent p-0 shadow-none">
231
+ {children ?? <ModelTokenRates {...panel} />}
232
+ </DropdownMenuContent>
233
+ </DropdownMenu>
234
+ );
235
+ }
236
+
237
+ /** On: the success tint, the one band of colour in the panel, so a refilling balance reads at a glance. Off: the panel's own background. */
238
+ function AutoTopUpLine({ enabled }: { enabled: boolean }) {
239
+ const Icon = enabled ? Refresh02VrIcon : XmarkCircleIcon;
240
+ return (
241
+ <div
242
+ className={cn(
243
+ 'flex shrink-0 items-center gap-[var(--spacing-system-xs)] border-b border-ods-border py-[var(--spacing-system-xs)] text-h4',
244
+ PAD_X,
245
+ enabled ? 'bg-ods-success-secondary text-ods-success' : 'text-ods-text-secondary',
246
+ )}
247
+ >
248
+ <Icon className="size-6 shrink-0" />
249
+ <span className="flex-1">
250
+ {enabled ? MODEL_TOKEN_RATES_COPY.autoTopUp.on : MODEL_TOKEN_RATES_COPY.autoTopUp.off}
251
+ </span>
252
+ </div>
253
+ );
254
+ }
255
+
256
+ function ModelTokenRatesTable({ rates }: { rates: readonly ModelTokenRate[] }) {
257
+ const { groups, group, setPicked } = useProviderGroup(rates);
258
+
259
+ if (!group) {
260
+ return (
261
+ <p className={cn('flex-1 py-[var(--spacing-system-s)] text-ods-text-secondary text-h6', PAD_X)}>
262
+ {MODEL_TOKEN_RATES_COPY.empty}
263
+ </p>
264
+ );
265
+ }
266
+
267
+ return (
268
+ <>
269
+ {groups.length > 1 && (
270
+ <ProviderTabs
271
+ groups={groups}
272
+ value={group.providerType}
273
+ onValueChange={setPicked}
274
+ className={cn('shrink-0 border-b border-ods-border py-[var(--spacing-system-xs)]', PAD_X)}
275
+ />
276
+ )}
277
+ <div role="table" aria-label={MODEL_TOKEN_RATES_COPY.unit} className="flex min-h-0 flex-1 flex-col">
278
+ <div
279
+ role="row"
280
+ className={cn(
281
+ ROW,
282
+ PAD_X,
283
+ 'shrink-0 py-[var(--spacing-system-xs)] uppercase tracking-[-0.02em] text-ods-text-secondary text-h5',
284
+ )}
285
+ >
286
+ <span role="columnheader" className="min-w-0 flex-1 truncate">
287
+ {MODEL_TOKEN_RATES_COPY.model}
288
+ </span>
289
+ <span role="columnheader" className={NUMBER_CELL}>
290
+ {MODEL_TOKEN_RATES_COPY.input}
291
+ </span>
292
+ <span role="columnheader" className={NUMBER_CELL}>
293
+ {MODEL_TOKEN_RATES_COPY.output}
294
+ </span>
295
+ </div>
296
+ {/* Every model of the provider, scrolling under the pinned header. */}
297
+ <div role="rowgroup" className="min-h-0 flex-1 overflow-y-auto">
298
+ {group.rates.map(rate => (
299
+ <div
300
+ key={rate.modelName}
301
+ role="row"
302
+ className={cn(ROW, PAD_X, 'py-[var(--spacing-system-xxs)] text-ods-text-primary text-h6')}
303
+ >
304
+ <span role="rowheader" className="min-w-0 flex-1 truncate">
305
+ {rate.displayName || rate.modelName}
306
+ </span>
307
+ <span role="cell" className={NUMBER_CELL}>
308
+ {formatTokenRate(rate.inputTokenRate)}
309
+ </span>
310
+ <span role="cell" className={NUMBER_CELL}>
311
+ {formatTokenRate(rate.outputTokenRate)}
312
+ </span>
313
+ </div>
314
+ ))}
315
+ </div>
316
+ </div>
317
+ {/* The unit, stated once, where it has room to wrap. */}
318
+ <p
319
+ className={cn(
320
+ 'shrink-0 border-t border-ods-border py-[var(--spacing-system-xs)] text-ods-text-secondary text-h6',
321
+ PAD_X,
322
+ )}
323
+ >
324
+ {MODEL_TOKEN_RATES_COPY.unit}
325
+ </p>
326
+ </>
327
+ );
328
+ }
329
+
330
+ /** Why the rates are missing, in the box the rates would fill. Shown on a deliberate click or in a page's reserved space, so it never renders empty. */
331
+ export function ModelTokenRatesUnavailable({ className }: { className?: string }) {
332
+ return (
333
+ <div
334
+ role="alert"
335
+ className={cn(
336
+ 'flex flex-col items-center justify-center gap-[var(--spacing-system-xs)] p-[var(--spacing-system-mf)] text-center',
337
+ className,
338
+ )}
339
+ >
340
+ <div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-ods-bg text-ods-text-secondary">
341
+ <AlertTriangleIcon className="size-5" />
342
+ </div>
343
+ <p className="text-ods-text-primary text-h3">{MODEL_TOKEN_RATES_COPY.unavailable.title}</p>
344
+ <p className="max-w-[320px] text-ods-text-secondary text-h6">{MODEL_TOKEN_RATES_COPY.unavailable.body}</p>
345
+ </div>
346
+ );
347
+ }
348
+
349
+ /** The table's loading footprint: the provider tabs, the header and rows of the boxes the loaded rows use. */
350
+ export function ModelTokenRatesSkeleton() {
351
+ const cells = (
352
+ <>
353
+ <div className="flex-1" />
354
+ <Skeleton className="h-4 w-12" />
355
+ <Skeleton className="h-4 w-12" />
356
+ </>
357
+ );
358
+ return (
359
+ <div aria-busy="true" className="min-h-0 flex-1 overflow-hidden">
360
+ <div className={cn('border-b border-ods-border py-[var(--spacing-system-xs)]', PAD_X)}>
361
+ <Skeleton className={TABS_SKELETON_CLASS} />
362
+ </div>
363
+ <div className={cn(ROW, PAD_X, 'py-[var(--spacing-system-xs)]')}>
364
+ <Skeleton className="h-4 w-14" />
365
+ {cells}
366
+ </div>
367
+ {Array.from({ length: TABLE_SKELETON_ROWS }, (_, index) => (
368
+ // biome-ignore lint/suspicious/noArrayIndexKey: identical placeholder rows
369
+ <div key={index} className={cn(ROW, PAD_X, 'py-[var(--spacing-system-xxs)]')}>
370
+ <Skeleton className="h-4 w-36" />
371
+ {cells}
372
+ </div>
373
+ ))}
374
+ </div>
375
+ );
376
+ }
377
+
378
+ // ─── The exchange (a page) ───────────────────────────────────────────────────
379
+
380
+ export interface ModelTokenExchangeProps {
381
+ /** The model whose rate is shown: the one picked, once the host has read it. */
382
+ rate?: ModelTokenRate | null;
383
+ /** Where the host's read of that model stands. Default `ready`. */
384
+ status?: ModelTokenRatesStatus;
385
+ /** The providers to choose between (their keys), in the host's order. */
386
+ providers?: readonly string[];
387
+ /** The provider chosen, and the host's handler for a change (it searches that provider's models). */
388
+ provider?: string | null;
389
+ onProviderChange?: (providerType: string) => void;
390
+ /**
391
+ * The picker's options: what the HOST's search answered for `query` within
392
+ * `provider`. The picker never filters them itself: searching is the host's
393
+ * server's job.
394
+ */
395
+ models?: readonly ModelTokenRate[];
396
+ /** What is typed in the picker, and the host's handler for it (it searches). */
397
+ query?: string;
398
+ onQueryChange?: (query: string) => void;
399
+ /** The host's search is in flight. */
400
+ searching?: boolean;
401
+ /** A model was picked: its `modelName`. */
402
+ onPick?: (modelName: string) => void;
403
+ /** What OpenFrame tokens cost. Given: step 1 states the price of the amount chosen. Not given: the tokens alone. */
404
+ tokenPrice?: TokenPrice | null;
405
+ /** The amount step 1 opens on, in OpenFrame tokens. Default: the amount `tokenPrice` is quoted for, else one million. */
406
+ defaultAmount?: number;
407
+ /** The slider's bounds and step, in OpenFrame tokens. Default one to fifty million, a million at a time. */
408
+ amountRange?: { min: number; max: number; step: number };
409
+ /** Amounts offered as one-tap choices under the slider (what a plan includes): the tokens and what to call them. */
410
+ presets?: readonly { tokens: number; label: string }[];
411
+ className?: string;
412
+ }
413
+
414
+ /** The amount step 1 opens on when the host names none, and the slider's default bounds. */
415
+ export const MODEL_TOKEN_EXCHANGE_BALANCE = 1_000_000;
416
+ export const MODEL_TOKEN_EXCHANGE_RANGE = { min: 1_000_000, max: 50_000_000, step: 1_000_000 } as const;
417
+
418
+ /** The provider rows drawn while the providers load: as many as OpenFrame has had. */
419
+ const PROVIDER_PLACEHOLDERS = ['loading-1', 'loading-2', 'loading-3'] as const;
420
+
421
+ /** A line of the surrounding text's own height, as a bar: what a figure is while it loads. */
422
+ function Bar({ className }: { className: string }) {
423
+ return (
424
+ <span
425
+ aria-hidden
426
+ className={cn('inline-block h-[0.8lh] animate-pulse rounded bg-ods-border align-middle', className)}
427
+ />
428
+ );
429
+ }
430
+
431
+ /** A model as a picker option: its name, its provider under it, the provider's mark beside it. */
432
+ function modelOption(rate: ModelTokenRate): AutocompleteOption<string> {
433
+ return {
434
+ label: rate.displayName || rate.modelName,
435
+ value: rate.modelName,
436
+ description: tokenRateProviderLabel(rate.providerType, rate.providerLabel),
437
+ icon: providerMark(rate.providerType, rate.providerIcon, 'size-full text-ods-text-secondary'),
438
+ };
439
+ }
440
+
441
+ /** One step of the flow: its number and what happens in it, over its content. */
442
+ function ExchangeStep({ step, title, children }: { step: number; title: string; children: ReactNode }) {
443
+ return (
444
+ <section className="flex min-w-0 flex-col gap-[var(--spacing-system-mf)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)]">
445
+ <header className="flex items-center gap-[var(--spacing-system-sf)]">
446
+ <span className="grid size-8 shrink-0 place-items-center rounded-full border border-ods-border tabular-nums text-ods-text-primary text-h5">
447
+ {step}
448
+ </span>
449
+ <h3 className="min-w-0 text-ods-text-primary text-h3">{title}</h3>
450
+ </header>
451
+ {children}
452
+ </section>
453
+ );
454
+ }
455
+
456
+ /**
457
+ * A figure over the plain words that say what it is. `hero` is the step's one
458
+ * headline figure; `plain` is a supporting one, a size down so two sit side by
459
+ * side in a narrow step. The words keep two lines, so a step is one height when
460
+ * they wrap.
461
+ */
462
+ function ExchangeFigure({
463
+ figure,
464
+ words,
465
+ loading,
466
+ size = 'hero',
467
+ }: {
468
+ figure: ReactNode;
469
+ words: ReactNode;
470
+ loading?: boolean;
471
+ size?: 'hero' | 'plain';
472
+ }) {
473
+ return (
474
+ <div className="flex min-w-0 flex-col">
475
+ <span
476
+ className={cn('whitespace-nowrap tabular-nums text-ods-text-primary', size === 'hero' ? 'text-h1' : 'text-h2')}
477
+ >
478
+ {loading ? <Bar className="w-20" /> : figure}
479
+ </span>
480
+ <span className="min-h-[2lh] text-ods-text-secondary text-h4">
481
+ {loading ? <Bar className="w-full max-w-xs" /> : words}
482
+ </span>
483
+ </div>
484
+ );
485
+ }
486
+
487
+ /**
488
+ * How AI tokens are paid for, as three steps read left to right, each with its
489
+ * own figure, so the whole idea is on screen at once:
490
+ *
491
+ * 1. You buy OpenFrame tokens "$10.00 buys 1M OpenFrame tokens"
492
+ * 2. You use a frontier AI model provider buttons, then the model
493
+ * 3. Its tokens are charged at an exchange rate
494
+ * "1.33× per token it reads, 6.67× per token it
495
+ * writes", then what the step 1 balance buys
496
+ *
497
+ * Step 2 is where the visitor acts: the lib's `PushButtonSelector` picks the
498
+ * provider and the lib's `Autocomplete` (the provider's mark on every option)
499
+ * finds a model of it. The picker SEARCHES ON THE SERVER: the host hands in
500
+ * `models` (what its search answered for `query` within `provider`) and the
501
+ * picker shows exactly those, never a filter of its own.
502
+ *
503
+ * One size in every state: each figure and its words are the same boxes loading
504
+ * (bars of their own line height), loaded, failed and empty.
505
+ */
506
+ export function ModelTokenExchange({
507
+ rate,
508
+ status = 'ready',
509
+ providers = [],
510
+ provider,
511
+ onProviderChange,
512
+ models = [],
513
+ query,
514
+ onQueryChange,
515
+ searching = false,
516
+ onPick,
517
+ tokenPrice,
518
+ defaultAmount,
519
+ amountRange = MODEL_TOKEN_EXCHANGE_RANGE,
520
+ presets = [],
521
+ className,
522
+ }: ModelTokenExchangeProps) {
523
+ const copy = MODEL_TOKEN_RATES_COPY.exchange;
524
+ const loading = status === 'loading';
525
+ const failed = status === 'error' || (status === 'ready' && !rate);
526
+ const sliderId = useId();
527
+ // The amount bought: what the visitor set, else what the host opens on. The slider reaches every preset.
528
+ const [chosen, setChosen] = useState<number | null>(null);
529
+ const balance = chosen ?? defaultAmount ?? tokenPrice?.tokens ?? MODEL_TOKEN_EXCHANGE_BALANCE;
530
+ const max = Math.max(amountRange.max, balance, ...presets.map(preset => preset.tokens));
531
+ const tokens = formatTokenAmount(balance);
532
+ const cost = tokenCost(balance, tokenPrice);
533
+ const balancePrice = cost === null ? null : formatPrice(cost);
534
+ const model = rate ? rate.displayName || rate.modelName : '';
535
+
536
+ // The picked model stays addressable when the search's answer does not hold it.
537
+ const options = models.map(modelOption);
538
+ if (rate && !models.some(candidate => candidate.modelName === rate.modelName)) options.unshift(modelOption(rate));
539
+
540
+ return (
541
+ <div className={cn('relative grid grid-cols-1 gap-[var(--spacing-system-mf)] lg:grid-cols-3', className)}>
542
+ <ExchangeStep step={1} title={copy.buy.title}>
543
+ <ExchangeFigure figure={balancePrice ?? tokens} words={balancePrice ? copy.buy.words(tokens) : copy.buy.unit} />
544
+ {/* The calculator: move the amount and every figure in step 3 follows. */}
545
+ <div className="flex flex-col gap-[var(--spacing-system-sf)]">
546
+ <label
547
+ htmlFor={sliderId}
548
+ className="flex items-baseline justify-between gap-[var(--spacing-system-sf)] text-ods-text-secondary text-h4"
549
+ >
550
+ <span>{copy.buy.amount}</span>
551
+ <output htmlFor={sliderId} className="tabular-nums text-ods-text-primary text-h3">
552
+ {tokens}
553
+ </output>
554
+ </label>
555
+ <Slider
556
+ id={sliderId}
557
+ min={amountRange.min}
558
+ max={max}
559
+ step={amountRange.step}
560
+ value={[balance]}
561
+ onValueChange={([value]) => setChosen(value)}
562
+ aria-valuetext={copy.buy.words(tokens)}
563
+ />
564
+ </div>
565
+ {presets.length > 0 && (
566
+ <div className="mt-auto flex flex-col gap-[var(--spacing-system-xs)] border-t border-ods-border pt-[var(--spacing-system-mf)]">
567
+ <span className="text-ods-text-secondary text-h5">{copy.buy.presets}</span>
568
+ <div className="flex flex-wrap gap-[var(--spacing-system-xs)]">
569
+ {presets.map(preset => (
570
+ <Button
571
+ key={preset.label}
572
+ type="button"
573
+ variant={preset.tokens === balance ? 'accent' : 'outline'}
574
+ size="small"
575
+ onClick={() => setChosen(preset.tokens)}
576
+ >
577
+ {copy.buy.preset(formatTokenAmount(preset.tokens), preset.label)}
578
+ </Button>
579
+ ))}
580
+ </div>
581
+ </div>
582
+ )}
583
+ </ExchangeStep>
584
+
585
+ <ExchangeStep step={2} title={copy.use.title}>
586
+ {/*
587
+ While the providers load, the SAME selector draws placeholder rows (not its own skeleton,
588
+ whose rows are a different height), so the step is one height loading and loaded.
589
+ */}
590
+ <PushButtonSelector<string>
591
+ options={
592
+ providers.length === 0 && !failed
593
+ ? PROVIDER_PLACEHOLDERS.map(id => ({
594
+ id,
595
+ name: '\u00a0',
596
+ icon: <Skeleton className="size-8 rounded" />,
597
+ disabled: true,
598
+ }))
599
+ : providers.map(providerType => {
600
+ // What the server stated for the provider rides its models' rows.
601
+ const stated =
602
+ models.find(option => option.providerType === providerType) ??
603
+ (rate?.providerType === providerType ? rate : null);
604
+ return {
605
+ id: providerType,
606
+ name: tokenRateProviderLabel(providerType, stated?.providerLabel),
607
+ icon: providerMark(providerType, stated?.providerIcon, 'size-8 text-ods-text-secondary'),
608
+ };
609
+ })
610
+ }
611
+ selectedIds={provider ? [provider] : []}
612
+ onSelectionChange={ids => {
613
+ // A single choice that cannot be cleared: choosing the chosen provider changes nothing.
614
+ if (ids[0]) onProviderChange?.(ids[0]);
615
+ }}
616
+ multiSelect={false}
617
+ />
618
+ <Autocomplete<string>
619
+ label={copy.use.model}
620
+ value={rate?.modelName ?? null}
621
+ onChange={modelName => {
622
+ if (modelName) onPick?.(modelName);
623
+ }}
624
+ options={options}
625
+ inputValue={query}
626
+ onInputChange={value => onQueryChange?.(value)}
627
+ disableClientFilter
628
+ loading={searching}
629
+ disabled={providers.length === 0}
630
+ placeholder={copy.use.search}
631
+ noOptionsText={copy.use.noMatch}
632
+ />
633
+ </ExchangeStep>
634
+
635
+ <ExchangeStep step={3} title={copy.charge.title}>
636
+ <div className="grid grid-cols-2 gap-x-[var(--spacing-system-mf)]">
637
+ <span className="col-span-2 text-ods-text-secondary text-h5">{copy.charge.unit}</span>
638
+ <ExchangeFigure
639
+ size="plain"
640
+ loading={loading}
641
+ figure={formatTokenRate(rate?.inputTokenRate)}
642
+ words={copy.charge.input}
643
+ />
644
+ <ExchangeFigure
645
+ size="plain"
646
+ loading={loading}
647
+ figure={formatTokenRate(rate?.outputTokenRate)}
648
+ words={copy.charge.output}
649
+ />
650
+ </div>
651
+ {/* THE ANSWER: the step 1 amount, read through this rate. The most prominent thing in the step. */}
652
+ <div
653
+ aria-live="polite"
654
+ className="mt-auto flex flex-col rounded-md border border-ods-accent bg-ods-bg p-[var(--spacing-system-mf)]"
655
+ >
656
+ <span className="min-h-[2lh] text-ods-text-secondary text-h4">
657
+ {loading || !rate ? (
658
+ <Bar className="w-full max-w-xs" />
659
+ ) : (
660
+ copy.charge.so(balancePrice ?? copy.buy.tokens(tokens), model)
661
+ )}
662
+ </span>
663
+ {/* The number is the headline, alone on its line: a longer number never pushes its unit to a new line and the box taller. */}
664
+ <span className="min-h-[1lh] whitespace-nowrap tabular-nums text-ods-text-primary text-h1">
665
+ {loading || !rate ? (
666
+ <Bar className="w-40" />
667
+ ) : (
668
+ formatTokenAmount(tokensForBalance(balance, rate.inputTokenRate))
669
+ )}
670
+ </span>
671
+ <span className="min-h-[1lh] text-ods-text-primary text-h3">{copy.charge.read}</span>
672
+ <span className="min-h-[1lh] text-ods-text-secondary text-h4">
673
+ {loading || !rate ? (
674
+ <Bar className="w-48" />
675
+ ) : (
676
+ copy.charge.orWritten(formatTokenAmount(tokensForBalance(balance, rate.outputTokenRate)))
677
+ )}
678
+ </span>
679
+ </div>
680
+ </ExchangeStep>
681
+
682
+ {failed && (
683
+ // The frame keeps its size; what is under it states no figure.
684
+ <div className="absolute inset-0 flex rounded-md border border-ods-border bg-ods-card">
685
+ {status === 'error' ? (
686
+ <ModelTokenRatesUnavailable className="flex-1" />
687
+ ) : (
688
+ <p className="m-auto p-[var(--spacing-system-mf)] text-center text-ods-text-secondary text-h6">
689
+ {MODEL_TOKEN_RATES_COPY.empty}
690
+ </p>
691
+ )}
692
+ </div>
693
+ )}
694
+ </div>
695
+ );
696
+ }