@jeffreyjyz/mpc 0.0.0-stage → 0.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 +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,89 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { normalizeKey } from "~/keys.ts";
|
|
5
|
+
import type { UsageEntry } from "./parse.ts";
|
|
6
|
+
|
|
7
|
+
interface LogLine {
|
|
8
|
+
ts?: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
input?: number;
|
|
11
|
+
cacheRead?: number;
|
|
12
|
+
cacheWrite?: number;
|
|
13
|
+
output?: number;
|
|
14
|
+
reasoning?: number;
|
|
15
|
+
costUsd?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Default path a per-request JSONL log lives at. */
|
|
19
|
+
export function defaultUsageLog(): string {
|
|
20
|
+
if (process.env.MPC_USAGE_LOG) return process.env.MPC_USAGE_LOG;
|
|
21
|
+
const base = process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache");
|
|
22
|
+
return join(base, "mpc", "usage.jsonl");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Aggregate a per-request JSONL log. This is the complete opencode-side mix
|
|
27
|
+
* when opencode's own store is unavailable.
|
|
28
|
+
*/
|
|
29
|
+
export function readUsageLog(path: string, since?: Date): UsageEntry[] | null {
|
|
30
|
+
let text: string;
|
|
31
|
+
try {
|
|
32
|
+
text = readFileSync(path, "utf8");
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const byKey = new Map<string, UsageEntry>();
|
|
37
|
+
for (const raw of text.split("\n")) {
|
|
38
|
+
if (!raw.trim()) continue;
|
|
39
|
+
let line: LogLine;
|
|
40
|
+
try {
|
|
41
|
+
line = JSON.parse(raw) as LogLine;
|
|
42
|
+
} catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!line.model) continue;
|
|
46
|
+
if (since && line.ts && new Date(line.ts) < since) continue;
|
|
47
|
+
const key = normalizeKey(line.model);
|
|
48
|
+
const entry = byKey.get(key) ?? {
|
|
49
|
+
key,
|
|
50
|
+
name: line.model,
|
|
51
|
+
requests: 0,
|
|
52
|
+
tokensIn: 0,
|
|
53
|
+
cacheRead: 0,
|
|
54
|
+
cacheWrite: 0,
|
|
55
|
+
tokensOut: 0,
|
|
56
|
+
reasoning: 0,
|
|
57
|
+
costUsd: 0,
|
|
58
|
+
};
|
|
59
|
+
entry.requests += 1;
|
|
60
|
+
entry.tokensIn += line.input ?? 0;
|
|
61
|
+
entry.cacheRead += line.cacheRead ?? 0;
|
|
62
|
+
entry.cacheWrite += line.cacheWrite ?? 0;
|
|
63
|
+
entry.tokensOut += line.output ?? 0;
|
|
64
|
+
entry.reasoning += line.reasoning ?? 0;
|
|
65
|
+
entry.costUsd += line.costUsd ?? 0;
|
|
66
|
+
byKey.set(key, entry);
|
|
67
|
+
}
|
|
68
|
+
return [...byKey.values()];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Sum two usage lists by model key. */
|
|
72
|
+
export function mergeUsage(a: UsageEntry[], b: UsageEntry[]): UsageEntry[] {
|
|
73
|
+
const byKey = new Map(a.map((entry) => [entry.key, { ...entry }]));
|
|
74
|
+
for (const entry of b) {
|
|
75
|
+
const existing = byKey.get(entry.key);
|
|
76
|
+
if (!existing) {
|
|
77
|
+
byKey.set(entry.key, { ...entry });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
existing.requests += entry.requests;
|
|
81
|
+
existing.tokensIn += entry.tokensIn;
|
|
82
|
+
existing.cacheRead += entry.cacheRead;
|
|
83
|
+
existing.cacheWrite += entry.cacheWrite;
|
|
84
|
+
existing.tokensOut += entry.tokensOut;
|
|
85
|
+
existing.reasoning += entry.reasoning;
|
|
86
|
+
existing.costUsd += entry.costUsd;
|
|
87
|
+
}
|
|
88
|
+
return [...byKey.values()];
|
|
89
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { normalizeKey } from "~/keys.ts";
|
|
5
|
+
import type { UsageEntry } from "./parse.ts";
|
|
6
|
+
|
|
7
|
+
interface Totals {
|
|
8
|
+
requests: number;
|
|
9
|
+
tokensIn: number;
|
|
10
|
+
cacheRead: number;
|
|
11
|
+
cacheWrite: number;
|
|
12
|
+
tokensOut: number;
|
|
13
|
+
reasoning: number;
|
|
14
|
+
costUsd: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function empty(): Totals {
|
|
18
|
+
return {
|
|
19
|
+
requests: 0,
|
|
20
|
+
tokensIn: 0,
|
|
21
|
+
cacheRead: 0,
|
|
22
|
+
cacheWrite: 0,
|
|
23
|
+
tokensOut: 0,
|
|
24
|
+
// The CommandCode session-log usage block carries no reasoning counter.
|
|
25
|
+
reasoning: 0,
|
|
26
|
+
costUsd: 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function jsonlFiles(dir: string, out: string[]): string[] {
|
|
31
|
+
let entries: string[];
|
|
32
|
+
try {
|
|
33
|
+
entries = readdirSync(dir);
|
|
34
|
+
} catch {
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
for (const name of entries) {
|
|
38
|
+
const path = join(dir, name);
|
|
39
|
+
let stat: ReturnType<typeof statSync>;
|
|
40
|
+
try {
|
|
41
|
+
stat = statSync(path);
|
|
42
|
+
} catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (stat.isDirectory()) jsonlFiles(path, out);
|
|
46
|
+
else if (
|
|
47
|
+
name.endsWith(".jsonl") &&
|
|
48
|
+
!name.endsWith(".checkpoints.jsonl")
|
|
49
|
+
) {
|
|
50
|
+
out.push(path);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface Line {
|
|
57
|
+
timestamp?: string;
|
|
58
|
+
model?: string;
|
|
59
|
+
usage?: {
|
|
60
|
+
inputTokens?: number;
|
|
61
|
+
outputTokens?: number;
|
|
62
|
+
cacheReadTokens?: number;
|
|
63
|
+
cacheWriteTokens?: number;
|
|
64
|
+
costUsd?: number;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Aggregate per-model usage from the local CommandCode session logs. Unlike
|
|
70
|
+
* `cmduse model`, this honours a date window so it can match a billing period.
|
|
71
|
+
*/
|
|
72
|
+
export function scanLogs(
|
|
73
|
+
since?: Date,
|
|
74
|
+
root = join(homedir(), ".commandcode", "projects"),
|
|
75
|
+
): UsageEntry[] {
|
|
76
|
+
const byModel = new Map<string, { name: string; totals: Totals }>();
|
|
77
|
+
|
|
78
|
+
for (const file of jsonlFiles(root, [])) {
|
|
79
|
+
let text: string;
|
|
80
|
+
try {
|
|
81
|
+
text = readFileSync(file, "utf8");
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (const raw of text.split("\n")) {
|
|
86
|
+
if (!raw?.includes('"usage"')) continue;
|
|
87
|
+
let line: Line;
|
|
88
|
+
try {
|
|
89
|
+
line = JSON.parse(raw) as Line;
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!line.usage || !line.model) continue;
|
|
94
|
+
if (since && line.timestamp && new Date(line.timestamp) < since)
|
|
95
|
+
continue;
|
|
96
|
+
const key = normalizeKey(line.model);
|
|
97
|
+
const bucket = byModel.get(key) ?? {
|
|
98
|
+
name: line.model,
|
|
99
|
+
totals: empty(),
|
|
100
|
+
};
|
|
101
|
+
const t = bucket.totals;
|
|
102
|
+
t.requests += 1;
|
|
103
|
+
t.tokensIn += line.usage.inputTokens ?? 0;
|
|
104
|
+
t.tokensOut += line.usage.outputTokens ?? 0;
|
|
105
|
+
t.cacheRead += line.usage.cacheReadTokens ?? 0;
|
|
106
|
+
t.cacheWrite += line.usage.cacheWriteTokens ?? 0;
|
|
107
|
+
t.costUsd += line.usage.costUsd ?? 0;
|
|
108
|
+
byModel.set(key, bucket);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return [...byModel].map(([key, { name, totals }]) => ({
|
|
113
|
+
key,
|
|
114
|
+
name,
|
|
115
|
+
...totals,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { normalizeKey } from "~/keys.ts";
|
|
5
|
+
import { hasV2, readV2Turns, type Turn } from "./opencodeV2.ts";
|
|
6
|
+
import type { UsageEntry } from "./parse.ts";
|
|
7
|
+
|
|
8
|
+
/** Default location of opencode's message store. */
|
|
9
|
+
export function defaultOpencodeDb(): string {
|
|
10
|
+
if (process.env.OPENCODE_DB) return process.env.OPENCODE_DB;
|
|
11
|
+
const base =
|
|
12
|
+
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
|
13
|
+
return join(base, "opencode", "opencode.db");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AssistantData {
|
|
17
|
+
role?: string;
|
|
18
|
+
cost?: number;
|
|
19
|
+
modelID?: string;
|
|
20
|
+
providerID?: string;
|
|
21
|
+
tokens?: {
|
|
22
|
+
input?: number;
|
|
23
|
+
output?: number;
|
|
24
|
+
reasoning?: number;
|
|
25
|
+
cache?: { read?: number; write?: number };
|
|
26
|
+
};
|
|
27
|
+
time?: { created?: number; completed?: number };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function numberOr(value: unknown): number {
|
|
31
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OpencodeDbUsage {
|
|
35
|
+
entries: UsageEntry[];
|
|
36
|
+
/** Providers seen, for the report's source line. */
|
|
37
|
+
providers: string[];
|
|
38
|
+
/** Total assistant records scanned, before de-duplication. */
|
|
39
|
+
records: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Aggregate per-model usage from opencode's own message store. This is the
|
|
44
|
+
* complete, backfilled record for every provider opencode ran — unlike the
|
|
45
|
+
* provider plugin's log, which only sees its own traffic.
|
|
46
|
+
*
|
|
47
|
+
* Reads v2's `session_message` when the store has it, and the legacy `message`
|
|
48
|
+
* table otherwise, so an upgraded opencode does not read as "no usage". Read-only
|
|
49
|
+
* so a running opencode is unaffected; returns null when the DB is unusable.
|
|
50
|
+
*/
|
|
51
|
+
export function readOpencodeDb(
|
|
52
|
+
path: string,
|
|
53
|
+
since?: Date,
|
|
54
|
+
): OpencodeDbUsage | null {
|
|
55
|
+
let db: Database;
|
|
56
|
+
try {
|
|
57
|
+
db = new Database(path, { readonly: true });
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const sinceMs = since ? since.getTime() : 0;
|
|
63
|
+
const turns = hasV2(db)
|
|
64
|
+
? readV2Turns(db, sinceMs)
|
|
65
|
+
: readLegacyTurns(db, sinceMs);
|
|
66
|
+
|
|
67
|
+
const byKey = new Map<string, UsageEntry>();
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const providers = new Set<string>();
|
|
70
|
+
for (const turn of turns) {
|
|
71
|
+
if (seen.has(turn.id)) continue; // turn rows can be rewritten in place
|
|
72
|
+
seen.add(turn.id);
|
|
73
|
+
providers.add(turn.providerID);
|
|
74
|
+
|
|
75
|
+
const key = normalizeKey(turn.modelID);
|
|
76
|
+
const entry = byKey.get(key) ?? {
|
|
77
|
+
key,
|
|
78
|
+
name: turn.modelID,
|
|
79
|
+
requests: 0,
|
|
80
|
+
tokensIn: 0,
|
|
81
|
+
cacheRead: 0,
|
|
82
|
+
cacheWrite: 0,
|
|
83
|
+
tokensOut: 0,
|
|
84
|
+
reasoning: 0,
|
|
85
|
+
costUsd: 0,
|
|
86
|
+
};
|
|
87
|
+
entry.requests += 1;
|
|
88
|
+
entry.tokensIn += numberOr(turn.tokens.input);
|
|
89
|
+
entry.cacheRead += numberOr(turn.tokens.cache?.read);
|
|
90
|
+
entry.cacheWrite += numberOr(turn.tokens.cache?.write);
|
|
91
|
+
entry.tokensOut += numberOr(turn.tokens.output);
|
|
92
|
+
entry.reasoning += numberOr(turn.tokens.reasoning);
|
|
93
|
+
entry.costUsd += numberOr(turn.cost);
|
|
94
|
+
byKey.set(key, entry);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
entries: [...byKey.values()],
|
|
98
|
+
providers: [...providers],
|
|
99
|
+
records: turns.length,
|
|
100
|
+
};
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
} finally {
|
|
104
|
+
db.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The pre-v2 layout: `message.data` carries the model, cost and tokens flat. */
|
|
109
|
+
function readLegacyTurns(db: Database, sinceMs: number): Turn[] {
|
|
110
|
+
const rows = db
|
|
111
|
+
.query(
|
|
112
|
+
`SELECT data FROM message
|
|
113
|
+
WHERE data LIKE '%"tokens"%' AND time_created >= ?`,
|
|
114
|
+
)
|
|
115
|
+
.all(sinceMs) as Array<{ data: string }>;
|
|
116
|
+
const turns: Turn[] = [];
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
let data: AssistantData;
|
|
119
|
+
try {
|
|
120
|
+
data = JSON.parse(row.data) as AssistantData;
|
|
121
|
+
} catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (data.role !== "assistant" || !data.modelID || !data.tokens)
|
|
125
|
+
continue;
|
|
126
|
+
turns.push({
|
|
127
|
+
id: `${data.modelID}@${(data.time?.created ?? 0) as number}`,
|
|
128
|
+
modelID: data.modelID,
|
|
129
|
+
providerID: data.providerID ?? "unknown",
|
|
130
|
+
cost: numberOr(data.cost),
|
|
131
|
+
tokens: data.tokens,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return turns;
|
|
135
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
/** One assistant turn, normalised across the two store layouts. */
|
|
4
|
+
export interface Turn {
|
|
5
|
+
id: string;
|
|
6
|
+
modelID: string;
|
|
7
|
+
providerID: string;
|
|
8
|
+
cost: number;
|
|
9
|
+
tokens: {
|
|
10
|
+
input?: number;
|
|
11
|
+
output?: number;
|
|
12
|
+
/** Separate counter, billed at the output rate on top of `output`. */
|
|
13
|
+
reasoning?: number;
|
|
14
|
+
cache?: { read?: number; write?: number };
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Does this store have v2's tables? opencode v2 appends to `session_message`
|
|
20
|
+
* and stopped writing `message` at the migration, so the legacy query reads
|
|
21
|
+
* nothing for any session since — which is how this store looked empty.
|
|
22
|
+
*/
|
|
23
|
+
export function hasV2(db: Database): boolean {
|
|
24
|
+
return Boolean(
|
|
25
|
+
db
|
|
26
|
+
.query(
|
|
27
|
+
"SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'session_message'",
|
|
28
|
+
)
|
|
29
|
+
.get(),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* v2 turns: a *completed* assistant row carries `model`, `cost` and `tokens`; an
|
|
35
|
+
* in-flight one carries only the model, so it is skipped until it finishes.
|
|
36
|
+
*/
|
|
37
|
+
export function readV2Turns(db: Database, sinceMs: number): Turn[] {
|
|
38
|
+
const rows = db
|
|
39
|
+
.query(
|
|
40
|
+
`SELECT id, data FROM session_message
|
|
41
|
+
WHERE type = 'assistant' AND time_created >= ?`,
|
|
42
|
+
)
|
|
43
|
+
.all(sinceMs) as Array<{ id: string; data: string }>;
|
|
44
|
+
const turns: Turn[] = [];
|
|
45
|
+
for (const row of rows) {
|
|
46
|
+
let data: Record<string, unknown>;
|
|
47
|
+
try {
|
|
48
|
+
data = JSON.parse(row.data) as Record<string, unknown>;
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const model = (data.model ?? {}) as {
|
|
53
|
+
id?: unknown;
|
|
54
|
+
providerID?: unknown;
|
|
55
|
+
};
|
|
56
|
+
const tokens = data.tokens as Turn["tokens"] | undefined;
|
|
57
|
+
if (typeof model.id !== "string" || !tokens) continue;
|
|
58
|
+
turns.push({
|
|
59
|
+
id: row.id,
|
|
60
|
+
modelID: model.id,
|
|
61
|
+
providerID:
|
|
62
|
+
typeof model.providerID === "string"
|
|
63
|
+
? model.providerID
|
|
64
|
+
: "unknown",
|
|
65
|
+
cost: typeof data.cost === "number" ? data.cost : 0,
|
|
66
|
+
tokens,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return turns;
|
|
70
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { runCmduse } from "~/data/cmduse.ts";
|
|
2
|
+
import { normalizeKey } from "~/keys.ts";
|
|
3
|
+
|
|
4
|
+
export interface UsageEntry {
|
|
5
|
+
key: string;
|
|
6
|
+
name: string;
|
|
7
|
+
requests: number;
|
|
8
|
+
tokensIn: number;
|
|
9
|
+
cacheRead: number;
|
|
10
|
+
cacheWrite: number;
|
|
11
|
+
tokensOut: number;
|
|
12
|
+
/** Reasoning tokens, billed at the output rate on top of `tokensOut`. */
|
|
13
|
+
reasoning: number;
|
|
14
|
+
costUsd: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AccountSummary {
|
|
18
|
+
requests: number;
|
|
19
|
+
cost: number;
|
|
20
|
+
periodEnd?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type CmduseModel = Omit<UsageEntry, "key" | "name"> & Record<string, number>;
|
|
24
|
+
|
|
25
|
+
function entryFrom(id: string, row: CmduseModel): UsageEntry {
|
|
26
|
+
return {
|
|
27
|
+
key: normalizeKey(id),
|
|
28
|
+
name: id,
|
|
29
|
+
requests: Number(row.requests ?? 0),
|
|
30
|
+
tokensIn: Number(row.tokensIn ?? 0),
|
|
31
|
+
cacheRead: Number(row.cacheRead ?? 0),
|
|
32
|
+
cacheWrite: Number(row.cacheWrite ?? 0),
|
|
33
|
+
tokensOut: Number(row.tokensOut ?? 0),
|
|
34
|
+
reasoning: Number(row.reasoning ?? 0),
|
|
35
|
+
costUsd: Number(row.costUsd ?? 0),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Accepts the cmduse shape, `{entries: [...]}` or a bare array. */
|
|
40
|
+
export function parseUsage(text: string): UsageEntry[] {
|
|
41
|
+
const body = JSON.parse(text) as unknown;
|
|
42
|
+
const rows: UsageEntry[] = [];
|
|
43
|
+
const push = (id: string, row: unknown): void => {
|
|
44
|
+
if (!id || !row || typeof row !== "object") return;
|
|
45
|
+
rows.push(entryFrom(id, row as CmduseModel));
|
|
46
|
+
};
|
|
47
|
+
if (Array.isArray(body)) {
|
|
48
|
+
for (const row of body) {
|
|
49
|
+
const record = (row ?? {}) as Record<string, unknown>;
|
|
50
|
+
push(String(record.model ?? record.name ?? ""), record);
|
|
51
|
+
}
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
const record = (body ?? {}) as Record<string, unknown>;
|
|
55
|
+
if (record.models && typeof record.models === "object") {
|
|
56
|
+
for (const [id, row] of Object.entries(record.models)) push(id, row);
|
|
57
|
+
return rows;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(record.entries)) {
|
|
60
|
+
for (const row of record.entries) {
|
|
61
|
+
const item = (row ?? {}) as Record<string, unknown>;
|
|
62
|
+
push(String(item.model ?? item.name ?? ""), row);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return rows;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Best-effort account summary, used only for the coverage line. */
|
|
69
|
+
export async function accountSummary(): Promise<AccountSummary | undefined> {
|
|
70
|
+
const result = await runCmduse(["-1", "--json", "-p"]);
|
|
71
|
+
if (!result.ok) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
const body = JSON.parse(result.stdout) as {
|
|
74
|
+
summary?: { requests?: number; cost?: number };
|
|
75
|
+
periodEnd?: string;
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
requests: Number(body.summary?.requests ?? 0),
|
|
79
|
+
cost: Number(body.summary?.cost ?? 0),
|
|
80
|
+
periodEnd: body.periodEnd,
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { run } from "./cli/run.ts";
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
process.exitCode = await run(process.argv.slice(2));
|
|
6
|
+
} catch (error) {
|
|
7
|
+
console.error(
|
|
8
|
+
`mpc: ${error instanceof Error ? error.message : String(error)}`,
|
|
9
|
+
);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
}
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ALIASES, BOUNDARY } from "~/constants/data.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collapse a model name from either catalog onto a shared key.
|
|
5
|
+
*
|
|
6
|
+
* "deepseek/deepseek-v4.1-flash" -> "deepseekv41flash"
|
|
7
|
+
* "DeepSeek V4.1 Flash (Off-Peak)" -> "deepseekv41flash"
|
|
8
|
+
* "Qwen/Qwen3.8-Max" -> "qwen38max"
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeKey(name: string): string {
|
|
11
|
+
let s = name.trim().toLowerCase();
|
|
12
|
+
// Drop parenthetical qualifiers: (Off-Peak), (> 256K tokens), (latest), (exp).
|
|
13
|
+
s = s.replace(/\([^)]*\)/g, " ");
|
|
14
|
+
// Drop a vendor prefix like "deepseek/", "z-ai/", "opencode-go/".
|
|
15
|
+
const parts = s.split("/");
|
|
16
|
+
s = parts[parts.length - 1] ?? s;
|
|
17
|
+
s = s.replace(/[^a-z0-9]+/g, "");
|
|
18
|
+
return ALIASES[s] ?? s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Display name: strip parentheticals and collapse whitespace. */
|
|
22
|
+
export function displayName(name: string): string {
|
|
23
|
+
return name
|
|
24
|
+
.split(BOUNDARY)
|
|
25
|
+
.join(" ")
|
|
26
|
+
.replace(/\([^)]*\)/g, " ")
|
|
27
|
+
.replace(/\s+/g, " ")
|
|
28
|
+
.trim();
|
|
29
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export type ProviderId = "oc-go" | "cc";
|
|
2
|
+
|
|
3
|
+
/** Per-1M-token rates in USD. */
|
|
4
|
+
export interface ModelPricing {
|
|
5
|
+
input: number;
|
|
6
|
+
output: number;
|
|
7
|
+
cacheRead: number;
|
|
8
|
+
cacheWrite: number | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** One model's pricing + monthly credit allowance on one provider's plan. */
|
|
12
|
+
export interface CatalogEntry {
|
|
13
|
+
provider: ProviderId;
|
|
14
|
+
plan: string;
|
|
15
|
+
/** Canonical cross-provider key (see normalize.ts). */
|
|
16
|
+
key: string;
|
|
17
|
+
/** Human display name. */
|
|
18
|
+
name: string;
|
|
19
|
+
pricing: ModelPricing;
|
|
20
|
+
/** Monthly credits ($) this plan devotes to this model. */
|
|
21
|
+
allowance: number;
|
|
22
|
+
/** Promotion CommandCode attaches to the row, when one is running. */
|
|
23
|
+
deal?: Deal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A promotion chip on a model row: "-40%", "Free", "2x usage". */
|
|
27
|
+
export interface Deal {
|
|
28
|
+
badge: string;
|
|
29
|
+
/** The expiry line the page prints beside it, verbatim. */
|
|
30
|
+
ends?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PlanInfo {
|
|
34
|
+
provider: ProviderId;
|
|
35
|
+
/** CLI id, e.g. "goat". */
|
|
36
|
+
id: string;
|
|
37
|
+
label: string;
|
|
38
|
+
/** Subscription USD per month. */
|
|
39
|
+
price: number;
|
|
40
|
+
/** Total monthly credits ($). */
|
|
41
|
+
credits: number;
|
|
42
|
+
fiveHour: number | null;
|
|
43
|
+
weekly: number | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fixed per-request token counts used for every model on both providers. */
|
|
47
|
+
export interface Workload {
|
|
48
|
+
input: number;
|
|
49
|
+
cacheRead: number;
|
|
50
|
+
output: number;
|
|
51
|
+
/** Reasoning tokens, billed at the output rate on top of `output`. */
|
|
52
|
+
reasoning: number;
|
|
53
|
+
/** Cache-write tokens, billed at the model's cache-write rate. */
|
|
54
|
+
cacheWrite: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EntryMetrics {
|
|
58
|
+
provider: ProviderId;
|
|
59
|
+
plan: string;
|
|
60
|
+
pricing: ModelPricing;
|
|
61
|
+
allowance: number;
|
|
62
|
+
/** USD of list-rate spend for one request. */
|
|
63
|
+
costPerRequest: number;
|
|
64
|
+
/** Requests a month fits in this model's allowance. Infinity when free. */
|
|
65
|
+
requestsPerMonth: number;
|
|
66
|
+
/** Requests the plan's 5-hour window allows for this model. */
|
|
67
|
+
requestsPerFiveHour: number;
|
|
68
|
+
/** Requests the plan's weekly window allows for this model. */
|
|
69
|
+
requestsPerWeek: number;
|
|
70
|
+
/** What one request actually costs you on the plan. */
|
|
71
|
+
payPerRequest: number;
|
|
72
|
+
/** Allowance per dollar of subscription — "$usage per $paid". */
|
|
73
|
+
multiplier: number;
|
|
74
|
+
/** Benchmark score for the model, or null when unscored. */
|
|
75
|
+
ability: number | null;
|
|
76
|
+
/** Output tokens per second, or null when unknown. */
|
|
77
|
+
tps: number | null;
|
|
78
|
+
/** Promotion this entry's provider is running on the model, if any. */
|
|
79
|
+
deal?: Deal;
|
|
80
|
+
/** 0-100 blended value score, higher is better. */
|
|
81
|
+
index: number;
|
|
82
|
+
/** 0-100 ability-aware value score, null when the model is unscored. */
|
|
83
|
+
valueIndex: number | null;
|
|
84
|
+
free: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CompareRow {
|
|
88
|
+
key: string;
|
|
89
|
+
name: string;
|
|
90
|
+
oc?: EntryMetrics;
|
|
91
|
+
cc?: EntryMetrics;
|
|
92
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Column } from "~/view/schema.ts";
|
|
2
|
+
import * as f from "~/view/text/index.ts";
|
|
3
|
+
|
|
4
|
+
export const CC_COLUMNS: Record<string, Column> = {
|
|
5
|
+
"cc-rates": {
|
|
6
|
+
header: "rates",
|
|
7
|
+
value: f.ccRates,
|
|
8
|
+
style: f.tintOrFree("cc"),
|
|
9
|
+
drop: 1,
|
|
10
|
+
},
|
|
11
|
+
"cc-allow": {
|
|
12
|
+
header: "allow",
|
|
13
|
+
value: (r) => f.fmtAllowance(r.cc),
|
|
14
|
+
right: true,
|
|
15
|
+
style: f.tintOrFree("cc"),
|
|
16
|
+
},
|
|
17
|
+
"cc-req5h": {
|
|
18
|
+
header: "5h",
|
|
19
|
+
drop: 3,
|
|
20
|
+
value: (r) => f.fmtRequests(r.cc?.requestsPerFiveHour, Boolean(r.cc)),
|
|
21
|
+
right: true,
|
|
22
|
+
style: f.tint("cc"),
|
|
23
|
+
},
|
|
24
|
+
"cc-reqwk": {
|
|
25
|
+
header: "wk",
|
|
26
|
+
drop: 3,
|
|
27
|
+
value: (r) => f.fmtRequests(r.cc?.requestsPerWeek, Boolean(r.cc)),
|
|
28
|
+
right: true,
|
|
29
|
+
style: f.tint("cc"),
|
|
30
|
+
},
|
|
31
|
+
"cc-reqmo": {
|
|
32
|
+
header: "mo",
|
|
33
|
+
value: (r) => f.fmtRequests(r.cc?.requestsPerMonth, Boolean(r.cc)),
|
|
34
|
+
right: true,
|
|
35
|
+
style: f.tint("cc"),
|
|
36
|
+
},
|
|
37
|
+
"cc-per1k": {
|
|
38
|
+
header: "$/1K",
|
|
39
|
+
value: (r) => f.fmtPerThousand(r.cc),
|
|
40
|
+
right: true,
|
|
41
|
+
style: f.sideStyle("cc"),
|
|
42
|
+
},
|
|
43
|
+
"cc-reqdollar": {
|
|
44
|
+
header: "req/$",
|
|
45
|
+
value: (r) => f.fmtPerDollar(r.cc),
|
|
46
|
+
right: true,
|
|
47
|
+
style: f.sideStyle("cc"),
|
|
48
|
+
drop: 2,
|
|
49
|
+
},
|
|
50
|
+
};
|