@antseed/cli 0.1.195 → 0.1.197

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 (117) hide show
  1. package/dist/cli/commands/buyer/start.d.ts.map +1 -1
  2. package/dist/cli/commands/buyer/start.js +1 -0
  3. package/dist/cli/commands/buyer/start.js.map +1 -1
  4. package/dist/cli/commands/gateway/activity.d.ts.map +1 -1
  5. package/dist/cli/commands/gateway/activity.js +26 -5
  6. package/dist/cli/commands/gateway/activity.js.map +1 -1
  7. package/dist/cli/commands/seller/start.d.ts.map +1 -1
  8. package/dist/cli/commands/seller/start.js +5 -3
  9. package/dist/cli/commands/seller/start.js.map +1 -1
  10. package/dist/config/types.d.ts +2 -0
  11. package/dist/config/types.d.ts.map +1 -1
  12. package/dist/gateway/accounting.d.ts +118 -6
  13. package/dist/gateway/accounting.d.ts.map +1 -1
  14. package/dist/gateway/accounting.js +319 -21
  15. package/dist/gateway/accounting.js.map +1 -1
  16. package/dist/gateway/console-api/handlers/favorites.d.ts +9 -0
  17. package/dist/gateway/console-api/handlers/favorites.d.ts.map +1 -0
  18. package/dist/gateway/console-api/handlers/favorites.js +44 -0
  19. package/dist/gateway/console-api/handlers/favorites.js.map +1 -0
  20. package/dist/gateway/console-api/handlers/favorites.test.d.ts +2 -0
  21. package/dist/gateway/console-api/handlers/favorites.test.d.ts.map +1 -0
  22. package/dist/gateway/console-api/handlers/favorites.test.js +41 -0
  23. package/dist/gateway/console-api/handlers/favorites.test.js.map +1 -0
  24. package/dist/gateway/console-api/handlers/index.d.ts.map +1 -1
  25. package/dist/gateway/console-api/handlers/index.js +2 -0
  26. package/dist/gateway/console-api/handlers/index.js.map +1 -1
  27. package/dist/gateway/console-api/handlers/network-mapping.d.ts +7 -1
  28. package/dist/gateway/console-api/handlers/network-mapping.d.ts.map +1 -1
  29. package/dist/gateway/console-api/handlers/network-mapping.js +44 -0
  30. package/dist/gateway/console-api/handlers/network-mapping.js.map +1 -1
  31. package/dist/gateway/console-api/handlers/network.d.ts.map +1 -1
  32. package/dist/gateway/console-api/handlers/network.js +3 -1
  33. package/dist/gateway/console-api/handlers/network.js.map +1 -1
  34. package/dist/gateway/console-api/handlers/network.test.js +39 -4
  35. package/dist/gateway/console-api/handlers/network.test.js.map +1 -1
  36. package/dist/gateway/console-api/handlers/usage.d.ts.map +1 -1
  37. package/dist/gateway/console-api/handlers/usage.js +2 -1
  38. package/dist/gateway/console-api/handlers/usage.js.map +1 -1
  39. package/dist/gateway/console-api/types.d.ts +69 -1
  40. package/dist/gateway/console-api/types.d.ts.map +1 -1
  41. package/dist/gateway/console-api/types.js.map +1 -1
  42. package/dist/gateway/gateway.test.js +829 -3
  43. package/dist/gateway/gateway.test.js.map +1 -1
  44. package/dist/gateway/request-errors.test.js +30 -0
  45. package/dist/gateway/request-errors.test.js.map +1 -1
  46. package/dist/gateway/routing-through-gateway.test.js +86 -4
  47. package/dist/gateway/routing-through-gateway.test.js.map +1 -1
  48. package/dist/gateway/runtime.d.ts.map +1 -1
  49. package/dist/gateway/runtime.js +4 -0
  50. package/dist/gateway/runtime.js.map +1 -1
  51. package/dist/gateway/server.d.ts +20 -0
  52. package/dist/gateway/server.d.ts.map +1 -1
  53. package/dist/gateway/server.js +178 -25
  54. package/dist/gateway/server.js.map +1 -1
  55. package/dist/gateway/services/network.d.ts +6 -1
  56. package/dist/gateway/services/network.d.ts.map +1 -1
  57. package/dist/gateway/services/network.js +12 -1
  58. package/dist/gateway/services/network.js.map +1 -1
  59. package/dist/gateway/services/usage.d.ts +2 -0
  60. package/dist/gateway/services/usage.d.ts.map +1 -1
  61. package/dist/gateway/services/usage.js +18 -4
  62. package/dist/gateway/services/usage.js.map +1 -1
  63. package/dist/gateway/spend-feed.d.ts +4 -1
  64. package/dist/gateway/spend-feed.d.ts.map +1 -1
  65. package/dist/gateway/spend-feed.js +7 -0
  66. package/dist/gateway/spend-feed.js.map +1 -1
  67. package/dist/gateway/store-data.test.js +1 -1
  68. package/dist/gateway/store-data.test.js.map +1 -1
  69. package/dist/gateway/store-migrations.d.ts.map +1 -1
  70. package/dist/gateway/store-migrations.js +35 -0
  71. package/dist/gateway/store-migrations.js.map +1 -1
  72. package/dist/gateway/store.d.ts +59 -1
  73. package/dist/gateway/store.d.ts.map +1 -1
  74. package/dist/gateway/store.js +124 -19
  75. package/dist/gateway/store.js.map +1 -1
  76. package/dist/gateway/video-jobs.d.ts +12 -0
  77. package/dist/gateway/video-jobs.d.ts.map +1 -0
  78. package/dist/gateway/video-jobs.js +24 -0
  79. package/dist/gateway/video-jobs.js.map +1 -0
  80. package/dist/proxy/buyer-proxy.d.ts +15 -0
  81. package/dist/proxy/buyer-proxy.d.ts.map +1 -1
  82. package/dist/proxy/buyer-proxy.js +111 -13
  83. package/dist/proxy/buyer-proxy.js.map +1 -1
  84. package/dist/proxy/buyer-proxy.test.js +174 -1
  85. package/dist/proxy/buyer-proxy.test.js.map +1 -1
  86. package/dist/proxy/request-utils.d.ts +4 -0
  87. package/dist/proxy/request-utils.d.ts.map +1 -1
  88. package/dist/proxy/request-utils.js +4 -0
  89. package/dist/proxy/request-utils.js.map +1 -1
  90. package/dist/proxy/resource-routes.d.ts +2 -0
  91. package/dist/proxy/resource-routes.d.ts.map +1 -1
  92. package/dist/proxy/resource-routes.js +4 -0
  93. package/dist/proxy/resource-routes.js.map +1 -1
  94. package/dist/proxy/routing-parameters.test.js +10 -0
  95. package/dist/proxy/routing-parameters.test.js.map +1 -1
  96. package/dist/proxy/routing.d.ts.map +1 -1
  97. package/dist/proxy/routing.js +2 -0
  98. package/dist/proxy/routing.js.map +1 -1
  99. package/dist/proxy/spend-attribution.d.ts +17 -1
  100. package/dist/proxy/spend-attribution.d.ts.map +1 -1
  101. package/dist/proxy/spend-attribution.js +45 -10
  102. package/dist/proxy/spend-attribution.js.map +1 -1
  103. package/dist/proxy/spend-attribution.test.d.ts +2 -0
  104. package/dist/proxy/spend-attribution.test.d.ts.map +1 -0
  105. package/dist/proxy/spend-attribution.test.js +63 -0
  106. package/dist/proxy/spend-attribution.test.js.map +1 -0
  107. package/dist/proxy/telemetry.d.ts +14 -0
  108. package/dist/proxy/telemetry.d.ts.map +1 -1
  109. package/dist/proxy/telemetry.js +23 -3
  110. package/dist/proxy/telemetry.js.map +1 -1
  111. package/dist/proxy/video-download.d.ts +18 -1
  112. package/dist/proxy/video-download.d.ts.map +1 -1
  113. package/dist/proxy/video-download.js +100 -12
  114. package/dist/proxy/video-download.js.map +1 -1
  115. package/dist/proxy/video-download.test.js +106 -13
  116. package/dist/proxy/video-download.test.js.map +1 -1
  117. package/package.json +8 -8
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import * as http from 'node:http';
3
3
  import { test } from 'node:test';
4
4
  import { SPEND_ATTRIBUTION_HEADER, SpendAttributionFeed } from '../proxy/spend-attribution.js';
5
- import { GatewayAccounting } from './accounting.js';
5
+ import { GatewayAccounting, videoJobKey } from './accounting.js';
6
6
  import { generateApiKey } from './keys.js';
7
7
  import { findLimitBreach, periodResetsAt, periodStart } from './limits.js';
8
8
  import { formatUsdc, parseUsdToUsdc, usdcToDecimalString } from './money.js';
@@ -20,6 +20,15 @@ async function listen(server) {
20
20
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
21
21
  return server.address().port;
22
22
  }
23
+ /** Resolves once `check` holds; fails after `timeoutMs`. */
24
+ async function waitFor(check, timeoutMs = 1_000) {
25
+ const deadline = Date.now() + timeoutMs;
26
+ while (!check()) {
27
+ if (Date.now() > deadline)
28
+ throw new Error('condition not met in time');
29
+ await new Promise((resolve) => setTimeout(resolve, 5));
30
+ }
31
+ }
23
32
  async function request(port, path, options = {}) {
24
33
  return new Promise((resolve, reject) => {
25
34
  const req = http.request({
@@ -35,6 +44,7 @@ async function request(port, path, options = {}) {
35
44
  const chunks = [];
36
45
  res.on('data', (chunk) => chunks.push(chunk));
37
46
  res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8'), headers: res.headers }));
47
+ res.on('error', reject);
38
48
  });
39
49
  req.on('error', reject);
40
50
  req.end(options.body);
@@ -98,8 +108,8 @@ async function fakeBuyer(options = {}) {
98
108
  const port = await listen(server);
99
109
  return { port, captured, close: () => new Promise((resolve) => server.close(() => { feed.close(); resolve(); })) };
100
110
  }
101
- async function startGateway(store, buyerPort, holdUsdc = 300_000, topup = null) {
102
- const accounting = new GatewayAccounting(store, { holdUsdc, settleGraceMs: 50 });
111
+ async function startGateway(store, buyerPort, holdUsdc = 300_000, topup = null, settleGraceMs = 50) {
112
+ const accounting = new GatewayAccounting(store, { holdUsdc, settleGraceMs });
103
113
  const feed = new SpendFeedPoller({
104
114
  buyerPort,
105
115
  onPage: (page) => { accounting.ingest(page.bootId, page.events); },
@@ -114,6 +124,7 @@ async function startGateway(store, buyerPort, holdUsdc = 300_000, topup = null)
114
124
  identityAddress: async (name) => (name === 'team-a' ? '0x00000000000000000000000000000000000000aa' : null),
115
125
  spendFeedState: () => feed.state,
116
126
  refreshSpendFeed: () => feed.pollOnce(),
127
+ videoBudgetEnforced: () => feed.videoBudgetEnforced,
117
128
  onLog: (message) => logs.push(message),
118
129
  });
119
130
  const port = await server.start();
@@ -353,6 +364,24 @@ test('gateway exposes only authenticated supported API routes', async () => {
353
364
  cleanup();
354
365
  }
355
366
  });
367
+ test('gateway returns its journal tag and still forwards the buyer request id', async () => {
368
+ const { store, cleanup } = tempDataDir();
369
+ const buyer = await fakeBuyer();
370
+ const { secret } = store.createKey({ label: 'Cursor', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
371
+ const gateway = await startGateway(store, buyer.port);
372
+ try {
373
+ const response = await request(gateway.port, '/v1/chat/completions', { method: 'POST', key: secret, body: '{"model":"m"}' });
374
+ assert.equal(response.status, 200);
375
+ assert.equal(response.headers['x-antseed-request-id'], 'req-1');
376
+ assert.equal(response.headers['x-antseed-gateway-tag'], buyer.captured[0]?.tag);
377
+ assert.match(String(response.headers['x-antseed-gateway-tag']), /^gw_/);
378
+ }
379
+ finally {
380
+ await gateway.stop();
381
+ await buyer.close();
382
+ cleanup();
383
+ }
384
+ });
356
385
  test('gateway passes native video jobs to the buyer and books their spend to the key', async () => {
357
386
  const { store, cleanup } = tempDataDir();
358
387
  const buyer = await fakeBuyer({ costUsdc: 250_000 });
@@ -448,6 +477,294 @@ test('a video job is charged on its delivery: the create shows that charge and n
448
477
  cleanup();
449
478
  }
450
479
  });
480
+ test('a video create tells the buyer what is left of the key caps; other requests and uncapped keys do not', async () => {
481
+ const { store, cleanup } = tempDataDir();
482
+ const buyer = await fakeBuyer();
483
+ const capped = store.createKey({ label: 'Capped', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 2_000_000, monthly: 1_500_000, total: null }, expiresAt: null });
484
+ const open = store.createKey({ label: 'Open', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
485
+ const gateway = await startGateway(store, buyer.port);
486
+ try {
487
+ const create = '{"model":"seedance","prompt":"An ant","duration":"5s"}';
488
+ assert.equal((await request(gateway.port, '/v1/chat/completions', { method: 'POST', key: capped.secret, body: '{"model":"m"}' })).status, 200);
489
+ await waitFor(() => gateway.accounting.heldUsdc(capped.key.id) === 0, 2_000);
490
+ assert.equal((await request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: capped.secret, body: create })).status, 200);
491
+ assert.equal((await request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: open.secret, body: create })).status, 200);
492
+ // A client cannot raise its own cap.
493
+ assert.equal((await request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: open.secret, body: create, headers: { 'x-antseed-max-video-usdc': '99000000' } })).status, 200);
494
+ assert.deepEqual(buyer.captured.map(({ headers }) => headers['x-antseed-max-video-usdc'] ?? null), [null, '1500000', null, null]);
495
+ }
496
+ finally {
497
+ await gateway.stop();
498
+ await buyer.close();
499
+ cleanup();
500
+ }
501
+ });
502
+ test('video creates, polls and deliveries hold no request hold once answered; a job the buyer does not price holds its whole allowance', async () => {
503
+ const { store, cleanup } = tempDataDir();
504
+ const buyer = await videoJobBuyer(0);
505
+ const { key, secret } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
506
+ const gateway = await startGateway(store, buyer.port, 300_000, null, 60_000);
507
+ const job = videoJobKey('venice-video', 'job-7');
508
+ try {
509
+ assert.equal((await request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: secret, body: '{"model":"seedance","prompt":"An ant"}' })).status, 200);
510
+ // No price from the buyer: the $1 the create was allowed stays held for the job.
511
+ await waitFor(() => gateway.accounting.heldUsdc(key.id) === 1_000_000);
512
+ // The job's own poll and delivery are admitted although its price fills the cap, and leave no request hold.
513
+ assert.equal((await request(gateway.port, '/api/v1/video/retrieve', { method: 'POST', key: secret, body: '{"model":"seedance","queue_id":"job-7"}' })).status, 200);
514
+ await waitFor(() => gateway.accounting.heldUsdc(key.id, job) === 0);
515
+ assert.equal((await request(gateway.port, '/api/v1/video/retrieve', { method: 'POST', key: secret, body: '{"model":"seedance","queue_id":"job-7"}' })).status, 200);
516
+ await waitFor(() => store.listRequests({ keyIds: null, keyId: key.id }).some((row) => row.video?.action === 'delivery'));
517
+ // Until the delivery's charge arrives (none does here) the job itself still holds its price.
518
+ await waitFor(() => gateway.accounting.heldUsdc(key.id, job) === 0);
519
+ assert.equal(gateway.accounting.heldUsdc(key.id), 1_000_000);
520
+ }
521
+ finally {
522
+ await gateway.stop();
523
+ await buyer.close();
524
+ cleanup();
525
+ }
526
+ });
527
+ /**
528
+ * Buyer that prices every video at `priceUsdc` and refuses one above the cap
529
+ * the gateway sends, as the real buyer does. A create names its job with the
530
+ * body's `job` and answers after `createDelayMs`; a retrieve for a job ending
531
+ * in `-fail` reports a failed job, one ending in `-done` delivers the file, one
532
+ * ending in `-cut` breaks off mid-file, one ending in `-gone` is unknown to the
533
+ * buyer, one ending in `-bad` is refused as a malformed request, any other is
534
+ * still processing.
535
+ */
536
+ async function pricedVideoBuyer(priceUsdc, createDelayMs = 0, priceHeader = String(priceUsdc), legacy = false) {
537
+ const caps = [];
538
+ const feed = new SpendAttributionFeed();
539
+ const retired = { 'x-antseed-video-job-retired': '1' };
540
+ const server = http.createServer((req, res) => {
541
+ if (req.url?.startsWith('/_antseed/attributed-spend')) {
542
+ const after = Number(new URL(req.url, 'http://localhost').searchParams.get('after') ?? '0');
543
+ res.writeHead(200, { 'content-type': 'application/json' });
544
+ const page = feed.page(after);
545
+ // An older buyer reports spend but not that it checks video prices against the room it is told about.
546
+ if (legacy)
547
+ delete page.capabilities;
548
+ res.end(JSON.stringify({ ok: true, ...page }));
549
+ return;
550
+ }
551
+ const chunks = [];
552
+ req.on('data', (chunk) => chunks.push(chunk));
553
+ req.on('end', () => {
554
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
555
+ const headers = { 'x-antseed-request-id': `req-${randomBytes(4).toString('hex')}`, 'x-antseed-peer-id': 'seller' };
556
+ if (req.url?.endsWith('/queue')) {
557
+ const cap = req.headers['x-antseed-max-video-usdc'] ?? null;
558
+ caps.push(cap);
559
+ if (cap !== null && priceUsdc > Number(cap)) {
560
+ res.writeHead(422, { ...headers, 'content-type': 'application/json' });
561
+ res.end(JSON.stringify({ error: { code: 'buyer-budget-too-low', message: 'Video costs more than the spend limit has left' } }));
562
+ return;
563
+ }
564
+ setTimeout(() => {
565
+ res.writeHead(200, { ...headers, 'content-type': 'application/json', 'x-antseed-video-price-usdc': priceHeader });
566
+ res.end(JSON.stringify({ queue_id: body.job }));
567
+ }, createDelayMs);
568
+ return;
569
+ }
570
+ if (body.queue_id?.endsWith('-gone') || body.queue_id?.endsWith('-bad')) {
571
+ const gone = body.queue_id.endsWith('-gone');
572
+ res.writeHead(gone ? 404 : 400, { ...headers, 'content-type': 'application/json', ...(gone ? retired : {}) });
573
+ res.end(JSON.stringify({ error: gone ? { code: 'video_route_not_found', message: 'Unknown video job' } : { code: 'invalid_required_parameters', message: 'Malformed' } }));
574
+ return;
575
+ }
576
+ if (body.queue_id?.endsWith('-cut')) {
577
+ res.writeHead(200, { ...headers, 'content-type': 'video/mp4' });
578
+ res.write(Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftypisom')]));
579
+ setTimeout(() => res.destroy(), 20);
580
+ return;
581
+ }
582
+ if (body.queue_id?.endsWith('-done')) {
583
+ // The buyer charges the delivery; its spend reaches the gateway through the feed.
584
+ feed.track(headers['x-antseed-request-id'], req.headers[SPEND_ATTRIBUTION_HEADER]);
585
+ feed.record({ requestId: headers['x-antseed-request-id'], buyerIdentity: DEFAULT_BUYER_IDENTITY, sellerPeerId: 'seller', amountUsdc: String(priceUsdc), inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0' });
586
+ res.writeHead(200, { ...headers, 'content-type': 'video/mp4' });
587
+ res.end(Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftypisom'), randomBytes(64)]));
588
+ return;
589
+ }
590
+ const failed = body.queue_id?.endsWith('-fail') === true;
591
+ res.writeHead(200, { ...headers, 'content-type': 'application/json', ...(failed ? retired : {}) });
592
+ res.end(JSON.stringify({ status: failed ? 'failed' : 'processing' }));
593
+ });
594
+ });
595
+ const port = await listen(server);
596
+ return { port, caps, close: () => new Promise((resolve) => server.close(() => { feed.close(); resolve(); })) };
597
+ }
598
+ async function videoGateway(priceUsdc, dailyCap, createDelayMs = 0, priceHeader) {
599
+ const { store, cleanup } = tempDataDir();
600
+ const buyer = await pricedVideoBuyer(priceUsdc, createDelayMs, priceHeader);
601
+ const { key, secret } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: dailyCap, monthly: null, total: null }, expiresAt: null });
602
+ const other = store.createKey({ label: 'Other', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
603
+ const gateway = await startGateway(store, buyer.port, 100_000, null, 60_000);
604
+ return {
605
+ store, key, buyer, gateway,
606
+ create: (job) => request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: secret, body: JSON.stringify({ model: 'seedance', prompt: 'An ant', duration: '8s', job }) }),
607
+ poll: (job, as = secret) => request(gateway.port, '/api/v1/video/retrieve', { method: 'POST', key: as, body: JSON.stringify({ model: 'seedance', queue_id: job }) }),
608
+ secret,
609
+ otherSecret: other.secret,
610
+ close: async () => {
611
+ await gateway.stop();
612
+ await buyer.close();
613
+ cleanup();
614
+ },
615
+ };
616
+ }
617
+ test('an accepted video stays counted against the caps, so two videos cannot share the same room', async () => {
618
+ const video = await videoGateway(600_000, 1_000_000);
619
+ const held = () => video.gateway.accounting.heldUsdc(video.key.id);
620
+ try {
621
+ assert.equal((await video.create('job-1')).status, 200);
622
+ await waitFor(() => held() === 600_000);
623
+ // Only $0.40 is left once the first job is counted: the buyer refuses a second $0.60 video before it starts.
624
+ assert.equal((await video.create('job-2')).status, 422);
625
+ assert.deepEqual(video.buyer.caps, ['1000000', '400000']);
626
+ await waitFor(() => held() === 600_000);
627
+ // The job's own polls are admitted without counting its price twice, and hold nothing.
628
+ const own = video.gateway.accounting.admit(video.key, undefined, { videoJob: videoJobKey('venice-video', 'job-1') });
629
+ assert.ok(own.ok);
630
+ assert.equal(video.gateway.accounting.heldUsdc(video.key.id), 600_000);
631
+ video.gateway.accounting.release(own.tag);
632
+ assert.equal((await video.poll('job-1')).status, 200);
633
+ await waitFor(() => held() === 600_000);
634
+ }
635
+ finally {
636
+ await video.close();
637
+ }
638
+ });
639
+ test('video creates in flight at once cannot both be told the same room', async () => {
640
+ const video = await videoGateway(600_000, 1_000_000, 100);
641
+ try {
642
+ // The first create holds the whole $1 it may spend until its price is known, so the second is refused at once.
643
+ const [first, second] = await Promise.all([video.create('job-1'), new Promise((resolve) => setTimeout(resolve, 20)).then(() => video.create('job-2'))]);
644
+ // Retryable: it fits once the first is priced.
645
+ assert.deepEqual([first.status, second.status], [200, 429]);
646
+ assert.equal(JSON.parse(second.body).error.code, 'video_pricing_in_progress');
647
+ assert.deepEqual(video.buyer.caps, ['1000000']);
648
+ // Once priced, only the job's $0.60 stays committed.
649
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id) === 600_000);
650
+ }
651
+ finally {
652
+ await video.close();
653
+ }
654
+ });
655
+ test('a malformed poll keeps the job committed; a job the buyer does not know stops counting', async () => {
656
+ const video = await videoGateway(600_000, 1_000_000);
657
+ const held = () => video.gateway.accounting.heldUsdc(video.key.id);
658
+ try {
659
+ assert.equal((await video.create('job-bad')).status, 200);
660
+ await waitFor(() => held() === 600_000);
661
+ assert.equal((await video.poll('job-bad')).status, 400);
662
+ await waitFor(() => held() === 600_000);
663
+ // The job is still worth $0.60: a second $0.60 video is refused.
664
+ assert.equal((await video.create('job-2')).status, 422);
665
+ assert.equal((await video.create('job-gone')).status, 422);
666
+ video.gateway.accounting.dropVideo(videoJobKey('venice-video', 'job-bad'));
667
+ assert.equal((await video.create('job-gone')).status, 200);
668
+ await waitFor(() => held() === 600_000);
669
+ assert.equal((await video.poll('job-gone')).status, 404);
670
+ await waitFor(() => held() === 0);
671
+ }
672
+ finally {
673
+ await video.close();
674
+ }
675
+ });
676
+ test('a video download that breaks off keeps the job committed', async () => {
677
+ const video = await videoGateway(400_000, 1_000_000);
678
+ const job = videoJobKey('venice-video', 'job-cut');
679
+ try {
680
+ assert.equal((await video.create('job-cut')).status, 200);
681
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id) === 400_000);
682
+ await video.poll('job-cut').catch(() => null);
683
+ // No request holds remain, and the job's price is still a commitment (leaving the job out drops it).
684
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id, job) === 0);
685
+ assert.equal(video.gateway.accounting.heldUsdc(video.key.id), 400_000);
686
+ }
687
+ finally {
688
+ await video.close();
689
+ }
690
+ });
691
+ test('a job the buyer retires stops counting, and a delivered one counts until its charge arrives', async () => {
692
+ const video = await videoGateway(400_000, 1_000_000);
693
+ const held = () => video.gateway.accounting.heldUsdc(video.key.id);
694
+ try {
695
+ assert.equal((await video.create('job-fail')).status, 200);
696
+ assert.equal((await video.create('job-done')).status, 200);
697
+ await waitFor(() => held() === 800_000);
698
+ assert.equal((await video.poll('job-fail')).status, 200);
699
+ await waitFor(() => held() === 400_000);
700
+ // Delivered: the job still holds its price (no request hold) until the buyer's charge arrives, then the ledger has it.
701
+ assert.equal((await video.poll('job-done')).status, 200);
702
+ await waitFor(() => video.store.listRequests({ keyIds: null, keyId: video.key.id }).some((row) => row.video?.action === 'delivery'));
703
+ assert.equal(held(), 400_000);
704
+ await video.gateway.feed.pollOnce();
705
+ assert.equal(held(), 0);
706
+ assert.equal(video.store.periodSpend(video.key.id).total, 400_000, 'counted once, as spend');
707
+ // Its owner may download it again (the buyer does not charge twice).
708
+ assert.equal((await video.poll('job-done')).status, 200);
709
+ }
710
+ finally {
711
+ await video.close();
712
+ }
713
+ });
714
+ test('only the key that created a video job may retrieve it', async () => {
715
+ const video = await videoGateway(400_000, 1_000_000);
716
+ try {
717
+ assert.equal((await video.create('job-done')).status, 200);
718
+ const stolen = await video.poll('job-done', video.otherSecret);
719
+ assert.equal(stolen.status, 404);
720
+ assert.equal(JSON.parse(stolen.body).error.code, 'video_route_not_found');
721
+ assert.equal((await video.poll('job-unknown')).status, 404, 'a job this gateway never created is refused too');
722
+ assert.equal((await video.poll('job-done')).status, 200);
723
+ }
724
+ finally {
725
+ await video.close();
726
+ }
727
+ });
728
+ test('without a single readable price from the buyer, an accepted job holds its whole allowance', async () => {
729
+ // A seller copy of the price header next to the buyer's arrives joined ("1, 400000"): not a price.
730
+ for (const header of [['1', '400000'], 'lots']) {
731
+ const video = await videoGateway(400_000, 1_000_000, 0, header);
732
+ try {
733
+ assert.equal((await video.create('job-1')).status, 200);
734
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id) === 1_000_000);
735
+ }
736
+ finally {
737
+ await video.close();
738
+ }
739
+ }
740
+ // A price above the allowance is held at the allowance.
741
+ const video = await videoGateway(400_000, 1_000_000, 0, '9000000');
742
+ try {
743
+ assert.equal((await video.create('job-1')).status, 200);
744
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id) === 1_000_000);
745
+ }
746
+ finally {
747
+ await video.close();
748
+ }
749
+ });
750
+ test('a revoked key\'s jobs stop counting against its workspace', () => {
751
+ const { store, cleanup } = tempDataDir();
752
+ try {
753
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
754
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
755
+ const create = accounting.admit(key);
756
+ assert.ok(create.ok);
757
+ accounting.commitVideo(create.tag, videoJobKey('venice-video', 'job-1'), 600_000);
758
+ accounting.release(create.tag);
759
+ assert.equal(accounting.heldForWorkspace(key.workspaceId), 600_000);
760
+ store.revokeKey(key.id);
761
+ assert.equal(accounting.heldForWorkspace(key.workspaceId), 0);
762
+ accounting.dispose();
763
+ }
764
+ finally {
765
+ cleanup();
766
+ }
767
+ });
451
768
  test('a video delivery whose spend has not arrived is pending, like any charged request', async () => {
452
769
  const { store, cleanup } = tempDataDir();
453
770
  const buyer = await videoJobBuyer(0);
@@ -455,6 +772,7 @@ test('a video delivery whose spend has not arrived is pending, like any charged
455
772
  const gateway = await startGateway(store, buyer.port);
456
773
  try {
457
774
  const retrieve = '{"model":"seedance","queue_id":"job-7"}';
775
+ await request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: secret, body: '{"model":"seedance","prompt":"An ant"}' });
458
776
  await request(gateway.port, '/api/v1/video/retrieve', { method: 'POST', key: secret, body: retrieve });
459
777
  await request(gateway.port, '/api/v1/video/retrieve', { method: 'POST', key: secret, body: retrieve });
460
778
  const delivery = store.listRequests({ keyIds: null, keyId: key.id }).find((row) => row.video?.action === 'delivery');
@@ -628,6 +946,514 @@ test('gateway holds block concurrent requests from slipping under a cap', () =>
628
946
  cleanup();
629
947
  }
630
948
  });
949
+ test('an accepted video stays committed across a gateway restart until it is dropped', () => {
950
+ const { store, cleanup } = tempDataDir();
951
+ const job = videoJobKey('venice-video', 'job-1');
952
+ try {
953
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
954
+ const before = new GatewayAccounting(store, { holdUsdc: 100_000 });
955
+ const create = before.admit(key, undefined, { videoCreate: true });
956
+ assert.ok(create.ok);
957
+ before.commitVideo(create.tag, job, 600_000);
958
+ before.release(create.tag);
959
+ before.dispose();
960
+ const after = new GatewayAccounting(store, { holdUsdc: 100_000 });
961
+ assert.equal(after.heldUsdc(key.id), 600_000);
962
+ const next = after.admit(key, undefined, { videoCreate: true });
963
+ assert.ok(next.ok);
964
+ assert.equal(next.remainingUsdc, 400_000, 'a second create is told only what the first job leaves');
965
+ after.release(next.tag);
966
+ after.dropVideo(job);
967
+ after.dispose();
968
+ assert.equal(new GatewayAccounting(store, { holdUsdc: 100_000 }).heldUsdc(key.id), 0);
969
+ }
970
+ finally {
971
+ cleanup();
972
+ }
973
+ });
974
+ test('an undelivered video stays committed for as long as the buyer can still charge it (30 days)', () => {
975
+ const { store, cleanup } = tempDataDir();
976
+ let now = Date.UTC(2026, 0, 1);
977
+ const day = 24 * 60 * 60_000;
978
+ try {
979
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: null, monthly: null, total: 1_000_000 }, expiresAt: null });
980
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000, now: () => now });
981
+ const create = accounting.admit(key, undefined, { videoCreate: true });
982
+ assert.ok(create.ok);
983
+ accounting.commitVideo(create.tag, videoJobKey('venice-video', 'old-job'), 600_000);
984
+ accounting.release(create.tag);
985
+ now += 29 * day;
986
+ const later = accounting.admit(key, undefined, { videoCreate: true });
987
+ assert.ok(later.ok);
988
+ assert.equal(later.remainingUsdc, 400_000, 'a month-old job that can still be downloaded still counts');
989
+ accounting.release(later.tag);
990
+ now += 2 * day;
991
+ assert.equal(accounting.heldUsdc(key.id), 0);
992
+ accounting.dispose();
993
+ }
994
+ finally {
995
+ cleanup();
996
+ }
997
+ });
998
+ test('a charge on a video job lowers what the job holds by that much, whatever request of the job it names', () => {
999
+ const { store, cleanup } = tempDataDir();
1000
+ try {
1001
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 5_000_000, monthly: null, total: null }, expiresAt: null });
1002
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
1003
+ const job = videoJobKey('venice-video', 'job-1');
1004
+ const create = accounting.admit(key, undefined, { videoCreate: true });
1005
+ assert.ok(create.ok);
1006
+ accounting.commitVideo(create.tag, job, 5_000_000);
1007
+ accounting.release(create.tag);
1008
+ // A status poll of the job that the seller bills $0.30 (for example a headroom request) frees only $0.30.
1009
+ store.startRequest({ tag: 'poll-tag', keyId: key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/api/v1/video/retrieve', model: null, startedAt: 0, video: { action: 'poll', jobId: 'job-1' } });
1010
+ const event = { seq: 1, tag: 'poll-tag', requestId: 'r', buyerIdentity: DEFAULT_BUYER_IDENTITY, sellerPeerId: 's', amountUsdc: '300000', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0', at: Date.now() };
1011
+ accounting.ingest('boot', [event]);
1012
+ assert.equal(accounting.heldUsdc(key.id), 4_700_000);
1013
+ assert.equal(store.periodSpend(key.id).daily, 300_000, 'spend plus what the job holds is still $5');
1014
+ accounting.dispose();
1015
+ }
1016
+ finally {
1017
+ cleanup();
1018
+ }
1019
+ });
1020
+ test('an opening fee charged on a video create is never held twice, whether it arrives before or after the job is accepted', () => {
1021
+ for (const feeFirst of [true, false]) {
1022
+ const { store, cleanup } = tempDataDir();
1023
+ try {
1024
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 5_000_000, monthly: null, total: null }, expiresAt: null });
1025
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
1026
+ const job = videoJobKey('venice-video', 'job-1');
1027
+ const create = accounting.admit(key, undefined, { videoCreate: true });
1028
+ assert.ok(create.ok);
1029
+ store.startRequest({ tag: create.tag, keyId: key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/api/v1/video/queue', model: null, startedAt: 0, video: { action: 'create', jobId: null } });
1030
+ // The one-off channel's $0.30 opening fee counts toward the $4.20 video.
1031
+ const fee = { seq: 1, tag: create.tag, requestId: 'r', buyerIdentity: DEFAULT_BUYER_IDENTITY, sellerPeerId: 's', amountUsdc: '300000', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0', at: Date.now() };
1032
+ if (feeFirst)
1033
+ accounting.ingest('boot', [fee]);
1034
+ store.recordRequestOutcome(create.tag, { sellerPeerId: 's', latencyMs: 1, firstTokenMs: null, tokensPerSecond: null, endUser: null, servedFree: false, videoJobId: 'job-1', videoDelivered: false });
1035
+ accounting.commitVideo(create.tag, job, 4_200_000);
1036
+ accounting.release(create.tag);
1037
+ if (!feeFirst)
1038
+ accounting.ingest('boot', [fee]);
1039
+ assert.equal(accounting.heldUsdc(key.id), 3_900_000, 'only what is not spent yet is held');
1040
+ assert.equal(store.periodSpend(key.id).daily, 300_000);
1041
+ // The delivery charges the rest: nothing stays held.
1042
+ store.startRequest({ tag: 'delivery', keyId: key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/api/v1/video/retrieve', model: null, startedAt: 0, video: { action: 'poll', jobId: 'job-1' } });
1043
+ store.recordRequestOutcome('delivery', { videoDelivered: true });
1044
+ accounting.ingest('boot', [{ ...fee, seq: 2, tag: 'delivery', amountUsdc: '3900000' }]);
1045
+ assert.equal(accounting.heldUsdc(key.id), 0);
1046
+ assert.equal(store.periodSpend(key.id).daily, 4_200_000);
1047
+ accounting.dispose();
1048
+ }
1049
+ finally {
1050
+ cleanup();
1051
+ }
1052
+ }
1053
+ });
1054
+ test('a job that holds its price can always be polled and downloaded, even after other spend fills the cap', () => {
1055
+ const { store, cleanup } = tempDataDir();
1056
+ try {
1057
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: null, monthly: null, total: 1_000_000 }, expiresAt: null });
1058
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
1059
+ const job = videoJobKey('venice-video', 'job-1');
1060
+ const create = accounting.admit(key, undefined, { videoCreate: true });
1061
+ assert.ok(create.ok);
1062
+ accounting.commitVideo(create.tag, job, 600_000);
1063
+ accounting.release(create.tag);
1064
+ // A late charge of earlier work fills what is left.
1065
+ store.startRequest({ tag: 'late', keyId: key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/v1/chat/completions', model: null, startedAt: 0 });
1066
+ accounting.ingest('boot', [{ seq: 1, tag: 'late', requestId: 'r', buyerIdentity: DEFAULT_BUYER_IDENTITY, sellerPeerId: 's', amountUsdc: '500000', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0', at: Date.now() }]);
1067
+ assert.equal(accounting.admit(key).ok, false, 'other requests are refused');
1068
+ const poll = accounting.admit(key, undefined, { videoJob: job });
1069
+ assert.ok(poll.ok, 'the job itself is not: its price already covers its charge');
1070
+ assert.equal(accounting.heldUsdc(key.id, job), 0, 'and it adds nothing to what is held');
1071
+ accounting.release(poll.tag);
1072
+ accounting.dispose();
1073
+ }
1074
+ finally {
1075
+ cleanup();
1076
+ }
1077
+ });
1078
+ test('capped requests are refused while a video commitment cannot be stored or loaded, and resume once it can', async () => {
1079
+ const { store, cleanup } = tempDataDir();
1080
+ const buyer = await pricedVideoBuyer(400_000);
1081
+ const { key, secret } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
1082
+ const save = store.saveVideoCommitment.bind(store);
1083
+ let failWrites = true;
1084
+ // The create's reservation is stored; its job's commitment is not.
1085
+ store.saveVideoCommitment = (record, replaces) => { if (failWrites && !record.job.startsWith('create\n'))
1086
+ throw new Error('database is locked'); save(record, replaces); };
1087
+ const gateway = await startGateway(store, buyer.port, 100_000, null, 60_000);
1088
+ try {
1089
+ assert.equal((await request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: secret, body: '{"model":"seedance","prompt":"An ant","duration":"8s","job":"job-1"}' })).status, 200);
1090
+ await waitFor(() => gateway.accounting.heldUsdc(key.id) === 400_000);
1091
+ // The job is held in memory but not stored: a restart could forget it, so capped requests wait.
1092
+ const refused = await request(gateway.port, '/v1/chat/completions', { method: 'POST', key: secret, body: '{"model":"m"}' });
1093
+ assert.equal(refused.status, 503);
1094
+ assert.equal(JSON.parse(refused.body).error.code, 'spend_tracking_unavailable');
1095
+ failWrites = false;
1096
+ assert.equal((await request(gateway.port, '/v1/chat/completions', { method: 'POST', key: secret, body: '{"model":"m"}' })).status, 200);
1097
+ assert.equal(store.listVideoCommitments(Date.now()).length, 1, 'stored on the retry, replacing its reservation');
1098
+ }
1099
+ finally {
1100
+ await gateway.stop();
1101
+ await buyer.close();
1102
+ cleanup();
1103
+ }
1104
+ });
1105
+ test('an accepted video whose commitment could not be stored still counts after a gateway restart', async () => {
1106
+ const { store, cleanup } = tempDataDir();
1107
+ const buyer = await pricedVideoBuyer(400_000);
1108
+ const { key, secret } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
1109
+ const save = store.saveVideoCommitment.bind(store);
1110
+ let failWrites = true;
1111
+ store.saveVideoCommitment = (record, replaces) => { if (failWrites && !record.job.startsWith('create\n'))
1112
+ throw new Error('database is locked'); save(record, replaces); };
1113
+ const gateway = await startGateway(store, buyer.port, 100_000, null, 60_000);
1114
+ try {
1115
+ assert.equal((await request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: secret, body: '{"model":"seedance","prompt":"An ant","duration":"8s","job":"job-1"}' })).status, 200);
1116
+ await waitFor(() => gateway.accounting.heldUsdc(key.id) === 400_000);
1117
+ }
1118
+ finally {
1119
+ await gateway.stop();
1120
+ await buyer.close();
1121
+ }
1122
+ try {
1123
+ // The gateway stopped before the job was stored; the database works again for the next one.
1124
+ failWrites = false;
1125
+ const restarted = new GatewayAccounting(store, { holdUsdc: 100_000 });
1126
+ const job = videoJobKey('venice-video', 'job-1');
1127
+ assert.equal(restarted.videoJobOwner(job), key.id, 'its key can still retrieve it');
1128
+ assert.equal(restarted.heldUsdc(key.id), 1_000_000, 'and it still holds the whole room its create was told about');
1129
+ assert.deepEqual(store.listVideoCommitments(Date.now()).map((row) => row.job), [job], 'the reservation became the job');
1130
+ assert.equal(restarted.admit(key).ok, false, 'so other spend cannot take that room');
1131
+ restarted.dispose();
1132
+ }
1133
+ finally {
1134
+ cleanup();
1135
+ }
1136
+ });
1137
+ test('an accepted video is committed even when the request log cannot be written', async () => {
1138
+ const video = await videoGateway(400_000, 1_000_000);
1139
+ const finishRequest = video.store.finishRequest.bind(video.store);
1140
+ video.store.finishRequest = (tag, result) => { if (result.status === 200)
1141
+ throw new Error('database is locked'); finishRequest(tag, result); };
1142
+ try {
1143
+ assert.equal((await video.create('job-1')).status, 200);
1144
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id) === 400_000);
1145
+ assert.equal(video.gateway.accounting.videoJobOwner(videoJobKey('venice-video', 'job-1')), video.key.id);
1146
+ }
1147
+ finally {
1148
+ await video.close();
1149
+ }
1150
+ });
1151
+ test('a capped video create whose reservation cannot be stored is refused before it is sent', async () => {
1152
+ const video = await videoGateway(400_000, 1_000_000);
1153
+ video.store.saveVideoCommitment = () => { throw new Error('database is locked'); };
1154
+ try {
1155
+ const refused = await video.create('job-1');
1156
+ assert.equal(refused.status, 503);
1157
+ assert.equal(JSON.parse(refused.body).error.code, 'spend_tracking_unavailable');
1158
+ assert.deepEqual(video.buyer.caps, [], 'the buyer never saw it');
1159
+ assert.equal(video.gateway.accounting.heldUsdc(video.key.id), 0);
1160
+ }
1161
+ finally {
1162
+ await video.close();
1163
+ }
1164
+ });
1165
+ test('video creates of capped keys are refused through a buyer that cannot check their price; uncapped keys still create', async () => {
1166
+ const { store, cleanup } = tempDataDir();
1167
+ const buyer = await pricedVideoBuyer(2_000_000, 0, '2000000', true);
1168
+ const capped = store.createKey({ label: 'Capped', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 500_000, monthly: null, total: null }, expiresAt: null });
1169
+ const open = store.createKey({ label: 'Open', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
1170
+ const gateway = await startGateway(store, buyer.port, 100_000, null, 60_000);
1171
+ const create = (secret, job) => request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: secret, body: JSON.stringify({ model: 'seedance', prompt: 'An ant', duration: '8s', job }) });
1172
+ try {
1173
+ // That buyer would start the $2 video on a key with $0.50 left.
1174
+ const refused = await create(capped.secret, 'job-1');
1175
+ assert.equal(refused.status, 503);
1176
+ assert.equal(JSON.parse(refused.body).error.code, 'buyer_upgrade_required');
1177
+ assert.deepEqual(buyer.caps, [], 'never sent');
1178
+ assert.equal(gateway.accounting.heldUsdc(capped.key.id), 0);
1179
+ // A key without caps has no price to check: its videos still run.
1180
+ assert.equal((await create(open.secret, 'job-2')).status, 200);
1181
+ assert.equal((await request(gateway.port, '/api/v1/video/retrieve', { method: 'POST', key: open.secret, body: '{"model":"seedance","queue_id":"job-2"}' })).status, 200);
1182
+ }
1183
+ finally {
1184
+ await gateway.stop();
1185
+ await buyer.close();
1186
+ cleanup();
1187
+ }
1188
+ });
1189
+ test('a video create refused by the buyer leaves no reservation behind', async () => {
1190
+ const video = await videoGateway(600_000, 500_000);
1191
+ try {
1192
+ assert.equal((await video.create('job-1')).status, 422);
1193
+ assert.deepEqual(video.store.listVideoCommitments(Date.now()), []);
1194
+ assert.equal(video.gateway.accounting.heldUsdc(video.key.id), 0);
1195
+ }
1196
+ finally {
1197
+ await video.close();
1198
+ }
1199
+ });
1200
+ test('a reservation left by a create whose job was never recorded is dropped at startup', () => {
1201
+ const { store, cleanup } = tempDataDir();
1202
+ try {
1203
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
1204
+ const before = new GatewayAccounting(store, { holdUsdc: 100_000 });
1205
+ // The gateway stops while the create is in flight: no record names its job, so no key can retrieve it.
1206
+ assert.ok(before.admit(key, undefined, { videoCreate: true }).ok);
1207
+ assert.equal(store.listVideoCommitments(Date.now()).length, 1);
1208
+ before.dispose();
1209
+ const after = new GatewayAccounting(store, { holdUsdc: 100_000 });
1210
+ assert.equal(after.heldUsdc(key.id), 0);
1211
+ assert.deepEqual(store.listVideoCommitments(Date.now()), []);
1212
+ after.dispose();
1213
+ }
1214
+ finally {
1215
+ cleanup();
1216
+ }
1217
+ });
1218
+ test("a video job ID that another key already owns does not take that key's job", () => {
1219
+ const { store, cleanup } = tempDataDir();
1220
+ try {
1221
+ const first = store.createKey({ label: 'A', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null }).key;
1222
+ const second = store.createKey({ label: 'B', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null }).key;
1223
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
1224
+ const job = videoJobKey('venice-video', 'job-1');
1225
+ for (const [key, amount] of [[first, 600_000], [second, 300_000]]) {
1226
+ const create = accounting.admit(key, undefined, { videoCreate: true });
1227
+ assert.ok(create.ok);
1228
+ accounting.commitVideo(create.tag, job, amount);
1229
+ accounting.release(create.tag);
1230
+ }
1231
+ assert.equal(accounting.videoJobOwner(job), first.id);
1232
+ assert.equal(accounting.heldUsdc(first.id), 600_000);
1233
+ assert.equal(accounting.heldUsdc(second.id), 0);
1234
+ assert.deepEqual(store.listVideoCommitments(Date.now()).map((row) => row.job), [job], 'no reservation is left either');
1235
+ accounting.dispose();
1236
+ }
1237
+ finally {
1238
+ cleanup();
1239
+ }
1240
+ });
1241
+ test('spend without a request ID does not lower what a video job holds', () => {
1242
+ const { store, cleanup } = tempDataDir();
1243
+ try {
1244
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 5_000_000, monthly: null, total: null }, expiresAt: null });
1245
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
1246
+ const create = accounting.admit(key, undefined, { videoCreate: true });
1247
+ assert.ok(create.ok);
1248
+ accounting.commitVideo(create.tag, videoJobKey('venice-video', 'job-1'), 600_000);
1249
+ accounting.release(create.tag);
1250
+ store.startRequest({ tag: 'poll-tag', keyId: key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/api/v1/video/retrieve', model: null, startedAt: 0, video: { action: 'poll', jobId: 'job-1' } });
1251
+ // Attributed to the job's poll only because it was the seller's latest request: it may be anything.
1252
+ accounting.ingest('boot', [{ seq: 1, tag: 'poll-tag', requestId: null, buyerIdentity: DEFAULT_BUYER_IDENTITY, sellerPeerId: 's', amountUsdc: '200000', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0', at: Date.now() }]);
1253
+ assert.equal(accounting.heldUsdc(key.id), 600_000);
1254
+ assert.equal(store.periodSpend(key.id).daily, 200_000);
1255
+ accounting.dispose();
1256
+ }
1257
+ finally {
1258
+ cleanup();
1259
+ }
1260
+ });
1261
+ test('stored video commitments that cannot be read at startup are read before any capped request', () => {
1262
+ const { store, cleanup } = tempDataDir();
1263
+ try {
1264
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
1265
+ store.saveVideoCommitment({ job: videoJobKey('venice-video', 'job-1'), keyId: key.id, memberId: null, workspaceId: key.workspaceId, amountUsdc: 600_000, expiresAt: Date.now() + 60_000, deliveredAt: null });
1266
+ const list = store.listVideoCommitments.bind(store);
1267
+ let failReads = true;
1268
+ store.listVideoCommitments = (now) => { if (failReads)
1269
+ throw new Error('database is locked'); return list(now); };
1270
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
1271
+ assert.equal(accounting.videoCommitmentsReady(), false);
1272
+ failReads = false;
1273
+ assert.equal(accounting.videoCommitmentsReady(), true);
1274
+ assert.equal(accounting.heldUsdc(key.id), 600_000);
1275
+ accounting.dispose();
1276
+ }
1277
+ finally {
1278
+ cleanup();
1279
+ }
1280
+ });
1281
+ test('a free video holds nothing for its job', async () => {
1282
+ const video = await videoGateway(0, 1_000_000);
1283
+ try {
1284
+ assert.equal((await video.create('job-free')).status, 200);
1285
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id) === 0);
1286
+ assert.equal(video.gateway.accounting.videoJobOwner(videoJobKey('venice-video', 'job-free')), video.key.id);
1287
+ }
1288
+ finally {
1289
+ await video.close();
1290
+ }
1291
+ });
1292
+ test('a request refused only because a video create is being priced is told to retry, not that the cap is reached', async () => {
1293
+ const video = await videoGateway(600_000, 1_000_000, 200);
1294
+ try {
1295
+ const created = video.create('job-1');
1296
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id) === 1_000_000);
1297
+ const chat = await request(video.gateway.port, '/v1/chat/completions', { method: 'POST', key: video.secret, body: '{"model":"m"}' });
1298
+ assert.equal(chat.status, 429);
1299
+ assert.equal(JSON.parse(chat.body).error.code, 'video_pricing_in_progress');
1300
+ assert.equal(chat.headers['retry-after'], '5');
1301
+ assert.equal((await created).status, 200);
1302
+ await waitFor(() => video.gateway.accounting.heldUsdc(video.key.id) === 600_000);
1303
+ assert.notEqual((await request(video.gateway.port, '/v1/chat/completions', { method: 'POST', key: video.secret, body: '{"model":"m"}' })).status, 429);
1304
+ }
1305
+ finally {
1306
+ await video.close();
1307
+ }
1308
+ });
1309
+ test('a free video can always be polled by its key, even with the caps filled', async () => {
1310
+ const video = await videoGateway(0, 1_000_000);
1311
+ try {
1312
+ assert.equal((await video.create('job-free')).status, 200);
1313
+ await waitFor(() => video.gateway.accounting.videoJobOwner(videoJobKey('venice-video', 'job-free')) === video.key.id);
1314
+ video.store.startRequest({ tag: 'other', keyId: video.key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/v1/chat/completions', model: null, startedAt: 0 });
1315
+ video.gateway.accounting.ingest('boot', [{ seq: 1, tag: 'other', requestId: 'r', buyerIdentity: DEFAULT_BUYER_IDENTITY, sellerPeerId: 's', amountUsdc: '1000000', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0', at: Date.now() }]);
1316
+ assert.equal((await request(video.gateway.port, '/v1/chat/completions', { method: 'POST', key: video.secret, body: '{"model":"m"}' })).status, 402);
1317
+ assert.equal((await video.poll('job-free')).status, 200);
1318
+ }
1319
+ finally {
1320
+ await video.close();
1321
+ }
1322
+ });
1323
+ test('a recovered video job does not hold again what its delivery was already charged', () => {
1324
+ const { store, cleanup } = tempDataDir();
1325
+ try {
1326
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
1327
+ const before = new GatewayAccounting(store, { holdUsdc: 100_000 });
1328
+ const create = before.admit(key, undefined, { videoCreate: true });
1329
+ assert.ok(create.ok);
1330
+ store.startRequest({ tag: create.tag, keyId: key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/api/v1/video/queue', model: null, startedAt: 0, video: { action: 'create', jobId: null } });
1331
+ store.recordRequestOutcome(create.tag, { videoJobId: 'job-1' });
1332
+ // The job's own commitment is never stored; it is delivered and charged $0.40, then the gateway stops.
1333
+ store.startRequest({ tag: 'delivery', keyId: key.id, buyerIdentity: DEFAULT_BUYER_IDENTITY, method: 'POST', path: '/api/v1/video/retrieve', model: null, startedAt: 1, video: { action: 'poll', jobId: 'job-1' } });
1334
+ store.recordRequestOutcome('delivery', { videoDelivered: true });
1335
+ store.finishRequest('delivery', { status: 200, buyerRequestId: null });
1336
+ before.ingest('boot', [{ seq: 1, tag: 'delivery', requestId: 'r', buyerIdentity: DEFAULT_BUYER_IDENTITY, sellerPeerId: 's', amountUsdc: '400000', inputTokens: '0', cachedInputTokens: '0', outputTokens: '0', outputImages: '0', at: Date.now() }]);
1337
+ before.dispose();
1338
+ const after = new GatewayAccounting(store, { holdUsdc: 100_000 });
1339
+ const job = videoJobKey('venice-video', 'job-1');
1340
+ assert.equal(after.videoJobOwner(job), key.id);
1341
+ assert.equal(after.videoJobHeldUsdc(job), 600_000, 'the room it was told about, less its charge');
1342
+ const record = store.listVideoCommitments(Date.now()).find((row) => row.job === job);
1343
+ assert.notEqual(record?.deliveredAt, null, 'and, delivered, it stops holding the rest an hour later');
1344
+ after.dispose();
1345
+ }
1346
+ finally {
1347
+ cleanup();
1348
+ }
1349
+ });
1350
+ test("a revoked key's video keeps counting while a download admitted before the revocation can still charge it", () => {
1351
+ const { store, cleanup } = tempDataDir();
1352
+ try {
1353
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
1354
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
1355
+ const job = videoJobKey('venice-video', 'job-1');
1356
+ const create = accounting.admit(key);
1357
+ assert.ok(create.ok);
1358
+ accounting.commitVideo(create.tag, job, 600_000);
1359
+ accounting.release(create.tag);
1360
+ const download = accounting.admit(key, undefined, { videoJob: job });
1361
+ assert.ok(download.ok);
1362
+ store.revokeKey(key.id);
1363
+ assert.equal(accounting.heldForWorkspace(key.workspaceId), 600_000, 'the running download may still be charged');
1364
+ // Delivered: its charge is still to come.
1365
+ accounting.videoDelivered(job);
1366
+ accounting.release(download.tag);
1367
+ assert.equal(accounting.heldForWorkspace(key.workspaceId), 600_000);
1368
+ accounting.dispose();
1369
+ }
1370
+ finally {
1371
+ cleanup();
1372
+ }
1373
+ });
1374
+ test("a revoked key's undelivered video stops counting once nothing can retrieve it", () => {
1375
+ const { store, cleanup } = tempDataDir();
1376
+ try {
1377
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
1378
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000 });
1379
+ const job = videoJobKey('venice-video', 'job-1');
1380
+ const create = accounting.admit(key);
1381
+ assert.ok(create.ok);
1382
+ accounting.commitVideo(create.tag, job, 600_000);
1383
+ accounting.release(create.tag);
1384
+ const poll = accounting.admit(key, undefined, { videoJob: job });
1385
+ assert.ok(poll.ok);
1386
+ store.revokeKey(key.id);
1387
+ assert.equal(accounting.heldForWorkspace(key.workspaceId), 600_000);
1388
+ accounting.release(poll.tag);
1389
+ assert.equal(accounting.heldForWorkspace(key.workspaceId), 0);
1390
+ accounting.dispose();
1391
+ }
1392
+ finally {
1393
+ cleanup();
1394
+ }
1395
+ });
1396
+ test('a delivered video that is never charged stops holding its price an hour after delivery', () => {
1397
+ const { store, cleanup } = tempDataDir();
1398
+ let now = Date.UTC(2026, 0, 1);
1399
+ try {
1400
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 1_000_000, monthly: null, total: null }, expiresAt: null });
1401
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000, now: () => now });
1402
+ const job = videoJobKey('venice-video', 'job-1');
1403
+ const create = accounting.admit(key, undefined, { videoCreate: true });
1404
+ assert.ok(create.ok);
1405
+ accounting.commitVideo(create.tag, job, 600_000);
1406
+ accounting.release(create.tag);
1407
+ accounting.videoDelivered(job);
1408
+ now += 59 * 60_000;
1409
+ assert.equal(accounting.heldUsdc(key.id), 600_000, 'its charge may still be on the way');
1410
+ now += 2 * 60_000;
1411
+ assert.equal(accounting.heldUsdc(key.id), 0);
1412
+ assert.equal(accounting.videoJobOwner(job), key.id, 'its owner can still download it again');
1413
+ accounting.dispose();
1414
+ assert.equal(new GatewayAccounting(store, { holdUsdc: 100_000, now: () => now }).heldUsdc(key.id), 0, 'stored that way');
1415
+ }
1416
+ finally {
1417
+ cleanup();
1418
+ }
1419
+ });
1420
+ test('a job created while its key had no caps counts once caps are added', async () => {
1421
+ const { store, cleanup } = tempDataDir();
1422
+ const buyer = await pricedVideoBuyer(600_000);
1423
+ const { key, secret } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: NO_LIMITS, expiresAt: null });
1424
+ const gateway = await startGateway(store, buyer.port, 100_000, null, 60_000);
1425
+ try {
1426
+ assert.equal((await request(gateway.port, '/api/v1/video/queue', { method: 'POST', key: secret, body: '{"model":"seedance","prompt":"An ant","duration":"8s","job":"job-1"}' })).status, 200);
1427
+ await waitFor(() => gateway.accounting.heldUsdc(key.id) === 600_000);
1428
+ const capped = store.updateKey(key.id, { limits: { daily: 1_000_000, monthly: null, total: null } });
1429
+ const next = gateway.accounting.admit(capped, undefined, { videoCreate: true });
1430
+ assert.ok(next.ok);
1431
+ assert.equal(next.remainingUsdc, 400_000);
1432
+ gateway.accounting.release(next.tag);
1433
+ }
1434
+ finally {
1435
+ await gateway.stop();
1436
+ await buyer.close();
1437
+ cleanup();
1438
+ }
1439
+ });
1440
+ test('a video create reserves up to the buyer video limit, configured or default', () => {
1441
+ const { store, cleanup } = tempDataDir();
1442
+ try {
1443
+ const { key } = store.createKey({ label: 'Video', buyerIdentity: DEFAULT_BUYER_IDENTITY, limits: { daily: 20_000_000, monthly: null, total: null }, expiresAt: null });
1444
+ for (const [maxVideoRequestUsdc, reserved] of [[undefined, 5_000_000], [10_000_000, 10_000_000], [50_000_000, 20_000_000]]) {
1445
+ const accounting = new GatewayAccounting(store, { holdUsdc: 100_000, ...(maxVideoRequestUsdc ? { maxVideoRequestUsdc } : {}) });
1446
+ const admission = accounting.admit(key, undefined, { videoCreate: true });
1447
+ assert.ok(admission.ok);
1448
+ assert.equal(admission.remainingUsdc, reserved);
1449
+ assert.equal(accounting.heldUsdc(key.id), reserved);
1450
+ accounting.dispose();
1451
+ }
1452
+ }
1453
+ finally {
1454
+ cleanup();
1455
+ }
1456
+ });
631
1457
  test('a finished request keeps its remaining hold after its first spend delta', () => {
632
1458
  const { store, cleanup } = tempDataDir();
633
1459
  const accounting = new GatewayAccounting(store, { holdUsdc: 300_000, settleGraceMs: 60_000 });