opencode-model-price-intel 0.0.0-stage → 1.0.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,480 @@
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. `/model-pricing` (alias `/prices`) prints the complete
20
+ * table — every price tier, cache read/write, and the AA score — into the
21
+ * session.
22
+ *
23
+ * Note: an experimental scrolling (marquee) mode for narrow screens existed
24
+ * here and was removed — periodic name repaints made client lists reset their
25
+ * scroll position (both the TUI and the web UI). Names are therefore static.
26
+ *
27
+ * Options (opencode.jsonc `plugins` entry):
28
+ * - decorateNames: boolean attach the line to model names (default true)
29
+ * - costFormat: "detailed" | "compact" cost line format (default detailed)
30
+ * - aaApiKey: string Artificial Analysis Data API key (optional;
31
+ * falls back to ARTIFICIALANALYSIS_API_KEY)
32
+ * - refreshHours: number score cache TTL / refresh interval in hours
33
+ * (default 12, clamped to 1..596)
34
+ */
35
+
36
+ import { Plugin } from "@opencode/plugin"
37
+ import type { CommandInvocation } from "@opencode/plugin/promise/command"
38
+ import { fetchAAScores, lookupScore, snapshotScores, type AAFetched } from "./aa.ts"
39
+ import { formatCost, pricingSegment, type CostFormat, type CostTier } from "./pricing.ts"
40
+
41
+ const CACHE_KEY = "aa-scores/v3"
42
+ const DEFAULT_REFRESH_HOURS = 12
43
+ const MIN_REFRESH_HOURS = 1
44
+ /** 596 h stays below the 2^31-1 ms setTimeout/setInterval ceiling. */
45
+ const MAX_REFRESH_HOURS = 596
46
+ /** Refuse absurdly old cache entries even if refreshHours is configured high. */
47
+ const MAX_CACHE_AGE_MS = 30 * 24 * 60 * 60_000
48
+
49
+ interface ResolvedOptions {
50
+ decorateNames: boolean
51
+ costFormat: CostFormat
52
+ aaApiKey?: string
53
+ refreshMs: number
54
+ }
55
+
56
+ interface TableModel {
57
+ readonly id: string
58
+ readonly modelID?: string
59
+ readonly providerID: string
60
+ readonly name: string
61
+ readonly cost: readonly CostTier[] | undefined
62
+ }
63
+
64
+ function modelKey(model: TableModel): string {
65
+ return `${model.providerID}/${model.modelID || model.id}`
66
+ }
67
+
68
+ function describeValue(value: unknown): string {
69
+ if (typeof value === "string") return JSON.stringify(value)
70
+ if (typeof value === "number" || typeof value === "boolean" || value == null) return String(value)
71
+ return typeof value
72
+ }
73
+
74
+ /**
75
+ * Validate user options once, with safe fallbacks. A bad option must never
76
+ * prevent the plugin from loading.
77
+ */
78
+ function resolveOptions(raw: unknown): ResolvedOptions {
79
+ const options: Record<string, unknown> =
80
+ raw !== null && typeof raw === "object" && !Array.isArray(raw)
81
+ ? (raw as Record<string, unknown>)
82
+ : {}
83
+ const problems: string[] = []
84
+
85
+ let decorateNames = true
86
+ if (options.decorateNames !== undefined) {
87
+ if (typeof options.decorateNames === "boolean") decorateNames = options.decorateNames
88
+ else {
89
+ problems.push(
90
+ `decorateNames must be a boolean (got ${describeValue(options.decorateNames)}); using true`,
91
+ )
92
+ }
93
+ }
94
+
95
+ let costFormat: CostFormat = "detailed"
96
+ if (options.costFormat !== undefined) {
97
+ if (options.costFormat === "detailed" || options.costFormat === "compact") {
98
+ costFormat = options.costFormat
99
+ } else {
100
+ problems.push(
101
+ `costFormat must be "detailed" or "compact" (got ${describeValue(options.costFormat)}); using "detailed"`,
102
+ )
103
+ }
104
+ }
105
+
106
+ let aaApiKey: string | undefined
107
+ if (options.aaApiKey !== undefined) {
108
+ if (typeof options.aaApiKey === "string") aaApiKey = options.aaApiKey || undefined
109
+ else {
110
+ problems.push(
111
+ `aaApiKey must be a string (got ${describeValue(options.aaApiKey)}); using the environment fallback`,
112
+ )
113
+ }
114
+ }
115
+
116
+ let hours = DEFAULT_REFRESH_HOURS
117
+ if (options.refreshHours !== undefined) {
118
+ const requested = options.refreshHours
119
+ if (typeof requested === "number" && Number.isFinite(requested) && requested > 0) {
120
+ hours = Math.min(MAX_REFRESH_HOURS, Math.max(MIN_REFRESH_HOURS, requested))
121
+ if (hours !== requested) {
122
+ problems.push(
123
+ `refreshHours ${requested} is outside ${MIN_REFRESH_HOURS}..${MAX_REFRESH_HOURS}; using ${hours}`,
124
+ )
125
+ }
126
+ } else {
127
+ problems.push(
128
+ `refreshHours must be a finite positive number (got ${describeValue(requested)}); using ${DEFAULT_REFRESH_HOURS}`,
129
+ )
130
+ }
131
+ }
132
+
133
+ if (problems.length > 0) {
134
+ console.warn(`[model-price-intel] invalid options — ${problems.join("; ")}`)
135
+ }
136
+
137
+ return {
138
+ decorateNames,
139
+ costFormat,
140
+ aaApiKey,
141
+ refreshMs: Math.round(hours * 3_600_000),
142
+ }
143
+ }
144
+
145
+ function isScoreEntry(value: unknown): boolean {
146
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
147
+ const entry = value as Record<string, unknown>
148
+ if (!("score" in entry)) return false
149
+ if (
150
+ entry.score !== null &&
151
+ (typeof entry.score !== "number" || !Number.isFinite(entry.score))
152
+ ) {
153
+ return false
154
+ }
155
+ if (typeof entry.name !== "string") return false
156
+ if (entry.note !== undefined && typeof entry.note !== "string") return false
157
+ return true
158
+ }
159
+
160
+ /** Validate a cached `AAFetched` before trusting any part of it. */
161
+ function isAAFetched(value: unknown): value is AAFetched {
162
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
163
+ const cached = value as Record<string, unknown>
164
+ if (
165
+ cached.scores === null ||
166
+ typeof cached.scores !== "object" ||
167
+ Array.isArray(cached.scores) ||
168
+ Object.keys(cached.scores as object).length === 0
169
+ ) {
170
+ return false
171
+ }
172
+ if (typeof cached.version !== "string" || cached.version.length === 0) return false
173
+ if (typeof cached.source !== "string" || cached.source.length === 0) return false
174
+ if (typeof cached.fetchedAt !== "string") return false
175
+ const fetchedAt = Date.parse(cached.fetchedAt)
176
+ if (!Number.isFinite(fetchedAt)) return false
177
+ // A future timestamp can otherwise pin the cache far past refreshHours.
178
+ if (fetchedAt > Date.now()) return false
179
+ if (Date.now() - fetchedAt > MAX_CACHE_AGE_MS) return false
180
+ return Object.values(cached.scores as Record<string, unknown>).every(isScoreEntry)
181
+ }
182
+
183
+ /**
184
+ * Comparison key that ignores `fetchedAt`: adopting a cache/fetch with the
185
+ * same scores, version and source does not need a model-registry reload.
186
+ */
187
+ function dataSignature(value: AAFetched): string {
188
+ const entries = Object.keys(value.scores)
189
+ .sort()
190
+ .map((key) => {
191
+ const entry = value.scores[key]
192
+ return [key, entry.score, entry.name, entry.note ?? ""]
193
+ })
194
+ return JSON.stringify([value.version, value.source, entries])
195
+ }
196
+
197
+ type JsonValue =
198
+ | null
199
+ | boolean
200
+ | number
201
+ | string
202
+ | JsonValue[]
203
+ | { readonly [key: string]: JsonValue }
204
+
205
+ /** Build a plain JSON value for `ctx.storage.set` (AAFetched has no index sig). */
206
+ function cachePayload(value: AAFetched): JsonValue {
207
+ const scores: Record<string, JsonValue> = {}
208
+ for (const [key, entry] of Object.entries(value.scores)) {
209
+ const score: Record<string, JsonValue> = { score: entry.score, name: entry.name }
210
+ if (entry.note !== undefined) score.note = entry.note
211
+ scores[key] = score
212
+ }
213
+ return {
214
+ scores,
215
+ version: value.version,
216
+ source: value.source,
217
+ fetchedAt: value.fetchedAt,
218
+ }
219
+ }
220
+
221
+ function markdownCell(value: unknown): string {
222
+ return String(value ?? "")
223
+ .replace(/\\/g, "\\\\")
224
+ .replace(/\|/g, "\\|")
225
+ .replace(/\r\n|\r|\n/g, " ")
226
+ }
227
+
228
+ function buildTable(
229
+ models: readonly TableModel[],
230
+ aa: AAFetched,
231
+ baseNames: ReadonlyMap<string, string>,
232
+ decorateNames: boolean,
233
+ ): string {
234
+ const lines: string[] = [
235
+ "### Model pricing & Artificial Analysis Intelligence Index",
236
+ "",
237
+ `Prices per 1M tokens from the live OpenCode model registry. Scores: Artificial Analysis Intelligence Index v${markdownCell(aa.version)}, source ${markdownCell(aa.source)}, fetched ${markdownCell(aa.fetchedAt.slice(0, 10))}. Attribution: https://artificialanalysis.ai/`,
238
+ "",
239
+ "| Model | Provider | Tier | Input | Output | Cache read | Cache write | AA Intelligence |",
240
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
241
+ ]
242
+
243
+ for (const model of models) {
244
+ const baseName = decorateNames
245
+ ? (baseNames.get(modelKey(model)) ?? model.name)
246
+ : model.name
247
+ const score = lookupScore(aa.scores, model.id, baseName)
248
+ const aaCell =
249
+ score?.score != null
250
+ ? `${Math.round(score.score * 10) / 10}${score.note ? ` (${score.note})` : ""}`
251
+ : "n/a"
252
+
253
+ let tiers: ReturnType<typeof formatCost> = []
254
+ try {
255
+ tiers = formatCost(model.cost)
256
+ } catch (error) {
257
+ console.warn(`[model-price-intel] could not format cost for ${modelKey(model)}:`, error)
258
+ }
259
+ const rows = tiers.length > 0 ? tiers : [undefined]
260
+ rows.forEach((tier, index) => {
261
+ const cells = [
262
+ index === 0 ? baseName : "",
263
+ index === 0 ? model.providerID : "",
264
+ tier ? (tier.tierLabel ?? (index === 0 ? "base" : "")) : "base",
265
+ tier ? tier.input : "n/a",
266
+ tier ? tier.output : "n/a",
267
+ tier ? tier.cacheRead : "n/a",
268
+ tier ? tier.cacheWrite : "n/a",
269
+ index === 0 ? aaCell : "",
270
+ ]
271
+ lines.push(`| ${cells.map(markdownCell).join(" | ")} |`)
272
+ })
273
+ }
274
+
275
+ return lines.join("\n")
276
+ }
277
+
278
+ /** Remove our own decoration if a transform is replayed against a stale name. */
279
+ function undecoratedName(name: string, price: string): string {
280
+ const suffix = ` · ${price} · AA `
281
+ const index = name.lastIndexOf(suffix)
282
+ if (index < 0) return name
283
+ const tail = name.slice(index + suffix.length)
284
+ if (tail !== "n/a" && !/^-?\d+(?:\.\d+)?$/.test(tail)) return name
285
+ return name.slice(0, index)
286
+ }
287
+
288
+ export default Plugin.define({
289
+ id: "model-price-intel",
290
+ async setup(ctx) {
291
+ const options = resolveOptions(ctx.options)
292
+
293
+ // The snapshot is usable synchronously: register the transform with it
294
+ // before touching storage or the network so the first render is immediate.
295
+ let aa: AAFetched = snapshotScores()
296
+ let stopped = false
297
+ let inFlight = false
298
+ let timer: ReturnType<typeof setInterval> | undefined
299
+ const abortController = new AbortController()
300
+ // Replay-safe base names: never derive the original name by splitting the
301
+ // decorated string, and never append the suffix twice.
302
+ const baseNames = new Map<string, string>()
303
+
304
+ function reloadModels(): void {
305
+ if (!options.decorateNames || stopped) return
306
+ try {
307
+ void Promise.resolve(ctx.model.reload()).catch((error) =>
308
+ console.warn("[model-price-intel] model reload failed:", error),
309
+ )
310
+ } catch (error) {
311
+ console.warn("[model-price-intel] model reload failed:", error)
312
+ }
313
+ }
314
+
315
+ function adopt(next: AAFetched): boolean {
316
+ const changed = dataSignature(next) !== dataSignature(aa)
317
+ aa = next
318
+ return changed
319
+ }
320
+
321
+ async function readCache(): Promise<AAFetched | undefined> {
322
+ let raw: unknown
323
+ try {
324
+ raw = await ctx.storage.get(CACHE_KEY)
325
+ } catch (error) {
326
+ console.warn("[model-price-intel] AA cache read failed; continuing without it:", error)
327
+ return undefined
328
+ }
329
+ if (raw === undefined) return undefined
330
+ if (!isAAFetched(raw)) {
331
+ console.warn("[model-price-intel] ignoring invalid AA cache; using the bundled snapshot")
332
+ return undefined
333
+ }
334
+ return raw
335
+ }
336
+
337
+ function makeAbortableFetch(): typeof fetch {
338
+ return (input, init) => {
339
+ const signal = init?.signal
340
+ ? AbortSignal.any([init.signal, abortController.signal])
341
+ : abortController.signal
342
+ return fetch(input, { ...init, signal })
343
+ }
344
+ }
345
+
346
+ async function refresh(force: boolean): Promise<void> {
347
+ if (stopped || inFlight) return
348
+ inFlight = true
349
+ try {
350
+ const cached = await readCache()
351
+ if (stopped) return
352
+ if (cached) {
353
+ const age = Date.now() - Date.parse(cached.fetchedAt)
354
+ if (age >= 0 && age < options.refreshMs) {
355
+ if (adopt(cached)) reloadModels()
356
+ if (!force) return
357
+ } else if (adopt(cached)) {
358
+ // Stale cache still beats the bundled snapshot while we refetch,
359
+ // but it is not authoritative: always try the network below.
360
+ reloadModels()
361
+ }
362
+ }
363
+
364
+ let live: AAFetched
365
+ try {
366
+ live = await fetchAAScores({ apiKey: options.aaApiKey, fetchImpl: makeAbortableFetch() })
367
+ } catch (error) {
368
+ if (!stopped) {
369
+ console.error("[model-price-intel] live Artificial Analysis fetch failed:", error)
370
+ }
371
+ return
372
+ }
373
+ if (stopped) return
374
+
375
+ const changed = adopt(live)
376
+ try {
377
+ await ctx.storage.set(CACHE_KEY, cachePayload(live))
378
+ } catch (error) {
379
+ // A cache write failure is not a fetch failure: keep live data.
380
+ console.warn("[model-price-intel] AA cache write failed; live data kept:", error)
381
+ }
382
+ if (stopped) return
383
+ console.log(
384
+ `[model-price-intel] loaded AA Intelligence Index v${live.version} from ${live.source}`,
385
+ )
386
+ if (changed) reloadModels()
387
+ } finally {
388
+ inFlight = false
389
+ }
390
+ }
391
+
392
+ if (options.decorateNames) {
393
+ await ctx.model.transform((editor) => {
394
+ try {
395
+ for (const model of editor.list()) {
396
+ try {
397
+ const key = modelKey(model)
398
+ const price = pricingSegment(model.cost, options.costFormat)
399
+ let base = baseNames.get(key)
400
+ if (base === undefined) {
401
+ base = undecoratedName(model.name, price)
402
+ baseNames.set(key, base)
403
+ }
404
+ const score = lookupScore(aa.scores, model.id, base)
405
+ const aaSegment =
406
+ score?.score != null ? `AA ${Math.round(score.score * 10) / 10}` : "AA n/a"
407
+ const decorated = `${base} · ${price} · ${aaSegment}`
408
+ if (model.name !== decorated) {
409
+ try {
410
+ editor.update(model.providerID, model.id, (draft) => {
411
+ draft.name = decorated
412
+ })
413
+ } catch (error) {
414
+ console.warn(
415
+ `[model-price-intel] editor.update failed for ${key}; mutating the draft:`,
416
+ error,
417
+ )
418
+ model.name = decorated
419
+ }
420
+ }
421
+ } catch (error) {
422
+ // One bad registry entry must never fail the whole transform.
423
+ console.warn("[model-price-intel] could not decorate a model:", error)
424
+ }
425
+ }
426
+ } catch (error) {
427
+ console.warn("[model-price-intel] model transform failed:", error)
428
+ }
429
+ })
430
+ }
431
+
432
+ // Full table as a synthetic session message (no model call needed). The
433
+ // command is registered even when name decoration is disabled.
434
+ await ctx.command.transform((editor) => {
435
+ const execute = async (input: CommandInvocation): Promise<void> => {
436
+ let models: readonly TableModel[] = []
437
+ try {
438
+ const { data } = await ctx.model.list()
439
+ models = Array.isArray(data) ? data : []
440
+ } catch (error) {
441
+ console.warn("[model-price-intel] could not list models for the pricing table:", error)
442
+ }
443
+ await ctx.session.synthetic({
444
+ sessionID: input.sessionID,
445
+ text: buildTable(models, aa, baseNames, options.decorateNames),
446
+ })
447
+ }
448
+ editor.add({
449
+ name: "model-pricing",
450
+ description:
451
+ "List input/output/cache prices and Artificial Analysis intelligence scores for every model",
452
+ execute,
453
+ })
454
+ editor.add({
455
+ name: "prices",
456
+ description: "Alias of /model-pricing",
457
+ execute,
458
+ })
459
+ })
460
+
461
+ // Hydrate the cache and refresh live scores in the background. setup must
462
+ // not wait for storage or the network; the snapshot transform is already
463
+ // registered. Reload the registry only when the data actually changed.
464
+ void refresh(false).catch((error) => {
465
+ if (!stopped) console.error("[model-price-intel] background refresh failed:", error)
466
+ })
467
+
468
+ timer = setInterval(() => {
469
+ void refresh(true).catch((error) => {
470
+ if (!stopped) console.error("[model-price-intel] refresh failed:", error)
471
+ })
472
+ }, options.refreshMs)
473
+
474
+ return () => {
475
+ stopped = true
476
+ if (timer !== undefined) clearInterval(timer)
477
+ abortController.abort()
478
+ }
479
+ },
480
+ })
package/src/pricing.ts ADDED
@@ -0,0 +1,143 @@
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; the `/model-pricing` command lists every tier
128
+ * with its `>N` context threshold.
129
+ */
130
+ export function pricingSegment(
131
+ cost: readonly CostTier[] | undefined,
132
+ format: CostFormat = "detailed",
133
+ ): string {
134
+ const tiers = formatCost(cost)
135
+ const base = tiers[0]
136
+ if (!base) return format === "compact" ? "n/a" : "pricing n/a"
137
+ if (base.free && tiers.every((tier) => tier.free)) return "free"
138
+ const tierSuffix = tiers.length > 1 ? ` (${tiers.length} tiers)` : ""
139
+ if (format === "compact") {
140
+ return `${base.input}/${base.output}/${base.cacheRead}/${base.cacheWrite}${tierSuffix}`
141
+ }
142
+ return `in ${base.input} out ${base.output} · cache read ${base.cacheRead} write ${base.cacheWrite}${tierSuffix}`
143
+ }