opencode-model-price-intel 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -31
- package/package.json +13 -3
- package/src/aa-snapshot.ts +230 -154
- package/src/aa.ts +100 -45
- package/src/index.ts +22 -91
- 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/src/aa.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Live Artificial Analysis Intelligence Index fetcher.
|
|
3
3
|
*
|
|
4
4
|
* Scores change as Artificial Analysis publishes new evaluations (the index is
|
|
5
|
-
* versioned —
|
|
6
|
-
* with a cached copy kept in plugin storage and periodic refresh
|
|
7
|
-
* session is open.
|
|
5
|
+
* versioned — v5.0.1 at the time of writing), so this runs whenever the plugin
|
|
6
|
+
* refreshes, with a cached copy kept in plugin storage and periodic refresh
|
|
7
|
+
* while the session is open. The version is read from the live payload rather
|
|
8
|
+
* than pinned, so a new release is adopted automatically.
|
|
8
9
|
*
|
|
9
10
|
* Two sources, in order:
|
|
10
11
|
*
|
|
@@ -15,12 +16,14 @@
|
|
|
15
16
|
* accepts any valid key (Free, Pro or Commercial). Attribution:
|
|
16
17
|
* https://artificialanalysis.ai/
|
|
17
18
|
*
|
|
18
|
-
* 2. The public model
|
|
19
|
-
*
|
|
20
|
-
* `intelligenceIndex` score per model variant.
|
|
19
|
+
* 2. The public model leaderboard page
|
|
20
|
+
* (https://artificialanalysis.ai/leaderboards/models), whose embedded
|
|
21
|
+
* dataset carries the `intelligenceIndex` score per model variant. It is a
|
|
22
|
+
* superset of the model-list page and publishes the current index version.
|
|
23
|
+
* Requires no key.
|
|
21
24
|
*
|
|
22
25
|
* The bundled snapshot in `aa-snapshot.ts` is only used when both fail, and
|
|
23
|
-
* only when its
|
|
26
|
+
* only when its index version exactly matches the live data.
|
|
24
27
|
*/
|
|
25
28
|
|
|
26
29
|
import {
|
|
@@ -33,7 +36,7 @@ import {
|
|
|
33
36
|
type AAScore,
|
|
34
37
|
} from "./aa-snapshot.ts"
|
|
35
38
|
|
|
36
|
-
export const AA_MODELS_URL = "https://artificialanalysis.ai/models"
|
|
39
|
+
export const AA_MODELS_URL = "https://artificialanalysis.ai/leaderboards/models"
|
|
37
40
|
/** Free-tier endpoint: accepts any valid API key; Pro-only route is not needed. */
|
|
38
41
|
export const AA_API_URL = "https://artificialanalysis.ai/api/v2/language/models/free"
|
|
39
42
|
/** Hard safety cap on API pagination (the Free quota is 100 requests/day). */
|
|
@@ -90,7 +93,7 @@ export interface AAAPIFetch {
|
|
|
90
93
|
export interface AAPageFetch {
|
|
91
94
|
records: AARecord[]
|
|
92
95
|
version: string
|
|
93
|
-
/** True when the page did not publish a version and
|
|
96
|
+
/** True when the page did not publish a version and the bundled version was assumed. */
|
|
94
97
|
versionAssumed: boolean
|
|
95
98
|
}
|
|
96
99
|
|
|
@@ -144,15 +147,17 @@ function finiteNonNegative(value: unknown): number | null {
|
|
|
144
147
|
return Number.isFinite(number) && number >= 0 ? number : null
|
|
145
148
|
}
|
|
146
149
|
|
|
147
|
-
/** Normalize AA's numeric (`5` / `5.1`) or string (`"5.0"`, `"v5"`) version. */
|
|
150
|
+
/** Normalize AA's numeric (`5` / `5.1`) or string (`"5.0"`, `"v5"`, `"5.0.1"`) version. */
|
|
148
151
|
export function normalizeVersion(value: unknown, fallback = AA_INDEX_VERSION): string {
|
|
149
152
|
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
|
150
153
|
const text = String(value)
|
|
151
154
|
return text.includes(".") ? text : `${text}.0`
|
|
152
155
|
}
|
|
153
156
|
if (typeof value === "string") {
|
|
154
|
-
const match = value.trim().match(/^v?(\d+)(?:\.(\d+))?/i)
|
|
155
|
-
if (match)
|
|
157
|
+
const match = value.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/i)
|
|
158
|
+
if (match) {
|
|
159
|
+
return `${match[1]}.${match[2] ?? "0"}${match[3] !== undefined ? `.${match[3]}` : ""}`
|
|
160
|
+
}
|
|
156
161
|
}
|
|
157
162
|
return fallback
|
|
158
163
|
}
|
|
@@ -160,26 +165,34 @@ export function normalizeVersion(value: unknown, fallback = AA_INDEX_VERSION): s
|
|
|
160
165
|
function optionalVersion(value: unknown): string | undefined {
|
|
161
166
|
if (value === undefined || value === null || value === "") return undefined
|
|
162
167
|
if (typeof value === "number" && (!Number.isFinite(value) || value < 0)) return undefined
|
|
163
|
-
if (typeof value === "string" && !/^v?\d+(?:\.\d+)
|
|
168
|
+
if (typeof value === "string" && !/^v?\d+(?:\.\d+){0,2}/i.test(value.trim())) return undefined
|
|
164
169
|
return normalizeVersion(value)
|
|
165
170
|
}
|
|
166
171
|
|
|
167
|
-
function majorOf(version: string): string {
|
|
168
|
-
return version.split(".")[0] ?? version
|
|
169
|
-
}
|
|
170
|
-
|
|
171
172
|
function countNumericScores(scores: Readonly<Record<string, AAScore>>): number {
|
|
172
173
|
let count = 0
|
|
173
174
|
for (const entry of Object.values(scores)) if (entry.score != null) count++
|
|
174
175
|
return count
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/** Compare normalized index versions segment by segment (`5.0.1` > `5.0`). */
|
|
179
|
+
function compareVersions(a: string, b: string): number {
|
|
180
|
+
const left = normalizeVersion(a).split(".").map(Number)
|
|
181
|
+
const right = normalizeVersion(b).split(".").map(Number)
|
|
182
|
+
const length = Math.max(left.length, right.length)
|
|
183
|
+
for (let index = 0; index < length; index++) {
|
|
184
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0)
|
|
185
|
+
if (difference !== 0) return difference
|
|
186
|
+
}
|
|
187
|
+
return 0
|
|
188
|
+
}
|
|
189
|
+
|
|
177
190
|
// ------------------------------------------------------------------ page
|
|
178
191
|
|
|
179
192
|
/**
|
|
180
193
|
* Company aggregate rows on AA's evaluations pages are shaped like model
|
|
181
|
-
* records. The
|
|
182
|
-
*
|
|
194
|
+
* records. The shared parser filters these explicitly as a second line of
|
|
195
|
+
* defence.
|
|
183
196
|
*/
|
|
184
197
|
const COMPANY_AGGREGATES = new Set([
|
|
185
198
|
"google",
|
|
@@ -338,9 +351,14 @@ export function parseAARecords(payload: string): AARecord[] {
|
|
|
338
351
|
export function parsePageVersion(payload: string): string | undefined {
|
|
339
352
|
const decoded = payload.includes("self.__next_f.push") ? decodeRSC(payload) : payload
|
|
340
353
|
const patterns = [
|
|
341
|
-
/"intelligenceIndexVersion"\s*:\s*"?v?(\d+(?:\.\d+)
|
|
342
|
-
/"intelligence_index_version"\s*:\s*"?v?(\d+(?:\.\d+)
|
|
343
|
-
/"indexVersion"\s*:\s*"?v?(\d+(?:\.\d+)
|
|
354
|
+
/"intelligenceIndexVersion"\s*:\s*"?v?(\d+(?:\.\d+){0,2})"?/i,
|
|
355
|
+
/"intelligence_index_version"\s*:\s*"?v?(\d+(?:\.\d+){0,2})"?/i,
|
|
356
|
+
/"indexVersion"\s*:\s*"?v?(\d+(?:\.\d+){0,2})"?/i,
|
|
357
|
+
// Rendered copy and JSON-LD metadata say "Artificial Analysis
|
|
358
|
+
// Intelligence Index v5.0.1"; use it when no machine-readable version key
|
|
359
|
+
// is present.
|
|
360
|
+
/Artificial Analysis Intelligence Index\s+v?(\d+(?:\.\d+){0,2})/i,
|
|
361
|
+
/Intelligence Index\s+v(\d+(?:\.\d+){0,2})/i,
|
|
344
362
|
]
|
|
345
363
|
for (const pattern of patterns) {
|
|
346
364
|
const match = decoded.match(pattern)
|
|
@@ -461,18 +479,34 @@ export function lookupScore(
|
|
|
461
479
|
modelName: string,
|
|
462
480
|
): AAScore | undefined {
|
|
463
481
|
const keys = [...new Set([matchKey(modelID), matchKey(modelName)].filter((key) => key.length > 0))]
|
|
464
|
-
const exactEntries: AAScore[] = []
|
|
482
|
+
const exactEntries: { key: string; entry: AAScore }[] = []
|
|
465
483
|
for (const key of keys) {
|
|
466
484
|
const entry = index[key]
|
|
467
485
|
if (!entry) continue
|
|
468
486
|
const duplicate = exactEntries.some(
|
|
469
|
-
(
|
|
487
|
+
({ entry: existing }) =>
|
|
488
|
+
existing === entry || (existing.name === entry.name && existing.score === entry.score),
|
|
470
489
|
)
|
|
471
|
-
if (!duplicate) exactEntries.push(entry)
|
|
490
|
+
if (!duplicate) exactEntries.push({ key, entry })
|
|
491
|
+
}
|
|
492
|
+
// ID and display name can legitimately resolve to two entries, e.g. a
|
|
493
|
+
// checkpoint-scored model whose ID normalizes to the unscored base name
|
|
494
|
+
// ("deepseek-v4.1-flash") while the display name normalizes to the scored
|
|
495
|
+
// checkpoint ("DeepSeek V4.1 Flash 1017"). When exactly one of them carries
|
|
496
|
+
// a score and the keys are versions of the same name, use it; unrelated
|
|
497
|
+
// names or two distinct scores stay ambiguous.
|
|
498
|
+
if (exactEntries.length > 1) {
|
|
499
|
+
const scored = exactEntries.filter(({ entry }) => entry.score != null)
|
|
500
|
+
if (scored.length === 1) {
|
|
501
|
+
const winner = scored[0]
|
|
502
|
+
const related = exactEntries.every(
|
|
503
|
+
({ key }) => key === winner.key || winner.key.startsWith(key) || key.startsWith(winner.key),
|
|
504
|
+
)
|
|
505
|
+
if (related) return winner.entry
|
|
506
|
+
}
|
|
507
|
+
return undefined
|
|
472
508
|
}
|
|
473
|
-
|
|
474
|
-
if (exactEntries.length > 1) return undefined
|
|
475
|
-
const exact = exactEntries[0]
|
|
509
|
+
const exact = exactEntries[0]?.entry
|
|
476
510
|
if (exact?.score != null) return exact
|
|
477
511
|
|
|
478
512
|
const candidates = new Map<string, AAScore>()
|
|
@@ -490,6 +524,18 @@ export function lookupScore(
|
|
|
490
524
|
candidates.set(candidate, entry)
|
|
491
525
|
}
|
|
492
526
|
}
|
|
527
|
+
// Some AA entries carry the vendor in the display name ("Tencent Hy4") while
|
|
528
|
+
// the OpenCode catalog uses the short name ("Hy4"). Accept a vendor-prefixed
|
|
529
|
+
// exact match too, but only for a known company prefix so unrelated model
|
|
530
|
+
// families cannot absorb each other's scores.
|
|
531
|
+
for (const key of keys) {
|
|
532
|
+
if (key.length < 3) continue
|
|
533
|
+
for (const [candidate, entry] of Object.entries(index)) {
|
|
534
|
+
if (candidate.length <= key.length || !candidate.endsWith(key)) continue
|
|
535
|
+
const prefix = candidate.slice(0, candidate.length - key.length)
|
|
536
|
+
if (COMPANY_AGGREGATES.has(prefix)) candidates.set(candidate, entry)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
493
539
|
// Multiple possible siblings are ambiguous: prefer no score over a guess.
|
|
494
540
|
if (candidates.size > 1) return undefined
|
|
495
541
|
const suffixed = [...candidates.values()][0]
|
|
@@ -535,7 +581,7 @@ function mergeWithSnapshot(
|
|
|
535
581
|
let live = 0
|
|
536
582
|
for (const entry of Object.values(index)) if (entry.score != null) live++
|
|
537
583
|
let snapshot = 0
|
|
538
|
-
if (
|
|
584
|
+
if (normalizeVersion(version) === normalizeVersion(AA_SNAPSHOT_VERSION)) {
|
|
539
585
|
for (const [key, entry] of Object.entries(snapshotIndex())) {
|
|
540
586
|
const existing = merged[key]
|
|
541
587
|
if (existing?.score != null) continue
|
|
@@ -553,8 +599,10 @@ function mergeWithSnapshot(
|
|
|
553
599
|
|
|
554
600
|
/**
|
|
555
601
|
* Use the bundled snapshot to fill gaps the live data does not cover, but only
|
|
556
|
-
* when the live index
|
|
557
|
-
*
|
|
602
|
+
* when the live index is exactly the snapshot's version. Scores change between
|
|
603
|
+
* index updates (including patch releases), so older snapshot values must not
|
|
604
|
+
* be presented as current. Live non-null scores always win; snapshot-derived
|
|
605
|
+
* entries carry a `[snapshot]` note.
|
|
558
606
|
*/
|
|
559
607
|
export function withSnapshotFallback(
|
|
560
608
|
index: Readonly<Record<string, AAScore>>,
|
|
@@ -799,9 +847,10 @@ export async function fetchFromAPI(
|
|
|
799
847
|
}
|
|
800
848
|
|
|
801
849
|
/**
|
|
802
|
-
* Scrape the embedded dataset from the public model
|
|
803
|
-
*
|
|
804
|
-
*
|
|
850
|
+
* Scrape the embedded dataset from the public model leaderboard page (no key
|
|
851
|
+
* needed). The leaderboard is a superset of the model-list page: it carries
|
|
852
|
+
* every scored variant and the current index version. Company aggregate rows
|
|
853
|
+
* are filtered during parsing.
|
|
805
854
|
*/
|
|
806
855
|
export async function fetchFromPages(
|
|
807
856
|
fetchImpl: typeof fetch = fetch,
|
|
@@ -813,7 +862,7 @@ export async function fetchFromPages(
|
|
|
813
862
|
retryDelayMs: options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
|
|
814
863
|
}
|
|
815
864
|
const response = await fetchWithRetry(fetchImpl, AA_MODELS_URL, {}, settings)
|
|
816
|
-
if (!response.ok) throw new Error(`Artificial Analysis
|
|
865
|
+
if (!response.ok) throw new Error(`Artificial Analysis leaderboard page returned ${response.status}`)
|
|
817
866
|
const html = await response.text()
|
|
818
867
|
const decoded = decodeRSC(html)
|
|
819
868
|
const records = parseAARecords(decoded)
|
|
@@ -827,9 +876,9 @@ export async function fetchFromPages(
|
|
|
827
876
|
|
|
828
877
|
/**
|
|
829
878
|
* Fetch current Intelligence Index scores. Tries the Free API when a key is
|
|
830
|
-
* configured, then the public
|
|
879
|
+
* configured, then the public leaderboard page, then falls back to the bundled
|
|
831
880
|
* snapshot. Every source completes before the snapshot is used, so a stalled
|
|
832
|
-
* API cannot consume the
|
|
881
|
+
* API cannot consume the leaderboard fallback's abort budget.
|
|
833
882
|
*/
|
|
834
883
|
export async function fetchAAScores(options?: AAFetchOptions): Promise<AAFetched> {
|
|
835
884
|
const fetchImpl = options?.fetchImpl ?? fetch
|
|
@@ -844,7 +893,7 @@ export async function fetchAAScores(options?: AAFetchOptions): Promise<AAFetched
|
|
|
844
893
|
if (apiKey) {
|
|
845
894
|
try {
|
|
846
895
|
const { records, version, pages } = await fetchFromAPI(apiKey, fetchImpl, settings)
|
|
847
|
-
if (records.length > 0) {
|
|
896
|
+
if (records.length > 0 && compareVersions(version, AA_INDEX_VERSION) >= 0) {
|
|
848
897
|
const { scores, coverage } = mergeWithSnapshot(indexRecords(records), version)
|
|
849
898
|
log(
|
|
850
899
|
`[model-price-intel] AA API (${pages} page${pages === 1 ? "" : "s"}): ${coverage.live} live + ${coverage.snapshot} snapshot scores`,
|
|
@@ -857,10 +906,16 @@ export async function fetchAAScores(options?: AAFetchOptions): Promise<AAFetched
|
|
|
857
906
|
coverage,
|
|
858
907
|
}
|
|
859
908
|
}
|
|
860
|
-
|
|
909
|
+
if (records.length > 0) {
|
|
910
|
+
log(
|
|
911
|
+
`[model-price-intel] AA API returned v${version}, older than the latest known v${AA_INDEX_VERSION}; falling back to the leaderboard page`,
|
|
912
|
+
)
|
|
913
|
+
} else {
|
|
914
|
+
log("[model-price-intel] AA API returned no usable model records; falling back to the leaderboard page")
|
|
915
|
+
}
|
|
861
916
|
} catch (error) {
|
|
862
917
|
log(
|
|
863
|
-
`[model-price-intel] AA API failed (${error instanceof Error ? error.message : String(error)}); falling back to the
|
|
918
|
+
`[model-price-intel] AA API failed (${error instanceof Error ? error.message : String(error)}); falling back to the leaderboard page`,
|
|
864
919
|
)
|
|
865
920
|
}
|
|
866
921
|
}
|
|
@@ -870,10 +925,10 @@ export async function fetchAAScores(options?: AAFetchOptions): Promise<AAFetched
|
|
|
870
925
|
if (page.records.length > 0) {
|
|
871
926
|
const { scores, coverage } = mergeWithSnapshot(indexRecords(page.records), page.version)
|
|
872
927
|
const source = page.versionAssumed
|
|
873
|
-
? `artificialanalysis.ai
|
|
874
|
-
: "artificialanalysis.ai
|
|
928
|
+
? `artificialanalysis.ai leaderboard page; assumed ${page.version}`
|
|
929
|
+
: "artificialanalysis.ai leaderboard page"
|
|
875
930
|
log(
|
|
876
|
-
`[model-price-intel] AA
|
|
931
|
+
`[model-price-intel] AA leaderboard page (v${page.version}${page.versionAssumed ? " assumed" : ""}): ${coverage.live} live + ${coverage.snapshot} snapshot scores`,
|
|
877
932
|
)
|
|
878
933
|
return {
|
|
879
934
|
scores,
|
|
@@ -883,10 +938,10 @@ export async function fetchAAScores(options?: AAFetchOptions): Promise<AAFetched
|
|
|
883
938
|
coverage,
|
|
884
939
|
}
|
|
885
940
|
}
|
|
886
|
-
log("[model-price-intel] AA
|
|
941
|
+
log("[model-price-intel] AA leaderboard page contained no model records")
|
|
887
942
|
} catch (error) {
|
|
888
943
|
log(
|
|
889
|
-
`[model-price-intel] AA
|
|
944
|
+
`[model-price-intel] AA leaderboard page failed (${error instanceof Error ? error.message : String(error)}); using bundled snapshot`,
|
|
890
945
|
)
|
|
891
946
|
}
|
|
892
947
|
|
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,11 +38,12 @@
|
|
|
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
|
-
const CACHE_KEY = "aa-scores/
|
|
46
|
+
const CACHE_KEY = "aa-scores/v4"
|
|
42
47
|
const DEFAULT_REFRESH_HOURS = 12
|
|
43
48
|
const MIN_REFRESH_HOURS = 1
|
|
44
49
|
/** 596 h stays below the 2^31-1 ms setTimeout/setInterval ceiling. */
|
|
@@ -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
|
+
}
|