@saadnvd1/agent-os 0.3.471 → 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.
- package/README.md +100 -0
- package/app/api/models/route.ts +34 -0
- package/app/api/models/test/route.ts +17 -0
- package/app/api/sessions/[id]/summarize/route.ts +4 -1
- package/app/api/tasks/[id]/model/route.ts +23 -0
- package/app/api/tasks/route.ts +5 -1
- package/app/api/usage/compare/route.ts +21 -0
- package/app/api/usage/route.ts +7 -4
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/CommandPalette/useAppCommands.ts +19 -1
- package/components/Draft/DraftChips.tsx +28 -0
- package/components/Draft/DraftPanel.tsx +1 -1
- package/components/Models/ModelSelect.tsx +67 -0
- package/components/Models/ModelsSection.tsx +199 -0
- package/components/Models/ProviderCard.tsx +271 -0
- package/components/SessionList/SidebarHeader.tsx +5 -1
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +19 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Tasks/ResumeOnModel.tsx +72 -0
- package/components/Tasks/TaskRow.tsx +4 -0
- package/components/Usage/CompareView.tsx +211 -0
- package/components/Usage/ModelSpend.tsx +133 -0
- package/components/Usage/UsageDialog.tsx +38 -3
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/models/index.ts +125 -0
- package/data/usage/index.ts +3 -0
- package/lib/agents/launch.ts +11 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +13 -2
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +28 -0
- package/lib/db/types.ts +6 -0
- package/lib/drafts.ts +3 -0
- package/lib/models/config.ts +218 -0
- package/lib/models/launch.ts +93 -0
- package/lib/models/plan-step.ts +94 -0
- package/lib/models/providers.ts +163 -0
- package/lib/models/resolve.ts +246 -0
- package/lib/models/resume.ts +99 -0
- package/lib/models/roles.ts +98 -0
- package/lib/models/secrets.ts +86 -0
- package/lib/models/session-model.ts +40 -0
- package/lib/models/test-provider.ts +116 -0
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claude-cli.ts +44 -15
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/review.ts +2 -0
- package/lib/orchestrator/scope.ts +2 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +38 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/pr-generation.ts +5 -2
- package/lib/providers/registry.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/launch.ts +19 -4
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/index.ts +59 -8
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +5 -2
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +26 -0
- package/lib/usage/turns.ts +79 -18
- package/package.json +1 -1
- package/stores/usageUi.ts +11 -2
package/lib/tasks/index.ts
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
* drops it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import {
|
|
8
|
+
parseOverride,
|
|
9
|
+
resolveRole,
|
|
10
|
+
sessionModelRef,
|
|
11
|
+
type ModelOverride,
|
|
12
|
+
} from "../models/resolve";
|
|
13
|
+
import { effectiveModels } from "../models/config";
|
|
14
|
+
import { ANTHROPIC } from "../models/roles";
|
|
7
15
|
import { processTable, runsSomething } from "../process-table";
|
|
8
16
|
import { randomUUID } from "crypto";
|
|
9
17
|
import { db, queries, type Session } from "../db";
|
|
@@ -19,7 +27,6 @@ import {
|
|
|
19
27
|
isBranchName,
|
|
20
28
|
slugify,
|
|
21
29
|
} from "../git";
|
|
22
|
-
import { resolveModelForAgent } from "../model-catalog";
|
|
23
30
|
import { getProvider } from "../providers";
|
|
24
31
|
import { statusDetector } from "../status-detector";
|
|
25
32
|
import { buildTaskBrief, type StackedOn } from "./brief";
|
|
@@ -85,6 +92,10 @@ export interface TaskView {
|
|
|
85
92
|
hostError: string | null;
|
|
86
93
|
// Merges by itself once every gate passes (orchestrator/armed.ts).
|
|
87
94
|
armed?: boolean;
|
|
95
|
+
// What it runs on now, "provider/model", and whether it's still in its
|
|
96
|
+
// plan step (lib/models).
|
|
97
|
+
model?: string;
|
|
98
|
+
planning?: boolean;
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
export async function createTask(opts: {
|
|
@@ -92,7 +103,12 @@ export async function createTask(opts: {
|
|
|
92
103
|
prompt: string;
|
|
93
104
|
// Its name; generated from the prompt when absent.
|
|
94
105
|
name?: string;
|
|
95
|
-
model
|
|
106
|
+
// Its own model: "provider/model" (or an Anthropic alias) for all its
|
|
107
|
+
// roles, or a map of role to one (lib/models). Its workspace's and
|
|
108
|
+
// Settings' role models otherwise.
|
|
109
|
+
model?: ModelOverride;
|
|
110
|
+
// Plan first on the planning model (Settings' default otherwise).
|
|
111
|
+
plan?: boolean;
|
|
96
112
|
// Started from this LumifyHub card, which the task then moves.
|
|
97
113
|
cardId?: string;
|
|
98
114
|
// Stacked: cut from another task's pushed branch, at this exact commit.
|
|
@@ -169,7 +185,7 @@ async function startTask(
|
|
|
169
185
|
id,
|
|
170
186
|
prompt,
|
|
171
187
|
name: opts.name,
|
|
172
|
-
model: opts.model,
|
|
188
|
+
model: typeof opts.model === "string" ? opts.model : undefined,
|
|
173
189
|
baseBranch: opts.baseBranch,
|
|
174
190
|
view,
|
|
175
191
|
});
|
|
@@ -179,6 +195,12 @@ async function startTask(
|
|
|
179
195
|
|
|
180
196
|
if (opts.baseBranch !== undefined && !isBranchName(opts.baseBranch))
|
|
181
197
|
throw new Error(`"${opts.baseBranch}" isn't a branch name`);
|
|
198
|
+
const routing = taskRouting(
|
|
199
|
+
project.workspace_id,
|
|
200
|
+
view,
|
|
201
|
+
opts.model,
|
|
202
|
+
opts.plan
|
|
203
|
+
);
|
|
182
204
|
const projectPath = expandHome(project.working_directory);
|
|
183
205
|
const naming = await nameFor(prompt, projectPath, opts.name);
|
|
184
206
|
const feature = `${slugify(naming.name.split(/\s+/).slice(0, 6).join(" "))}-${id.slice(0, 4)}`;
|
|
@@ -198,10 +220,7 @@ async function startTask(
|
|
|
198
220
|
});
|
|
199
221
|
|
|
200
222
|
const provider = getProvider("claude");
|
|
201
|
-
const model =
|
|
202
|
-
"claude",
|
|
203
|
-
opts.model || project.default_model
|
|
204
|
-
);
|
|
223
|
+
const model = routing.first.model;
|
|
205
224
|
const tmuxName = `${provider.id}-${id}`;
|
|
206
225
|
|
|
207
226
|
queries
|
|
@@ -225,7 +244,8 @@ async function startTask(
|
|
|
225
244
|
.run(wt.worktreePath, wt.branchName, baseBranch, null, id);
|
|
226
245
|
// Everything the launch needs is on the row, so a restart can resume it.
|
|
227
246
|
db.prepare(
|
|
228
|
-
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view =
|
|
247
|
+
`UPDATE sessions SET task_prompt = ?, task_brief = ?, task_status = 'running', setup_status = 'running', name_source = ?, view = ?,
|
|
248
|
+
model_provider = ?, model_overrides = ?, plan_phase = ? WHERE id = ?`
|
|
229
249
|
).run(
|
|
230
250
|
prompt,
|
|
231
251
|
buildTaskBrief({
|
|
@@ -235,6 +255,9 @@ async function startTask(
|
|
|
235
255
|
}),
|
|
236
256
|
naming.source,
|
|
237
257
|
view,
|
|
258
|
+
routing.first.provider.id === ANTHROPIC ? null : routing.first.provider.id,
|
|
259
|
+
routing.override ? JSON.stringify(routing.override) : null,
|
|
260
|
+
routing.plan ? "planning" : null,
|
|
238
261
|
id
|
|
239
262
|
);
|
|
240
263
|
opts.onCreated?.(id);
|
|
@@ -340,6 +363,8 @@ export async function taskView(session: Session): Promise<TaskView> {
|
|
|
340
363
|
hostName: null,
|
|
341
364
|
hostError: null,
|
|
342
365
|
armed: !!session.merge_armed_at,
|
|
366
|
+
model: sessionModelRef(session),
|
|
367
|
+
planning: session.plan_phase === "planning",
|
|
343
368
|
};
|
|
344
369
|
}
|
|
345
370
|
|
|
@@ -365,3 +390,29 @@ export async function listTasks(): Promise<TaskView[]> {
|
|
|
365
390
|
b.createdAt.localeCompare(a.createdAt)
|
|
366
391
|
);
|
|
367
392
|
}
|
|
393
|
+
|
|
394
|
+
// The model a task starts on, and whether it plans first: only as a chat,
|
|
395
|
+
// where the handoff can follow the end of its plan turn, and only when
|
|
396
|
+
// planning and implementing are on different models. Both roles resolve
|
|
397
|
+
// now, so a bad override or a provider that's off fails the start.
|
|
398
|
+
export function taskRouting(
|
|
399
|
+
workspaceId: string | null,
|
|
400
|
+
view: StartView,
|
|
401
|
+
model: unknown,
|
|
402
|
+
plan?: boolean
|
|
403
|
+
) {
|
|
404
|
+
const override = parseOverride(model);
|
|
405
|
+
const models = effectiveModels();
|
|
406
|
+
const opts = { workspaceId, override };
|
|
407
|
+
const implement = resolveRole("implement", opts, models);
|
|
408
|
+
const planning = resolveRole("plan", opts, models);
|
|
409
|
+
const planStep =
|
|
410
|
+
view === "chat" &&
|
|
411
|
+
(plan ?? models.planStep) &&
|
|
412
|
+
planning.ref !== implement.ref;
|
|
413
|
+
return {
|
|
414
|
+
override,
|
|
415
|
+
plan: planStep,
|
|
416
|
+
first: planStep ? planning : implement,
|
|
417
|
+
};
|
|
418
|
+
}
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* is deleted: a started or removed row stays as history.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { ModelOverride } from "../models/resolve";
|
|
14
15
|
import { randomUUID } from "crypto";
|
|
15
16
|
import { db, type Session } from "../db";
|
|
16
17
|
import { getProject } from "../projects";
|
|
@@ -41,6 +42,8 @@ export interface QueueRow {
|
|
|
41
42
|
prompt: string;
|
|
42
43
|
name: string | null;
|
|
43
44
|
model: string | null;
|
|
45
|
+
// A per-role model map (lib/models), when that's what it was given.
|
|
46
|
+
model_overrides: string | null;
|
|
44
47
|
view: "chat" | "terminal" | null;
|
|
45
48
|
base_branch: string | null;
|
|
46
49
|
host_id: string | null;
|
|
@@ -200,7 +203,7 @@ export interface QueueRequest {
|
|
|
200
203
|
projectId: string;
|
|
201
204
|
prompt: string;
|
|
202
205
|
name?: string;
|
|
203
|
-
model?:
|
|
206
|
+
model?: ModelOverride;
|
|
204
207
|
view?: "chat" | "terminal";
|
|
205
208
|
baseBranch?: string;
|
|
206
209
|
hostId?: string;
|
|
@@ -247,9 +250,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
247
250
|
if (!req.prompt.trim()) throw new Error("Describe the task");
|
|
248
251
|
const id = randomUUID();
|
|
249
252
|
db.prepare(
|
|
250
|
-
`INSERT INTO task_queue (id, project_id, prompt, name, model, view, base_branch,
|
|
253
|
+
`INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, view, base_branch,
|
|
251
254
|
host_id, after_task, after_any, origin_workspace_id, position)
|
|
252
|
-
VALUES (@id, @project_id, @prompt, @name, @model, @view, @base_branch,
|
|
255
|
+
VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @view, @base_branch,
|
|
253
256
|
@host_id, @after_task, @after_any, @origin,
|
|
254
257
|
(SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
|
|
255
258
|
).run({
|
|
@@ -257,7 +260,11 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
|
|
|
257
260
|
project_id: project.id,
|
|
258
261
|
prompt: req.prompt.trim(),
|
|
259
262
|
name: req.name?.trim() || null,
|
|
260
|
-
model: req.model || null,
|
|
263
|
+
model: typeof req.model === "string" ? req.model || null : null,
|
|
264
|
+
model_overrides:
|
|
265
|
+
req.model && typeof req.model === "object"
|
|
266
|
+
? JSON.stringify(req.model)
|
|
267
|
+
: null,
|
|
261
268
|
view: req.view ?? null,
|
|
262
269
|
base_branch: req.baseBranch || null,
|
|
263
270
|
host_id: req.hostId || null,
|
|
@@ -429,7 +436,9 @@ async function startRow(
|
|
|
429
436
|
projectId: r.project_id,
|
|
430
437
|
prompt: r.prompt,
|
|
431
438
|
name: r.name ?? undefined,
|
|
432
|
-
model: r.
|
|
439
|
+
model: r.model_overrides
|
|
440
|
+
? (JSON.parse(r.model_overrides) as ModelOverride)
|
|
441
|
+
: (r.model ?? undefined),
|
|
433
442
|
view: r.view ?? undefined,
|
|
434
443
|
baseBranch: r.base_branch ?? undefined,
|
|
435
444
|
hostId: r.host_id ?? undefined,
|
package/lib/tasks/start.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { deleteQueued, enqueue, listQueue } from "../chat/queued";
|
|
|
23
23
|
import { sendQueuedNow } from "../chat/runner";
|
|
24
24
|
import { hasItem } from "../chat/store";
|
|
25
25
|
import { firstMessageId, launchPending } from "./launch-gate";
|
|
26
|
+
import { planPrompt, preparePlanDir } from "../models/plan-step";
|
|
26
27
|
|
|
27
28
|
export { firstMessageId, launchPending };
|
|
28
29
|
import { expandHome } from "./session";
|
|
@@ -136,7 +137,10 @@ export async function finishTaskStart(
|
|
|
136
137
|
ms: setup.ms,
|
|
137
138
|
error: "Not launched: the task was closed during setup",
|
|
138
139
|
});
|
|
139
|
-
const
|
|
140
|
+
const task = (session.task_prompt ?? "") + setupNote(setup);
|
|
141
|
+
const planning = session.plan_phase === "planning";
|
|
142
|
+
if (planning) preparePlanDir(session.worktree_path);
|
|
143
|
+
const prompt = planning ? planPrompt(task) : task;
|
|
140
144
|
try {
|
|
141
145
|
if (session.view === "chat") await launchChat(sessionId, prompt);
|
|
142
146
|
else {
|
|
@@ -148,6 +152,7 @@ export async function finishTaskStart(
|
|
|
148
152
|
tmuxName: session.tmux_name,
|
|
149
153
|
cwd: session.worktree_path,
|
|
150
154
|
model: session.model,
|
|
155
|
+
provider: session.model_provider,
|
|
151
156
|
prompt,
|
|
152
157
|
brief: session.task_brief ?? undefined,
|
|
153
158
|
});
|
|
@@ -190,7 +195,7 @@ function earlierSetup(row: SetupColumns): TaskSetup | undefined {
|
|
|
190
195
|
return undefined;
|
|
191
196
|
}
|
|
192
197
|
|
|
193
|
-
const killTmux = (name: string) =>
|
|
198
|
+
export const killTmux = (name: string) =>
|
|
194
199
|
execFileAsync("tmux", ["kill-session", "-t", `=${name}`], {
|
|
195
200
|
timeout: 5000,
|
|
196
201
|
}).catch(() => undefined);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend by model and by role over a window: tokens of each kind, turns,
|
|
3
|
+
* and the estimated cost from the price table (lib/usage/cost).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { estimateCost, rowModel, UNRECORDED, type PricedRow } from "./cost";
|
|
7
|
+
import type { ModelPrice } from "../models/providers";
|
|
8
|
+
|
|
9
|
+
export interface ModelTurnRow extends PricedRow {
|
|
10
|
+
at: number;
|
|
11
|
+
role: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TokenSpend {
|
|
15
|
+
inputTokens: number;
|
|
16
|
+
outputTokens: number;
|
|
17
|
+
cacheReadTokens: number;
|
|
18
|
+
cacheWriteTokens: number;
|
|
19
|
+
turns: number;
|
|
20
|
+
costUsd: number;
|
|
21
|
+
// Some of its turns are on a model the price table doesn't list.
|
|
22
|
+
unpriced: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelSpend extends TokenSpend {
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RoleSpend extends TokenSpend {
|
|
31
|
+
role: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SpendByModel {
|
|
35
|
+
since: number;
|
|
36
|
+
total: TokenSpend;
|
|
37
|
+
models: ModelSpend[];
|
|
38
|
+
roles: RoleSpend[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const emptySpend = (): TokenSpend => ({
|
|
42
|
+
inputTokens: 0,
|
|
43
|
+
outputTokens: 0,
|
|
44
|
+
cacheReadTokens: 0,
|
|
45
|
+
cacheWriteTokens: 0,
|
|
46
|
+
turns: 0,
|
|
47
|
+
costUsd: 0,
|
|
48
|
+
unpriced: false,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export function addRow(
|
|
52
|
+
s: TokenSpend,
|
|
53
|
+
r: PricedRow,
|
|
54
|
+
prices: Record<string, ModelPrice>
|
|
55
|
+
): void {
|
|
56
|
+
const cost = estimateCost(r, prices);
|
|
57
|
+
s.inputTokens += r.input_tokens;
|
|
58
|
+
s.outputTokens += r.output_tokens;
|
|
59
|
+
s.cacheReadTokens += r.cache_read_tokens;
|
|
60
|
+
s.cacheWriteTokens += r.cache_write_tokens;
|
|
61
|
+
s.turns += 1;
|
|
62
|
+
s.costUsd += cost.usd;
|
|
63
|
+
s.unpriced ||= !cost.priced;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function spendByModel(
|
|
67
|
+
rows: ModelTurnRow[],
|
|
68
|
+
since: number,
|
|
69
|
+
prices: Record<string, ModelPrice>
|
|
70
|
+
): SpendByModel {
|
|
71
|
+
const total = emptySpend();
|
|
72
|
+
const models = new Map<string, ModelSpend>();
|
|
73
|
+
const roles = new Map<string, RoleSpend>();
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
if (r.at < since) continue;
|
|
76
|
+
const { provider, model } = rowModel(r);
|
|
77
|
+
const mk = `${provider}/${model}`;
|
|
78
|
+
if (!models.has(mk)) models.set(mk, { ...emptySpend(), provider, model });
|
|
79
|
+
const role = r.role ?? UNRECORDED;
|
|
80
|
+
if (!roles.has(role)) roles.set(role, { ...emptySpend(), role });
|
|
81
|
+
for (const s of [total, models.get(mk)!, roles.get(role)!])
|
|
82
|
+
addRow(s, r, prices);
|
|
83
|
+
}
|
|
84
|
+
const byCost = (a: TokenSpend, b: TokenSpend) => b.costUsd - a.costUsd;
|
|
85
|
+
return {
|
|
86
|
+
since,
|
|
87
|
+
total,
|
|
88
|
+
models: [...models.values()].sort(byCost),
|
|
89
|
+
roles: [...roles.values()].sort(byCost),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sumSpend(xs: TokenSpend[]): TokenSpend {
|
|
94
|
+
const t = emptySpend();
|
|
95
|
+
for (const x of xs) {
|
|
96
|
+
t.inputTokens += x.inputTokens;
|
|
97
|
+
t.outputTokens += x.outputTokens;
|
|
98
|
+
t.cacheReadTokens += x.cacheReadTokens;
|
|
99
|
+
t.cacheWriteTokens += x.cacheWriteTokens;
|
|
100
|
+
t.turns += x.turns;
|
|
101
|
+
t.costUsd += x.costUsd;
|
|
102
|
+
t.unpriced ||= x.unpriced;
|
|
103
|
+
}
|
|
104
|
+
return t;
|
|
105
|
+
}
|
package/lib/usage/check-runs.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { formatTokens } from "../chat/context";
|
|
|
10
10
|
|
|
11
11
|
export interface RunUsage {
|
|
12
12
|
model: string | null;
|
|
13
|
+
// Where the model ran (lib/models); Anthropic when absent.
|
|
14
|
+
provider?: string;
|
|
13
15
|
inputTokens: number;
|
|
14
16
|
cacheWriteTokens: number;
|
|
15
17
|
cacheReadTokens: number;
|
|
@@ -92,9 +94,9 @@ export function recordCheckRun(
|
|
|
92
94
|
at = Date.now()
|
|
93
95
|
): void {
|
|
94
96
|
db.prepare(
|
|
95
|
-
`INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model,
|
|
97
|
+
`INSERT INTO check_runs (workspace_id, session_id, label, pr_number, sha, kind, model, provider,
|
|
96
98
|
input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, at)
|
|
97
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
98
100
|
).run(
|
|
99
101
|
target.workspaceId,
|
|
100
102
|
target.sessionId,
|
|
@@ -103,6 +105,7 @@ export function recordCheckRun(
|
|
|
103
105
|
target.sha,
|
|
104
106
|
target.kind,
|
|
105
107
|
u.model,
|
|
108
|
+
u.provider ?? "anthropic",
|
|
106
109
|
u.inputTokens,
|
|
107
110
|
u.cacheWriteTokens,
|
|
108
111
|
u.cacheReadTokens,
|
|
@@ -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
|
+
}
|