@jeffreyjyz/mpc 0.0.0-stage → 0.1.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.
- package/LICENSE +18 -0
- package/README.md +269 -2
- package/package.json +31 -5
- package/src/cli/config.ts +79 -0
- package/src/cli/engine/ability.ts +50 -0
- package/src/cli/engine/cost.ts +62 -0
- package/src/cli/engine/index.ts +59 -0
- package/src/cli/engine/project.ts +145 -0
- package/src/cli/engine/rows.ts +64 -0
- package/src/cli/engine/score.ts +118 -0
- package/src/cli/flow/check.ts +66 -0
- package/src/cli/flow/collect.ts +76 -0
- package/src/cli/flow/columns.ts +32 -0
- package/src/cli/flow/sort.ts +68 -0
- package/src/cli/options.ts +78 -0
- package/src/cli/parse/cac.ts +114 -0
- package/src/cli/parse/fields.ts +65 -0
- package/src/cli/parse/map.ts +116 -0
- package/src/cli/parse/plugins.ts +67 -0
- package/src/cli/parse/validate.ts +60 -0
- package/src/cli/run.ts +151 -0
- package/src/constants/cli.ts +153 -0
- package/src/constants/data.ts +30 -0
- package/src/constants/scoring.ts +22 -0
- package/src/constants/shape.ts +19 -0
- package/src/constants/sources.ts +69 -0
- package/src/constants/view.ts +161 -0
- package/src/data/bench/cc.ts +26 -0
- package/src/data/bench/index.ts +126 -0
- package/src/data/bench/resolve.ts +107 -0
- package/src/data/bench/store.ts +169 -0
- package/src/data/bench/types.ts +34 -0
- package/src/data/cmduse.ts +43 -0
- package/src/data/scrape/catalog/catalog.ts +99 -0
- package/src/data/scrape/catalog/deal.ts +20 -0
- package/src/data/scrape/catalog/numeric.ts +30 -0
- package/src/data/scrape/catalog/variant.ts +11 -0
- package/src/data/scrape/index.ts +21 -0
- package/src/data/scrape/roleRows.ts +41 -0
- package/src/data/scrape/tables.ts +96 -0
- package/src/data/shape.ts +205 -0
- package/src/data/sources/aa/api.ts +54 -0
- package/src/data/sources/aa/parse.ts +66 -0
- package/src/data/sources/aa/web.ts +53 -0
- package/src/data/sources/cc/catalog.ts +71 -0
- package/src/data/sources/cc/cmduse.ts +24 -0
- package/src/data/sources/cc/plans.ts +29 -0
- package/src/data/sources/opencode.ts +48 -0
- package/src/data/usage/index.ts +118 -0
- package/src/data/usage/log.ts +89 -0
- package/src/data/usage/logs.ts +117 -0
- package/src/data/usage/opencodeDb.ts +135 -0
- package/src/data/usage/opencodeV2.ts +70 -0
- package/src/data/usage/parse.ts +85 -0
- package/src/index.ts +11 -0
- package/src/keys.ts +29 -0
- package/src/types.ts +92 -0
- package/src/view/columns/cc.ts +50 -0
- package/src/view/columns/meta.ts +41 -0
- package/src/view/columns/oc.ts +50 -0
- package/src/view/footer.ts +124 -0
- package/src/view/layout/fit.ts +58 -0
- package/src/view/layout/segments.ts +98 -0
- package/src/view/layout/table.ts +85 -0
- package/src/view/layout/usage.ts +163 -0
- package/src/view/render.ts +62 -0
- package/src/view/schema.ts +81 -0
- package/src/view/text/export.ts +72 -0
- package/src/view/text/format.ts +79 -0
- package/src/view/text/index.ts +54 -0
- package/src/view/text/styles.ts +151 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { loadAaApi } from "~/data/sources/aa/api.ts";
|
|
2
|
+
import { loadCc } from "./cc.ts";
|
|
3
|
+
import { loadAaWebCached, loadFile, loadUrl } from "./store.ts";
|
|
4
|
+
import {
|
|
5
|
+
type AbilityOptions,
|
|
6
|
+
type BenchData,
|
|
7
|
+
emptyData,
|
|
8
|
+
type Resolved,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export async function resolvePrimary(opts: AbilityOptions): Promise<Resolved> {
|
|
12
|
+
const [scheme, arg] = opts.source.includes(":")
|
|
13
|
+
? [
|
|
14
|
+
opts.source.split(":")[0] ?? "",
|
|
15
|
+
opts.source.slice(opts.source.indexOf(":") + 1),
|
|
16
|
+
]
|
|
17
|
+
: [opts.source, undefined];
|
|
18
|
+
|
|
19
|
+
switch (scheme) {
|
|
20
|
+
case "cc":
|
|
21
|
+
return {
|
|
22
|
+
scheme: "cc",
|
|
23
|
+
data: await loadCc(),
|
|
24
|
+
label: "CommandCode Intelligence",
|
|
25
|
+
};
|
|
26
|
+
case "aa-web": {
|
|
27
|
+
const data = emptyData();
|
|
28
|
+
data.intelligence = await loadAaWebCached(Boolean(opts.refresh));
|
|
29
|
+
return {
|
|
30
|
+
scheme: "aa-web",
|
|
31
|
+
data,
|
|
32
|
+
label: "Artificial Analysis (web, partial)",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
case "aa": {
|
|
36
|
+
const key = opts.key ?? process.env.AA_API_KEY;
|
|
37
|
+
if (!key) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"--bench aa needs a key: set AA_API_KEY or pass --aa-key",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const data = await loadAaApi(key);
|
|
43
|
+
if (data.intelligence.size === 0) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"Artificial Analysis returned no scores — check the API key and response shape",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
scheme: "aa",
|
|
50
|
+
data,
|
|
51
|
+
label: `Artificial Analysis (${data.intelligence.size} models, ${data.tps.size} speed)`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
case "file":
|
|
55
|
+
if (!arg) throw new Error("--bench file:<path> needs a path");
|
|
56
|
+
return {
|
|
57
|
+
scheme: "file",
|
|
58
|
+
data: { intelligence: await loadFile(arg), tps: new Map() },
|
|
59
|
+
label: `bench file ${arg}`,
|
|
60
|
+
};
|
|
61
|
+
case "url":
|
|
62
|
+
if (!arg) throw new Error("--bench url:<url> needs a url");
|
|
63
|
+
return {
|
|
64
|
+
scheme: "url",
|
|
65
|
+
data: { intelligence: await loadUrl(arg), tps: new Map() },
|
|
66
|
+
label: `bench ${arg}`,
|
|
67
|
+
};
|
|
68
|
+
default:
|
|
69
|
+
throw new Error(
|
|
70
|
+
`unknown --bench "${opts.source}" (cc | aa | aa-web | file:<path> | url:<url>)`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Keyless Artificial Analysis page scrape (partial — only the models AA embeds).
|
|
77
|
+
*/
|
|
78
|
+
export async function loadAaWeb(opts: AbilityOptions): Promise<BenchData> {
|
|
79
|
+
const data = emptyData();
|
|
80
|
+
data.intelligence = await loadAaWebCached(Boolean(opts.refresh));
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Artificial Analysis as a gap filler: the full API when a key is available,
|
|
86
|
+
* otherwise the keyless page scrape (partial).
|
|
87
|
+
*/
|
|
88
|
+
export async function loadAaFallback(
|
|
89
|
+
opts: AbilityOptions,
|
|
90
|
+
): Promise<{ data: BenchData; how: string }> {
|
|
91
|
+
const key = opts.key ?? process.env.AA_API_KEY;
|
|
92
|
+
if (key) {
|
|
93
|
+
try {
|
|
94
|
+
return { data: await loadAaApi(key), how: "AA API" };
|
|
95
|
+
} catch {
|
|
96
|
+
// fall through to the scrape
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { data: await loadAaWeb(opts), how: "AA web scrape" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type {
|
|
103
|
+
AbilityOptions,
|
|
104
|
+
AbilityResult,
|
|
105
|
+
AbilitySource,
|
|
106
|
+
BenchData,
|
|
107
|
+
} from "./types.ts";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { CACHE_TTL_MS } from "~/constants/sources.ts";
|
|
2
|
+
import { fetchText } from "~/data/scrape/index.ts";
|
|
3
|
+
import { normalizeKey } from "~/keys.ts";
|
|
4
|
+
|
|
5
|
+
function parseJsonScores(text: string): Map<string, number> {
|
|
6
|
+
const body = JSON.parse(text) as unknown;
|
|
7
|
+
const scores = new Map<string, number>();
|
|
8
|
+
const add = (name: unknown, score: unknown): void => {
|
|
9
|
+
const key = typeof name === "string" ? normalizeKey(name) : "";
|
|
10
|
+
const value = Number(score);
|
|
11
|
+
if (key && Number.isFinite(value)) scores.set(key, value);
|
|
12
|
+
};
|
|
13
|
+
if (Array.isArray(body)) {
|
|
14
|
+
for (const row of body) {
|
|
15
|
+
const record = (row ?? {}) as Record<string, unknown>;
|
|
16
|
+
add(record.model ?? record.name, record.score ?? record.value);
|
|
17
|
+
}
|
|
18
|
+
} else if (body && typeof body === "object") {
|
|
19
|
+
for (const [name, score] of Object.entries(
|
|
20
|
+
body as Record<string, unknown>,
|
|
21
|
+
)) {
|
|
22
|
+
add(name, score);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return scores;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadFile(path: string): Promise<Map<string, number>> {
|
|
29
|
+
const file = Bun.file(path);
|
|
30
|
+
if (!(await file.exists()))
|
|
31
|
+
throw new Error(`bench file not found: ${path}`);
|
|
32
|
+
return parseJsonScores(await file.text());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function loadUrl(url: string): Promise<Map<string, number>> {
|
|
36
|
+
return parseJsonScores(await fetchText(url));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
import { mkdir } from "node:fs/promises";
|
|
40
|
+
import { homedir } from "node:os";
|
|
41
|
+
import { join } from "node:path";
|
|
42
|
+
import { loadAaWeb } from "~/data/sources/aa/web.ts";
|
|
43
|
+
|
|
44
|
+
function cachePath(): string {
|
|
45
|
+
const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
|
|
46
|
+
return join(base, "mpc", "ability-aa-web.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readCache(
|
|
50
|
+
refresh: boolean,
|
|
51
|
+
): Promise<Map<string, number> | null> {
|
|
52
|
+
if (refresh) return null;
|
|
53
|
+
try {
|
|
54
|
+
const file = Bun.file(cachePath());
|
|
55
|
+
if (!(await file.exists())) return null;
|
|
56
|
+
const raw = (await file.json()) as {
|
|
57
|
+
fetchedAt?: number;
|
|
58
|
+
scores?: Record<string, number>;
|
|
59
|
+
};
|
|
60
|
+
if (!raw.fetchedAt || Date.now() - raw.fetchedAt > CACHE_TTL_MS)
|
|
61
|
+
return null;
|
|
62
|
+
return new Map(Object.entries(raw.scores ?? {}));
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeCache(scores: Map<string, number>): Promise<void> {
|
|
69
|
+
try {
|
|
70
|
+
const path = cachePath();
|
|
71
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
72
|
+
await Bun.write(
|
|
73
|
+
path,
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
fetchedAt: Date.now(),
|
|
76
|
+
scores: Object.fromEntries(scores),
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
// Cache is best-effort.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Keyless AA page scrape, cached on disk. */
|
|
85
|
+
export async function loadAaWebCached(
|
|
86
|
+
refresh: boolean,
|
|
87
|
+
): Promise<Map<string, number>> {
|
|
88
|
+
const cached = await readCache(refresh);
|
|
89
|
+
if (cached) return cached;
|
|
90
|
+
const scores = await loadAaWeb();
|
|
91
|
+
await writeCache(scores);
|
|
92
|
+
return scores;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The last good benchmark result, whole. The per-source caches above only cover
|
|
97
|
+
* the AA page scrape; this one covers the result mpc actually returned, so a
|
|
98
|
+
* source that is down (or a page that silently dropped a column, as the
|
|
99
|
+
* CommandCode plan pages did with Tok/s) falls back to the scores we already
|
|
100
|
+
* have instead of blanking the ability/tps columns.
|
|
101
|
+
*/
|
|
102
|
+
export interface CachedAbility {
|
|
103
|
+
intelligence: Map<string, number>;
|
|
104
|
+
tps: Map<string, number>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function abilityCachePath(): string {
|
|
108
|
+
const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
|
|
109
|
+
return join(base, "mpc", "ability.json");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function readAbilityCache(
|
|
113
|
+
refresh = false,
|
|
114
|
+
): Promise<CachedAbility | null> {
|
|
115
|
+
if (refresh) return null;
|
|
116
|
+
try {
|
|
117
|
+
const file = Bun.file(abilityCachePath());
|
|
118
|
+
if (!(await file.exists())) return null;
|
|
119
|
+
const raw = (await file.json()) as {
|
|
120
|
+
fetchedAt?: number;
|
|
121
|
+
intelligence?: Record<string, number>;
|
|
122
|
+
tps?: Record<string, number>;
|
|
123
|
+
};
|
|
124
|
+
if (!raw.fetchedAt || Date.now() - raw.fetchedAt > CACHE_TTL_MS)
|
|
125
|
+
return null;
|
|
126
|
+
return {
|
|
127
|
+
intelligence: new Map(Object.entries(raw.intelligence ?? {})),
|
|
128
|
+
tps: new Map(Object.entries(raw.tps ?? {})),
|
|
129
|
+
};
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function writeAbilityCache(data: CachedAbility): Promise<void> {
|
|
136
|
+
try {
|
|
137
|
+
const path = abilityCachePath();
|
|
138
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
139
|
+
await Bun.write(
|
|
140
|
+
path,
|
|
141
|
+
JSON.stringify({
|
|
142
|
+
fetchedAt: Date.now(),
|
|
143
|
+
intelligence: Object.fromEntries(data.intelligence),
|
|
144
|
+
tps: Object.fromEntries(data.tps),
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
} catch {
|
|
148
|
+
// Cache is best-effort.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Fill keys the live run is missing from `cached`. Returns how many were added. */
|
|
153
|
+
export function fillFromCache(
|
|
154
|
+
data: CachedAbility,
|
|
155
|
+
cached: CachedAbility,
|
|
156
|
+
): number {
|
|
157
|
+
let added = 0;
|
|
158
|
+
for (const [into, from] of [
|
|
159
|
+
[data.intelligence, cached.intelligence],
|
|
160
|
+
[data.tps, cached.tps],
|
|
161
|
+
] as const) {
|
|
162
|
+
for (const [key, value] of from) {
|
|
163
|
+
if (into.has(key)) continue;
|
|
164
|
+
into.set(key, value);
|
|
165
|
+
added++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return added;
|
|
169
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type AbilitySource = "cc" | "aa" | "aa-web" | "file" | "url";
|
|
2
|
+
|
|
3
|
+
/** Benchmark data, keyed by canonical model key. */
|
|
4
|
+
export interface BenchData {
|
|
5
|
+
intelligence: Map<string, number>;
|
|
6
|
+
/** Output tokens per second, when the source publishes it. */
|
|
7
|
+
tps: Map<string, number>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AbilityResult extends BenchData {
|
|
11
|
+
label: string;
|
|
12
|
+
/** Note about coverage limits, shown in the footer. */
|
|
13
|
+
note?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AbilityOptions {
|
|
17
|
+
source: string;
|
|
18
|
+
key?: string;
|
|
19
|
+
/** Only these model keys matter; fills outside the catalog are ignored. */
|
|
20
|
+
keys?: Set<string>;
|
|
21
|
+
/** Fill models the primary source misses from the other sources. */
|
|
22
|
+
fallback?: boolean;
|
|
23
|
+
refresh?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Resolved {
|
|
27
|
+
scheme: AbilitySource;
|
|
28
|
+
data: BenchData;
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function emptyData(): BenchData {
|
|
33
|
+
return { intelligence: new Map(), tps: new Map() };
|
|
34
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface RunResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** CommandCode CLI to shell out to; override to test a dev build. */
|
|
8
|
+
export function cmduseBin(): string {
|
|
9
|
+
return process.env.CMDUSE_BIN || "cmduse";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run a binary with args. Never throws: a missing or failing binary comes back
|
|
14
|
+
* as `ok: false` so callers can pick a fallback rather than crash the report.
|
|
15
|
+
* `extraEnv` is merged over the environment for this child (and inherited by
|
|
16
|
+
* anything it spawns).
|
|
17
|
+
*/
|
|
18
|
+
export async function runBinary(
|
|
19
|
+
cmd: string,
|
|
20
|
+
args: string[],
|
|
21
|
+
extraEnv?: Record<string, string>,
|
|
22
|
+
): Promise<RunResult> {
|
|
23
|
+
let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
24
|
+
try {
|
|
25
|
+
proc = Bun.spawn([cmd, ...args], {
|
|
26
|
+
stdout: "pipe",
|
|
27
|
+
stderr: "pipe",
|
|
28
|
+
env: extraEnv ? { ...process.env, ...extraEnv } : undefined,
|
|
29
|
+
});
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return { ok: false, stdout: "", stderr: String(error) };
|
|
32
|
+
}
|
|
33
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
34
|
+
new Response(proc.stdout).text(),
|
|
35
|
+
new Response(proc.stderr).text(),
|
|
36
|
+
proc.exited,
|
|
37
|
+
]);
|
|
38
|
+
return { ok: code === 0, stdout, stderr };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runCmduse(args: string[]): Promise<RunResult> {
|
|
42
|
+
return runBinary(cmduseBin(), args);
|
|
43
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cellText,
|
|
3
|
+
headerIndex,
|
|
4
|
+
nameCell,
|
|
5
|
+
parseMoney,
|
|
6
|
+
type Table,
|
|
7
|
+
} from "~/data/scrape/tables.ts";
|
|
8
|
+
import { displayName, normalizeKey } from "~/keys.ts";
|
|
9
|
+
import type { CatalogEntry, ModelPricing, ProviderId } from "~/types.ts";
|
|
10
|
+
import { dealIn } from "./deal.ts";
|
|
11
|
+
import { variantScore } from "./variant.ts";
|
|
12
|
+
|
|
13
|
+
interface CatalogOptions {
|
|
14
|
+
provider: ProviderId;
|
|
15
|
+
plan: string;
|
|
16
|
+
/** Matches the header cell that carries the per-model allowance. */
|
|
17
|
+
creditHeader?: RegExp;
|
|
18
|
+
/** Allowance to assume for rate-only tables (no allowance column). */
|
|
19
|
+
defaultAllowance?: number;
|
|
20
|
+
/** OpenCode Go only: prefer peak-rate rows over off-peak. */
|
|
21
|
+
peak?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pricingOf(
|
|
25
|
+
cells: string[],
|
|
26
|
+
cols: { in: number; out: number; cache: number; write: number },
|
|
27
|
+
): ModelPricing {
|
|
28
|
+
return {
|
|
29
|
+
input: parseMoney(cells[cols.in] ?? "") ?? 0,
|
|
30
|
+
output: parseMoney(cells[cols.out] ?? "") ?? 0,
|
|
31
|
+
cacheRead:
|
|
32
|
+
cols.cache >= 0 ? (parseMoney(cells[cols.cache] ?? "") ?? 0) : 0,
|
|
33
|
+
cacheWrite:
|
|
34
|
+
cols.write >= 0 ? parseMoney(cells[cols.write] ?? "") : null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract a provider catalog from already-parsed tables. Only tables whose
|
|
40
|
+
* header carries Input + Output + an allowance column qualify, so the wide
|
|
41
|
+
* "models included" tables are ignored.
|
|
42
|
+
*/
|
|
43
|
+
export function extractCatalog(
|
|
44
|
+
tables: Table[],
|
|
45
|
+
options: CatalogOptions,
|
|
46
|
+
): CatalogEntry[] {
|
|
47
|
+
const {
|
|
48
|
+
provider,
|
|
49
|
+
plan,
|
|
50
|
+
creditHeader,
|
|
51
|
+
defaultAllowance,
|
|
52
|
+
peak = false,
|
|
53
|
+
} = options;
|
|
54
|
+
const best = new Map<string, { entry: CatalogEntry; score: number }>();
|
|
55
|
+
|
|
56
|
+
for (const table of tables) {
|
|
57
|
+
const header = (table[0] ?? []).map(cellText);
|
|
58
|
+
if (header.length < 4) continue;
|
|
59
|
+
const cols = {
|
|
60
|
+
in: headerIndex(header, /^input/i),
|
|
61
|
+
out: headerIndex(header, /^output/i),
|
|
62
|
+
cache: headerIndex(header, /cache(d)?\s*read/i),
|
|
63
|
+
write: headerIndex(header, /cache\s*write/i),
|
|
64
|
+
};
|
|
65
|
+
const creditCol = creditHeader ? headerIndex(header, creditHeader) : -2;
|
|
66
|
+
if (cols.in < 0 || cols.out < 0) continue;
|
|
67
|
+
if (creditHeader && creditCol < 0) continue;
|
|
68
|
+
if (!creditHeader && defaultAllowance === undefined) continue;
|
|
69
|
+
|
|
70
|
+
for (const cells of table.slice(1)) {
|
|
71
|
+
const rawName = nameCell(cells[0] ?? "");
|
|
72
|
+
const deal = dealIn(cells[0] ?? "");
|
|
73
|
+
if (!rawName) continue;
|
|
74
|
+
const allowance = creditHeader
|
|
75
|
+
? parseMoney(cells[creditCol] ?? "")
|
|
76
|
+
: (defaultAllowance ?? 0);
|
|
77
|
+
if (allowance === null) continue;
|
|
78
|
+
const key = normalizeKey(rawName);
|
|
79
|
+
if (!key) continue;
|
|
80
|
+
|
|
81
|
+
const score = variantScore(rawName, peak);
|
|
82
|
+
const existing = best.get(key);
|
|
83
|
+
if (existing && existing.score <= score) continue;
|
|
84
|
+
best.set(key, {
|
|
85
|
+
score,
|
|
86
|
+
entry: {
|
|
87
|
+
provider,
|
|
88
|
+
plan,
|
|
89
|
+
key,
|
|
90
|
+
name: displayName(rawName),
|
|
91
|
+
pricing: pricingOf(cells, cols),
|
|
92
|
+
allowance,
|
|
93
|
+
...(deal ? { deal } : {}),
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return [...best.values()].map((v) => v.entry);
|
|
99
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BADGE, BOUNDARY, ENDS } from "~/constants/data.ts";
|
|
2
|
+
import { cellText } from "~/data/scrape/tables.ts";
|
|
3
|
+
import type { Deal } from "~/types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read the promotion from a raw model name cell. `nameCell` deliberately keeps
|
|
7
|
+
* only the first text node — the one that is the name — so the badges that
|
|
8
|
+
* follow it are dropped at the door unless they are read here.
|
|
9
|
+
*
|
|
10
|
+
* Cells look like `Grok 4.7` BOUNDARY `-40%` BOUNDARY `Ends September 27, 2026`
|
|
11
|
+
* or `Pixel Canary` BOUNDARY `Free`.
|
|
12
|
+
*/
|
|
13
|
+
export function dealIn(cell: string): Deal | undefined {
|
|
14
|
+
const parts = cell.split(BOUNDARY).map(cellText).filter(Boolean);
|
|
15
|
+
const rest = parts.slice(1);
|
|
16
|
+
const badge = rest.find((part) => BADGE.test(part));
|
|
17
|
+
if (!badge) return undefined;
|
|
18
|
+
const ends = rest.find((part) => ENDS.test(part));
|
|
19
|
+
return ends ? { badge, ends } : { badge };
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cellText,
|
|
3
|
+
headerIndex,
|
|
4
|
+
nameCell,
|
|
5
|
+
type Table,
|
|
6
|
+
} from "~/data/scrape/tables.ts";
|
|
7
|
+
import { normalizeKey } from "~/keys.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pull one numeric column out of any tables that carry it, keyed by model.
|
|
11
|
+
* Used for benchmark columns like CommandCode's "Intelligence".
|
|
12
|
+
*/
|
|
13
|
+
export function extractNumericColumn(
|
|
14
|
+
tables: Table[],
|
|
15
|
+
header: RegExp,
|
|
16
|
+
): Map<string, number> {
|
|
17
|
+
const scores = new Map<string, number>();
|
|
18
|
+
for (const table of tables) {
|
|
19
|
+
const headerCells = (table[0] ?? []).map(cellText);
|
|
20
|
+
const col = headerIndex(headerCells, header);
|
|
21
|
+
if (col < 0) continue;
|
|
22
|
+
for (const cells of table.slice(1)) {
|
|
23
|
+
const key = normalizeKey(nameCell(cells[0] ?? ""));
|
|
24
|
+
if (!key || scores.has(key)) continue;
|
|
25
|
+
const match = (cells[col] ?? "").match(/-?[0-9]+(?:\.[0-9]+)?/);
|
|
26
|
+
if (match) scores.set(key, Number(match[0]));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return scores;
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function variantScore(name: string, peak: boolean): number {
|
|
2
|
+
const lower = name.toLowerCase();
|
|
3
|
+
let score = 0;
|
|
4
|
+
// Prefer the base context tier over the "> 200K" tier.
|
|
5
|
+
if (lower.includes(">")) score += 2;
|
|
6
|
+
const isOff = lower.includes("off-peak");
|
|
7
|
+
const isPeak = !isOff && lower.includes("peak");
|
|
8
|
+
if (peak) score += isPeak ? 0 : 2;
|
|
9
|
+
else score += isOff ? 0 : isPeak ? 2 : 1;
|
|
10
|
+
return score;
|
|
11
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { extractCatalog } from "./catalog/catalog.ts";
|
|
2
|
+
export { dealIn } from "./catalog/deal.ts";
|
|
3
|
+
export { extractNumericColumn } from "./catalog/numeric.ts";
|
|
4
|
+
export { parseRoleRows } from "./roleRows.ts";
|
|
5
|
+
export type { Table } from "./tables.ts";
|
|
6
|
+
export {
|
|
7
|
+
cellText,
|
|
8
|
+
headerIndex,
|
|
9
|
+
nameCell,
|
|
10
|
+
parseMoney,
|
|
11
|
+
parseTables,
|
|
12
|
+
} from "./tables.ts";
|
|
13
|
+
|
|
14
|
+
export async function fetchText(url: string): Promise<string> {
|
|
15
|
+
const res = await fetch(url, {
|
|
16
|
+
headers: { "user-agent": "mpc/0.1 (+model price compare)" },
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`);
|
|
20
|
+
return res.text();
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { BOUNDARY } from "~/constants/data.ts";
|
|
2
|
+
import type { Table } from "./tables.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse `role="row"` div grids (CommandCode's model catalog on some plan
|
|
6
|
+
* pages) into the same Table shape as parseTables.
|
|
7
|
+
*/
|
|
8
|
+
export async function parseRoleRows(html: string): Promise<Table[]> {
|
|
9
|
+
let grid: Table | null = null;
|
|
10
|
+
let row: string[] | null = null;
|
|
11
|
+
let cell: string | null = null;
|
|
12
|
+
|
|
13
|
+
const rewriter = new HTMLRewriter()
|
|
14
|
+
.on('div[role="row"]', {
|
|
15
|
+
element(el) {
|
|
16
|
+
grid ??= [];
|
|
17
|
+
const start = grid;
|
|
18
|
+
row = [];
|
|
19
|
+
el.onEndTag(() => {
|
|
20
|
+
if (row && start) start.push(row);
|
|
21
|
+
row = null;
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
.on('div[role="row"] > div', {
|
|
26
|
+
element(el) {
|
|
27
|
+
const current = row;
|
|
28
|
+
cell = "";
|
|
29
|
+
el.onEndTag(() => {
|
|
30
|
+
if (current) current.push((cell ?? "").trim());
|
|
31
|
+
cell = null;
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
text(chunk) {
|
|
35
|
+
if (cell !== null) cell += `${chunk.text}${BOUNDARY}`;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await rewriter.transform(new Response(html)).text();
|
|
40
|
+
return grid ? [grid] : [];
|
|
41
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { BOUNDARY } from "~/constants/data.ts";
|
|
2
|
+
|
|
3
|
+
export type Table = string[][];
|
|
4
|
+
|
|
5
|
+
function clean(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.replace(/\u00a0/g, " ")
|
|
8
|
+
.replace(/\s+/g, " ")
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Collapse a raw cell (boundary markers included) into plain text. */
|
|
13
|
+
export function cellText(cell: string): string {
|
|
14
|
+
return clean(cell.split(BOUNDARY).join(" "));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The first text node of a cell; deal badges live in later nodes. */
|
|
18
|
+
export function nameCell(cell: string): string {
|
|
19
|
+
return cellText(cell.split(BOUNDARY)[0] ?? "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parse every <table> on a page into rows of trimmed cell text. */
|
|
23
|
+
export async function parseTables(html: string): Promise<Table[]> {
|
|
24
|
+
const tables: Table[] = [];
|
|
25
|
+
let table: Table | null = null;
|
|
26
|
+
let row: string[] | null = null;
|
|
27
|
+
let cell: string | null = null;
|
|
28
|
+
|
|
29
|
+
const rewriter = new HTMLRewriter()
|
|
30
|
+
.on("table", {
|
|
31
|
+
element(el) {
|
|
32
|
+
table = [];
|
|
33
|
+
el.onEndTag(() => {
|
|
34
|
+
if (table) tables.push(table);
|
|
35
|
+
table = null;
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
.on("tr", {
|
|
40
|
+
element(el) {
|
|
41
|
+
const start = table;
|
|
42
|
+
row = [];
|
|
43
|
+
el.onEndTag(() => {
|
|
44
|
+
if (row && start) start.push(row);
|
|
45
|
+
row = null;
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
.on("th, td", {
|
|
50
|
+
element(el) {
|
|
51
|
+
const current = row;
|
|
52
|
+
cell = "";
|
|
53
|
+
el.onEndTag(() => {
|
|
54
|
+
// BOUNDARY marks a text-node boundary so "$60" + "4x" stays
|
|
55
|
+
// two tokens instead of collapsing into "$604x".
|
|
56
|
+
if (current) current.push((cell ?? "").trim());
|
|
57
|
+
cell = null;
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
text(chunk) {
|
|
61
|
+
if (cell !== null) cell += `${chunk.text}${BOUNDARY}`;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await rewriter.transform(new Response(html)).text();
|
|
66
|
+
return tables;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pull USD out of a price/allowance cell. Cells carry BOUNDARY text-node
|
|
71
|
+
* boundaries; each segment is scanned and the last one carrying a price wins,
|
|
72
|
+
* which skips trailing multiplier notes like "4x". "Free" is 0, em-dash null.
|
|
73
|
+
*/
|
|
74
|
+
export function parseMoney(cell: string): number | null {
|
|
75
|
+
let result: number | null = null;
|
|
76
|
+
for (const segment of cell.split(BOUNDARY)) {
|
|
77
|
+
const s = segment.replace(/~~/g, "").trim();
|
|
78
|
+
if (/\bfree\b/i.test(s)) return 0;
|
|
79
|
+
const matches = [...s.matchAll(/\$\s*([0-9]+(?:\.[0-9]+)?)/g)];
|
|
80
|
+
if (matches.length > 0) {
|
|
81
|
+
result = Number(matches[matches.length - 1]?.[1]);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Match a table header cell, ignoring sort arrows and punctuation. */
|
|
88
|
+
export function headerIndex(header: string[], pattern: RegExp): number {
|
|
89
|
+
return header.findIndex((h) => {
|
|
90
|
+
const norm = h
|
|
91
|
+
.replace(/[^a-z0-9 ]+/gi, " ")
|
|
92
|
+
.replace(/\s+/g, " ")
|
|
93
|
+
.trim();
|
|
94
|
+
return pattern.test(norm);
|
|
95
|
+
});
|
|
96
|
+
}
|