catherd-cli 1.1.1 → 1.2.1

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.
Files changed (62) hide show
  1. package/README.md +40 -14
  2. package/catalog/ATTRIBUTION.md +36 -0
  3. package/catalog/models.json +30 -0
  4. package/catalog/scores.json +3006 -194
  5. package/catalog/sources.json +87 -0
  6. package/package.json +1 -1
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +2 -2
  10. package/src/adapters/claude-code/models.ts +2 -1
  11. package/src/domain/bars.ts +119 -0
  12. package/src/domain/calibration.ts +125 -0
  13. package/src/domain/catalog.ts +210 -23
  14. package/src/domain/failover.ts +14 -1
  15. package/src/domain/profile-rules.ts +108 -18
  16. package/src/domain/sources.ts +144 -0
  17. package/src/entry/catalog-command.ts +163 -8
  18. package/src/entry/init-command.ts +72 -2
  19. package/src/entry/mcp/lane-tools.ts +1 -1
  20. package/src/entry/mcp/server.ts +13 -3
  21. package/src/entry/mcp/setup-tools.ts +24 -2
  22. package/src/entry/profile-command.ts +10 -4
  23. package/src/entry/tui/commands.ts +20 -2
  24. package/src/entry/tui/effects.ts +50 -1
  25. package/src/entry/tui/fixtures.ts +31 -3
  26. package/src/entry/tui/profile-edits.ts +17 -9
  27. package/src/entry/tui/profile-tree.ts +2 -2
  28. package/src/entry/tui/state.ts +4 -0
  29. package/src/entry/tui/views/profile-actions.ts +9 -1
  30. package/src/entry/tui/views/profiles.tsx +81 -14
  31. package/src/entry/tui/views/save-dialog.tsx +12 -4
  32. package/src/infra/env.ts +2 -0
  33. package/src/infra/jev-client.ts +88 -40
  34. package/src/infra/paths.ts +2 -0
  35. package/src/infra/sources/arena.ts +87 -0
  36. package/src/infra/sources/artificial-analysis.ts +112 -0
  37. package/src/infra/sources/cache.ts +124 -0
  38. package/src/infra/sources/csv.ts +38 -0
  39. package/src/infra/sources/epoch.ts +82 -0
  40. package/src/infra/sources/http.ts +75 -0
  41. package/src/infra/sources/litellm.ts +58 -0
  42. package/src/infra/sources/models-dev.ts +59 -0
  43. package/src/infra/sources/openrouter-endpoints.ts +52 -0
  44. package/src/infra/sources/openrouter-models.ts +60 -0
  45. package/src/infra/sources/rows.ts +38 -0
  46. package/src/infra/sources/vectara.ts +69 -0
  47. package/src/infra/sources/zip.ts +48 -0
  48. package/src/services/catalog-refresh.ts +200 -0
  49. package/src/services/catalog-service.ts +83 -23
  50. package/src/services/credentials.ts +69 -0
  51. package/src/services/doctor-sources.ts +76 -0
  52. package/src/services/doctor.ts +10 -0
  53. package/src/services/jev-service.ts +14 -41
  54. package/src/services/ports.ts +6 -0
  55. package/src/services/profile-service.ts +8 -6
  56. package/src/services/provenance.ts +115 -0
  57. package/src/services/routing-service.ts +7 -2
  58. package/src/services/run-evidence.ts +86 -0
  59. package/src/services/source-derive.ts +349 -0
  60. package/src/services/source-sync.ts +305 -0
  61. package/src/services/standins.ts +204 -0
  62. package/src/services/treat-likes.ts +84 -0
@@ -0,0 +1,87 @@
1
+ import { effortWord } from "../../domain/sources.ts";
2
+ import { type SourceTransport, sourceGet } from "./http.ts";
3
+ import { isoDay, num, type SourceRow } from "./rows.ts";
4
+
5
+ /** Spec 1.2 §3.1: the Agent Arena boards and WebDev (CC-BY-4.0). */
6
+ export const ARENA_CONFIGS = [
7
+ "agent",
8
+ "agent_task_outcome_explicit",
9
+ "agent_bash_recovery_steps",
10
+ "agent_steerability",
11
+ "agent_tool_hallucination",
12
+ "webdev",
13
+ ] as const;
14
+ export type ArenaConfig = (typeof ARENA_CONFIGS)[number];
15
+
16
+ export const ARENA_PAGE = "https://huggingface.co/datasets/lmarena-ai/leaderboard-dataset";
17
+
18
+ export const arenaUrl = (config: ArenaConfig): string =>
19
+ `https://datasets-server.huggingface.co/rows?dataset=lmarena-ai/leaderboard-dataset&config=${config}&split=latest&length=100`;
20
+
21
+ /** Every config, in parallel; one failure fails the source, which keeps its last answer. */
22
+ export async function fetchArena(t: SourceTransport = {}): Promise<Record<ArenaConfig, unknown>> {
23
+ const got = await Promise.all(
24
+ ARENA_CONFIGS.map(async (c) => [c, (await sourceGet(arenaUrl(c), t)).json()]),
25
+ );
26
+ return Object.fromEntries(got) as Record<ArenaConfig, unknown>;
27
+ }
28
+
29
+ /**
30
+ * Spec 1.2 §3.4: an Arena model name as `<name>#<effort>`: the effort from a `(Max)`/`(xHigh)`/`(High)`
31
+ * suffix, or from a trailing `-max`/`-xhigh`/`-high` (WebDev's ids). A harness note such as
32
+ * `(codex-harness)` is dropped; any other parenthesis stays part of the name.
33
+ */
34
+ export function arenaRung(name: string): string {
35
+ let rest = name.replace(/\s*\([^()]*harness\)/gi, "").trim();
36
+ let effort: string | null = null;
37
+ rest = rest
38
+ .replace(/\s*\(([^()]*)\)/g, (whole, inner: string) => {
39
+ const e = effort === null ? effortWord(inner) : null;
40
+ if (e === null) return whole;
41
+ effort = e;
42
+ return "";
43
+ })
44
+ .trim();
45
+ if (effort === null) {
46
+ const m = /^(.*)-([A-Za-z]+)$/.exec(rest);
47
+ const e = m ? effortWord(m[2] as string) : null;
48
+ if (m && e) {
49
+ effort = e;
50
+ rest = m[1] as string;
51
+ }
52
+ }
53
+ return effort === null ? rest : `${rest}#${effort}`;
54
+ }
55
+
56
+ interface ArenaRow {
57
+ model_name?: unknown;
58
+ score?: unknown;
59
+ rating?: unknown;
60
+ leaderboard_publish_date?: unknown;
61
+ }
62
+
63
+ /**
64
+ * One row per model and config: `field` is the config, `value` the agent boards' `score` (net improvement) or
65
+ * WebDev's `rating`, dated by the board's publish date.
66
+ */
67
+ export function parseArena(raw: Record<string, unknown>, fetchedAt: string): SourceRow[] {
68
+ const rows: SourceRow[] = [];
69
+ for (const config of ARENA_CONFIGS) {
70
+ const list = (raw[config] as { rows?: { row?: ArenaRow }[] } | undefined)?.rows;
71
+ if (!Array.isArray(list)) continue;
72
+ for (const { row } of list) {
73
+ if (!row || typeof row.model_name !== "string") continue;
74
+ const value = num(config === "webdev" ? row.rating : row.score);
75
+ if (value === null) continue;
76
+ const published = typeof row.leaderboard_publish_date === "string" ? row.leaderboard_publish_date : "";
77
+ rows.push({
78
+ rung: arenaRung(row.model_name),
79
+ field: config,
80
+ value,
81
+ date: /^\d{4}-\d{2}-\d{2}$/.test(published) ? published : isoDay(fetchedAt),
82
+ url: ARENA_PAGE,
83
+ });
84
+ }
85
+ }
86
+ return rows;
87
+ }
@@ -0,0 +1,112 @@
1
+ import { effortWord } from "../../domain/sources.ts";
2
+ import { rateLimitRemaining, SourceError, type SourceTransport, sourceGet } from "./http.ts";
3
+ import { isoDay, num, type SourceRow } from "./rows.ts";
4
+
5
+ /** Spec 1.2 §3.1: the legacy v2 route a free key reads today, one row per effort. */
6
+ export const AA_MODELS_URL = "https://artificialanalysis.ai/api/v2/data/llms/models";
7
+ /** Spec 1.2 §3.1: the fallback when the first path answers 403 or 404, a page at a time. */
8
+ export const aaFreeUrl = (page: number): string =>
9
+ `https://artificialanalysis.ai/api/v2/language/models/free?page=${page}`;
10
+ export const AA_PAGE = "https://artificialanalysis.ai";
11
+ /** The free path had 4 pages on 2026-09-27; this bounds a runaway pagination. */
12
+ const MAX_FREE_PAGES = 10;
13
+
14
+ /** What catherd caches for Artificial Analysis: the path that answered, each page, the requests left. */
15
+ export interface AaAnswer {
16
+ path: "models" | "free";
17
+ pages: unknown[];
18
+ rateLimitRemaining: number | null;
19
+ }
20
+
21
+ /**
22
+ * Spec 1.2 §3.1: the models path with the user's key; on a 403 or 404 there, the free path's pages until an
23
+ * empty page or the last one. Throws a SourceError (carrying the last answer's headers) on any other failure.
24
+ */
25
+ export async function fetchArtificialAnalysis(key: string, t: SourceTransport = {}): Promise<AaAnswer> {
26
+ const o = { ...t, headers: { "x-api-key": key } };
27
+ try {
28
+ const r = await sourceGet(AA_MODELS_URL, o);
29
+ return { path: "models", pages: [r.json()], rateLimitRemaining: rateLimitRemaining(r.headers) };
30
+ } catch (e) {
31
+ if (!(e instanceof SourceError) || (e.status !== 403 && e.status !== 404)) throw e;
32
+ }
33
+ const pages: unknown[] = [];
34
+ let remaining: number | null = null;
35
+ for (let page = 1; page <= MAX_FREE_PAGES; page++) {
36
+ const r = await sourceGet(aaFreeUrl(page), o);
37
+ remaining = rateLimitRemaining(r.headers) ?? remaining;
38
+ const body = r.json() as { data?: unknown; pagination?: { total_pages?: unknown } } | null;
39
+ if (!Array.isArray(body?.data) || body.data.length === 0) break;
40
+ pages.push(body);
41
+ const total = num(body.pagination?.total_pages);
42
+ if (total !== null && page >= total) break;
43
+ }
44
+ return { path: "free", pages, rateLimitRemaining: remaining };
45
+ }
46
+
47
+ /**
48
+ * Spec 1.2 §9: tests a key with one request to the free path's first page. `ok` on a 200, `refused` on a 401;
49
+ * anything else (a network error, a 5xx) leaves the key `unchecked`.
50
+ */
51
+ export async function testAaKey(
52
+ key: string,
53
+ t: SourceTransport = {},
54
+ ): Promise<{ result: "ok" | "refused" | "unchecked"; error?: string; rateLimitRemaining: number | null }> {
55
+ try {
56
+ const r = await sourceGet(aaFreeUrl(1), {
57
+ attemptMs: 10_000,
58
+ deadlineMs: 20_000,
59
+ ...t,
60
+ headers: { "x-api-key": key },
61
+ });
62
+ return { result: "ok", rateLimitRemaining: rateLimitRemaining(r.headers) };
63
+ } catch (e) {
64
+ const err = e instanceof SourceError ? e : new SourceError(String(e));
65
+ return {
66
+ result: err.status === 401 ? "refused" : "unchecked",
67
+ error: err.message,
68
+ rateLimitRemaining: rateLimitRemaining(err.headers),
69
+ };
70
+ }
71
+ }
72
+
73
+ /** Spec 1.2 §3.4: an AA slug as `<id>#<effort>`: its effort suffix, and `max` for a bare slug. */
74
+ export function aaRung(slug: string): string {
75
+ const m = /^(.*)-([a-z]+)$/.exec(slug);
76
+ const effort = m ? effortWord(m[2] as string) : null;
77
+ return m && effort ? `${m[1]}#${effort}` : `${slug}#max`;
78
+ }
79
+
80
+ const TOP = [
81
+ "median_output_tokens_per_second",
82
+ "median_time_to_first_token_seconds",
83
+ "cost_per_task",
84
+ ] as const;
85
+
86
+ /**
87
+ * One row per slug and number: every evaluation (`livecodebench`, `terminalbench_v2_1`, the indexes, …), every
88
+ * price, speed and `cost_per_task`, named as AA names them. Nulls are left out.
89
+ */
90
+ export function parseArtificialAnalysis(answer: AaAnswer, fetchedAt: string): SourceRow[] {
91
+ const rows: SourceRow[] = [];
92
+ const date = isoDay(fetchedAt);
93
+ for (const page of answer.pages) {
94
+ const data = (page as { data?: unknown } | null)?.data;
95
+ if (!Array.isArray(data)) continue;
96
+ for (const m of data as Record<string, unknown>[]) {
97
+ if (typeof m?.slug !== "string") continue;
98
+ const rung = aaRung(m.slug);
99
+ const url = `${AA_PAGE}/models/${m.slug}`;
100
+ const push = (field: string, v: unknown) => {
101
+ const value = num(v);
102
+ if (value !== null) rows.push({ rung, field, value, date, url });
103
+ };
104
+ for (const group of ["evaluations", "pricing"] as const) {
105
+ const g = m[group];
106
+ if (g && typeof g === "object") for (const [field, v] of Object.entries(g)) push(field, v);
107
+ }
108
+ for (const field of TOP) push(field, m[field]);
109
+ }
110
+ }
111
+ return rows;
112
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { errorMessage } from "../../domain/errors.ts";
5
+ import { type Derived, DerivedSchema, type SourceId } from "../../domain/sources.ts";
6
+ import { tryLock } from "../filelock.ts";
7
+ import { log } from "../log.ts";
8
+ import { sourcesDir } from "../paths.ts";
9
+ import { ensurePrivateDir, readVersioned, writeJsonAtomic, writeTextAtomic } from "../store.ts";
10
+
11
+ // Spec 1.2 §3.3: `<data>/sources/<source>.json` holds a source's last good answer with its fetch time; a
12
+ // failed fetch never touches it. `state.json` holds each source's last attempt and error.
13
+
14
+ export const cachePath = (id: SourceId): string => join(sourcesDir(), `${id}.json`);
15
+
16
+ const CachedSchema = z.looseObject({
17
+ schema: z.literal(1),
18
+ source: z.string(),
19
+ fetchedAt: z.iso.datetime(),
20
+ /** the answer as fetched (Epoch: the CSV tables catherd reads out of its zip) */
21
+ data: z.unknown(),
22
+ meta: z.record(z.string(), z.unknown()).default({}),
23
+ });
24
+ export type Cached = z.infer<typeof CachedSchema>;
25
+
26
+ /** A source's last good answer; null when there is none, or when it cannot be read (the next sync rewrites it). */
27
+ export function readCached(id: SourceId): Cached | null {
28
+ const file = cachePath(id);
29
+ if (!existsSync(file)) return null;
30
+ try {
31
+ return readVersioned(file, CachedSchema, 1);
32
+ } catch (e) {
33
+ log("debug", "sources", { source: id, error: errorMessage(e) });
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Writes a source's answer atomically (mode 600), compact: models.dev alone is 5 MB. */
39
+ export function writeCached(
40
+ id: SourceId,
41
+ data: unknown,
42
+ fetchedAt: number,
43
+ meta: Record<string, unknown> = {},
44
+ ): Cached {
45
+ const c: Cached = { schema: 1, source: id, fetchedAt: new Date(fetchedAt).toISOString(), data, meta };
46
+ writeTextAtomic(cachePath(id), `${JSON.stringify(c)}\n`);
47
+ return c;
48
+ }
49
+
50
+ const StateSchema = z.looseObject({
51
+ schema: z.literal(1),
52
+ sources: z
53
+ .record(
54
+ z.string(),
55
+ z.looseObject({
56
+ /** when the cached answer was fetched (its TTL runs from here); null before a first success */
57
+ fetchedAt: z.iso.datetime().nullable().default(null),
58
+ lastAttemptAt: z.iso.datetime(),
59
+ error: z.string().nullable(),
60
+ /** Artificial Analysis: `x-ratelimit-remaining` of its last answer */
61
+ rateLimitRemaining: z.number().nullable().default(null),
62
+ }),
63
+ )
64
+ .default({}),
65
+ });
66
+ export type SyncState = z.infer<typeof StateSchema>;
67
+
68
+ export const statePath = (): string => join(sourcesDir(), "state.json");
69
+
70
+ /** Each source's last attempt; empty when never synced or unreadable. */
71
+ export function readSyncState(): SyncState {
72
+ const empty: SyncState = { schema: 1, sources: {} };
73
+ if (!existsSync(statePath())) return empty;
74
+ try {
75
+ return readVersioned(statePath(), StateSchema, 1);
76
+ } catch (e) {
77
+ log("debug", "sources", { state: errorMessage(e) });
78
+ return empty;
79
+ }
80
+ }
81
+
82
+ export const writeSyncState = (s: SyncState): void => writeJsonAtomic(statePath(), s);
83
+
84
+ /** Spec 1.2 §3.2: one sync at a time on this machine. Its release, or null while another live process syncs. */
85
+ export function tryLockSync(): (() => void) | null {
86
+ ensurePrivateDir(sourcesDir());
87
+ return tryLock(join(sourcesDir(), "sync"));
88
+ }
89
+
90
+ export const derivedPath = (): string => join(sourcesDir(), "derived.json");
91
+ /** Spec 1.2 §4.2: every fit with its R², used or not. */
92
+ export const calibrationPath = (): string => join(sourcesDir(), "calibration.json");
93
+
94
+ let memo: { mtimeMs: number; size: number; file: string; derived: Derived | null } | null = null;
95
+
96
+ /**
97
+ * What the last sync derived; null when never synced or unreadable (routing then reads the shipped values
98
+ * alone). Read on every catalog load, so it is parsed again only when the file changes.
99
+ */
100
+ export function readDerived(): Derived | null {
101
+ const file = derivedPath();
102
+ let st: { mtimeMs: number; size: number };
103
+ try {
104
+ st = statSync(file);
105
+ } catch {
106
+ return null;
107
+ }
108
+ if (memo?.file === file && memo.mtimeMs === st.mtimeMs && memo.size === st.size) return memo.derived;
109
+ let derived: Derived | null = null;
110
+ try {
111
+ derived = readVersioned(file, DerivedSchema, 1);
112
+ } catch (e) {
113
+ log("debug", "sources", { derived: errorMessage(e) });
114
+ }
115
+ memo = { file, mtimeMs: st.mtimeMs, size: st.size, derived };
116
+ return derived;
117
+ }
118
+
119
+ /** Writes derived.json and calibration.json atomically. */
120
+ export function writeDerived(d: Derived): void {
121
+ memo = null;
122
+ writeTextAtomic(derivedPath(), `${JSON.stringify(d)}\n`);
123
+ writeJsonAtomic(calibrationPath(), { schema: 1, builtAt: d.builtAt, fits: d.fits });
124
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * RFC 4180 CSV as one object per row, keyed by the header row: quoted fields may hold commas, `""` and line
3
+ * breaks (Epoch's notes do); CRLF and LF both end a row; blank lines are skipped.
4
+ */
5
+ export function parseCsv(text: string): Record<string, string>[] {
6
+ const records: string[][] = [];
7
+ let row: string[] = [];
8
+ let field = "";
9
+ let quoted = false;
10
+ for (let i = 0; i < text.length; i++) {
11
+ const ch = text[i] as string;
12
+ if (quoted) {
13
+ if (ch === '"' && text[i + 1] === '"') {
14
+ field += '"';
15
+ i++;
16
+ } else if (ch === '"') quoted = false;
17
+ else field += ch;
18
+ } else if (ch === '"') quoted = true;
19
+ else if (ch === ",") {
20
+ row.push(field);
21
+ field = "";
22
+ } else if (ch === "\n" || ch === "\r") {
23
+ if (ch === "\r" && text[i + 1] === "\n") i++;
24
+ row.push(field);
25
+ records.push(row);
26
+ row = [];
27
+ field = "";
28
+ } else field += ch;
29
+ }
30
+ if (field !== "" || row.length) {
31
+ row.push(field);
32
+ records.push(row);
33
+ }
34
+ const rows = records.filter((r) => r.some((c) => c !== ""));
35
+ const [header, ...body] = rows;
36
+ if (!header) return [];
37
+ return body.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? ""])));
38
+ }
@@ -0,0 +1,82 @@
1
+ import { effortWord } from "../../domain/sources.ts";
2
+ import { parseCsv } from "./csv.ts";
3
+ import { SourceError, type SourceTransport, sourceGet } from "./http.ts";
4
+ import { isoDay, keepHighest, num, type SourceRow, urlOr } from "./rows.ts";
5
+ import { unzip } from "./zip.ts";
6
+
7
+ /** Spec 1.2 §3.1: one CSV per benchmark in a zip (CC-BY; external tables keep their own license). */
8
+ export const EPOCH_URL = "https://epoch.ai/data/benchmark_data.zip";
9
+ export const EPOCH_PAGE = "https://epoch.ai/benchmarks";
10
+
11
+ /**
12
+ * The tables catherd reads, by field: the file, its score column, whether that score is a fraction (read in
13
+ * percent, as the shipped values are), and the column that dates a row, if any.
14
+ */
15
+ export const EPOCH_TABLES = {
16
+ frontiercode: { file: "frontiercode_external.csv", score: "Main score", fraction: true, dated: null },
17
+ terminalbench: {
18
+ file: "terminalbench_external.csv",
19
+ score: "Accuracy mean",
20
+ fraction: true,
21
+ dated: "Run date",
22
+ },
23
+ webdev: { file: "webdev_arena_external.csv", score: "Arena Score", fraction: false, dated: "Last updated" },
24
+ } as const;
25
+ type EpochField = keyof typeof EPOCH_TABLES;
26
+ const FIELDS = Object.keys(EPOCH_TABLES) as EpochField[];
27
+
28
+ /** The zip's tables catherd reads, by file name (the answer catherd caches); a missing one fails the source. */
29
+ export async function fetchEpoch(t: SourceTransport = {}): Promise<Record<string, string>> {
30
+ const r = await sourceGet(EPOCH_URL, t);
31
+ const wanted = new Set<string>(FIELDS.map((f) => EPOCH_TABLES[f].file));
32
+ let files: Map<string, Uint8Array>;
33
+ try {
34
+ files = unzip(r.bytes, (name) => wanted.has(name));
35
+ } catch (e) {
36
+ throw new SourceError(`the answer is not a readable zip: ${(e as Error).message}`, r.status);
37
+ }
38
+ const out: Record<string, string> = {};
39
+ for (const name of wanted) {
40
+ const data = files.get(name);
41
+ if (!data) throw new SourceError(`the zip has no ${name}`, r.status);
42
+ out[name] = new TextDecoder().decode(data);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Spec 1.2 §3.4: Epoch's `Model version` as `<id>#<effort>`: the effort after the last `_` (`gpt-6-astra_max`),
49
+ * else the row's `Reasoning effort` column; `_unknown` and suffixes that are not efforts (`_32K`) name none.
50
+ */
51
+ export function epochRung(version: string, effortColumn = ""): string {
52
+ const cut = version.lastIndexOf("_");
53
+ const id = cut < 0 ? version : version.slice(0, cut);
54
+ const effort = (cut < 0 ? null : effortWord(version.slice(cut + 1))) ?? effortWord(effortColumn);
55
+ return effort ? `${id}#${effort}` : id;
56
+ }
57
+
58
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
59
+
60
+ /** One row per model and table, the highest where a model has several (Terminal-Bench: one per agent). */
61
+ export function parseEpoch(tables: Record<string, string>, fetchedAt: string): SourceRow[] {
62
+ const rows: SourceRow[] = [];
63
+ for (const field of FIELDS) {
64
+ const t = EPOCH_TABLES[field];
65
+ const text = tables[t.file];
66
+ if (typeof text !== "string") continue;
67
+ for (const r of parseCsv(text)) {
68
+ const version = (r["Model version"] ?? "").trim();
69
+ const raw = num(r[t.score]);
70
+ if (!version || raw === null) continue;
71
+ const dated = t.dated ? (r[t.dated] ?? "").trim() : "";
72
+ rows.push({
73
+ rung: epochRung(version, r["Reasoning effort"] ?? ""),
74
+ field,
75
+ value: t.fraction ? Math.round(raw * 1e6) / 1e4 : raw,
76
+ date: DAY.test(dated) ? dated : isoDay(fetchedAt),
77
+ url: urlOr(r.Source, urlOr(r["Source Link"] ?? r["Source link (site from table)"], EPOCH_PAGE)),
78
+ });
79
+ }
80
+ }
81
+ return keepHighest(rows);
82
+ }
@@ -0,0 +1,75 @@
1
+ import { type JevTransport, retryingFetch } from "../jev-client.ts";
2
+ import { VERSION } from "../version.ts";
3
+
4
+ /** How a source is fetched; tests replace `fetchImpl` and the clock, so no test reaches the network. */
5
+ export type SourceTransport = JevTransport;
6
+
7
+ /** A source request that failed: the error, and the status and headers of the last answer when there was one. */
8
+ export class SourceError extends Error {
9
+ constructor(
10
+ message: string,
11
+ readonly status: number | null = null,
12
+ readonly headers: Headers | null = null,
13
+ ) {
14
+ super(message);
15
+ this.name = "SourceError";
16
+ }
17
+ }
18
+
19
+ export interface SourceResponse {
20
+ status: number;
21
+ headers: Headers;
22
+ bytes: Uint8Array;
23
+ text(): string;
24
+ /** the body as JSON; a SourceError when it is not */
25
+ json(): unknown;
26
+ }
27
+
28
+ /** Spec 1.2 §3.3: one timeout per source; the big files (models.dev, LiteLLM, Epoch) need more than Jev's 10 s. */
29
+ const ATTEMPT_MS = 30_000;
30
+ const DEADLINE_MS = 60_000;
31
+
32
+ /**
33
+ * Spec 1.2 §3.3: GET `url` with the Jev client's retry policy and the source's own timeout (30 s an attempt,
34
+ * 60 s in all, unless the transport says otherwise). Throws a SourceError on any failure.
35
+ */
36
+ export async function sourceGet(
37
+ url: string,
38
+ o: SourceTransport & { headers?: Record<string, string> } = {},
39
+ ): Promise<SourceResponse> {
40
+ const { headers = {}, ...t } = o;
41
+ const r = await retryingFetch(
42
+ url,
43
+ {
44
+ method: "GET",
45
+ headers: () => ({
46
+ "user-agent": `catherd-cli/${VERSION} (+https://github.com/47vigen/catherd)`,
47
+ ...headers,
48
+ }),
49
+ },
50
+ { attemptMs: ATTEMPT_MS, deadlineMs: DEADLINE_MS, ...t },
51
+ );
52
+ if (!r.ok) throw new SourceError(r.error, r.status, r.headers);
53
+ const { bytes, status } = r;
54
+ const text = () => new TextDecoder().decode(bytes);
55
+ return {
56
+ status,
57
+ headers: r.headers,
58
+ bytes,
59
+ text,
60
+ json() {
61
+ try {
62
+ return JSON.parse(text());
63
+ } catch {
64
+ throw new SourceError("unexpected response (not JSON)", status, r.headers);
65
+ }
66
+ },
67
+ };
68
+ }
69
+
70
+ /** `x-ratelimit-remaining` of an answer, when it has one (Artificial Analysis, spec 1.2 §9). */
71
+ export function rateLimitRemaining(h: Headers | null): number | null {
72
+ const raw = h?.get("x-ratelimit-remaining")?.trim();
73
+ const v = raw ? Number(raw) : Number.NaN;
74
+ return Number.isFinite(v) ? v : null;
75
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ import { type SourceTransport, sourceGet } from "./http.ts";
3
+ import { perMillion } from "./openrouter-models.ts";
4
+ import { num } from "./rows.ts";
5
+
6
+ /** Spec 1.2 §3.1: price and effort support, a cross-check only (MIT). */
7
+ export const LITELLM_URL =
8
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
9
+
10
+ export const fetchLiteLlm = async (t: SourceTransport = {}): Promise<unknown> =>
11
+ (await sourceGet(LITELLM_URL, t)).json();
12
+
13
+ /** The efforts LiteLLM flags per model (`supports_<effort>_reasoning_effort`). */
14
+ export const LITELLM_EFFORTS = ["none", "minimal", "low", "xhigh", "max"] as const;
15
+
16
+ export interface LiteLlmModel {
17
+ id: string;
18
+ provider: string;
19
+ /** dollars per million tokens */
20
+ price: { input: number; output: number; cached: number | null } | null;
21
+ context: number | null;
22
+ /** effort → whether LiteLLM says the model supports it; absent when it does not say */
23
+ efforts: Partial<Record<(typeof LITELLM_EFFORTS)[number], boolean>>;
24
+ }
25
+
26
+ /** Spec 1.2 §3.5: the vendors' own APIs, whose prices the catalog's are. */
27
+ const FIRST_PARTY = new Set(["openai", "anthropic"]);
28
+
29
+ const EntrySchema = z.looseObject({ litellm_provider: z.string() });
30
+
31
+ /** The first-party entries of LiteLLM's price file: an OpenAI or Anthropic provider, a key with no `/`. */
32
+ export function parseLiteLlm(raw: unknown): LiteLlmModel[] {
33
+ if (!raw || typeof raw !== "object") return [];
34
+ const out: LiteLlmModel[] = [];
35
+ for (const [id, v] of Object.entries(raw as Record<string, unknown>)) {
36
+ const e = EntrySchema.safeParse(v);
37
+ if (!e.success || !FIRST_PARTY.has(e.data.litellm_provider) || id.includes("/")) continue;
38
+ const entry = e.data as Record<string, unknown>;
39
+ const input = perMillion(entry.input_cost_per_token);
40
+ const output = perMillion(entry.output_cost_per_token);
41
+ const efforts: LiteLlmModel["efforts"] = {};
42
+ for (const effort of LITELLM_EFFORTS) {
43
+ const flag = entry[`supports_${effort}_reasoning_effort`];
44
+ if (typeof flag === "boolean") efforts[effort] = flag;
45
+ }
46
+ out.push({
47
+ id,
48
+ provider: e.data.litellm_provider,
49
+ price:
50
+ input !== null && output !== null
51
+ ? { input, output, cached: perMillion(entry.cache_read_input_token_cost) }
52
+ : null,
53
+ context: num(entry.max_input_tokens),
54
+ efforts,
55
+ });
56
+ }
57
+ return out;
58
+ }
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ import { type SourceTransport, sourceGet } from "./http.ts";
3
+ import { num } from "./rows.ts";
4
+
5
+ /** Spec 1.2 §3.1: efforts, context, output limit, price, tool calling, modalities, release date (MIT). */
6
+ export const MODELS_DEV_URL = "https://models.dev/api.json";
7
+
8
+ export const fetchModelsDev = async (t: SourceTransport = {}): Promise<unknown> =>
9
+ (await sourceGet(MODELS_DEV_URL, t)).json();
10
+
11
+ /** What catherd reads of one models.dev model; each field null when the source does not say. */
12
+ export interface ModelFacts {
13
+ efforts: string[] | null;
14
+ context: number | null;
15
+ output: number | null;
16
+ /** dollars per million tokens */
17
+ price: { input: number; cached: number; output: number } | null;
18
+ toolUse: boolean | null;
19
+ imageIn: boolean | null;
20
+ reasoning: boolean | null;
21
+ releaseDate: string | null;
22
+ }
23
+
24
+ const ModelSchema = z.looseObject({
25
+ reasoning: z.boolean().optional(),
26
+ reasoning_options: z
27
+ .array(z.looseObject({ type: z.string(), values: z.array(z.string()).optional() }))
28
+ .optional(),
29
+ tool_call: z.boolean().optional(),
30
+ release_date: z.string().optional(),
31
+ modalities: z.looseObject({ input: z.array(z.string()).optional() }).optional(),
32
+ limit: z.looseObject({ context: z.unknown(), output: z.unknown() }).optional(),
33
+ cost: z.looseObject({ input: z.unknown(), output: z.unknown(), cache_read: z.unknown() }).optional(),
34
+ });
35
+
36
+ /**
37
+ * Spec 1.2 §3.5: `provider`'s model `id` in models.dev's api.json (a provider id such as `openai` or
38
+ * `opencode-go`, and the model id within it); null when absent or unreadable.
39
+ */
40
+ export function modelsDevFacts(raw: unknown, provider: string, id: string): ModelFacts | null {
41
+ const p = (raw as Record<string, { models?: Record<string, unknown> }> | null)?.[provider];
42
+ const parsed = ModelSchema.safeParse(p?.models?.[id]);
43
+ if (!parsed.success) return null;
44
+ const m = parsed.data;
45
+ const efforts = m.reasoning_options?.find((o) => o.type === "effort")?.values ?? null;
46
+ const input = num(m.cost?.input);
47
+ const output = num(m.cost?.output);
48
+ return {
49
+ efforts,
50
+ context: num(m.limit?.context),
51
+ output: num(m.limit?.output),
52
+ price:
53
+ input !== null && output !== null ? { input, cached: num(m.cost?.cache_read) ?? input, output } : null,
54
+ toolUse: m.tool_call ?? null,
55
+ imageIn: m.modalities?.input ? m.modalities.input.includes("image") : null,
56
+ reasoning: m.reasoning ?? null,
57
+ releaseDate: m.release_date ?? null,
58
+ };
59
+ }