opencode-model-price-intel 0.0.0-stage → 1.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,411 @@
1
+ /**
2
+ * opencode-model-price-intel
3
+ *
4
+ * Makes the `/models` list show each model's input, output and cache prices
5
+ * together with its Artificial Analysis Intelligence Index score.
6
+ *
7
+ * Prices come from the live model registry (`Model.Info.cost`). Intelligence
8
+ * scores are fetched from artificialanalysis.ai and cached in plugin storage,
9
+ * with a periodic refresh while the session is open. Attribution:
10
+ * https://artificialanalysis.ai/
11
+ *
12
+ * The model transform is registered with the bundled snapshot *before* any
13
+ * storage or network work. That makes the first `/models` render immediate;
14
+ * cached or live scores are then adopted in the background and the registry is
15
+ * reloaded only when the score data actually changed.
16
+ *
17
+ * Because the built-in `/models` picker renders `Model.Info.name` as the row
18
+ * title, the pricing/score line is attached to the display name so `/models`
19
+ * lists it per model.
20
+ *
21
+ * Note: an experimental scrolling (marquee) mode for narrow screens existed
22
+ * here and was removed — periodic name repaints made client lists reset their
23
+ * scroll position (both the TUI and the web UI). Names are therefore static.
24
+ *
25
+ * The full table is exposed over RPC (`src/rpc.ts`). The TUI part
26
+ * (`src/tui.tsx`) adds `/model-pricing` and `/prices`, fetches structured rows
27
+ * from this server and renders them in a native dialog table. Keeping the
28
+ * command in the TUI avoids the old behavior where `ctx.session.synthetic`
29
+ * delivered the table to the model as a prompt instead of displaying it.
30
+ *
31
+ * Options (opencode.jsonc `plugins` entry):
32
+ * - decorateNames: boolean attach the line to model names (default true)
33
+ * - costFormat: "detailed" | "compact" cost line format (default detailed)
34
+ * - aaApiKey: string Artificial Analysis Data API key (optional;
35
+ * falls back to ARTIFICIALANALYSIS_API_KEY)
36
+ * - refreshHours: number score cache TTL / refresh interval in hours
37
+ * (default 12, clamped to 1..596)
38
+ */
39
+
40
+ import { Plugin } from "@opencode/plugin"
41
+ import { fetchAAScores, lookupScore, snapshotScores, type AAFetched } from "./aa.ts"
42
+ import { pricingSegment, type CostFormat } from "./pricing.ts"
43
+ import { ModelPricing } from "./rpc.ts"
44
+ import { pricingData, renderTable, type TableModel } from "./table.ts"
45
+
46
+ const CACHE_KEY = "aa-scores/v3"
47
+ const DEFAULT_REFRESH_HOURS = 12
48
+ const MIN_REFRESH_HOURS = 1
49
+ /** 596 h stays below the 2^31-1 ms setTimeout/setInterval ceiling. */
50
+ const MAX_REFRESH_HOURS = 596
51
+ /** Refuse absurdly old cache entries even if refreshHours is configured high. */
52
+ const MAX_CACHE_AGE_MS = 30 * 24 * 60 * 60_000
53
+
54
+ interface ResolvedOptions {
55
+ decorateNames: boolean
56
+ costFormat: CostFormat
57
+ aaApiKey?: string
58
+ refreshMs: number
59
+ }
60
+
61
+ function modelKey(model: {
62
+ readonly providerID: string
63
+ readonly modelID?: string
64
+ readonly id: string
65
+ }): string {
66
+ return `${model.providerID}/${model.modelID || model.id}`
67
+ }
68
+
69
+ function describeValue(value: unknown): string {
70
+ if (typeof value === "string") return JSON.stringify(value)
71
+ if (typeof value === "number" || typeof value === "boolean" || value == null) return String(value)
72
+ return typeof value
73
+ }
74
+
75
+ /**
76
+ * Validate user options once, with safe fallbacks. A bad option must never
77
+ * prevent the plugin from loading.
78
+ */
79
+ function resolveOptions(raw: unknown): ResolvedOptions {
80
+ const options: Record<string, unknown> =
81
+ raw !== null && typeof raw === "object" && !Array.isArray(raw)
82
+ ? (raw as Record<string, unknown>)
83
+ : {}
84
+ const problems: string[] = []
85
+
86
+ let decorateNames = true
87
+ if (options.decorateNames !== undefined) {
88
+ if (typeof options.decorateNames === "boolean") decorateNames = options.decorateNames
89
+ else {
90
+ problems.push(
91
+ `decorateNames must be a boolean (got ${describeValue(options.decorateNames)}); using true`,
92
+ )
93
+ }
94
+ }
95
+
96
+ let costFormat: CostFormat = "detailed"
97
+ if (options.costFormat !== undefined) {
98
+ if (options.costFormat === "detailed" || options.costFormat === "compact") {
99
+ costFormat = options.costFormat
100
+ } else {
101
+ problems.push(
102
+ `costFormat must be "detailed" or "compact" (got ${describeValue(options.costFormat)}); using "detailed"`,
103
+ )
104
+ }
105
+ }
106
+
107
+ let aaApiKey: string | undefined
108
+ if (options.aaApiKey !== undefined) {
109
+ if (typeof options.aaApiKey === "string") aaApiKey = options.aaApiKey || undefined
110
+ else {
111
+ problems.push(
112
+ `aaApiKey must be a string (got ${describeValue(options.aaApiKey)}); using the environment fallback`,
113
+ )
114
+ }
115
+ }
116
+
117
+ let hours = DEFAULT_REFRESH_HOURS
118
+ if (options.refreshHours !== undefined) {
119
+ const requested = options.refreshHours
120
+ if (typeof requested === "number" && Number.isFinite(requested) && requested > 0) {
121
+ hours = Math.min(MAX_REFRESH_HOURS, Math.max(MIN_REFRESH_HOURS, requested))
122
+ if (hours !== requested) {
123
+ problems.push(
124
+ `refreshHours ${requested} is outside ${MIN_REFRESH_HOURS}..${MAX_REFRESH_HOURS}; using ${hours}`,
125
+ )
126
+ }
127
+ } else {
128
+ problems.push(
129
+ `refreshHours must be a finite positive number (got ${describeValue(requested)}); using ${DEFAULT_REFRESH_HOURS}`,
130
+ )
131
+ }
132
+ }
133
+
134
+ if (problems.length > 0) {
135
+ console.warn(`[model-price-intel] invalid options — ${problems.join("; ")}`)
136
+ }
137
+
138
+ return {
139
+ decorateNames,
140
+ costFormat,
141
+ aaApiKey,
142
+ refreshMs: Math.round(hours * 3_600_000),
143
+ }
144
+ }
145
+
146
+ function isScoreEntry(value: unknown): boolean {
147
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
148
+ const entry = value as Record<string, unknown>
149
+ if (!("score" in entry)) return false
150
+ if (
151
+ entry.score !== null &&
152
+ (typeof entry.score !== "number" || !Number.isFinite(entry.score))
153
+ ) {
154
+ return false
155
+ }
156
+ if (typeof entry.name !== "string") return false
157
+ if (entry.note !== undefined && typeof entry.note !== "string") return false
158
+ return true
159
+ }
160
+
161
+ /** Validate a cached `AAFetched` before trusting any part of it. */
162
+ function isAAFetched(value: unknown): value is AAFetched {
163
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
164
+ const cached = value as Record<string, unknown>
165
+ if (
166
+ cached.scores === null ||
167
+ typeof cached.scores !== "object" ||
168
+ Array.isArray(cached.scores) ||
169
+ Object.keys(cached.scores as object).length === 0
170
+ ) {
171
+ return false
172
+ }
173
+ if (typeof cached.version !== "string" || cached.version.length === 0) return false
174
+ if (typeof cached.source !== "string" || cached.source.length === 0) return false
175
+ if (typeof cached.fetchedAt !== "string") return false
176
+ const fetchedAt = Date.parse(cached.fetchedAt)
177
+ if (!Number.isFinite(fetchedAt)) return false
178
+ // A future timestamp can otherwise pin the cache far past refreshHours.
179
+ if (fetchedAt > Date.now()) return false
180
+ if (Date.now() - fetchedAt > MAX_CACHE_AGE_MS) return false
181
+ return Object.values(cached.scores as Record<string, unknown>).every(isScoreEntry)
182
+ }
183
+
184
+ /**
185
+ * Comparison key that ignores `fetchedAt`: adopting a cache/fetch with the
186
+ * same scores, version and source does not need a model-registry reload.
187
+ */
188
+ function dataSignature(value: AAFetched): string {
189
+ const entries = Object.keys(value.scores)
190
+ .sort()
191
+ .map((key) => {
192
+ const entry = value.scores[key]
193
+ return [key, entry.score, entry.name, entry.note ?? ""]
194
+ })
195
+ return JSON.stringify([value.version, value.source, entries])
196
+ }
197
+
198
+ type JsonValue =
199
+ | null
200
+ | boolean
201
+ | number
202
+ | string
203
+ | JsonValue[]
204
+ | { readonly [key: string]: JsonValue }
205
+
206
+ /** Build a plain JSON value for `ctx.storage.set` (AAFetched has no index sig). */
207
+ function cachePayload(value: AAFetched): JsonValue {
208
+ const scores: Record<string, JsonValue> = {}
209
+ for (const [key, entry] of Object.entries(value.scores)) {
210
+ const score: Record<string, JsonValue> = { score: entry.score, name: entry.name }
211
+ if (entry.note !== undefined) score.note = entry.note
212
+ scores[key] = score
213
+ }
214
+ return {
215
+ scores,
216
+ version: value.version,
217
+ source: value.source,
218
+ fetchedAt: value.fetchedAt,
219
+ }
220
+ }
221
+
222
+ /** Remove our own decoration if a transform is replayed against a stale name. */
223
+ function undecoratedName(name: string, price: string): string {
224
+ const suffix = ` · ${price} · AA `
225
+ const index = name.lastIndexOf(suffix)
226
+ if (index < 0) return name
227
+ const tail = name.slice(index + suffix.length)
228
+ if (tail !== "n/a" && !/^-?\d+(?:\.\d+)?$/.test(tail)) return name
229
+ return name.slice(0, index)
230
+ }
231
+
232
+ export default Plugin.define({
233
+ id: "model-price-intel",
234
+ async setup(ctx) {
235
+ const options = resolveOptions(ctx.options)
236
+
237
+ // The snapshot is usable synchronously: register the transform with it
238
+ // before touching storage or the network so the first render is immediate.
239
+ let aa: AAFetched = snapshotScores()
240
+ let stopped = false
241
+ let inFlight = false
242
+ let timer: ReturnType<typeof setInterval> | undefined
243
+ const abortController = new AbortController()
244
+ // Replay-safe base names: never derive the original name by splitting the
245
+ // decorated string, and never append the suffix twice.
246
+ const baseNames = new Map<string, string>()
247
+
248
+ function reloadModels(): void {
249
+ if (!options.decorateNames || stopped) return
250
+ try {
251
+ void Promise.resolve(ctx.model.reload()).catch((error) =>
252
+ console.warn("[model-price-intel] model reload failed:", error),
253
+ )
254
+ } catch (error) {
255
+ console.warn("[model-price-intel] model reload failed:", error)
256
+ }
257
+ }
258
+
259
+ function adopt(next: AAFetched): boolean {
260
+ const changed = dataSignature(next) !== dataSignature(aa)
261
+ aa = next
262
+ return changed
263
+ }
264
+
265
+ async function readCache(): Promise<AAFetched | undefined> {
266
+ let raw: unknown
267
+ try {
268
+ raw = await ctx.storage.get(CACHE_KEY)
269
+ } catch (error) {
270
+ console.warn("[model-price-intel] AA cache read failed; continuing without it:", error)
271
+ return undefined
272
+ }
273
+ if (raw === undefined) return undefined
274
+ if (!isAAFetched(raw)) {
275
+ console.warn("[model-price-intel] ignoring invalid AA cache; using the bundled snapshot")
276
+ return undefined
277
+ }
278
+ return raw
279
+ }
280
+
281
+ function makeAbortableFetch(): typeof fetch {
282
+ return (input, init) => {
283
+ const signal = init?.signal
284
+ ? AbortSignal.any([init.signal, abortController.signal])
285
+ : abortController.signal
286
+ return fetch(input, { ...init, signal })
287
+ }
288
+ }
289
+
290
+ async function refresh(force: boolean): Promise<void> {
291
+ if (stopped || inFlight) return
292
+ inFlight = true
293
+ try {
294
+ const cached = await readCache()
295
+ if (stopped) return
296
+ if (cached) {
297
+ const age = Date.now() - Date.parse(cached.fetchedAt)
298
+ if (age >= 0 && age < options.refreshMs) {
299
+ if (adopt(cached)) reloadModels()
300
+ if (!force) return
301
+ } else if (adopt(cached)) {
302
+ // Stale cache still beats the bundled snapshot while we refetch,
303
+ // but it is not authoritative: always try the network below.
304
+ reloadModels()
305
+ }
306
+ }
307
+
308
+ let live: AAFetched
309
+ try {
310
+ live = await fetchAAScores({ apiKey: options.aaApiKey, fetchImpl: makeAbortableFetch() })
311
+ } catch (error) {
312
+ if (!stopped) {
313
+ console.error("[model-price-intel] live Artificial Analysis fetch failed:", error)
314
+ }
315
+ return
316
+ }
317
+ if (stopped) return
318
+
319
+ const changed = adopt(live)
320
+ try {
321
+ await ctx.storage.set(CACHE_KEY, cachePayload(live))
322
+ } catch (error) {
323
+ // A cache write failure is not a fetch failure: keep live data.
324
+ console.warn("[model-price-intel] AA cache write failed; live data kept:", error)
325
+ }
326
+ if (stopped) return
327
+ console.log(
328
+ `[model-price-intel] loaded AA Intelligence Index v${live.version} from ${live.source}`,
329
+ )
330
+ if (changed) reloadModels()
331
+ } finally {
332
+ inFlight = false
333
+ }
334
+ }
335
+
336
+ if (options.decorateNames) {
337
+ await ctx.model.transform((editor) => {
338
+ try {
339
+ for (const model of editor.list()) {
340
+ try {
341
+ const key = modelKey(model)
342
+ const price = pricingSegment(model.cost, options.costFormat)
343
+ let base = baseNames.get(key)
344
+ if (base === undefined) {
345
+ base = undecoratedName(model.name, price)
346
+ baseNames.set(key, base)
347
+ }
348
+ const score = lookupScore(aa.scores, model.id, base)
349
+ const aaSegment =
350
+ score?.score != null ? `AA ${Math.round(score.score * 10) / 10}` : "AA n/a"
351
+ const decorated = `${base} · ${price} · ${aaSegment}`
352
+ if (model.name !== decorated) {
353
+ try {
354
+ editor.update(model.providerID, model.id, (draft) => {
355
+ draft.name = decorated
356
+ })
357
+ } catch (error) {
358
+ console.warn(
359
+ `[model-price-intel] editor.update failed for ${key}; mutating the draft:`,
360
+ error,
361
+ )
362
+ model.name = decorated
363
+ }
364
+ }
365
+ } catch (error) {
366
+ // One bad registry entry must never fail the whole transform.
367
+ console.warn("[model-price-intel] could not decorate a model:", error)
368
+ }
369
+ }
370
+ } catch (error) {
371
+ console.warn("[model-price-intel] model transform failed:", error)
372
+ }
373
+ })
374
+ }
375
+
376
+ // The TUI part calls this RPC to render the full table in a dialog. The
377
+ // server builds the rows because only it owns the live score cache.
378
+ await ctx.rpc.register(ModelPricing, {
379
+ table: async () => {
380
+ let models: readonly TableModel[] = []
381
+ try {
382
+ const { data } = await ctx.model.list()
383
+ models = Array.isArray(data) ? data : []
384
+ } catch (error) {
385
+ console.warn("[model-price-intel] could not list models for the pricing table:", error)
386
+ }
387
+ const data = pricingData(models, aa, baseNames, options.decorateNames)
388
+ return { ...data, text: renderTable(data) }
389
+ },
390
+ })
391
+
392
+ // Hydrate the cache and refresh live scores in the background. setup must
393
+ // not wait for storage or the network; the snapshot transform is already
394
+ // registered. Reload the registry only when the data actually changed.
395
+ void refresh(false).catch((error) => {
396
+ if (!stopped) console.error("[model-price-intel] background refresh failed:", error)
397
+ })
398
+
399
+ timer = setInterval(() => {
400
+ void refresh(true).catch((error) => {
401
+ if (!stopped) console.error("[model-price-intel] refresh failed:", error)
402
+ })
403
+ }, options.refreshMs)
404
+
405
+ return () => {
406
+ stopped = true
407
+ if (timer !== undefined) clearInterval(timer)
408
+ abortController.abort()
409
+ }
410
+ },
411
+ })
package/src/pricing.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Formatting helpers for model pricing.
3
+ *
4
+ * Prices come from the LIVE model registry (`Model.Info.cost`), which OpenCode
5
+ * refreshes from provider catalogs — not from a bundled table. This module only
6
+ * formats and packages those values.
7
+ *
8
+ * `Model.Cost` shape (OpenCode schema):
9
+ * {
10
+ * tier?: { type: "context", size: number } // applies ABOVE `size` context tokens
11
+ * input: number // USD per 1M tokens
12
+ * output: number
13
+ * cache: { read: number, write: number }
14
+ * }
15
+ * `Model.Info.cost` is an ARRAY of such entries: an untiered base price
16
+ * followed by zero or more context overrides. OpenCode selects the matching
17
+ * override when context tokens exceed `tier.size`.
18
+ */
19
+
20
+ export interface CostTier {
21
+ tier?: { type: string; size: number }
22
+ input: number
23
+ output: number
24
+ cache: { read: number; write: number }
25
+ }
26
+
27
+ export interface FormattedPrice {
28
+ input: string
29
+ output: string
30
+ cacheRead: string
31
+ cacheWrite: string
32
+ /** e.g. ">32K" when the entry is a context-tier override. */
33
+ tierLabel?: string
34
+ /** True only when input, output, cache read and cache write are all 0. */
35
+ free: boolean
36
+ }
37
+
38
+ /** Expand exponent notation so prices never render as `1e+21`. */
39
+ function plainDecimal(value: number): string {
40
+ const text = String(value)
41
+ const match = text.match(/^(-?)(\d+)(?:\.(\d+))?e([+-]?\d+)$/i)
42
+ if (!match) return text
43
+ const [, sign, whole, fraction = "", rawExponent] = match
44
+ const exponent = Number(rawExponent)
45
+ const digits = whole + fraction
46
+ if (exponent >= 0) {
47
+ const zeros = exponent - fraction.length
48
+ if (zeros >= 0) return `${sign}${digits}${"0".repeat(zeros)}`
49
+ const split = digits.length + zeros
50
+ return `${sign}${digits.slice(0, split)}.${digits.slice(split)}`
51
+ }
52
+ const zeros = -exponent - whole.length
53
+ if (zeros >= 0) return `${sign}0.${"0".repeat(zeros)}${digits}`
54
+ const split = whole.length + exponent
55
+ return `${sign}${whole.slice(0, split)}.${whole.slice(split)}${fraction}`
56
+ }
57
+
58
+ /**
59
+ * Format a USD-per-1M-token price, preserving published precision:
60
+ * 10 -> "$10", 0.006 -> "$0.006", 0.003625 -> "$0.003625".
61
+ * Prices below $0.00005 render explicitly as "<$0.00005"; negative or
62
+ * non-finite values render "?".
63
+ */
64
+ export function usd(value: number): string {
65
+ if (!Number.isFinite(value) || value < 0) return "?"
66
+ if (value === 0) return "$0"
67
+ if (value < 0.00005) return "<$0.00005"
68
+ // Registry prices are exact JSON decimals; this only removes floating-point
69
+ // noise from arithmetic while keeping every published digit.
70
+ const normalized = value < 1 ? Math.round(value * 1e12) / 1e12 : value
71
+ let text = plainDecimal(normalized)
72
+ if (text.includes(".")) text = text.replace(/0+$/, "").replace(/\.$/, "")
73
+ return `$${text}`
74
+ }
75
+
76
+ function contextBound(size: number): string {
77
+ if (Number.isFinite(size) && size >= 1_000 && size % 1_000 === 0) {
78
+ return `${size / 1_000}K`
79
+ }
80
+ return String(size)
81
+ }
82
+
83
+ function tierLabelFor(tier: CostTier): string | undefined {
84
+ // An entry with tier.type === "context" applies ABOVE tier.size.
85
+ if (tier.tier?.type !== "context") return undefined
86
+ return `>${contextBound(tier.tier.size)}`
87
+ }
88
+
89
+ function isFree(tier: CostTier): boolean {
90
+ return (
91
+ tier.input === 0 &&
92
+ tier.output === 0 &&
93
+ (tier.cache?.read ?? 0) === 0 &&
94
+ (tier.cache?.write ?? 0) === 0
95
+ )
96
+ }
97
+
98
+ export function formatTier(tier: CostTier, _index = 0, _all: readonly CostTier[] = [tier]): FormattedPrice {
99
+ return {
100
+ input: usd(tier.input),
101
+ output: usd(tier.output),
102
+ cacheRead: usd(tier.cache?.read ?? 0),
103
+ cacheWrite: usd(tier.cache?.write ?? 0),
104
+ tierLabel: tierLabelFor(tier),
105
+ free: isFree(tier),
106
+ }
107
+ }
108
+
109
+ export function formatCost(cost: readonly CostTier[] | undefined): FormattedPrice[] {
110
+ return (cost ?? []).map((tier, index, all) => formatTier(tier, index, all))
111
+ }
112
+
113
+ /** How the per-model cost line is rendered in `/models`. */
114
+ export type CostFormat = "detailed" | "compact"
115
+
116
+ /**
117
+ * Compact per-model line used to decorate the model's display name so the
118
+ * built-in `/models` picker lists input, output and cache prices plus the
119
+ * Artificial Analysis intelligence score:
120
+ *
121
+ * detailed: "GPT 6 Astra · in $10 out $50 · cache read $1 write $12.5 · AA 56.9"
122
+ * compact: "GPT 6 Astra · $10/$50/$1/$12.5 · AA 56.9"
123
+ * free: "Big Pickle · free · AA n/a"
124
+ *
125
+ * The compact form (<in>/<out>/<read>/<write>) keeps rows readable on narrow
126
+ * screens such as mobile web sessions. Multi-tier models show their base tier
127
+ * and an `(N tiers)` marker.
128
+ */
129
+ export function pricingSegment(
130
+ cost: readonly CostTier[] | undefined,
131
+ format: CostFormat = "detailed",
132
+ ): string {
133
+ const tiers = formatCost(cost)
134
+ const base = tiers[0]
135
+ if (!base) return format === "compact" ? "n/a" : "pricing n/a"
136
+ if (base.free && tiers.every((tier) => tier.free)) return "free"
137
+ const tierSuffix = tiers.length > 1 ? ` (${tiers.length} tiers)` : ""
138
+ if (format === "compact") {
139
+ return `${base.input}/${base.output}/${base.cacheRead}/${base.cacheWrite}${tierSuffix}`
140
+ }
141
+ return `in ${base.input} out ${base.output} · cache read ${base.cacheRead} write ${base.cacheWrite}${tierSuffix}`
142
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,65 @@
1
+ /** RPC contract shared by the server plugin and the TUI part. */
2
+
3
+ import { Rpc } from "@opencode/plugin/rpc"
4
+
5
+ /** One aligned table row; continuation tiers leave model/provider/aa blank. */
6
+ export interface PricingRow {
7
+ readonly model: string
8
+ readonly provider: string
9
+ readonly tier: string
10
+ readonly input: string
11
+ readonly output: string
12
+ readonly cache: string
13
+ readonly aa: string
14
+ }
15
+
16
+ export const ModelPricing = Rpc.define({
17
+ id: "model-price-intel",
18
+ methods: {
19
+ table: {
20
+ input: {
21
+ type: "object",
22
+ properties: {},
23
+ additionalProperties: false,
24
+ },
25
+ output: {
26
+ type: "object",
27
+ properties: {
28
+ /** Pre-rendered fallback table (also useful to copy for support). */
29
+ text: { type: "string" },
30
+ title: { type: "string" },
31
+ meta: { type: "string" },
32
+ footnote: { type: "string" },
33
+ rows: {
34
+ type: "array",
35
+ items: {
36
+ type: "object",
37
+ properties: {
38
+ model: { type: "string" },
39
+ provider: { type: "string" },
40
+ tier: { type: "string" },
41
+ input: { type: "string" },
42
+ output: { type: "string" },
43
+ cache: { type: "string" },
44
+ aa: { type: "string" },
45
+ },
46
+ required: ["model", "provider", "tier", "input", "output", "cache", "aa"],
47
+ additionalProperties: false,
48
+ },
49
+ },
50
+ },
51
+ required: ["text", "title", "meta", "footnote", "rows"],
52
+ additionalProperties: false,
53
+ },
54
+ },
55
+ },
56
+ events: {},
57
+ })
58
+
59
+ export interface PricingTable {
60
+ readonly text: string
61
+ readonly title: string
62
+ readonly meta: string
63
+ readonly footnote: string
64
+ readonly rows: readonly PricingRow[]
65
+ }