@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.
Files changed (62) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +994 -62
  3. package/package.json +2 -1
  4. package/src/be/budget-admission.ts +121 -0
  5. package/src/be/budget-refusal-notify.ts +145 -0
  6. package/src/be/db.ts +488 -5
  7. package/src/be/migrations/044_provider_meta.sql +2 -0
  8. package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
  9. package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
  10. package/src/cli.tsx +22 -1
  11. package/src/commands/claude-managed-setup.ts +687 -0
  12. package/src/commands/codex-login.ts +1 -1
  13. package/src/commands/runner.ts +175 -28
  14. package/src/commands/templates.ts +10 -6
  15. package/src/http/budgets.ts +219 -0
  16. package/src/http/index.ts +6 -0
  17. package/src/http/integrations.ts +134 -0
  18. package/src/http/poll.ts +161 -3
  19. package/src/http/pricing.ts +245 -0
  20. package/src/http/session-data.ts +54 -6
  21. package/src/http/tasks.ts +23 -2
  22. package/src/prompts/base-prompt.ts +103 -73
  23. package/src/prompts/session-templates.ts +43 -0
  24. package/src/providers/claude-adapter.ts +3 -1
  25. package/src/providers/claude-managed-adapter.ts +871 -0
  26. package/src/providers/claude-managed-models.ts +117 -0
  27. package/src/providers/claude-managed-swarm-events.ts +77 -0
  28. package/src/providers/codex-adapter.ts +3 -1
  29. package/src/providers/codex-skill-resolver.ts +10 -0
  30. package/src/providers/codex-swarm-events.ts +20 -161
  31. package/src/providers/devin-adapter.ts +894 -0
  32. package/src/providers/devin-api.ts +207 -0
  33. package/src/providers/devin-playbooks.ts +91 -0
  34. package/src/providers/devin-skill-resolver.ts +113 -0
  35. package/src/providers/index.ts +10 -1
  36. package/src/providers/pi-mono-adapter.ts +3 -1
  37. package/src/providers/swarm-events-shared.ts +262 -0
  38. package/src/providers/types.ts +26 -1
  39. package/src/tests/base-prompt.test.ts +199 -0
  40. package/src/tests/budget-admission.test.ts +339 -0
  41. package/src/tests/budget-claim-gate.test.ts +288 -0
  42. package/src/tests/budget-refusal-notification.test.ts +324 -0
  43. package/src/tests/budgets-routes.test.ts +331 -0
  44. package/src/tests/claude-managed-adapter.test.ts +1301 -0
  45. package/src/tests/claude-managed-setup.test.ts +325 -0
  46. package/src/tests/devin-adapter.test.ts +677 -0
  47. package/src/tests/devin-api.test.ts +339 -0
  48. package/src/tests/integrations-http.test.ts +211 -0
  49. package/src/tests/migration-046-budgets.test.ts +327 -0
  50. package/src/tests/pricing-routes.test.ts +315 -0
  51. package/src/tests/prompt-template-remaining.test.ts +4 -0
  52. package/src/tests/prompt-template-session.test.ts +2 -2
  53. package/src/tests/provider-adapter.test.ts +1 -1
  54. package/src/tests/runner-budget-refused.test.ts +271 -0
  55. package/src/tests/session-costs-codex-recompute.test.ts +386 -0
  56. package/src/tools/poll-task.ts +13 -2
  57. package/src/tools/task-action.ts +92 -2
  58. package/src/tools/templates.ts +29 -0
  59. package/src/types.ts +116 -0
  60. package/src/utils/budget-backoff.ts +34 -0
  61. package/src/utils/credentials.ts +4 -0
  62. 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
- let result:
137
+ type PollTxnResult =
99
138
  | { error: string; status: number }
100
- | { trigger: { type: string; [key: string]: unknown } | null };
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
- json(res, result);
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
+ }