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/src/aa.ts ADDED
@@ -0,0 +1,911 @@
1
+ /**
2
+ * Live Artificial Analysis Intelligence Index fetcher.
3
+ *
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.
8
+ *
9
+ * Two sources, in order:
10
+ *
11
+ * 1. The official Free Data API
12
+ * (https://artificialanalysis.ai/api/v2/language/models/free), when an API
13
+ * key is available (`options.aaApiKey` or the
14
+ * ARTIFICIALANALYSIS_API_KEY environment variable). The free endpoint
15
+ * accepts any valid key (Free, Pro or Commercial). Attribution:
16
+ * https://artificialanalysis.ai/
17
+ *
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.
21
+ *
22
+ * 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.
24
+ */
25
+
26
+ import {
27
+ AA_INDEX_VERSION,
28
+ AA_SNAPSHOT,
29
+ AA_SNAPSHOT_ALIASES,
30
+ AA_SNAPSHOT_CAPTURED_AT,
31
+ AA_SNAPSHOT_DATE,
32
+ AA_SNAPSHOT_VERSION,
33
+ type AAScore,
34
+ } from "./aa-snapshot.ts"
35
+
36
+ export const AA_MODELS_URL = "https://artificialanalysis.ai/models"
37
+ /** Free-tier endpoint: accepts any valid API key; Pro-only route is not needed. */
38
+ export const AA_API_URL = "https://artificialanalysis.ai/api/v2/language/models/free"
39
+ /** Hard safety cap on API pagination (the Free quota is 100 requests/day). */
40
+ export const AA_MAX_API_PAGES = 10
41
+
42
+ const MAX_ATTEMPTS = 3
43
+ const DEFAULT_TIMEOUT_MS = 15_000
44
+ const DEFAULT_RETRY_DELAY_MS = 200
45
+ const MAX_RETRY_DELAY_MS = 5_000
46
+
47
+ export interface AARecord {
48
+ slug: string
49
+ name: string
50
+ score: number | null
51
+ estimated: boolean
52
+ }
53
+
54
+ /** Where the numeric scores in a merged result came from. */
55
+ export interface AACoverage {
56
+ live: number
57
+ snapshot: number
58
+ total: number
59
+ }
60
+
61
+ export interface AAFetched {
62
+ /** Keyed by normalized base name / model ID (see `matchKey`). */
63
+ scores: Readonly<Record<string, AAScore>>
64
+ version: string
65
+ source: string
66
+ fetchedAt: string
67
+ /** Counts of numeric scores supplied by the live source vs the snapshot. */
68
+ coverage?: AACoverage
69
+ }
70
+
71
+ export type AALog = (message: string) => void
72
+
73
+ export interface AAFetchOptions {
74
+ apiKey?: string
75
+ fetchImpl?: typeof fetch
76
+ /** Per-request timeout. Each source/attempt gets a fresh controller. */
77
+ timeoutMs?: number
78
+ /** Diagnostics sink (defaults to console.warn). Never receives the API key. */
79
+ log?: AALog
80
+ /** Base backoff for 429/5xx retries; exposed for tests. */
81
+ retryDelayMs?: number
82
+ }
83
+
84
+ export interface AAAPIFetch {
85
+ records: AARecord[]
86
+ version: string
87
+ pages: number
88
+ }
89
+
90
+ export interface AAPageFetch {
91
+ records: AARecord[]
92
+ version: string
93
+ /** True when the page did not publish a version and 5.0 was assumed. */
94
+ versionAssumed: boolean
95
+ }
96
+
97
+ interface SourceOptions {
98
+ timeoutMs: number
99
+ log: AALog
100
+ retryDelayMs: number
101
+ }
102
+
103
+ /** Decode the escaped JSON-string payloads in `self.__next_f.push([1,"..."])`. */
104
+ export function decodeRSC(html: string): string {
105
+ let result = ""
106
+ const re = /self\.__next_f\.push\(\s*\[\s*1\s*,\s*"/g
107
+ let match: RegExpExecArray | null
108
+ while ((match = re.exec(html)) !== null) {
109
+ let i = re.lastIndex // just after the opening quote
110
+ let raw = ""
111
+ while (i < html.length) {
112
+ const ch = html[i]
113
+ if (ch === "\\") {
114
+ raw += html.slice(i, i + 2)
115
+ i += 2
116
+ continue
117
+ }
118
+ if (ch === '"') break
119
+ raw += ch
120
+ i++
121
+ }
122
+ try {
123
+ result += JSON.parse(`"${raw}"`) as string
124
+ } catch {
125
+ result += raw
126
+ }
127
+ re.lastIndex = i + 1
128
+ }
129
+ return result
130
+ }
131
+
132
+ function isObject(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value)
134
+ }
135
+
136
+ /** Coerce finite, non-negative numbers or numeric strings; reject everything else. */
137
+ function finiteNonNegative(value: unknown): number | null {
138
+ const number =
139
+ typeof value === "number"
140
+ ? value
141
+ : typeof value === "string" && value.trim() !== ""
142
+ ? Number(value)
143
+ : Number.NaN
144
+ return Number.isFinite(number) && number >= 0 ? number : null
145
+ }
146
+
147
+ /** Normalize AA's numeric (`5` / `5.1`) or string (`"5.0"`, `"v5"`) version. */
148
+ export function normalizeVersion(value: unknown, fallback = AA_INDEX_VERSION): string {
149
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
150
+ const text = String(value)
151
+ return text.includes(".") ? text : `${text}.0`
152
+ }
153
+ if (typeof value === "string") {
154
+ const match = value.trim().match(/^v?(\d+)(?:\.(\d+))?/i)
155
+ if (match) return `${match[1]}.${match[2] ?? "0"}`
156
+ }
157
+ return fallback
158
+ }
159
+
160
+ function optionalVersion(value: unknown): string | undefined {
161
+ if (value === undefined || value === null || value === "") return undefined
162
+ 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
164
+ return normalizeVersion(value)
165
+ }
166
+
167
+ function majorOf(version: string): string {
168
+ return version.split(".")[0] ?? version
169
+ }
170
+
171
+ function countNumericScores(scores: Readonly<Record<string, AAScore>>): number {
172
+ let count = 0
173
+ for (const entry of Object.values(scores)) if (entry.score != null) count++
174
+ return count
175
+ }
176
+
177
+ // ------------------------------------------------------------------ page
178
+
179
+ /**
180
+ * 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.
183
+ */
184
+ const COMPANY_AGGREGATES = new Set([
185
+ "google",
186
+ "googledeepmind",
187
+ "anthropic",
188
+ "openai",
189
+ "meta",
190
+ "metaai",
191
+ "kimi",
192
+ "moonshot",
193
+ "moonshotai",
194
+ "spacexai",
195
+ "xai",
196
+ "alibaba",
197
+ "alibabacloud",
198
+ "deepseek",
199
+ "mistral",
200
+ "mistralai",
201
+ "nvidia",
202
+ "microsoft",
203
+ "amazon",
204
+ "apple",
205
+ "bytedance",
206
+ "baidu",
207
+ "tencent",
208
+ "minimax",
209
+ "stepfun",
210
+ "zai",
211
+ ])
212
+
213
+ function keyForCompany(value: unknown): string {
214
+ return typeof value === "string" ? value.toLowerCase().replace(/[^a-z0-9]/g, "") : ""
215
+ }
216
+
217
+ function isCompanyAggregate(record: Record<string, unknown>): boolean {
218
+ if (COMPANY_AGGREGATES.has(keyForCompany(record.name))) return true
219
+ if (COMPANY_AGGREGATES.has(keyForCompany(record.slug))) return true
220
+ // Brand rows carry a colour/logo but none of the model-only fields.
221
+ const looksLikeBrand = "color" in record || "logo" in record
222
+ const looksLikeModel =
223
+ "shortName" in record ||
224
+ "suffix" in record ||
225
+ "releaseDate" in record ||
226
+ "effort" in record ||
227
+ "release" in record
228
+ return looksLikeBrand && !looksLikeModel
229
+ }
230
+
231
+ /**
232
+ * Extract balanced `{...}` JSON objects starting at `start`, respecting
233
+ * escaped quotes inside strings. This keeps schema parsing independent of
234
+ * field order and record length.
235
+ */
236
+ function balancedObjectAt(text: string, start: number): string | undefined {
237
+ if (text[start] !== "{") return undefined
238
+ let depth = 0
239
+ let inString = false
240
+ let escaped = false
241
+ for (let i = start; i < text.length; i++) {
242
+ const ch = text[i]
243
+ if (inString) {
244
+ if (escaped) {
245
+ escaped = false
246
+ } else if (ch === "\\") {
247
+ escaped = true
248
+ } else if (ch === '"') {
249
+ inString = false
250
+ }
251
+ continue
252
+ }
253
+ if (ch === '"') {
254
+ inString = true
255
+ } else if (ch === "{") {
256
+ depth++
257
+ } else if (ch === "}") {
258
+ depth--
259
+ if (depth === 0) return text.slice(start, i + 1)
260
+ }
261
+ }
262
+ return undefined
263
+ }
264
+
265
+ function recordScore(record: Record<string, unknown>): number | null {
266
+ // Model-list records use `intelligenceIndex`; accept the API-shaped
267
+ // evaluation field too so both payloads share one parser.
268
+ if ("intelligenceIndex" in record) return finiteNonNegative(record.intelligenceIndex)
269
+ const evaluations = isObject(record.evaluations) ? record.evaluations : undefined
270
+ return finiteNonNegative(evaluations?.artificial_analysis_intelligence_index)
271
+ }
272
+
273
+ function preferRecord(existing: AARecord | undefined, candidate: AARecord): boolean {
274
+ if (!existing) return true
275
+ if (existing.score === null && candidate.score !== null) return true
276
+ if (existing.score !== null && candidate.score !== null) {
277
+ const priority = variantPriority(candidate.name) - variantPriority(existing.name)
278
+ if (priority > 0) return true
279
+ if (priority < 0) return false
280
+ }
281
+ if (existing.estimated && !candidate.estimated && candidate.score !== null) return true
282
+ return false
283
+ }
284
+
285
+ /**
286
+ * Extract `initialModels`-style records from a decoded RSC payload. Records
287
+ * are parsed as balanced JSON objects, so escaped quotes, reordered fields and
288
+ * long records are handled; malformed/negative scores become `null` rather
289
+ * than `NaN` or a borrowed sibling score.
290
+ */
291
+ export function parseAARecords(payload: string): AARecord[] {
292
+ const decoded = payload.includes("self.__next_f.push") ? decodeRSC(payload) : payload
293
+ const records = new Map<string, AARecord>()
294
+ // Any object start; JSON.parse tells us whether it is a real record. This
295
+ // keeps field order and whitespace from mattering.
296
+ const starts = /\{\s*(?="[A-Za-z_][A-Za-z0-9_]*"\s*:)/g
297
+ let start: RegExpExecArray | null
298
+ while ((start = starts.exec(decoded)) !== null) {
299
+ const raw = balancedObjectAt(decoded, start.index)
300
+ if (!raw) continue
301
+ let parsed: unknown
302
+ try {
303
+ parsed = JSON.parse(raw)
304
+ } catch {
305
+ continue // invalid JSON (NaN, 1.2.3, unescaped junk) never scores
306
+ }
307
+ if (!isObject(parsed)) continue
308
+ const hasScoreField = "intelligenceIndex" in parsed || "intelligenceIndexIsEstimated" in parsed
309
+ // Keep a model record with a missing score (AA n/a) instead of dropping it;
310
+ // nested `release` objects and unrelated payloads lack these model fields.
311
+ const hasModelShape =
312
+ "shortName" in parsed ||
313
+ "suffix" in parsed ||
314
+ "releaseDate" in parsed ||
315
+ "effort" in parsed
316
+ const hasModelIdentity =
317
+ typeof parsed.id === "string" &&
318
+ typeof parsed.slug === "string" &&
319
+ typeof parsed.name === "string"
320
+ if (!hasScoreField && !hasModelShape && !hasModelIdentity) continue
321
+ if (isCompanyAggregate(parsed)) continue
322
+ const name = typeof parsed.name === "string" ? parsed.name.trim() : ""
323
+ const slug = typeof parsed.slug === "string" ? parsed.slug.trim() : ""
324
+ if (!name && !slug) continue
325
+ const record: AARecord = {
326
+ slug: slug || name,
327
+ name: name || slug,
328
+ score: recordScore(parsed),
329
+ estimated: parsed.intelligenceIndexIsEstimated === true,
330
+ }
331
+ const key = slug || name
332
+ if (preferRecord(records.get(key), record)) records.set(key, record)
333
+ }
334
+ return [...records.values()]
335
+ }
336
+
337
+ /** Version embedded in the public page payload, if it publishes one. */
338
+ export function parsePageVersion(payload: string): string | undefined {
339
+ const decoded = payload.includes("self.__next_f.push") ? decodeRSC(payload) : payload
340
+ 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,
344
+ ]
345
+ for (const pattern of patterns) {
346
+ const match = decoded.match(pattern)
347
+ if (match) return normalizeVersion(match[1])
348
+ }
349
+ return undefined
350
+ }
351
+
352
+ // --------------------------------------------------------------- matching
353
+
354
+ /**
355
+ * Normalize a model name or ID into a comparison key.
356
+ *
357
+ * Parenthesized qualifiers ("(Max)"), the OpenCode "Free" tier marker
358
+ * ("GLM-5.3-Flash Free", "glm-5.3-flash-free" -> "glm53flash"), and all
359
+ * punctuation are removed so an OpenCode catalog entry finds its Artificial
360
+ * Analysis counterpart. A trailing "free" is only removed as a whole word, so
361
+ * names such as "Carefree" survive intact.
362
+ */
363
+ export function matchKey(nameOrId: string): string {
364
+ return nameOrId
365
+ .toLowerCase()
366
+ .replace(/\(.*?\)/g, "")
367
+ .replace(/\bfree\b/g, "")
368
+ .replace(/[^a-z0-9]/g, "")
369
+ }
370
+
371
+ /**
372
+ * Prefer the highest-effort variant of a model when AA scores several.
373
+ * Plain/date entries rank below every explicit effort, and Max is highest.
374
+ */
375
+ export function variantPriority(name: string): number {
376
+ const qualifier = (name.match(/\(([^)]*)\)/)?.[1] ?? "").toLowerCase()
377
+ if (/\bmax\b/.test(qualifier)) return 6
378
+ if (/\bxhigh\b/.test(qualifier)) return 5
379
+ if (/\bhigh\b/.test(qualifier)) return 4
380
+ if (/\bmedium\b/.test(qualifier)) return 3
381
+ if (/\blow\b/.test(qualifier)) return 2
382
+ return 1 // plain entries and date qualifiers such as (0902)
383
+ }
384
+
385
+ function variantLabel(name: string): string | undefined {
386
+ const qualifier = name.match(/\(([^)]*)\)/)?.[1]?.trim()
387
+ return qualifier ? qualifier.toLowerCase() : undefined
388
+ }
389
+
390
+ function roundScore(score: number): number {
391
+ return Math.round(score * 10) / 10
392
+ }
393
+
394
+ /**
395
+ * Build a lookup keyed by `matchKey`. When a base name has several effort
396
+ * variants, order is deterministic: Max > Xhigh > High > Medium > Low >
397
+ * plain/date, with scored entries before unscored ties. If the highest-effort
398
+ * entry is unscored, the result is `null` (AA n/a) with a note naming the
399
+ * lower-effort score rather than silently presenting a low-effort number.
400
+ */
401
+ export function indexRecords(records: readonly AARecord[]): Record<string, AAScore> {
402
+ const grouped = new Map<string, AARecord[]>()
403
+ for (const record of records) {
404
+ const key = matchKey(record.name) || matchKey(record.slug)
405
+ if (!key) continue
406
+ const list = grouped.get(key) ?? []
407
+ list.push(record)
408
+ grouped.set(key, list)
409
+ }
410
+ const out: Record<string, AAScore> = {}
411
+ for (const [key, list] of grouped) {
412
+ const sorted = [...list].sort((a, b) => {
413
+ const priority = variantPriority(b.name) - variantPriority(a.name)
414
+ if (priority !== 0) return priority
415
+ const scored = Number(a.score === null) - Number(b.score === null)
416
+ if (scored !== 0) return scored
417
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
418
+ })
419
+ const best = sorted[0]
420
+ const label = variantLabel(best.name)
421
+ if (best.score !== null) {
422
+ out[key] = {
423
+ score: best.score,
424
+ name: best.name,
425
+ note: label && label !== "max" ? `AA variant: (${label})` : undefined,
426
+ }
427
+ continue
428
+ }
429
+ const lower = sorted.find((record) => record.score !== null)
430
+ if (lower) {
431
+ const lowerLabel = variantLabel(lower.name) ?? "plain"
432
+ out[key] = {
433
+ score: null,
434
+ name: best.name,
435
+ note: `Not scored at (${label ?? "plain"}); lower-effort (${lowerLabel}) score ${roundScore(lower.score!)} not used`,
436
+ }
437
+ } else {
438
+ out[key] = {
439
+ score: null,
440
+ name: best.name,
441
+ note: label && label !== "max" ? `AA variant: (${label})` : undefined,
442
+ }
443
+ }
444
+ }
445
+ return out
446
+ }
447
+
448
+ /**
449
+ * Look up the AA entry for an OpenCode model by model ID or display name.
450
+ *
451
+ * Matching is deliberately strict to avoid false positives between sibling
452
+ * models (e.g. "Kimi K3" must never pick up "Kimi K3.1 Pro"'s score). Exact
453
+ * keys win; a checkpoint/version suffix is considered only when there is a
454
+ * single candidate. Generic family names ("Grok", "Claude") never absorb a
455
+ * digit-suffixed sibling; only a whole `X.0` release such as Beam 1.0 may
456
+ * stand in for "Beam".
457
+ */
458
+ export function lookupScore(
459
+ index: Readonly<Record<string, AAScore>>,
460
+ modelID: string,
461
+ modelName: string,
462
+ ): AAScore | undefined {
463
+ const keys = [...new Set([matchKey(modelID), matchKey(modelName)].filter((key) => key.length > 0))]
464
+ const exactEntries: AAScore[] = []
465
+ for (const key of keys) {
466
+ const entry = index[key]
467
+ if (!entry) continue
468
+ const duplicate = exactEntries.some(
469
+ (existing) => existing === entry || (existing.name === entry.name && existing.score === entry.score),
470
+ )
471
+ if (!duplicate) exactEntries.push(entry)
472
+ }
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]
476
+ if (exact?.score != null) return exact
477
+
478
+ const candidates = new Map<string, AAScore>()
479
+ for (const key of keys) {
480
+ if (key.length < 4) continue
481
+ const keyEndsInDigit = /\d$/.test(key)
482
+ for (const [candidate, entry] of Object.entries(index)) {
483
+ if (!candidate.startsWith(key) || candidate === key) continue
484
+ const suffix = candidate.slice(key.length)
485
+ if (!/^\d{1,4}$/.test(suffix)) continue
486
+ if (keyEndsInDigit && suffix.length < 3) continue
487
+ // A generic family name only stands in for a whole major release
488
+ // ("Beam" -> "Beam 1.0"). "Grok" must not borrow "Grok 4.7".
489
+ if (!keyEndsInDigit && suffix.length < 3 && !suffix.endsWith("0")) continue
490
+ candidates.set(candidate, entry)
491
+ }
492
+ }
493
+ // Multiple possible siblings are ambiguous: prefer no score over a guess.
494
+ if (candidates.size > 1) return undefined
495
+ const suffixed = [...candidates.values()][0]
496
+ if (suffixed?.score != null) return suffixed
497
+ return exact ?? suffixed
498
+ }
499
+
500
+ // --------------------------------------------------------------- snapshot
501
+
502
+ let normalizedSnapshot: Readonly<Record<string, AAScore>> | undefined
503
+
504
+ /**
505
+ * The bundled snapshot is keyed by `matchKey(name)`. Re-normalize defensively
506
+ * and add documented OpenCode-ID aliases (e.g. `claude-haiku-4-5`). The shared
507
+ * index is frozen so callers cannot poison it for the rest of the process.
508
+ */
509
+ function snapshotIndex(): Readonly<Record<string, AAScore>> {
510
+ if (normalizedSnapshot) return normalizedSnapshot
511
+ const out: Record<string, AAScore> = {}
512
+ const add = (key: string, entry: AAScore) => {
513
+ const normalized = matchKey(key) || key
514
+ const existing = out[normalized]
515
+ if (!existing || (existing.score == null && entry.score != null)) {
516
+ out[normalized] = Object.freeze({ ...entry })
517
+ }
518
+ }
519
+ for (const [key, entry] of Object.entries(AA_SNAPSHOT)) add(key, entry)
520
+ for (const [alias, target] of Object.entries(AA_SNAPSHOT_ALIASES)) {
521
+ const entry = out[matchKey(target) || target]
522
+ // Reuse the canonical entry object so ID/name lookups are not seen as two
523
+ // distinct candidates by `lookupScore`.
524
+ if (entry) out[matchKey(alias) || alias] = entry
525
+ }
526
+ normalizedSnapshot = Object.freeze(out)
527
+ return normalizedSnapshot
528
+ }
529
+
530
+ function mergeWithSnapshot(
531
+ index: Readonly<Record<string, AAScore>>,
532
+ version: string,
533
+ ): { scores: Record<string, AAScore>; coverage: AACoverage } {
534
+ const merged: Record<string, AAScore> = { ...index }
535
+ let live = 0
536
+ for (const entry of Object.values(index)) if (entry.score != null) live++
537
+ let snapshot = 0
538
+ if (majorOf(version) === majorOf(AA_SNAPSHOT_VERSION)) {
539
+ for (const [key, entry] of Object.entries(snapshotIndex())) {
540
+ const existing = merged[key]
541
+ if (existing?.score != null) continue
542
+ if (existing && entry.score == null) continue
543
+ merged[key] = {
544
+ score: entry.score,
545
+ name: existing?.name ?? entry.name,
546
+ note: entry.note ? `${entry.note} [snapshot]` : "[snapshot]",
547
+ }
548
+ if (entry.score != null) snapshot++
549
+ }
550
+ }
551
+ return { scores: merged, coverage: { live, snapshot, total: live + snapshot } }
552
+ }
553
+
554
+ /**
555
+ * 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.
558
+ */
559
+ export function withSnapshotFallback(
560
+ index: Readonly<Record<string, AAScore>>,
561
+ version: string | number = AA_INDEX_VERSION,
562
+ ): Record<string, AAScore> {
563
+ return mergeWithSnapshot(index, normalizeVersion(version)).scores
564
+ }
565
+
566
+ // ------------------------------------------------------------------ fetch
567
+
568
+ function sleep(ms: number): Promise<void> {
569
+ return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve()
570
+ }
571
+
572
+ function isRetryableStatus(status: number): boolean {
573
+ return status === 429 || status >= 500
574
+ }
575
+
576
+ function parseRetryAfter(header: string | null | undefined): number | undefined {
577
+ if (!header) return undefined
578
+ const seconds = Number(header)
579
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000
580
+ const date = Date.parse(header)
581
+ if (Number.isFinite(date)) return Math.max(0, date - Date.now())
582
+ return undefined
583
+ }
584
+
585
+ function isAbortError(error: unknown): boolean {
586
+ return error instanceof Error && (error.name === "AbortError" || /abort/i.test(error.message))
587
+ }
588
+
589
+ /** One request, one fresh controller/timer. Never share an abort signal. */
590
+ async function fetchOnce(
591
+ fetchImpl: typeof fetch,
592
+ input: string,
593
+ init: RequestInit,
594
+ timeoutMs: number,
595
+ ): Promise<Response> {
596
+ const controller = new AbortController()
597
+ let timer: ReturnType<typeof setTimeout> | undefined
598
+ // Race the request as well as aborting it, so even a fetch implementation
599
+ // that ignores the signal cannot hold up the next source forever.
600
+ const timeout = new Promise<never>((_resolve, reject) => {
601
+ timer = setTimeout(() => {
602
+ controller.abort()
603
+ const error = new Error(`Artificial Analysis request timed out after ${timeoutMs}ms`)
604
+ error.name = "AbortError"
605
+ reject(error)
606
+ }, timeoutMs)
607
+ })
608
+ try {
609
+ // Preserve a caller-provided signal (plugin cleanup) while still applying
610
+ // this attempt's own timeout controller.
611
+ const signal = init.signal
612
+ ? AbortSignal.any([init.signal, controller.signal])
613
+ : controller.signal
614
+ return await Promise.race([
615
+ fetchImpl(input, { ...init, signal }),
616
+ timeout,
617
+ ])
618
+ } finally {
619
+ if (timer !== undefined) clearTimeout(timer)
620
+ }
621
+ }
622
+
623
+ /** Retry 429/5xx a bounded number of times; honor Retry-After when present. */
624
+ async function fetchWithRetry(
625
+ fetchImpl: typeof fetch,
626
+ input: string,
627
+ init: RequestInit,
628
+ options: SourceOptions,
629
+ ): Promise<Response> {
630
+ for (let attempt = 1; ; attempt++) {
631
+ try {
632
+ const response = await fetchOnce(fetchImpl, input, init, options.timeoutMs)
633
+ if (response.ok || !isRetryableStatus(response.status) || attempt >= MAX_ATTEMPTS) {
634
+ return response
635
+ }
636
+ const retryAfter = parseRetryAfter(response.headers?.get?.("retry-after"))
637
+ const requestedDelay = retryAfter ?? options.retryDelayMs * 2 ** (attempt - 1)
638
+ if (requestedDelay > MAX_RETRY_DELAY_MS) {
639
+ options.log(
640
+ `[model-price-intel] Artificial Analysis returned ${response.status} with Retry-After ${requestedDelay}ms; not retrying (cap ${MAX_RETRY_DELAY_MS}ms)`,
641
+ )
642
+ return response
643
+ }
644
+ options.log(
645
+ `[model-price-intel] Artificial Analysis returned ${response.status}; retry ${attempt}/${MAX_ATTEMPTS - 1} in ${requestedDelay}ms`,
646
+ )
647
+ await sleep(requestedDelay)
648
+ } catch (error) {
649
+ if (isAbortError(error) || attempt >= MAX_ATTEMPTS) throw error
650
+ const delay = Math.min(options.retryDelayMs * 2 ** (attempt - 1), MAX_RETRY_DELAY_MS)
651
+ options.log(
652
+ `[model-price-intel] Artificial Analysis request failed (${error instanceof Error ? error.message : String(error)}); retry ${attempt}/${MAX_ATTEMPTS - 1} in ${delay}ms`,
653
+ )
654
+ await sleep(delay)
655
+ }
656
+ }
657
+ }
658
+
659
+ function pageFromNext(next: unknown): number | undefined {
660
+ if (typeof next === "number" && Number.isInteger(next) && next > 0) return next
661
+ if (typeof next !== "string" || next.trim() === "") return undefined
662
+ if (/^\d+$/.test(next.trim())) return Number(next)
663
+ try {
664
+ const url = new URL(next, AA_API_URL)
665
+ const page = Number(url.searchParams.get("page"))
666
+ if (Number.isInteger(page) && page > 0) return page
667
+ } catch {
668
+ // ignore malformed next values
669
+ }
670
+ return undefined
671
+ }
672
+
673
+ function integerOrUndefined(value: unknown): number | undefined {
674
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined
675
+ }
676
+
677
+ function apiScore(item: Record<string, unknown>): number | null {
678
+ const evaluations = isObject(item.evaluations) ? item.evaluations : undefined
679
+ for (const candidate of [
680
+ item.artificial_analysis_intelligence_index,
681
+ evaluations?.artificial_analysis_intelligence_index,
682
+ ]) {
683
+ const score = finiteNonNegative(candidate)
684
+ if (score !== null) return score
685
+ }
686
+ return null
687
+ }
688
+
689
+ /**
690
+ * Fetch scores from the Free Data API. Pages until `has_more`/`total_pages`/
691
+ * `next` says to stop, with a hard cap. A later-page failure keeps the records
692
+ * already collected instead of discarding page 1. Exported for tests and the
693
+ * snapshot regeneration script.
694
+ */
695
+ export async function fetchFromAPI(
696
+ apiKey: string,
697
+ fetchImpl: typeof fetch = fetch,
698
+ options?: Partial<SourceOptions>,
699
+ ): Promise<AAAPIFetch> {
700
+ const settings: SourceOptions = {
701
+ timeoutMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
702
+ log: options?.log ?? ((message) => console.warn(message)),
703
+ retryDelayMs: options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
704
+ }
705
+ const bySlug = new Map<string, AARecord>()
706
+ let version = AA_INDEX_VERSION
707
+ let page = 1
708
+ let pages = 0
709
+
710
+ while (page >= 1 && page <= AA_MAX_API_PAGES) {
711
+ const url = `${AA_API_URL}?page=${page}`
712
+ pages++
713
+ let response: Response
714
+ try {
715
+ response = await fetchWithRetry(
716
+ fetchImpl,
717
+ url,
718
+ { headers: { "x-api-key": apiKey } },
719
+ settings,
720
+ )
721
+ } catch (error) {
722
+ if (page === 1) throw error
723
+ settings.log(
724
+ `[model-price-intel] Artificial Analysis API page ${page} failed (${error instanceof Error ? error.message : String(error)}); keeping ${bySlug.size} records from earlier pages`,
725
+ )
726
+ break
727
+ }
728
+ if (!response.ok) {
729
+ if (page === 1) throw new Error(`AA API returned ${response.status}`)
730
+ settings.log(
731
+ `[model-price-intel] Artificial Analysis API page ${page} returned ${response.status}; keeping ${bySlug.size} records from earlier pages`,
732
+ )
733
+ break
734
+ }
735
+
736
+ let body: unknown
737
+ try {
738
+ body = await response.json()
739
+ } catch {
740
+ if (page === 1) throw new Error("AA API returned invalid JSON")
741
+ settings.log(`[model-price-intel] Artificial Analysis API page ${page} was not JSON; keeping earlier pages`)
742
+ break
743
+ }
744
+ if (!isObject(body)) {
745
+ if (page === 1) throw new Error("AA API returned a non-object body")
746
+ settings.log(`[model-price-intel] Artificial Analysis API page ${page} had an invalid shape; keeping earlier pages`)
747
+ break
748
+ }
749
+ if (!Array.isArray(body.data)) {
750
+ if (page === 1) throw new Error("AA API response data is not an array")
751
+ settings.log(`[model-price-intel] Artificial Analysis API page ${page} had no data array; keeping earlier pages`)
752
+ break
753
+ }
754
+
755
+ const pageVersion = optionalVersion(body.intelligence_index_version)
756
+ if (pageVersion) version = pageVersion
757
+
758
+ for (const raw of body.data) {
759
+ if (!isObject(raw)) continue // skip null and non-object items
760
+ const slug =
761
+ typeof raw.slug === "string" && raw.slug !== ""
762
+ ? raw.slug
763
+ : typeof raw.id === "string"
764
+ ? raw.id
765
+ : ""
766
+ const name = typeof raw.name === "string" && raw.name !== "" ? raw.name : slug
767
+ if (!slug && !name) continue
768
+ const record: AARecord = {
769
+ slug: slug || name,
770
+ name: name || slug,
771
+ score: apiScore(raw),
772
+ estimated: false,
773
+ }
774
+ const key = record.slug
775
+ if (preferRecord(bySlug.get(key), record)) bySlug.set(key, record)
776
+ }
777
+
778
+ const pagination = isObject(body.pagination) ? body.pagination : undefined
779
+ const totalPages = integerOrUndefined(pagination?.total_pages)
780
+ const hasMore = typeof pagination?.has_more === "boolean" ? pagination.has_more : undefined
781
+ const nextPage = pageFromNext(pagination?.next)
782
+
783
+ if (body.data.length === 0) break
784
+ if (totalPages !== undefined && page >= totalPages) break
785
+ if (hasMore === false) break
786
+ if (nextPage === undefined && totalPages === undefined && hasMore === undefined) break
787
+ const target = nextPage ?? page + 1
788
+ if (target <= page) break
789
+ if (target > AA_MAX_API_PAGES) {
790
+ settings.log(
791
+ `[model-price-intel] Artificial Analysis pagination stopped at the ${AA_MAX_API_PAGES}-page safety cap`,
792
+ )
793
+ break
794
+ }
795
+ page = target
796
+ }
797
+
798
+ return { records: [...bySlug.values()], version, pages }
799
+ }
800
+
801
+ /**
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.
805
+ */
806
+ export async function fetchFromPages(
807
+ fetchImpl: typeof fetch = fetch,
808
+ options?: Partial<SourceOptions>,
809
+ ): Promise<AAPageFetch> {
810
+ const settings: SourceOptions = {
811
+ timeoutMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
812
+ log: options?.log ?? ((message) => console.warn(message)),
813
+ retryDelayMs: options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
814
+ }
815
+ const response = await fetchWithRetry(fetchImpl, AA_MODELS_URL, {}, settings)
816
+ if (!response.ok) throw new Error(`Artificial Analysis model page returned ${response.status}`)
817
+ const html = await response.text()
818
+ const decoded = decodeRSC(html)
819
+ const records = parseAARecords(decoded)
820
+ const pageVersion = parsePageVersion(decoded)
821
+ return {
822
+ records,
823
+ version: pageVersion ?? AA_INDEX_VERSION,
824
+ versionAssumed: pageVersion === undefined,
825
+ }
826
+ }
827
+
828
+ /**
829
+ * 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
831
+ * snapshot. Every source completes before the snapshot is used, so a stalled
832
+ * API cannot consume the page fallback's abort budget.
833
+ */
834
+ export async function fetchAAScores(options?: AAFetchOptions): Promise<AAFetched> {
835
+ const fetchImpl = options?.fetchImpl ?? fetch
836
+ const apiKey = options?.apiKey ?? process.env.ARTIFICIALANALYSIS_API_KEY
837
+ const log = options?.log ?? ((message) => console.warn(message))
838
+ const settings: SourceOptions = {
839
+ timeoutMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
840
+ log,
841
+ retryDelayMs: options?.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS,
842
+ }
843
+
844
+ if (apiKey) {
845
+ try {
846
+ const { records, version, pages } = await fetchFromAPI(apiKey, fetchImpl, settings)
847
+ if (records.length > 0) {
848
+ const { scores, coverage } = mergeWithSnapshot(indexRecords(records), version)
849
+ log(
850
+ `[model-price-intel] AA API (${pages} page${pages === 1 ? "" : "s"}): ${coverage.live} live + ${coverage.snapshot} snapshot scores`,
851
+ )
852
+ return {
853
+ scores,
854
+ version,
855
+ source: "artificialanalysis.ai Data API",
856
+ fetchedAt: new Date().toISOString(),
857
+ coverage,
858
+ }
859
+ }
860
+ log("[model-price-intel] AA API returned no usable model records; falling back to the model page")
861
+ } catch (error) {
862
+ log(
863
+ `[model-price-intel] AA API failed (${error instanceof Error ? error.message : String(error)}); falling back to the model page`,
864
+ )
865
+ }
866
+ }
867
+
868
+ try {
869
+ const page = await fetchFromPages(fetchImpl, settings)
870
+ if (page.records.length > 0) {
871
+ const { scores, coverage } = mergeWithSnapshot(indexRecords(page.records), page.version)
872
+ const source = page.versionAssumed
873
+ ? `artificialanalysis.ai model pages; assumed ${page.version} (page)`
874
+ : "artificialanalysis.ai model pages"
875
+ log(
876
+ `[model-price-intel] AA model page (v${page.version}${page.versionAssumed ? " assumed" : ""}): ${coverage.live} live + ${coverage.snapshot} snapshot scores`,
877
+ )
878
+ return {
879
+ scores,
880
+ version: page.version,
881
+ source,
882
+ fetchedAt: new Date().toISOString(),
883
+ coverage,
884
+ }
885
+ }
886
+ log("[model-price-intel] AA model page contained no model records")
887
+ } catch (error) {
888
+ log(
889
+ `[model-price-intel] AA model page failed (${error instanceof Error ? error.message : String(error)}); using bundled snapshot`,
890
+ )
891
+ }
892
+
893
+ throw new Error("no Artificial Analysis data could be fetched")
894
+ }
895
+
896
+ /**
897
+ * Snapshot fallback used when live fetching fails. `fetchedAt` is the snapshot
898
+ * capture date, never "now", so the command table cannot present stale data as
899
+ * freshly fetched.
900
+ */
901
+ export function snapshotScores(): AAFetched {
902
+ const scores = Object.freeze({ ...snapshotIndex() })
903
+ const scored = countNumericScores(scores)
904
+ return {
905
+ scores,
906
+ version: AA_SNAPSHOT_VERSION,
907
+ source: `bundled snapshot (artificialanalysis.ai, ${AA_SNAPSHOT_DATE})`,
908
+ fetchedAt: AA_SNAPSHOT_CAPTURED_AT,
909
+ coverage: { live: 0, snapshot: scored, total: scored },
910
+ }
911
+ }