opencode-model-price-intel 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 — currently v5.0), so this runs whenever the plugin refreshes,
6
- * with a cached copy kept in plugin storage and periodic refresh while the
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-list page (https://artificialanalysis.ai/models), whose
19
- * embedded dataset (`initialModels` records in the RSC payload) carries the
20
- * `intelligenceIndex` score per model variant. Requires no key.
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 major index version matches the live data.
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 5.0 was assumed. */
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) return `${match[1]}.${match[2] ?? "0"}`
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+)?/i.test(value.trim())) return undefined
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 fallback only reads the model-list page, but filter these
182
- * explicitly as a second line of defence.
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+)?)"?/i,
342
- /"intelligence_index_version"\s*:\s*"?v?(\d+(?:\.\d+)?)"?/i,
343
- /"indexVersion"\s*:\s*"?v?(\d+(?:\.\d+)?)"?/i,
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
- (existing) => existing === entry || (existing.name === entry.name && existing.score === entry.score),
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
- // ID and display name disagreeing is ambiguous: show AA n/a rather than guess.
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 (majorOf(version) === majorOf(AA_SNAPSHOT_VERSION)) {
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 has the same major version. Live non-null scores always
557
- * win; snapshot-derived entries carry a `[snapshot]` note.
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-list page (no key needed).
803
- * Only the model-list page is used: the evaluations page contains company
804
- * aggregate rows and must never be treated as a model source.
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 model page returned ${response.status}`)
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 model-list page, then falls back to the bundled
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 page fallback's abort budget.
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
- log("[model-price-intel] AA API returned no usable model records; falling back to the model page")
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 model page`,
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 model pages; assumed ${page.version} (page)`
874
- : "artificialanalysis.ai model pages"
928
+ ? `artificialanalysis.ai leaderboard page; assumed ${page.version}`
929
+ : "artificialanalysis.ai leaderboard page"
875
930
  log(
876
- `[model-price-intel] AA model page (v${page.version}${page.versionAssumed ? " assumed" : ""}): ${coverage.live} live + ${coverage.snapshot} snapshot scores`,
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 model page contained no model records")
941
+ log("[model-price-intel] AA leaderboard page contained no model records")
887
942
  } catch (error) {
888
943
  log(
889
- `[model-price-intel] AA model page failed (${error instanceof Error ? error.message : String(error)}); using bundled snapshot`,
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. `/model-pricing` (alias `/prices`) prints the complete
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 { formatCost, pricingSegment, type CostFormat, type CostTier } from "./pricing.ts"
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/v3"
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
- interface TableModel {
57
- readonly id: string
58
- readonly modelID?: string
61
+ function modelKey(model: {
59
62
  readonly providerID: string
60
- readonly name: string
61
- readonly cost: readonly CostTier[] | undefined
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
- // 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> => {
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
- 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
- })
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; the `/model-pricing` command lists every tier
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
+ }