opencode-model-price-intel 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +37 -0
- package/README.md +163 -2
- package/package.json +63 -4
- package/src/aa-snapshot.ts +515 -0
- package/src/aa.ts +911 -0
- package/src/index.ts +411 -0
- package/src/pricing.ts +142 -0
- package/src/rpc.ts +65 -0
- package/src/table.ts +404 -0
- package/src/tui.tsx +207 -0
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
|
+
}
|