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/LICENSE +21 -0
- package/NOTICE +37 -0
- package/README.md +146 -2
- package/package.json +53 -4
- package/src/aa-snapshot.ts +515 -0
- package/src/aa.ts +911 -0
- package/src/index.ts +480 -0
- package/src/pricing.ts +143 -0
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
|
+
}
|