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/LICENSE +21 -0
- package/NOTICE +37 -0
- package/README.md +163 -2
- package/package.json +63 -4
- package/src/aa-snapshot.ts +515 -0
- package/src/aa.ts +911 -0
- package/src/index.ts +411 -0
- package/src/pricing.ts +142 -0
- package/src/rpc.ts +65 -0
- package/src/table.ts +404 -0
- package/src/tui.tsx +207 -0
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 v5 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 & 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
|
+
})
|