opencode-model-price-intel 1.0.0 → 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/README.md +42 -25
- package/package.json +13 -3
- package/src/index.ts +21 -90
- package/src/pricing.ts +1 -2
- package/src/rpc.ts +65 -0
- package/src/table.ts +404 -0
- package/src/tui.tsx +207 -0
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# opencode-model-price-intel
|
|
2
2
|
|
|
3
3
|
[](https://github.com/chris15joseph/opencode-model-price-intel/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/opencode-model-price-intel)
|
|
4
5
|
[](LICENSE)
|
|
5
6
|
|
|
6
7
|
An OpenCode 2 plugin that puts **prices and the Artificial Analysis
|
|
7
|
-
Intelligence Index** next to every model in `/models`, and
|
|
8
|
-
`/model-pricing` table when you need the details.
|
|
8
|
+
Intelligence Index** next to every model in `/models`, and opens a full
|
|
9
|
+
`/model-pricing` table in a native TUI dialog when you need the details.
|
|
9
10
|
|
|
10
11
|
```text
|
|
11
12
|
/models
|
|
@@ -23,13 +24,6 @@ so the first `/models` render is instant and the network never blocks it.
|
|
|
23
24
|
Requires OpenCode 2.2.x (the plugin pins `@opencode/plugin` to `2.2.5`) and
|
|
24
25
|
Node.js 20 or newer.
|
|
25
26
|
|
|
26
|
-
> **npm status:** the package is staged but not yet published. Until it appears
|
|
27
|
-
> on npm, install from Git:
|
|
28
|
-
>
|
|
29
|
-
> ```sh
|
|
30
|
-
> opencode plugin add github:chris15joseph/opencode-model-price-intel
|
|
31
|
-
> ```
|
|
32
|
-
|
|
33
27
|
```sh
|
|
34
28
|
opencode plugin add opencode-model-price-intel
|
|
35
29
|
```
|
|
@@ -76,23 +70,40 @@ list the same plugin twice.
|
|
|
76
70
|
|
|
77
71
|
| Option | Default | Meaning |
|
|
78
72
|
| --- | --- | --- |
|
|
79
|
-
| `decorateNames` | `true` | Append the price/score summary to `/models` names. The `/model-pricing`
|
|
73
|
+
| `decorateNames` | `true` | Append the price/score summary to `/models` names. The `/model-pricing` dialog still works when this is off. |
|
|
80
74
|
| `costFormat` | `"detailed"` | `"detailed"` renders `in $2.5 out $15 · cache read $0.25 write $0`; `"compact"` renders `$2.5/$15/$0.25/$0`. |
|
|
81
75
|
| `aaApiKey` | unset | Artificial Analysis API key. Falls back to `ARTIFICIALANALYSIS_API_KEY`. Without either, the public model-list page is used. |
|
|
82
76
|
| `refreshHours` | `12` | Score cache TTL and refresh interval, clamped to `1..596` hours. |
|
|
83
77
|
|
|
84
78
|
Invalid option values log one warning and fall back to the defaults.
|
|
85
79
|
|
|
86
|
-
##
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
Open the native model picker with `/models`. Each row's name carries the
|
|
83
|
+
one-line summary:
|
|
87
84
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
| `/prices` | Alias of `/model-pricing`. |
|
|
92
|
-
| `/models` | The native picker, now decorated with the one-line summary. |
|
|
85
|
+
```text
|
|
86
|
+
GPT 6 Astra · Turbo · in $2.5 out $15 · cache read $0.25 write $0 (2 tiers) · AA 56.9
|
|
87
|
+
```
|
|
93
88
|
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
Multi-tier models show the tier count; the `/models` row itself stays a single
|
|
90
|
+
line.
|
|
91
|
+
|
|
92
|
+
For the full table, run `/model-pricing` (or `/prices`). It opens a native TUI
|
|
93
|
+
dialog with an aligned, scrollable table of every provider, price tier, cache
|
|
94
|
+
price, and AA score. Click any column header to sort by it; click the same
|
|
95
|
+
header again to reverse the order. The active header shows `▲` (ascending) or
|
|
96
|
+
`▼` (descending), and multi-tier models stay together with their continuation
|
|
97
|
+
rows. The dialog can be dismissed with `Esc`, `Ctrl+C`, or a
|
|
98
|
+
click outside. The command handler is a toggle, so invoking it again while the
|
|
99
|
+
dialog is open closes it (the dialog is modal, so `Esc` is the usual path from
|
|
100
|
+
the prompt). The table is a TUI feature; the `/models` decoration is
|
|
101
|
+
server-side and works in every client.
|
|
102
|
+
|
|
103
|
+
The TUI part asks the server for structured rows over RPC, so the dialog uses
|
|
104
|
+
the same live registry and score cache as `/models`. It does not send a prompt
|
|
105
|
+
or make a model call. The table is laid out for the current terminal width and
|
|
106
|
+
scrolls vertically (and horizontally when the window is narrow).
|
|
96
107
|
|
|
97
108
|
## How it works
|
|
98
109
|
|
|
@@ -106,6 +117,8 @@ models get one row per price entry (`base`, then `>N` for context overrides).
|
|
|
106
117
|
- The last good result is cached in OpenCode plugin storage for `refreshHours`.
|
|
107
118
|
A cache older than 30 days is ignored.
|
|
108
119
|
- The model registry reloads only when the score data actually changes.
|
|
120
|
+
- The TUI part is registered through the package's `./tui` export and fetches
|
|
121
|
+
the table through the server-side RPC in `./rpc`.
|
|
109
122
|
- Unscored models show `AA n/a`. A model with no registry cost shows
|
|
110
123
|
`pricing n/a` (or `n/a` in compact mode).
|
|
111
124
|
|
|
@@ -117,8 +130,10 @@ models get one row per price entry (`base`, then `>N` for context overrides).
|
|
|
117
130
|
discounts, or regional pricing that the registry does not carry.
|
|
118
131
|
- AA scores are comparative within one index major version. The plugin never
|
|
119
132
|
mixes a v5 snapshot into a v6 result.
|
|
120
|
-
- The plugin only decorates the list and
|
|
121
|
-
requests or change model selection.
|
|
133
|
+
- The plugin only decorates the model list and renders the pricing dialog. It
|
|
134
|
+
does not route requests or change model selection.
|
|
135
|
+
- The `/model-pricing` dialog requires the TUI. Other clients still get the
|
|
136
|
+
`/models` decoration.
|
|
122
137
|
|
|
123
138
|
## Development
|
|
124
139
|
|
|
@@ -131,16 +146,18 @@ npm test
|
|
|
131
146
|
npm pack --dry-run
|
|
132
147
|
```
|
|
133
148
|
|
|
134
|
-
The package exports raw TypeScript (`src/index.ts`
|
|
135
|
-
it at load. Plain Node cannot import
|
|
149
|
+
The package exports raw TypeScript (`src/index.ts`, `src/rpc.ts`, and
|
|
150
|
+
`src/tui.tsx`); OpenCode 2.2.x transpiles it at load. Plain Node cannot import
|
|
151
|
+
the installed package from `node_modules`. The TUI part uses the host's
|
|
152
|
+
`@opentui` packages, declared as peer dependencies.
|
|
136
153
|
|
|
137
154
|
## Attribution
|
|
138
155
|
|
|
139
156
|
Scores and related model data are produced by
|
|
140
157
|
[Artificial Analysis](https://artificialanalysis.ai/). The `/model-pricing`
|
|
141
|
-
|
|
142
|
-
link. See [`NOTICE`](NOTICE) and [`SNAPSHOT-NOTICE.md`](SNAPSHOT-NOTICE.md)
|
|
143
|
-
provenance.
|
|
158
|
+
dialog carries the source, index version, capture/fetch date, and attribution
|
|
159
|
+
link. See [`NOTICE`](NOTICE) and [`SNAPSHOT-NOTICE.md`](SNAPSHOT-NOTICE.md)
|
|
160
|
+
for further provenance.
|
|
144
161
|
|
|
145
162
|
## License
|
|
146
163
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-model-price-intel",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "OpenCode 2 plugin that adds input/output/cache prices and Artificial Analysis Intelligence Index scores to /models, plus a full /model-pricing
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "OpenCode 2 plugin that adds input/output/cache prices and Artificial Analysis Intelligence Index scores to /models, plus a full /model-pricing TUI dialog.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./src/index.ts"
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./rpc": "./src/rpc.ts",
|
|
9
|
+
"./tui": "./src/tui.tsx"
|
|
8
10
|
},
|
|
9
11
|
"types": "./src/index.ts",
|
|
10
12
|
"files": [
|
|
@@ -19,8 +21,16 @@
|
|
|
19
21
|
"dependencies": {
|
|
20
22
|
"@opencode/plugin": "2.2.5"
|
|
21
23
|
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@opentui/core": ">=0.6.6",
|
|
26
|
+
"@opentui/solid": ">=0.6.6",
|
|
27
|
+
"solid-js": ">=1.9.0"
|
|
28
|
+
},
|
|
22
29
|
"devDependencies": {
|
|
30
|
+
"@opentui/core": "^0.8.3",
|
|
31
|
+
"@opentui/solid": "^0.8.3",
|
|
23
32
|
"@types/node": "22.20.5",
|
|
33
|
+
"solid-js": "^1.9.18",
|
|
24
34
|
"typescript": "5.9.3"
|
|
25
35
|
},
|
|
26
36
|
"scripts": {
|
package/src/index.ts
CHANGED
|
@@ -16,14 +16,18 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Because the built-in `/models` picker renders `Model.Info.name` as the row
|
|
18
18
|
* title, the pricing/score line is attached to the display name so `/models`
|
|
19
|
-
* lists it per model.
|
|
20
|
-
* table — every price tier, cache read/write, and the AA score — into the
|
|
21
|
-
* session.
|
|
19
|
+
* lists it per model.
|
|
22
20
|
*
|
|
23
21
|
* Note: an experimental scrolling (marquee) mode for narrow screens existed
|
|
24
22
|
* here and was removed — periodic name repaints made client lists reset their
|
|
25
23
|
* scroll position (both the TUI and the web UI). Names are therefore static.
|
|
26
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
|
+
*
|
|
27
31
|
* Options (opencode.jsonc `plugins` entry):
|
|
28
32
|
* - decorateNames: boolean attach the line to model names (default true)
|
|
29
33
|
* - costFormat: "detailed" | "compact" cost line format (default detailed)
|
|
@@ -34,9 +38,10 @@
|
|
|
34
38
|
*/
|
|
35
39
|
|
|
36
40
|
import { Plugin } from "@opencode/plugin"
|
|
37
|
-
import type { CommandInvocation } from "@opencode/plugin/promise/command"
|
|
38
41
|
import { fetchAAScores, lookupScore, snapshotScores, type AAFetched } from "./aa.ts"
|
|
39
|
-
import {
|
|
42
|
+
import { pricingSegment, type CostFormat } from "./pricing.ts"
|
|
43
|
+
import { ModelPricing } from "./rpc.ts"
|
|
44
|
+
import { pricingData, renderTable, type TableModel } from "./table.ts"
|
|
40
45
|
|
|
41
46
|
const CACHE_KEY = "aa-scores/v3"
|
|
42
47
|
const DEFAULT_REFRESH_HOURS = 12
|
|
@@ -53,15 +58,11 @@ interface ResolvedOptions {
|
|
|
53
58
|
refreshMs: number
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
|
|
57
|
-
readonly id: string
|
|
58
|
-
readonly modelID?: string
|
|
61
|
+
function modelKey(model: {
|
|
59
62
|
readonly providerID: string
|
|
60
|
-
readonly
|
|
61
|
-
readonly
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function modelKey(model: TableModel): string {
|
|
63
|
+
readonly modelID?: string
|
|
64
|
+
readonly id: string
|
|
65
|
+
}): string {
|
|
65
66
|
return `${model.providerID}/${model.modelID || model.id}`
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -218,63 +219,6 @@ function cachePayload(value: AAFetched): JsonValue {
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
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
222
|
/** Remove our own decoration if a transform is replayed against a stale name. */
|
|
279
223
|
function undecoratedName(name: string, price: string): string {
|
|
280
224
|
const suffix = ` · ${price} · AA `
|
|
@@ -429,10 +373,10 @@ export default Plugin.define({
|
|
|
429
373
|
})
|
|
430
374
|
}
|
|
431
375
|
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
await ctx.
|
|
435
|
-
|
|
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 () => {
|
|
436
380
|
let models: readonly TableModel[] = []
|
|
437
381
|
try {
|
|
438
382
|
const { data } = await ctx.model.list()
|
|
@@ -440,22 +384,9 @@ export default Plugin.define({
|
|
|
440
384
|
} catch (error) {
|
|
441
385
|
console.warn("[model-price-intel] could not list models for the pricing table:", error)
|
|
442
386
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
})
|
|
387
|
+
const data = pricingData(models, aa, baseNames, options.decorateNames)
|
|
388
|
+
return { ...data, text: renderTable(data) }
|
|
389
|
+
},
|
|
459
390
|
})
|
|
460
391
|
|
|
461
392
|
// Hydrate the cache and refresh live scores in the background. setup must
|
package/src/pricing.ts
CHANGED
|
@@ -124,8 +124,7 @@ export type CostFormat = "detailed" | "compact"
|
|
|
124
124
|
*
|
|
125
125
|
* The compact form (<in>/<out>/<read>/<write>) keeps rows readable on narrow
|
|
126
126
|
* screens such as mobile web sessions. Multi-tier models show their base tier
|
|
127
|
-
* and an `(N tiers)` marker
|
|
128
|
-
* with its `>N` context threshold.
|
|
127
|
+
* and an `(N tiers)` marker.
|
|
129
128
|
*/
|
|
130
129
|
export function pricingSegment(
|
|
131
130
|
cost: readonly CostTier[] | undefined,
|
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
|
+
}
|
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
|
+
})
|