@desplega.ai/agent-swarm 1.71.2 → 1.72.1
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 +3 -2
- package/openapi.json +994 -62
- package/package.json +2 -1
- package/src/be/budget-admission.ts +121 -0
- package/src/be/budget-refusal-notify.ts +145 -0
- package/src/be/db.ts +488 -5
- package/src/be/migrations/044_provider_meta.sql +2 -0
- package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
- package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
- package/src/cli.tsx +22 -1
- package/src/commands/claude-managed-setup.ts +687 -0
- package/src/commands/codex-login.ts +1 -1
- package/src/commands/runner.ts +175 -28
- package/src/commands/templates.ts +10 -6
- package/src/http/budgets.ts +219 -0
- package/src/http/index.ts +6 -0
- package/src/http/integrations.ts +134 -0
- package/src/http/poll.ts +161 -3
- package/src/http/pricing.ts +245 -0
- package/src/http/session-data.ts +54 -6
- package/src/http/tasks.ts +23 -2
- package/src/prompts/base-prompt.ts +103 -73
- package/src/prompts/session-templates.ts +43 -0
- package/src/providers/claude-adapter.ts +3 -1
- package/src/providers/claude-managed-adapter.ts +871 -0
- package/src/providers/claude-managed-models.ts +117 -0
- package/src/providers/claude-managed-swarm-events.ts +77 -0
- package/src/providers/codex-adapter.ts +3 -1
- package/src/providers/codex-skill-resolver.ts +10 -0
- package/src/providers/codex-swarm-events.ts +20 -161
- package/src/providers/devin-adapter.ts +894 -0
- package/src/providers/devin-api.ts +207 -0
- package/src/providers/devin-playbooks.ts +91 -0
- package/src/providers/devin-skill-resolver.ts +113 -0
- package/src/providers/index.ts +10 -1
- package/src/providers/pi-mono-adapter.ts +3 -1
- package/src/providers/swarm-events-shared.ts +262 -0
- package/src/providers/types.ts +26 -1
- package/src/tests/base-prompt.test.ts +199 -0
- package/src/tests/budget-admission.test.ts +338 -0
- package/src/tests/budget-claim-gate.test.ts +288 -0
- package/src/tests/budget-refusal-notification.test.ts +324 -0
- package/src/tests/budgets-routes.test.ts +331 -0
- package/src/tests/claude-managed-adapter.test.ts +1301 -0
- package/src/tests/claude-managed-setup.test.ts +325 -0
- package/src/tests/devin-adapter.test.ts +677 -0
- package/src/tests/devin-api.test.ts +339 -0
- package/src/tests/integrations-http.test.ts +211 -0
- package/src/tests/migration-046-budgets.test.ts +327 -0
- package/src/tests/pricing-routes.test.ts +315 -0
- package/src/tests/prompt-template-remaining.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +2 -2
- package/src/tests/provider-adapter.test.ts +1 -1
- package/src/tests/runner-budget-refused.test.ts +271 -0
- package/src/tests/session-costs-codex-recompute.test.ts +386 -0
- package/src/tools/poll-task.ts +13 -2
- package/src/tools/task-action.ts +92 -2
- package/src/tools/templates.ts +29 -0
- package/src/types.ts +116 -0
- package/src/utils/budget-backoff.ts +34 -0
- package/src/utils/credentials.ts +4 -0
- package/src/utils/provider-metadata.ts +9 -0
- package/src/workflows/executors/raw-llm.ts +1 -1
- package/src/workflows/executors/validate.ts +1 -1
package/src/be/db.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
+
import { parseProviderMeta } from "@/utils/provider-metadata.ts";
|
|
2
3
|
import pkg from "../../package.json";
|
|
3
4
|
import { addEyesReactionOnTaskStart } from "../github/task-reactions";
|
|
4
5
|
import { configureDbResolver } from "../prompts/resolver";
|
|
@@ -14,6 +15,10 @@ import type {
|
|
|
14
15
|
AgentTaskSource,
|
|
15
16
|
AgentTaskStatus,
|
|
16
17
|
AgentWithTasks,
|
|
18
|
+
Budget,
|
|
19
|
+
BudgetRefusalCause,
|
|
20
|
+
BudgetRefusalNotification,
|
|
21
|
+
BudgetScope,
|
|
17
22
|
ChangeSource,
|
|
18
23
|
Channel,
|
|
19
24
|
ChannelMessage,
|
|
@@ -22,6 +27,7 @@ import type {
|
|
|
22
27
|
ContextSnapshotEventType,
|
|
23
28
|
ContextVersion,
|
|
24
29
|
CooldownConfig,
|
|
30
|
+
DevinProviderMeta,
|
|
25
31
|
InboxMessage,
|
|
26
32
|
InboxMessageStatus,
|
|
27
33
|
InputValue,
|
|
@@ -29,13 +35,18 @@ import type {
|
|
|
29
35
|
McpServerScope,
|
|
30
36
|
McpServerTransport,
|
|
31
37
|
McpServerWithInstallInfo,
|
|
38
|
+
PricingProvider,
|
|
39
|
+
PricingRow,
|
|
40
|
+
PricingTokenClass,
|
|
32
41
|
PromptTemplate,
|
|
33
42
|
PromptTemplateHistory,
|
|
43
|
+
ProviderName,
|
|
34
44
|
RepoGuidelines,
|
|
35
45
|
ScheduledTask,
|
|
36
46
|
Service,
|
|
37
47
|
ServiceStatus,
|
|
38
48
|
SessionCost,
|
|
49
|
+
SessionCostSource,
|
|
39
50
|
SessionLog,
|
|
40
51
|
Skill,
|
|
41
52
|
SkillScope,
|
|
@@ -819,6 +830,8 @@ type AgentTaskRow = {
|
|
|
819
830
|
credentialKeyType: string | null;
|
|
820
831
|
requestedByUserId: string | null;
|
|
821
832
|
swarmVersion: string | null;
|
|
833
|
+
provider: string | null;
|
|
834
|
+
providerMeta: string | null;
|
|
822
835
|
};
|
|
823
836
|
|
|
824
837
|
function rowToAgentTask(row: AgentTaskRow): AgentTask {
|
|
@@ -880,6 +893,8 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
|
|
|
880
893
|
credentialKeyType: row.credentialKeyType ?? undefined,
|
|
881
894
|
requestedByUserId: row.requestedByUserId ?? undefined,
|
|
882
895
|
swarmVersion: row.swarmVersion ?? undefined,
|
|
896
|
+
provider: (row.provider as ProviderName | null) ?? undefined,
|
|
897
|
+
providerMeta: parseProviderMeta(row.provider as ProviderName | null, row.providerMeta),
|
|
883
898
|
};
|
|
884
899
|
}
|
|
885
900
|
|
|
@@ -1061,12 +1076,28 @@ export function getChildTasks(parentTaskId: string): AgentTask[] {
|
|
|
1061
1076
|
export function updateTaskClaudeSessionId(
|
|
1062
1077
|
taskId: string,
|
|
1063
1078
|
claudeSessionId: string,
|
|
1079
|
+
provider?: ProviderName,
|
|
1080
|
+
providerMeta?: Record<string, unknown>,
|
|
1064
1081
|
): AgentTask | null {
|
|
1082
|
+
const setClauses = ["claudeSessionId = ?", "lastUpdatedAt = ?"];
|
|
1083
|
+
const params: (string | null)[] = [claudeSessionId, new Date().toISOString()];
|
|
1084
|
+
|
|
1085
|
+
if (provider !== undefined) {
|
|
1086
|
+
setClauses.push("provider = ?");
|
|
1087
|
+
params.push(provider);
|
|
1088
|
+
}
|
|
1089
|
+
if (providerMeta !== undefined) {
|
|
1090
|
+
setClauses.push("providerMeta = ?");
|
|
1091
|
+
params.push(JSON.stringify(providerMeta));
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
params.push(taskId);
|
|
1095
|
+
|
|
1065
1096
|
const row = getDb()
|
|
1066
|
-
.prepare<AgentTaskRow,
|
|
1067
|
-
`UPDATE agent_tasks SET
|
|
1097
|
+
.prepare<AgentTaskRow, (string | null)[]>(
|
|
1098
|
+
`UPDATE agent_tasks SET ${setClauses.join(", ")} WHERE id = ? RETURNING *`,
|
|
1068
1099
|
)
|
|
1069
|
-
.get(
|
|
1100
|
+
.get(...params);
|
|
1070
1101
|
return row ? rowToAgentTask(row) : null;
|
|
1071
1102
|
}
|
|
1072
1103
|
|
|
@@ -1909,6 +1940,14 @@ export function getLogsByTaskIdChronological(taskId: string): AgentLog[] {
|
|
|
1909
1940
|
.map(rowToAgentLog);
|
|
1910
1941
|
}
|
|
1911
1942
|
|
|
1943
|
+
/**
|
|
1944
|
+
* Phase 6: list all log rows of a given eventType, newest first. Used by the
|
|
1945
|
+
* REST audit-log tests to assert mutation rows landed.
|
|
1946
|
+
*/
|
|
1947
|
+
export function getLogsByEventType(eventType: AgentLogEventType): AgentLog[] {
|
|
1948
|
+
return logQueries.getByEventType().all(eventType).map(rowToAgentLog);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1912
1951
|
export function getAllLogs(limit?: number): AgentLog[] {
|
|
1913
1952
|
if (limit) {
|
|
1914
1953
|
return getDb()
|
|
@@ -3478,6 +3517,7 @@ type SessionCostRow = {
|
|
|
3478
3517
|
numTurns: number;
|
|
3479
3518
|
model: string;
|
|
3480
3519
|
isError: number;
|
|
3520
|
+
costSource: string;
|
|
3481
3521
|
createdAt: string;
|
|
3482
3522
|
};
|
|
3483
3523
|
|
|
@@ -3496,6 +3536,7 @@ function rowToSessionCost(row: SessionCostRow): SessionCost {
|
|
|
3496
3536
|
numTurns: row.numTurns,
|
|
3497
3537
|
model: row.model,
|
|
3498
3538
|
isError: row.isError === 1,
|
|
3539
|
+
costSource: (row.costSource as SessionCostSource) ?? "harness",
|
|
3499
3540
|
createdAt: row.createdAt,
|
|
3500
3541
|
};
|
|
3501
3542
|
}
|
|
@@ -3518,10 +3559,11 @@ const sessionCostQueries = {
|
|
|
3518
3559
|
number,
|
|
3519
3560
|
string,
|
|
3520
3561
|
number,
|
|
3562
|
+
string,
|
|
3521
3563
|
]
|
|
3522
3564
|
>(
|
|
3523
|
-
`INSERT INTO session_costs (id, sessionId, taskId, agentId, totalCostUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, durationMs, numTurns, model, isError, createdAt)
|
|
3524
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
|
|
3565
|
+
`INSERT INTO session_costs (id, sessionId, taskId, agentId, totalCostUsd, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, durationMs, numTurns, model, isError, costSource, createdAt)
|
|
3566
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`,
|
|
3525
3567
|
),
|
|
3526
3568
|
|
|
3527
3569
|
getByTaskId: () =>
|
|
@@ -3553,10 +3595,19 @@ export interface CreateSessionCostInput {
|
|
|
3553
3595
|
numTurns: number;
|
|
3554
3596
|
model: string;
|
|
3555
3597
|
isError?: boolean;
|
|
3598
|
+
/**
|
|
3599
|
+
* Phase 6: where the recorded `totalCostUsd` came from.
|
|
3600
|
+
* - 'harness' — value reported by the harness as-is (default).
|
|
3601
|
+
* - 'pricing-table' — value recomputed by the API from `pricing` rows
|
|
3602
|
+
* (Codex when DB pricing rows exist for all three
|
|
3603
|
+
* token classes).
|
|
3604
|
+
*/
|
|
3605
|
+
costSource?: SessionCostSource;
|
|
3556
3606
|
}
|
|
3557
3607
|
|
|
3558
3608
|
export function createSessionCost(input: CreateSessionCostInput): SessionCost {
|
|
3559
3609
|
const id = crypto.randomUUID();
|
|
3610
|
+
const costSource: SessionCostSource = input.costSource ?? "harness";
|
|
3560
3611
|
sessionCostQueries
|
|
3561
3612
|
.insert()
|
|
3562
3613
|
.run(
|
|
@@ -3573,6 +3624,7 @@ export function createSessionCost(input: CreateSessionCostInput): SessionCost {
|
|
|
3573
3624
|
input.numTurns,
|
|
3574
3625
|
input.model,
|
|
3575
3626
|
input.isError ? 1 : 0,
|
|
3627
|
+
costSource,
|
|
3576
3628
|
);
|
|
3577
3629
|
|
|
3578
3630
|
return {
|
|
@@ -3589,6 +3641,7 @@ export function createSessionCost(input: CreateSessionCostInput): SessionCost {
|
|
|
3589
3641
|
numTurns: input.numTurns,
|
|
3590
3642
|
model: input.model,
|
|
3591
3643
|
isError: input.isError ?? false,
|
|
3644
|
+
costSource,
|
|
3592
3645
|
createdAt: new Date().toISOString(),
|
|
3593
3646
|
};
|
|
3594
3647
|
}
|
|
@@ -8070,3 +8123,433 @@ export function deleteUser(id: string): boolean {
|
|
|
8070
8123
|
const result = getDb().prepare("DELETE FROM users WHERE id = ?").run(id);
|
|
8071
8124
|
return result.changes > 0;
|
|
8072
8125
|
}
|
|
8126
|
+
|
|
8127
|
+
// ============================================================================
|
|
8128
|
+
// Budgets, daily-spend aggregation, and budget-refusal notifications (Phase 2)
|
|
8129
|
+
// ----------------------------------------------------------------------------
|
|
8130
|
+
// `budgets` and `budget_refusal_notifications` use INTEGER epoch-ms for their
|
|
8131
|
+
// `createdAt` / `lastUpdatedAt` columns (deliberate divergence — see migration
|
|
8132
|
+
// 044). All inserts here use `Date.now()` accordingly.
|
|
8133
|
+
// ============================================================================
|
|
8134
|
+
|
|
8135
|
+
interface BudgetRow {
|
|
8136
|
+
scope: string;
|
|
8137
|
+
scope_id: string;
|
|
8138
|
+
daily_budget_usd: number;
|
|
8139
|
+
createdAt: number;
|
|
8140
|
+
lastUpdatedAt: number;
|
|
8141
|
+
}
|
|
8142
|
+
|
|
8143
|
+
interface BudgetRefusalNotificationRow {
|
|
8144
|
+
task_id: string;
|
|
8145
|
+
date: string;
|
|
8146
|
+
agent_id: string;
|
|
8147
|
+
cause: string;
|
|
8148
|
+
agent_spend_usd: number | null;
|
|
8149
|
+
agent_budget_usd: number | null;
|
|
8150
|
+
global_spend_usd: number | null;
|
|
8151
|
+
global_budget_usd: number | null;
|
|
8152
|
+
follow_up_task_id: string | null;
|
|
8153
|
+
createdAt: number;
|
|
8154
|
+
}
|
|
8155
|
+
|
|
8156
|
+
interface CoalesceSumRow {
|
|
8157
|
+
total: number;
|
|
8158
|
+
}
|
|
8159
|
+
|
|
8160
|
+
function rowToBudget(row: BudgetRow): Budget {
|
|
8161
|
+
return {
|
|
8162
|
+
scope: row.scope as BudgetScope,
|
|
8163
|
+
scopeId: row.scope_id,
|
|
8164
|
+
dailyBudgetUsd: row.daily_budget_usd,
|
|
8165
|
+
createdAt: row.createdAt,
|
|
8166
|
+
lastUpdatedAt: row.lastUpdatedAt,
|
|
8167
|
+
};
|
|
8168
|
+
}
|
|
8169
|
+
|
|
8170
|
+
function rowToBudgetRefusalNotification(
|
|
8171
|
+
row: BudgetRefusalNotificationRow,
|
|
8172
|
+
): BudgetRefusalNotification {
|
|
8173
|
+
return {
|
|
8174
|
+
taskId: row.task_id,
|
|
8175
|
+
date: row.date,
|
|
8176
|
+
agentId: row.agent_id,
|
|
8177
|
+
cause: row.cause as BudgetRefusalCause,
|
|
8178
|
+
agentSpendUsd: row.agent_spend_usd ?? undefined,
|
|
8179
|
+
agentBudgetUsd: row.agent_budget_usd ?? undefined,
|
|
8180
|
+
globalSpendUsd: row.global_spend_usd ?? undefined,
|
|
8181
|
+
globalBudgetUsd: row.global_budget_usd ?? undefined,
|
|
8182
|
+
followUpTaskId: row.follow_up_task_id ?? undefined,
|
|
8183
|
+
createdAt: row.createdAt,
|
|
8184
|
+
};
|
|
8185
|
+
}
|
|
8186
|
+
|
|
8187
|
+
/**
|
|
8188
|
+
* Look up a single budget row by (scope, scopeId). Returns `null` when no row
|
|
8189
|
+
* exists — callers treat that as "unlimited / no budget configured".
|
|
8190
|
+
*/
|
|
8191
|
+
export function getBudget(scope: BudgetScope, scopeId: string): Budget | null {
|
|
8192
|
+
const row = getDb()
|
|
8193
|
+
.prepare<BudgetRow, [string, string]>(
|
|
8194
|
+
"SELECT scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt FROM budgets WHERE scope = ? AND scope_id = ?",
|
|
8195
|
+
)
|
|
8196
|
+
.get(scope, scopeId);
|
|
8197
|
+
return row ? rowToBudget(row) : null;
|
|
8198
|
+
}
|
|
8199
|
+
|
|
8200
|
+
/**
|
|
8201
|
+
* Phase 6: list every budget row in the system. Used by `GET /api/budgets`.
|
|
8202
|
+
* Order is `(scope, scope_id)` for stable output across calls.
|
|
8203
|
+
*/
|
|
8204
|
+
export function getBudgets(): Budget[] {
|
|
8205
|
+
return getDb()
|
|
8206
|
+
.prepare<BudgetRow, []>(
|
|
8207
|
+
"SELECT scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt FROM budgets ORDER BY scope, scope_id",
|
|
8208
|
+
)
|
|
8209
|
+
.all()
|
|
8210
|
+
.map(rowToBudget);
|
|
8211
|
+
}
|
|
8212
|
+
|
|
8213
|
+
/**
|
|
8214
|
+
* Phase 6: upsert a budget row. Creates the row if `(scope, scopeId)` does not
|
|
8215
|
+
* exist, otherwise updates `daily_budget_usd` and `lastUpdatedAt`. Returns the
|
|
8216
|
+
* resulting row in both cases.
|
|
8217
|
+
*/
|
|
8218
|
+
export function upsertBudget(scope: BudgetScope, scopeId: string, dailyBudgetUsd: number): Budget {
|
|
8219
|
+
const now = Date.now();
|
|
8220
|
+
getDb()
|
|
8221
|
+
.prepare(
|
|
8222
|
+
`INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt)
|
|
8223
|
+
VALUES (?, ?, ?, ?, ?)
|
|
8224
|
+
ON CONFLICT(scope, scope_id) DO UPDATE SET
|
|
8225
|
+
daily_budget_usd = excluded.daily_budget_usd,
|
|
8226
|
+
lastUpdatedAt = excluded.lastUpdatedAt`,
|
|
8227
|
+
)
|
|
8228
|
+
.run(scope, scopeId, dailyBudgetUsd, now, now);
|
|
8229
|
+
|
|
8230
|
+
const updated = getBudget(scope, scopeId);
|
|
8231
|
+
if (!updated) {
|
|
8232
|
+
throw new Error(
|
|
8233
|
+
`upsertBudget: row missing after insert for (scope=${scope}, scopeId=${scopeId})`,
|
|
8234
|
+
);
|
|
8235
|
+
}
|
|
8236
|
+
return updated;
|
|
8237
|
+
}
|
|
8238
|
+
|
|
8239
|
+
/**
|
|
8240
|
+
* Phase 6: delete a budget row. Returns `true` if a row was deleted, `false`
|
|
8241
|
+
* if `(scope, scopeId)` did not exist.
|
|
8242
|
+
*/
|
|
8243
|
+
export function deleteBudget(scope: BudgetScope, scopeId: string): boolean {
|
|
8244
|
+
const result = getDb()
|
|
8245
|
+
.prepare("DELETE FROM budgets WHERE scope = ? AND scope_id = ?")
|
|
8246
|
+
.run(scope, scopeId);
|
|
8247
|
+
return result.changes > 0;
|
|
8248
|
+
}
|
|
8249
|
+
|
|
8250
|
+
// ============================================================================
|
|
8251
|
+
// Pricing rows (Phase 6 — append-only price book)
|
|
8252
|
+
// ----------------------------------------------------------------------------
|
|
8253
|
+
// `pricing` uses INTEGER epoch-ms for `effective_from`, `createdAt`,
|
|
8254
|
+
// `lastUpdatedAt` (see migration 044). Append-only by design: operators add a
|
|
8255
|
+
// new row with a later `effective_from` rather than mutating an existing row.
|
|
8256
|
+
// `getActivePricingRow` resolves the row with the largest
|
|
8257
|
+
// `effective_from <= atEpochMs`, which is the correct "what price was in
|
|
8258
|
+
// effect at time T" semantics regardless of insertion order.
|
|
8259
|
+
// ============================================================================
|
|
8260
|
+
|
|
8261
|
+
interface PricingRowDb {
|
|
8262
|
+
provider: string;
|
|
8263
|
+
model: string;
|
|
8264
|
+
token_class: string;
|
|
8265
|
+
effective_from: number;
|
|
8266
|
+
price_per_million_usd: number;
|
|
8267
|
+
createdAt: number;
|
|
8268
|
+
lastUpdatedAt: number;
|
|
8269
|
+
}
|
|
8270
|
+
|
|
8271
|
+
function rowToPricingRow(row: PricingRowDb): PricingRow {
|
|
8272
|
+
return {
|
|
8273
|
+
provider: row.provider as PricingProvider,
|
|
8274
|
+
model: row.model,
|
|
8275
|
+
tokenClass: row.token_class as PricingTokenClass,
|
|
8276
|
+
effectiveFrom: row.effective_from,
|
|
8277
|
+
pricePerMillionUsd: row.price_per_million_usd,
|
|
8278
|
+
createdAt: row.createdAt,
|
|
8279
|
+
lastUpdatedAt: row.lastUpdatedAt,
|
|
8280
|
+
};
|
|
8281
|
+
}
|
|
8282
|
+
|
|
8283
|
+
/** Phase 6: list every pricing row, latest-effective first. */
|
|
8284
|
+
export function getAllPricingRows(): PricingRow[] {
|
|
8285
|
+
return getDb()
|
|
8286
|
+
.prepare<PricingRowDb, []>(
|
|
8287
|
+
"SELECT provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt FROM pricing ORDER BY provider, model, token_class, effective_from DESC",
|
|
8288
|
+
)
|
|
8289
|
+
.all()
|
|
8290
|
+
.map(rowToPricingRow);
|
|
8291
|
+
}
|
|
8292
|
+
|
|
8293
|
+
/**
|
|
8294
|
+
* Phase 6: list every pricing row for a given (provider, model, tokenClass)
|
|
8295
|
+
* triple. Order is `effective_from DESC` so newest is first.
|
|
8296
|
+
*/
|
|
8297
|
+
export function getPricingRows(
|
|
8298
|
+
provider: PricingProvider,
|
|
8299
|
+
model: string,
|
|
8300
|
+
tokenClass: PricingTokenClass,
|
|
8301
|
+
): PricingRow[] {
|
|
8302
|
+
return getDb()
|
|
8303
|
+
.prepare<PricingRowDb, [string, string, string]>(
|
|
8304
|
+
"SELECT provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt FROM pricing WHERE provider = ? AND model = ? AND token_class = ? ORDER BY effective_from DESC",
|
|
8305
|
+
)
|
|
8306
|
+
.all(provider, model, tokenClass)
|
|
8307
|
+
.map(rowToPricingRow);
|
|
8308
|
+
}
|
|
8309
|
+
|
|
8310
|
+
/**
|
|
8311
|
+
* Phase 6: resolve "what price was in effect at time `atEpochMs`" — the row
|
|
8312
|
+
* with the largest `effective_from <= atEpochMs`. Returns null when no row
|
|
8313
|
+
* matches (model unseeded for that triple at that time). Backed by the
|
|
8314
|
+
* `idx_pricing_lookup` index from migration 044.
|
|
8315
|
+
*/
|
|
8316
|
+
export function getActivePricingRow(
|
|
8317
|
+
provider: PricingProvider,
|
|
8318
|
+
model: string,
|
|
8319
|
+
tokenClass: PricingTokenClass,
|
|
8320
|
+
atEpochMs: number,
|
|
8321
|
+
): PricingRow | null {
|
|
8322
|
+
const row = getDb()
|
|
8323
|
+
.prepare<PricingRowDb, [string, string, string, number]>(
|
|
8324
|
+
"SELECT provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt FROM pricing WHERE provider = ? AND model = ? AND token_class = ? AND effective_from <= ? ORDER BY effective_from DESC LIMIT 1",
|
|
8325
|
+
)
|
|
8326
|
+
.get(provider, model, tokenClass, atEpochMs);
|
|
8327
|
+
return row ? rowToPricingRow(row) : null;
|
|
8328
|
+
}
|
|
8329
|
+
|
|
8330
|
+
export interface InsertPricingRowInput {
|
|
8331
|
+
provider: PricingProvider;
|
|
8332
|
+
model: string;
|
|
8333
|
+
tokenClass: PricingTokenClass;
|
|
8334
|
+
effectiveFrom: number;
|
|
8335
|
+
pricePerMillionUsd: number;
|
|
8336
|
+
}
|
|
8337
|
+
|
|
8338
|
+
/**
|
|
8339
|
+
* Phase 6: insert a new pricing row. Throws on PK collision
|
|
8340
|
+
* `(provider, model, token_class, effective_from)` — caller (the HTTP route)
|
|
8341
|
+
* translates that into a 409.
|
|
8342
|
+
*/
|
|
8343
|
+
export function insertPricingRow(input: InsertPricingRowInput): PricingRow {
|
|
8344
|
+
const now = Date.now();
|
|
8345
|
+
getDb()
|
|
8346
|
+
.prepare(
|
|
8347
|
+
`INSERT INTO pricing (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
|
|
8348
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
8349
|
+
)
|
|
8350
|
+
.run(
|
|
8351
|
+
input.provider,
|
|
8352
|
+
input.model,
|
|
8353
|
+
input.tokenClass,
|
|
8354
|
+
input.effectiveFrom,
|
|
8355
|
+
input.pricePerMillionUsd,
|
|
8356
|
+
now,
|
|
8357
|
+
now,
|
|
8358
|
+
);
|
|
8359
|
+
return {
|
|
8360
|
+
provider: input.provider,
|
|
8361
|
+
model: input.model,
|
|
8362
|
+
tokenClass: input.tokenClass,
|
|
8363
|
+
effectiveFrom: input.effectiveFrom,
|
|
8364
|
+
pricePerMillionUsd: input.pricePerMillionUsd,
|
|
8365
|
+
createdAt: now,
|
|
8366
|
+
lastUpdatedAt: now,
|
|
8367
|
+
};
|
|
8368
|
+
}
|
|
8369
|
+
|
|
8370
|
+
/**
|
|
8371
|
+
* Phase 6: delete a pricing row. Returns true if a row was deleted, false if
|
|
8372
|
+
* the row did not exist. Discouraged operationally — historical session_costs
|
|
8373
|
+
* are not retroactively recomputed — but allowed for typo correction.
|
|
8374
|
+
*/
|
|
8375
|
+
export function deletePricingRow(
|
|
8376
|
+
provider: PricingProvider,
|
|
8377
|
+
model: string,
|
|
8378
|
+
tokenClass: PricingTokenClass,
|
|
8379
|
+
effectiveFrom: number,
|
|
8380
|
+
): boolean {
|
|
8381
|
+
const result = getDb()
|
|
8382
|
+
.prepare(
|
|
8383
|
+
"DELETE FROM pricing WHERE provider = ? AND model = ? AND token_class = ? AND effective_from = ?",
|
|
8384
|
+
)
|
|
8385
|
+
.run(provider, model, tokenClass, effectiveFrom);
|
|
8386
|
+
return result.changes > 0;
|
|
8387
|
+
}
|
|
8388
|
+
|
|
8389
|
+
/**
|
|
8390
|
+
* Sum of `totalCostUsd` across all `session_costs` rows for a given agent on a
|
|
8391
|
+
* given UTC calendar day. `dateUtc` MUST be `'YYYY-MM-DD'` (UTC). Returns 0
|
|
8392
|
+
* when no rows exist.
|
|
8393
|
+
*
|
|
8394
|
+
* Implementation note: we filter on `substr(createdAt, 1, 10) = ?` rather than
|
|
8395
|
+
* `date(createdAt / 1000, 'unixepoch') = ?` because `session_costs.createdAt`
|
|
8396
|
+
* is TEXT in ISO 8601 format (`'YYYY-MM-DDTHH:MM:SS.SSSZ'`), populated via
|
|
8397
|
+
* `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`. The left-anchored `substr` prefix
|
|
8398
|
+
* also lets the SQLite optimizer use the existing
|
|
8399
|
+
* `idx_session_costs_agent_createdAt` index (verified via EXPLAIN QUERY PLAN
|
|
8400
|
+
* in the test suite).
|
|
8401
|
+
*/
|
|
8402
|
+
export function getDailySpendForAgent(agentId: string, dateUtc: string): number {
|
|
8403
|
+
const row = getDb()
|
|
8404
|
+
.prepare<CoalesceSumRow, [string, string]>(
|
|
8405
|
+
"SELECT COALESCE(SUM(totalCostUsd), 0) as total FROM session_costs WHERE agentId = ? AND substr(createdAt, 1, 10) = ?",
|
|
8406
|
+
)
|
|
8407
|
+
.get(agentId, dateUtc);
|
|
8408
|
+
return row?.total ?? 0;
|
|
8409
|
+
}
|
|
8410
|
+
|
|
8411
|
+
/**
|
|
8412
|
+
* Sum of `totalCostUsd` across all `session_costs` rows for a given UTC
|
|
8413
|
+
* calendar day, regardless of agent. `dateUtc` MUST be `'YYYY-MM-DD'` (UTC).
|
|
8414
|
+
*
|
|
8415
|
+
* NOTE: this query has no `agentId` prefix and therefore does not naturally
|
|
8416
|
+
* match the `(agentId, createdAt)` composite index. SQLite's optimizer may
|
|
8417
|
+
* pick `idx_session_costs_createdAt` (single-column on `createdAt`) — but
|
|
8418
|
+
* because the predicate is `substr(createdAt, 1, 10) = ?` rather than a range
|
|
8419
|
+
* scan, the planner often falls back to a full table scan. That is acceptable
|
|
8420
|
+
* for V1 daily-spend volumes; if it ever becomes a hotspot, a covering
|
|
8421
|
+
* functional index on `substr(createdAt, 1, 10)` would be the fix.
|
|
8422
|
+
*/
|
|
8423
|
+
export function getDailySpendGlobal(dateUtc: string): number {
|
|
8424
|
+
const row = getDb()
|
|
8425
|
+
.prepare<CoalesceSumRow, [string]>(
|
|
8426
|
+
"SELECT COALESCE(SUM(totalCostUsd), 0) as total FROM session_costs WHERE substr(createdAt, 1, 10) = ?",
|
|
8427
|
+
)
|
|
8428
|
+
.get(dateUtc);
|
|
8429
|
+
return row?.total ?? 0;
|
|
8430
|
+
}
|
|
8431
|
+
|
|
8432
|
+
export interface RecordBudgetRefusalNotificationInput {
|
|
8433
|
+
taskId: string;
|
|
8434
|
+
date: string;
|
|
8435
|
+
agentId: string;
|
|
8436
|
+
cause: BudgetRefusalCause;
|
|
8437
|
+
agentSpendUsd?: number;
|
|
8438
|
+
agentBudgetUsd?: number;
|
|
8439
|
+
globalSpendUsd?: number;
|
|
8440
|
+
globalBudgetUsd?: number;
|
|
8441
|
+
}
|
|
8442
|
+
|
|
8443
|
+
/**
|
|
8444
|
+
* Idempotent insert of a budget-refusal notification keyed by
|
|
8445
|
+
* `(task_id, date)`. Returns `{ inserted: true, row }` on first call for that
|
|
8446
|
+
* key, or `{ inserted: false, row }` (with the original row) on subsequent
|
|
8447
|
+
* calls — used by the notification path to dedup "the agent told me about
|
|
8448
|
+
* this task already" across retries within the same UTC day.
|
|
8449
|
+
*/
|
|
8450
|
+
export function recordBudgetRefusalNotification(input: RecordBudgetRefusalNotificationInput): {
|
|
8451
|
+
inserted: boolean;
|
|
8452
|
+
row: BudgetRefusalNotification;
|
|
8453
|
+
} {
|
|
8454
|
+
const db = getDb();
|
|
8455
|
+
const now = Date.now();
|
|
8456
|
+
const result = db
|
|
8457
|
+
.prepare(
|
|
8458
|
+
`INSERT OR IGNORE INTO budget_refusal_notifications
|
|
8459
|
+
(task_id, date, agent_id, cause, agent_spend_usd, agent_budget_usd, global_spend_usd, global_budget_usd, follow_up_task_id, createdAt)
|
|
8460
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
|
|
8461
|
+
)
|
|
8462
|
+
.run(
|
|
8463
|
+
input.taskId,
|
|
8464
|
+
input.date,
|
|
8465
|
+
input.agentId,
|
|
8466
|
+
input.cause,
|
|
8467
|
+
input.agentSpendUsd ?? null,
|
|
8468
|
+
input.agentBudgetUsd ?? null,
|
|
8469
|
+
input.globalSpendUsd ?? null,
|
|
8470
|
+
input.globalBudgetUsd ?? null,
|
|
8471
|
+
now,
|
|
8472
|
+
);
|
|
8473
|
+
|
|
8474
|
+
const existing = db
|
|
8475
|
+
.prepare<BudgetRefusalNotificationRow, [string, string]>(
|
|
8476
|
+
"SELECT * FROM budget_refusal_notifications WHERE task_id = ? AND date = ?",
|
|
8477
|
+
)
|
|
8478
|
+
.get(input.taskId, input.date);
|
|
8479
|
+
|
|
8480
|
+
if (!existing) {
|
|
8481
|
+
// Should be unreachable: INSERT OR IGNORE either inserts or leaves an
|
|
8482
|
+
// existing row. If we hit this it's a hard schema/runtime invariant break.
|
|
8483
|
+
throw new Error(
|
|
8484
|
+
`recordBudgetRefusalNotification: row missing after insert for (taskId=${input.taskId}, date=${input.date})`,
|
|
8485
|
+
);
|
|
8486
|
+
}
|
|
8487
|
+
|
|
8488
|
+
return {
|
|
8489
|
+
inserted: result.changes > 0,
|
|
8490
|
+
row: rowToBudgetRefusalNotification(existing),
|
|
8491
|
+
};
|
|
8492
|
+
}
|
|
8493
|
+
|
|
8494
|
+
/**
|
|
8495
|
+
* Lookup helper used by tests and by the Phase 5 follow-up-task write-back.
|
|
8496
|
+
*/
|
|
8497
|
+
export function getBudgetRefusalNotification(
|
|
8498
|
+
taskId: string,
|
|
8499
|
+
date: string,
|
|
8500
|
+
): BudgetRefusalNotification | null {
|
|
8501
|
+
const row = getDb()
|
|
8502
|
+
.prepare<BudgetRefusalNotificationRow, [string, string]>(
|
|
8503
|
+
"SELECT * FROM budget_refusal_notifications WHERE task_id = ? AND date = ?",
|
|
8504
|
+
)
|
|
8505
|
+
.get(taskId, date);
|
|
8506
|
+
return row ? rowToBudgetRefusalNotification(row) : null;
|
|
8507
|
+
}
|
|
8508
|
+
|
|
8509
|
+
/**
|
|
8510
|
+
* List recent budget refusal notifications across all tasks/dates, newest
|
|
8511
|
+
* first. Used by the operator dashboard to surface refusals as an
|
|
8512
|
+
* actionable feed (parent task → follow-up task link).
|
|
8513
|
+
*/
|
|
8514
|
+
export function getRecentBudgetRefusalNotifications(limit = 50): BudgetRefusalNotification[] {
|
|
8515
|
+
const rows = getDb()
|
|
8516
|
+
.prepare<BudgetRefusalNotificationRow, [number]>(
|
|
8517
|
+
"SELECT * FROM budget_refusal_notifications ORDER BY createdAt DESC LIMIT ?",
|
|
8518
|
+
)
|
|
8519
|
+
.all(limit);
|
|
8520
|
+
return rows.map(rowToBudgetRefusalNotification);
|
|
8521
|
+
}
|
|
8522
|
+
|
|
8523
|
+
/**
|
|
8524
|
+
* Boolean observability helper — returns true iff a refusal notification has
|
|
8525
|
+
* already been recorded for `(taskId, date)`.
|
|
8526
|
+
*/
|
|
8527
|
+
export function hasBudgetRefusalNotificationToday(taskId: string, date: string): boolean {
|
|
8528
|
+
const row = getDb()
|
|
8529
|
+
.prepare<{ one: number }, [string, string]>(
|
|
8530
|
+
"SELECT 1 as one FROM budget_refusal_notifications WHERE task_id = ? AND date = ? LIMIT 1",
|
|
8531
|
+
)
|
|
8532
|
+
.get(taskId, date);
|
|
8533
|
+
return row !== null;
|
|
8534
|
+
}
|
|
8535
|
+
|
|
8536
|
+
/**
|
|
8537
|
+
* Phase 5 write-back: link the freshly-created lead-facing follow-up task
|
|
8538
|
+
* back to its dedup row so operators can audit "find the lead-facing
|
|
8539
|
+
* follow-up that was created when this task was first refused".
|
|
8540
|
+
*
|
|
8541
|
+
* Idempotent — safe to call multiple times with the same `(taskId, date)`,
|
|
8542
|
+
* but only the first refusal per day creates a follow-up task in the first
|
|
8543
|
+
* place (see `recordBudgetRefusalNotification` for the dedup invariant).
|
|
8544
|
+
*/
|
|
8545
|
+
export function setBudgetRefusalFollowUpTaskId(
|
|
8546
|
+
taskId: string,
|
|
8547
|
+
date: string,
|
|
8548
|
+
followUpTaskId: string,
|
|
8549
|
+
): void {
|
|
8550
|
+
getDb()
|
|
8551
|
+
.prepare(
|
|
8552
|
+
"UPDATE budget_refusal_notifications SET follow_up_task_id = ? WHERE task_id = ? AND date = ?",
|
|
8553
|
+
)
|
|
8554
|
+
.run(followUpTaskId, taskId, date);
|
|
8555
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
-- 046_budgets_and_pricing.sql
|
|
2
|
+
-- Per-agent daily cost budget (V1) — schema + price-book DB-ification.
|
|
3
|
+
--
|
|
4
|
+
-- Tables added:
|
|
5
|
+
-- * budgets — daily USD budgets per scope (global/agent).
|
|
6
|
+
-- * pricing — append-only price book per (provider, model, token_class, effective_from).
|
|
7
|
+
-- * budget_refusal_notifications — per (task_id, day) dedup of refusal notifications.
|
|
8
|
+
--
|
|
9
|
+
-- Index added:
|
|
10
|
+
-- * idx_pricing_lookup — supports "latest active price" queries.
|
|
11
|
+
--
|
|
12
|
+
-- Seed:
|
|
13
|
+
-- * pricing rows derived from `CODEX_MODEL_PRICING` (src/providers/codex-models.ts:97-119),
|
|
14
|
+
-- 12 rows total (4 models × 3 token_classes), all with effective_from = 0 (epoch).
|
|
15
|
+
-- INSERT OR IGNORE keeps the migration idempotent on re-apply.
|
|
16
|
+
--
|
|
17
|
+
-- Timestamp convention (deliberate divergence from existing tables):
|
|
18
|
+
-- The columns `createdAt`, `lastUpdatedAt`, and `effective_from` in this migration are
|
|
19
|
+
-- INTEGER epoch milliseconds — NOT TEXT ISO 8601 like the rest of the schema. This is
|
|
20
|
+
-- intentional: integer math keeps the price-book "largest effective_from <= now" lookup
|
|
21
|
+
-- simple and lets the three timestamp fields share the same numeric domain. Seed rows
|
|
22
|
+
-- use literal 0 so re-runs of the seed are idempotent under INSERT OR IGNORE.
|
|
23
|
+
--
|
|
24
|
+
-- Index that was deliberately NOT added here:
|
|
25
|
+
-- `(agentId, createdAt)` on session_costs already exists as `idx_session_costs_agent_createdAt`
|
|
26
|
+
-- in `001_initial.sql:363`. Adding it again under a new name (e.g. idx_session_costs_agent_created)
|
|
27
|
+
-- would be a redundant duplicate. Phase 2 query plans should reference the canonical
|
|
28
|
+
-- index name from 001.
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS budgets (
|
|
31
|
+
scope TEXT NOT NULL,
|
|
32
|
+
scope_id TEXT NOT NULL,
|
|
33
|
+
daily_budget_usd REAL NOT NULL,
|
|
34
|
+
createdAt INTEGER NOT NULL,
|
|
35
|
+
lastUpdatedAt INTEGER NOT NULL,
|
|
36
|
+
PRIMARY KEY (scope, scope_id),
|
|
37
|
+
CHECK (scope IN ('global', 'agent')),
|
|
38
|
+
CHECK (daily_budget_usd >= 0)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS pricing (
|
|
42
|
+
provider TEXT NOT NULL,
|
|
43
|
+
model TEXT NOT NULL,
|
|
44
|
+
token_class TEXT NOT NULL,
|
|
45
|
+
effective_from INTEGER NOT NULL,
|
|
46
|
+
price_per_million_usd REAL NOT NULL,
|
|
47
|
+
createdAt INTEGER NOT NULL,
|
|
48
|
+
lastUpdatedAt INTEGER NOT NULL,
|
|
49
|
+
PRIMARY KEY (provider, model, token_class, effective_from),
|
|
50
|
+
CHECK (provider IN ('claude', 'codex', 'pi')),
|
|
51
|
+
CHECK (token_class IN ('input', 'cached_input', 'output'))
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_pricing_lookup
|
|
55
|
+
ON pricing (provider, model, token_class, effective_from DESC);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS budget_refusal_notifications (
|
|
58
|
+
task_id TEXT NOT NULL,
|
|
59
|
+
date TEXT NOT NULL,
|
|
60
|
+
agent_id TEXT NOT NULL,
|
|
61
|
+
cause TEXT NOT NULL,
|
|
62
|
+
agent_spend_usd REAL,
|
|
63
|
+
agent_budget_usd REAL,
|
|
64
|
+
global_spend_usd REAL,
|
|
65
|
+
global_budget_usd REAL,
|
|
66
|
+
follow_up_task_id TEXT,
|
|
67
|
+
createdAt INTEGER NOT NULL,
|
|
68
|
+
PRIMARY KEY (task_id, date),
|
|
69
|
+
CHECK (cause IN ('agent', 'global'))
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
-- Seed Codex price book. Mirrors CODEX_MODEL_PRICING (src/providers/codex-models.ts).
|
|
73
|
+
-- Use literal 0 for effective_from / createdAt / lastUpdatedAt so re-applying the
|
|
74
|
+
-- seed under INSERT OR IGNORE is a true no-op (no clock drift between runs).
|
|
75
|
+
INSERT OR IGNORE INTO pricing (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt) VALUES
|
|
76
|
+
('codex', 'gpt-5.4', 'input', 0, 2.5, 0, 0),
|
|
77
|
+
('codex', 'gpt-5.4', 'cached_input', 0, 0.25, 0, 0),
|
|
78
|
+
('codex', 'gpt-5.4', 'output', 0, 15.0, 0, 0),
|
|
79
|
+
('codex', 'gpt-5.4-mini', 'input', 0, 0.75, 0, 0),
|
|
80
|
+
('codex', 'gpt-5.4-mini', 'cached_input', 0, 0.075, 0, 0),
|
|
81
|
+
('codex', 'gpt-5.4-mini', 'output', 0, 4.5, 0, 0),
|
|
82
|
+
('codex', 'gpt-5.3-codex', 'input', 0, 1.75, 0, 0),
|
|
83
|
+
('codex', 'gpt-5.3-codex', 'cached_input', 0, 0.175, 0, 0),
|
|
84
|
+
('codex', 'gpt-5.3-codex', 'output', 0, 14.0, 0, 0),
|
|
85
|
+
('codex', 'gpt-5.2-codex', 'input', 0, 1.75, 0, 0),
|
|
86
|
+
('codex', 'gpt-5.2-codex', 'cached_input', 0, 0.175, 0, 0),
|
|
87
|
+
('codex', 'gpt-5.2-codex', 'output', 0, 14.0, 0, 0);
|