stagent 0.1.10 → 0.1.12
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 +58 -27
- package/package.json +3 -3
- package/src/__tests__/e2e/blueprint.test.ts +63 -0
- package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
- package/src/__tests__/e2e/helpers.ts +286 -0
- package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
- package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
- package/src/__tests__/e2e/setup.ts +156 -0
- package/src/__tests__/e2e/single-task.test.ts +170 -0
- package/src/app/api/command-palette/recent/route.ts +41 -18
- package/src/app/api/context/batch/route.ts +44 -0
- package/src/app/api/permissions/presets/route.ts +80 -0
- package/src/app/api/playbook/status/route.ts +15 -0
- package/src/app/api/profiles/route.ts +23 -21
- package/src/app/api/settings/pricing/route.ts +15 -0
- package/src/app/costs/page.tsx +53 -43
- package/src/app/globals.css +0 -5
- package/src/app/playbook/[slug]/page.tsx +76 -0
- package/src/app/playbook/page.tsx +54 -0
- package/src/app/profiles/page.tsx +7 -4
- package/src/app/settings/page.tsx +2 -2
- package/src/app/tasks/page.tsx +5 -0
- package/src/components/costs/cost-dashboard.tsx +226 -320
- package/src/components/dashboard/activity-feed.tsx +6 -2
- package/src/components/notifications/batch-proposal-review.tsx +150 -0
- package/src/components/notifications/notification-item.tsx +6 -3
- package/src/components/notifications/pending-approval-host.tsx +57 -11
- package/src/components/playbook/adoption-heatmap.tsx +69 -0
- package/src/components/playbook/journey-card.tsx +110 -0
- package/src/components/playbook/playbook-action-button.tsx +22 -0
- package/src/components/playbook/playbook-browser.tsx +143 -0
- package/src/components/playbook/playbook-card.tsx +102 -0
- package/src/components/playbook/playbook-detail-view.tsx +223 -0
- package/src/components/playbook/playbook-homepage.tsx +142 -0
- package/src/components/playbook/playbook-toc.tsx +90 -0
- package/src/components/playbook/playbook-updated-badge.tsx +23 -0
- package/src/components/playbook/related-docs.tsx +30 -0
- package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
- package/src/components/profiles/context-proposal-review.tsx +7 -3
- package/src/components/profiles/learned-context-panel.tsx +116 -8
- package/src/components/profiles/profile-detail-view.tsx +7 -19
- package/src/components/profiles/profile-form-view.tsx +0 -22
- package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
- package/src/components/settings/api-key-form.tsx +5 -43
- package/src/components/settings/auth-config-section.tsx +10 -6
- package/src/components/settings/auth-status-badge.tsx +8 -0
- package/src/components/settings/budget-guardrails-section.tsx +403 -620
- package/src/components/settings/connection-test-control.tsx +63 -0
- package/src/components/settings/permissions-section.tsx +85 -75
- package/src/components/settings/permissions-sections.tsx +24 -0
- package/src/components/settings/presets-section.tsx +159 -0
- package/src/components/settings/pricing-registry-panel.tsx +164 -0
- package/src/components/shared/app-sidebar.tsx +2 -0
- package/src/components/shared/command-palette.tsx +30 -0
- package/src/components/shared/light-markdown.tsx +134 -0
- package/src/components/workflows/loop-status-view.tsx +8 -4
- package/src/components/workflows/workflow-status-view.tsx +16 -9
- package/src/lib/agents/__tests__/claude-agent.test.ts +7 -2
- package/src/lib/agents/__tests__/learned-context.test.ts +500 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +243 -0
- package/src/lib/agents/__tests__/sweep.test.ts +202 -0
- package/src/lib/agents/claude-agent.ts +104 -78
- package/src/lib/agents/learned-context.ts +32 -28
- package/src/lib/agents/learning-session.ts +234 -0
- package/src/lib/agents/pattern-extractor.ts +34 -64
- package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
- package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/general/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/researcher/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/sweep/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +0 -1
- package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +0 -1
- package/src/lib/agents/profiles/registry.ts +0 -1
- package/src/lib/agents/profiles/sort.ts +7 -0
- package/src/lib/agents/profiles/types.ts +0 -1
- package/src/lib/agents/runtime/catalog.ts +1 -1
- package/src/lib/agents/runtime/claude.ts +66 -0
- package/src/lib/constants/settings.ts +1 -0
- package/src/lib/constants/task-status.ts +6 -0
- package/src/lib/data/seed-data/profiles.ts +0 -3
- package/src/lib/db/schema.ts +3 -0
- package/src/lib/docs/adoption.ts +105 -0
- package/src/lib/docs/journey-tracker.ts +21 -0
- package/src/lib/docs/reader.ts +102 -0
- package/src/lib/docs/types.ts +54 -0
- package/src/lib/docs/usage-stage.ts +60 -0
- package/src/lib/notifications/actionable.ts +18 -10
- package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
- package/src/lib/settings/budget-guardrails.ts +213 -85
- package/src/lib/settings/permission-presets.ts +150 -0
- package/src/lib/settings/runtime-setup.ts +71 -0
- package/src/lib/usage/__tests__/ledger.test.ts +29 -5
- package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
- package/src/lib/usage/ledger.ts +4 -2
- package/src/lib/usage/pricing-registry.ts +570 -0
- package/src/lib/usage/pricing.ts +15 -41
- package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
- package/src/lib/utils/learned-context-history.ts +150 -0
- package/src/lib/validators/__tests__/profile.test.ts +0 -15
- package/src/lib/validators/__tests__/settings.test.ts +23 -16
- package/src/lib/validators/profile.ts +0 -1
- package/src/lib/validators/settings.ts +3 -9
- package/src/lib/workflows/__tests__/engine.test.ts +2 -0
- package/src/lib/workflows/engine.ts +20 -1
|
@@ -1,75 +1,38 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, type ComponentType } from "react";
|
|
3
|
+
import { useEffect, useMemo, useState, type ComponentType } from "react";
|
|
4
|
+
import { AlertTriangle, ArrowRight, CalendarClock, ShieldAlert, Wallet } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
4
6
|
import { Badge } from "@/components/ui/badge";
|
|
5
7
|
import { Button } from "@/components/ui/button";
|
|
6
8
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
9
|
import { Input } from "@/components/ui/input";
|
|
8
|
-
import {
|
|
9
|
-
import { listRuntimeCatalog } from "@/lib/agents/runtime/catalog";
|
|
10
|
-
import type { BudgetPolicy } from "@/lib/validators/settings";
|
|
10
|
+
import { Slider } from "@/components/ui/slider";
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from "
|
|
23
|
-
import { toast } from "sonner";
|
|
24
|
-
|
|
25
|
-
type BudgetHealth = "unlimited" | "ok" | "warning" | "blocked";
|
|
26
|
-
type BudgetMetric = "spend" | "tokens";
|
|
27
|
-
type BudgetWindow = "daily" | "monthly";
|
|
28
|
-
|
|
29
|
-
interface BudgetStatus {
|
|
30
|
-
id: string;
|
|
31
|
-
scopeId: string;
|
|
32
|
-
scopeLabel: string;
|
|
33
|
-
runtimeId: string | null;
|
|
34
|
-
metric: BudgetMetric;
|
|
35
|
-
window: BudgetWindow;
|
|
36
|
-
currentValue: number;
|
|
37
|
-
limitValue: number | null;
|
|
38
|
-
ratio: number | null;
|
|
39
|
-
health: BudgetHealth;
|
|
40
|
-
resetAtIso: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface BudgetSnapshot {
|
|
44
|
-
policy: BudgetPolicy;
|
|
45
|
-
statuses: BudgetStatus[];
|
|
46
|
-
dailyResetAtIso: string;
|
|
47
|
-
monthlyResetAtIso: string;
|
|
48
|
-
}
|
|
12
|
+
Select,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
} from "@/components/ui/select";
|
|
18
|
+
import type { AgentRuntimeId } from "@/lib/agents/runtime/catalog";
|
|
19
|
+
import type { BudgetPolicy, ClaudeOAuthPlan } from "@/lib/validators/settings";
|
|
20
|
+
import type { BudgetSnapshot, BudgetWindowStatus } from "@/lib/settings/budget-guardrails";
|
|
21
|
+
import type { RuntimeSetupState } from "@/lib/settings/runtime-setup";
|
|
22
|
+
import { PricingRegistryPanel } from "./pricing-registry-panel";
|
|
49
23
|
|
|
50
24
|
interface BudgetFormState {
|
|
51
|
-
overallDailySpendCapUsd: string;
|
|
52
25
|
overallMonthlySpendCapUsd: string;
|
|
53
26
|
runtimes: Record<
|
|
54
|
-
|
|
27
|
+
AgentRuntimeId,
|
|
55
28
|
{
|
|
56
|
-
dailySpendCapUsd: string;
|
|
57
29
|
monthlySpendCapUsd: string;
|
|
58
|
-
|
|
59
|
-
monthlyTokenCap: string;
|
|
30
|
+
claudeOAuthPlan?: ClaudeOAuthPlan;
|
|
60
31
|
}
|
|
61
32
|
>;
|
|
62
33
|
}
|
|
63
34
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
interface DerivedTokenEstimate {
|
|
67
|
-
estimatedBudgetTokens: number | null;
|
|
68
|
-
estimatedRemainingTokens: number | null;
|
|
69
|
-
sourceLabel: string | null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function toInputValue(value: number | null) {
|
|
35
|
+
function toInputValue(value: number | null | undefined) {
|
|
73
36
|
return value == null ? "" : String(value);
|
|
74
37
|
}
|
|
75
38
|
|
|
@@ -80,101 +43,71 @@ function toNullableNumber(value: string) {
|
|
|
80
43
|
|
|
81
44
|
function buildFormState(policy: BudgetPolicy): BudgetFormState {
|
|
82
45
|
return {
|
|
83
|
-
overallDailySpendCapUsd: toInputValue(policy.overall.dailySpendCapUsd),
|
|
84
46
|
overallMonthlySpendCapUsd: toInputValue(policy.overall.monthlySpendCapUsd),
|
|
85
|
-
runtimes:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
),
|
|
99
|
-
},
|
|
100
|
-
])
|
|
101
|
-
),
|
|
47
|
+
runtimes: {
|
|
48
|
+
"claude-code": {
|
|
49
|
+
monthlySpendCapUsd: toInputValue(
|
|
50
|
+
policy.runtimes["claude-code"].monthlySpendCapUsd
|
|
51
|
+
),
|
|
52
|
+
claudeOAuthPlan: policy.runtimes["claude-code"].claudeOAuthPlan,
|
|
53
|
+
},
|
|
54
|
+
"openai-codex-app-server": {
|
|
55
|
+
monthlySpendCapUsd: toInputValue(
|
|
56
|
+
policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
102
60
|
};
|
|
103
61
|
}
|
|
104
62
|
|
|
105
63
|
function buildPayload(form: BudgetFormState): BudgetPolicy {
|
|
106
64
|
return {
|
|
107
65
|
overall: {
|
|
108
|
-
dailySpendCapUsd: toNullableNumber(form.overallDailySpendCapUsd),
|
|
109
66
|
monthlySpendCapUsd: toNullableNumber(form.overallMonthlySpendCapUsd),
|
|
110
67
|
},
|
|
111
|
-
runtimes:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
),
|
|
125
|
-
},
|
|
126
|
-
])
|
|
127
|
-
) as BudgetPolicy["runtimes"],
|
|
68
|
+
runtimes: {
|
|
69
|
+
"claude-code": {
|
|
70
|
+
monthlySpendCapUsd: toNullableNumber(
|
|
71
|
+
form.runtimes["claude-code"].monthlySpendCapUsd
|
|
72
|
+
),
|
|
73
|
+
claudeOAuthPlan: form.runtimes["claude-code"].claudeOAuthPlan,
|
|
74
|
+
},
|
|
75
|
+
"openai-codex-app-server": {
|
|
76
|
+
monthlySpendCapUsd: toNullableNumber(
|
|
77
|
+
form.runtimes["openai-codex-app-server"].monthlySpendCapUsd
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
128
81
|
};
|
|
129
82
|
}
|
|
130
83
|
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
timeStyle: "short",
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function formatStatusValue(status: BudgetStatus) {
|
|
139
|
-
if (status.metric === "tokens") {
|
|
140
|
-
return new Intl.NumberFormat("en-US").format(status.currentValue);
|
|
84
|
+
function formatCurrencyUsd(value: number | null) {
|
|
85
|
+
if (value == null) {
|
|
86
|
+
return "Unlimited";
|
|
141
87
|
}
|
|
142
88
|
|
|
143
89
|
return new Intl.NumberFormat("en-US", {
|
|
144
90
|
style: "currency",
|
|
145
91
|
currency: "USD",
|
|
146
|
-
minimumFractionDigits: 2,
|
|
147
|
-
maximumFractionDigits:
|
|
148
|
-
}).format(
|
|
92
|
+
minimumFractionDigits: value >= 100 ? 0 : 2,
|
|
93
|
+
maximumFractionDigits: value >= 100 ? 0 : 2,
|
|
94
|
+
}).format(value);
|
|
149
95
|
}
|
|
150
96
|
|
|
151
|
-
function
|
|
152
|
-
if (status.limitValue == null) {
|
|
153
|
-
return "Unlimited";
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (status.metric === "tokens") {
|
|
157
|
-
return new Intl.NumberFormat("en-US").format(status.limitValue);
|
|
158
|
-
}
|
|
159
|
-
|
|
97
|
+
function formatMicrosAsUsd(value: number) {
|
|
160
98
|
return new Intl.NumberFormat("en-US", {
|
|
161
99
|
style: "currency",
|
|
162
100
|
currency: "USD",
|
|
163
101
|
minimumFractionDigits: 2,
|
|
164
|
-
maximumFractionDigits: 4,
|
|
165
|
-
}).format(
|
|
102
|
+
maximumFractionDigits: value >= 1_000_000 ? 2 : 4,
|
|
103
|
+
}).format(value / 1_000_000);
|
|
166
104
|
}
|
|
167
105
|
|
|
168
|
-
function
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const rounded = Math.max(0, Math.round(value));
|
|
174
|
-
return new Intl.NumberFormat("en-US", {
|
|
175
|
-
notation: rounded >= 100_000 ? "compact" : "standard",
|
|
176
|
-
maximumFractionDigits: rounded >= 100_000 ? 1 : 0,
|
|
177
|
-
}).format(rounded);
|
|
106
|
+
function formatResetAt(value: string) {
|
|
107
|
+
return new Date(value).toLocaleString(undefined, {
|
|
108
|
+
dateStyle: "medium",
|
|
109
|
+
timeStyle: "short",
|
|
110
|
+
});
|
|
178
111
|
}
|
|
179
112
|
|
|
180
113
|
function SectionEyebrow({
|
|
@@ -192,104 +125,91 @@ function SectionEyebrow({
|
|
|
192
125
|
);
|
|
193
126
|
}
|
|
194
127
|
|
|
195
|
-
function healthBadge(status: BudgetStatus) {
|
|
196
|
-
if (status.health === "blocked") {
|
|
197
|
-
return <Badge variant="destructive">Blocked</Badge>;
|
|
198
|
-
}
|
|
199
|
-
if (status.health === "warning") {
|
|
200
|
-
return (
|
|
201
|
-
<Badge
|
|
202
|
-
variant="outline"
|
|
203
|
-
className="border-status-warning/30 bg-status-warning/10 text-status-warning"
|
|
204
|
-
>
|
|
205
|
-
Warning
|
|
206
|
-
</Badge>
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
if (status.health === "ok") {
|
|
210
|
-
return <Badge variant="success">Healthy</Badge>;
|
|
211
|
-
}
|
|
212
|
-
return <Badge variant="secondary">Unlimited</Badge>;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
128
|
function getStatus(
|
|
216
|
-
statuses:
|
|
129
|
+
statuses: BudgetWindowStatus[],
|
|
217
130
|
scopeId: string,
|
|
218
|
-
window:
|
|
219
|
-
metric: BudgetMetric
|
|
131
|
+
window: "daily" | "monthly"
|
|
220
132
|
) {
|
|
221
133
|
return statuses.find(
|
|
222
|
-
(status) =>
|
|
223
|
-
status.scopeId === scopeId &&
|
|
224
|
-
status.window === window &&
|
|
225
|
-
status.metric === metric
|
|
134
|
+
(status) => status.scopeId === scopeId && status.window === window
|
|
226
135
|
);
|
|
227
136
|
}
|
|
228
137
|
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
};
|
|
138
|
+
function roundUsd(value: number) {
|
|
139
|
+
return Math.round(value * 100) / 100;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function deriveClaudeAllocation(form: BudgetFormState) {
|
|
143
|
+
const overall = toNullableNumber(form.overallMonthlySpendCapUsd);
|
|
144
|
+
const claude = toNullableNumber(form.runtimes["claude-code"].monthlySpendCapUsd);
|
|
145
|
+
const openai = toNullableNumber(
|
|
146
|
+
form.runtimes["openai-codex-app-server"].monthlySpendCapUsd
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (overall == null || overall <= 0) {
|
|
150
|
+
return 50;
|
|
243
151
|
}
|
|
244
152
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
(input.primaryTokenStatus?.currentValue ?? 0) > 0;
|
|
249
|
-
const fallbackHasRate =
|
|
250
|
-
(input.fallbackSpendStatus?.currentValue ?? 0) > 0 &&
|
|
251
|
-
(input.fallbackTokenStatus?.currentValue ?? 0) > 0;
|
|
252
|
-
|
|
253
|
-
const spendStatus = primaryHasRate
|
|
254
|
-
? input.primarySpendStatus
|
|
255
|
-
: fallbackHasRate
|
|
256
|
-
? input.fallbackSpendStatus
|
|
257
|
-
: undefined;
|
|
258
|
-
const tokenStatus = primaryHasRate
|
|
259
|
-
? input.primaryTokenStatus
|
|
260
|
-
: fallbackHasRate
|
|
261
|
-
? input.fallbackTokenStatus
|
|
262
|
-
: undefined;
|
|
263
|
-
|
|
264
|
-
if (!spendStatus || !tokenStatus) {
|
|
265
|
-
return {
|
|
266
|
-
estimatedBudgetTokens: null,
|
|
267
|
-
estimatedRemainingTokens: null,
|
|
268
|
-
sourceLabel: null,
|
|
269
|
-
};
|
|
153
|
+
const total = (claude ?? 0) + (openai ?? 0);
|
|
154
|
+
if (total <= 0) {
|
|
155
|
+
return 50;
|
|
270
156
|
}
|
|
271
157
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const estimatedRemainingTokens = Math.max(
|
|
275
|
-
0,
|
|
276
|
-
(spendCapMicros - spendStatus.currentValue) * tokensPerMicro
|
|
277
|
-
);
|
|
158
|
+
return Math.round(((claude ?? 0) / total) * 100);
|
|
159
|
+
}
|
|
278
160
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
161
|
+
function applyBudgetSplit(
|
|
162
|
+
current: BudgetFormState,
|
|
163
|
+
overallMonthlySpendCapUsd: string,
|
|
164
|
+
activeRuntimeIds: AgentRuntimeId[],
|
|
165
|
+
claudePercent = deriveClaudeAllocation(current)
|
|
166
|
+
): BudgetFormState {
|
|
167
|
+
const overall = toNullableNumber(overallMonthlySpendCapUsd);
|
|
168
|
+
const next: BudgetFormState = {
|
|
169
|
+
overallMonthlySpendCapUsd,
|
|
170
|
+
runtimes: {
|
|
171
|
+
...current.runtimes,
|
|
172
|
+
"claude-code": { ...current.runtimes["claude-code"] },
|
|
173
|
+
"openai-codex-app-server": {
|
|
174
|
+
...current.runtimes["openai-codex-app-server"],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
286
177
|
};
|
|
178
|
+
|
|
179
|
+
if (overall == null || activeRuntimeIds.length === 0) {
|
|
180
|
+
next.runtimes["claude-code"].monthlySpendCapUsd = "";
|
|
181
|
+
next.runtimes["openai-codex-app-server"].monthlySpendCapUsd = "";
|
|
182
|
+
return next;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (activeRuntimeIds.length === 1) {
|
|
186
|
+
const runtimeId = activeRuntimeIds[0];
|
|
187
|
+
next.runtimes[runtimeId].monthlySpendCapUsd = String(overall);
|
|
188
|
+
for (const candidate of ["claude-code", "openai-codex-app-server"] as const) {
|
|
189
|
+
if (candidate !== runtimeId) {
|
|
190
|
+
next.runtimes[candidate].monthlySpendCapUsd = "";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return next;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const claudeCap = roundUsd(overall * (claudePercent / 100));
|
|
197
|
+
const openAICap = roundUsd(Math.max(overall - claudeCap, 0));
|
|
198
|
+
|
|
199
|
+
next.runtimes["claude-code"].monthlySpendCapUsd = String(claudeCap);
|
|
200
|
+
next.runtimes["openai-codex-app-server"].monthlySpendCapUsd = String(openAICap);
|
|
201
|
+
return next;
|
|
287
202
|
}
|
|
288
203
|
|
|
204
|
+
const CLAUDE_PLAN_LABELS: Record<ClaudeOAuthPlan, string> = {
|
|
205
|
+
pro: "Pro",
|
|
206
|
+
max_5x: "Max 5x",
|
|
207
|
+
max_20x: "Max 20x",
|
|
208
|
+
};
|
|
209
|
+
|
|
289
210
|
export function BudgetGuardrailsSection() {
|
|
290
211
|
const [snapshot, setSnapshot] = useState<BudgetSnapshot | null>(null);
|
|
291
212
|
const [form, setForm] = useState<BudgetFormState | null>(null);
|
|
292
|
-
const [advancedOpen, setAdvancedOpen] = useState<Record<string, boolean>>({});
|
|
293
213
|
const [loading, setLoading] = useState(true);
|
|
294
214
|
const [saving, setSaving] = useState(false);
|
|
295
215
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -309,17 +229,6 @@ export function BudgetGuardrailsSection() {
|
|
|
309
229
|
const parsed = data as BudgetSnapshot;
|
|
310
230
|
setSnapshot(parsed);
|
|
311
231
|
setForm(buildFormState(parsed.policy));
|
|
312
|
-
setAdvancedOpen(
|
|
313
|
-
Object.fromEntries(
|
|
314
|
-
runtimes.map((runtime) => [
|
|
315
|
-
runtime.id,
|
|
316
|
-
Boolean(
|
|
317
|
-
parsed.policy.runtimes[runtime.id].dailyTokenCap ||
|
|
318
|
-
parsed.policy.runtimes[runtime.id].monthlyTokenCap
|
|
319
|
-
),
|
|
320
|
-
])
|
|
321
|
-
)
|
|
322
|
-
);
|
|
323
232
|
} catch (fetchError) {
|
|
324
233
|
setError(
|
|
325
234
|
fetchError instanceof Error
|
|
@@ -371,471 +280,345 @@ export function BudgetGuardrailsSection() {
|
|
|
371
280
|
}
|
|
372
281
|
}
|
|
373
282
|
|
|
283
|
+
const activeRuntimes = useMemo(() => {
|
|
284
|
+
if (!snapshot) {
|
|
285
|
+
return [] as RuntimeSetupState[];
|
|
286
|
+
}
|
|
287
|
+
return Object.values(snapshot.runtimeStates).filter((runtime) => runtime.configured);
|
|
288
|
+
}, [snapshot]);
|
|
289
|
+
|
|
374
290
|
if (loading || !snapshot || !form) {
|
|
375
291
|
return (
|
|
376
292
|
<Card className="surface-card">
|
|
377
293
|
<CardHeader>
|
|
378
294
|
<CardTitle>Cost & Usage Guardrails</CardTitle>
|
|
379
|
-
<CardDescription>Loading budget policy and
|
|
295
|
+
<CardDescription>Loading budget policy, runtime setup, and pricing data.</CardDescription>
|
|
380
296
|
</CardHeader>
|
|
381
297
|
</Card>
|
|
382
298
|
);
|
|
383
299
|
}
|
|
384
300
|
|
|
385
|
-
const
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return acc;
|
|
393
|
-
},
|
|
394
|
-
{}
|
|
395
|
-
);
|
|
301
|
+
const overallDaily = getStatus(snapshot.statuses, "overall", "daily");
|
|
302
|
+
const overallMonthly = getStatus(snapshot.statuses, "overall", "monthly");
|
|
303
|
+
const blocked = snapshot.statuses.filter((status) => status.health === "blocked");
|
|
304
|
+
const warnings = snapshot.statuses.filter((status) => status.health === "warning");
|
|
305
|
+
const claudeAllocation = deriveClaudeAllocation(form);
|
|
306
|
+
const claudeRuntime = snapshot.runtimeStates["claude-code"];
|
|
307
|
+
const showSplitSlider = activeRuntimes.length === 2;
|
|
396
308
|
|
|
397
309
|
return (
|
|
398
310
|
<Card className="surface-card">
|
|
399
|
-
<CardHeader>
|
|
400
|
-
<div className="space-y-
|
|
401
|
-
<
|
|
402
|
-
<
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
<div className="
|
|
416
|
-
<
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
Blocked now
|
|
421
|
-
</p>
|
|
422
|
-
<p className="mt-1 text-lg font-semibold">{blockedStatuses.length}</p>
|
|
423
|
-
</div>
|
|
424
|
-
</div>
|
|
425
|
-
<div className="surface-card-muted flex items-start gap-3 rounded-xl px-4 py-3">
|
|
426
|
-
<div className="rounded-lg bg-status-warning/10 p-2 text-status-warning">
|
|
427
|
-
<AlertTriangle className="h-4 w-4" />
|
|
428
|
-
</div>
|
|
429
|
-
<div className="min-w-0">
|
|
430
|
-
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
431
|
-
Near cap
|
|
311
|
+
<CardHeader className="space-y-4">
|
|
312
|
+
<div className="space-y-2">
|
|
313
|
+
<CardTitle className="flex items-center gap-2">
|
|
314
|
+
<Wallet className="h-5 w-5" />
|
|
315
|
+
Cost & Usage Guardrails
|
|
316
|
+
</CardTitle>
|
|
317
|
+
<CardDescription>
|
|
318
|
+
Set one monthly budget, let Stagent derive daily pacing, and keep provider spend
|
|
319
|
+
splits aligned with the runtimes you actually have configured.
|
|
320
|
+
</CardDescription>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{blocked.length > 0 ? (
|
|
324
|
+
<div className="surface-card-muted rounded-2xl border border-destructive/20 bg-destructive/8 p-4">
|
|
325
|
+
<div className="flex items-start gap-3">
|
|
326
|
+
<ShieldAlert className="mt-0.5 h-4 w-4 text-destructive" />
|
|
327
|
+
<div className="space-y-1">
|
|
328
|
+
<p className="text-sm font-semibold">Spend is currently blocked</p>
|
|
329
|
+
<p className="text-sm text-muted-foreground">
|
|
330
|
+
{blocked[0]?.scopeLabel} hit its {blocked[0]?.window} cap. New paid work stays
|
|
331
|
+
blocked until the next reset.
|
|
432
332
|
</p>
|
|
433
|
-
<p className="mt-1 text-lg font-semibold">{warningStatuses.length}</p>
|
|
434
333
|
</div>
|
|
435
334
|
</div>
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
<
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
Day: {formatResetAt(snapshot.dailyResetAtIso)}
|
|
446
|
-
</p>
|
|
447
|
-
<p className="text-xs text-muted-foreground">
|
|
448
|
-
Month: {formatResetAt(snapshot.monthlyResetAtIso)}
|
|
335
|
+
</div>
|
|
336
|
+
) : warnings.length > 0 ? (
|
|
337
|
+
<div className="surface-card-muted rounded-2xl border border-status-warning/25 bg-status-warning/8 p-4">
|
|
338
|
+
<div className="flex items-start gap-3">
|
|
339
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 text-status-warning" />
|
|
340
|
+
<div className="space-y-1">
|
|
341
|
+
<p className="text-sm font-semibold">Spend is approaching a cap</p>
|
|
342
|
+
<p className="text-sm text-muted-foreground">
|
|
343
|
+
{warnings[0]?.scopeLabel} is close to its {warnings[0]?.window} spend limit.
|
|
449
344
|
</p>
|
|
450
345
|
</div>
|
|
451
346
|
</div>
|
|
452
347
|
</div>
|
|
453
|
-
|
|
348
|
+
) : null}
|
|
454
349
|
</CardHeader>
|
|
455
|
-
<CardContent className="space-y-6">
|
|
456
|
-
<div className="surface-panel rounded-xl p-4">
|
|
457
|
-
<div className="flex items-start gap-3">
|
|
458
|
-
{blockedStatuses.length > 0 ? (
|
|
459
|
-
<ShieldAlert className="mt-0.5 h-4 w-4 text-destructive" />
|
|
460
|
-
) : (
|
|
461
|
-
<AlertTriangle className="mt-0.5 h-4 w-4 text-status-warning" />
|
|
462
|
-
)}
|
|
463
|
-
<div className="space-y-1 text-sm text-muted-foreground">
|
|
464
|
-
<p>
|
|
465
|
-
Warning notifications are emitted once per window when usage
|
|
466
|
-
reaches 80% of a configured cap.
|
|
467
|
-
</p>
|
|
468
|
-
<p>
|
|
469
|
-
Blocked attempts are recorded in the usage ledger with zero cost
|
|
470
|
-
so later audit views can explain why work did not start.
|
|
471
|
-
</p>
|
|
472
|
-
</div>
|
|
473
|
-
</div>
|
|
474
|
-
</div>
|
|
475
350
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
<p className="text-xs text-muted-foreground">
|
|
482
|
-
Leave an input blank to keep that window unlimited.
|
|
483
|
-
</p>
|
|
484
|
-
</div>
|
|
485
|
-
<div className="grid gap-3 md:grid-cols-2">
|
|
486
|
-
<label className="space-y-2">
|
|
487
|
-
<span className="text-sm font-medium">Daily spend cap (USD)</span>
|
|
488
|
-
<Input
|
|
489
|
-
className="surface-control"
|
|
490
|
-
inputMode="decimal"
|
|
491
|
-
placeholder="Unlimited"
|
|
492
|
-
value={form.overallDailySpendCapUsd}
|
|
493
|
-
onChange={(event) =>
|
|
494
|
-
setForm((current) =>
|
|
495
|
-
current
|
|
496
|
-
? {
|
|
497
|
-
...current,
|
|
498
|
-
overallDailySpendCapUsd: event.target.value,
|
|
499
|
-
}
|
|
500
|
-
: current
|
|
501
|
-
)
|
|
502
|
-
}
|
|
503
|
-
/>
|
|
504
|
-
</label>
|
|
505
|
-
<label className="space-y-2">
|
|
506
|
-
<span className="text-sm font-medium">Monthly spend cap (USD)</span>
|
|
507
|
-
<Input
|
|
508
|
-
className="surface-control"
|
|
509
|
-
inputMode="decimal"
|
|
510
|
-
placeholder="Unlimited"
|
|
511
|
-
value={form.overallMonthlySpendCapUsd}
|
|
512
|
-
onChange={(event) =>
|
|
513
|
-
setForm((current) =>
|
|
514
|
-
current
|
|
515
|
-
? {
|
|
516
|
-
...current,
|
|
517
|
-
overallMonthlySpendCapUsd: event.target.value,
|
|
518
|
-
}
|
|
519
|
-
: current
|
|
520
|
-
)
|
|
521
|
-
}
|
|
522
|
-
/>
|
|
523
|
-
</label>
|
|
524
|
-
</div>
|
|
351
|
+
<CardContent className="space-y-6">
|
|
352
|
+
{activeRuntimes.length === 0 ? (
|
|
353
|
+
<div className="surface-panel rounded-2xl p-4 text-sm text-muted-foreground">
|
|
354
|
+
Configure Claude and/or OpenAI first. Guardrails adapt to the runtimes that are
|
|
355
|
+
currently set up.
|
|
525
356
|
</div>
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
<SectionEyebrow icon={Wallet} label="Runtime Budget" />
|
|
534
|
-
<h3 className="mt-1 text-sm font-semibold">{runtime.label}</h3>
|
|
357
|
+
) : (
|
|
358
|
+
<>
|
|
359
|
+
<div className="surface-panel rounded-2xl p-4">
|
|
360
|
+
<div className="space-y-3">
|
|
361
|
+
<div>
|
|
362
|
+
<SectionEyebrow icon={Wallet} label="Monthly Budget" />
|
|
363
|
+
<h3 className="mt-1 text-sm font-semibold">Overall spend cap</h3>
|
|
535
364
|
<p className="text-xs text-muted-foreground">
|
|
536
|
-
|
|
365
|
+
Leave blank to keep spend unlimited. Daily pacing is derived automatically.
|
|
537
366
|
</p>
|
|
538
367
|
</div>
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
368
|
+
|
|
369
|
+
<label className="space-y-2">
|
|
370
|
+
<span className="text-sm font-medium">Monthly spend cap (USD)</span>
|
|
371
|
+
<Input
|
|
372
|
+
className="surface-control"
|
|
373
|
+
inputMode="decimal"
|
|
374
|
+
placeholder="Unlimited"
|
|
375
|
+
value={form.overallMonthlySpendCapUsd}
|
|
376
|
+
onChange={(event) =>
|
|
377
|
+
setForm((current) =>
|
|
378
|
+
current
|
|
379
|
+
? applyBudgetSplit(
|
|
380
|
+
current,
|
|
381
|
+
event.target.value,
|
|
382
|
+
activeRuntimes.map((runtime) => runtime.runtimeId)
|
|
383
|
+
)
|
|
384
|
+
: current
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
/>
|
|
388
|
+
</label>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{showSplitSlider ? (
|
|
393
|
+
<div className="surface-panel rounded-2xl p-4">
|
|
394
|
+
<div className="space-y-4">
|
|
395
|
+
<div>
|
|
396
|
+
<SectionEyebrow icon={ArrowRight} label="Provider Allocation" />
|
|
397
|
+
<h3 className="mt-1 text-sm font-semibold">Split the monthly cap</h3>
|
|
398
|
+
<p className="text-xs text-muted-foreground">
|
|
399
|
+
Adjust one slider. Stagent writes the provider cap figures for you.
|
|
400
|
+
</p>
|
|
401
|
+
</div>
|
|
402
|
+
|
|
403
|
+
<div className="space-y-3">
|
|
404
|
+
<div className="flex items-center justify-between gap-3 text-sm">
|
|
405
|
+
<span className="font-medium">Claude</span>
|
|
406
|
+
<span className="text-muted-foreground">{claudeAllocation}%</span>
|
|
407
|
+
</div>
|
|
408
|
+
<Slider
|
|
409
|
+
value={[claudeAllocation]}
|
|
410
|
+
min={0}
|
|
411
|
+
max={100}
|
|
412
|
+
step={1}
|
|
413
|
+
onValueChange={(value) =>
|
|
548
414
|
setForm((current) =>
|
|
549
415
|
current
|
|
550
|
-
?
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
dailySpendCapUsd: event.target.value,
|
|
557
|
-
},
|
|
558
|
-
},
|
|
559
|
-
}
|
|
416
|
+
? applyBudgetSplit(
|
|
417
|
+
current,
|
|
418
|
+
current.overallMonthlySpendCapUsd,
|
|
419
|
+
activeRuntimes.map((runtime) => runtime.runtimeId),
|
|
420
|
+
value[0] ?? 50
|
|
421
|
+
)
|
|
560
422
|
: current
|
|
561
423
|
)
|
|
562
424
|
}
|
|
563
425
|
/>
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
426
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
427
|
+
{activeRuntimes.map((runtime) => (
|
|
428
|
+
<div key={runtime.runtimeId} className="rounded-xl border border-border/60 bg-background/40 p-3">
|
|
429
|
+
<p className="text-sm font-medium">{runtime.label}</p>
|
|
430
|
+
<p className="mt-1 text-lg font-semibold">
|
|
431
|
+
{formatCurrencyUsd(
|
|
432
|
+
toNullableNumber(form.runtimes[runtime.runtimeId].monthlySpendCapUsd)
|
|
433
|
+
)}
|
|
434
|
+
</p>
|
|
435
|
+
<p className="text-xs text-muted-foreground">
|
|
436
|
+
{runtime.runtimeId === "claude-code"
|
|
437
|
+
? `${claudeAllocation}% of the monthly cap`
|
|
438
|
+
: `${100 - claudeAllocation}% of the monthly cap`}
|
|
439
|
+
</p>
|
|
440
|
+
</div>
|
|
441
|
+
))}
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
) : (
|
|
447
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
448
|
+
{activeRuntimes.map((runtime) => (
|
|
449
|
+
<div key={runtime.runtimeId} className="surface-panel rounded-2xl p-4">
|
|
450
|
+
<SectionEyebrow icon={Wallet} label="Provider Cap" />
|
|
451
|
+
<h3 className="mt-1 text-sm font-semibold">{runtime.label}</h3>
|
|
452
|
+
<p className="mt-2 text-lg font-semibold">
|
|
453
|
+
{formatCurrencyUsd(
|
|
454
|
+
toNullableNumber(form.runtimes[runtime.runtimeId].monthlySpendCapUsd)
|
|
455
|
+
)}
|
|
456
|
+
</p>
|
|
457
|
+
<p className="text-xs text-muted-foreground">
|
|
458
|
+
{runtime.label} receives the full monthly cap because it is the only
|
|
459
|
+
configured paid provider.
|
|
460
|
+
</p>
|
|
461
|
+
</div>
|
|
462
|
+
))}
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
|
|
466
|
+
{claudeRuntime.configured && claudeRuntime.billingMode === "subscription" ? (
|
|
467
|
+
<div className="surface-panel rounded-2xl p-4">
|
|
468
|
+
<div className="space-y-3">
|
|
469
|
+
<div>
|
|
470
|
+
<SectionEyebrow icon={CalendarClock} label="Claude Plan" />
|
|
471
|
+
<h3 className="mt-1 text-sm font-semibold">Claude OAuth billing</h3>
|
|
472
|
+
<p className="text-xs text-muted-foreground">
|
|
473
|
+
Claude OAuth uses fixed monthly subscription pricing for budgeting instead
|
|
474
|
+
of token-priced usage.
|
|
475
|
+
</p>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<div className="grid gap-3 md:grid-cols-[minmax(0,240px)_1fr]">
|
|
479
|
+
<Select
|
|
480
|
+
value={form.runtimes["claude-code"].claudeOAuthPlan ?? "pro"}
|
|
481
|
+
onValueChange={(value: ClaudeOAuthPlan) =>
|
|
573
482
|
setForm((current) =>
|
|
574
483
|
current
|
|
575
484
|
? {
|
|
576
485
|
...current,
|
|
577
486
|
runtimes: {
|
|
578
487
|
...current.runtimes,
|
|
579
|
-
|
|
580
|
-
...current.runtimes[
|
|
581
|
-
|
|
488
|
+
"claude-code": {
|
|
489
|
+
...current.runtimes["claude-code"],
|
|
490
|
+
claudeOAuthPlan: value,
|
|
582
491
|
},
|
|
583
492
|
},
|
|
584
493
|
}
|
|
585
494
|
: current
|
|
586
495
|
)
|
|
587
496
|
}
|
|
588
|
-
|
|
589
|
-
|
|
497
|
+
>
|
|
498
|
+
<SelectTrigger className="surface-control">
|
|
499
|
+
<SelectValue placeholder="Select Claude plan" />
|
|
500
|
+
</SelectTrigger>
|
|
501
|
+
<SelectContent>
|
|
502
|
+
{Object.entries(CLAUDE_PLAN_LABELS).map(([value, label]) => (
|
|
503
|
+
<SelectItem key={value} value={value}>
|
|
504
|
+
{label}
|
|
505
|
+
</SelectItem>
|
|
506
|
+
))}
|
|
507
|
+
</SelectContent>
|
|
508
|
+
</Select>
|
|
509
|
+
|
|
510
|
+
<div className="rounded-xl border border-border/60 bg-background/40 p-3">
|
|
511
|
+
<p className="text-sm font-medium">Budget basis</p>
|
|
512
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
513
|
+
Dashboard pacing and guardrail enforcement use the selected plan price as
|
|
514
|
+
Claude's monthly cost basis. Activity and tokens still appear in audit
|
|
515
|
+
views.
|
|
516
|
+
</p>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
) : null}
|
|
522
|
+
|
|
523
|
+
<PricingRegistryPanel
|
|
524
|
+
initialSnapshot={snapshot.pricing}
|
|
525
|
+
showClaudePlans={claudeRuntime.billingMode === "subscription"}
|
|
526
|
+
/>
|
|
527
|
+
|
|
528
|
+
<div className="surface-panel rounded-2xl p-4">
|
|
529
|
+
<div className="space-y-3">
|
|
530
|
+
<div>
|
|
531
|
+
<SectionEyebrow icon={ArrowRight} label="Live Status" />
|
|
532
|
+
<h3 className="mt-1 text-sm font-semibold">Current spend pacing</h3>
|
|
533
|
+
<p className="text-xs text-muted-foreground">
|
|
534
|
+
Derived daily pacing is recalculated from the monthly cap using the current
|
|
535
|
+
calendar month.
|
|
536
|
+
</p>
|
|
590
537
|
</div>
|
|
591
|
-
{(() => {
|
|
592
|
-
const dailySpendStatus = getStatus(
|
|
593
|
-
snapshot.statuses,
|
|
594
|
-
runtime.id,
|
|
595
|
-
"daily",
|
|
596
|
-
"spend"
|
|
597
|
-
);
|
|
598
|
-
const dailyTokenStatus = getStatus(
|
|
599
|
-
snapshot.statuses,
|
|
600
|
-
runtime.id,
|
|
601
|
-
"daily",
|
|
602
|
-
"tokens"
|
|
603
|
-
);
|
|
604
|
-
const monthlySpendStatus = getStatus(
|
|
605
|
-
snapshot.statuses,
|
|
606
|
-
runtime.id,
|
|
607
|
-
"monthly",
|
|
608
|
-
"spend"
|
|
609
|
-
);
|
|
610
|
-
const monthlyTokenStatus = getStatus(
|
|
611
|
-
snapshot.statuses,
|
|
612
|
-
runtime.id,
|
|
613
|
-
"monthly",
|
|
614
|
-
"tokens"
|
|
615
|
-
);
|
|
616
|
-
const dailyEstimate = deriveTokenEstimate({
|
|
617
|
-
spendCapUsd: form.runtimes[runtime.id].dailySpendCapUsd,
|
|
618
|
-
primarySpendStatus: dailySpendStatus,
|
|
619
|
-
primaryTokenStatus: dailyTokenStatus,
|
|
620
|
-
fallbackSpendStatus: monthlySpendStatus,
|
|
621
|
-
fallbackTokenStatus: monthlyTokenStatus,
|
|
622
|
-
});
|
|
623
|
-
const monthlyEstimate = deriveTokenEstimate({
|
|
624
|
-
spendCapUsd: form.runtimes[runtime.id].monthlySpendCapUsd,
|
|
625
|
-
primarySpendStatus: monthlySpendStatus,
|
|
626
|
-
primaryTokenStatus: monthlyTokenStatus,
|
|
627
|
-
fallbackSpendStatus: dailySpendStatus,
|
|
628
|
-
fallbackTokenStatus: dailyTokenStatus,
|
|
629
|
-
});
|
|
630
|
-
const hasAdvancedTokenCaps =
|
|
631
|
-
Boolean(form.runtimes[runtime.id].dailyTokenCap) ||
|
|
632
|
-
Boolean(form.runtimes[runtime.id].monthlyTokenCap);
|
|
633
|
-
|
|
634
|
-
return (
|
|
635
|
-
<>
|
|
636
|
-
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
637
|
-
<div className="surface-panel rounded-lg px-3 py-3">
|
|
638
|
-
<SectionEyebrow icon={Coins} label="Derived Guidance" />
|
|
639
|
-
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
640
|
-
Estimated Daily Token Budget
|
|
641
|
-
</p>
|
|
642
|
-
{dailyEstimate.sourceLabel ? (
|
|
643
|
-
<>
|
|
644
|
-
<p className="mt-1 text-sm font-semibold">
|
|
645
|
-
~{formatEstimatedTokens(dailyEstimate.estimatedBudgetTokens)}
|
|
646
|
-
</p>
|
|
647
|
-
<p className="mt-1 text-xs text-muted-foreground">
|
|
648
|
-
~{formatEstimatedTokens(dailyEstimate.estimatedRemainingTokens)} remaining headroom based on {dailyEstimate.sourceLabel}.
|
|
649
|
-
</p>
|
|
650
|
-
</>
|
|
651
|
-
) : (
|
|
652
|
-
<p className="mt-1 text-xs text-muted-foreground">
|
|
653
|
-
Set a spend cap and accumulate priced usage to see a token estimate.
|
|
654
|
-
</p>
|
|
655
|
-
)}
|
|
656
|
-
</div>
|
|
657
|
-
<div className="surface-panel rounded-lg px-3 py-3">
|
|
658
|
-
<SectionEyebrow icon={Coins} label="Derived Guidance" />
|
|
659
|
-
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
660
|
-
Estimated Monthly Token Budget
|
|
661
|
-
</p>
|
|
662
|
-
{monthlyEstimate.sourceLabel ? (
|
|
663
|
-
<>
|
|
664
|
-
<p className="mt-1 text-sm font-semibold">
|
|
665
|
-
~{formatEstimatedTokens(monthlyEstimate.estimatedBudgetTokens)}
|
|
666
|
-
</p>
|
|
667
|
-
<p className="mt-1 text-xs text-muted-foreground">
|
|
668
|
-
~{formatEstimatedTokens(monthlyEstimate.estimatedRemainingTokens)} remaining headroom based on {monthlyEstimate.sourceLabel}.
|
|
669
|
-
</p>
|
|
670
|
-
</>
|
|
671
|
-
) : (
|
|
672
|
-
<p className="mt-1 text-xs text-muted-foreground">
|
|
673
|
-
Set a spend cap and accumulate priced usage to see a token estimate.
|
|
674
|
-
</p>
|
|
675
|
-
)}
|
|
676
|
-
</div>
|
|
677
|
-
</div>
|
|
678
538
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
dailyTokenCap: event.target.value,
|
|
726
|
-
},
|
|
727
|
-
},
|
|
728
|
-
}
|
|
729
|
-
: current
|
|
730
|
-
)
|
|
731
|
-
}
|
|
732
|
-
/>
|
|
733
|
-
</label>
|
|
734
|
-
<label className="space-y-2">
|
|
735
|
-
<span className="text-sm font-medium">Monthly token cap</span>
|
|
736
|
-
<Input
|
|
737
|
-
className="surface-control"
|
|
738
|
-
inputMode="numeric"
|
|
739
|
-
placeholder="Unlimited"
|
|
740
|
-
value={form.runtimes[runtime.id].monthlyTokenCap}
|
|
741
|
-
onChange={(event) =>
|
|
742
|
-
setForm((current) =>
|
|
743
|
-
current
|
|
744
|
-
? {
|
|
745
|
-
...current,
|
|
746
|
-
runtimes: {
|
|
747
|
-
...current.runtimes,
|
|
748
|
-
[runtime.id]: {
|
|
749
|
-
...current.runtimes[runtime.id],
|
|
750
|
-
monthlyTokenCap: event.target.value,
|
|
751
|
-
},
|
|
752
|
-
},
|
|
753
|
-
}
|
|
754
|
-
: current
|
|
755
|
-
)
|
|
756
|
-
}
|
|
757
|
-
/>
|
|
758
|
-
</label>
|
|
759
|
-
</div>
|
|
539
|
+
<div className="grid gap-3 lg:grid-cols-2">
|
|
540
|
+
<div className="rounded-xl border border-border/60 bg-background/40 p-3">
|
|
541
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
542
|
+
Overall monthly spend
|
|
543
|
+
</p>
|
|
544
|
+
<p className="mt-1 text-lg font-semibold">
|
|
545
|
+
{overallMonthly
|
|
546
|
+
? `${formatMicrosAsUsd(overallMonthly.currentValue)} / ${overallMonthly.limitValue == null ? "Unlimited" : formatMicrosAsUsd(overallMonthly.limitValue)}`
|
|
547
|
+
: "Unavailable"}
|
|
548
|
+
</p>
|
|
549
|
+
<p className="text-xs text-muted-foreground">
|
|
550
|
+
Resets {formatResetAt(snapshot.monthlyResetAtIso)}
|
|
551
|
+
</p>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
<div className="rounded-xl border border-border/60 bg-background/40 p-3">
|
|
555
|
+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
556
|
+
Today pace vs derived daily cap
|
|
557
|
+
</p>
|
|
558
|
+
<p className="mt-1 text-lg font-semibold">
|
|
559
|
+
{overallDaily
|
|
560
|
+
? `${formatMicrosAsUsd(overallDaily.currentValue)} / ${overallDaily.limitValue == null ? "Unlimited" : formatMicrosAsUsd(overallDaily.limitValue)}`
|
|
561
|
+
: "Unavailable"}
|
|
562
|
+
</p>
|
|
563
|
+
<p className="text-xs text-muted-foreground">
|
|
564
|
+
Resets {formatResetAt(snapshot.dailyResetAtIso)}
|
|
565
|
+
</p>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<div className="space-y-2">
|
|
570
|
+
{activeRuntimes.map((runtime) => {
|
|
571
|
+
const monthlyStatus = getStatus(snapshot.statuses, runtime.runtimeId, "monthly");
|
|
572
|
+
const dailyStatus = getStatus(snapshot.statuses, runtime.runtimeId, "daily");
|
|
573
|
+
|
|
574
|
+
return (
|
|
575
|
+
<div
|
|
576
|
+
key={runtime.runtimeId}
|
|
577
|
+
className="flex flex-col gap-2 rounded-xl border border-border/60 bg-background/40 px-3 py-3 sm:flex-row sm:items-center sm:justify-between"
|
|
578
|
+
>
|
|
579
|
+
<div>
|
|
580
|
+
<div className="flex items-center gap-2">
|
|
581
|
+
<p className="text-sm font-medium">{runtime.label}</p>
|
|
582
|
+
<Badge variant="outline">
|
|
583
|
+
{runtime.billingMode === "subscription" ? "Plan priced" : "Usage priced"}
|
|
584
|
+
</Badge>
|
|
760
585
|
</div>
|
|
761
|
-
|
|
586
|
+
<p className="text-xs text-muted-foreground">
|
|
587
|
+
Monthly {monthlyStatus ? formatMicrosAsUsd(monthlyStatus.currentValue) : "Unavailable"}
|
|
588
|
+
{monthlyStatus?.limitValue != null
|
|
589
|
+
? ` of ${formatMicrosAsUsd(monthlyStatus.limitValue)}`
|
|
590
|
+
: " of Unlimited"}
|
|
591
|
+
</p>
|
|
592
|
+
</div>
|
|
593
|
+
<p className="text-xs text-muted-foreground">
|
|
594
|
+
Today {dailyStatus ? formatMicrosAsUsd(dailyStatus.currentValue) : "Unavailable"}
|
|
595
|
+
{dailyStatus?.limitValue != null
|
|
596
|
+
? ` / ${formatMicrosAsUsd(dailyStatus.limitValue)}`
|
|
597
|
+
: " / Unlimited"}
|
|
598
|
+
</p>
|
|
762
599
|
</div>
|
|
763
|
-
|
|
764
|
-
)
|
|
765
|
-
|
|
600
|
+
);
|
|
601
|
+
})}
|
|
602
|
+
</div>
|
|
766
603
|
</div>
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
604
|
+
</div>
|
|
605
|
+
</>
|
|
606
|
+
)}
|
|
770
607
|
|
|
771
608
|
<div className="flex items-center justify-between gap-3">
|
|
772
609
|
<div className="text-xs text-muted-foreground">
|
|
773
|
-
|
|
610
|
+
Warning notifications fire once per window after 80% of a configured cap is reached.
|
|
774
611
|
</div>
|
|
775
612
|
<Button onClick={handleSave} disabled={saving}>
|
|
776
613
|
{saving ? "Saving..." : "Save guardrails"}
|
|
777
614
|
</Button>
|
|
778
615
|
</div>
|
|
779
616
|
|
|
780
|
-
{error
|
|
617
|
+
{error ? (
|
|
781
618
|
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
782
619
|
{error}
|
|
783
620
|
</div>
|
|
784
|
-
)}
|
|
785
|
-
|
|
786
|
-
<Separator />
|
|
787
|
-
|
|
788
|
-
<div className="space-y-3">
|
|
789
|
-
<div>
|
|
790
|
-
<SectionEyebrow icon={ArrowRight} label="Live Status" />
|
|
791
|
-
<h3 className="text-sm font-semibold">Current window status</h3>
|
|
792
|
-
<p className="text-xs text-muted-foreground">
|
|
793
|
-
Live usage is derived from the normalized usage ledger in the
|
|
794
|
-
machine's local timezone.
|
|
795
|
-
</p>
|
|
796
|
-
</div>
|
|
797
|
-
<div className="grid gap-4 xl:grid-cols-3">
|
|
798
|
-
{Object.entries(groupedStatuses).map(([scopeId, statuses]) => (
|
|
799
|
-
<div key={scopeId} className="surface-card-muted rounded-xl p-4">
|
|
800
|
-
<div className="mb-3 flex items-center justify-between gap-2">
|
|
801
|
-
<h4 className="text-sm font-semibold">{statuses[0]?.scopeLabel}</h4>
|
|
802
|
-
{statuses.some((status) => status.health === "blocked")
|
|
803
|
-
? <Badge variant="destructive">Blocked</Badge>
|
|
804
|
-
: statuses.some((status) => status.health === "warning")
|
|
805
|
-
? (
|
|
806
|
-
<Badge
|
|
807
|
-
variant="outline"
|
|
808
|
-
className="border-status-warning/30 bg-status-warning/10 text-status-warning"
|
|
809
|
-
>
|
|
810
|
-
Warning
|
|
811
|
-
</Badge>
|
|
812
|
-
)
|
|
813
|
-
: <Badge variant="success">Healthy</Badge>}
|
|
814
|
-
</div>
|
|
815
|
-
<div className="space-y-2">
|
|
816
|
-
{statuses.map((status) => (
|
|
817
|
-
<div key={status.id} className="surface-panel rounded-lg px-3 py-2">
|
|
818
|
-
<div className="flex items-center justify-between gap-2">
|
|
819
|
-
<div>
|
|
820
|
-
<p className="text-sm font-medium capitalize">
|
|
821
|
-
{status.window} {status.metric}
|
|
822
|
-
</p>
|
|
823
|
-
<p className="text-xs text-muted-foreground">
|
|
824
|
-
{formatStatusValue(status)} / {formatStatusLimit(status)}
|
|
825
|
-
</p>
|
|
826
|
-
</div>
|
|
827
|
-
{healthBadge(status)}
|
|
828
|
-
</div>
|
|
829
|
-
<p className="mt-2 text-xs text-muted-foreground">
|
|
830
|
-
Resets {formatResetAt(status.resetAtIso)}
|
|
831
|
-
</p>
|
|
832
|
-
</div>
|
|
833
|
-
))}
|
|
834
|
-
</div>
|
|
835
|
-
</div>
|
|
836
|
-
))}
|
|
837
|
-
</div>
|
|
838
|
-
</div>
|
|
621
|
+
) : null}
|
|
839
622
|
</CardContent>
|
|
840
623
|
</Card>
|
|
841
624
|
);
|