@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
@@ -1,5 +1,6 @@
1
1
  import type { AttributedSpendEvent } from '../proxy/spend-attribution.js';
2
2
  import { type BudgetLimits, type LimitBreach } from './limits.js';
3
+ export { videoJobKey } from './video-jobs.js';
3
4
  import type { ApiKeyRecord, GatewayStore, MemberRecord, WorkspaceRecord } from './store.js';
4
5
  /**
5
6
  * The member and workspace a key's request is accounted to, when the caller
@@ -12,22 +13,55 @@ export interface KeyContext {
12
13
  }
13
14
  /** The key's cap per period: the lower of the admins' and the owner's. */
14
15
  export declare function effectiveKeyLimits(key: Pick<ApiKeyRecord, 'limits' | 'ownerLimits'>): BudgetLimits;
16
+ /** The buyer's default limit for one video ($5), when its config sets none. */
17
+ export declare const DEFAULT_MAX_VIDEO_REQUEST_USDC = 5000000;
15
18
  export interface GatewayAccountingOptions {
16
19
  /**
17
20
  * Worst-case cost of one request, reserved against the key's caps while it
18
21
  * is in flight. The buyer's maxPerRequestUsdc is the natural bound.
19
22
  */
20
23
  holdUsdc: number;
24
+ /**
25
+ * The most one video may cost: the buyer's maxVideoRequestUsdc. A video
26
+ * create reserves up to this much of a key's room while it is priced.
27
+ */
28
+ maxVideoRequestUsdc?: number;
21
29
  settleGraceMs?: number;
30
+ videoCommitmentMs?: number;
22
31
  now?: () => number;
32
+ onLog?: (message: string) => void;
23
33
  }
24
34
  export declare function newRequestTag(): string;
25
- export type Admission = {
35
+ export interface AdmitOptions {
36
+ /** A video create: reserve the room it may spend (up to one video's limit) until its price is known. */
37
+ videoCreate?: boolean;
38
+ /** A video job's own poll or download, admitted without that job's commitment. */
39
+ videoJob?: string;
40
+ }
41
+ export type Admission =
42
+ /**
43
+ * `remainingUsdc`: the least room left under any cap before this request's
44
+ * hold, or for a video create the allowance reserved for it; null when
45
+ * nothing is capped.
46
+ */
47
+ {
26
48
  ok: true;
27
49
  tag: string;
50
+ remainingUsdc: number | null;
28
51
  } | {
29
52
  ok: false;
30
53
  breach: LimitBreach;
54
+ }
55
+ /**
56
+ * `video_storage`: a capped video create whose reservation could not be
57
+ * stored; it is not started. `video_pricing`: the request only fails
58
+ * because video creates still being priced hold the room they may spend;
59
+ * it fits once they are priced (seconds), so it may be retried.
60
+ */
61
+ | {
62
+ ok: false;
63
+ breach: null;
64
+ reason: 'video_storage' | 'video_pricing';
31
65
  };
32
66
  /**
33
67
  * Hold → settle accounting per key. Admission reserves a hold so concurrent
@@ -39,14 +73,90 @@ export declare class GatewayAccounting {
39
73
  private readonly _store;
40
74
  private readonly _options;
41
75
  private readonly _holds;
76
+ /** Accepted video jobs, by `videoJobKey`: their price counts against the caps until delivered, failed or expired. */
77
+ private readonly _videoCommitments;
78
+ /**
79
+ * Commitment writes that failed, retried before admitting a capped request
80
+ * (null: a delete). A job's write also removes its create's reservation
81
+ * (`replaces`), in the same transaction.
82
+ */
83
+ private readonly _pendingVideoWrites;
84
+ /** Whether the stored commitments were read; until then a capped request cannot be admitted safely. */
85
+ private _videoCommitmentsLoaded;
42
86
  private readonly _settleGraceMs;
87
+ private readonly _videoCommitmentMs;
88
+ private readonly _maxVideoRequestUsdc;
43
89
  private readonly _now;
44
90
  constructor(_store: GatewayStore, _options: GatewayAccountingOptions);
45
- heldUsdc(keyId: string): number;
91
+ /** In-flight holds plus accepted videos not yet charged, optionally leaving one video job out. */
92
+ heldUsdc(keyId: string, excludeVideoJob?: string, withoutVideoCreates?: boolean): number;
46
93
  /** Holds in flight for every key a member owns. */
47
- heldForMember(memberId: string): number;
48
- heldForWorkspace(workspaceId: string): number;
94
+ heldForMember(memberId: string, excludeVideoJob?: string, withoutVideoCreates?: boolean): number;
95
+ heldForWorkspace(workspaceId: string, excludeVideoJob?: string, withoutVideoCreates?: boolean): number;
49
96
  private _held;
97
+ /**
98
+ * A video create was accepted: remember which key owns the job (only that
99
+ * key may retrieve it) and keep `amountUsdc` counted against the caps of the
100
+ * key, member and workspace the create was admitted for until the buyer's
101
+ * charge for its delivery arrives, the buyer retires the job, or the record
102
+ * expires. A key without caps records the price too, so caps added later
103
+ * count the job.
104
+ *
105
+ * Part of the price may already be spent by the create itself (a one-off
106
+ * channel's opening fee counts toward the video): what is already in the
107
+ * ledger is not held again, and a charge on the create that arrives later
108
+ * lowers the commitment like any other charge of the job (see `ingest`).
109
+ */
110
+ commitVideo(tag: string, job: string, amountUsdc: number): void;
111
+ /** A video create ended without an accepted job: its reservation goes. */
112
+ videoNotAccepted(tag: string): void;
113
+ /** The key that created a video job through this gateway, if it is still remembered. */
114
+ videoJobOwner(job: string): string | null;
115
+ /** What a video job still holds against the caps (0 once charged, or for an uncapped key). */
116
+ videoJobHeldUsdc(job: string): number;
117
+ /**
118
+ * The buyer retired the job: it failed, its seller no longer has it, or the
119
+ * buyer has no record of it. It can never be delivered or charged.
120
+ */
121
+ dropVideo(job: string): void;
122
+ /** A retrieve streamed the job's whole file: its charge should follow (see DELIVERY_CHARGE_WAIT_MS). */
123
+ videoDelivered(job: string): void;
124
+ /**
125
+ * The buyer charged `amountUsdc` for the job's delivery: that much is in the
126
+ * ledger now, so the job holds that much less. Spend plus what the job still
127
+ * holds stays the same, whatever request the charge names. Ownership stays,
128
+ * so its owner can download it again (the buyer does not charge twice).
129
+ */
130
+ private _videoCharged;
131
+ private _setVideoHeld;
132
+ private _forgetVideo;
133
+ /**
134
+ * Commitments live in memory first and are stored so a restart keeps them.
135
+ * A failed write (for example another process holding the database lock)
136
+ * does not fail the request that caused it; it is retried, and until it
137
+ * succeeds capped requests are refused (see `videoCommitmentsReady`).
138
+ * A restart before the retry still keeps the job counted: its create's
139
+ * reservation, stored before the create was sent, is only removed in the
140
+ * same transaction that stores the job (see `_loadVideoCommitments`).
141
+ */
142
+ private _storeVideo;
143
+ /**
144
+ * Reads the stored commitments. A create's reservation still stored was
145
+ * left by a gateway that stopped before storing the create's job: when the
146
+ * request log names the job, the reservation becomes that job's commitment
147
+ * (the whole room the create was told about, less what it already spent),
148
+ * since its key can still retrieve it and be charged; otherwise the create
149
+ * was not accepted, or its job cannot be retrieved through this gateway
150
+ * (no record names its owner), so it is never charged and the reservation
151
+ * goes.
152
+ */
153
+ private _loadVideoCommitments;
154
+ /**
155
+ * Whether every video commitment is read and stored, so caps can be applied:
156
+ * retries a failed load and failed writes first. A delete that still fails
157
+ * only over-counts after a restart (the safe side) and does not block.
158
+ */
159
+ videoCommitmentsReady(): boolean;
50
160
  /** Whether any level above or at the key caps spend (so admission depends on the spend feed). */
51
161
  hasBudgets(key: ApiKeyRecord, context?: KeyContext): boolean;
52
162
  /** The key (the lower of its admin and owner caps), the member who owns it and its workspace. */
@@ -58,9 +168,11 @@ export declare class GatewayAccounting {
58
168
  * Member and workspace spend is the ledger as attributed at request time,
59
169
  * so a member who revokes a key and creates a new one still counts
60
170
  * everything spent before against the member's caps. Levels and periods
61
- * without a cap cost no query.
171
+ * without a cap cost no query. A video job's own polls and delivery are
172
+ * admitted without its own commitment (`videoJob`), which they would
173
+ * otherwise count twice.
62
174
  */
63
- admit(key: ApiKeyRecord, context?: KeyContext): Admission;
175
+ admit(key: ApiKeyRecord, context?: KeyContext, { videoCreate, videoJob }?: AdmitOptions): Admission;
64
176
  /** Drops a hold at once, for a request that never reached the buyer. */
65
177
  release(tag: string): void;
66
178
  /** The response is over; keep the hold briefly for spend that is signed late. */
@@ -1 +1 @@
1
- {"version":3,"file":"accounting.d.ts","sourceRoot":"","sources":["../../src/gateway/accounting.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAA;AACzE,OAAO,EAAoF,KAAK,YAAY,EAAE,KAAK,WAAW,EAAoB,MAAM,aAAa,CAAA;AAErK,OAAO,KAAK,EAAE,YAAY,EAAe,YAAY,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAExG;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,YAAY,GAAG,IAAI,CAAA;IAC3B,SAAS,EAAE,eAAe,GAAG,IAAI,CAAA;CAClC;AAED,0EAA0E;AAC1E,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,EAAE,QAAQ,GAAG,aAAa,CAAC,GAAG,YAAY,CAMlG;AAKD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAmBD,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,MAAM,MAAM,SAAS,GACjB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,CAAA;AAEtC;;;;;GAKG;AACH,qBAAa,iBAAiB;IAKhB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAAgB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAJ5E,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;IACjD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAc;gBAEN,MAAM,EAAE,YAAY,EAAmB,QAAQ,EAAE,wBAAwB;IAKtG,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM;IAI/B,mDAAmD;IACnD,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM;IAIvC,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM;IAI7C,OAAO,CAAC,KAAK;IAMb,iGAAiG;IACjG,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,GAAE,UAA+B,GAAG,OAAO;IAIhF,iGAAiG;IACjG,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,QAAQ;IAOhB;;;;;;;OAOG;IACH,KAAK,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,GAAE,UAA+B,GAAG,SAAS;IAoB7E,wEAAwE;IACxE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAM1B,iFAAiF;IACjF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAQzB;;;OAGG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,oBAAoB,EAAE,GAAG,MAAM;IAkCvE,OAAO,IAAI,IAAI;CAIhB"}
1
+ {"version":3,"file":"accounting.d.ts","sourceRoot":"","sources":["../../src/gateway/accounting.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAA;AACzE,OAAO,EAAoF,KAAK,YAAY,EAAE,KAAK,WAAW,EAAoB,MAAM,aAAa,CAAA;AAGrK,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAe,YAAY,EAAE,YAAY,EAAyB,eAAe,EAAE,MAAM,YAAY,CAAA;AAE/H;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,YAAY,GAAG,IAAI,CAAA;IAC3B,SAAS,EAAE,eAAe,GAAG,IAAI,CAAA;CAClC;AAED,0EAA0E;AAC1E,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,EAAE,QAAQ,GAAG,aAAa,CAAC,GAAG,YAAY,CAMlG;AAoBD,+EAA+E;AAC/E,eAAO,MAAM,8BAA8B,UAAY,CAAA;AAEvD,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;IAC5B,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAClC;AA8BD,wBAAgB,aAAa,IAAI,MAAM,CAEtC;AAED,MAAM,WAAW,YAAY;IAC3B,wGAAwG;IACxG,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,MAAM,SAAS;AACnB;;;;GAIG;AACD;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACvD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE;AACpC;;;;;GAKG;GACD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,eAAe,GAAG,eAAe,CAAA;CAAE,CAAA;AAE1E;;;;;GAKG;AACH,qBAAa,iBAAiB;IAiBhB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAAgB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAhB5E,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;IACjD,qHAAqH;IACrH,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAqC;IACvE;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAiF;IACrH,uGAAuG;IACvG,OAAO,CAAC,uBAAuB,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAQ;IACvC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAQ;IAC3C,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAQ;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAc;gBAEN,MAAM,EAAE,YAAY,EAAmB,QAAQ,EAAE,wBAAwB;IAQtG,kGAAkG;IAClG,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,EAAE,mBAAmB,UAAQ,GAAG,MAAM;IAItF,mDAAmD;IACnD,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,EAAE,mBAAmB,UAAQ,GAAG,MAAM;IAI9F,gBAAgB,CAAC,WAAW,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,EAAE,mBAAmB,UAAQ,GAAG,MAAM;IAIpG,OAAO,CAAC,KAAK;IAuCb;;;;;;;;;;;;OAYG;IACH,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IA+B/D,0EAA0E;IAC1E,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAQnC,wFAAwF;IACxF,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAKzC,8FAA8F;IAC9F,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM;IAIrC;;;OAGG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAI5B,wGAAwG;IACxG,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAOjC;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAMrB,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,YAAY;IAKpB;;;;;;;;OAQG;IACH,OAAO,CAAC,WAAW;IAWnB;;;;;;;;;OASG;IACH,OAAO,CAAC,qBAAqB;IAkC7B;;;;OAIG;IACH,qBAAqB,IAAI,OAAO;IAOhC,iGAAiG;IACjG,UAAU,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,GAAE,UAA+B,GAAG,OAAO;IAIhF,iGAAiG;IACjG,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,QAAQ;IAOhB;;;;;;;;;OASG;IACH,KAAK,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,GAAE,UAA+B,EAAE,EAAE,WAAmB,EAAE,QAAQ,EAAE,GAAE,YAAiB,GAAG,SAAS;IA4DnI,wEAAwE;IACxE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAM1B,iFAAiF;IACjF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAQzB;;;OAGG;IACH,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,oBAAoB,EAAE,GAAG,MAAM;IAsCvE,OAAO,IAAI,IAAI;CAMhB"}
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { BUDGET_PERIODS, cappedPeriods, findLimitBreach, hasSpendLimits } from './limits.js';
3
3
  import { parseBaseUnits } from './money.js';
4
+ import { isVideoReservationKey, videoReservationKey } from './video-jobs.js';
5
+ export { videoJobKey } from './video-jobs.js';
4
6
  /** The key's cap per period: the lower of the admins' and the owner's. */
5
7
  export function effectiveKeyLimits(key) {
6
8
  const result = {};
@@ -11,6 +13,21 @@ export function effectiveKeyLimits(key) {
11
13
  }
12
14
  /** Spend can be signed after the response ends (seller-initiated auth). */
13
15
  const DEFAULT_SETTLE_GRACE_MS = 60_000;
16
+ /**
17
+ * An accepted video nobody downloads stops counting against the caps after
18
+ * this long: as long as the buyer keeps the job's route and billing (30 days),
19
+ * so a video that can still be delivered and charged is never forgotten first.
20
+ */
21
+ const DEFAULT_VIDEO_COMMITMENT_MS = 30 * 24 * 60 * 60_000;
22
+ /**
23
+ * A delivered video is charged right after its file is received; if no charge
24
+ * has arrived this long after a complete delivery, none will (the buyer had no
25
+ * billing for the job, or the seller did not bill it), and the job stops
26
+ * holding its price.
27
+ */
28
+ const DELIVERY_CHARGE_WAIT_MS = 60 * 60_000;
29
+ /** The buyer's default limit for one video ($5), when its config sets none. */
30
+ export const DEFAULT_MAX_VIDEO_REQUEST_USDC = 5_000_000;
14
31
  export function newRequestTag() {
15
32
  return `gw_${randomUUID()}`;
16
33
  }
@@ -24,44 +41,274 @@ export class GatewayAccounting {
24
41
  _store;
25
42
  _options;
26
43
  _holds = new Map();
44
+ /** Accepted video jobs, by `videoJobKey`: their price counts against the caps until delivered, failed or expired. */
45
+ _videoCommitments = new Map();
46
+ /**
47
+ * Commitment writes that failed, retried before admitting a capped request
48
+ * (null: a delete). A job's write also removes its create's reservation
49
+ * (`replaces`), in the same transaction.
50
+ */
51
+ _pendingVideoWrites = new Map();
52
+ /** Whether the stored commitments were read; until then a capped request cannot be admitted safely. */
53
+ _videoCommitmentsLoaded = false;
27
54
  _settleGraceMs;
55
+ _videoCommitmentMs;
56
+ _maxVideoRequestUsdc;
28
57
  _now;
29
58
  constructor(_store, _options) {
30
59
  this._store = _store;
31
60
  this._options = _options;
32
61
  this._settleGraceMs = _options.settleGraceMs ?? DEFAULT_SETTLE_GRACE_MS;
62
+ this._videoCommitmentMs = _options.videoCommitmentMs ?? DEFAULT_VIDEO_COMMITMENT_MS;
63
+ this._maxVideoRequestUsdc = _options.maxVideoRequestUsdc ?? DEFAULT_MAX_VIDEO_REQUEST_USDC;
33
64
  this._now = _options.now ?? (() => Date.now());
65
+ this._loadVideoCommitments();
34
66
  }
35
- heldUsdc(keyId) {
36
- return this._held((hold) => hold.keyId === keyId);
67
+ /** In-flight holds plus accepted videos not yet charged, optionally leaving one video job out. */
68
+ heldUsdc(keyId, excludeVideoJob, withoutVideoCreates = false) {
69
+ return this._held((hold) => hold.keyId === keyId, excludeVideoJob, withoutVideoCreates);
37
70
  }
38
71
  /** Holds in flight for every key a member owns. */
39
- heldForMember(memberId) {
40
- return this._held((hold) => hold.memberId === memberId);
72
+ heldForMember(memberId, excludeVideoJob, withoutVideoCreates = false) {
73
+ return this._held((hold) => hold.memberId === memberId, excludeVideoJob, withoutVideoCreates);
41
74
  }
42
- heldForWorkspace(workspaceId) {
43
- return this._held((hold) => hold.workspaceId === workspaceId);
75
+ heldForWorkspace(workspaceId, excludeVideoJob, withoutVideoCreates = false) {
76
+ return this._held((hold) => hold.workspaceId === workspaceId, excludeVideoJob, withoutVideoCreates);
44
77
  }
45
- _held(match) {
78
+ _held(match, excludeVideoJob, withoutVideoCreates = false) {
46
79
  let held = 0;
47
80
  for (const hold of this._holds.values())
48
- if (match(hold))
81
+ if (match(hold) && !(withoutVideoCreates && hold.videoCreate))
49
82
  held += hold.amountUsdc;
83
+ const now = this._now();
84
+ // A revoked key cannot retrieve its jobs any more (only the creating key may), so they are never charged:
85
+ // unless a retrieve admitted before the revocation is still running, or a delivery's charge is still to come.
86
+ let retrieving = null;
87
+ const chargeable = (job, commitment) => {
88
+ if (commitment.deliveredAt !== null)
89
+ return true;
90
+ retrieving ??= new Set([...this._holds.values()].flatMap((hold) => hold.videoJob ? [hold.videoJob] : []));
91
+ return retrieving.has(job);
92
+ };
93
+ const active = new Map();
94
+ const isActive = (keyId) => {
95
+ let result = active.get(keyId);
96
+ if (result === undefined) {
97
+ try {
98
+ result = this._store.getKey(keyId)?.status !== 'revoked';
99
+ }
100
+ catch {
101
+ // Unreadable: keep counting it, the safe side.
102
+ result = true;
103
+ }
104
+ active.set(keyId, result);
105
+ }
106
+ return result;
107
+ };
108
+ for (const [job, commitment] of this._videoCommitments) {
109
+ if (commitment.expiresAt <= now) {
110
+ this._forgetVideo(job);
111
+ continue;
112
+ }
113
+ if (commitment.amountUsdc > 0 && commitment.deliveredAt !== null && now - commitment.deliveredAt >= DELIVERY_CHARGE_WAIT_MS) {
114
+ this._setVideoHeld(job, commitment, 0);
115
+ }
116
+ if (commitment.amountUsdc > 0 && job !== excludeVideoJob && match(commitment) && (isActive(commitment.keyId) || chargeable(job, commitment)))
117
+ held += commitment.amountUsdc;
118
+ }
50
119
  return held;
51
120
  }
121
+ /**
122
+ * A video create was accepted: remember which key owns the job (only that
123
+ * key may retrieve it) and keep `amountUsdc` counted against the caps of the
124
+ * key, member and workspace the create was admitted for until the buyer's
125
+ * charge for its delivery arrives, the buyer retires the job, or the record
126
+ * expires. A key without caps records the price too, so caps added later
127
+ * count the job.
128
+ *
129
+ * Part of the price may already be spent by the create itself (a one-off
130
+ * channel's opening fee counts toward the video): what is already in the
131
+ * ledger is not held again, and a charge on the create that arrives later
132
+ * lowers the commitment like any other charge of the job (see `ingest`).
133
+ */
134
+ commitVideo(tag, job, amountUsdc) {
135
+ const hold = this._holds.get(tag);
136
+ if (!hold)
137
+ return;
138
+ const existing = this._videoCommitments.get(job);
139
+ if (existing && existing.keyId !== hold.keyId && existing.expiresAt > this._now()) {
140
+ // Another key's job by the same ID: it keeps its owner and commitment. This create's job cannot be
141
+ // retrieved by its key (only the owner may), so it is never charged to it.
142
+ this._options.onLog?.('gateway ignored a video job ID that another key already owns');
143
+ this.videoNotAccepted(tag);
144
+ return;
145
+ }
146
+ let spent = 0;
147
+ try {
148
+ spent = this._store.spendForRequest(tag);
149
+ }
150
+ catch {
151
+ // Unreadable now: hold the whole price, never less than the job can still be charged.
152
+ }
153
+ const commitment = {
154
+ keyId: hold.keyId,
155
+ memberId: hold.memberId,
156
+ workspaceId: hold.workspaceId,
157
+ amountUsdc: Math.max(0, Math.floor(amountUsdc) - spent),
158
+ expiresAt: this._now() + this._videoCommitmentMs,
159
+ deliveredAt: null,
160
+ };
161
+ this._videoCommitments.set(job, commitment);
162
+ this._storeVideo(job, { job, ...commitment }, hold.reserved ? videoReservationKey(tag) : undefined);
163
+ hold.reserved = false;
164
+ hold.videoCreate = false;
165
+ }
166
+ /** A video create ended without an accepted job: its reservation goes. */
167
+ videoNotAccepted(tag) {
168
+ const hold = this._holds.get(tag);
169
+ if (!hold?.reserved)
170
+ return;
171
+ hold.reserved = false;
172
+ hold.videoCreate = false;
173
+ this._storeVideo(videoReservationKey(tag), null);
174
+ }
175
+ /** The key that created a video job through this gateway, if it is still remembered. */
176
+ videoJobOwner(job) {
177
+ const commitment = this._videoCommitments.get(job);
178
+ return commitment && commitment.expiresAt > this._now() ? commitment.keyId : null;
179
+ }
180
+ /** What a video job still holds against the caps (0 once charged, or for an uncapped key). */
181
+ videoJobHeldUsdc(job) {
182
+ return this._videoCommitments.get(job)?.amountUsdc ?? 0;
183
+ }
184
+ /**
185
+ * The buyer retired the job: it failed, its seller no longer has it, or the
186
+ * buyer has no record of it. It can never be delivered or charged.
187
+ */
188
+ dropVideo(job) {
189
+ if (this._videoCommitments.has(job))
190
+ this._forgetVideo(job);
191
+ }
192
+ /** A retrieve streamed the job's whole file: its charge should follow (see DELIVERY_CHARGE_WAIT_MS). */
193
+ videoDelivered(job) {
194
+ const commitment = this._videoCommitments.get(job);
195
+ if (!commitment || commitment.deliveredAt !== null)
196
+ return;
197
+ commitment.deliveredAt = this._now();
198
+ this._storeVideo(job, { job, ...commitment });
199
+ }
200
+ /**
201
+ * The buyer charged `amountUsdc` for the job's delivery: that much is in the
202
+ * ledger now, so the job holds that much less. Spend plus what the job still
203
+ * holds stays the same, whatever request the charge names. Ownership stays,
204
+ * so its owner can download it again (the buyer does not charge twice).
205
+ */
206
+ _videoCharged(job, amountUsdc) {
207
+ const commitment = this._videoCommitments.get(job);
208
+ if (!commitment || commitment.amountUsdc === 0)
209
+ return;
210
+ this._setVideoHeld(job, commitment, Math.max(0, commitment.amountUsdc - amountUsdc));
211
+ }
212
+ _setVideoHeld(job, commitment, amountUsdc) {
213
+ commitment.amountUsdc = amountUsdc;
214
+ this._storeVideo(job, { job, ...commitment });
215
+ }
216
+ _forgetVideo(job) {
217
+ this._videoCommitments.delete(job);
218
+ this._storeVideo(job, null);
219
+ }
220
+ /**
221
+ * Commitments live in memory first and are stored so a restart keeps them.
222
+ * A failed write (for example another process holding the database lock)
223
+ * does not fail the request that caused it; it is retried, and until it
224
+ * succeeds capped requests are refused (see `videoCommitmentsReady`).
225
+ * A restart before the retry still keeps the job counted: its create's
226
+ * reservation, stored before the create was sent, is only removed in the
227
+ * same transaction that stores the job (see `_loadVideoCommitments`).
228
+ */
229
+ _storeVideo(job, record, replaces) {
230
+ this._pendingVideoWrites.delete(job);
231
+ try {
232
+ if (record)
233
+ this._store.saveVideoCommitment(record, replaces);
234
+ else
235
+ this._store.deleteVideoCommitment(job);
236
+ }
237
+ catch (error) {
238
+ this._pendingVideoWrites.set(job, { record, ...(replaces !== undefined ? { replaces } : {}) });
239
+ this._options.onLog?.(`gateway could not store a video commitment: ${error instanceof Error ? error.message : String(error)}`);
240
+ }
241
+ }
242
+ /**
243
+ * Reads the stored commitments. A create's reservation still stored was
244
+ * left by a gateway that stopped before storing the create's job: when the
245
+ * request log names the job, the reservation becomes that job's commitment
246
+ * (the whole room the create was told about, less what it already spent),
247
+ * since its key can still retrieve it and be charged; otherwise the create
248
+ * was not accepted, or its job cannot be retrieved through this gateway
249
+ * (no record names its owner), so it is never charged and the reservation
250
+ * goes.
251
+ */
252
+ _loadVideoCommitments() {
253
+ try {
254
+ const rows = this._store.listVideoCommitments(this._now());
255
+ const stored = new Set(rows.map((row) => row.job));
256
+ for (const { job, ...commitment } of rows) {
257
+ if (!isVideoReservationKey(job)) {
258
+ if (!this._videoCommitments.has(job) && !this._pendingVideoWrites.has(job))
259
+ this._videoCommitments.set(job, commitment);
260
+ continue;
261
+ }
262
+ const tag = job.slice(videoReservationKey('').length);
263
+ const created = this._store.videoCreateJob(tag);
264
+ if (!created || stored.has(created) || this._videoCommitments.has(created)) {
265
+ this._store.deleteVideoCommitment(job);
266
+ continue;
267
+ }
268
+ // Everything already charged for the job (its create, polls and delivery) is spend, not held again; a
269
+ // delivered job gets the same hour for its charge as any other.
270
+ const jobId = created.slice(created.indexOf('\n') + 1);
271
+ const delivery = this._store.videoJobDeliveredAt(commitment.keyId, jobId);
272
+ const recovered = {
273
+ ...commitment,
274
+ amountUsdc: Math.max(0, commitment.amountUsdc - this._store.videoJobSpend(commitment.keyId, jobId)),
275
+ deliveredAt: delivery,
276
+ };
277
+ this._store.saveVideoCommitment({ job: created, ...recovered }, job);
278
+ stored.add(created);
279
+ this._videoCommitments.set(created, recovered);
280
+ }
281
+ this._videoCommitmentsLoaded = true;
282
+ }
283
+ catch (error) {
284
+ this._options.onLog?.(`gateway could not load video commitments: ${error instanceof Error ? error.message : String(error)}`);
285
+ }
286
+ }
287
+ /**
288
+ * Whether every video commitment is read and stored, so caps can be applied:
289
+ * retries a failed load and failed writes first. A delete that still fails
290
+ * only over-counts after a restart (the safe side) and does not block.
291
+ */
292
+ videoCommitmentsReady() {
293
+ if (!this._videoCommitmentsLoaded)
294
+ this._loadVideoCommitments();
295
+ for (const [job, { record, replaces }] of [...this._pendingVideoWrites])
296
+ this._storeVideo(job, record, replaces);
297
+ return this._videoCommitmentsLoaded && [...this._pendingVideoWrites.values()].every(({ record }) => record === null);
298
+ }
52
299
  /** Whether any level above or at the key caps spend (so admission depends on the spend feed). */
53
300
  hasBudgets(key, context = this._context(key)) {
54
301
  return this._budgetChecks(key, context).some(({ limits }) => hasSpendLimits(limits));
55
302
  }
56
303
  /** The key (the lower of its admin and owner caps), the member who owns it and its workspace. */
57
- _budgetChecks(key, { member, workspace }) {
304
+ _budgetChecks(key, { member, workspace }, excludeVideoJob) {
58
305
  const checks = [
59
- { level: 'key', limits: effectiveKeyLimits(key), scope: { keyId: key.id }, held: () => this.heldUsdc(key.id) },
306
+ { level: 'key', limits: effectiveKeyLimits(key), scope: { keyId: key.id }, held: (without) => this.heldUsdc(key.id, excludeVideoJob, without) },
60
307
  ];
61
308
  if (member)
62
- checks.push({ level: 'member', limits: member.limits, scope: { memberId: member.id }, held: () => this.heldForMember(member.id) });
309
+ checks.push({ level: 'member', limits: member.limits, scope: { memberId: member.id }, held: (without) => this.heldForMember(member.id, excludeVideoJob, without) });
63
310
  if (workspace)
64
- checks.push({ level: 'workspace', limits: workspace.limits, scope: { workspaceId: workspace.id }, held: () => this.heldForWorkspace(workspace.id) });
311
+ checks.push({ level: 'workspace', limits: workspace.limits, scope: { workspaceId: workspace.id }, held: (without) => this.heldForWorkspace(workspace.id, excludeVideoJob, without) });
65
312
  return checks;
66
313
  }
67
314
  _context(key) {
@@ -76,28 +323,72 @@ export class GatewayAccounting {
76
323
  * Member and workspace spend is the ledger as attributed at request time,
77
324
  * so a member who revokes a key and creates a new one still counts
78
325
  * everything spent before against the member's caps. Levels and periods
79
- * without a cap cost no query.
326
+ * without a cap cost no query. A video job's own polls and delivery are
327
+ * admitted without its own commitment (`videoJob`), which they would
328
+ * otherwise count twice.
80
329
  */
81
- admit(key, context = this._context(key)) {
330
+ admit(key, context = this._context(key), { videoCreate = false, videoJob } = {}) {
82
331
  const now = this._now();
83
- for (const { level, limits, scope, held } of this._budgetChecks(key, context)) {
332
+ // A job that still holds its price already covers its delivery's charge, and one that holds nothing (free,
333
+ // or already charged: the buyer charges a job once) has none to come: its own polls and download are
334
+ // admitted even when other spend has filled the caps since, so the job can always finish (or be retired).
335
+ const own = videoJob ? this._videoCommitments.get(videoJob) : undefined;
336
+ if (own && own.keyId === key.id && own.expiresAt > now) {
337
+ const tag = newRequestTag();
338
+ this._holds.set(tag, { keyId: key.id, memberId: key.ownerMemberId, workspaceId: key.workspaceId, amountUsdc: 0, finished: false, timer: null, videoJob });
339
+ return { ok: true, tag, remainingUsdc: null };
340
+ }
341
+ let remainingUsdc = null;
342
+ for (const { level, limits, scope, held } of this._budgetChecks(key, context, videoJob)) {
84
343
  const periods = cappedPeriods(limits);
85
344
  if (periods.length === 0)
86
345
  continue;
87
- const breach = findLimitBreach(limits, this._store.spendByPeriod(scope, now, periods), held(), now);
88
- if (breach)
346
+ const spent = this._store.spendByPeriod(scope, now, periods);
347
+ const heldUsdc = held();
348
+ const breach = findLimitBreach(limits, spent, heldUsdc, now);
349
+ if (breach) {
350
+ // Room a create holds while it is priced comes back in seconds: say so, rather than that the cap is reached.
351
+ if (findLimitBreach(limits, spent, held(true), now) === null)
352
+ return { ok: false, breach: null, reason: 'video_pricing' };
89
353
  return { ok: false, breach: { ...breach, level } };
354
+ }
355
+ for (const period of periods) {
356
+ const room = (limits[period] ?? 0) - (spent[period] ?? 0) - heldUsdc;
357
+ remainingUsdc = remainingUsdc === null ? room : Math.min(remainingUsdc, room);
358
+ }
90
359
  }
360
+ // A video create may start a job worth up to the room it is told about, so it
361
+ // holds all of that until the buyer reports the price: two creates in flight
362
+ // at once cannot both be told the same room.
363
+ if (videoCreate && remainingUsdc !== null)
364
+ remainingUsdc = Math.min(remainingUsdc, this._maxVideoRequestUsdc);
91
365
  const tag = newRequestTag();
92
- this._holds.set(tag, {
366
+ const hold = {
93
367
  keyId: key.id,
94
368
  memberId: key.ownerMemberId,
95
369
  workspaceId: key.workspaceId,
96
- amountUsdc: this._options.holdUsdc,
370
+ amountUsdc: videoCreate && remainingUsdc !== null ? remainingUsdc : this._options.holdUsdc,
97
371
  finished: false,
98
372
  timer: null,
99
- });
100
- return { ok: true, tag };
373
+ };
374
+ if (videoCreate && remainingUsdc !== null) {
375
+ // Stored before the create is sent, so a job the buyer accepts is never lost from the caps, even when
376
+ // its own commitment cannot be stored and the gateway restarts (see `_loadVideoCommitments`).
377
+ try {
378
+ this._store.saveVideoCommitment({
379
+ job: videoReservationKey(tag), keyId: hold.keyId, memberId: hold.memberId, workspaceId: hold.workspaceId,
380
+ amountUsdc: remainingUsdc, expiresAt: now + this._videoCommitmentMs, deliveredAt: null,
381
+ });
382
+ }
383
+ catch (error) {
384
+ this._options.onLog?.(`gateway could not store a video reservation: ${error instanceof Error ? error.message : String(error)}`);
385
+ return { ok: false, breach: null, reason: 'video_storage' };
386
+ }
387
+ hold.reserved = true;
388
+ hold.videoCreate = true;
389
+ }
390
+ this._holds.set(tag, hold);
391
+ return { ok: true, tag, remainingUsdc };
101
392
  }
102
393
  /** Drops a hold at once, for a request that never reached the buyer. */
103
394
  release(tag) {
@@ -142,10 +433,15 @@ export class GatewayAccounting {
142
433
  memberId: request.memberId,
143
434
  model: request.model,
144
435
  endUser: request.endUser,
436
+ routingFee: event.purpose === 'routing',
145
437
  });
146
438
  if (!inserted)
147
439
  continue;
148
440
  recorded += 1;
441
+ // A charge of a video job (its create's opening fee, a poll or its delivery): that much of its commitment is now spend.
442
+ // Spend without a request ID is only attributed to the seller's latest request, so it may not be the job's.
443
+ if (request.videoJob && event.requestId !== null)
444
+ this._videoCharged(request.videoJob, parseBaseUnits(event.amountUsdc));
149
445
  // Settled spend replaces that much of the request's worst-case hold.
150
446
  // The rest stays until the grace period ends: one request can sign
151
447
  // several deltas, and later ones still need to be covered.
@@ -160,6 +456,8 @@ export class GatewayAccounting {
160
456
  if (hold.timer)
161
457
  clearTimeout(hold.timer);
162
458
  this._holds.clear();
459
+ // Commitments stay stored: the next gateway reloads them.
460
+ this._videoCommitments.clear();
163
461
  }
164
462
  }
165
463
  /** The stricter of two caps; null is no cap. */