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
|
@@ -8,10 +8,13 @@ let tempDir: string;
|
|
|
8
8
|
beforeEach(() => {
|
|
9
9
|
tempDir = mkdtempSync(join(tmpdir(), "stagent-budget-guardrails-"));
|
|
10
10
|
vi.resetModules();
|
|
11
|
+
vi.useFakeTimers();
|
|
12
|
+
vi.setSystemTime(new Date("2026-03-17T12:00:00.000Z"));
|
|
11
13
|
vi.stubEnv("STAGENT_DATA_DIR", tempDir);
|
|
12
14
|
});
|
|
13
15
|
|
|
14
16
|
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
15
18
|
vi.unstubAllEnvs();
|
|
16
19
|
rmSync(tempDir, { recursive: true, force: true });
|
|
17
20
|
});
|
|
@@ -21,8 +24,9 @@ async function loadModules() {
|
|
|
21
24
|
const schema = await import("@/lib/db/schema");
|
|
22
25
|
const ledger = await import("@/lib/usage/ledger");
|
|
23
26
|
const budgets = await import("../budget-guardrails");
|
|
27
|
+
const auth = await import("../auth");
|
|
24
28
|
|
|
25
|
-
return { db, ...schema, ...ledger, ...budgets };
|
|
29
|
+
return { db, ...schema, ...ledger, ...budgets, ...auth };
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
describe("budget guardrails", () => {
|
|
@@ -33,25 +37,22 @@ describe("budget guardrails", () => {
|
|
|
33
37
|
recordUsageLedgerEntry,
|
|
34
38
|
setBudgetPolicy,
|
|
35
39
|
enforceBudgetGuardrails,
|
|
40
|
+
setAuthSettings,
|
|
36
41
|
} = await loadModules();
|
|
37
42
|
|
|
43
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
|
|
44
|
+
await setAuthSettings({ method: "api_key" });
|
|
45
|
+
|
|
38
46
|
await setBudgetPolicy({
|
|
39
47
|
overall: {
|
|
40
|
-
|
|
41
|
-
monthlySpendCapUsd: null,
|
|
48
|
+
monthlySpendCapUsd: 0.36,
|
|
42
49
|
},
|
|
43
50
|
runtimes: {
|
|
44
51
|
"claude-code": {
|
|
45
|
-
|
|
46
|
-
monthlySpendCapUsd: null,
|
|
47
|
-
dailyTokenCap: null,
|
|
48
|
-
monthlyTokenCap: null,
|
|
52
|
+
monthlySpendCapUsd: 0.36,
|
|
49
53
|
},
|
|
50
54
|
"openai-codex-app-server": {
|
|
51
|
-
dailySpendCapUsd: null,
|
|
52
55
|
monthlySpendCapUsd: null,
|
|
53
|
-
dailyTokenCap: null,
|
|
54
|
-
monthlyTokenCap: null,
|
|
55
56
|
},
|
|
56
57
|
},
|
|
57
58
|
});
|
|
@@ -80,9 +81,10 @@ describe("budget guardrails", () => {
|
|
|
80
81
|
});
|
|
81
82
|
|
|
82
83
|
const rows = await db.select().from(notifications);
|
|
83
|
-
expect(rows).toHaveLength(
|
|
84
|
-
expect(rows
|
|
85
|
-
expect(rows
|
|
84
|
+
expect(rows).toHaveLength(2);
|
|
85
|
+
expect(rows.every((row) => row.type === "budget_alert")).toBe(true);
|
|
86
|
+
expect(rows.some((row) => row.title.includes("Overall daily spend"))).toBe(true);
|
|
87
|
+
expect(rows.some((row) => row.title.includes("Claude Code daily spend"))).toBe(true);
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
it("blocks new runtime activity, records a zero-cost ledger row, and fails queued tasks when requested", async () => {
|
|
@@ -95,8 +97,12 @@ describe("budget guardrails", () => {
|
|
|
95
97
|
setBudgetPolicy,
|
|
96
98
|
enforceTaskBudgetGuardrails,
|
|
97
99
|
BudgetLimitExceededError,
|
|
100
|
+
setAuthSettings,
|
|
98
101
|
} = await loadModules();
|
|
99
102
|
|
|
103
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
|
|
104
|
+
await setAuthSettings({ method: "api_key" });
|
|
105
|
+
|
|
100
106
|
const taskId = crypto.randomUUID();
|
|
101
107
|
const now = new Date();
|
|
102
108
|
|
|
@@ -120,21 +126,14 @@ describe("budget guardrails", () => {
|
|
|
120
126
|
|
|
121
127
|
await setBudgetPolicy({
|
|
122
128
|
overall: {
|
|
123
|
-
|
|
124
|
-
monthlySpendCapUsd: null,
|
|
129
|
+
monthlySpendCapUsd: 0.001,
|
|
125
130
|
},
|
|
126
131
|
runtimes: {
|
|
127
132
|
"claude-code": {
|
|
128
|
-
|
|
129
|
-
monthlySpendCapUsd: null,
|
|
130
|
-
dailyTokenCap: 100,
|
|
131
|
-
monthlyTokenCap: null,
|
|
133
|
+
monthlySpendCapUsd: 0.001,
|
|
132
134
|
},
|
|
133
135
|
"openai-codex-app-server": {
|
|
134
|
-
dailySpendCapUsd: null,
|
|
135
136
|
monthlySpendCapUsd: null,
|
|
136
|
-
dailyTokenCap: null,
|
|
137
|
-
monthlyTokenCap: null,
|
|
138
137
|
},
|
|
139
138
|
},
|
|
140
139
|
});
|
|
@@ -159,7 +158,7 @@ describe("budget guardrails", () => {
|
|
|
159
158
|
|
|
160
159
|
const [task] = await db.select().from(tasks);
|
|
161
160
|
expect(task?.status).toBe("failed");
|
|
162
|
-
expect(task?.result).toContain("
|
|
161
|
+
expect(task?.result).toContain("spend");
|
|
163
162
|
|
|
164
163
|
const ledgerRows = await db.select().from(usageLedger);
|
|
165
164
|
const blockedRow = ledgerRows.find((row) => row.status === "blocked");
|
|
@@ -176,6 +175,69 @@ describe("budget guardrails", () => {
|
|
|
176
175
|
|
|
177
176
|
const budgetNotifications = await db.select().from(notifications);
|
|
178
177
|
expect(budgetNotifications.at(-1)?.title).toContain("blocked");
|
|
179
|
-
expect(budgetNotifications.at(-1)?.body).toContain("
|
|
178
|
+
expect(budgetNotifications.at(-1)?.body).toContain("spend");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("derives daily spend caps from the monthly cap and auto-assigns a single configured runtime", async () => {
|
|
182
|
+
const { setBudgetPolicy, getBudgetGuardrailSnapshot, setAuthSettings } =
|
|
183
|
+
await loadModules();
|
|
184
|
+
|
|
185
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
|
|
186
|
+
vi.stubEnv("OPENAI_API_KEY", "");
|
|
187
|
+
await setAuthSettings({ method: "api_key" });
|
|
188
|
+
|
|
189
|
+
await setBudgetPolicy({
|
|
190
|
+
overall: {
|
|
191
|
+
monthlySpendCapUsd: 310,
|
|
192
|
+
},
|
|
193
|
+
runtimes: {
|
|
194
|
+
"claude-code": {
|
|
195
|
+
monthlySpendCapUsd: 155,
|
|
196
|
+
},
|
|
197
|
+
"openai-codex-app-server": {
|
|
198
|
+
monthlySpendCapUsd: 155,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const snapshot = await getBudgetGuardrailSnapshot();
|
|
204
|
+
const overallDaily = snapshot.statuses.find(
|
|
205
|
+
(status) => status.scopeId === "overall" && status.window === "daily"
|
|
206
|
+
);
|
|
207
|
+
const claudeMonthly = snapshot.policy.runtimes["claude-code"].monthlySpendCapUsd;
|
|
208
|
+
const openAIMonthly =
|
|
209
|
+
snapshot.policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd;
|
|
210
|
+
|
|
211
|
+
expect(overallDaily?.limitValue).toBe(10_000_000);
|
|
212
|
+
expect(claudeMonthly).toBe(310);
|
|
213
|
+
expect(openAIMonthly).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("splits configured provider caps from the overall budget when both runtimes are configured", async () => {
|
|
217
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-test-key");
|
|
218
|
+
vi.stubEnv("OPENAI_API_KEY", "sk-test-openai");
|
|
219
|
+
const { setBudgetPolicy, getBudgetGuardrailSnapshot, setAuthSettings } =
|
|
220
|
+
await loadModules();
|
|
221
|
+
await setAuthSettings({ method: "api_key" });
|
|
222
|
+
|
|
223
|
+
await setBudgetPolicy({
|
|
224
|
+
overall: {
|
|
225
|
+
monthlySpendCapUsd: 300,
|
|
226
|
+
},
|
|
227
|
+
runtimes: {
|
|
228
|
+
"claude-code": {
|
|
229
|
+
monthlySpendCapUsd: 120,
|
|
230
|
+
},
|
|
231
|
+
"openai-codex-app-server": {
|
|
232
|
+
monthlySpendCapUsd: 180,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const snapshot = await getBudgetGuardrailSnapshot();
|
|
238
|
+
expect(snapshot.policy.runtimes["claude-code"].monthlySpendCapUsd).toBe(120);
|
|
239
|
+
expect(snapshot.policy.runtimes["openai-codex-app-server"].monthlySpendCapUsd).toBe(
|
|
240
|
+
180
|
|
241
|
+
);
|
|
180
242
|
});
|
|
181
243
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { getSetting, setSetting } from "./helpers";
|
|
13
13
|
import {
|
|
14
14
|
budgetPolicySchema,
|
|
15
|
+
claudeOAuthPlanSchema,
|
|
15
16
|
type BudgetPolicy,
|
|
16
17
|
type RuntimeBudgetPolicy,
|
|
17
18
|
type UpdateBudgetPolicyInput,
|
|
@@ -21,11 +22,20 @@ import {
|
|
|
21
22
|
resolveUsageActivityType,
|
|
22
23
|
type UsageActivityType,
|
|
23
24
|
} from "@/lib/usage/ledger";
|
|
25
|
+
import {
|
|
26
|
+
getClaudeOAuthPlanPrice,
|
|
27
|
+
getPricingRegistrySnapshot,
|
|
28
|
+
type PricingRegistrySnapshot,
|
|
29
|
+
} from "@/lib/usage/pricing-registry";
|
|
30
|
+
import {
|
|
31
|
+
getRuntimeSetupStates,
|
|
32
|
+
listConfiguredRuntimeIds,
|
|
33
|
+
type RuntimeSetupState,
|
|
34
|
+
} from "./runtime-setup";
|
|
24
35
|
|
|
25
36
|
const WARNING_THRESHOLD = 0.8;
|
|
26
37
|
|
|
27
38
|
type BudgetWindow = "daily" | "monthly";
|
|
28
|
-
type BudgetMetric = "spend" | "tokens";
|
|
29
39
|
type BudgetHealth = "unlimited" | "ok" | "warning" | "blocked";
|
|
30
40
|
type BudgetScopeId = "overall" | AgentRuntimeId;
|
|
31
41
|
|
|
@@ -34,12 +44,11 @@ interface UsageAggregate {
|
|
|
34
44
|
totalTokens: number;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
interface BudgetWindowStatus {
|
|
47
|
+
export interface BudgetWindowStatus {
|
|
38
48
|
id: string;
|
|
39
49
|
scopeId: BudgetScopeId;
|
|
40
50
|
scopeLabel: string;
|
|
41
51
|
runtimeId: AgentRuntimeId | null;
|
|
42
|
-
metric: BudgetMetric;
|
|
43
52
|
window: BudgetWindow;
|
|
44
53
|
currentValue: number;
|
|
45
54
|
limitValue: number | null;
|
|
@@ -52,11 +61,13 @@ interface BudgetWarningState {
|
|
|
52
61
|
[statusId: string]: string;
|
|
53
62
|
}
|
|
54
63
|
|
|
55
|
-
interface BudgetSnapshot {
|
|
64
|
+
export interface BudgetSnapshot {
|
|
56
65
|
policy: BudgetPolicy;
|
|
57
66
|
statuses: Array<BudgetWindowStatus & { resetAtIso: string }>;
|
|
58
67
|
dailyResetAtIso: string;
|
|
59
68
|
monthlyResetAtIso: string;
|
|
69
|
+
runtimeStates: Record<AgentRuntimeId, RuntimeSetupState>;
|
|
70
|
+
pricing: PricingRegistrySnapshot;
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
interface BudgetGuardInput {
|
|
@@ -69,19 +80,34 @@ interface BudgetGuardInput {
|
|
|
69
80
|
failTaskOnBlock?: boolean;
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
84
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toPositiveNumber(value: unknown) {
|
|
88
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
92
|
+
const parsed = Number(value);
|
|
93
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function roundUsd(value: number) {
|
|
99
|
+
return Math.round(value * 100) / 100;
|
|
100
|
+
}
|
|
101
|
+
|
|
72
102
|
function createEmptyRuntimeBudgetPolicy(): RuntimeBudgetPolicy {
|
|
73
103
|
return {
|
|
74
|
-
dailySpendCapUsd: null,
|
|
75
104
|
monthlySpendCapUsd: null,
|
|
76
|
-
dailyTokenCap: null,
|
|
77
|
-
monthlyTokenCap: null,
|
|
78
105
|
};
|
|
79
106
|
}
|
|
80
107
|
|
|
81
108
|
export function createEmptyBudgetPolicy(): BudgetPolicy {
|
|
82
109
|
return {
|
|
83
110
|
overall: {
|
|
84
|
-
dailySpendCapUsd: null,
|
|
85
111
|
monthlySpendCapUsd: null,
|
|
86
112
|
},
|
|
87
113
|
runtimes: Object.fromEntries(
|
|
@@ -93,6 +119,10 @@ export function createEmptyBudgetPolicy(): BudgetPolicy {
|
|
|
93
119
|
};
|
|
94
120
|
}
|
|
95
121
|
|
|
122
|
+
function daysInMonth(date: Date) {
|
|
123
|
+
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
|
124
|
+
}
|
|
125
|
+
|
|
96
126
|
function formatWindowKey(window: BudgetWindow, date: Date) {
|
|
97
127
|
if (window === "daily") {
|
|
98
128
|
return new Intl.DateTimeFormat("en-CA", {
|
|
@@ -116,10 +146,6 @@ function formatMicrosAsUsd(micros: number) {
|
|
|
116
146
|
}).format(micros / 1_000_000);
|
|
117
147
|
}
|
|
118
148
|
|
|
119
|
-
function formatTokenCount(tokens: number) {
|
|
120
|
-
return new Intl.NumberFormat("en-US").format(tokens);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
149
|
function formatResetAt(date: Date) {
|
|
124
150
|
return new Intl.DateTimeFormat("en-US", {
|
|
125
151
|
dateStyle: "medium",
|
|
@@ -131,6 +157,13 @@ function usdToMicros(value: number | null) {
|
|
|
131
157
|
return value == null ? null : Math.round(value * 1_000_000);
|
|
132
158
|
}
|
|
133
159
|
|
|
160
|
+
function deriveDailyMicros(monthlySpendCapUsd: number | null, now: Date) {
|
|
161
|
+
if (monthlySpendCapUsd == null) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
return Math.round((monthlySpendCapUsd * 1_000_000) / daysInMonth(now));
|
|
165
|
+
}
|
|
166
|
+
|
|
134
167
|
function getBudgetWindowBounds(now = new Date()) {
|
|
135
168
|
const dailyStart = new Date(now);
|
|
136
169
|
dailyStart.setHours(0, 0, 0, 0);
|
|
@@ -167,6 +200,86 @@ async function setWarningState(state: BudgetWarningState) {
|
|
|
167
200
|
await setSetting(SETTINGS_KEYS.BUDGET_WARNING_STATE, JSON.stringify(state));
|
|
168
201
|
}
|
|
169
202
|
|
|
203
|
+
function normalizePersistedBudgetPolicy(raw: unknown): BudgetPolicy {
|
|
204
|
+
const fallback = createEmptyBudgetPolicy();
|
|
205
|
+
if (!isRecord(raw)) {
|
|
206
|
+
return fallback;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const overall = isRecord(raw.overall) ? raw.overall : {};
|
|
210
|
+
const runtimes = isRecord(raw.runtimes) ? raw.runtimes : {};
|
|
211
|
+
|
|
212
|
+
const next = createEmptyBudgetPolicy();
|
|
213
|
+
next.overall.monthlySpendCapUsd =
|
|
214
|
+
toPositiveNumber(overall.monthlySpendCapUsd) ??
|
|
215
|
+
toPositiveNumber(overall.dailySpendCapUsd);
|
|
216
|
+
|
|
217
|
+
for (const runtimeId of SUPPORTED_AGENT_RUNTIMES) {
|
|
218
|
+
const runtimeRaw = isRecord(runtimes[runtimeId]) ? runtimes[runtimeId] : {};
|
|
219
|
+
const runtime = next.runtimes[runtimeId];
|
|
220
|
+
runtime.monthlySpendCapUsd =
|
|
221
|
+
toPositiveNumber(runtimeRaw.monthlySpendCapUsd) ??
|
|
222
|
+
toPositiveNumber(runtimeRaw.dailySpendCapUsd);
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
runtimeId === "claude-code" &&
|
|
226
|
+
typeof runtimeRaw.claudeOAuthPlan === "string"
|
|
227
|
+
) {
|
|
228
|
+
const parsedPlan = claudeOAuthPlanSchema.safeParse(runtimeRaw.claudeOAuthPlan);
|
|
229
|
+
if (parsedPlan.success) {
|
|
230
|
+
runtime.claudeOAuthPlan = parsedPlan.data;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return next;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeBudgetPolicyWithRuntimeSetup(input: {
|
|
239
|
+
policy: BudgetPolicy;
|
|
240
|
+
runtimeStates: Record<AgentRuntimeId, RuntimeSetupState>;
|
|
241
|
+
}): BudgetPolicy {
|
|
242
|
+
const next = createEmptyBudgetPolicy();
|
|
243
|
+
const overallMonthly = input.policy.overall.monthlySpendCapUsd;
|
|
244
|
+
const configuredRuntimeIds = listConfiguredRuntimeIds(input.runtimeStates);
|
|
245
|
+
|
|
246
|
+
next.overall.monthlySpendCapUsd = overallMonthly;
|
|
247
|
+
next.runtimes["claude-code"].claudeOAuthPlan =
|
|
248
|
+
input.policy.runtimes["claude-code"].claudeOAuthPlan ??
|
|
249
|
+
(input.runtimeStates["claude-code"].billingMode === "subscription"
|
|
250
|
+
? "pro"
|
|
251
|
+
: undefined);
|
|
252
|
+
|
|
253
|
+
if (overallMonthly == null || configuredRuntimeIds.length === 0) {
|
|
254
|
+
return next;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (configuredRuntimeIds.length === 1) {
|
|
258
|
+
next.runtimes[configuredRuntimeIds[0]].monthlySpendCapUsd = overallMonthly;
|
|
259
|
+
return next;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const activeRuntimeIds = configuredRuntimeIds.filter(
|
|
263
|
+
(runtimeId) => input.runtimeStates[runtimeId].configured
|
|
264
|
+
);
|
|
265
|
+
const totalRequested = activeRuntimeIds.reduce(
|
|
266
|
+
(sum, runtimeId) => sum + (input.policy.runtimes[runtimeId].monthlySpendCapUsd ?? 0),
|
|
267
|
+
0
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const claudeShare =
|
|
271
|
+
totalRequested > 0
|
|
272
|
+
? (input.policy.runtimes["claude-code"].monthlySpendCapUsd ?? 0) / totalRequested
|
|
273
|
+
: 0.5;
|
|
274
|
+
const claudeMonthly = roundUsd(overallMonthly * claudeShare);
|
|
275
|
+
const openAIMonthly = roundUsd(Math.max(overallMonthly - claudeMonthly, 0));
|
|
276
|
+
|
|
277
|
+
next.runtimes["claude-code"].monthlySpendCapUsd = claudeMonthly;
|
|
278
|
+
next.runtimes["openai-codex-app-server"].monthlySpendCapUsd = openAIMonthly;
|
|
279
|
+
|
|
280
|
+
return next;
|
|
281
|
+
}
|
|
282
|
+
|
|
170
283
|
export async function getBudgetPolicy(): Promise<BudgetPolicy> {
|
|
171
284
|
const raw = await getSetting(SETTINGS_KEYS.BUDGET_POLICY);
|
|
172
285
|
if (!raw) {
|
|
@@ -174,8 +287,8 @@ export async function getBudgetPolicy(): Promise<BudgetPolicy> {
|
|
|
174
287
|
}
|
|
175
288
|
|
|
176
289
|
try {
|
|
177
|
-
const parsed =
|
|
178
|
-
return parsed
|
|
290
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
291
|
+
return normalizePersistedBudgetPolicy(parsed);
|
|
179
292
|
} catch {
|
|
180
293
|
return createEmptyBudgetPolicy();
|
|
181
294
|
}
|
|
@@ -185,12 +298,21 @@ export async function setBudgetPolicy(
|
|
|
185
298
|
input: UpdateBudgetPolicyInput
|
|
186
299
|
): Promise<BudgetPolicy> {
|
|
187
300
|
const parsed = budgetPolicySchema.parse(input);
|
|
188
|
-
await
|
|
301
|
+
const runtimeStates = await getRuntimeSetupStates();
|
|
302
|
+
const normalized = normalizeBudgetPolicyWithRuntimeSetup({
|
|
303
|
+
policy: parsed,
|
|
304
|
+
runtimeStates,
|
|
305
|
+
});
|
|
306
|
+
await setSetting(SETTINGS_KEYS.BUDGET_POLICY, JSON.stringify(normalized));
|
|
189
307
|
await setWarningState({});
|
|
190
|
-
return
|
|
308
|
+
return normalized;
|
|
191
309
|
}
|
|
192
310
|
|
|
193
|
-
async function getUsageAggregates(
|
|
311
|
+
async function getUsageAggregates(
|
|
312
|
+
policy: BudgetPolicy,
|
|
313
|
+
runtimeStates: Record<AgentRuntimeId, RuntimeSetupState>,
|
|
314
|
+
now = new Date()
|
|
315
|
+
) {
|
|
194
316
|
const { dailyStart, dailyEnd, monthlyStart, monthlyEnd } =
|
|
195
317
|
getBudgetWindowBounds(now);
|
|
196
318
|
|
|
@@ -209,10 +331,6 @@ async function getUsageAggregates(now = new Date()) {
|
|
|
209
331
|
)
|
|
210
332
|
);
|
|
211
333
|
|
|
212
|
-
const overall = {
|
|
213
|
-
daily: { costMicros: 0, totalTokens: 0 },
|
|
214
|
-
monthly: { costMicros: 0, totalTokens: 0 },
|
|
215
|
-
};
|
|
216
334
|
const runtimes = Object.fromEntries(
|
|
217
335
|
SUPPORTED_AGENT_RUNTIMES.map((runtimeId) => [
|
|
218
336
|
runtimeId,
|
|
@@ -231,22 +349,41 @@ async function getUsageAggregates(now = new Date()) {
|
|
|
231
349
|
return;
|
|
232
350
|
}
|
|
233
351
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
overall.monthly.costMicros += costMicros;
|
|
238
|
-
overall.monthly.totalTokens += totalTokens;
|
|
239
|
-
runtimes[runtimeId].monthly.costMicros += costMicros;
|
|
240
|
-
runtimes[runtimeId].monthly.totalTokens += totalTokens;
|
|
352
|
+
runtimes[runtimeId].monthly.costMicros += row.costMicros ?? 0;
|
|
353
|
+
runtimes[runtimeId].monthly.totalTokens += row.totalTokens ?? 0;
|
|
241
354
|
|
|
242
355
|
if (row.finishedAt >= dailyStart && row.finishedAt < dailyEnd) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
runtimes[runtimeId].daily.costMicros += costMicros;
|
|
246
|
-
runtimes[runtimeId].daily.totalTokens += totalTokens;
|
|
356
|
+
runtimes[runtimeId].daily.costMicros += row.costMicros ?? 0;
|
|
357
|
+
runtimes[runtimeId].daily.totalTokens += row.totalTokens ?? 0;
|
|
247
358
|
}
|
|
248
359
|
});
|
|
249
360
|
|
|
361
|
+
if (runtimeStates["claude-code"].billingMode === "subscription") {
|
|
362
|
+
const planPriceUsd = await getClaudeOAuthPlanPrice(
|
|
363
|
+
policy.runtimes["claude-code"].claudeOAuthPlan
|
|
364
|
+
);
|
|
365
|
+
const monthlyMicros = usdToMicros(planPriceUsd) ?? 0;
|
|
366
|
+
const dailyMicros = Math.round(monthlyMicros / daysInMonth(now));
|
|
367
|
+
runtimes["claude-code"].monthly.costMicros = monthlyMicros;
|
|
368
|
+
runtimes["claude-code"].daily.costMicros = dailyMicros;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const overall = {
|
|
372
|
+
daily: { costMicros: 0, totalTokens: 0 },
|
|
373
|
+
monthly: { costMicros: 0, totalTokens: 0 },
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
for (const runtimeId of SUPPORTED_AGENT_RUNTIMES) {
|
|
377
|
+
if (!runtimeStates[runtimeId].configured) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
overall.daily.costMicros += runtimes[runtimeId].daily.costMicros;
|
|
382
|
+
overall.daily.totalTokens += runtimes[runtimeId].daily.totalTokens;
|
|
383
|
+
overall.monthly.costMicros += runtimes[runtimeId].monthly.costMicros;
|
|
384
|
+
overall.monthly.totalTokens += runtimes[runtimeId].monthly.totalTokens;
|
|
385
|
+
}
|
|
386
|
+
|
|
250
387
|
return {
|
|
251
388
|
overall,
|
|
252
389
|
runtimes,
|
|
@@ -258,7 +395,6 @@ function buildStatus(input: {
|
|
|
258
395
|
scopeId: BudgetScopeId;
|
|
259
396
|
scopeLabel: string;
|
|
260
397
|
runtimeId: AgentRuntimeId | null;
|
|
261
|
-
metric: BudgetMetric;
|
|
262
398
|
window: BudgetWindow;
|
|
263
399
|
currentValue: number;
|
|
264
400
|
limitValue: number | null;
|
|
@@ -281,11 +417,10 @@ function buildStatus(input: {
|
|
|
281
417
|
}
|
|
282
418
|
|
|
283
419
|
return {
|
|
284
|
-
id: `${input.scopeId}:${input.window}
|
|
420
|
+
id: `${input.scopeId}:${input.window}:spend`,
|
|
285
421
|
scopeId: input.scopeId,
|
|
286
422
|
scopeLabel: input.scopeLabel,
|
|
287
423
|
runtimeId: input.runtimeId,
|
|
288
|
-
metric: input.metric,
|
|
289
424
|
window: input.window,
|
|
290
425
|
currentValue: input.currentValue,
|
|
291
426
|
limitValue: input.limitValue,
|
|
@@ -297,7 +432,9 @@ function buildStatus(input: {
|
|
|
297
432
|
|
|
298
433
|
function buildBudgetStatuses(
|
|
299
434
|
policy: BudgetPolicy,
|
|
300
|
-
|
|
435
|
+
runtimeStates: Record<AgentRuntimeId, RuntimeSetupState>,
|
|
436
|
+
aggregates: Awaited<ReturnType<typeof getUsageAggregates>>,
|
|
437
|
+
now: Date
|
|
301
438
|
) {
|
|
302
439
|
const statuses: BudgetWindowStatus[] = [];
|
|
303
440
|
|
|
@@ -306,17 +443,15 @@ function buildBudgetStatuses(
|
|
|
306
443
|
scopeId: "overall",
|
|
307
444
|
scopeLabel: "Overall",
|
|
308
445
|
runtimeId: null,
|
|
309
|
-
metric: "spend",
|
|
310
446
|
window: "daily",
|
|
311
447
|
currentValue: aggregates.overall.daily.costMicros,
|
|
312
|
-
limitValue:
|
|
448
|
+
limitValue: deriveDailyMicros(policy.overall.monthlySpendCapUsd, now),
|
|
313
449
|
resetAt: aggregates.dailyEnd,
|
|
314
450
|
}),
|
|
315
451
|
buildStatus({
|
|
316
452
|
scopeId: "overall",
|
|
317
453
|
scopeLabel: "Overall",
|
|
318
454
|
runtimeId: null,
|
|
319
|
-
metric: "spend",
|
|
320
455
|
window: "monthly",
|
|
321
456
|
currentValue: aggregates.overall.monthly.costMicros,
|
|
322
457
|
limitValue: usdToMicros(policy.overall.monthlySpendCapUsd),
|
|
@@ -324,7 +459,11 @@ function buildBudgetStatuses(
|
|
|
324
459
|
})
|
|
325
460
|
);
|
|
326
461
|
|
|
327
|
-
|
|
462
|
+
for (const runtimeId of SUPPORTED_AGENT_RUNTIMES) {
|
|
463
|
+
if (!runtimeStates[runtimeId].configured) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
328
467
|
const runtime = getRuntimeCatalogEntry(runtimeId);
|
|
329
468
|
const runtimePolicy = policy.runtimes[runtimeId];
|
|
330
469
|
const usage = aggregates.runtimes[runtimeId];
|
|
@@ -334,62 +473,32 @@ function buildBudgetStatuses(
|
|
|
334
473
|
scopeId: runtimeId,
|
|
335
474
|
scopeLabel: runtime.label,
|
|
336
475
|
runtimeId,
|
|
337
|
-
metric: "spend",
|
|
338
476
|
window: "daily",
|
|
339
477
|
currentValue: usage.daily.costMicros,
|
|
340
|
-
limitValue:
|
|
478
|
+
limitValue: deriveDailyMicros(runtimePolicy.monthlySpendCapUsd, now),
|
|
341
479
|
resetAt: aggregates.dailyEnd,
|
|
342
480
|
}),
|
|
343
481
|
buildStatus({
|
|
344
482
|
scopeId: runtimeId,
|
|
345
483
|
scopeLabel: runtime.label,
|
|
346
484
|
runtimeId,
|
|
347
|
-
metric: "spend",
|
|
348
485
|
window: "monthly",
|
|
349
486
|
currentValue: usage.monthly.costMicros,
|
|
350
487
|
limitValue: usdToMicros(runtimePolicy.monthlySpendCapUsd),
|
|
351
488
|
resetAt: aggregates.monthlyEnd,
|
|
352
|
-
}),
|
|
353
|
-
buildStatus({
|
|
354
|
-
scopeId: runtimeId,
|
|
355
|
-
scopeLabel: runtime.label,
|
|
356
|
-
runtimeId,
|
|
357
|
-
metric: "tokens",
|
|
358
|
-
window: "daily",
|
|
359
|
-
currentValue: usage.daily.totalTokens,
|
|
360
|
-
limitValue: runtimePolicy.dailyTokenCap,
|
|
361
|
-
resetAt: aggregates.dailyEnd,
|
|
362
|
-
}),
|
|
363
|
-
buildStatus({
|
|
364
|
-
scopeId: runtimeId,
|
|
365
|
-
scopeLabel: runtime.label,
|
|
366
|
-
runtimeId,
|
|
367
|
-
metric: "tokens",
|
|
368
|
-
window: "monthly",
|
|
369
|
-
currentValue: usage.monthly.totalTokens,
|
|
370
|
-
limitValue: runtimePolicy.monthlyTokenCap,
|
|
371
|
-
resetAt: aggregates.monthlyEnd,
|
|
372
489
|
})
|
|
373
490
|
);
|
|
374
|
-
}
|
|
491
|
+
}
|
|
375
492
|
|
|
376
493
|
return statuses;
|
|
377
494
|
}
|
|
378
495
|
|
|
379
496
|
function describeBudgetStatus(status: BudgetWindowStatus) {
|
|
380
|
-
const
|
|
381
|
-
const currentLabel =
|
|
382
|
-
status.metric === "spend"
|
|
383
|
-
? formatMicrosAsUsd(status.currentValue)
|
|
384
|
-
: formatTokenCount(status.currentValue);
|
|
497
|
+
const currentLabel = formatMicrosAsUsd(status.currentValue);
|
|
385
498
|
const limitLabel =
|
|
386
|
-
status.limitValue == null
|
|
387
|
-
? "Unlimited"
|
|
388
|
-
: status.metric === "spend"
|
|
389
|
-
? formatMicrosAsUsd(status.limitValue)
|
|
390
|
-
: formatTokenCount(status.limitValue);
|
|
499
|
+
status.limitValue == null ? "Unlimited" : formatMicrosAsUsd(status.limitValue);
|
|
391
500
|
|
|
392
|
-
return `${status.scopeLabel} ${status.window}
|
|
501
|
+
return `${status.scopeLabel} ${status.window} spend is ${currentLabel} of ${limitLabel}. Resets ${formatResetAt(status.resetAt)}.`;
|
|
393
502
|
}
|
|
394
503
|
|
|
395
504
|
async function createBudgetNotification(input: {
|
|
@@ -428,7 +537,7 @@ async function emitWarningNotifications(
|
|
|
428
537
|
const percent = status.ratio == null ? 0 : Math.round(status.ratio * 100);
|
|
429
538
|
await createBudgetNotification({
|
|
430
539
|
taskId,
|
|
431
|
-
title: `${status.scopeLabel} ${status.window}
|
|
540
|
+
title: `${status.scopeLabel} ${status.window} spend at ${percent}%`,
|
|
432
541
|
body: describeBudgetStatus(status),
|
|
433
542
|
});
|
|
434
543
|
warningState[status.id] = windowKey;
|
|
@@ -518,8 +627,15 @@ export async function enforceBudgetGuardrails(input: BudgetGuardInput) {
|
|
|
518
627
|
const runtimeId = resolveAgentRuntime(input.runtimeId ?? DEFAULT_AGENT_RUNTIME);
|
|
519
628
|
const policy = await getBudgetPolicy();
|
|
520
629
|
const warningState = await getWarningState();
|
|
521
|
-
const
|
|
522
|
-
|
|
630
|
+
const runtimeStates = await getRuntimeSetupStates();
|
|
631
|
+
|
|
632
|
+
if (!runtimeStates[runtimeId].configured) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const now = new Date();
|
|
637
|
+
const aggregates = await getUsageAggregates(policy, runtimeStates, now);
|
|
638
|
+
const statuses = buildBudgetStatuses(policy, runtimeStates, aggregates, now).filter(
|
|
523
639
|
(status) => status.scopeId === "overall" || status.runtimeId === runtimeId
|
|
524
640
|
);
|
|
525
641
|
|
|
@@ -535,7 +651,7 @@ export async function enforceBudgetGuardrails(input: BudgetGuardInput) {
|
|
|
535
651
|
}
|
|
536
652
|
|
|
537
653
|
const runtime = getRuntimeCatalogEntry(runtimeId);
|
|
538
|
-
const title = `${runtime.label} blocked by ${blocked.window}
|
|
654
|
+
const title = `${runtime.label} blocked by ${blocked.window} spend cap`;
|
|
539
655
|
const body = describeBudgetStatus(blocked);
|
|
540
656
|
|
|
541
657
|
await createBudgetNotification({
|
|
@@ -574,17 +690,29 @@ export async function enforceTaskBudgetGuardrails(
|
|
|
574
690
|
}
|
|
575
691
|
|
|
576
692
|
export async function getBudgetGuardrailSnapshot(): Promise<BudgetSnapshot> {
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
693
|
+
const [runtimeStates, pricing] = await Promise.all([
|
|
694
|
+
getRuntimeSetupStates(),
|
|
695
|
+
getPricingRegistrySnapshot(),
|
|
696
|
+
]);
|
|
697
|
+
const policy = normalizeBudgetPolicyWithRuntimeSetup({
|
|
698
|
+
policy: await getBudgetPolicy(),
|
|
699
|
+
runtimeStates,
|
|
700
|
+
});
|
|
701
|
+
const now = new Date();
|
|
702
|
+
const aggregates = await getUsageAggregates(policy, runtimeStates, now);
|
|
703
|
+
const statuses = buildBudgetStatuses(policy, runtimeStates, aggregates, now).map(
|
|
704
|
+
(status) => ({
|
|
705
|
+
...status,
|
|
706
|
+
resetAtIso: status.resetAt.toISOString(),
|
|
707
|
+
})
|
|
708
|
+
);
|
|
583
709
|
|
|
584
710
|
return {
|
|
585
711
|
policy,
|
|
586
712
|
statuses,
|
|
587
713
|
dailyResetAtIso: aggregates.dailyEnd.toISOString(),
|
|
588
714
|
monthlyResetAtIso: aggregates.monthlyEnd.toISOString(),
|
|
715
|
+
runtimeStates,
|
|
716
|
+
pricing,
|
|
589
717
|
};
|
|
590
718
|
}
|