opencode-model-price-intel 1.0.0 → 1.2.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/table.ts ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Pricing table data and rendering shared by the server RPC and the TUI
3
+ * dialog. The server owns the live registry and AA cache; the TUI only lays
4
+ * the returned rows out for the current terminal width.
5
+ */
6
+
7
+ import { lookupScore, type AAFetched } from "./aa.ts"
8
+ import { formatCost, type CostTier } from "./pricing.ts"
9
+ import type { PricingRow } from "./rpc.ts"
10
+
11
+ export interface TableModel {
12
+ readonly id: string
13
+ readonly modelID?: string
14
+ readonly providerID: string
15
+ readonly name: string
16
+ readonly cost: readonly CostTier[] | undefined
17
+ }
18
+
19
+ /** Everything the TUI needs to draw the table, minus the pre-rendered text. */
20
+ export interface PricingDataset {
21
+ readonly title: string
22
+ readonly meta: string
23
+ readonly footnote: string
24
+ readonly rows: readonly PricingRow[]
25
+ }
26
+
27
+ export type SortDirection = "asc" | "desc"
28
+
29
+ /** The active sort column and direction. Model groups sort together. */
30
+ export interface SortState {
31
+ readonly key: keyof PricingRow
32
+ readonly direction: SortDirection
33
+ }
34
+
35
+ /** One header cell, including the gap that follows it. */
36
+ export interface PricingTableColumn {
37
+ readonly key: keyof PricingRow
38
+ /** Header text plus the direction indicator when this column is sorted. */
39
+ readonly label: string
40
+ /** Cell width, not counting the inter-column gap. */
41
+ readonly width: number
42
+ readonly align: "left" | "right"
43
+ /** Padded header cell followed by `COLUMN_GAP` (empty on the last cell). */
44
+ readonly header: string
45
+ /** Set only for the active sort column. */
46
+ readonly direction?: SortDirection
47
+ }
48
+
49
+ export interface PricingTableLayout {
50
+ /** Header row, already padded to the chosen column widths. */
51
+ readonly header: string
52
+ /** A rule under the header, one character per header cell. */
53
+ readonly separator: string
54
+ /** One aligned line per pricing row. */
55
+ readonly lines: readonly string[]
56
+ /** Per-column layout, used by the TUI to make headers clickable. */
57
+ readonly columns: readonly PricingTableColumn[]
58
+ }
59
+
60
+ /** Fallback width used by the RPC `text` field and `dialog.alert`. */
61
+ export const DEFAULT_TABLE_WIDTH = 108
62
+
63
+ const TITLE = "Model pricing & Artificial Analysis Intelligence Index"
64
+ const FOOTNOTE =
65
+ "AA: * bundled snapshot; ~ other match note (variant or entry name). Attribution: https://artificialanalysis.ai/"
66
+ const COLUMN_GAP = " "
67
+
68
+ interface Column {
69
+ readonly key: keyof PricingRow
70
+ readonly header: string
71
+ readonly align: "left" | "right"
72
+ readonly min: number
73
+ readonly max: number
74
+ }
75
+
76
+ const COLUMNS: readonly Column[] = [
77
+ { key: "model", header: "MODEL", align: "left", min: 10, max: 56 },
78
+ { key: "provider", header: "PROVIDER", align: "left", min: 6, max: 14 },
79
+ { key: "tier", header: "TIER", align: "left", min: 4, max: 9 },
80
+ { key: "input", header: "IN $", align: "right", min: 3, max: 12 },
81
+ { key: "output", header: "OUT $", align: "right", min: 3, max: 12 },
82
+ { key: "cache", header: "CACHE R/W $", align: "right", min: 7, max: 18 },
83
+ { key: "aa", header: "AA", align: "right", min: 2, max: 6 },
84
+ ]
85
+
86
+ function modelKey(model: TableModel): string {
87
+ return `${model.providerID}/${model.modelID || model.id}`
88
+ }
89
+
90
+ /** Keep one physical line per cell. */
91
+ function oneLine(value: unknown): string {
92
+ return String(value ?? "")
93
+ .replace(/\r\n|\r|\n/g, " ")
94
+ .replace(/\s+/g, " ")
95
+ .trim()
96
+ }
97
+
98
+ /** Drop the currency sign; the column header already states the unit. */
99
+ function price(value: string): string {
100
+ return value.startsWith("$") ? value.slice(1) : value
101
+ }
102
+
103
+ function aaCell(score: ReturnType<typeof lookupScore>): string {
104
+ if (score?.score == null) return "n/a"
105
+ const value = String(Math.round(score.score * 10) / 10)
106
+ if (!score.note) return value
107
+ return `${value}${score.note.includes("[snapshot]") ? "*" : "~"}`
108
+ }
109
+
110
+ /**
111
+ * Build the grouped table rows. The model and provider are written once per
112
+ * model; extra context tiers continue underneath with those columns blank.
113
+ */
114
+ export function pricingData(
115
+ models: readonly TableModel[],
116
+ aa: AAFetched,
117
+ baseNames: ReadonlyMap<string, string>,
118
+ decorateNames: boolean,
119
+ ): PricingDataset {
120
+ const rows: PricingRow[] = []
121
+
122
+ for (const model of models) {
123
+ const key = modelKey(model)
124
+ const baseName = decorateNames ? (baseNames.get(key) ?? model.name) : model.name
125
+ const score = lookupScore(aa.scores, model.id, baseName)
126
+ const aaValue = aaCell(score)
127
+
128
+ let tiers: ReturnType<typeof formatCost> = []
129
+ try {
130
+ tiers = formatCost(model.cost)
131
+ } catch (error) {
132
+ console.warn(`[model-price-intel] could not format cost for ${key}:`, error)
133
+ }
134
+
135
+ if (tiers.length === 0) {
136
+ rows.push({
137
+ model: oneLine(baseName),
138
+ provider: oneLine(model.providerID),
139
+ tier: "base",
140
+ input: "n/a",
141
+ output: "—",
142
+ cache: "—",
143
+ aa: aaValue,
144
+ })
145
+ continue
146
+ }
147
+
148
+ tiers.forEach((tier, index) => {
149
+ const first = index === 0
150
+ rows.push({
151
+ model: first ? oneLine(baseName) : "",
152
+ provider: first ? oneLine(model.providerID) : "",
153
+ tier: oneLine(tier.tierLabel ?? "base"),
154
+ input: tier.free ? "free" : price(tier.input),
155
+ output: tier.free ? "—" : price(tier.output),
156
+ cache: tier.free ? "—" : `${price(tier.cacheRead)}/${price(tier.cacheWrite)}`,
157
+ aa: first ? aaValue : "",
158
+ })
159
+ })
160
+ }
161
+
162
+ return {
163
+ title: TITLE,
164
+ meta: `Prices per 1M tokens (USD) from the live OpenCode model registry.\nScores: Artificial Analysis Intelligence Index v${aa.version}.\nSource: ${aa.source}, fetched ${aa.fetchedAt.slice(0, 10)}.`,
165
+ footnote: FOOTNOTE,
166
+ rows,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Toggle a sort: the first click on a column sorts ascending, a second click
172
+ * on the same column flips the direction, and a different column starts over.
173
+ */
174
+ export function nextSort(current: SortState | undefined, key: keyof PricingRow): SortState {
175
+ if (current?.key === key) {
176
+ return { key, direction: current.direction === "asc" ? "desc" : "asc" }
177
+ }
178
+ return { key, direction: "asc" }
179
+ }
180
+
181
+ interface RowGroup {
182
+ readonly rows: PricingRow[]
183
+ readonly index: number
184
+ }
185
+
186
+ /**
187
+ * Split the flat row list into model groups. A non-empty model starts a group;
188
+ * blank model cells are continuation tiers and stay with the group above.
189
+ */
190
+ function groupRows(rows: readonly PricingRow[]): RowGroup[] {
191
+ const groups: RowGroup[] = []
192
+ for (const row of rows) {
193
+ if (row.model !== "" || groups.length === 0) {
194
+ groups.push({ rows: [row], index: groups.length })
195
+ } else {
196
+ groups[groups.length - 1].rows.push(row)
197
+ }
198
+ }
199
+ return groups
200
+ }
201
+
202
+ type Sortable =
203
+ | { readonly kind: "text"; readonly text: string }
204
+ | { readonly kind: "number"; readonly values: readonly number[] }
205
+ | { readonly kind: "missing" }
206
+
207
+ function sortable(value: string, key: keyof PricingRow): Sortable {
208
+ const text = value.trim()
209
+ if (key === "model" || key === "provider" || key === "tier") {
210
+ return { kind: "text", text }
211
+ }
212
+ if (text === "" || text === "—" || text === "?" || text.toLowerCase() === "n/a") {
213
+ return { kind: "missing" }
214
+ }
215
+
216
+ // Cache cells are "read/write", so compare the read price first and use the
217
+ // write price as a tie-breaker. Other numeric cells are a single value.
218
+ const parts = key === "cache" ? text.split("/") : [text]
219
+ const values: number[] = []
220
+ for (const part of parts) {
221
+ const token = part.trim().replace(/^\$/, "").replace(/^</, "")
222
+ if (token.toLowerCase() === "free") {
223
+ values.push(0)
224
+ continue
225
+ }
226
+ const match = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?/i.exec(token)
227
+ const number = match ? Number(match[0]) : Number.NaN
228
+ if (!Number.isFinite(number)) return { kind: "missing" }
229
+ values.push(number)
230
+ }
231
+ return { kind: "number", values }
232
+ }
233
+
234
+ function compareText(left: string, right: string): number {
235
+ const a = left.toLowerCase()
236
+ const b = right.toLowerCase()
237
+ if (a < b) return -1
238
+ if (a > b) return 1
239
+ if (left === right) return 0
240
+ return left < right ? -1 : 1
241
+ }
242
+
243
+ function compareSortable(left: Sortable, right: Sortable): number {
244
+ if (left.kind === "text" && right.kind === "text") {
245
+ return compareText(left.text, right.text)
246
+ }
247
+ if (left.kind === "number" && right.kind === "number") {
248
+ const length = Math.max(left.values.length, right.values.length)
249
+ for (let index = 0; index < length; index++) {
250
+ const a = left.values[index]
251
+ const b = right.values[index]
252
+ if (a === b) continue
253
+ if (a === undefined) return -1
254
+ if (b === undefined) return 1
255
+ return a < b ? -1 : 1
256
+ }
257
+ return 0
258
+ }
259
+ return 0
260
+ }
261
+
262
+ /**
263
+ * Sort model groups by one column while keeping continuation tiers attached.
264
+ * Sorting is stable: registry order survives ties in both directions, and
265
+ * missing/unpriced values sort last regardless of direction.
266
+ */
267
+ export function sortRows(
268
+ rows: readonly PricingRow[],
269
+ key: keyof PricingRow,
270
+ direction: SortDirection = "asc",
271
+ ): PricingRow[] {
272
+ const groups = groupRows(rows)
273
+ groups.sort((left, right) => {
274
+ const a = sortable(left.rows[0][key], key)
275
+ const b = sortable(right.rows[0][key], key)
276
+ if (a.kind === "missing" || b.kind === "missing") {
277
+ if (a.kind === b.kind) return left.index - right.index
278
+ return a.kind === "missing" ? 1 : -1
279
+ }
280
+ const comparison = compareSortable(a, b)
281
+ if (comparison !== 0) {
282
+ return direction === "asc" ? comparison : -comparison
283
+ }
284
+ return left.index - right.index
285
+ })
286
+ return groups.flatMap((group) => group.rows)
287
+ }
288
+
289
+ function clamp(value: number, min: number, max: number): number {
290
+ return Math.max(min, Math.min(max, value))
291
+ }
292
+
293
+ /** Shorten a cell to the column width, keeping one cell for the ellipsis. */
294
+ function truncate(value: string, width: number): string {
295
+ if (value.length <= width) return value
296
+ if (width <= 1) return "…".slice(0, width)
297
+ return `${value.slice(0, width - 1)}…`
298
+ }
299
+
300
+ function fit(value: string, width: number, align: "left" | "right"): string {
301
+ const text = truncate(oneLine(value), width)
302
+ const padding = " ".repeat(Math.max(0, width - text.length))
303
+ return align === "right" ? `${padding}${text}` : `${text}${padding}`
304
+ }
305
+
306
+ function headerLabel(column: Column, sort: SortState | undefined): string {
307
+ if (sort?.key !== column.key) return column.header
308
+ return `${column.header} ${sort.direction === "asc" ? "▲" : "▼"}`
309
+ }
310
+
311
+ function columnWidths(
312
+ rows: readonly PricingRow[],
313
+ width: number,
314
+ sort: SortState | undefined,
315
+ ): Record<keyof PricingRow, number> {
316
+ const natural = {} as Record<keyof PricingRow, number>
317
+ for (const column of COLUMNS) {
318
+ let widest = headerLabel(column, sort).length
319
+ for (const row of rows) widest = Math.max(widest, row[column.key].length)
320
+ natural[column.key] = clamp(widest, column.min, column.max)
321
+ }
322
+
323
+ // Give the leftover space to the model column so the table spans the dialog
324
+ // instead of leaving a ragged right edge. When space is tight, truncating the
325
+ // model name is preferable to overlapping the numeric columns.
326
+ const gaps = COLUMN_GAP.length * (COLUMNS.length - 1)
327
+ const fixed = COLUMNS.reduce(
328
+ (total, column) => total + (column.key === "model" ? 0 : natural[column.key]),
329
+ gaps,
330
+ )
331
+ const available = Math.max(0, width - fixed)
332
+ natural.model = clamp(available, COLUMNS[0].min, COLUMNS[0].max)
333
+
334
+ return natural
335
+ }
336
+
337
+ /** Lay the rows out as a monospace table for the given total width. */
338
+ export function formatRows(
339
+ rows: readonly PricingRow[],
340
+ width: number = DEFAULT_TABLE_WIDTH,
341
+ sort?: SortState,
342
+ ): PricingTableLayout {
343
+ const safeWidth = Math.max(24, Math.floor(width))
344
+ const widths = columnWidths(rows, safeWidth, sort)
345
+ const line = (cells: Readonly<Record<keyof PricingRow, string>>, trim: boolean): string => {
346
+ const rendered = COLUMNS.map((column) =>
347
+ fit(cells[column.key], widths[column.key], column.align),
348
+ ).join(COLUMN_GAP)
349
+ return trim ? rendered.trimEnd() : rendered
350
+ }
351
+
352
+ const columns = COLUMNS.map((column, index): PricingTableColumn => {
353
+ const label = headerLabel(column, sort)
354
+ const gap = index === COLUMNS.length - 1 ? "" : COLUMN_GAP
355
+ return {
356
+ key: column.key,
357
+ label: oneLine(label),
358
+ width: widths[column.key],
359
+ align: column.align,
360
+ header: `${fit(label, widths[column.key], column.align)}${gap}`,
361
+ direction: sort?.key === column.key ? sort.direction : undefined,
362
+ }
363
+ })
364
+ const header = columns.map((column) => column.header).join("")
365
+
366
+ return {
367
+ header,
368
+ separator: "─".repeat(header.length),
369
+ lines: rows.map((row) => line(row, true)),
370
+ columns,
371
+ }
372
+ }
373
+
374
+ /** Render the full text table used by the RPC fallback and tests. */
375
+ export function renderTable(
376
+ data: PricingDataset,
377
+ width: number = DEFAULT_TABLE_WIDTH,
378
+ ): string {
379
+ const layout = formatRows(data.rows, width)
380
+ return [
381
+ data.title,
382
+ "",
383
+ data.meta,
384
+ "",
385
+ layout.header,
386
+ layout.separator,
387
+ ...layout.lines,
388
+ "",
389
+ data.footnote,
390
+ ].join("\n")
391
+ }
392
+
393
+ /**
394
+ * Backwards-compatible text helper: build the dataset and render it at the
395
+ * default width.
396
+ */
397
+ export function buildTable(
398
+ models: readonly TableModel[],
399
+ aa: AAFetched,
400
+ baseNames: ReadonlyMap<string, string>,
401
+ decorateNames: boolean,
402
+ ): string {
403
+ return renderTable(pricingData(models, aa, baseNames, decorateNames))
404
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * TUI part: `/model-pricing` and `/prices` open a native dialog table.
3
+ *
4
+ * A dialog (rather than a session panel) keeps the right side of the session
5
+ * usable and inherits the built-in dialog behavior: scrolling, Escape,
6
+ * backdrop-click to close, and modal focus. The table rows come from the
7
+ * server over RPC because only the server owns the live registry and AA cache.
8
+ */
9
+
10
+ import { Plugin, usePlugin } from "@opencode/plugin/tui"
11
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
12
+ import { useTerminalDimensions } from "@opentui/solid"
13
+ import { For, Show, createMemo, createSignal } from "solid-js"
14
+ import { ModelPricing, type PricingTable } from "./rpc.ts"
15
+ import {
16
+ DEFAULT_TABLE_WIDTH,
17
+ formatRows,
18
+ nextSort,
19
+ sortRows,
20
+ type SortState,
21
+ } from "./table.ts"
22
+
23
+ function PricingDialog(props: {
24
+ readonly table: () => PricingTable | undefined
25
+ readonly onClose: () => void
26
+ }) {
27
+ const context = usePlugin()
28
+ const dimensions = useTerminalDimensions()
29
+ const theme = context.theme.surface("dialog")
30
+ const width = () => Math.max(40, Math.min(DEFAULT_TABLE_WIDTH, dimensions().width - 10))
31
+ const maxHeight = () => Math.max(5, Math.floor(dimensions().height / 2) - 6)
32
+ const [sort, setSort] = createSignal<SortState | undefined>()
33
+ const sortBy = (key: SortState["key"]) => setSort((current) => nextSort(current, key))
34
+
35
+ const focus = (scroll: ScrollBoxRenderable) => {
36
+ // The dialog has no search input, so give the table keyboard focus.
37
+ setTimeout(() => {
38
+ if (!scroll.isDestroyed) scroll.focus()
39
+ }, 1)
40
+ }
41
+
42
+ return (
43
+ <box flexDirection="column" paddingLeft={3} paddingRight={3} paddingBottom={1} gap={1}>
44
+ <box flexDirection="row" justifyContent="space-between">
45
+ <text attributes={TextAttributes.BOLD} fg={theme.text.base}>
46
+ Model pricing &amp; AA Intelligence Index
47
+ </text>
48
+ <text fg={theme.text.muted} onMouseUp={props.onClose}>
49
+ esc
50
+ </text>
51
+ </box>
52
+ <Show
53
+ when={props.table()}
54
+ fallback={<text fg={theme.text.muted}>Loading model pricing…</text>}
55
+ >
56
+ {(table: () => PricingTable) => {
57
+ const layout = createMemo(() => {
58
+ const state = sort()
59
+ const rows = state
60
+ ? sortRows(table().rows, state.key, state.direction)
61
+ : table().rows
62
+ return formatRows(rows, width(), state)
63
+ })
64
+ return (
65
+ <>
66
+ <text fg={theme.text.muted} wrapMode="word">
67
+ {table().meta}
68
+ </text>
69
+ <box flexDirection="column">
70
+ <box flexDirection="row" flexShrink={0}>
71
+ <For each={layout().columns}>
72
+ {(column) => (
73
+ <text
74
+ attributes={TextAttributes.BOLD}
75
+ fg={column.direction ? theme.text.base : theme.text.muted}
76
+ wrapMode="none"
77
+ flexShrink={0}
78
+ onMouseUp={(event) => {
79
+ // The host closes on a backdrop click; a header click
80
+ // must only change the sort order.
81
+ event.preventDefault()
82
+ event.stopPropagation()
83
+ sortBy(column.key)
84
+ }}
85
+ >
86
+ {column.header}
87
+ </text>
88
+ )}
89
+ </For>
90
+ </box>
91
+ <text fg={theme.text.faint} wrapMode="none">
92
+ {layout().separator}
93
+ </text>
94
+ <scrollbox
95
+ ref={focus}
96
+ maxHeight={maxHeight()}
97
+ scrollX
98
+ scrollbarOptions={{ visible: true }}
99
+ horizontalScrollbarOptions={{ visible: false }}
100
+ >
101
+ <Show
102
+ when={layout().lines.length > 0}
103
+ fallback={<text fg={theme.text.muted}>No models available.</text>}
104
+ >
105
+ <For each={layout().lines}>
106
+ {(line) => (
107
+ <text fg={theme.text.base} wrapMode="none">
108
+ {line}
109
+ </text>
110
+ )}
111
+ </For>
112
+ </Show>
113
+ </scrollbox>
114
+ </box>
115
+ <text fg={theme.text.muted} wrapMode="word">
116
+ {table().footnote}
117
+ </text>
118
+ </>
119
+ )
120
+ }}
121
+ </Show>
122
+ </box>
123
+ )
124
+ }
125
+
126
+ function message(error: unknown): string {
127
+ return error instanceof Error ? error.message : String(error)
128
+ }
129
+
130
+ export default Plugin.define({
131
+ id: "model-price-intel.tui",
132
+ async setup(context) {
133
+ const pricing = context.client.rpc(ModelPricing)
134
+ const [table, setTable] = createSignal<PricingTable | undefined>()
135
+ let open = false
136
+ // Ignore a response that belongs to a dialog the user already closed.
137
+ let request = 0
138
+
139
+ const close = () => {
140
+ request++
141
+ open = false
142
+ setTable(undefined)
143
+ }
144
+
145
+ const toggle = async () => {
146
+ if (open) {
147
+ open = false
148
+ request++
149
+ setTable(undefined)
150
+ context.ui.dialog.clear()
151
+ return
152
+ }
153
+
154
+ let location
155
+ try {
156
+ location = context.location ?? context.data.location.default()
157
+ } catch (error) {
158
+ context.ui.toast.show({
159
+ title: "Model pricing",
160
+ message: message(error),
161
+ variant: "error",
162
+ })
163
+ return
164
+ }
165
+
166
+ open = true
167
+ const id = ++request
168
+ setTable(undefined)
169
+
170
+ try {
171
+ context.ui.dialog.show(
172
+ () => <PricingDialog table={table} onClose={close} />,
173
+ close,
174
+ )
175
+ // `show` starts a fresh dialog at the default size, so apply the larger
176
+ // presentation afterwards.
177
+ context.ui.dialog.set({ size: "xlarge", centered: true })
178
+
179
+ const result = (await pricing.table({}, { location })) as PricingTable
180
+ if (!open || id !== request) return
181
+ setTable(result)
182
+ } catch (error) {
183
+ if (!open || id !== request) return
184
+ close()
185
+ context.ui.dialog.clear()
186
+ context.ui.toast.show({
187
+ title: "Model pricing",
188
+ message: message(error),
189
+ variant: "error",
190
+ })
191
+ }
192
+ }
193
+
194
+ context.keymap.layer(() => ({
195
+ mode: "global",
196
+ commands: [
197
+ {
198
+ id: "model-price-intel.prices",
199
+ title: "Model pricing",
200
+ group: "Model price intel",
201
+ slash: { name: "model-pricing", aliases: ["prices"] },
202
+ run: toggle,
203
+ },
204
+ ],
205
+ }))
206
+ },
207
+ })