@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,41 @@
|
|
|
1
|
+
import { SGR } from "~/constants/view.ts";
|
|
2
|
+
import type { Column } from "~/view/schema.ts";
|
|
3
|
+
import * as f from "~/view/text/index.ts";
|
|
4
|
+
|
|
5
|
+
export const META_COLUMNS: Record<string, Column> = {
|
|
6
|
+
model: { header: "MODEL", value: (r) => r.name, style: () => SGR.bold },
|
|
7
|
+
win: { header: "WIN", value: f.winner, style: f.winnerStyle },
|
|
8
|
+
cost: {
|
|
9
|
+
header: "COST",
|
|
10
|
+
value: f.bestCost,
|
|
11
|
+
right: true,
|
|
12
|
+
style: f.costStyle,
|
|
13
|
+
},
|
|
14
|
+
ability: {
|
|
15
|
+
header: "ability",
|
|
16
|
+
value: f.fmtAbility,
|
|
17
|
+
right: true,
|
|
18
|
+
style: f.abilityStyle,
|
|
19
|
+
},
|
|
20
|
+
tps: {
|
|
21
|
+
header: "tps",
|
|
22
|
+
value: f.fmtTps,
|
|
23
|
+
right: true,
|
|
24
|
+
style: (r) => f.enabledStyle(r.oc?.tps ?? r.cc?.tps ?? null),
|
|
25
|
+
drop: 4,
|
|
26
|
+
},
|
|
27
|
+
deal: {
|
|
28
|
+
header: "DEAL",
|
|
29
|
+
// The badge alone: the expiry line would blow the column out, and it
|
|
30
|
+
// rides along in --json for the sidebar, which has room for it.
|
|
31
|
+
value: (r) => r.cc?.deal?.badge ?? "—",
|
|
32
|
+
style: (r) => (r.cc?.deal ? SGR.green : SGR.dim),
|
|
33
|
+
drop: 3,
|
|
34
|
+
},
|
|
35
|
+
val: {
|
|
36
|
+
header: "VAL",
|
|
37
|
+
value: f.bestValue,
|
|
38
|
+
right: true,
|
|
39
|
+
style: f.valueStyle,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -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 OC_COLUMNS: Record<string, Column> = {
|
|
5
|
+
"oc-rates": {
|
|
6
|
+
header: "rates",
|
|
7
|
+
value: f.ocRates,
|
|
8
|
+
style: f.tintOrFree("oc-go"),
|
|
9
|
+
drop: 1,
|
|
10
|
+
},
|
|
11
|
+
"oc-allow": {
|
|
12
|
+
header: "allow",
|
|
13
|
+
value: (r) => f.fmtAllowance(r.oc),
|
|
14
|
+
right: true,
|
|
15
|
+
style: f.tintOrFree("oc-go"),
|
|
16
|
+
},
|
|
17
|
+
"oc-req5h": {
|
|
18
|
+
header: "5h",
|
|
19
|
+
drop: 3,
|
|
20
|
+
value: (r) => f.fmtRequests(r.oc?.requestsPerFiveHour, Boolean(r.oc)),
|
|
21
|
+
right: true,
|
|
22
|
+
style: f.tint("oc-go"),
|
|
23
|
+
},
|
|
24
|
+
"oc-reqwk": {
|
|
25
|
+
header: "wk",
|
|
26
|
+
drop: 3,
|
|
27
|
+
value: (r) => f.fmtRequests(r.oc?.requestsPerWeek, Boolean(r.oc)),
|
|
28
|
+
right: true,
|
|
29
|
+
style: f.tint("oc-go"),
|
|
30
|
+
},
|
|
31
|
+
"oc-reqmo": {
|
|
32
|
+
header: "mo",
|
|
33
|
+
value: (r) => f.fmtRequests(r.oc?.requestsPerMonth, Boolean(r.oc)),
|
|
34
|
+
right: true,
|
|
35
|
+
style: f.tint("oc-go"),
|
|
36
|
+
},
|
|
37
|
+
"oc-per1k": {
|
|
38
|
+
header: "$/1K",
|
|
39
|
+
value: (r) => f.fmtPerThousand(r.oc),
|
|
40
|
+
right: true,
|
|
41
|
+
style: f.sideStyle("oc"),
|
|
42
|
+
},
|
|
43
|
+
"oc-reqdollar": {
|
|
44
|
+
header: "req/$",
|
|
45
|
+
value: (r) => f.fmtPerDollar(r.oc),
|
|
46
|
+
right: true,
|
|
47
|
+
style: f.sideStyle("oc"),
|
|
48
|
+
drop: 2,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { LEGEND, PROVIDER_COLOR, SGR } from "~/constants/view.ts";
|
|
2
|
+
import type { CompareRow, PlanInfo, Workload } from "~/types.ts";
|
|
3
|
+
import { tally } from "./layout/segments.ts";
|
|
4
|
+
import type { ReportMeta } from "./schema.ts";
|
|
5
|
+
import {
|
|
6
|
+
paint,
|
|
7
|
+
planTitle,
|
|
8
|
+
providerName,
|
|
9
|
+
shortProviderName,
|
|
10
|
+
} from "./text/index.ts";
|
|
11
|
+
|
|
12
|
+
/** One side's per-request token vector; fields with no measured signal stay out. */
|
|
13
|
+
function shapeText(workload: Workload): string {
|
|
14
|
+
const parts = [
|
|
15
|
+
`${workload.input.toLocaleString("en-US")} input`,
|
|
16
|
+
`${workload.cacheRead.toLocaleString("en-US")} cache-read`,
|
|
17
|
+
`${workload.output.toLocaleString("en-US")} output`,
|
|
18
|
+
];
|
|
19
|
+
if (workload.reasoning > 0) {
|
|
20
|
+
parts.push(`${workload.reasoning.toLocaleString("en-US")} reasoning`);
|
|
21
|
+
}
|
|
22
|
+
if (workload.cacheWrite > 0) {
|
|
23
|
+
parts.push(
|
|
24
|
+
`${workload.cacheWrite.toLocaleString("en-US")} cache-write`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return parts.join(" · ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function planBlock(plan: PlanInfo): { title: string; rest: string } {
|
|
31
|
+
if (plan.provider === "oc-go") {
|
|
32
|
+
// Per-model limits, no shared pool, so the sum is only an upper bound.
|
|
33
|
+
return {
|
|
34
|
+
title: planTitle(plan),
|
|
35
|
+
rest: `$${plan.price}/mo · per-model limits (sum $${plan.credits})`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const window =
|
|
39
|
+
plan.fiveHour !== null && plan.weekly !== null
|
|
40
|
+
? ` · 5h $${plan.fiveHour} / wk $${plan.weekly}`
|
|
41
|
+
: "";
|
|
42
|
+
return {
|
|
43
|
+
title: planTitle(plan),
|
|
44
|
+
rest: `$${plan.price}/mo · $${plan.credits} credits${window}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function windowPercents(plan: PlanInfo, override?: [number, number]): string {
|
|
49
|
+
if (override) {
|
|
50
|
+
return `${Math.round(override[0] * 100)}% / ${Math.round(override[1] * 100)}%`;
|
|
51
|
+
}
|
|
52
|
+
if (plan.provider === "oc-go") return "20% / 50%";
|
|
53
|
+
if (plan.fiveHour !== null && plan.weekly !== null && plan.credits > 0) {
|
|
54
|
+
return `${Math.round((plan.fiveHour / plan.credits) * 100)}% / ${Math.round(
|
|
55
|
+
(plan.weekly / plan.credits) * 100,
|
|
56
|
+
)}%`;
|
|
57
|
+
}
|
|
58
|
+
return "—";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function footer(rows: CompareRow[], meta: ReportMeta): void {
|
|
62
|
+
// A key/value list: labels bold, values plain. It used to be SGR 2 end to
|
|
63
|
+
// end, which made every label as faint as its value and read as one grey
|
|
64
|
+
// block (the shape decision among it).
|
|
65
|
+
const label = (text: string): string => paint(SGR.bold, text.padEnd(10));
|
|
66
|
+
const line = (key: string, text: string): void =>
|
|
67
|
+
console.log(`${label(key)}${text}`);
|
|
68
|
+
const plans = [meta.ocPlan, meta.ccPlan].map(planBlock);
|
|
69
|
+
const planWidth = Math.max(...plans.map((p) => p.title.length));
|
|
70
|
+
const [ocBlock, ccBlock] = plans;
|
|
71
|
+
|
|
72
|
+
// A measured shape is one profile for both plans, so this reads as a single
|
|
73
|
+
// line; a second is printed only if the two sides ever diverge.
|
|
74
|
+
const ocWorkload = meta.workloads["oc-go"];
|
|
75
|
+
const ccWorkload = meta.workloads.cc;
|
|
76
|
+
const perSide = JSON.stringify(ocWorkload) !== JSON.stringify(ccWorkload);
|
|
77
|
+
line(
|
|
78
|
+
"workload",
|
|
79
|
+
`${perSide ? `${shortProviderName("oc-go")} ` : ""}${shapeText(ocWorkload)} tokens per request`,
|
|
80
|
+
);
|
|
81
|
+
if (perSide) {
|
|
82
|
+
line(
|
|
83
|
+
"",
|
|
84
|
+
`${shortProviderName("cc")} ${shapeText(ccWorkload)} tokens per request`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (meta.shapeNote) line("shape", meta.shapeNote);
|
|
88
|
+
|
|
89
|
+
console.log(
|
|
90
|
+
`${label("plans")}${paint(PROVIDER_COLOR["oc-go"], (ocBlock?.title ?? "").padEnd(planWidth))} ${ocBlock?.rest ?? ""}`,
|
|
91
|
+
);
|
|
92
|
+
console.log(
|
|
93
|
+
`${label("")}${paint(PROVIDER_COLOR.cc, (ccBlock?.title ?? "").padEnd(planWidth))} ${ccBlock?.rest ?? ""}`,
|
|
94
|
+
);
|
|
95
|
+
line(
|
|
96
|
+
"windows",
|
|
97
|
+
`rolling caps as % of monthly allowance: ${planTitle(meta.ocPlan)} ${windowPercents(meta.ocPlan, meta.window)} · ${planTitle(meta.ccPlan)} ${windowPercents(meta.ccPlan, meta.window)}`,
|
|
98
|
+
);
|
|
99
|
+
if (meta.abilityLabel) {
|
|
100
|
+
line(
|
|
101
|
+
"ability",
|
|
102
|
+
`${meta.abilityLabel}${meta.abilityNote ? ` · ${meta.abilityNote}` : ""}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const t = tally(rows);
|
|
107
|
+
const parts = [
|
|
108
|
+
`${providerName("oc-go")} ${t.ocWins}`,
|
|
109
|
+
`${providerName("cc")} ${t.ccWins}`,
|
|
110
|
+
];
|
|
111
|
+
if (t.ties > 0) parts.push(`tie ${t.ties}`);
|
|
112
|
+
line("wins", `head-to-head ${t.headToHead} shared: ${parts.join(" · ")}`);
|
|
113
|
+
line(
|
|
114
|
+
"",
|
|
115
|
+
`exclusive: ${providerName("oc-go")} ${t.ocOnly} · ${providerName("cc")} ${t.ccOnly}`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
LEGEND.forEach(([key, text], i) => {
|
|
119
|
+
line(
|
|
120
|
+
i === 0 ? "legend" : "",
|
|
121
|
+
`${paint(SGR.bold, (key ?? "").padEnd(9))}${text}`,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { BAR } from "~/constants/view.ts";
|
|
2
|
+
import type { Column, FitResult, Row } from "~/view/schema.ts";
|
|
3
|
+
import { columns } from "~/view/schema.ts";
|
|
4
|
+
import { buildSegments } from "./segments.ts";
|
|
5
|
+
|
|
6
|
+
function measure(
|
|
7
|
+
rows: Row[],
|
|
8
|
+
ids: string[],
|
|
9
|
+
): { cols: Column[]; widths: number[] } {
|
|
10
|
+
const cols = columns(ids);
|
|
11
|
+
const widths = cols.map((c) => c.header.length);
|
|
12
|
+
for (const row of rows) {
|
|
13
|
+
cols.forEach((c, i) => {
|
|
14
|
+
const len = c.value(row).length;
|
|
15
|
+
if (len > (widths[i] ?? 0)) widths[i] = len;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return { cols, widths };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Printed width of the whole table, in characters (ANSI colour not counted). */
|
|
22
|
+
export function tableWidth(rows: Row[], ids: string[]): number {
|
|
23
|
+
const { cols, widths } = measure(rows, ids);
|
|
24
|
+
const segs = buildSegments(ids, cols, widths);
|
|
25
|
+
if (segs.length === 0) return 0;
|
|
26
|
+
return (
|
|
27
|
+
segs.reduce((sum, seg) => sum + seg.width, 0) +
|
|
28
|
+
BAR.length * (segs.length - 1)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Drop optional columns, widest-first, until the table fits `limit`. Columns
|
|
34
|
+
* with the same `drop` value are removed together, so both providers stay
|
|
35
|
+
* symmetric.
|
|
36
|
+
*/
|
|
37
|
+
export function fitColumns(
|
|
38
|
+
rows: Row[],
|
|
39
|
+
ids: string[],
|
|
40
|
+
limit: number,
|
|
41
|
+
): FitResult {
|
|
42
|
+
let current = [...ids];
|
|
43
|
+
const dropped: string[] = [];
|
|
44
|
+
while (limit > 0 && tableWidth(rows, current) > limit) {
|
|
45
|
+
const levels = current
|
|
46
|
+
.map((id) => columns([id])[0]?.drop)
|
|
47
|
+
.filter((drop): drop is number => drop !== undefined);
|
|
48
|
+
if (levels.length === 0) break;
|
|
49
|
+
const lowest = Math.min(...levels);
|
|
50
|
+
const remove = new Set(
|
|
51
|
+
current.filter((id) => columns([id])[0]?.drop === lowest),
|
|
52
|
+
);
|
|
53
|
+
if (remove.size === 0) break;
|
|
54
|
+
current = current.filter((id) => !remove.has(id));
|
|
55
|
+
dropped.push(...remove);
|
|
56
|
+
}
|
|
57
|
+
return { ids: current, dropped, width: tableWidth(rows, current) };
|
|
58
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { GAP } from "~/constants/view.ts";
|
|
2
|
+
import type { Column, GroupKey, Segment } from "~/view/schema.ts";
|
|
3
|
+
import { paint } from "~/view/text/index.ts";
|
|
4
|
+
|
|
5
|
+
function groupOf(id: string): GroupKey {
|
|
6
|
+
if (id === "model") return "model";
|
|
7
|
+
if (id.startsWith("oc-")) return "oc";
|
|
8
|
+
if (id.startsWith("cc-")) return "cc";
|
|
9
|
+
return "misc";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildSegments(
|
|
13
|
+
ids: string[],
|
|
14
|
+
cols: Column[],
|
|
15
|
+
widths: number[],
|
|
16
|
+
): Segment[] {
|
|
17
|
+
const segs: Segment[] = [];
|
|
18
|
+
ids.forEach((id, i) => {
|
|
19
|
+
const column = cols[i];
|
|
20
|
+
if (!column) return;
|
|
21
|
+
const key = groupOf(id);
|
|
22
|
+
const last = segs[segs.length - 1];
|
|
23
|
+
if (!last || last.key !== key) {
|
|
24
|
+
segs.push({
|
|
25
|
+
key,
|
|
26
|
+
columns: [column],
|
|
27
|
+
widths: [widths[i] ?? 0],
|
|
28
|
+
width: 0,
|
|
29
|
+
});
|
|
30
|
+
} else {
|
|
31
|
+
last.columns.push(column);
|
|
32
|
+
last.widths.push(widths[i] ?? 0);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
for (const seg of segs) {
|
|
36
|
+
seg.width =
|
|
37
|
+
seg.widths.reduce((a, b) => a + b, 0) +
|
|
38
|
+
GAP.length * (seg.widths.length - 1);
|
|
39
|
+
}
|
|
40
|
+
return segs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cellText(
|
|
44
|
+
col: Column,
|
|
45
|
+
text: string,
|
|
46
|
+
width: number,
|
|
47
|
+
alignRight: boolean,
|
|
48
|
+
): string {
|
|
49
|
+
if (col.pad === false) return text;
|
|
50
|
+
return alignRight && col.right ? text.padStart(width) : text.padEnd(width);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderSegment(
|
|
54
|
+
seg: Segment,
|
|
55
|
+
cells: string[],
|
|
56
|
+
alignRight: boolean,
|
|
57
|
+
styles?: (string | undefined)[],
|
|
58
|
+
): string {
|
|
59
|
+
return seg.columns
|
|
60
|
+
.map((col, i) => {
|
|
61
|
+
const text = cellText(
|
|
62
|
+
col,
|
|
63
|
+
cells[i] ?? "",
|
|
64
|
+
seg.widths[i] ?? 0,
|
|
65
|
+
alignRight,
|
|
66
|
+
);
|
|
67
|
+
const code = styles?.[i];
|
|
68
|
+
return code ? paint(code, text) : text;
|
|
69
|
+
})
|
|
70
|
+
.join(GAP);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
import type { Row, Tally } from "~/view/schema.ts";
|
|
74
|
+
|
|
75
|
+
export function tally(rows: Row[]): Tally {
|
|
76
|
+
const result: Tally = {
|
|
77
|
+
headToHead: 0,
|
|
78
|
+
ocWins: 0,
|
|
79
|
+
ccWins: 0,
|
|
80
|
+
ties: 0,
|
|
81
|
+
ocOnly: 0,
|
|
82
|
+
ccOnly: 0,
|
|
83
|
+
};
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
if (row.oc && row.cc) {
|
|
86
|
+
result.headToHead++;
|
|
87
|
+
if (row.oc.payPerRequest === row.cc.payPerRequest) result.ties++;
|
|
88
|
+
else if (row.oc.payPerRequest < row.cc.payPerRequest)
|
|
89
|
+
result.ocWins++;
|
|
90
|
+
else result.ccWins++;
|
|
91
|
+
} else if (row.oc) {
|
|
92
|
+
result.ocOnly++;
|
|
93
|
+
} else if (row.cc) {
|
|
94
|
+
result.ccOnly++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { BAR, BAR_RULE, SGR } from "~/constants/view.ts";
|
|
2
|
+
import type { Column, GroupKey, GroupLabel, Row } from "~/view/schema.ts";
|
|
3
|
+
import { columns } from "~/view/schema.ts";
|
|
4
|
+
import { paint } from "~/view/text/index.ts";
|
|
5
|
+
import { buildSegments, renderSegment } from "./segments.ts";
|
|
6
|
+
|
|
7
|
+
function measure(
|
|
8
|
+
rows: Row[],
|
|
9
|
+
ids: string[],
|
|
10
|
+
): { cols: Column[]; widths: number[] } {
|
|
11
|
+
const cols = columns(ids);
|
|
12
|
+
const widths = cols.map((c) => c.header.length);
|
|
13
|
+
for (const row of rows) {
|
|
14
|
+
cols.forEach((c, i) => {
|
|
15
|
+
const len = c.value(row).length;
|
|
16
|
+
if (len > (widths[i] ?? 0)) widths[i] = len;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return { cols, widths };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function printTable(
|
|
23
|
+
rows: Row[],
|
|
24
|
+
columnIds: string[],
|
|
25
|
+
labels: Partial<Record<GroupKey, GroupLabel>>,
|
|
26
|
+
): void {
|
|
27
|
+
const { cols, widths } = measure(rows, columnIds);
|
|
28
|
+
const segs = buildSegments(columnIds, cols, widths);
|
|
29
|
+
const bar = paint(SGR.dim, BAR);
|
|
30
|
+
|
|
31
|
+
if (segs.some((seg) => labels[seg.key])) {
|
|
32
|
+
console.log(
|
|
33
|
+
segs
|
|
34
|
+
.map((seg) => {
|
|
35
|
+
const label = labels[seg.key];
|
|
36
|
+
if (!label) return " ".repeat(seg.width);
|
|
37
|
+
const left = Math.max(
|
|
38
|
+
0,
|
|
39
|
+
Math.floor((seg.width - label.title.length) / 2),
|
|
40
|
+
);
|
|
41
|
+
return paint(
|
|
42
|
+
label.color,
|
|
43
|
+
label.title
|
|
44
|
+
.padStart(left + label.title.length)
|
|
45
|
+
.padEnd(seg.width),
|
|
46
|
+
);
|
|
47
|
+
})
|
|
48
|
+
.join(bar),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(
|
|
53
|
+
segs
|
|
54
|
+
.map((seg) =>
|
|
55
|
+
// Each provider block's headers take that provider's colour, so a
|
|
56
|
+
// column's side reads from the header, not only the banner above.
|
|
57
|
+
paint(
|
|
58
|
+
labels[seg.key]?.color ?? SGR.bold,
|
|
59
|
+
renderSegment(
|
|
60
|
+
seg,
|
|
61
|
+
seg.columns.map((c) => c.header),
|
|
62
|
+
false,
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
.join(bar),
|
|
67
|
+
);
|
|
68
|
+
console.log(
|
|
69
|
+
segs.map((seg) => "─".repeat(seg.width)).join(paint(SGR.dim, BAR_RULE)),
|
|
70
|
+
);
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
console.log(
|
|
73
|
+
segs
|
|
74
|
+
.map((seg) =>
|
|
75
|
+
renderSegment(
|
|
76
|
+
seg,
|
|
77
|
+
seg.columns.map((c) => c.value(row)),
|
|
78
|
+
true,
|
|
79
|
+
seg.columns.map((c) => c.style?.(row)),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
.join(bar),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { UsageProjection, UsageRow } from "~/cli/engine/project.ts";
|
|
2
|
+
import { HEADERS, SGR } from "~/constants/view.ts";
|
|
3
|
+
import type { PlanInfo, ProviderId } from "~/types.ts";
|
|
4
|
+
import { fmtCount, fmtUsd, paint, planTitle } from "~/view/text/index.ts";
|
|
5
|
+
|
|
6
|
+
export interface UsageMeta {
|
|
7
|
+
label: string;
|
|
8
|
+
window: string;
|
|
9
|
+
account?: { requests: number; cost: number };
|
|
10
|
+
months: number;
|
|
11
|
+
plans: Record<ProviderId, PlanInfo>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function money(value: number | undefined): string {
|
|
15
|
+
if (value === undefined || value === 0) return "—";
|
|
16
|
+
return fmtUsd(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function perReq(value: number | undefined): string {
|
|
20
|
+
if (value === undefined) return "—";
|
|
21
|
+
return fmtUsd(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cells(row: UsageRow): string[] {
|
|
25
|
+
const oc = row.oc;
|
|
26
|
+
const cc = row.cc;
|
|
27
|
+
const cheaper =
|
|
28
|
+
oc && cc
|
|
29
|
+
? oc.payPerRequest === cc.payPerRequest
|
|
30
|
+
? "tie"
|
|
31
|
+
: oc.payPerRequest < cc.payPerRequest
|
|
32
|
+
? "OC"
|
|
33
|
+
: "CC"
|
|
34
|
+
: oc
|
|
35
|
+
? "OC only"
|
|
36
|
+
: "CC only";
|
|
37
|
+
return [
|
|
38
|
+
row.name,
|
|
39
|
+
fmtCount(row.requests),
|
|
40
|
+
money(row.listCost),
|
|
41
|
+
perReq(row.cc?.payPerRequest),
|
|
42
|
+
money(row.cc?.monthly),
|
|
43
|
+
perReq(row.oc?.payPerRequest),
|
|
44
|
+
money(row.oc?.monthly),
|
|
45
|
+
cheaper,
|
|
46
|
+
row.cc?.overCap || row.oc?.overCap ? "over cap" : "",
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HeadToHead {
|
|
51
|
+
/** Rows priced on both sides — the only ones a head-to-head can use. */
|
|
52
|
+
shared: number;
|
|
53
|
+
total: number;
|
|
54
|
+
cc: number;
|
|
55
|
+
oc: number;
|
|
56
|
+
winner: "OpenCode" | "CommandCode" | "tie" | "none";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compare the plans on the models both of them price. Summing every row lets a
|
|
61
|
+
* CC-only model add to CC's total and nothing to OpenCode's, which then "proves"
|
|
62
|
+
* OpenCode cheaper on traffic it cannot serve at all — the artefact that made a
|
|
63
|
+
* mix of one tied model plus two CC-only ones read as "OpenCode cheaper by 45%".
|
|
64
|
+
*/
|
|
65
|
+
export function headToHead(rows: UsageRow[]): HeadToHead {
|
|
66
|
+
const shared = rows.filter((row) => row.oc && row.cc);
|
|
67
|
+
const cc = shared.reduce((sum, row) => sum + (row.cc?.monthly ?? 0), 0);
|
|
68
|
+
const oc = shared.reduce((sum, row) => sum + (row.oc?.monthly ?? 0), 0);
|
|
69
|
+
if (shared.length === 0) {
|
|
70
|
+
return { shared: 0, total: rows.length, cc: 0, oc: 0, winner: "none" };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
shared: shared.length,
|
|
74
|
+
total: rows.length,
|
|
75
|
+
cc,
|
|
76
|
+
oc,
|
|
77
|
+
winner: cc > oc ? "OpenCode" : cc < oc ? "CommandCode" : "tie",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function headToHeadLine(report: UsageProjection): string {
|
|
82
|
+
const head = headToHead(report.rows);
|
|
83
|
+
if (head.winner === "none") {
|
|
84
|
+
return " head-to-head no model is priced on both sides — nothing to compare";
|
|
85
|
+
}
|
|
86
|
+
const excluded = head.total - head.shared;
|
|
87
|
+
const note =
|
|
88
|
+
excluded > 0
|
|
89
|
+
? ` (${excluded} one-sided row${excluded === 1 ? "" : "s"} excluded)`
|
|
90
|
+
: "";
|
|
91
|
+
const delta = Math.abs(head.cc - head.oc);
|
|
92
|
+
const pct = head.cc > 0 ? (delta / head.cc) * 100 : 0;
|
|
93
|
+
const verdict =
|
|
94
|
+
head.winner === "tie"
|
|
95
|
+
? "tie"
|
|
96
|
+
: `cheaper ${head.winner} by ${fmtUsd(delta)} (${pct.toFixed(0)}%)`;
|
|
97
|
+
return ` head-to-head ${head.shared} of ${head.total} models${note} · CC ${fmtUsd(head.cc)}/mo · OpenCode ${fmtUsd(head.oc)}/mo · ${verdict}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function renderUsage(report: UsageProjection, meta: UsageMeta): void {
|
|
101
|
+
const dim = (text: string): string => paint("2", text);
|
|
102
|
+
const scale = meta.months === 1 ? "" : ` · scaled to ${meta.months} months`;
|
|
103
|
+
console.log(
|
|
104
|
+
paint(
|
|
105
|
+
"1",
|
|
106
|
+
`USAGE your logged mix · ${meta.window}${scale} · list value ${fmtUsd(report.ccListCost)}${meta.months === 1 ? "" : "/mo"}`,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
console.log(`${paint(SGR.bold, "source")} ${meta.label}`);
|
|
110
|
+
const local = report.rows.reduce((sum, row) => sum + row.requests, 0);
|
|
111
|
+
if (meta.account && meta.account.requests > 0) {
|
|
112
|
+
const pct = (local / meta.account.requests) * 100;
|
|
113
|
+
console.log(
|
|
114
|
+
`${paint(SGR.bold, "cover")} local usage ${fmtCount(local)} of ${fmtCount(meta.account.requests)} account requests (${pct.toFixed(0)}%)`,
|
|
115
|
+
);
|
|
116
|
+
if (pct < 90) {
|
|
117
|
+
console.log(
|
|
118
|
+
dim(
|
|
119
|
+
" partial: other machines/harnesses are missing — point --usage-log at the provider log, or use --usage-file",
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rows = report.rows.map(cells);
|
|
126
|
+
const widths = HEADERS.map((h, i) =>
|
|
127
|
+
Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)),
|
|
128
|
+
);
|
|
129
|
+
const line = (values: string[], rightSet: Set<number>): string =>
|
|
130
|
+
values
|
|
131
|
+
.map((v, i) =>
|
|
132
|
+
rightSet.has(i)
|
|
133
|
+
? v.padStart(widths[i] ?? 0)
|
|
134
|
+
: v.padEnd(widths[i] ?? 0),
|
|
135
|
+
)
|
|
136
|
+
.join(" ");
|
|
137
|
+
const right = new Set([1, 2, 3, 4, 5, 6]);
|
|
138
|
+
|
|
139
|
+
console.log();
|
|
140
|
+
console.log(paint("1", line(HEADERS, right)));
|
|
141
|
+
console.log(widths.map((w) => "─".repeat(w)).join(" "));
|
|
142
|
+
for (const row of rows) console.log(line(row, right));
|
|
143
|
+
|
|
144
|
+
console.log();
|
|
145
|
+
console.log(
|
|
146
|
+
`${paint(SGR.bold, "totals")} your mix · CC ${fmtUsd(report.ccMonthly)}/mo · OpenCode ${fmtUsd(report.ocMonthly)}/mo`,
|
|
147
|
+
);
|
|
148
|
+
console.log(headToHeadLine(report));
|
|
149
|
+
console.log(
|
|
150
|
+
`${paint(SGR.bold, "plans")} ${planTitle(meta.plans["oc-go"])} $${meta.plans["oc-go"].price}/mo · ${planTitle(meta.plans.cc)} $${meta.plans.cc.price}/mo`,
|
|
151
|
+
);
|
|
152
|
+
const over = report.rows
|
|
153
|
+
.filter((r) => r.cc?.overCap || r.oc?.overCap)
|
|
154
|
+
.map((r) => r.name);
|
|
155
|
+
if (over.length > 0) {
|
|
156
|
+
console.log(dim(`over cap for at least one plan: ${over.join(", ")}`));
|
|
157
|
+
}
|
|
158
|
+
if (report.unmatched.length > 0) {
|
|
159
|
+
console.log(
|
|
160
|
+
dim(`unmatched models (excluded): ${report.unmatched.join(", ")}`),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { PROVIDER_COLOR, SGR } from "~/constants/view.ts";
|
|
2
|
+
import type { CompareRow } from "~/types.ts";
|
|
3
|
+
import { footer } from "./footer.ts";
|
|
4
|
+
import { tally } from "./layout/segments.ts";
|
|
5
|
+
import { printTable } from "./layout/table.ts";
|
|
6
|
+
import type { ReportMeta } from "./schema.ts";
|
|
7
|
+
import { paint, planTitle, setColor, setColorMode } from "./text/index.ts";
|
|
8
|
+
import { setAbilityRange, setThresholds } from "./text/styles.ts";
|
|
9
|
+
|
|
10
|
+
export { fitColumns, tableWidth } from "./layout/fit.ts";
|
|
11
|
+
export { tally } from "./layout/segments.ts";
|
|
12
|
+
export { columns } from "./schema.ts";
|
|
13
|
+
export { renderCsv, renderMarkdown } from "./text/export.ts";
|
|
14
|
+
export {
|
|
15
|
+
fmtUsd,
|
|
16
|
+
planTitle,
|
|
17
|
+
providerName,
|
|
18
|
+
shortProviderName,
|
|
19
|
+
} from "./text/index.ts";
|
|
20
|
+
export { setAbilityRange, setColor, setColorMode, setThresholds };
|
|
21
|
+
|
|
22
|
+
export function renderText(
|
|
23
|
+
rows: CompareRow[],
|
|
24
|
+
meta: ReportMeta,
|
|
25
|
+
columnIds: string[],
|
|
26
|
+
dropped: string[] = [],
|
|
27
|
+
): void {
|
|
28
|
+
// The ability column ranks across the rows on screen, like COST/VAL.
|
|
29
|
+
const abilities = rows
|
|
30
|
+
.map((row) => row.oc?.ability ?? row.cc?.ability)
|
|
31
|
+
.filter((value): value is number => typeof value === "number");
|
|
32
|
+
if (abilities.length > 0) {
|
|
33
|
+
setAbilityRange(Math.min(...abilities), Math.max(...abilities));
|
|
34
|
+
}
|
|
35
|
+
printTable(rows, columnIds, {
|
|
36
|
+
oc: { title: planTitle(meta.ocPlan), color: PROVIDER_COLOR["oc-go"] },
|
|
37
|
+
cc: { title: planTitle(meta.ccPlan), color: PROVIDER_COLOR.cc },
|
|
38
|
+
});
|
|
39
|
+
if (dropped.length > 0) {
|
|
40
|
+
console.log(
|
|
41
|
+
paint(
|
|
42
|
+
SGR.dim,
|
|
43
|
+
`\ndropped for width: ${[...new Set(dropped)].join(", ")} (use --columns to force, --width <n> to widen)`,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
console.log();
|
|
48
|
+
footer(rows, meta);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderJson(rows: CompareRow[], meta: ReportMeta): string {
|
|
52
|
+
return JSON.stringify(
|
|
53
|
+
{
|
|
54
|
+
workloads: meta.workloads,
|
|
55
|
+
plans: { "oc-go": meta.ocPlan, cc: meta.ccPlan },
|
|
56
|
+
tally: tally(rows),
|
|
57
|
+
rows,
|
|
58
|
+
},
|
|
59
|
+
null,
|
|
60
|
+
2,
|
|
61
|
+
);
|
|
62
|
+
}
|