@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.
Files changed (71) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +269 -2
  3. package/package.json +31 -5
  4. package/src/cli/config.ts +79 -0
  5. package/src/cli/engine/ability.ts +50 -0
  6. package/src/cli/engine/cost.ts +62 -0
  7. package/src/cli/engine/index.ts +59 -0
  8. package/src/cli/engine/project.ts +145 -0
  9. package/src/cli/engine/rows.ts +64 -0
  10. package/src/cli/engine/score.ts +118 -0
  11. package/src/cli/flow/check.ts +66 -0
  12. package/src/cli/flow/collect.ts +76 -0
  13. package/src/cli/flow/columns.ts +32 -0
  14. package/src/cli/flow/sort.ts +68 -0
  15. package/src/cli/options.ts +78 -0
  16. package/src/cli/parse/cac.ts +114 -0
  17. package/src/cli/parse/fields.ts +65 -0
  18. package/src/cli/parse/map.ts +116 -0
  19. package/src/cli/parse/plugins.ts +67 -0
  20. package/src/cli/parse/validate.ts +60 -0
  21. package/src/cli/run.ts +151 -0
  22. package/src/constants/cli.ts +153 -0
  23. package/src/constants/data.ts +30 -0
  24. package/src/constants/scoring.ts +22 -0
  25. package/src/constants/shape.ts +19 -0
  26. package/src/constants/sources.ts +69 -0
  27. package/src/constants/view.ts +161 -0
  28. package/src/data/bench/cc.ts +26 -0
  29. package/src/data/bench/index.ts +126 -0
  30. package/src/data/bench/resolve.ts +107 -0
  31. package/src/data/bench/store.ts +169 -0
  32. package/src/data/bench/types.ts +34 -0
  33. package/src/data/cmduse.ts +43 -0
  34. package/src/data/scrape/catalog/catalog.ts +99 -0
  35. package/src/data/scrape/catalog/deal.ts +20 -0
  36. package/src/data/scrape/catalog/numeric.ts +30 -0
  37. package/src/data/scrape/catalog/variant.ts +11 -0
  38. package/src/data/scrape/index.ts +21 -0
  39. package/src/data/scrape/roleRows.ts +41 -0
  40. package/src/data/scrape/tables.ts +96 -0
  41. package/src/data/shape.ts +205 -0
  42. package/src/data/sources/aa/api.ts +54 -0
  43. package/src/data/sources/aa/parse.ts +66 -0
  44. package/src/data/sources/aa/web.ts +53 -0
  45. package/src/data/sources/cc/catalog.ts +71 -0
  46. package/src/data/sources/cc/cmduse.ts +24 -0
  47. package/src/data/sources/cc/plans.ts +29 -0
  48. package/src/data/sources/opencode.ts +48 -0
  49. package/src/data/usage/index.ts +118 -0
  50. package/src/data/usage/log.ts +89 -0
  51. package/src/data/usage/logs.ts +117 -0
  52. package/src/data/usage/opencodeDb.ts +135 -0
  53. package/src/data/usage/opencodeV2.ts +70 -0
  54. package/src/data/usage/parse.ts +85 -0
  55. package/src/index.ts +11 -0
  56. package/src/keys.ts +29 -0
  57. package/src/types.ts +92 -0
  58. package/src/view/columns/cc.ts +50 -0
  59. package/src/view/columns/meta.ts +41 -0
  60. package/src/view/columns/oc.ts +50 -0
  61. package/src/view/footer.ts +124 -0
  62. package/src/view/layout/fit.ts +58 -0
  63. package/src/view/layout/segments.ts +98 -0
  64. package/src/view/layout/table.ts +85 -0
  65. package/src/view/layout/usage.ts +163 -0
  66. package/src/view/render.ts +62 -0
  67. package/src/view/schema.ts +81 -0
  68. package/src/view/text/export.ts +72 -0
  69. package/src/view/text/format.ts +79 -0
  70. package/src/view/text/index.ts +54 -0
  71. 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
+ }