@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.
Files changed (75) hide show
  1. package/README.md +100 -0
  2. package/app/api/models/route.ts +34 -0
  3. package/app/api/models/test/route.ts +17 -0
  4. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  5. package/app/api/tasks/[id]/model/route.ts +23 -0
  6. package/app/api/tasks/route.ts +5 -1
  7. package/app/api/usage/compare/route.ts +21 -0
  8. package/app/api/usage/route.ts +7 -4
  9. package/app/api/workspaces/[id]/models/route.ts +27 -0
  10. package/components/CommandPalette/useAppCommands.ts +19 -1
  11. package/components/Draft/DraftChips.tsx +28 -0
  12. package/components/Draft/DraftPanel.tsx +1 -1
  13. package/components/Models/ModelSelect.tsx +67 -0
  14. package/components/Models/ModelsSection.tsx +199 -0
  15. package/components/Models/ProviderCard.tsx +271 -0
  16. package/components/SessionList/SidebarHeader.tsx +5 -1
  17. package/components/Settings/SettingsDialog.test.ts +3 -0
  18. package/components/Settings/SettingsDialog.tsx +9 -0
  19. package/components/Settings/commands.ts +19 -0
  20. package/components/Settings/entry-points.test.ts +1 -0
  21. package/components/Tasks/ResumeOnModel.tsx +72 -0
  22. package/components/Tasks/TaskRow.tsx +4 -0
  23. package/components/Usage/CompareView.tsx +211 -0
  24. package/components/Usage/ModelSpend.tsx +133 -0
  25. package/components/Usage/UsageDialog.tsx +38 -3
  26. package/components/Usage/format.ts +9 -0
  27. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  28. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  29. package/data/models/index.ts +125 -0
  30. package/data/usage/index.ts +3 -0
  31. package/lib/agents/launch.ts +11 -2
  32. package/lib/chat/context.ts +1 -1
  33. package/lib/chat/runner.ts +5 -0
  34. package/lib/chat/worker/extras.ts +12 -0
  35. package/lib/chat/worker/host.ts +13 -2
  36. package/lib/chat/worker/main.ts +5 -2
  37. package/lib/db/migrations.ts +28 -0
  38. package/lib/db/types.ts +6 -0
  39. package/lib/drafts.ts +3 -0
  40. package/lib/models/config.ts +218 -0
  41. package/lib/models/launch.ts +93 -0
  42. package/lib/models/plan-step.ts +94 -0
  43. package/lib/models/providers.ts +163 -0
  44. package/lib/models/resolve.ts +246 -0
  45. package/lib/models/resume.ts +99 -0
  46. package/lib/models/roles.ts +98 -0
  47. package/lib/models/secrets.ts +86 -0
  48. package/lib/models/session-model.ts +40 -0
  49. package/lib/models/test-provider.ts +116 -0
  50. package/lib/orchestrator/act.ts +13 -1
  51. package/lib/orchestrator/brief.ts +2 -1
  52. package/lib/orchestrator/claude-cli.ts +44 -15
  53. package/lib/orchestrator/home.ts +2 -2
  54. package/lib/orchestrator/review.ts +2 -0
  55. package/lib/orchestrator/scope.ts +2 -0
  56. package/lib/orchestrator/serve.ts +13 -0
  57. package/lib/orchestrator/tool-names.ts +1 -0
  58. package/lib/orchestrator/tool-schemas.ts +38 -0
  59. package/lib/orchestrator/tools.ts +3 -1
  60. package/lib/pr-generation.ts +5 -2
  61. package/lib/providers/registry.ts +1 -1
  62. package/lib/session-titles.ts +1 -1
  63. package/lib/sessions/launch.ts +19 -4
  64. package/lib/settings-url.ts +1 -0
  65. package/lib/tasks/index.ts +59 -8
  66. package/lib/tasks/queue.ts +14 -5
  67. package/lib/tasks/start.ts +7 -2
  68. package/lib/usage/by-model.ts +105 -0
  69. package/lib/usage/check-runs.ts +5 -2
  70. package/lib/usage/compare.ts +280 -0
  71. package/lib/usage/cost.ts +71 -0
  72. package/lib/usage/report.ts +26 -0
  73. package/lib/usage/turns.ts +79 -18
  74. package/package.json +1 -1
  75. 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&apos;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&apos;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
- <section className="space-y-3">
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
+ }
@@ -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) {