@desplega.ai/agent-swarm 1.71.2 → 1.72.0
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 +339 -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/http/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { handleActiveSessions } from "./active-sessions";
|
|
|
21
21
|
import { handleAgentRegister, handleAgentsRest } from "./agents";
|
|
22
22
|
import { handleApiKeys } from "./api-keys";
|
|
23
23
|
import { handleApprovalRequests } from "./approval-requests";
|
|
24
|
+
import { handleBudgets } from "./budgets";
|
|
24
25
|
import { handleConfig } from "./config";
|
|
25
26
|
import { handleContext } from "./context";
|
|
26
27
|
import { handleCore, loadGlobalConfigsIntoEnv } from "./core";
|
|
@@ -28,11 +29,13 @@ import { handleDbQuery } from "./db-query";
|
|
|
28
29
|
import { handleEcosystem } from "./ecosystem";
|
|
29
30
|
import { handleEvents } from "./events";
|
|
30
31
|
import { handleHeartbeat } from "./heartbeat";
|
|
32
|
+
import { handleIntegrations } from "./integrations";
|
|
31
33
|
import { handleMcp } from "./mcp";
|
|
32
34
|
import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
|
|
33
35
|
import { handleMcpServers } from "./mcp-servers";
|
|
34
36
|
import { handleMemory } from "./memory";
|
|
35
37
|
import { handlePoll } from "./poll";
|
|
38
|
+
import { handlePricing } from "./pricing";
|
|
36
39
|
import { handlePromptTemplates } from "./prompt-templates";
|
|
37
40
|
import { handleRepos } from "./repos";
|
|
38
41
|
import { handleSchedules } from "./schedules";
|
|
@@ -118,14 +121,17 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
118
121
|
() => handleTrackers(req, res, pathSegments),
|
|
119
122
|
() => handleWebhooks(req, res, pathSegments),
|
|
120
123
|
() => handleAgentsRest(req, res, pathSegments, queryParams, myAgentId),
|
|
124
|
+
() => handleBudgets(req, res, pathSegments, queryParams, myAgentId),
|
|
121
125
|
() => handleContext(req, res, pathSegments, queryParams, myAgentId),
|
|
122
126
|
() => handleTasks(req, res, pathSegments, queryParams, myAgentId),
|
|
123
127
|
() => handleStats(req, res, pathSegments, queryParams),
|
|
124
128
|
() => handleActiveSessions(req, res, pathSegments, queryParams, myAgentId),
|
|
129
|
+
() => handlePricing(req, res, pathSegments, queryParams, myAgentId),
|
|
125
130
|
() => handleSchedules(req, res, pathSegments, queryParams, myAgentId),
|
|
126
131
|
() => handleWorkflows(req, res, pathSegments, queryParams, myAgentId),
|
|
127
132
|
() => handleApprovalRequests(req, res, pathSegments, queryParams),
|
|
128
133
|
() => handleConfig(req, res, pathSegments, queryParams),
|
|
134
|
+
() => handleIntegrations(req, res, pathSegments),
|
|
129
135
|
() => handlePromptTemplates(req, res, pathSegments, queryParams),
|
|
130
136
|
() => handleDbQuery(req, res, pathSegments, queryParams),
|
|
131
137
|
() => handleRepos(req, res, pathSegments, queryParams),
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getResolvedConfig } from "../be/db";
|
|
5
|
+
import { route } from "./route-def";
|
|
6
|
+
import { json } from "./utils";
|
|
7
|
+
|
|
8
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal `client.beta.agents.retrieve` shape we depend on. Lets tests inject
|
|
12
|
+
* a fake without pulling the entire SDK surface in.
|
|
13
|
+
*/
|
|
14
|
+
export interface ClaudeManagedTestClient {
|
|
15
|
+
beta: {
|
|
16
|
+
agents: {
|
|
17
|
+
retrieve: (agentId: string) => Promise<{ name?: string | null; model?: string | null }>;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TestConnectionDeps {
|
|
23
|
+
/**
|
|
24
|
+
* Optional injectable client factory. When omitted, a real `Anthropic` SDK
|
|
25
|
+
* client is constructed with the resolved API key.
|
|
26
|
+
*/
|
|
27
|
+
buildClient?: (apiKey: string) => ClaudeManagedTestClient;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Route Definition ────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const claudeManagedTestRoute = route({
|
|
33
|
+
method: "post",
|
|
34
|
+
path: "/api/integrations/claude-managed/test",
|
|
35
|
+
pattern: ["api", "integrations", "claude-managed", "test"],
|
|
36
|
+
summary:
|
|
37
|
+
"Test the claude-managed integration: resolves ANTHROPIC_API_KEY + MANAGED_AGENT_ID from swarm_config and calls beta.agents.retrieve.",
|
|
38
|
+
tags: ["Integrations"],
|
|
39
|
+
body: z.object({}).optional(),
|
|
40
|
+
responses: {
|
|
41
|
+
200: {
|
|
42
|
+
description:
|
|
43
|
+
"Connection result — `{ ok: true, agentName, model }` on success or `{ ok: false, error }` on any failure (missing config, Anthropic API error). Always 200 OK.",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Look up a config value by key. Falls back to `process.env` when no
|
|
52
|
+
* swarm_config row exists — mirrors the resolution order used elsewhere
|
|
53
|
+
* (see `loadGlobalConfigsIntoEnv`).
|
|
54
|
+
*
|
|
55
|
+
* Returns the trimmed value or `null` if unset/empty.
|
|
56
|
+
*/
|
|
57
|
+
function resolveConfigValue(key: string): string | null {
|
|
58
|
+
const configs = getResolvedConfig();
|
|
59
|
+
// The setup CLI persists keys in lowercase (e.g. `managed_agent_id`) while
|
|
60
|
+
// the docker-entrypoint hydrates env vars in uppercase (`MANAGED_AGENT_ID`).
|
|
61
|
+
// Look up both variants so this endpoint works against either shape.
|
|
62
|
+
const variants = [key, key.toLowerCase(), key.toUpperCase()];
|
|
63
|
+
for (const variant of variants) {
|
|
64
|
+
const row = configs.find((c) => c.key === variant);
|
|
65
|
+
if (row && typeof row.value === "string" && row.value.length > 0) {
|
|
66
|
+
return row.value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Env fallback — the row may not exist if the operator deployed via env
|
|
70
|
+
// file rather than swarm_config.
|
|
71
|
+
const envValue = process.env[key];
|
|
72
|
+
if (envValue && envValue.length > 0) return envValue;
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Public handler factory ──────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the integrations handler. Exposed as a factory so tests can inject a
|
|
80
|
+
* fake Anthropic client.
|
|
81
|
+
*/
|
|
82
|
+
export function createIntegrationsHandler(deps: TestConnectionDeps = {}) {
|
|
83
|
+
const buildClient =
|
|
84
|
+
deps.buildClient ??
|
|
85
|
+
((apiKey: string) => new Anthropic({ apiKey }) as unknown as ClaudeManagedTestClient);
|
|
86
|
+
|
|
87
|
+
return async function handleIntegrations(
|
|
88
|
+
req: IncomingMessage,
|
|
89
|
+
res: ServerResponse,
|
|
90
|
+
pathSegments: string[],
|
|
91
|
+
): Promise<boolean> {
|
|
92
|
+
if (claudeManagedTestRoute.match(req.method, pathSegments)) {
|
|
93
|
+
const apiKey = resolveConfigValue("ANTHROPIC_API_KEY");
|
|
94
|
+
const agentId = resolveConfigValue("MANAGED_AGENT_ID");
|
|
95
|
+
|
|
96
|
+
if (!apiKey || !agentId) {
|
|
97
|
+
const missing: string[] = [];
|
|
98
|
+
if (!apiKey) missing.push("ANTHROPIC_API_KEY");
|
|
99
|
+
if (!agentId) missing.push("MANAGED_AGENT_ID");
|
|
100
|
+
json(res, {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: `Missing required config: ${missing.join(", ")}. Run \`bun run src/cli.tsx claude-managed-setup\` to populate.`,
|
|
103
|
+
});
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const client = buildClient(apiKey);
|
|
109
|
+
const agent = await client.beta.agents.retrieve(agentId);
|
|
110
|
+
// `agent.model` is `BetaManagedAgentsModelConfig` ({id, speed}). Flatten
|
|
111
|
+
// to a string so the UI can render it directly without type guards.
|
|
112
|
+
const modelId =
|
|
113
|
+
typeof agent.model === "string"
|
|
114
|
+
? agent.model
|
|
115
|
+
: ((agent.model as { id?: string } | null | undefined)?.id ?? null);
|
|
116
|
+
json(res, {
|
|
117
|
+
ok: true,
|
|
118
|
+
agentName: agent.name ?? null,
|
|
119
|
+
model: modelId,
|
|
120
|
+
});
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
123
|
+
json(res, { ok: false, error: message });
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Default singleton (used in production / OpenAPI generation) ─────────────
|
|
133
|
+
|
|
134
|
+
export const handleIntegrations = createIntegrationsHandler();
|
package/src/http/poll.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { ensure } from "@desplega.ai/business-use";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { canClaim } from "../be/budget-admission";
|
|
5
|
+
import {
|
|
6
|
+
type BudgetRefusalContext,
|
|
7
|
+
emitBudgetRefusalSideEffects,
|
|
8
|
+
} from "../be/budget-refusal-notify";
|
|
4
9
|
import {
|
|
5
10
|
claimMentions,
|
|
6
11
|
claimOfferedTask,
|
|
@@ -11,9 +16,11 @@ import {
|
|
|
11
16
|
getInboxSummary,
|
|
12
17
|
getOfferedTasksForAgent,
|
|
13
18
|
getPendingTaskForAgent,
|
|
19
|
+
getTaskById,
|
|
14
20
|
getUnassignedTaskIds,
|
|
15
21
|
getUserById,
|
|
16
22
|
hasCapacity,
|
|
23
|
+
recordBudgetRefusalNotification,
|
|
17
24
|
startTask,
|
|
18
25
|
upsertChannelActivityCursor,
|
|
19
26
|
} from "../be/db";
|
|
@@ -22,6 +29,38 @@ import { telemetry } from "../telemetry";
|
|
|
22
29
|
import { route } from "./route-def";
|
|
23
30
|
import { json, jsonError } from "./utils";
|
|
24
31
|
|
|
32
|
+
// ─── Budget-refused trigger envelope ────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the `budget_refused` trigger envelope from a `canClaim` refusal. Lives
|
|
36
|
+
* here (not in budget-admission) because it's the API-shape contract — workers
|
|
37
|
+
* read this on the wire (Phase 4 teaches them how).
|
|
38
|
+
*
|
|
39
|
+
* Phase 5: each refusal site additionally calls
|
|
40
|
+
* `recordBudgetRefusalNotification` (in-txn) and
|
|
41
|
+
* `emitBudgetRefusalSideEffects` (after-commit) to drive the lead follow-up
|
|
42
|
+
* + workflow bus emit. See `src/be/budget-refusal-notify.ts`.
|
|
43
|
+
*/
|
|
44
|
+
function buildBudgetRefusedTrigger(refusal: {
|
|
45
|
+
cause: "agent" | "global";
|
|
46
|
+
agentSpend?: number;
|
|
47
|
+
agentBudget?: number;
|
|
48
|
+
globalSpend?: number;
|
|
49
|
+
globalBudget?: number;
|
|
50
|
+
resetAt: string;
|
|
51
|
+
}): { type: "budget_refused"; [key: string]: unknown } {
|
|
52
|
+
const trigger: { type: "budget_refused"; [key: string]: unknown } = {
|
|
53
|
+
type: "budget_refused",
|
|
54
|
+
cause: refusal.cause,
|
|
55
|
+
resetAt: refusal.resetAt,
|
|
56
|
+
};
|
|
57
|
+
if (refusal.agentSpend !== undefined) trigger.agentSpend = refusal.agentSpend;
|
|
58
|
+
if (refusal.agentBudget !== undefined) trigger.agentBudget = refusal.agentBudget;
|
|
59
|
+
if (refusal.globalSpend !== undefined) trigger.globalSpend = refusal.globalSpend;
|
|
60
|
+
if (refusal.globalBudget !== undefined) trigger.globalBudget = refusal.globalBudget;
|
|
61
|
+
return trigger;
|
|
62
|
+
}
|
|
63
|
+
|
|
25
64
|
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
26
65
|
|
|
27
66
|
const pollTriggers = route({
|
|
@@ -95,9 +134,19 @@ export async function handlePoll(
|
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
// Use transaction for consistent reads across all trigger checks
|
|
98
|
-
|
|
137
|
+
type PollTxnResult =
|
|
99
138
|
| { error: string; status: number }
|
|
100
|
-
| {
|
|
139
|
+
| {
|
|
140
|
+
trigger: { type: string; [key: string]: unknown } | null;
|
|
141
|
+
/**
|
|
142
|
+
* Phase 5: when the trigger is `budget_refused`, the txn captures
|
|
143
|
+
* the dedup-row state + the refused task's Slack context so the
|
|
144
|
+
* after-commit step can resolve the template and create the lead
|
|
145
|
+
* follow-up. Undefined for any other trigger.
|
|
146
|
+
*/
|
|
147
|
+
refusalSideEffects?: { context: BudgetRefusalContext; inserted: boolean };
|
|
148
|
+
};
|
|
149
|
+
let result: PollTxnResult;
|
|
101
150
|
try {
|
|
102
151
|
result = getDb().transaction(() => {
|
|
103
152
|
const agent = getAgentById(myAgentId);
|
|
@@ -127,6 +176,48 @@ export async function handlePoll(
|
|
|
127
176
|
if (hasCapacity(myAgentId)) {
|
|
128
177
|
const pendingTask = getPendingTaskForAgent(myAgentId);
|
|
129
178
|
if (pendingTask) {
|
|
179
|
+
// Budget admission gate (Phase 3). Runs in the same transaction as
|
|
180
|
+
// the capacity check so capacity AND budget gates share atomicity.
|
|
181
|
+
// Phase 5 also records the dedup row + captures the side-effect
|
|
182
|
+
// context here so the after-commit step can notify the lead.
|
|
183
|
+
const admission = canClaim(myAgentId, new Date());
|
|
184
|
+
if (!admission.allowed) {
|
|
185
|
+
const utcDate = new Date().toISOString().slice(0, 10);
|
|
186
|
+
const dedup = recordBudgetRefusalNotification({
|
|
187
|
+
taskId: pendingTask.id,
|
|
188
|
+
date: utcDate,
|
|
189
|
+
agentId: myAgentId,
|
|
190
|
+
cause: admission.cause,
|
|
191
|
+
agentSpendUsd: admission.agentSpend,
|
|
192
|
+
agentBudgetUsd: admission.agentBudget,
|
|
193
|
+
globalSpendUsd: admission.globalSpend,
|
|
194
|
+
globalBudgetUsd: admission.globalBudget,
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
trigger: buildBudgetRefusedTrigger(admission),
|
|
198
|
+
refusalSideEffects: {
|
|
199
|
+
context: {
|
|
200
|
+
task: {
|
|
201
|
+
id: pendingTask.id,
|
|
202
|
+
task: pendingTask.task,
|
|
203
|
+
slackChannelId: pendingTask.slackChannelId,
|
|
204
|
+
slackThreadTs: pendingTask.slackThreadTs,
|
|
205
|
+
slackUserId: pendingTask.slackUserId,
|
|
206
|
+
},
|
|
207
|
+
agentId: myAgentId,
|
|
208
|
+
date: utcDate,
|
|
209
|
+
cause: admission.cause,
|
|
210
|
+
agentSpendUsd: admission.agentSpend,
|
|
211
|
+
agentBudgetUsd: admission.agentBudget,
|
|
212
|
+
globalSpendUsd: admission.globalSpend,
|
|
213
|
+
globalBudgetUsd: admission.globalBudget,
|
|
214
|
+
resetAt: admission.resetAt,
|
|
215
|
+
},
|
|
216
|
+
inserted: dedup.inserted,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
130
221
|
// Mark task as in_progress immediately to prevent duplicate polling
|
|
131
222
|
startTask(pendingTask.id);
|
|
132
223
|
|
|
@@ -203,6 +294,57 @@ export async function handlePoll(
|
|
|
203
294
|
// from the start (no reassociation needed).
|
|
204
295
|
if (hasCapacity(myAgentId)) {
|
|
205
296
|
const unassignedIds = getUnassignedTaskIds(5);
|
|
297
|
+
// Budget admission gate (Phase 3). Pool path is workers-only —
|
|
298
|
+
// per-agent budgets matter most here, but we still check global.
|
|
299
|
+
// Only run the gate when there's at least one candidate task; an
|
|
300
|
+
// empty pool is "no work", not "refused".
|
|
301
|
+
// Phase 5: dedup row keyed on the FIRST candidate id (the one we
|
|
302
|
+
// would have claimed). That id is stable for the duration of the
|
|
303
|
+
// refusal, and the dedup is per-(task,date) so subsequent same-day
|
|
304
|
+
// refusals on the same lead-candidate are suppressed.
|
|
305
|
+
if (unassignedIds.length > 0) {
|
|
306
|
+
const admission = canClaim(myAgentId, new Date());
|
|
307
|
+
if (!admission.allowed) {
|
|
308
|
+
const candidateId = unassignedIds[0]!;
|
|
309
|
+
const candidateTask = getTaskById(candidateId);
|
|
310
|
+
const utcDate = new Date().toISOString().slice(0, 10);
|
|
311
|
+
const dedup = recordBudgetRefusalNotification({
|
|
312
|
+
taskId: candidateId,
|
|
313
|
+
date: utcDate,
|
|
314
|
+
agentId: myAgentId,
|
|
315
|
+
cause: admission.cause,
|
|
316
|
+
agentSpendUsd: admission.agentSpend,
|
|
317
|
+
agentBudgetUsd: admission.agentBudget,
|
|
318
|
+
globalSpendUsd: admission.globalSpend,
|
|
319
|
+
globalBudgetUsd: admission.globalBudget,
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
trigger: buildBudgetRefusedTrigger(admission),
|
|
323
|
+
refusalSideEffects: candidateTask
|
|
324
|
+
? {
|
|
325
|
+
context: {
|
|
326
|
+
task: {
|
|
327
|
+
id: candidateTask.id,
|
|
328
|
+
task: candidateTask.task,
|
|
329
|
+
slackChannelId: candidateTask.slackChannelId,
|
|
330
|
+
slackThreadTs: candidateTask.slackThreadTs,
|
|
331
|
+
slackUserId: candidateTask.slackUserId,
|
|
332
|
+
},
|
|
333
|
+
agentId: myAgentId,
|
|
334
|
+
date: utcDate,
|
|
335
|
+
cause: admission.cause,
|
|
336
|
+
agentSpendUsd: admission.agentSpend,
|
|
337
|
+
agentBudgetUsd: admission.agentBudget,
|
|
338
|
+
globalSpendUsd: admission.globalSpend,
|
|
339
|
+
globalBudgetUsd: admission.globalBudget,
|
|
340
|
+
resetAt: admission.resetAt,
|
|
341
|
+
},
|
|
342
|
+
inserted: dedup.inserted,
|
|
343
|
+
}
|
|
344
|
+
: undefined,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
206
348
|
for (const candidateId of unassignedIds) {
|
|
207
349
|
const claimed = claimTask(candidateId, myAgentId);
|
|
208
350
|
if (claimed) {
|
|
@@ -243,6 +385,16 @@ export async function handlePoll(
|
|
|
243
385
|
return true;
|
|
244
386
|
}
|
|
245
387
|
|
|
388
|
+
// Phase 5: after the refusal txn commits, run side effects (lead
|
|
389
|
+
// follow-up + workflow event bus). Errors here are logged inside the
|
|
390
|
+
// helper; we never let them affect the response the worker sees.
|
|
391
|
+
if (result.refusalSideEffects) {
|
|
392
|
+
emitBudgetRefusalSideEffects(
|
|
393
|
+
result.refusalSideEffects.context,
|
|
394
|
+
result.refusalSideEffects.inserted,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
246
398
|
// If no trigger found and agent is lead, check for Slack channel activity.
|
|
247
399
|
// This is the lowest-priority trigger, checked AFTER all others.
|
|
248
400
|
// Runs outside the transaction because it requires async Slack API calls.
|
|
@@ -310,7 +462,13 @@ export async function handlePoll(
|
|
|
310
462
|
}
|
|
311
463
|
}
|
|
312
464
|
|
|
313
|
-
|
|
465
|
+
// Strip the internal-only `refusalSideEffects` field from the wire
|
|
466
|
+
// response — workers receive only the public trigger envelope.
|
|
467
|
+
const { refusalSideEffects: _omit, ...publicResult } = result as {
|
|
468
|
+
refusalSideEffects?: unknown;
|
|
469
|
+
[key: string]: unknown;
|
|
470
|
+
};
|
|
471
|
+
json(res, publicResult);
|
|
314
472
|
return true;
|
|
315
473
|
}
|
|
316
474
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Phase 6: REST surface for the append-only `pricing` price book.
|
|
2
|
+
//
|
|
3
|
+
// Append-only by design: operators add a NEW row with a later
|
|
4
|
+
// `effective_from` rather than mutating an existing row. There is no PUT.
|
|
5
|
+
// The only write endpoints are POST (insert) and DELETE (typo correction).
|
|
6
|
+
//
|
|
7
|
+
// Every POST and DELETE writes a row to `agent_log` with eventType
|
|
8
|
+
// `pricing.inserted` / `pricing.deleted` for compliance auditing. The raw
|
|
9
|
+
// API key is never logged — only a short SHA-256 fingerprint.
|
|
10
|
+
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import {
|
|
15
|
+
createLogEntry,
|
|
16
|
+
deletePricingRow,
|
|
17
|
+
getActivePricingRow,
|
|
18
|
+
getAllPricingRows,
|
|
19
|
+
getPricingRows,
|
|
20
|
+
insertPricingRow,
|
|
21
|
+
} from "../be/db";
|
|
22
|
+
import { PricingProviderSchema, PricingRowSchema, PricingTokenClassSchema } from "../types";
|
|
23
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
24
|
+
import { route } from "./route-def";
|
|
25
|
+
import { json, jsonError } from "./utils";
|
|
26
|
+
|
|
27
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function apiKeyFingerprint(req: IncomingMessage): string {
|
|
30
|
+
const authHeader = req.headers.authorization;
|
|
31
|
+
const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
32
|
+
if (!providedKey) return "";
|
|
33
|
+
const digest = createHash("sha256").update(providedKey).digest("hex").slice(0, 8);
|
|
34
|
+
return scrubSecrets(digest);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const PricingTriplePathParams = z.object({
|
|
40
|
+
provider: PricingProviderSchema,
|
|
41
|
+
model: z.string().min(1),
|
|
42
|
+
tokenClass: PricingTokenClassSchema,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const listAllPricing = route({
|
|
46
|
+
method: "get",
|
|
47
|
+
path: "/api/pricing",
|
|
48
|
+
pattern: ["api", "pricing"],
|
|
49
|
+
summary: "List every pricing row across all providers",
|
|
50
|
+
tags: ["Pricing"],
|
|
51
|
+
responses: {
|
|
52
|
+
200: { description: "Pricing rows", schema: z.object({ rows: z.array(PricingRowSchema) }) },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const listPricingForTriple = route({
|
|
57
|
+
method: "get",
|
|
58
|
+
path: "/api/pricing/{provider}/{model}/{tokenClass}",
|
|
59
|
+
pattern: ["api", "pricing", null, null, null],
|
|
60
|
+
summary: "List pricing history for a (provider, model, tokenClass) triple",
|
|
61
|
+
tags: ["Pricing"],
|
|
62
|
+
params: PricingTriplePathParams,
|
|
63
|
+
responses: {
|
|
64
|
+
200: { description: "Pricing rows (latest first)" },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const getActivePricing = route({
|
|
69
|
+
method: "get",
|
|
70
|
+
path: "/api/pricing/{provider}/{model}/{tokenClass}/active",
|
|
71
|
+
pattern: ["api", "pricing", null, null, null, "active"],
|
|
72
|
+
summary: "Get the currently active pricing row",
|
|
73
|
+
tags: ["Pricing"],
|
|
74
|
+
params: PricingTriplePathParams,
|
|
75
|
+
responses: {
|
|
76
|
+
200: { description: "Active pricing row", schema: PricingRowSchema },
|
|
77
|
+
404: { description: "No pricing row in effect" },
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const insertPricing = route({
|
|
82
|
+
method: "post",
|
|
83
|
+
path: "/api/pricing/{provider}/{model}/{tokenClass}",
|
|
84
|
+
pattern: ["api", "pricing", null, null, null],
|
|
85
|
+
summary: "Append a new pricing row",
|
|
86
|
+
tags: ["Pricing"],
|
|
87
|
+
params: PricingTriplePathParams,
|
|
88
|
+
body: z.object({
|
|
89
|
+
pricePerMillionUsd: z.number().nonnegative(),
|
|
90
|
+
effectiveFrom: z.number().nonnegative().optional(),
|
|
91
|
+
}),
|
|
92
|
+
responses: {
|
|
93
|
+
201: { description: "Pricing row inserted", schema: PricingRowSchema },
|
|
94
|
+
400: { description: "Validation error" },
|
|
95
|
+
409: { description: "Duplicate (provider, model, tokenClass, effectiveFrom)" },
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const deletePricing = route({
|
|
100
|
+
method: "delete",
|
|
101
|
+
path: "/api/pricing/{provider}/{model}/{tokenClass}/{effectiveFrom}",
|
|
102
|
+
pattern: ["api", "pricing", null, null, null, null],
|
|
103
|
+
summary: "Delete a pricing row (typo correction)",
|
|
104
|
+
tags: ["Pricing"],
|
|
105
|
+
// `effectiveFrom` is parsed as a numeric string in the path. Using
|
|
106
|
+
// z.string() (instead of z.coerce.number()) keeps the OpenAPI spec valid:
|
|
107
|
+
// `z.coerce.number()` emits a non-required path parameter which trips
|
|
108
|
+
// swagger-cli validation. We re-parse to a number in the handler.
|
|
109
|
+
params: PricingTriplePathParams.extend({
|
|
110
|
+
effectiveFrom: z.string().regex(/^\d+$/, "effectiveFrom must be a non-negative integer"),
|
|
111
|
+
}),
|
|
112
|
+
responses: {
|
|
113
|
+
204: { description: "Pricing row deleted" },
|
|
114
|
+
404: { description: "Pricing row not found" },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export async function handlePricing(
|
|
121
|
+
req: IncomingMessage,
|
|
122
|
+
res: ServerResponse,
|
|
123
|
+
pathSegments: string[],
|
|
124
|
+
queryParams: URLSearchParams,
|
|
125
|
+
_myAgentId: string | undefined,
|
|
126
|
+
): Promise<boolean> {
|
|
127
|
+
// GET /api/pricing
|
|
128
|
+
if (listAllPricing.match(req.method, pathSegments)) {
|
|
129
|
+
const parsed = await listAllPricing.parse(req, res, pathSegments, queryParams);
|
|
130
|
+
if (!parsed) return true;
|
|
131
|
+
json(res, { rows: getAllPricingRows() });
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// GET /api/pricing/{provider}/{model}/{tokenClass}/active — must come BEFORE
|
|
136
|
+
// the 5-segment variants below so the `active` literal is resolved first.
|
|
137
|
+
if (getActivePricing.match(req.method, pathSegments)) {
|
|
138
|
+
const parsed = await getActivePricing.parse(req, res, pathSegments, queryParams);
|
|
139
|
+
if (!parsed) return true;
|
|
140
|
+
const row = getActivePricingRow(
|
|
141
|
+
parsed.params.provider,
|
|
142
|
+
parsed.params.model,
|
|
143
|
+
parsed.params.tokenClass,
|
|
144
|
+
Date.now(),
|
|
145
|
+
);
|
|
146
|
+
if (!row) {
|
|
147
|
+
jsonError(res, "No pricing row in effect", 404);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
json(res, row);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// DELETE /api/pricing/{provider}/{model}/{tokenClass}/{effectiveFrom}
|
|
155
|
+
// (6-segment delete, matched before the 5-segment list/insert)
|
|
156
|
+
if (deletePricing.match(req.method, pathSegments)) {
|
|
157
|
+
const parsed = await deletePricing.parse(req, res, pathSegments, queryParams);
|
|
158
|
+
if (!parsed) return true;
|
|
159
|
+
const effectiveFrom = Number.parseInt(parsed.params.effectiveFrom, 10);
|
|
160
|
+
const deleted = deletePricingRow(
|
|
161
|
+
parsed.params.provider,
|
|
162
|
+
parsed.params.model,
|
|
163
|
+
parsed.params.tokenClass,
|
|
164
|
+
effectiveFrom,
|
|
165
|
+
);
|
|
166
|
+
if (!deleted) {
|
|
167
|
+
jsonError(res, "Pricing row not found", 404);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
createLogEntry({
|
|
172
|
+
eventType: "pricing.deleted",
|
|
173
|
+
metadata: {
|
|
174
|
+
provider: parsed.params.provider,
|
|
175
|
+
model: parsed.params.model,
|
|
176
|
+
tokenClass: parsed.params.tokenClass,
|
|
177
|
+
effectiveFrom,
|
|
178
|
+
apiKeyFingerprint: apiKeyFingerprint(req),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
res.writeHead(204);
|
|
183
|
+
res.end();
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// GET /api/pricing/{provider}/{model}/{tokenClass}
|
|
188
|
+
if (listPricingForTriple.match(req.method, pathSegments)) {
|
|
189
|
+
const parsed = await listPricingForTriple.parse(req, res, pathSegments, queryParams);
|
|
190
|
+
if (!parsed) return true;
|
|
191
|
+
const rows = getPricingRows(
|
|
192
|
+
parsed.params.provider,
|
|
193
|
+
parsed.params.model,
|
|
194
|
+
parsed.params.tokenClass,
|
|
195
|
+
);
|
|
196
|
+
json(res, { rows });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// POST /api/pricing/{provider}/{model}/{tokenClass}
|
|
201
|
+
if (insertPricing.match(req.method, pathSegments)) {
|
|
202
|
+
const parsed = await insertPricing.parse(req, res, pathSegments, queryParams);
|
|
203
|
+
if (!parsed) return true;
|
|
204
|
+
const effectiveFrom = parsed.body.effectiveFrom ?? Date.now();
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const row = insertPricingRow({
|
|
208
|
+
provider: parsed.params.provider,
|
|
209
|
+
model: parsed.params.model,
|
|
210
|
+
tokenClass: parsed.params.tokenClass,
|
|
211
|
+
effectiveFrom,
|
|
212
|
+
pricePerMillionUsd: parsed.body.pricePerMillionUsd,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
createLogEntry({
|
|
216
|
+
eventType: "pricing.inserted",
|
|
217
|
+
metadata: {
|
|
218
|
+
provider: parsed.params.provider,
|
|
219
|
+
model: parsed.params.model,
|
|
220
|
+
tokenClass: parsed.params.tokenClass,
|
|
221
|
+
effectiveFrom,
|
|
222
|
+
pricePerMillionUsd: parsed.body.pricePerMillionUsd,
|
|
223
|
+
apiKeyFingerprint: apiKeyFingerprint(req),
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
json(res, row, 201);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
230
|
+
// bun:sqlite raises SQLITE_CONSTRAINT for PK collision. Surface as 409.
|
|
231
|
+
if (message.includes("UNIQUE constraint") || message.includes("constraint")) {
|
|
232
|
+
jsonError(
|
|
233
|
+
res,
|
|
234
|
+
"Duplicate pricing row for (provider, model, tokenClass, effectiveFrom). Use a different effectiveFrom.",
|
|
235
|
+
409,
|
|
236
|
+
);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
jsonError(res, "Failed to insert pricing row", 500);
|
|
240
|
+
}
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return false;
|
|
245
|
+
}
|