@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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useCompareQuery } from "@/data/models";
|
|
5
|
+
import type { Cohort, Comparison } from "@/lib/usage/compare";
|
|
6
|
+
import { ROLE_LABELS, isModelRole } from "@/lib/models/roles";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { formatPct, formatTokens, formatUsd } from "./format";
|
|
9
|
+
import { SpendTable } from "./ModelSpend";
|
|
10
|
+
import { emptySpend, sumSpend } from "@/lib/usage/by-model";
|
|
11
|
+
|
|
12
|
+
const RANGES = [7, 30, 90] as const;
|
|
13
|
+
|
|
14
|
+
const per = (n: number, tasks: number) => (tasks ? n / tasks : 0);
|
|
15
|
+
const minutes = (ms: number) => `${Math.round(ms / 60000)}m`;
|
|
16
|
+
|
|
17
|
+
const totalCohort = (cs: Cohort[]): Cohort =>
|
|
18
|
+
cs.reduce<Cohort>(
|
|
19
|
+
(t, c) => ({
|
|
20
|
+
...t,
|
|
21
|
+
tasks: t.tasks + c.tasks,
|
|
22
|
+
spend: sumSpend([t.spend, c.spend]),
|
|
23
|
+
planPct: t.planPct + c.planPct,
|
|
24
|
+
wallMs: t.wallMs + c.wallMs,
|
|
25
|
+
ciFirstGreen: add(t.ciFirstGreen, c.ciFirstGreen),
|
|
26
|
+
reviewFirstPass: add(t.reviewFirstPass, c.reviewFirstPass),
|
|
27
|
+
gateFailures: t.gateFailures + c.gateFailures,
|
|
28
|
+
rework: t.rework + c.rework,
|
|
29
|
+
merged: add(t.merged, c.merged),
|
|
30
|
+
dropped: add(t.dropped, c.dropped),
|
|
31
|
+
}),
|
|
32
|
+
{
|
|
33
|
+
key: "Total",
|
|
34
|
+
baseline: false,
|
|
35
|
+
tasks: 0,
|
|
36
|
+
smallSample: false,
|
|
37
|
+
spend: emptySpend(),
|
|
38
|
+
planPct: 0,
|
|
39
|
+
wallMs: 0,
|
|
40
|
+
ciFirstGreen: { yes: 0, of: 0 },
|
|
41
|
+
reviewFirstPass: { yes: 0, of: 0 },
|
|
42
|
+
gateFailures: 0,
|
|
43
|
+
rework: 0,
|
|
44
|
+
merged: { yes: 0, of: 0 },
|
|
45
|
+
dropped: { yes: 0, of: 0 },
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
function add(a: { yes: number; of: number }, b: { yes: number; of: number }) {
|
|
50
|
+
return { yes: a.yes + b.yes, of: a.of + b.of };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ROWS: { label: string; value: (c: Cohort) => string }[] = [
|
|
54
|
+
{ label: "Tasks", value: (c) => String(c.tasks) },
|
|
55
|
+
{ label: "Turns", value: (c) => String(c.spend.turns) },
|
|
56
|
+
{
|
|
57
|
+
label: "Tokens",
|
|
58
|
+
value: (c) =>
|
|
59
|
+
formatTokens(
|
|
60
|
+
c.spend.inputTokens +
|
|
61
|
+
c.spend.outputTokens +
|
|
62
|
+
c.spend.cacheReadTokens +
|
|
63
|
+
c.spend.cacheWriteTokens
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
{ label: "Est. cost", value: (c) => formatUsd(c.spend.costUsd) },
|
|
67
|
+
{
|
|
68
|
+
label: "Cost per task",
|
|
69
|
+
value: (c) => formatUsd(per(c.spend.costUsd, c.tasks)),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
label: "Claude window per task",
|
|
73
|
+
value: (c) =>
|
|
74
|
+
c.planPct ? `${per(c.planPct, c.tasks).toFixed(1)} pts` : "—",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
label: "Agent time per task",
|
|
78
|
+
value: (c) => minutes(per(c.wallMs, c.tasks)),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
label: "CI green on first push",
|
|
82
|
+
value: (c) => formatPct(c.ciFirstGreen.yes, c.ciFirstGreen.of),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
label: "Review passed first try",
|
|
86
|
+
value: (c) => formatPct(c.reviewFirstPass.yes, c.reviewFirstPass.of),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
label: "Gate failures per task",
|
|
90
|
+
value: (c) => per(c.gateFailures, c.tasks).toFixed(1),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
label: "Rework commits per task",
|
|
94
|
+
value: (c) => per(c.rework, c.tasks).toFixed(1),
|
|
95
|
+
},
|
|
96
|
+
{ label: "Merged", value: (c) => formatPct(c.merged.yes, c.merged.of) },
|
|
97
|
+
{ label: "Dropped", value: (c) => formatPct(c.dropped.yes, c.dropped.of) },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// Claude-only tasks against routed ones, side by side, over a date range.
|
|
101
|
+
export function CompareView({ enabled }: { enabled: boolean }) {
|
|
102
|
+
const [days, setDays] = useState<number>(30);
|
|
103
|
+
const { data, isPending, isError, error } = useCompareQuery(days, enabled);
|
|
104
|
+
return (
|
|
105
|
+
<section className="space-y-3">
|
|
106
|
+
<div className="flex items-center justify-between gap-2">
|
|
107
|
+
<h3 className="text-sm font-medium">Before and after routing</h3>
|
|
108
|
+
<div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
|
|
109
|
+
{RANGES.map((d) => (
|
|
110
|
+
<button
|
|
111
|
+
key={d}
|
|
112
|
+
type="button"
|
|
113
|
+
onClick={() => setDays(d)}
|
|
114
|
+
aria-pressed={days === d}
|
|
115
|
+
className={cn(
|
|
116
|
+
"h-11 rounded-md px-3 text-xs md:h-8",
|
|
117
|
+
days === d
|
|
118
|
+
? "bg-card text-foreground shadow-sm"
|
|
119
|
+
: "text-muted-foreground"
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
{d} days
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
{isError && <p className="text-destructive text-sm">{error.message}</p>}
|
|
128
|
+
{isPending && !data && (
|
|
129
|
+
<div className="bg-foreground/[0.04] h-40 animate-pulse rounded-xl" />
|
|
130
|
+
)}
|
|
131
|
+
{data && <CompareTables data={data} />}
|
|
132
|
+
</section>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function CompareTables({ data }: { data: Comparison }) {
|
|
137
|
+
if (!data.cohorts.length)
|
|
138
|
+
return (
|
|
139
|
+
<p className="text-muted-foreground text-xs">
|
|
140
|
+
No tasks ran in this range.
|
|
141
|
+
</p>
|
|
142
|
+
);
|
|
143
|
+
const columns = [...data.cohorts, totalCohort(data.cohorts)];
|
|
144
|
+
const cell = "py-1.5 pr-2 text-right tabular-nums";
|
|
145
|
+
return (
|
|
146
|
+
<>
|
|
147
|
+
<div className="overflow-x-auto">
|
|
148
|
+
<table className="w-full text-xs">
|
|
149
|
+
<thead>
|
|
150
|
+
<tr>
|
|
151
|
+
<th className="py-1 pr-2 text-left font-normal" />
|
|
152
|
+
{columns.map((c) => (
|
|
153
|
+
<th
|
|
154
|
+
key={c.key}
|
|
155
|
+
className={cn(cell, "align-bottom font-medium")}
|
|
156
|
+
>
|
|
157
|
+
<span className="font-mono">{c.key}</span>
|
|
158
|
+
{c.baseline && (
|
|
159
|
+
<span className="text-muted-foreground block font-normal">
|
|
160
|
+
baseline
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
{c.smallSample && (
|
|
164
|
+
<span className="block font-normal text-amber-600 dark:text-amber-400">
|
|
165
|
+
small sample
|
|
166
|
+
</span>
|
|
167
|
+
)}
|
|
168
|
+
</th>
|
|
169
|
+
))}
|
|
170
|
+
</tr>
|
|
171
|
+
</thead>
|
|
172
|
+
<tbody>
|
|
173
|
+
{ROWS.map((row) => (
|
|
174
|
+
<tr key={row.label} className="border-border border-t">
|
|
175
|
+
<td className="text-muted-foreground py-1.5 pr-2">
|
|
176
|
+
{row.label}
|
|
177
|
+
</td>
|
|
178
|
+
{columns.map((c) => (
|
|
179
|
+
<td
|
|
180
|
+
key={c.key}
|
|
181
|
+
className={cn(cell, c.key === "Total" && "font-medium")}
|
|
182
|
+
>
|
|
183
|
+
{row.value(c)}
|
|
184
|
+
</td>
|
|
185
|
+
))}
|
|
186
|
+
</tr>
|
|
187
|
+
))}
|
|
188
|
+
</tbody>
|
|
189
|
+
</table>
|
|
190
|
+
</div>
|
|
191
|
+
<SpendTable
|
|
192
|
+
title="Role · model"
|
|
193
|
+
rows={data.roles.map((r) => ({
|
|
194
|
+
...r,
|
|
195
|
+
key: `${r.role} ${r.provider}/${r.model}`,
|
|
196
|
+
label: `${isModelRole(r.role) ? ROLE_LABELS[r.role].label : "Before routing"} · ${r.provider}/${r.model}`,
|
|
197
|
+
}))}
|
|
198
|
+
total={sumSpend(data.roles)}
|
|
199
|
+
/>
|
|
200
|
+
<p className="text-muted-foreground text-[11px]">
|
|
201
|
+
A task counts under the provider its own turns ran on (anthropic is the
|
|
202
|
+
baseline); its reviews and checks count toward its cost. Dollars are
|
|
203
|
+
list-price estimates; for Claude, the plan's real measure is its
|
|
204
|
+
5-hour window, shared out by how far each turn moved it (an estimate).
|
|
205
|
+
Outcomes come from the orchestrator's checks, so only its tasks
|
|
206
|
+
have them. Under 10 tasks a column is a small sample: read it as a hint,
|
|
207
|
+
not a result.
|
|
208
|
+
</p>
|
|
209
|
+
</>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { SpendByModel, TokenSpend } from "@/lib/usage/by-model";
|
|
5
|
+
import { ROLE_LABELS, isModelRole } from "@/lib/models/roles";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { formatTokens, formatUsd } from "./format";
|
|
8
|
+
|
|
9
|
+
const roleLabel = (r: string) =>
|
|
10
|
+
isModelRole(r) ? ROLE_LABELS[r].label : "Before routing";
|
|
11
|
+
|
|
12
|
+
// Tokens and estimated cost by role and by model, in the plan's 5-hour
|
|
13
|
+
// window or the last 24 hours.
|
|
14
|
+
export function ModelSpend({
|
|
15
|
+
data,
|
|
16
|
+
}: {
|
|
17
|
+
data: { fiveHour: SpendByModel; day: SpendByModel } | undefined;
|
|
18
|
+
}) {
|
|
19
|
+
const [span, setSpan] = useState<"fiveHour" | "day">("fiveHour");
|
|
20
|
+
if (!data) return null;
|
|
21
|
+
const shown = data[span];
|
|
22
|
+
return (
|
|
23
|
+
<section className="space-y-3">
|
|
24
|
+
<div className="flex items-center justify-between gap-2">
|
|
25
|
+
<h3 className="text-sm font-medium">By model and role</h3>
|
|
26
|
+
<div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
|
|
27
|
+
{(
|
|
28
|
+
[
|
|
29
|
+
["fiveHour", "5-hour window"],
|
|
30
|
+
["day", "24 hours"],
|
|
31
|
+
] as const
|
|
32
|
+
).map(([v, label]) => (
|
|
33
|
+
<button
|
|
34
|
+
key={v}
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={() => setSpan(v)}
|
|
37
|
+
aria-pressed={span === v}
|
|
38
|
+
className={cn(
|
|
39
|
+
"h-11 rounded-md px-3 text-xs md:h-8",
|
|
40
|
+
span === v
|
|
41
|
+
? "bg-card text-foreground shadow-sm"
|
|
42
|
+
: "text-muted-foreground"
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{label}
|
|
46
|
+
</button>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<SpendTable
|
|
51
|
+
title="Role"
|
|
52
|
+
rows={shown.roles.map((r) => ({
|
|
53
|
+
key: r.role,
|
|
54
|
+
label: roleLabel(r.role),
|
|
55
|
+
...r,
|
|
56
|
+
}))}
|
|
57
|
+
total={shown.total}
|
|
58
|
+
/>
|
|
59
|
+
<SpendTable
|
|
60
|
+
title="Model"
|
|
61
|
+
rows={shown.models.map((m) => ({
|
|
62
|
+
key: `${m.provider}/${m.model}`,
|
|
63
|
+
label: `${m.provider}/${m.model}`,
|
|
64
|
+
...m,
|
|
65
|
+
}))}
|
|
66
|
+
total={shown.total}
|
|
67
|
+
/>
|
|
68
|
+
</section>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function SpendTable({
|
|
73
|
+
title,
|
|
74
|
+
rows,
|
|
75
|
+
total,
|
|
76
|
+
}: {
|
|
77
|
+
title: string;
|
|
78
|
+
rows: (TokenSpend & { key: string; label: string })[];
|
|
79
|
+
total: TokenSpend;
|
|
80
|
+
}) {
|
|
81
|
+
if (!rows.length)
|
|
82
|
+
return <p className="text-muted-foreground text-xs">Nothing yet.</p>;
|
|
83
|
+
const cell = "py-1.5 pr-2 text-right tabular-nums";
|
|
84
|
+
return (
|
|
85
|
+
<div className="overflow-x-auto">
|
|
86
|
+
<table className="w-full text-xs">
|
|
87
|
+
<thead className="text-muted-foreground">
|
|
88
|
+
<tr>
|
|
89
|
+
<th className="py-1 pr-2 text-left font-normal">{title}</th>
|
|
90
|
+
<th className={cn(cell, "font-normal")}>In</th>
|
|
91
|
+
<th className={cn(cell, "font-normal")}>Out</th>
|
|
92
|
+
<th className={cn(cell, "font-normal")}>Cache r/w</th>
|
|
93
|
+
<th className={cn(cell, "font-normal")}>Turns</th>
|
|
94
|
+
<th className={cn(cell, "font-normal")}>Est. cost</th>
|
|
95
|
+
</tr>
|
|
96
|
+
</thead>
|
|
97
|
+
<tbody>
|
|
98
|
+
{[...rows, { ...total, key: "__total", label: "Total" }].map((r) => (
|
|
99
|
+
<tr
|
|
100
|
+
key={r.key}
|
|
101
|
+
className={cn(
|
|
102
|
+
"border-border border-t",
|
|
103
|
+
r.key === "__total" && "font-medium"
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
<td className="max-w-48 truncate py-1.5 pr-2 font-mono md:max-w-72">
|
|
107
|
+
{r.label}
|
|
108
|
+
</td>
|
|
109
|
+
<td className={cell}>{formatTokens(r.inputTokens)}</td>
|
|
110
|
+
<td className={cell}>{formatTokens(r.outputTokens)}</td>
|
|
111
|
+
<td className={cell}>
|
|
112
|
+
{formatTokens(r.cacheReadTokens)}/
|
|
113
|
+
{formatTokens(r.cacheWriteTokens)}
|
|
114
|
+
</td>
|
|
115
|
+
<td className={cell}>{r.turns}</td>
|
|
116
|
+
<td
|
|
117
|
+
className={cell}
|
|
118
|
+
title={
|
|
119
|
+
r.unpriced
|
|
120
|
+
? "Partly the agent's own figure: not in the price table"
|
|
121
|
+
: undefined
|
|
122
|
+
}
|
|
123
|
+
>
|
|
124
|
+
{formatUsd(r.costUsd)}
|
|
125
|
+
{r.unpriced ? "*" : ""}
|
|
126
|
+
</td>
|
|
127
|
+
</tr>
|
|
128
|
+
))}
|
|
129
|
+
</tbody>
|
|
130
|
+
</table>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -12,11 +12,19 @@ import {
|
|
|
12
12
|
import { useUsageQuery } from "@/data/usage";
|
|
13
13
|
import type { UsageRange } from "@/lib/usage/aggregate";
|
|
14
14
|
import { cn } from "@/lib/utils";
|
|
15
|
-
import { usageUi, usageUiActions } from "@/stores/usageUi";
|
|
15
|
+
import { usageUi, usageUiActions, type UsageTab } from "@/stores/usageUi";
|
|
16
|
+
import { CompareView } from "./CompareView";
|
|
17
|
+
import { ModelSpend } from "./ModelSpend";
|
|
16
18
|
import { DayBars, SpendList } from "./SpendCharts";
|
|
17
19
|
import { WindowCard } from "./WindowCard";
|
|
18
20
|
import { formatUsd } from "./format";
|
|
19
21
|
|
|
22
|
+
const TABS: { value: UsageTab; label: string }[] = [
|
|
23
|
+
{ value: "spend", label: "Spend" },
|
|
24
|
+
{ value: "models", label: "By model" },
|
|
25
|
+
{ value: "compare", label: "Compare" },
|
|
26
|
+
];
|
|
27
|
+
|
|
20
28
|
const RANGES: { value: UsageRange; label: string }[] = [
|
|
21
29
|
{ value: "today", label: "Today" },
|
|
22
30
|
{ value: "7d", label: "7 days" },
|
|
@@ -26,7 +34,7 @@ const RANGES: { value: UsageRange; label: string }[] = [
|
|
|
26
34
|
// The account's rolling windows, and what chats cost by day, session and
|
|
27
35
|
// workspace.
|
|
28
36
|
export function UsageDialog() {
|
|
29
|
-
const { open } = useSnapshot(usageUi);
|
|
37
|
+
const { open, tab } = useSnapshot(usageUi);
|
|
30
38
|
const [range, setRange] = useState<UsageRange>("7d");
|
|
31
39
|
const { data, isPending, isError, error } = useUsageQuery(range, open);
|
|
32
40
|
const report = data?.report;
|
|
@@ -56,7 +64,34 @@ export function UsageDialog() {
|
|
|
56
64
|
)}
|
|
57
65
|
</section>
|
|
58
66
|
|
|
59
|
-
<
|
|
67
|
+
<div
|
|
68
|
+
role="tablist"
|
|
69
|
+
aria-label="Usage views"
|
|
70
|
+
className="bg-foreground/[0.04] flex rounded-lg p-0.5"
|
|
71
|
+
>
|
|
72
|
+
{TABS.map((t) => (
|
|
73
|
+
<button
|
|
74
|
+
key={t.value}
|
|
75
|
+
type="button"
|
|
76
|
+
role="tab"
|
|
77
|
+
aria-selected={tab === t.value}
|
|
78
|
+
onClick={() => usageUiActions.setTab(t.value)}
|
|
79
|
+
className={cn(
|
|
80
|
+
"h-11 flex-1 rounded-md px-3 text-xs md:h-8",
|
|
81
|
+
tab === t.value
|
|
82
|
+
? "bg-card text-foreground shadow-sm"
|
|
83
|
+
: "text-muted-foreground"
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{t.label}
|
|
87
|
+
</button>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{tab === "models" && <ModelSpend data={data?.models} />}
|
|
92
|
+
{tab === "compare" && <CompareView enabled={open} />}
|
|
93
|
+
|
|
94
|
+
<section className={cn("space-y-3", tab !== "spend" && "hidden")}>
|
|
60
95
|
<div className="flex items-center justify-between gap-2">
|
|
61
96
|
<h3 className="text-sm font-medium">Chat spend</h3>
|
|
62
97
|
<div className="bg-foreground/[0.04] flex rounded-lg p-0.5">
|
|
@@ -10,3 +10,12 @@ export function formatDuration(seconds: number): string {
|
|
|
10
10
|
if (h < 48) return `${h}h${m % 60 ? ` ${m % 60}m` : ""}`;
|
|
11
11
|
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
export function formatTokens(n: number): string {
|
|
15
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
16
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
|
|
17
|
+
return String(n);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const formatPct = (yes: number, of: number) =>
|
|
21
|
+
of ? `${Math.round((yes / of) * 100)}%` : "—";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ModelSelect } from "@/components/Models/ModelSelect";
|
|
5
|
+
import {
|
|
6
|
+
useModelsQuery,
|
|
7
|
+
useUpdateWorkspaceModels,
|
|
8
|
+
useWorkspaceModelsQuery,
|
|
9
|
+
} from "@/data/models";
|
|
10
|
+
import { MODEL_ROLES, ROLE_LABELS, type ModelRole } from "@/lib/models/roles";
|
|
11
|
+
|
|
12
|
+
// The roles that run inside a workspace; interactive sessions and helpers
|
|
13
|
+
// follow Settings.
|
|
14
|
+
const WORKSPACE_ROLES = MODEL_ROLES.filter(
|
|
15
|
+
(r): r is Exclude<ModelRole, "interactive" | "helper"> =>
|
|
16
|
+
r !== "interactive" && r !== "helper"
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// A workspace's own role models, over Settings > Models.
|
|
20
|
+
export function WorkspaceModelsForm({ workspaceId }: { workspaceId: string }) {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
const models = useModelsQuery(open);
|
|
23
|
+
const own = useWorkspaceModelsQuery(workspaceId);
|
|
24
|
+
const update = useUpdateWorkspaceModels(workspaceId);
|
|
25
|
+
const roles = own.data?.roles ?? {};
|
|
26
|
+
const set = Object.keys(roles).length;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="space-y-2">
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
className="text-muted-foreground hover:text-foreground h-11 text-left text-xs md:h-auto"
|
|
33
|
+
onClick={() => setOpen((o) => !o)}
|
|
34
|
+
aria-expanded={open}
|
|
35
|
+
>
|
|
36
|
+
Models: {set ? `${set} set for this workspace` : "as Settings › Models"}{" "}
|
|
37
|
+
{open ? "▴" : "▾"}
|
|
38
|
+
</button>
|
|
39
|
+
{open && (
|
|
40
|
+
<ul className="space-y-2">
|
|
41
|
+
{WORKSPACE_ROLES.map((role) => (
|
|
42
|
+
<li key={role} className="space-y-1">
|
|
43
|
+
<label
|
|
44
|
+
htmlFor={`ws-${workspaceId}-${role}`}
|
|
45
|
+
className="text-muted-foreground block text-xs"
|
|
46
|
+
>
|
|
47
|
+
{ROLE_LABELS[role].label}
|
|
48
|
+
</label>
|
|
49
|
+
<ModelSelect
|
|
50
|
+
id={`ws-${workspaceId}-${role}`}
|
|
51
|
+
value={roles[role] ?? null}
|
|
52
|
+
effective={models.data?.effective}
|
|
53
|
+
inheritLabel={`As Settings (${models.data?.effective.roles[role] ?? "…"})`}
|
|
54
|
+
disabled={update.isPending || !models.data}
|
|
55
|
+
onChange={(ref) => {
|
|
56
|
+
const next = { ...roles };
|
|
57
|
+
if (ref) next[role] = ref;
|
|
58
|
+
else delete next[role];
|
|
59
|
+
update.mutate({ roles: next });
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
</li>
|
|
63
|
+
))}
|
|
64
|
+
</ul>
|
|
65
|
+
)}
|
|
66
|
+
{update.isError && (
|
|
67
|
+
<p className="text-destructive text-xs">{update.error.message}</p>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -5,6 +5,7 @@ import { useUpdateWorkspace, useWorkspacesQuery } from "@/data/workspaces";
|
|
|
5
5
|
import { TaskLimitForm } from "./TaskLimitForm";
|
|
6
6
|
import { RulesForm } from "./RulesForm";
|
|
7
7
|
import { FreshStartForm } from "./FreshStartForm";
|
|
8
|
+
import { WorkspaceModelsForm } from "./WorkspaceModelsForm";
|
|
8
9
|
|
|
9
10
|
// Settings > Workspaces: each workspace's own settings.
|
|
10
11
|
export function WorkspaceSettings() {
|
|
@@ -41,6 +42,7 @@ export function WorkspaceSettings() {
|
|
|
41
42
|
workspace={w}
|
|
42
43
|
/>
|
|
43
44
|
<RulesForm workspaceId={w.id} />
|
|
45
|
+
<WorkspaceModelsForm workspaceId={w.id} />
|
|
44
46
|
<SettingToggle
|
|
45
47
|
title="Wake tasks on CI failures and reviews"
|
|
46
48
|
detail="A task's agent is messaged once when a check fails on its PR's head, with the log's tail, and when someone reviews or comments on it."
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type {
|
|
3
|
+
EffectiveModels,
|
|
4
|
+
ModelSettings,
|
|
5
|
+
WorkspaceModels,
|
|
6
|
+
} from "@/lib/models/config";
|
|
7
|
+
import type { ModelRole } from "@/lib/models/roles";
|
|
8
|
+
import type { ProviderTest } from "@/lib/models/test-provider";
|
|
9
|
+
import type { ResumeResult } from "@/lib/models/resume";
|
|
10
|
+
import type { ModelOverride } from "@/lib/models/resolve";
|
|
11
|
+
import type { Comparison } from "@/lib/usage/compare";
|
|
12
|
+
|
|
13
|
+
// Model routing (lib/models): role models, providers, prices.
|
|
14
|
+
export const modelKeys = {
|
|
15
|
+
all: ["models"] as const,
|
|
16
|
+
workspace: (id: string) => [...modelKeys.all, "workspace", id] as const,
|
|
17
|
+
compare: (days: number) => [...modelKeys.all, "compare", days] as const,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface ModelsResponse {
|
|
21
|
+
saved: ModelSettings;
|
|
22
|
+
effective: EffectiveModels;
|
|
23
|
+
defaults: Record<ModelRole, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function json<T>(res: Response): Promise<T> {
|
|
27
|
+
const data = await res.json().catch(() => ({}));
|
|
28
|
+
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const send = (url: string, method: string, body: unknown) =>
|
|
33
|
+
fetch(url, {
|
|
34
|
+
method,
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export function useModelsQuery(enabled = true) {
|
|
40
|
+
return useQuery({
|
|
41
|
+
queryKey: modelKeys.all,
|
|
42
|
+
queryFn: async () => json<ModelsResponse>(await fetch("/api/models")),
|
|
43
|
+
staleTime: 60_000,
|
|
44
|
+
enabled,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useUpdateModels() {
|
|
49
|
+
const queryClient = useQueryClient();
|
|
50
|
+
return useMutation({
|
|
51
|
+
mutationFn: async (settings: ModelSettings) =>
|
|
52
|
+
json<ModelsResponse>(await send("/api/models", "PUT", settings)),
|
|
53
|
+
onSuccess: (data) => queryClient.setQueryData(modelKeys.all, data),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useTestProvider() {
|
|
58
|
+
return useMutation({
|
|
59
|
+
mutationFn: async (input: { provider: string; model?: string }) =>
|
|
60
|
+
json<ProviderTest>(await send("/api/models/test", "POST", input)),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useWorkspaceModelsQuery(workspaceId: string) {
|
|
65
|
+
return useQuery({
|
|
66
|
+
queryKey: modelKeys.workspace(workspaceId),
|
|
67
|
+
queryFn: async () =>
|
|
68
|
+
json<WorkspaceModels>(
|
|
69
|
+
await fetch(`/api/workspaces/${workspaceId}/models`)
|
|
70
|
+
),
|
|
71
|
+
staleTime: 60_000,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function useUpdateWorkspaceModels(workspaceId: string) {
|
|
76
|
+
const queryClient = useQueryClient();
|
|
77
|
+
return useMutation({
|
|
78
|
+
mutationFn: async (models: WorkspaceModels) =>
|
|
79
|
+
json<WorkspaceModels>(
|
|
80
|
+
await send(`/api/workspaces/${workspaceId}/models`, "PUT", models)
|
|
81
|
+
),
|
|
82
|
+
onSuccess: (data) =>
|
|
83
|
+
queryClient.setQueryData(modelKeys.workspace(workspaceId), data),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useResumeOnModel() {
|
|
88
|
+
return useMutation({
|
|
89
|
+
mutationFn: async (input: {
|
|
90
|
+
taskId: string;
|
|
91
|
+
model: ModelOverride;
|
|
92
|
+
message?: string;
|
|
93
|
+
}) =>
|
|
94
|
+
json<ResumeResult>(
|
|
95
|
+
await send(`/api/tasks/${input.taskId}/model`, "POST", {
|
|
96
|
+
model: input.model,
|
|
97
|
+
message: input.message,
|
|
98
|
+
})
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function useCompareQuery(days: number, enabled = true) {
|
|
104
|
+
return useQuery({
|
|
105
|
+
queryKey: modelKeys.compare(days),
|
|
106
|
+
queryFn: async () =>
|
|
107
|
+
json<Comparison>(await fetch(`/api/usage/compare?days=${days}`)),
|
|
108
|
+
enabled,
|
|
109
|
+
staleTime: 60_000,
|
|
110
|
+
placeholderData: (prev) => prev,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Every model a role can be set to: each usable provider's, as references.
|
|
115
|
+
export function modelChoices(effective: EffectiveModels | undefined) {
|
|
116
|
+
return (effective?.providers ?? [])
|
|
117
|
+
.filter((p) => p.enabled)
|
|
118
|
+
.flatMap((p) =>
|
|
119
|
+
p.models.map((m) => ({
|
|
120
|
+
ref: `${p.id}/${m}`,
|
|
121
|
+
label: m,
|
|
122
|
+
provider: p.label,
|
|
123
|
+
}))
|
|
124
|
+
);
|
|
125
|
+
}
|
package/data/usage/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
2
|
import type { UsageRange, UsageReport } from "@/lib/usage/aggregate";
|
|
3
3
|
import type { UsageWindows } from "@/lib/usage/windows";
|
|
4
|
+
import type { SpendByModel } from "@/lib/usage/by-model";
|
|
4
5
|
|
|
5
6
|
export const usageKeys = {
|
|
6
7
|
all: ["usage"] as const,
|
|
@@ -12,6 +13,8 @@ export interface UsageResponse {
|
|
|
12
13
|
// The orchestrator's reviewer and scope check runs, by PR.
|
|
13
14
|
reviews: UsageReport;
|
|
14
15
|
windows: UsageWindows;
|
|
16
|
+
// By model and role, in the plan's 5-hour window and the last 24 hours.
|
|
17
|
+
models: { fiveHour: SpendByModel; day: SpendByModel };
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function useUsageQuery(range: UsageRange, enabled = true) {
|