@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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { closeDb, getDb, initDb } from "../be/db";
|
|
4
|
+
import { CODEX_MODEL_PRICING } from "../providers/codex-models";
|
|
5
|
+
|
|
6
|
+
const TEST_DB_PATH = "./test-migration-046.sqlite";
|
|
7
|
+
|
|
8
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
9
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
10
|
+
try {
|
|
11
|
+
await unlink(path + suffix);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
initDb(TEST_DB_PATH);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterAll(async () => {
|
|
25
|
+
closeDb();
|
|
26
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
interface TableInfoRow {
|
|
30
|
+
name: string;
|
|
31
|
+
type: string;
|
|
32
|
+
notnull: number;
|
|
33
|
+
pk: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface MasterRow {
|
|
37
|
+
sql: string;
|
|
38
|
+
name: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CountRow {
|
|
42
|
+
cnt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PricingRow {
|
|
46
|
+
provider: string;
|
|
47
|
+
model: string;
|
|
48
|
+
token_class: string;
|
|
49
|
+
effective_from: number;
|
|
50
|
+
price_per_million_usd: number;
|
|
51
|
+
createdAt: number;
|
|
52
|
+
lastUpdatedAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe("migration 046 — budgets and pricing", () => {
|
|
56
|
+
test("budgets table exists with expected columns and PK", () => {
|
|
57
|
+
const db = getDb();
|
|
58
|
+
const cols = db.prepare<TableInfoRow, []>("PRAGMA table_info(budgets)").all();
|
|
59
|
+
expect(cols.length).toBeGreaterThan(0);
|
|
60
|
+
|
|
61
|
+
const colMap = new Map(cols.map((c) => [c.name, c]));
|
|
62
|
+
expect(colMap.has("scope")).toBe(true);
|
|
63
|
+
expect(colMap.has("scope_id")).toBe(true);
|
|
64
|
+
expect(colMap.has("daily_budget_usd")).toBe(true);
|
|
65
|
+
expect(colMap.has("createdAt")).toBe(true);
|
|
66
|
+
expect(colMap.has("lastUpdatedAt")).toBe(true);
|
|
67
|
+
|
|
68
|
+
// Composite PK on (scope, scope_id) — both pk fields > 0.
|
|
69
|
+
expect(colMap.get("scope")!.pk).toBeGreaterThan(0);
|
|
70
|
+
expect(colMap.get("scope_id")!.pk).toBeGreaterThan(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("budgets CHECK constraints reject invalid scope and negative budget", () => {
|
|
74
|
+
const db = getDb();
|
|
75
|
+
// Valid global row.
|
|
76
|
+
db.prepare(
|
|
77
|
+
"INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?)",
|
|
78
|
+
).run("global", "", 10.0, 0, 0);
|
|
79
|
+
|
|
80
|
+
// Round-trip
|
|
81
|
+
const row = db
|
|
82
|
+
.prepare<{ scope: string; scope_id: string; daily_budget_usd: number }, []>(
|
|
83
|
+
"SELECT scope, scope_id, daily_budget_usd FROM budgets WHERE scope = 'global'",
|
|
84
|
+
)
|
|
85
|
+
.get();
|
|
86
|
+
expect(row?.scope).toBe("global");
|
|
87
|
+
expect(row?.scope_id).toBe("");
|
|
88
|
+
expect(row?.daily_budget_usd).toBe(10.0);
|
|
89
|
+
|
|
90
|
+
// Inserting another row with same PK fails.
|
|
91
|
+
expect(() =>
|
|
92
|
+
db
|
|
93
|
+
.prepare(
|
|
94
|
+
"INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?)",
|
|
95
|
+
)
|
|
96
|
+
.run("global", "", 5.0, 0, 0),
|
|
97
|
+
).toThrow();
|
|
98
|
+
|
|
99
|
+
// Invalid scope rejected by CHECK.
|
|
100
|
+
expect(() =>
|
|
101
|
+
db
|
|
102
|
+
.prepare(
|
|
103
|
+
"INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?)",
|
|
104
|
+
)
|
|
105
|
+
.run("not-a-scope", "x", 1.0, 0, 0),
|
|
106
|
+
).toThrow();
|
|
107
|
+
|
|
108
|
+
// Negative budget rejected by CHECK.
|
|
109
|
+
expect(() =>
|
|
110
|
+
db
|
|
111
|
+
.prepare(
|
|
112
|
+
"INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?)",
|
|
113
|
+
)
|
|
114
|
+
.run("agent", "agent-x", -1, 0, 0),
|
|
115
|
+
).toThrow();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("pricing table exists with expected columns and composite PK", () => {
|
|
119
|
+
const db = getDb();
|
|
120
|
+
const cols = db.prepare<TableInfoRow, []>("PRAGMA table_info(pricing)").all();
|
|
121
|
+
expect(cols.length).toBeGreaterThan(0);
|
|
122
|
+
|
|
123
|
+
const colMap = new Map(cols.map((c) => [c.name, c]));
|
|
124
|
+
expect(colMap.has("provider")).toBe(true);
|
|
125
|
+
expect(colMap.has("model")).toBe(true);
|
|
126
|
+
expect(colMap.has("token_class")).toBe(true);
|
|
127
|
+
expect(colMap.has("effective_from")).toBe(true);
|
|
128
|
+
expect(colMap.has("price_per_million_usd")).toBe(true);
|
|
129
|
+
|
|
130
|
+
// All four PK columns participate in the composite PK.
|
|
131
|
+
expect(colMap.get("provider")!.pk).toBeGreaterThan(0);
|
|
132
|
+
expect(colMap.get("model")!.pk).toBeGreaterThan(0);
|
|
133
|
+
expect(colMap.get("token_class")!.pk).toBeGreaterThan(0);
|
|
134
|
+
expect(colMap.get("effective_from")!.pk).toBeGreaterThan(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("pricing seed has exactly 12 rows (4 models × 3 token_classes), all at effective_from=0", () => {
|
|
138
|
+
const db = getDb();
|
|
139
|
+
const total = db.prepare<CountRow, []>("SELECT COUNT(*) as cnt FROM pricing").get();
|
|
140
|
+
expect(total?.cnt).toBe(12);
|
|
141
|
+
|
|
142
|
+
const seedRows = db
|
|
143
|
+
.prepare<CountRow, []>("SELECT COUNT(*) as cnt FROM pricing WHERE effective_from = 0")
|
|
144
|
+
.get();
|
|
145
|
+
expect(seedRows?.cnt).toBe(12);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("every CODEX_MODEL_PRICING entry has rows for input / cached_input / output with matching rates", () => {
|
|
149
|
+
const db = getDb();
|
|
150
|
+
|
|
151
|
+
for (const [model, pricing] of Object.entries(CODEX_MODEL_PRICING)) {
|
|
152
|
+
const inputRow = db
|
|
153
|
+
.prepare<PricingRow, [string, string, number]>(
|
|
154
|
+
"SELECT * FROM pricing WHERE provider = 'codex' AND model = ? AND token_class = ? AND effective_from = ?",
|
|
155
|
+
)
|
|
156
|
+
.get(model, "input", 0);
|
|
157
|
+
expect(inputRow?.price_per_million_usd).toBe(pricing.inputPerMillion);
|
|
158
|
+
|
|
159
|
+
const cachedRow = db
|
|
160
|
+
.prepare<PricingRow, [string, string, number]>(
|
|
161
|
+
"SELECT * FROM pricing WHERE provider = 'codex' AND model = ? AND token_class = ? AND effective_from = ?",
|
|
162
|
+
)
|
|
163
|
+
.get(model, "cached_input", 0);
|
|
164
|
+
expect(cachedRow?.price_per_million_usd).toBe(pricing.cachedInputPerMillion);
|
|
165
|
+
|
|
166
|
+
const outputRow = db
|
|
167
|
+
.prepare<PricingRow, [string, string, number]>(
|
|
168
|
+
"SELECT * FROM pricing WHERE provider = 'codex' AND model = ? AND token_class = ? AND effective_from = ?",
|
|
169
|
+
)
|
|
170
|
+
.get(model, "output", 0);
|
|
171
|
+
expect(outputRow?.price_per_million_usd).toBe(pricing.outputPerMillion);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("idx_pricing_lookup index exists", () => {
|
|
176
|
+
const db = getDb();
|
|
177
|
+
const idx = db
|
|
178
|
+
.prepare<MasterRow, []>(
|
|
179
|
+
"SELECT name, sql FROM sqlite_master WHERE type='index' AND name='idx_pricing_lookup'",
|
|
180
|
+
)
|
|
181
|
+
.get();
|
|
182
|
+
expect(idx?.name).toBe("idx_pricing_lookup");
|
|
183
|
+
expect(idx?.sql).toContain("provider");
|
|
184
|
+
expect(idx?.sql).toContain("model");
|
|
185
|
+
expect(idx?.sql).toContain("token_class");
|
|
186
|
+
expect(idx?.sql).toContain("effective_from");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("re-applying seed INSERT OR IGNORE does not duplicate rows", () => {
|
|
190
|
+
const db = getDb();
|
|
191
|
+
const before = db.prepare<CountRow, []>("SELECT COUNT(*) as cnt FROM pricing").get();
|
|
192
|
+
|
|
193
|
+
// Replay the same seed statements.
|
|
194
|
+
db.prepare(
|
|
195
|
+
`INSERT OR IGNORE INTO pricing (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
|
|
196
|
+
VALUES ('codex', 'gpt-5.4', 'input', 0, 2.5, 0, 0)`,
|
|
197
|
+
).run();
|
|
198
|
+
db.prepare(
|
|
199
|
+
`INSERT OR IGNORE INTO pricing (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
|
|
200
|
+
VALUES ('codex', 'gpt-5.3-codex', 'output', 0, 14.0, 0, 0)`,
|
|
201
|
+
).run();
|
|
202
|
+
|
|
203
|
+
const after = db.prepare<CountRow, []>("SELECT COUNT(*) as cnt FROM pricing").get();
|
|
204
|
+
expect(after?.cnt).toBe(before?.cnt);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("append-only price history: new effective_from row coexists with seed; latest-active lookup picks correct row", () => {
|
|
208
|
+
const db = getDb();
|
|
209
|
+
const NOW = 1_700_000_000_000; // arbitrary epoch ms in the future relative to 0
|
|
210
|
+
|
|
211
|
+
// Add a NEW pricing row for codex/gpt-5.3-codex/input at a later effective_from with a different price.
|
|
212
|
+
db.prepare(
|
|
213
|
+
`INSERT INTO pricing (provider, model, token_class, effective_from, price_per_million_usd, createdAt, lastUpdatedAt)
|
|
214
|
+
VALUES ('codex', 'gpt-5.3-codex', 'input', ?, ?, ?, ?)`,
|
|
215
|
+
).run(NOW, 99.99, NOW, NOW);
|
|
216
|
+
|
|
217
|
+
// Seed row should still exist at effective_from = 0.
|
|
218
|
+
const seedRow = db
|
|
219
|
+
.prepare<PricingRow, []>(
|
|
220
|
+
"SELECT * FROM pricing WHERE provider='codex' AND model='gpt-5.3-codex' AND token_class='input' AND effective_from=0",
|
|
221
|
+
)
|
|
222
|
+
.get();
|
|
223
|
+
expect(seedRow?.price_per_million_usd).toBe(1.75);
|
|
224
|
+
|
|
225
|
+
// "Largest effective_from <= now" — should return the new row.
|
|
226
|
+
const latestRow = db
|
|
227
|
+
.prepare<PricingRow, [number]>(
|
|
228
|
+
`SELECT * FROM pricing
|
|
229
|
+
WHERE provider='codex' AND model='gpt-5.3-codex' AND token_class='input'
|
|
230
|
+
AND effective_from <= ?
|
|
231
|
+
ORDER BY effective_from DESC LIMIT 1`,
|
|
232
|
+
)
|
|
233
|
+
.get(NOW + 1);
|
|
234
|
+
expect(latestRow?.effective_from).toBe(NOW);
|
|
235
|
+
expect(latestRow?.price_per_million_usd).toBe(99.99);
|
|
236
|
+
|
|
237
|
+
// Same query against effective_from <= 0 should return the seed row.
|
|
238
|
+
const seedLookup = db
|
|
239
|
+
.prepare<PricingRow, [number]>(
|
|
240
|
+
`SELECT * FROM pricing
|
|
241
|
+
WHERE provider='codex' AND model='gpt-5.3-codex' AND token_class='input'
|
|
242
|
+
AND effective_from <= ?
|
|
243
|
+
ORDER BY effective_from DESC LIMIT 1`,
|
|
244
|
+
)
|
|
245
|
+
.get(0);
|
|
246
|
+
expect(seedLookup?.effective_from).toBe(0);
|
|
247
|
+
expect(seedLookup?.price_per_million_usd).toBe(1.75);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("budget_refusal_notifications table exists with expected columns and composite PK", () => {
|
|
251
|
+
const db = getDb();
|
|
252
|
+
const cols = db
|
|
253
|
+
.prepare<TableInfoRow, []>("PRAGMA table_info(budget_refusal_notifications)")
|
|
254
|
+
.all();
|
|
255
|
+
expect(cols.length).toBeGreaterThan(0);
|
|
256
|
+
|
|
257
|
+
const colMap = new Map(cols.map((c) => [c.name, c]));
|
|
258
|
+
expect(colMap.has("task_id")).toBe(true);
|
|
259
|
+
expect(colMap.has("date")).toBe(true);
|
|
260
|
+
expect(colMap.has("agent_id")).toBe(true);
|
|
261
|
+
expect(colMap.has("cause")).toBe(true);
|
|
262
|
+
expect(colMap.has("agent_spend_usd")).toBe(true);
|
|
263
|
+
expect(colMap.has("agent_budget_usd")).toBe(true);
|
|
264
|
+
expect(colMap.has("global_spend_usd")).toBe(true);
|
|
265
|
+
expect(colMap.has("global_budget_usd")).toBe(true);
|
|
266
|
+
expect(colMap.has("follow_up_task_id")).toBe(true);
|
|
267
|
+
expect(colMap.has("createdAt")).toBe(true);
|
|
268
|
+
|
|
269
|
+
// Composite PK on (task_id, date).
|
|
270
|
+
expect(colMap.get("task_id")!.pk).toBeGreaterThan(0);
|
|
271
|
+
expect(colMap.get("date")!.pk).toBeGreaterThan(0);
|
|
272
|
+
|
|
273
|
+
// Optional spend/budget fields are NULL-able.
|
|
274
|
+
expect(colMap.get("agent_spend_usd")!.notnull).toBe(0);
|
|
275
|
+
expect(colMap.get("global_budget_usd")!.notnull).toBe(0);
|
|
276
|
+
expect(colMap.get("follow_up_task_id")!.notnull).toBe(0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("budget_refusal_notifications dedup via INSERT OR IGNORE on (task_id, date)", () => {
|
|
280
|
+
const db = getDb();
|
|
281
|
+
|
|
282
|
+
const taskId = "task-dedup-1";
|
|
283
|
+
const date = "2026-04-28";
|
|
284
|
+
|
|
285
|
+
const first = db
|
|
286
|
+
.prepare(
|
|
287
|
+
`INSERT OR IGNORE INTO budget_refusal_notifications
|
|
288
|
+
(task_id, date, agent_id, cause, createdAt)
|
|
289
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
290
|
+
)
|
|
291
|
+
.run(taskId, date, "agent-1", "agent", 0);
|
|
292
|
+
expect(first.changes).toBe(1);
|
|
293
|
+
|
|
294
|
+
// Second insert with same PK is silently ignored.
|
|
295
|
+
const second = db
|
|
296
|
+
.prepare(
|
|
297
|
+
`INSERT OR IGNORE INTO budget_refusal_notifications
|
|
298
|
+
(task_id, date, agent_id, cause, createdAt)
|
|
299
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
300
|
+
)
|
|
301
|
+
.run(taskId, date, "agent-1", "agent", 1);
|
|
302
|
+
expect(second.changes).toBe(0);
|
|
303
|
+
|
|
304
|
+
// Different date succeeds (PK rolls over).
|
|
305
|
+
const nextDay = db
|
|
306
|
+
.prepare(
|
|
307
|
+
`INSERT OR IGNORE INTO budget_refusal_notifications
|
|
308
|
+
(task_id, date, agent_id, cause, createdAt)
|
|
309
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
310
|
+
)
|
|
311
|
+
.run(taskId, "2026-04-29", "agent-1", "agent", 2);
|
|
312
|
+
expect(nextDay.changes).toBe(1);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("budget_refusal_notifications CHECK rejects unknown cause", () => {
|
|
316
|
+
const db = getDb();
|
|
317
|
+
expect(() =>
|
|
318
|
+
db
|
|
319
|
+
.prepare(
|
|
320
|
+
`INSERT INTO budget_refusal_notifications
|
|
321
|
+
(task_id, date, agent_id, cause, createdAt)
|
|
322
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
323
|
+
)
|
|
324
|
+
.run("task-cause-1", "2026-04-28", "agent-1", "not-a-cause", 0),
|
|
325
|
+
).toThrow();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Phase 6: REST CRUD + audit-log + auth tests for /api/pricing/*.
|
|
2
|
+
//
|
|
3
|
+
// The pricing surface is append-only — operators add a new row with a later
|
|
4
|
+
// `effective_from` rather than mutating an existing one. `POST` collisions
|
|
5
|
+
// on the same `(provider, model, token_class, effective_from)` PK return 409.
|
|
6
|
+
|
|
7
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { unlink } from "node:fs/promises";
|
|
9
|
+
import {
|
|
10
|
+
createServer as createHttpServer,
|
|
11
|
+
type IncomingMessage,
|
|
12
|
+
type Server,
|
|
13
|
+
type ServerResponse,
|
|
14
|
+
} from "node:http";
|
|
15
|
+
import { closeDb, getDb, getLogsByEventType, initDb } from "../be/db";
|
|
16
|
+
import { handleCore } from "../http/core";
|
|
17
|
+
import { handlePricing } from "../http/pricing";
|
|
18
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
19
|
+
|
|
20
|
+
const TEST_DB_PATH = "./test-pricing-routes.sqlite";
|
|
21
|
+
const API_KEY = "test-pricing-secret-key";
|
|
22
|
+
|
|
23
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
24
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
25
|
+
try {
|
|
26
|
+
await unlink(path + suffix);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function listen(server: Server): Promise<number> {
|
|
34
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
35
|
+
const addr = server.address();
|
|
36
|
+
if (!addr || typeof addr === "string") throw new Error("no port");
|
|
37
|
+
return addr.port;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createTestServer(apiKey: string): Server {
|
|
41
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
42
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
43
|
+
const handled = await handleCore(req, res, myAgentId, apiKey);
|
|
44
|
+
if (handled) return;
|
|
45
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
46
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
47
|
+
const ok = await handlePricing(req, res, pathSegments, queryParams, myAgentId);
|
|
48
|
+
if (!ok) {
|
|
49
|
+
res.writeHead(404);
|
|
50
|
+
res.end("Not Found");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let server: Server;
|
|
56
|
+
let port: number;
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
60
|
+
initDb(TEST_DB_PATH);
|
|
61
|
+
server = createTestServer(API_KEY);
|
|
62
|
+
port = await listen(server);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
67
|
+
closeDb();
|
|
68
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
// Remove every non-seed pricing row so each test starts from the migration
|
|
74
|
+
// 044 seed (effective_from=0). The seed uses literal 0 for effective_from.
|
|
75
|
+
db.prepare("DELETE FROM pricing WHERE effective_from > 0").run();
|
|
76
|
+
db.prepare("DELETE FROM agent_log WHERE eventType LIKE 'pricing.%'").run();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
80
|
+
return fetch(`http://localhost:${port}${path}`, {
|
|
81
|
+
...init,
|
|
82
|
+
headers: {
|
|
83
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
...(init.headers ?? {}),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("Phase 6 — /api/pricing REST surface", () => {
|
|
91
|
+
describe("auth", () => {
|
|
92
|
+
test("401 when Authorization header is missing", async () => {
|
|
93
|
+
const res = await fetch(`http://localhost:${port}/api/pricing`);
|
|
94
|
+
expect(res.status).toBe(401);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("401 when bearer is wrong", async () => {
|
|
98
|
+
const res = await fetch(`http://localhost:${port}/api/pricing`, {
|
|
99
|
+
headers: { Authorization: "Bearer WRONG" },
|
|
100
|
+
});
|
|
101
|
+
expect(res.status).toBe(401);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("read endpoints", () => {
|
|
106
|
+
test("GET /api/pricing lists every row including the migration 044 seed", async () => {
|
|
107
|
+
const res = await authedFetch(`/api/pricing`);
|
|
108
|
+
expect(res.status).toBe(200);
|
|
109
|
+
const body = await res.json();
|
|
110
|
+
expect(body.rows).toBeInstanceOf(Array);
|
|
111
|
+
// Migration 044 seeds 12 codex rows with effective_from=0. They should
|
|
112
|
+
// all be present here.
|
|
113
|
+
const seedRows = body.rows.filter(
|
|
114
|
+
(r: { provider: string; effectiveFrom: number }) =>
|
|
115
|
+
r.provider === "codex" && r.effectiveFrom === 0,
|
|
116
|
+
);
|
|
117
|
+
expect(seedRows.length).toBe(12);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("GET /api/pricing/{provider}/{model}/{tokenClass} returns rows latest-first", async () => {
|
|
121
|
+
// Insert two new rows on top of the existing seed for gpt-5.3-codex/input.
|
|
122
|
+
await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
body: JSON.stringify({ pricePerMillionUsd: 2.0, effectiveFrom: 1_000 }),
|
|
125
|
+
});
|
|
126
|
+
await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
body: JSON.stringify({ pricePerMillionUsd: 2.5, effectiveFrom: 5_000 }),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const res = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`);
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
const body = await res.json();
|
|
134
|
+
expect(body.rows.length).toBe(3);
|
|
135
|
+
// Newest first.
|
|
136
|
+
expect(body.rows[0].effectiveFrom).toBe(5_000);
|
|
137
|
+
expect(body.rows[1].effectiveFrom).toBe(1_000);
|
|
138
|
+
expect(body.rows[2].effectiveFrom).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("GET /api/pricing/.../{tokenClass} returns empty list (NOT 404) for unseeded triple", async () => {
|
|
142
|
+
const res = await authedFetch(`/api/pricing/claude/sonnet-4/input`);
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
const body = await res.json();
|
|
145
|
+
expect(body.rows).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("GET /api/pricing/.../active returns the largest effective_from <= now", async () => {
|
|
149
|
+
const past = Date.now() - 10_000;
|
|
150
|
+
await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
body: JSON.stringify({ pricePerMillionUsd: 99.0, effectiveFrom: past }),
|
|
153
|
+
});
|
|
154
|
+
const res = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input/active`);
|
|
155
|
+
expect(res.status).toBe(200);
|
|
156
|
+
const row = await res.json();
|
|
157
|
+
expect(row.effectiveFrom).toBe(past);
|
|
158
|
+
expect(row.pricePerMillionUsd).toBe(99.0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("GET /api/pricing/.../active returns 404 for unseeded triple with no rows", async () => {
|
|
162
|
+
const res = await authedFetch(`/api/pricing/claude/sonnet-4/input/active`);
|
|
163
|
+
expect(res.status).toBe(404);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("write endpoints", () => {
|
|
168
|
+
test("POST inserts a new row, returns 201, body matches", async () => {
|
|
169
|
+
const res = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
body: JSON.stringify({ pricePerMillionUsd: 1.5, effectiveFrom: 12_345 }),
|
|
172
|
+
});
|
|
173
|
+
expect(res.status).toBe(201);
|
|
174
|
+
const row = await res.json();
|
|
175
|
+
expect(row.provider).toBe("codex");
|
|
176
|
+
expect(row.model).toBe("gpt-5.3-codex");
|
|
177
|
+
expect(row.tokenClass).toBe("input");
|
|
178
|
+
expect(row.effectiveFrom).toBe(12_345);
|
|
179
|
+
expect(row.pricePerMillionUsd).toBe(1.5);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("POST defaults effectiveFrom to Date.now() when omitted", async () => {
|
|
183
|
+
const before = Date.now();
|
|
184
|
+
const res = await authedFetch(`/api/pricing/codex/gpt-5.4/input`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
body: JSON.stringify({ pricePerMillionUsd: 3.0 }),
|
|
187
|
+
});
|
|
188
|
+
const after = Date.now();
|
|
189
|
+
expect(res.status).toBe(201);
|
|
190
|
+
const row = await res.json();
|
|
191
|
+
expect(row.effectiveFrom).toBeGreaterThanOrEqual(before);
|
|
192
|
+
expect(row.effectiveFrom).toBeLessThanOrEqual(after);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("POST 409 on duplicate (provider, model, tokenClass, effectiveFrom)", async () => {
|
|
196
|
+
await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: JSON.stringify({ pricePerMillionUsd: 1.5, effectiveFrom: 99_999 }),
|
|
199
|
+
});
|
|
200
|
+
const res = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
body: JSON.stringify({ pricePerMillionUsd: 2.0, effectiveFrom: 99_999 }),
|
|
203
|
+
});
|
|
204
|
+
expect(res.status).toBe(409);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("same-millisecond collision: two rapid POSTs without explicit effectiveFrom may 409; explicit unblocks", async () => {
|
|
208
|
+
// Run two POSTs back-to-back. They MIGHT land on the same Date.now() millisecond.
|
|
209
|
+
// If they do, the second returns 409. If they don't, both succeed.
|
|
210
|
+
const r1 = await authedFetch(`/api/pricing/codex/gpt-5.4-mini/input`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
body: JSON.stringify({ pricePerMillionUsd: 0.5 }),
|
|
213
|
+
});
|
|
214
|
+
const r2 = await authedFetch(`/api/pricing/codex/gpt-5.4-mini/input`, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
body: JSON.stringify({ pricePerMillionUsd: 0.6 }),
|
|
217
|
+
});
|
|
218
|
+
// At least the first must succeed.
|
|
219
|
+
expect(r1.status).toBe(201);
|
|
220
|
+
expect([201, 409]).toContain(r2.status);
|
|
221
|
+
|
|
222
|
+
// Workaround: pass an explicit effectiveFrom that we KNOW is unique.
|
|
223
|
+
// It still must not collide with r1's effective_from. Use a future ms.
|
|
224
|
+
const futureMs = Date.now() + 10_000;
|
|
225
|
+
const r3 = await authedFetch(`/api/pricing/codex/gpt-5.4-mini/input`, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
body: JSON.stringify({ pricePerMillionUsd: 0.7, effectiveFrom: futureMs }),
|
|
228
|
+
});
|
|
229
|
+
expect(r3.status).toBe(201);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("POST 400 on invalid body (negative price)", async () => {
|
|
233
|
+
const res = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
234
|
+
method: "POST",
|
|
235
|
+
body: JSON.stringify({ pricePerMillionUsd: -1 }),
|
|
236
|
+
});
|
|
237
|
+
expect(res.status).toBe(400);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("POST 400 on invalid provider", async () => {
|
|
241
|
+
const res = await authedFetch(`/api/pricing/totally-fake/x/input`, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
body: JSON.stringify({ pricePerMillionUsd: 1 }),
|
|
244
|
+
});
|
|
245
|
+
expect(res.status).toBe(400);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("POST 400 on invalid token class", async () => {
|
|
249
|
+
const res = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/wrong-class`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
body: JSON.stringify({ pricePerMillionUsd: 1 }),
|
|
252
|
+
});
|
|
253
|
+
expect(res.status).toBe(400);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("DELETE removes a row, returns 204, GET reflects removal", async () => {
|
|
257
|
+
await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
body: JSON.stringify({ pricePerMillionUsd: 1.5, effectiveFrom: 42 }),
|
|
260
|
+
});
|
|
261
|
+
const delRes = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input/42`, {
|
|
262
|
+
method: "DELETE",
|
|
263
|
+
});
|
|
264
|
+
expect(delRes.status).toBe(204);
|
|
265
|
+
|
|
266
|
+
const listRes = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`);
|
|
267
|
+
const body = await listRes.json();
|
|
268
|
+
const found = body.rows.find((r: { effectiveFrom: number }) => r.effectiveFrom === 42);
|
|
269
|
+
expect(found).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("DELETE 404 when row does not exist", async () => {
|
|
273
|
+
const res = await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input/123456789`, {
|
|
274
|
+
method: "DELETE",
|
|
275
|
+
});
|
|
276
|
+
expect(res.status).toBe(404);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("audit logging", () => {
|
|
281
|
+
test("POST writes a pricing.inserted log row with key fingerprint", async () => {
|
|
282
|
+
await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
283
|
+
method: "POST",
|
|
284
|
+
body: JSON.stringify({ pricePerMillionUsd: 1.5, effectiveFrom: 100 }),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const logs = getLogsByEventType("pricing.inserted");
|
|
288
|
+
expect(logs.length).toBe(1);
|
|
289
|
+
const meta = JSON.parse(logs[0].metadata!);
|
|
290
|
+
expect(meta.provider).toBe("codex");
|
|
291
|
+
expect(meta.model).toBe("gpt-5.3-codex");
|
|
292
|
+
expect(meta.tokenClass).toBe("input");
|
|
293
|
+
expect(meta.effectiveFrom).toBe(100);
|
|
294
|
+
expect(meta.pricePerMillionUsd).toBe(1.5);
|
|
295
|
+
expect(meta.apiKeyFingerprint).toMatch(/^[a-f0-9]{8}$/);
|
|
296
|
+
expect(logs[0].metadata).not.toContain(API_KEY);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("DELETE writes a pricing.deleted log row with key fingerprint", async () => {
|
|
300
|
+
await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input`, {
|
|
301
|
+
method: "POST",
|
|
302
|
+
body: JSON.stringify({ pricePerMillionUsd: 1.5, effectiveFrom: 200 }),
|
|
303
|
+
});
|
|
304
|
+
await authedFetch(`/api/pricing/codex/gpt-5.3-codex/input/200`, { method: "DELETE" });
|
|
305
|
+
|
|
306
|
+
const logs = getLogsByEventType("pricing.deleted");
|
|
307
|
+
expect(logs.length).toBe(1);
|
|
308
|
+
const meta = JSON.parse(logs[0].metadata!);
|
|
309
|
+
expect(meta.provider).toBe("codex");
|
|
310
|
+
expect(meta.effectiveFrom).toBe(200);
|
|
311
|
+
expect(meta.apiKeyFingerprint).toMatch(/^[a-f0-9]{8}$/);
|
|
312
|
+
expect(logs[0].metadata).not.toContain(API_KEY);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -433,6 +433,8 @@ Note: Claims are first-come-first-serve. If claim fails, pick another.`;
|
|
|
433
433
|
task_id: "task-xyz",
|
|
434
434
|
task_description: "Fix the test suite",
|
|
435
435
|
progress: "Fixed 3 of 5 failing tests",
|
|
436
|
+
completion_instructions:
|
|
437
|
+
'\n\nWhen done, use `store-progress` with status: "completed" and include your output.',
|
|
436
438
|
});
|
|
437
439
|
|
|
438
440
|
expect(result.skipped).toBe(false);
|
|
@@ -458,6 +460,8 @@ When done, use \`store-progress\` with status: "completed" and include your outp
|
|
|
458
460
|
work_on_task_cmd: "/work-on-task",
|
|
459
461
|
task_id: "task-xyz",
|
|
460
462
|
task_description: "Fix the test suite",
|
|
463
|
+
completion_instructions:
|
|
464
|
+
'\n\nWhen done, use `store-progress` with status: "completed" and include your output.',
|
|
461
465
|
});
|
|
462
466
|
|
|
463
467
|
expect(result.skipped).toBe(false);
|
|
@@ -88,10 +88,10 @@ describe("Session templates — registration", () => {
|
|
|
88
88
|
}
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
test("total of
|
|
91
|
+
test("total of 17 session/system templates registered", () => {
|
|
92
92
|
const all = getAllTemplateDefinitions();
|
|
93
93
|
const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
|
|
94
|
-
expect(sessionSystem.length).toBe(
|
|
94
|
+
expect(sessionSystem.length).toBe(17);
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
@@ -19,7 +19,7 @@ describe("createProviderAdapter", () => {
|
|
|
19
19
|
|
|
20
20
|
test("throws for unknown provider", () => {
|
|
21
21
|
expect(() => createProviderAdapter("unknown")).toThrow(
|
|
22
|
-
'Unknown HARNESS_PROVIDER: "unknown". Supported: claude, pi, codex',
|
|
22
|
+
'Unknown HARNESS_PROVIDER: "unknown". Supported: claude, pi, codex, devin, claude-managed',
|
|
23
23
|
);
|
|
24
24
|
});
|
|
25
25
|
|