@saadnvd1/agent-os 0.3.470 → 0.3.472

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 (95) hide show
  1. package/README.md +139 -36
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/models/route.ts +34 -0
  4. package/app/api/models/test/route.ts +17 -0
  5. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  6. package/app/api/tasks/[id]/model/route.ts +23 -0
  7. package/app/api/tasks/route.ts +5 -1
  8. package/app/api/usage/compare/route.ts +21 -0
  9. package/app/api/usage/route.ts +8 -3
  10. package/app/api/workspaces/[id]/models/route.ts +27 -0
  11. package/components/Chat/ChatPanel.tsx +5 -5
  12. package/components/Chat/StaleChip.tsx +7 -6
  13. package/components/CommandPalette/useAppCommands.ts +19 -1
  14. package/components/Draft/DraftChips.tsx +28 -0
  15. package/components/Draft/DraftPanel.tsx +1 -1
  16. package/components/Models/ModelSelect.tsx +67 -0
  17. package/components/Models/ModelsSection.tsx +199 -0
  18. package/components/Models/ProviderCard.tsx +271 -0
  19. package/components/SessionList/SidebarHeader.tsx +5 -1
  20. package/components/Settings/SettingsDialog.test.ts +3 -0
  21. package/components/Settings/SettingsDialog.tsx +9 -0
  22. package/components/Settings/commands.ts +19 -0
  23. package/components/Settings/entry-points.test.ts +1 -0
  24. package/components/Tasks/ResumeOnModel.tsx +72 -0
  25. package/components/Tasks/TaskRow.tsx +4 -0
  26. package/components/Usage/CompareView.tsx +211 -0
  27. package/components/Usage/ModelSpend.tsx +133 -0
  28. package/components/Usage/UsageDialog.tsx +69 -5
  29. package/components/Usage/format.ts +9 -0
  30. package/components/Workspaces/FreshStartForm.tsx +13 -13
  31. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  32. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  33. package/data/models/index.ts +125 -0
  34. package/data/usage/index.ts +5 -0
  35. package/lib/agents/launch.ts +13 -2
  36. package/lib/chat/context.ts +1 -1
  37. package/lib/chat/refresh.ts +31 -13
  38. package/lib/chat/runner.ts +5 -0
  39. package/lib/chat/stale.ts +25 -16
  40. package/lib/chat/worker/extras.ts +12 -0
  41. package/lib/chat/worker/host.ts +70 -24
  42. package/lib/chat/worker/main.ts +5 -2
  43. package/lib/db/migrations.ts +70 -0
  44. package/lib/db/types.ts +6 -0
  45. package/lib/drafts.ts +3 -0
  46. package/lib/models/config.ts +218 -0
  47. package/lib/models/launch.ts +93 -0
  48. package/lib/models/plan-step.ts +94 -0
  49. package/lib/models/providers.ts +163 -0
  50. package/lib/models/resolve.ts +246 -0
  51. package/lib/models/resume.ts +99 -0
  52. package/lib/models/roles.ts +98 -0
  53. package/lib/models/secrets.ts +86 -0
  54. package/lib/models/session-model.ts +40 -0
  55. package/lib/models/test-provider.ts +116 -0
  56. package/lib/orchestrator/act.ts +13 -1
  57. package/lib/orchestrator/armed.ts +1 -2
  58. package/lib/orchestrator/brief.ts +5 -4
  59. package/lib/orchestrator/claude-cli.ts +48 -14
  60. package/lib/orchestrator/diff.ts +1 -1
  61. package/lib/orchestrator/external-gates.ts +8 -43
  62. package/lib/orchestrator/gates.ts +10 -10
  63. package/lib/orchestrator/home.ts +2 -2
  64. package/lib/orchestrator/pr-wake.ts +2 -2
  65. package/lib/orchestrator/rebase.ts +6 -45
  66. package/lib/orchestrator/review.ts +20 -5
  67. package/lib/orchestrator/scope.ts +11 -0
  68. package/lib/orchestrator/serve.ts +13 -0
  69. package/lib/orchestrator/signoff.ts +13 -8
  70. package/lib/orchestrator/tool-names.ts +1 -0
  71. package/lib/orchestrator/tool-schemas.ts +38 -0
  72. package/lib/orchestrator/tools.ts +3 -1
  73. package/lib/pr-generation.ts +5 -2
  74. package/lib/providers/registry.ts +1 -1
  75. package/lib/session-audit/render.ts +1 -1
  76. package/lib/session-titles.ts +1 -1
  77. package/lib/sessions/launch.ts +19 -4
  78. package/lib/settings-url.ts +1 -0
  79. package/lib/stacks/land.ts +23 -16
  80. package/lib/tasks/brief.ts +3 -14
  81. package/lib/tasks/code-review.ts +69 -107
  82. package/lib/tasks/gh.ts +3 -2
  83. package/lib/tasks/index.ts +60 -9
  84. package/lib/tasks/queue.ts +14 -5
  85. package/lib/tasks/start.ts +7 -2
  86. package/lib/tasks/state.ts +4 -4
  87. package/lib/usage/by-model.ts +105 -0
  88. package/lib/usage/check-runs.ts +170 -0
  89. package/lib/usage/compare.ts +280 -0
  90. package/lib/usage/cost.ts +71 -0
  91. package/lib/usage/report.ts +51 -0
  92. package/lib/usage/turns.ts +107 -18
  93. package/package.json +1 -1
  94. package/stores/usageUi.ts +11 -2
  95. package/scripts/check-code-review.ts +0 -21
@@ -0,0 +1,170 @@
1
+ /**
2
+ * What the orchestrator's one-shot checks cost: each `claude -p` run of the
3
+ * independent reviewer and the scope check, read from its JSON result.
4
+ * They run with --no-session-persistence and leave no transcript, so this
5
+ * is the only record of their tokens.
6
+ */
7
+
8
+ import { db } from "../db";
9
+ import { formatTokens } from "../chat/context";
10
+
11
+ export interface RunUsage {
12
+ model: string | null;
13
+ // Where the model ran (lib/models); Anthropic when absent.
14
+ provider?: string;
15
+ inputTokens: number;
16
+ cacheWriteTokens: number;
17
+ cacheReadTokens: number;
18
+ outputTokens: number;
19
+ costUsd: number;
20
+ durationMs: number | null;
21
+ }
22
+
23
+ interface ModelUsage {
24
+ inputTokens?: number;
25
+ outputTokens?: number;
26
+ cacheReadInputTokens?: number;
27
+ cacheCreationInputTokens?: number;
28
+ costUSD?: number;
29
+ }
30
+
31
+ const n = (v: unknown) => (typeof v === "number" && v > 0 ? v : 0);
32
+
33
+ // The usage in a `claude -p --output-format json` result, or null when it
34
+ // carries none. Per-model usage covers every model the run used; the model
35
+ // named is the one that cost the most.
36
+ export function runUsageOf(result: unknown): RunUsage | null {
37
+ if (!result || typeof result !== "object") return null;
38
+ const r = result as {
39
+ usage?: {
40
+ input_tokens?: number;
41
+ output_tokens?: number;
42
+ cache_read_input_tokens?: number;
43
+ cache_creation_input_tokens?: number;
44
+ };
45
+ modelUsage?: Record<string, ModelUsage>;
46
+ total_cost_usd?: number;
47
+ duration_ms?: number;
48
+ };
49
+ const models = Object.entries(r.modelUsage ?? {});
50
+ if (!r.usage && !models.length) return null;
51
+ const durationMs = n(r.duration_ms) || null;
52
+ if (models.length) {
53
+ const sum = (k: keyof ModelUsage) =>
54
+ models.reduce((t, [, m]) => t + n(m[k]), 0);
55
+ const [model] = models.reduce((a, b) =>
56
+ n(b[1].costUSD) > n(a[1].costUSD) ? b : a
57
+ );
58
+ return {
59
+ model,
60
+ inputTokens: sum("inputTokens"),
61
+ cacheWriteTokens: sum("cacheCreationInputTokens"),
62
+ cacheReadTokens: sum("cacheReadInputTokens"),
63
+ outputTokens: sum("outputTokens"),
64
+ costUsd: n(r.total_cost_usd) || sum("costUSD"),
65
+ durationMs,
66
+ };
67
+ }
68
+ const u = r.usage!;
69
+ return {
70
+ model: null,
71
+ inputTokens: n(u.input_tokens),
72
+ cacheWriteTokens: n(u.cache_creation_input_tokens),
73
+ cacheReadTokens: n(u.cache_read_input_tokens),
74
+ outputTokens: n(u.output_tokens),
75
+ costUsd: n(r.total_cost_usd),
76
+ durationMs,
77
+ };
78
+ }
79
+
80
+ export interface CheckRunTarget {
81
+ workspaceId: string;
82
+ // The task's session id, or an external PR's id.
83
+ sessionId: string;
84
+ // How the Usage page names it: "task add-a", "PR o/r#5".
85
+ label: string;
86
+ prNumber: number | null;
87
+ sha: string;
88
+ kind: "review" | "scope";
89
+ }
90
+
91
+ export function recordCheckRun(
92
+ target: CheckRunTarget,
93
+ u: RunUsage,
94
+ at = Date.now()
95
+ ): void {
96
+ db.prepare(
97
+ `INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model, provider,
98
+ input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, at)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
100
+ ).run(
101
+ target.workspaceId,
102
+ target.sessionId,
103
+ target.label,
104
+ target.prNumber,
105
+ target.sha,
106
+ target.kind,
107
+ u.model,
108
+ u.provider ?? "anthropic",
109
+ u.inputTokens,
110
+ u.cacheWriteTokens,
111
+ u.cacheReadTokens,
112
+ u.outputTokens,
113
+ u.costUsd,
114
+ u.durationMs,
115
+ at
116
+ );
117
+ }
118
+
119
+ // A recorder for one check, that never lets a write break the check.
120
+ export function checkRunRecorder(target: CheckRunTarget) {
121
+ return (u: RunUsage) => {
122
+ try {
123
+ recordCheckRun(target, u);
124
+ } catch (e) {
125
+ console.error(`[check-runs] recording ${target.label}:`, e);
126
+ }
127
+ };
128
+ }
129
+
130
+ export interface CheckRunTotals {
131
+ runs: number;
132
+ tokens: number;
133
+ costUsd: number;
134
+ durationMs: number;
135
+ models: string[];
136
+ }
137
+
138
+ // Everything the checks of one commit cost, every part and retry.
139
+ export function checkRunTotals(
140
+ sessionId: string,
141
+ sha: string
142
+ ): CheckRunTotals | null {
143
+ const row = db
144
+ .prepare(
145
+ `SELECT count(*) AS runs,
146
+ coalesce(sum(input_tokens + cache_write_tokens + cache_read_tokens + output_tokens), 0) AS tokens,
147
+ coalesce(sum(cost_usd), 0) AS costUsd,
148
+ coalesce(sum(duration_ms), 0) AS durationMs,
149
+ group_concat(DISTINCT model) AS models
150
+ FROM check_runs WHERE session_id = ? AND sha = ?`
151
+ )
152
+ .get(sessionId, sha) as Omit<CheckRunTotals, "models"> & {
153
+ models: string | null;
154
+ };
155
+ if (!row.runs) return null;
156
+ return { ...row, models: row.models ? row.models.split(",") : [] };
157
+ }
158
+
159
+ // "2 runs · 412k tokens · $0.84 · 3m 10s · claude-opus-5-5"
160
+ export function describeCheckRuns(t: CheckRunTotals): string {
161
+ const s = Math.round(t.durationMs / 1000);
162
+ const time = s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
163
+ return [
164
+ `${t.runs} run${t.runs === 1 ? "" : "s"}`,
165
+ `${formatTokens(t.tokens)} tokens`,
166
+ `$${t.costUsd.toFixed(2)}`,
167
+ time,
168
+ ...t.models,
169
+ ].join(" · ");
170
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Claude-only tasks against routed ones, side by side: what each cost
3
+ * (tokens, dollars at list prices, share of the plan's window, wall time)
4
+ * and how it went (CI green on the first push, the first review passing,
5
+ * gate failures, commits after the first review, merged or dropped). Built
6
+ * on the usage ledgers (chat_turns, and check_runs for the gate's own runs)
7
+ * and the orchestrator's checks: no record of its own.
8
+ *
9
+ * A task's cohort is the provider its own turns (planning and implementing)
10
+ * ran on: anthropic is the baseline; one that used two is "mixed". Its
11
+ * reviews and scope checks count toward its cost, whatever they ran on.
12
+ */
13
+
14
+ import { db } from "../db";
15
+ import { effectiveModels } from "../models/config";
16
+ import { ANTHROPIC } from "../models/roles";
17
+ import type { ModelPrice } from "../models/providers";
18
+ import { planShares, rowModel, UNRECORDED } from "./cost";
19
+ import {
20
+ addRow,
21
+ emptySpend,
22
+ type ModelTurnRow,
23
+ type TokenSpend,
24
+ } from "./by-model";
25
+
26
+ export const SMALL_SAMPLE = 10;
27
+ const CHECK_ROLES = new Set(["review", "scope", "helper"]);
28
+
29
+ export interface CompareTurn extends ModelTurnRow {
30
+ session_id: string;
31
+ duration_ms: number | null;
32
+ window_pct: number | null;
33
+ }
34
+
35
+ export interface TaskOutcome {
36
+ id: string;
37
+ status: string | null;
38
+ // The first finished CI run, and review, on its PR: passed or not; null
39
+ // when it never had one (no PR, or not under an orchestrator).
40
+ ciFirstGreen: boolean | null;
41
+ reviewFirstPass: boolean | null;
42
+ gateFailures: number;
43
+ // Heads checked after its first review: the rework the review caused.
44
+ rework: number;
45
+ }
46
+
47
+ export interface Rate {
48
+ yes: number;
49
+ of: number;
50
+ }
51
+
52
+ export interface Cohort {
53
+ key: string;
54
+ baseline: boolean;
55
+ tasks: number;
56
+ smallSample: boolean;
57
+ spend: TokenSpend;
58
+ // Points of the plan's 5-hour window (an estimate), Anthropic turns only.
59
+ planPct: number;
60
+ wallMs: number;
61
+ ciFirstGreen: Rate;
62
+ reviewFirstPass: Rate;
63
+ gateFailures: number;
64
+ rework: number;
65
+ merged: Rate;
66
+ dropped: Rate;
67
+ }
68
+
69
+ export interface RoleModelRow extends TokenSpend {
70
+ role: string;
71
+ provider: string;
72
+ model: string;
73
+ planPct: number;
74
+ wallMs: number;
75
+ }
76
+
77
+ export interface Comparison {
78
+ from: number;
79
+ to: number;
80
+ cohorts: Cohort[];
81
+ roles: RoleModelRow[];
82
+ }
83
+
84
+ const rate = (): Rate => ({ yes: 0, of: 0 });
85
+ const count = (r: Rate, v: boolean | null) => {
86
+ if (v === null) return;
87
+ r.of += 1;
88
+ if (v) r.yes += 1;
89
+ };
90
+
91
+ function cohortOf(turns: CompareTurn[]): string {
92
+ const providers = new Set(
93
+ turns
94
+ .filter((t) => !CHECK_ROLES.has(t.role ?? ""))
95
+ .map((t) => rowModel(t).provider)
96
+ );
97
+ if (providers.size === 0) return ANTHROPIC;
98
+ return providers.size === 1 ? [...providers][0] : "mixed";
99
+ }
100
+
101
+ export function compareRuns(
102
+ turns: CompareTurn[],
103
+ outcomes: Map<string, TaskOutcome>,
104
+ prices: Record<string, ModelPrice>,
105
+ range: { from: number; to: number }
106
+ ): Comparison {
107
+ const shares = planShares(turns);
108
+ const share = new Map(turns.map((t, i) => [t, shares[i]]));
109
+ const inRange = turns.filter((t) => t.at >= range.from && t.at <= range.to);
110
+
111
+ const byTask = new Map<string, CompareTurn[]>();
112
+ for (const t of inRange)
113
+ if (outcomes.has(t.session_id))
114
+ byTask.set(t.session_id, [...(byTask.get(t.session_id) ?? []), t]);
115
+
116
+ const cohorts = new Map<string, Cohort>();
117
+ for (const [id, ts] of byTask) {
118
+ const key = cohortOf(ts);
119
+ let c = cohorts.get(key);
120
+ if (!c)
121
+ cohorts.set(
122
+ key,
123
+ (c = {
124
+ key,
125
+ baseline: key === ANTHROPIC,
126
+ tasks: 0,
127
+ smallSample: true,
128
+ spend: emptySpend(),
129
+ planPct: 0,
130
+ wallMs: 0,
131
+ ciFirstGreen: rate(),
132
+ reviewFirstPass: rate(),
133
+ gateFailures: 0,
134
+ rework: 0,
135
+ merged: rate(),
136
+ dropped: rate(),
137
+ })
138
+ );
139
+ const o = outcomes.get(id)!;
140
+ c.tasks += 1;
141
+ for (const t of ts) {
142
+ addRow(c.spend, t, prices);
143
+ c.planPct += share.get(t) ?? 0;
144
+ c.wallMs += t.duration_ms ?? 0;
145
+ }
146
+ count(c.ciFirstGreen, o.ciFirstGreen);
147
+ count(c.reviewFirstPass, o.reviewFirstPass);
148
+ c.gateFailures += o.gateFailures;
149
+ c.rework += o.rework;
150
+ // Still running counts toward neither.
151
+ const finished = o.status !== "running" && o.status !== null;
152
+ count(c.merged, finished ? o.status === "merged" : null);
153
+ count(c.dropped, finished ? o.status === "dropped" : null);
154
+ }
155
+ for (const c of cohorts.values()) c.smallSample = c.tasks < SMALL_SAMPLE;
156
+
157
+ const roles = new Map<string, RoleModelRow>();
158
+ for (const t of inRange) {
159
+ const { provider, model } = rowModel(t);
160
+ const role = t.role ?? UNRECORDED;
161
+ const k = `${role} ${provider}/${model}`;
162
+ let r = roles.get(k);
163
+ if (!r)
164
+ roles.set(
165
+ k,
166
+ (r = { ...emptySpend(), role, provider, model, planPct: 0, wallMs: 0 })
167
+ );
168
+ addRow(r, t, prices);
169
+ r.planPct += share.get(t) ?? 0;
170
+ r.wallMs += t.duration_ms ?? 0;
171
+ }
172
+
173
+ return {
174
+ ...range,
175
+ // The baseline first, then the most tasks.
176
+ cohorts: [...cohorts.values()].sort(
177
+ (a, b) => Number(b.baseline) - Number(a.baseline) || b.tasks - a.tasks
178
+ ),
179
+ roles: [...roles.values()].sort(
180
+ (a, b) => a.role.localeCompare(b.role) || b.costUsd - a.costUsd
181
+ ),
182
+ };
183
+ }
184
+
185
+ // The ledger's turns, with the gate reviews and scope checks recorded in
186
+ // check_runs (lib/usage/check-runs) as turns of their own role.
187
+ export const LEDGER_SQL = `
188
+ SELECT session_id, at, cost_usd, input_tokens, output_tokens,
189
+ cache_read_tokens, cache_write_tokens, model, provider, role, duration_ms,
190
+ window_pct FROM chat_turns WHERE at >= @from AND at <= @to
191
+ UNION ALL
192
+ SELECT session_id, at, cost_usd, input_tokens, output_tokens,
193
+ cache_read_tokens, cache_write_tokens, model, provider, kind AS role,
194
+ duration_ms, NULL AS window_pct FROM check_runs WHERE at >= @from AND at <= @to
195
+ ORDER BY at`;
196
+
197
+ // The outcome of each task with turns in the range.
198
+ export function taskOutcomes(ids: string[]): Map<string, TaskOutcome> {
199
+ const out = new Map<string, TaskOutcome>();
200
+ if (!ids.length) return out;
201
+ const marks = ids.map(() => "?").join(",");
202
+ const tasks = db
203
+ .prepare(
204
+ `SELECT id, task_status FROM sessions
205
+ WHERE id IN (${marks}) AND task_status IS NOT NULL`
206
+ )
207
+ .all(...ids) as { id: string; task_status: string }[];
208
+ const checks = db
209
+ .prepare(
210
+ `SELECT id, session_id, sha, kind, status FROM orchestrator_checks
211
+ WHERE session_id IN (${marks}) ORDER BY id`
212
+ )
213
+ .all(...ids) as {
214
+ id: number;
215
+ session_id: string;
216
+ sha: string;
217
+ kind: string;
218
+ status: string;
219
+ }[];
220
+ const gates = db
221
+ .prepare(
222
+ `SELECT session_id, SUM(count) AS n FROM orchestrator_gate_failures
223
+ WHERE session_id IN (${marks}) GROUP BY session_id`
224
+ )
225
+ .all(...ids) as { session_id: string; n: number }[];
226
+ for (const t of tasks)
227
+ out.set(t.id, {
228
+ id: t.id,
229
+ status: t.task_status,
230
+ ciFirstGreen: null,
231
+ reviewFirstPass: null,
232
+ gateFailures: 0,
233
+ rework: 0,
234
+ });
235
+ const firstReview = new Map<string, number>();
236
+ const reworked = new Map<string, Set<string>>();
237
+ for (const c of checks) {
238
+ const o = out.get(c.session_id);
239
+ if (!o) continue;
240
+ const settled = c.status === "pass" || c.status === "block";
241
+ if (c.kind === "ci" && settled && o.ciFirstGreen === null)
242
+ o.ciFirstGreen = c.status === "pass";
243
+ if (c.kind === "review" && settled && o.reviewFirstPass === null) {
244
+ o.reviewFirstPass = c.status === "pass";
245
+ firstReview.set(c.session_id, c.id);
246
+ }
247
+ const after = firstReview.get(c.session_id);
248
+ if (after !== undefined && c.id > after) {
249
+ const shas = reworked.get(c.session_id) ?? new Set();
250
+ shas.add(c.sha);
251
+ reworked.set(c.session_id, shas);
252
+ }
253
+ }
254
+ for (const [id, shas] of reworked) {
255
+ // The reviewed head itself isn't rework.
256
+ const o = out.get(id)!;
257
+ const reviewed = checks.find((c) => c.id === firstReview.get(id))?.sha;
258
+ o.rework = [...shas].filter((s) => s !== reviewed).length;
259
+ }
260
+ for (const g of gates) {
261
+ const o = out.get(g.session_id);
262
+ if (o) o.gateFailures = g.n;
263
+ }
264
+ return out;
265
+ }
266
+
267
+ export function comparisonReport(from: number, to: number): Comparison {
268
+ // From a day earlier, so the first turn in range has a reading before it
269
+ // to measure its share of the window from.
270
+ const turns = db
271
+ .prepare(LEDGER_SQL)
272
+ .all({ from: from - 24 * 3600 * 1000, to }) as CompareTurn[];
273
+ const ids = [
274
+ ...new Set(turns.filter((t) => t.at >= from).map((t) => t.session_id)),
275
+ ];
276
+ return compareRuns(turns, taskOutcomes(ids), effectiveModels().prices, {
277
+ from,
278
+ to,
279
+ });
280
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What a turn would cost at a provider's list prices (the price table in
3
+ * Settings > Models). For Anthropic that's the API's price, not what the
4
+ * plan charges: the plan's own measure is its share of the 5-hour window.
5
+ */
6
+
7
+ import { ANTHROPIC_ALIASES, type ModelPrice } from "../models/providers";
8
+ import { ANTHROPIC } from "../models/roles";
9
+
10
+ export interface PricedRow {
11
+ provider: string | null;
12
+ model: string | null;
13
+ cost_usd: number;
14
+ input_tokens: number;
15
+ output_tokens: number;
16
+ cache_read_tokens: number;
17
+ cache_write_tokens: number;
18
+ }
19
+
20
+ export const UNRECORDED = "unrecorded";
21
+
22
+ // The row's provider and model as the table names them: turns from before
23
+ // routing have neither, and ran on Anthropic.
24
+ export function rowModel(r: Pick<PricedRow, "provider" | "model">): {
25
+ provider: string;
26
+ model: string;
27
+ } {
28
+ const provider = r.provider ?? ANTHROPIC;
29
+ const raw = r.model ?? UNRECORDED;
30
+ const model = provider === ANTHROPIC ? (ANTHROPIC_ALIASES[raw] ?? raw) : raw;
31
+ return { provider, model };
32
+ }
33
+
34
+ // Dollars, and whether it came from the table (false: the agent's own
35
+ // figure, for a model the table doesn't price).
36
+ export function estimateCost(
37
+ r: PricedRow,
38
+ prices: Record<string, ModelPrice>
39
+ ): { usd: number; priced: boolean } {
40
+ const { provider, model } = rowModel(r);
41
+ const p = prices[`${provider}/${model}`];
42
+ if (!p) return { usd: r.cost_usd, priced: false };
43
+ const usd =
44
+ (r.input_tokens * p.input +
45
+ r.cache_write_tokens * p.cacheWrite +
46
+ r.cache_read_tokens * p.cacheRead +
47
+ r.output_tokens * p.output) /
48
+ 1_000_000;
49
+ return { usd, priced: true };
50
+ }
51
+
52
+ // Each Anthropic turn's share of the plan's 5-hour window, in points: how
53
+ // far the window moved since the turn before it (any session's) ended.
54
+ // Turns overlap, so one turn's share is rough; summed over many it isn't.
55
+ // A drop means the window reset, and all of the new reading is this turn's;
56
+ // the first reading has nothing to measure from.
57
+ export function planShares(
58
+ rows: { at: number; provider: string | null; window_pct: number | null }[]
59
+ ): number[] {
60
+ const order = rows.map((r, i) => ({ r, i })).sort((a, b) => a.r.at - b.r.at);
61
+ const out = new Array<number>(rows.length).fill(0);
62
+ let prev: number | null = null;
63
+ for (const { r, i } of order) {
64
+ if ((r.provider ?? ANTHROPIC) !== ANTHROPIC || r.window_pct == null)
65
+ continue;
66
+ const pct = r.window_pct;
67
+ out[i] = prev === null ? 0 : pct < prev ? pct : pct - prev;
68
+ prev = pct;
69
+ }
70
+ return out;
71
+ }
@@ -1,4 +1,7 @@
1
1
  import { db } from "../db";
2
+ import { effectiveModels } from "../models/config";
3
+ import { LEDGER_SQL } from "./compare";
4
+ import { spendByModel, type ModelTurnRow, type SpendByModel } from "./by-model";
2
5
  import { listWorkspaces } from "../workspaces";
3
6
  import {
4
7
  aggregateUsage,
@@ -18,3 +21,51 @@ export function usageReport(range: UsageRange, now = Date.now()) {
18
21
  const names = Object.fromEntries(listWorkspaces().map((w) => [w.id, w.name]));
19
22
  return aggregateUsage(rows, range, names, now);
20
23
  }
24
+
25
+ // What the orchestrator's reviewer and scope check cost over the range
26
+ // (lib/usage/check-runs), by PR: a "turn" is one run.
27
+ export function checkRunReport(range: UsageRange, now = Date.now()) {
28
+ const rows = db
29
+ .prepare(
30
+ `SELECT session_id, label, pr_number, workspace_id, at, cost_usd, input_tokens,
31
+ output_tokens, cache_read_tokens, cache_write_tokens
32
+ FROM check_runs WHERE at >= ? ORDER BY at`
33
+ )
34
+ .all(rangeStart(range, now)) as (Omit<TurnRow, "session_name"> & {
35
+ label: string;
36
+ pr_number: number | null;
37
+ })[];
38
+ const named: TurnRow[] = rows.map(({ label, pr_number, ...r }) => ({
39
+ ...r,
40
+ // An external PR's label already names it.
41
+ session_name:
42
+ pr_number === null || label.startsWith("PR ")
43
+ ? label
44
+ : `${label} · PR #${pr_number}`,
45
+ }));
46
+ const names = Object.fromEntries(listWorkspaces().map((w) => [w.id, w.name]));
47
+ return aggregateUsage(named, range, names, now);
48
+ }
49
+
50
+ const HOUR = 3600 * 1000;
51
+
52
+ // Spend by model and role in the plan's current 5-hour window (its start
53
+ // from when it resets, or the last five hours) and the last 24 hours.
54
+ export function modelUsage(
55
+ fiveHourResetsIn: number | null | undefined,
56
+ now = Date.now()
57
+ ): { fiveHour: SpendByModel; day: SpendByModel } {
58
+ const day = now - 24 * HOUR;
59
+ const fiveHour =
60
+ fiveHourResetsIn != null
61
+ ? now + fiveHourResetsIn * 1000 - 5 * HOUR
62
+ : now - 5 * HOUR;
63
+ const rows = db
64
+ .prepare(LEDGER_SQL)
65
+ .all({ from: Math.min(day, fiveHour), to: now }) as ModelTurnRow[];
66
+ const { prices } = effectiveModels();
67
+ return {
68
+ fiveHour: spendByModel(rows, fiveHour, prices),
69
+ day: spendByModel(rows, day, prices),
70
+ };
71
+ }