@antseed/node 0.2.128 → 0.2.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/billing/index.d.ts +1 -1
- package/dist/billing/index.d.ts.map +1 -1
- package/dist/billing/index.js +1 -1
- package/dist/billing/index.js.map +1 -1
- package/dist/buyer-identity-context.d.ts +87 -0
- package/dist/buyer-identity-context.d.ts.map +1 -0
- package/dist/buyer-identity-context.js +226 -0
- package/dist/buyer-identity-context.js.map +1 -0
- package/dist/discovery/metadata-codec.d.ts.map +1 -1
- package/dist/discovery/metadata-codec.js +95 -4
- package/dist/discovery/metadata-codec.js.map +1 -1
- package/dist/discovery/metadata-validator.d.ts.map +1 -1
- package/dist/discovery/metadata-validator.js +6 -4
- package/dist/discovery/metadata-validator.js.map +1 -1
- package/dist/discovery/peer-metadata.d.ts +1 -1
- package/dist/discovery/peer-metadata.d.ts.map +1 -1
- package/dist/discovery/peer-metadata.js +1 -1
- package/dist/discovery/peer-metadata.js.map +1 -1
- package/dist/discovery/service-catalog.d.ts +6 -2
- package/dist/discovery/service-catalog.d.ts.map +1 -1
- package/dist/discovery/service-catalog.js +13 -8
- package/dist/discovery/service-catalog.js.map +1 -1
- package/dist/health/model-health-checker.d.ts +1 -1
- package/dist/health/model-health-checker.d.ts.map +1 -1
- package/dist/health/model-health-checker.js +4 -3
- package/dist/health/model-health-checker.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/seller-provider.d.ts +4 -2
- package/dist/interfaces/seller-provider.d.ts.map +1 -1
- package/dist/node.d.ts +45 -11
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +221 -132
- package/dist/node.js.map +1 -1
- package/dist/p2p/connection-manager.d.ts.map +1 -1
- package/dist/p2p/connection-manager.js +7 -1
- package/dist/p2p/connection-manager.js.map +1 -1
- package/dist/p2p/outbound-connection.d.ts +14 -0
- package/dist/p2p/outbound-connection.d.ts.map +1 -0
- package/dist/p2p/outbound-connection.js +88 -0
- package/dist/p2p/outbound-connection.js.map +1 -0
- package/dist/payments/channel-store.d.ts +4 -0
- package/dist/payments/channel-store.d.ts.map +1 -1
- package/dist/payments/channel-store.js +53 -6
- package/dist/payments/channel-store.js.map +1 -1
- package/dist/payments/seller-payment-manager.d.ts +60 -1
- package/dist/payments/seller-payment-manager.d.ts.map +1 -1
- package/dist/payments/seller-payment-manager.js +399 -10
- package/dist/payments/seller-payment-manager.js.map +1 -1
- package/dist/resources/resource-ownership-store.d.ts +30 -0
- package/dist/resources/resource-ownership-store.d.ts.map +1 -0
- package/dist/resources/resource-ownership-store.js +72 -0
- package/dist/resources/resource-ownership-store.js.map +1 -0
- package/dist/seller-request-handler.d.ts +34 -0
- package/dist/seller-request-handler.d.ts.map +1 -1
- package/dist/seller-request-handler.js +322 -56
- package/dist/seller-request-handler.js.map +1 -1
- package/dist/storage/migrations/channels/006_add_video_payments.d.ts +3 -0
- package/dist/storage/migrations/channels/006_add_video_payments.d.ts.map +1 -0
- package/dist/storage/migrations/channels/006_add_video_payments.js +24 -0
- package/dist/storage/migrations/channels/006_add_video_payments.js.map +1 -0
- package/dist/storage/migrations/channels/index.d.ts.map +1 -1
- package/dist/storage/migrations/channels/index.js +2 -1
- package/dist/storage/migrations/channels/index.js.map +1 -1
- package/dist/storage/migrations/metering/003_create_resource_tables.d.ts +3 -0
- package/dist/storage/migrations/metering/003_create_resource_tables.d.ts.map +1 -0
- package/dist/storage/migrations/metering/003_create_resource_tables.js +34 -0
- package/dist/storage/migrations/metering/003_create_resource_tables.js.map +1 -0
- package/dist/storage/migrations/metering/index.d.ts.map +1 -1
- package/dist/storage/migrations/metering/index.js +2 -1
- package/dist/storage/migrations/metering/index.js.map +1 -1
- package/dist/verification/samples.d.ts.map +1 -1
- package/dist/verification/samples.js +2 -0
- package/dist/verification/samples.js.map +1 -1
- package/package.json +4 -4
|
@@ -2,10 +2,12 @@ import { ANTSEED_ATTEST_PATH } from './interfaces/plugin.js';
|
|
|
2
2
|
import { ProxyMux } from './proxy/proxy-mux.js';
|
|
3
3
|
import { computeCostUsdc, estimateTokensFromBytes, } from './payments/pricing.js';
|
|
4
4
|
import { debugLog, debugWarn } from './utils/debug.js';
|
|
5
|
-
import { CONNECTION_CAPABILITY_RESPONSE_AUTH_V1, PAYMENT_CODE_CHANNEL_EXHAUSTED } from './types/protocol.js';
|
|
6
|
-
import { createResponseAuthPayload } from './verification/response-auth.js';
|
|
5
|
+
import { CONNECTION_CAPABILITY_RESPONSE_AUTH_V1, DEFAULT_FIRST_SIGN_CAP, DEFAULT_TOP_UP_SETTLED_THRESHOLD_BPS, computeOneOffChannelPlan, PAYMENT_CODE_CHANNEL_EXHAUSTED, PAYMENT_CODE_ONE_OFF_CHANNEL_REQUIRED, } from './types/protocol.js';
|
|
6
|
+
import { createResponseAuthPayload, createStreamingResponseHash } from './verification/response-auth.js';
|
|
7
|
+
import { VIDEO_DOWNLOAD_STREAM_HEADER, VIDEO_DOWNLOAD_STREAM_VERSION } from '@antseed/protocol/http';
|
|
7
8
|
import { hasJsonContentType, tryParseJsonObject } from './utils/json-codec.js';
|
|
8
|
-
import { captureUnitBillingContext, computeFinalUnitBilling,
|
|
9
|
+
import { captureUnitBillingContext, computeFinalUnitBilling, estimateUnitRequestCost, isFreeUnitBillingModel } from './billing/unit.js';
|
|
10
|
+
import { nativeVideoAcceptance, nativeVideoDelivered, nativeVideoRoute, requestService } from '@antseed/api-adapter';
|
|
9
11
|
import { detectRequestServiceApiProtocol, extractRequestBodyFields, selectTargetProtocolForRequest, } from '@antseed/api-adapter';
|
|
10
12
|
import { parseResponseUsage } from './utils/response-usage.js';
|
|
11
13
|
function isZeroTokenPricing(pricing) {
|
|
@@ -13,6 +15,17 @@ function isZeroTokenPricing(pricing) {
|
|
|
13
15
|
&& pricing.outputUsdPerMillion === 0
|
|
14
16
|
&& (pricing.cachedInputUsdPerMillion == null || pricing.cachedInputUsdPerMillion === 0);
|
|
15
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Set a header the seller controls. Header names are case-insensitive, so any
|
|
20
|
+
* buyer-sent copy (in any letter case) is removed first and only ours remains.
|
|
21
|
+
*/
|
|
22
|
+
function setTrustedHeader(headers, name, value) {
|
|
23
|
+
for (const key of Object.keys(headers)) {
|
|
24
|
+
if (key.toLowerCase() === name)
|
|
25
|
+
delete headers[key];
|
|
26
|
+
}
|
|
27
|
+
headers[name] = value;
|
|
28
|
+
}
|
|
16
29
|
/** Debounce interval for metadata refresh after load changes. */
|
|
17
30
|
const METADATA_REFRESH_DEBOUNCE_MS = 200;
|
|
18
31
|
/** Time to wait for a catch-up SpendingAuth before returning 402. */
|
|
@@ -171,13 +184,42 @@ export class SellerRequestHandler {
|
|
|
171
184
|
return;
|
|
172
185
|
}
|
|
173
186
|
const requestPricing = this.resolveProviderPricing(provider, request);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
let requestBilling;
|
|
188
|
+
let unitBillingModel;
|
|
189
|
+
try {
|
|
190
|
+
requestBilling = this._captureSellerBillingContext(provider, request);
|
|
191
|
+
unitBillingModel = requestBilling ? this.resolveProviderUnitBillingModel(provider, requestBilling.context) : undefined;
|
|
192
|
+
const facts = requestBilling?.requestFacts;
|
|
193
|
+
if (requestBilling && facts?.kind === 'video' && facts.video.action === 'create' && unitBillingModel)
|
|
194
|
+
this._estimateUnitRequestCostUsdc(requestBilling, unitBillingModel);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
this._sendJsonError(mux, request.requestId, 400, 'invalid_billing_request', error instanceof Error ? error.message : String(error));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const videoRoute = nativeVideoRoute(request);
|
|
201
|
+
if (videoRoute && this._handleVideoPrecheck(mux, request, videoRoute, buyerPeerId, unitBillingModel))
|
|
202
|
+
return;
|
|
203
|
+
const isVideoRetrieve = videoRoute?.action === 'retrieve';
|
|
204
|
+
const isFreeService = isVideoRetrieve || isZeroTokenPricing(requestPricing)
|
|
179
205
|
&& (!unitBillingModel || isFreeUnitBillingModel(unitBillingModel));
|
|
180
|
-
|
|
206
|
+
// A video retrieve is paid by its job's create, never by the free tier:
|
|
207
|
+
// status polls and downloads must not use up or report free usage.
|
|
208
|
+
const isFreeTierRequest = isFreeService && !isVideoRetrieve;
|
|
209
|
+
let requestCostEstimate = null;
|
|
210
|
+
try {
|
|
211
|
+
requestCostEstimate = requestBilling && !isVideoRetrieve
|
|
212
|
+
? this._estimateRequestCostUsdc(request, requestBilling, requestPricing, unitBillingModel)
|
|
213
|
+
: null;
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
217
|
+
debugWarn(`[SellerHandler] Rejecting unbillable request: ${message}`);
|
|
218
|
+
this._sendJsonError(mux, request.requestId, 503, 'billing_tier_unmatched', `Seller billing configuration cannot price this request: ${message}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const estimatedRequestCost = requestCostEstimate?.cost ?? 0n;
|
|
222
|
+
if (isFreeTierRequest && this._deps.sellerFreeTierLimiter) {
|
|
181
223
|
const requestedService = this._extractRequestedService(request) ?? 'unknown';
|
|
182
224
|
let decision;
|
|
183
225
|
try {
|
|
@@ -235,18 +277,54 @@ export class SellerRequestHandler {
|
|
|
235
277
|
return;
|
|
236
278
|
}
|
|
237
279
|
}
|
|
238
|
-
// Reject with 402 if no active payment session and channels client is configured.
|
|
239
280
|
const spm = this._deps.sellerPaymentManager;
|
|
281
|
+
// A paid video create is paid from its own one-off channel, bound to this
|
|
282
|
+
// requestId, never from the buyer's session channel. Without one, offer
|
|
283
|
+
// the channel terms in a 402; the buyer opens it and resends the request.
|
|
284
|
+
let oneOffChannelId = null;
|
|
285
|
+
if (videoRoute?.action === 'create' && !isFreeService && spm && this._deps.channelsClient) {
|
|
286
|
+
const oneOff = spm.getOneOffChannelForRequest(buyerPeerId, request.requestId);
|
|
287
|
+
if (!oneOff) {
|
|
288
|
+
const plan = await this._buildOneOffPlan(estimatedRequestCost);
|
|
289
|
+
spm.registerOneOffPlan(buyerPeerId, request.requestId, plan);
|
|
290
|
+
debugLog(`[SellerHandler] Video create ${request.requestId} needs a one-off channel (price=${estimatedRequestCost})`);
|
|
291
|
+
const requirements = spm.getPaymentRequirements(request.requestId, buyerPeerId, requestPricing);
|
|
292
|
+
mux.sendProxyResponse({
|
|
293
|
+
requestId: request.requestId,
|
|
294
|
+
statusCode: 402,
|
|
295
|
+
headers: { 'content-type': 'application/json' },
|
|
296
|
+
body: new TextEncoder().encode(JSON.stringify({
|
|
297
|
+
error: 'payment_required',
|
|
298
|
+
code: PAYMENT_CODE_ONE_OFF_CHANNEL_REQUIRED,
|
|
299
|
+
minBudgetPerRequest: requirements.minBudgetPerRequest,
|
|
300
|
+
suggestedAmount: requirements.suggestedAmount,
|
|
301
|
+
oneOffPlan: plan,
|
|
302
|
+
})),
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (spm.getReserveMax(oneOff.sessionId) < estimatedRequestCost) {
|
|
307
|
+
this._sendJsonError(mux, request.requestId, 409, 'one_off_channel_mismatch', 'The payment channel opened for this video does not cover its price');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!spm.claimOneOffChannel(oneOff.sessionId)) {
|
|
311
|
+
this._sendJsonError(mux, request.requestId, 409, 'one_off_channel_used', 'The payment channel opened for this video was already used');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
oneOffChannelId = oneOff.sessionId;
|
|
315
|
+
}
|
|
316
|
+
// Reject with 402 if no active payment session and channels client is configured.
|
|
240
317
|
const spmAuthorized = spm?.hasSession(buyerPeerId) ?? false;
|
|
241
|
-
if (this._deps.channelsClient && !spmAuthorized) {
|
|
318
|
+
if (this._deps.channelsClient && !spmAuthorized && !oneOffChannelId) {
|
|
242
319
|
// Free services skip the payment channel handshake entirely — no 402,
|
|
243
320
|
// no ReserveAuth, no on-chain reserve.
|
|
244
321
|
if (isFreeService) {
|
|
245
322
|
debugLog(`[SellerHandler] Free service for ${buyerPeerId.slice(0, 12)}... — skipping 402 / payment channel`);
|
|
246
323
|
}
|
|
247
324
|
else {
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
325
|
+
const baseRequirements = spm?.getPaymentRequirements(request.requestId, buyerPeerId, requestPricing);
|
|
326
|
+
if (baseRequirements) {
|
|
327
|
+
const requirements = baseRequirements;
|
|
250
328
|
debugLog(`[SellerHandler] No payment session for ${buyerPeerId.slice(0, 12)}... — sending 402 + PaymentRequired`);
|
|
251
329
|
const paymentBody = JSON.stringify({
|
|
252
330
|
error: 'payment_required',
|
|
@@ -282,7 +360,7 @@ export class SellerRequestHandler {
|
|
|
282
360
|
// Check budget before routing — reject if buyer hasn't authorized enough.
|
|
283
361
|
// Free requests must not be blocked by an existing exhausted/blocked paid
|
|
284
362
|
// payment channel for the same buyer.
|
|
285
|
-
if (spm && !isFreeService) {
|
|
363
|
+
if (spm && !isFreeService && !oneOffChannelId) {
|
|
286
364
|
const initialSession = spm.getChannelByPeer(buyerPeerId);
|
|
287
365
|
if (initialSession) {
|
|
288
366
|
// Drain any in-flight SpendingAuth processing (e.g. an on-chain top-up
|
|
@@ -332,30 +410,6 @@ export class SellerRequestHandler {
|
|
|
332
410
|
debugLog(`[SellerHandler] Caught up before 402 for ${buyerPeerId.slice(0, 12)}... (spent=${spent} accepted=${accepted})`);
|
|
333
411
|
}
|
|
334
412
|
}
|
|
335
|
-
let requestCostEstimate = null;
|
|
336
|
-
try {
|
|
337
|
-
requestCostEstimate = requestBilling
|
|
338
|
-
? this._estimateRequestCostUsdc(request, requestBilling, requestPricing, unitBillingModel)
|
|
339
|
-
: null;
|
|
340
|
-
}
|
|
341
|
-
catch (err) {
|
|
342
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
343
|
-
debugWarn(`[SellerHandler] Rejecting unbillable request: ${message}`);
|
|
344
|
-
mux.sendProxyResponse({
|
|
345
|
-
requestId: request.requestId,
|
|
346
|
-
statusCode: 503,
|
|
347
|
-
headers: { 'content-type': 'application/json' },
|
|
348
|
-
body: new TextEncoder().encode(JSON.stringify({
|
|
349
|
-
error: {
|
|
350
|
-
message: `Seller billing configuration cannot price this request: ${message}`,
|
|
351
|
-
type: 'billing_configuration_error',
|
|
352
|
-
code: 'billing_tier_unmatched',
|
|
353
|
-
},
|
|
354
|
-
})),
|
|
355
|
-
});
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
const estimatedRequestCost = requestCostEstimate?.cost ?? 0n;
|
|
359
413
|
const remainingLockedReserve = reserveMax > spent ? reserveMax - spent : 0n;
|
|
360
414
|
const reserveEstimateOverdraft = this._deps.reserveEstimateOverdraftUsdc;
|
|
361
415
|
const effectiveEstimateLimit = reserveEstimateOverdraft != null
|
|
@@ -454,13 +508,18 @@ export class SellerRequestHandler {
|
|
|
454
508
|
};
|
|
455
509
|
// Track active seller session at request start
|
|
456
510
|
this._deps.sessionTracker?.getOrCreateSession(buyerPeerId, provider.name);
|
|
457
|
-
|
|
511
|
+
// Tell the provider who the buyer really is (from the authenticated connection).
|
|
512
|
+
// Needed now that video jobs belong to one buyer: a provider that checks job
|
|
513
|
+
// ownership by this header must not see a buyer-sent copy with different casing.
|
|
514
|
+
setTrustedHeader(request.headers, 'x-antseed-buyer-peer-id', buyerPeerId);
|
|
458
515
|
const requestedModel = this._extractRequestedService(request) ?? 'unknown';
|
|
459
516
|
debugLog(`[SellerHandler] Routing to provider "${provider.name}" model="${requestedModel}"`);
|
|
460
517
|
const startTime = Date.now();
|
|
461
518
|
let statusCode = 500;
|
|
462
519
|
let responseBody = new Uint8Array(0);
|
|
463
520
|
let streamedResponseStarted = false;
|
|
521
|
+
const isDownload = videoRoute?.action === 'retrieve' && request.headers[VIDEO_DOWNLOAD_STREAM_HEADER] === VIDEO_DOWNLOAD_STREAM_VERSION;
|
|
522
|
+
let downloadHash;
|
|
464
523
|
let heldDoneChunkData = null;
|
|
465
524
|
let responseStartedAt = startTime;
|
|
466
525
|
let responseForAuth = null;
|
|
@@ -472,7 +531,7 @@ export class SellerRequestHandler {
|
|
|
472
531
|
// Hold the channel open for the whole billable span — provider call,
|
|
473
532
|
// spend recording, and NeedAuth — so a buyer-requested close can't land
|
|
474
533
|
// between serving the request and claiming its cost.
|
|
475
|
-
const isBillable = !isFreeService && (spm?.hasSession(buyerPeerId) ?? false);
|
|
534
|
+
const isBillable = !isFreeService && !oneOffChannelId && (spm?.hasSession(buyerPeerId) ?? false);
|
|
476
535
|
if (isBillable && spm.hasClosingChannel(buyerPeerId)) {
|
|
477
536
|
mux.sendProxyResponse({
|
|
478
537
|
requestId: request.requestId,
|
|
@@ -490,8 +549,11 @@ export class SellerRequestHandler {
|
|
|
490
549
|
this.adjustProviderLoad(provider.name, 1);
|
|
491
550
|
try {
|
|
492
551
|
try {
|
|
493
|
-
|
|
552
|
+
let response = await this._executeRequest(provider, request, {
|
|
553
|
+
signal: isDownload ? mux.downloadSignal(request.requestId) : undefined,
|
|
494
554
|
onResponseStart: (streamResponseStart) => {
|
|
555
|
+
if (isDownload)
|
|
556
|
+
downloadHash = createStreamingResponseHash(streamResponseStart);
|
|
495
557
|
streamedResponseStarted = true;
|
|
496
558
|
responseStartedAt = Date.now();
|
|
497
559
|
statusCode = streamResponseStart.statusCode;
|
|
@@ -502,6 +564,11 @@ export class SellerRequestHandler {
|
|
|
502
564
|
onResponseChunk: (chunk) => {
|
|
503
565
|
if (!streamedResponseStarted)
|
|
504
566
|
return;
|
|
567
|
+
if (downloadHash) {
|
|
568
|
+
downloadHash.update(chunk.data);
|
|
569
|
+
if (!chunk.done)
|
|
570
|
+
return mux.sendDownloadChunk(chunk);
|
|
571
|
+
}
|
|
505
572
|
// Hold the done chunk — send it after usage is parsed so we can append cost trailer
|
|
506
573
|
if (chunk.done) {
|
|
507
574
|
heldDoneChunkData = chunk.data;
|
|
@@ -510,9 +577,15 @@ export class SellerRequestHandler {
|
|
|
510
577
|
mux.sendProxyChunk(chunk);
|
|
511
578
|
},
|
|
512
579
|
});
|
|
580
|
+
// A video is charged when the buyer downloads it, not on acceptance.
|
|
581
|
+
if (videoRoute?.action === 'create') {
|
|
582
|
+
response = this._acceptVideoCreate(videoRoute, request, response, buyerPeerId, requestedModel, requestBilling, unitBillingModel, oneOffChannelId);
|
|
583
|
+
}
|
|
513
584
|
statusCode = response.statusCode;
|
|
514
585
|
responseBody = response.body ?? new Uint8Array(0);
|
|
515
586
|
responseForAuth = response;
|
|
587
|
+
if (downloadHash)
|
|
588
|
+
responseForAuth = { ...response, streamedBody: downloadHash.finish() };
|
|
516
589
|
if (statusCode >= 400) {
|
|
517
590
|
const errBody = new TextDecoder().decode(responseBody).slice(0, 200);
|
|
518
591
|
debugWarn(`[SellerHandler] Provider error response: status=${statusCode} provider="${provider.name}" model="${requestedModel}" buyer=${buyerPeerId.slice(0, 12)}... (${Date.now() - startTime}ms) body=${errBody}`);
|
|
@@ -520,7 +593,12 @@ export class SellerRequestHandler {
|
|
|
520
593
|
else {
|
|
521
594
|
debugLog(`[SellerHandler] Provider responded: status=${statusCode} (${Date.now() - startTime}ms, ${responseBody.length}b)`);
|
|
522
595
|
}
|
|
523
|
-
if (
|
|
596
|
+
if (videoRoute?.action === 'create') {
|
|
597
|
+
// Nothing is charged for the create itself; see _chargeDeliveredVideo.
|
|
598
|
+
billingUsageReport = null;
|
|
599
|
+
unitCostUsdc = 0n;
|
|
600
|
+
}
|
|
601
|
+
else if (requestBilling && unitBillingModel) {
|
|
524
602
|
const unitBilling = computeFinalUnitBilling(unitBillingModel, requestBilling.context, response, requestBilling.requestFacts);
|
|
525
603
|
responseUsage = unitBilling.tokenUsage;
|
|
526
604
|
billingUsageReport = unitBilling.billingUsage;
|
|
@@ -546,8 +624,15 @@ export class SellerRequestHandler {
|
|
|
546
624
|
catch (err) {
|
|
547
625
|
const message = err instanceof Error ? err.message : "Internal error";
|
|
548
626
|
debugWarn(`[SellerHandler] Provider exception: provider="${provider.name}" model="${requestedModel}" buyer=${buyerPeerId.slice(0, 12)}... (${Date.now() - startTime}ms) ${message}`);
|
|
627
|
+
if (videoRoute?.action === 'create')
|
|
628
|
+
this._closeOneOffBestEffort(oneOffChannelId, 'video create failed');
|
|
549
629
|
responseBody = new TextEncoder().encode(message);
|
|
550
|
-
if (streamedResponseStarted) {
|
|
630
|
+
if (streamedResponseStarted && isDownload) {
|
|
631
|
+
statusCode = 502;
|
|
632
|
+
responseForAuth = null;
|
|
633
|
+
mux.sendProxyError(request.requestId);
|
|
634
|
+
}
|
|
635
|
+
else if (streamedResponseStarted) {
|
|
551
636
|
const errorFrame = new TextEncoder().encode(`event: error\ndata: ${message}\n\n`);
|
|
552
637
|
responseBody = errorFrame;
|
|
553
638
|
mux.sendProxyChunk({
|
|
@@ -580,7 +665,7 @@ export class SellerRequestHandler {
|
|
|
580
665
|
mux.sendProxyResponse(responseForAuth);
|
|
581
666
|
}
|
|
582
667
|
}
|
|
583
|
-
if (requestBilling && unitBillingModel && responseForAuth && billingUsageReport === null) {
|
|
668
|
+
if (requestBilling && unitBillingModel && responseForAuth && billingUsageReport === null && videoRoute?.action !== 'create') {
|
|
584
669
|
const finalBilling = computeFinalUnitBilling(unitBillingModel, requestBilling.context, responseForAuth, requestBilling.requestFacts);
|
|
585
670
|
responseUsage = finalBilling.tokenUsage;
|
|
586
671
|
billingUsageReport = finalBilling.billingUsage;
|
|
@@ -597,14 +682,14 @@ export class SellerRequestHandler {
|
|
|
597
682
|
statusCode,
|
|
598
683
|
latencyMs,
|
|
599
684
|
inputBytes: request.body.length,
|
|
600
|
-
outputBytes: responseBody.length,
|
|
685
|
+
outputBytes: responseForAuth?.streamedBody?.byteLength ?? responseBody.length,
|
|
601
686
|
responseBody,
|
|
602
687
|
providerUsage: responseUsage,
|
|
603
688
|
});
|
|
604
689
|
}
|
|
605
690
|
// Record spend and send NeedAuth with cost data after every request.
|
|
606
691
|
// The buyer validates the cost independently and responds with SpendingAuth.
|
|
607
|
-
if (!isFreeService && spm?.hasSession(buyerPeerId)) {
|
|
692
|
+
if (!isFreeService && !oneOffChannelId && spm?.hasSession(buyerPeerId)) {
|
|
608
693
|
const usage = responseUsage;
|
|
609
694
|
const tokenCostUsdc = computeCostUsdc(usage.freshInputTokens, usage.outputTokens, requestPricing, usage.cachedInputTokens);
|
|
610
695
|
const costUsdc = tokenCostUsdc + unitCostUsdc;
|
|
@@ -632,7 +717,7 @@ export class SellerRequestHandler {
|
|
|
632
717
|
}, buyerPeerId, 'post-response');
|
|
633
718
|
}
|
|
634
719
|
}
|
|
635
|
-
else if (
|
|
720
|
+
else if (isFreeTierRequest) {
|
|
636
721
|
this._deps.sellerFreeUsageManager?.reportUsageRequest(buyerPeerId, paymentMux, {
|
|
637
722
|
requestId: request.requestId,
|
|
638
723
|
inputTokens: responseUsage.inputTokens,
|
|
@@ -640,9 +725,13 @@ export class SellerRequestHandler {
|
|
|
640
725
|
service: this._extractRequestedService(request) ?? undefined,
|
|
641
726
|
});
|
|
642
727
|
}
|
|
728
|
+
if (isVideoRetrieve && responseForAuth) {
|
|
729
|
+
this._chargeDeliveredVideo(videoRoute, responseForAuth, buyerPeerId, paymentMux, request.requestId);
|
|
730
|
+
this._closeFailedVideoJob(videoRoute, responseForAuth, buyerPeerId);
|
|
731
|
+
}
|
|
643
732
|
const buyerSupportsResponseAuth = conn.hasRemoteCapability(CONNECTION_CAPABILITY_RESPONSE_AUTH_V1);
|
|
644
733
|
if (responseForAuth && buyerSupportsResponseAuth) {
|
|
645
|
-
const channelId = spm?.getChannelByPeer(buyerPeerId)?.sessionId ?? null;
|
|
734
|
+
const channelId = oneOffChannelId ?? spm?.getChannelByPeer(buyerPeerId)?.sessionId ?? null;
|
|
646
735
|
this._sendResponseAuthBestEffort(verificationMux, responseAuthRequest, responseForAuth, {
|
|
647
736
|
buyerPeerId,
|
|
648
737
|
providerName: provider.name,
|
|
@@ -661,6 +750,157 @@ export class SellerRequestHandler {
|
|
|
661
750
|
});
|
|
662
751
|
return { mux };
|
|
663
752
|
}
|
|
753
|
+
/** Returns true when it already sent a response. */
|
|
754
|
+
_handleVideoPrecheck(mux, request, route, buyerPeerId, unitBillingModel) {
|
|
755
|
+
if (!unitBillingModel) {
|
|
756
|
+
this._sendJsonError(mux, request.requestId, 503, 'billing_configuration_error', 'Video service requires explicit unit pricing');
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
const store = this._deps.resourceOwnershipStore;
|
|
760
|
+
if (!store) {
|
|
761
|
+
this._sendJsonError(mux, request.requestId, 503, 'resource_ownership_unavailable', 'Seller cannot verify video job ownership');
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
if (route.action !== 'retrieve')
|
|
765
|
+
return false;
|
|
766
|
+
try {
|
|
767
|
+
if (route.resourceId && store.getOwner(route.protocol, route.resourceId) === buyerPeerId.toLowerCase())
|
|
768
|
+
return false;
|
|
769
|
+
this._sendJsonError(mux, request.requestId, 404, 'resource_not_found', 'Video job not found');
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
debugWarn(`[SellerHandler] Video ownership lookup failed: ${err instanceof Error ? err.message : err}`);
|
|
774
|
+
this._sendJsonError(mux, request.requestId, 503, 'resource_ownership_unavailable', 'Seller cannot verify video job ownership');
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Charge a video once, when the buyer first receives the finished file. The
|
|
780
|
+
* price is charged to the job's one-off channel; the buyer's SpendingAuth
|
|
781
|
+
* for it lets the seller close that channel and release the rest.
|
|
782
|
+
*/
|
|
783
|
+
_chargeDeliveredVideo(route, response, buyerPeerId, paymentMux, requestId) {
|
|
784
|
+
const store = this._deps.resourceOwnershipStore;
|
|
785
|
+
const spm = this._deps.sellerPaymentManager;
|
|
786
|
+
if (!store || !spm || !route.resourceId)
|
|
787
|
+
return;
|
|
788
|
+
const buyer = buyerPeerId.toLowerCase();
|
|
789
|
+
try {
|
|
790
|
+
const charge = store.getPendingCharge(route.protocol, route.resourceId, buyer);
|
|
791
|
+
if (!charge || !nativeVideoDelivered(response, charge.durationSeconds))
|
|
792
|
+
return;
|
|
793
|
+
const channel = spm.getChannel(charge.channelId);
|
|
794
|
+
if (!channel || channel.status !== 'active' || !spm.isOneOffChannel(channel.sessionId))
|
|
795
|
+
return;
|
|
796
|
+
if (!store.markCharged(route.protocol, route.resourceId))
|
|
797
|
+
return;
|
|
798
|
+
// The channel pays for this video alone: its total spend is the price.
|
|
799
|
+
const alreadySpent = spm.getCumulativeSpend(channel.sessionId);
|
|
800
|
+
try {
|
|
801
|
+
if (charge.amount > alreadySpent)
|
|
802
|
+
spm.recordSpend(channel.sessionId, charge.amount - alreadySpent);
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
store.unmarkCharged(route.protocol, route.resourceId);
|
|
806
|
+
throw err;
|
|
807
|
+
}
|
|
808
|
+
const cumulativeSpend = spm.getCumulativeSpend(channel.sessionId);
|
|
809
|
+
debugLog(`[SellerHandler] Video delivered: buyer=${buyerPeerId.slice(0, 12)}... job=${route.resourceId} cost=${charge.amount} channel=${channel.sessionId.slice(0, 18)}...`);
|
|
810
|
+
this._sendNeedAuthBestEffort(paymentMux, {
|
|
811
|
+
channelId: channel.sessionId,
|
|
812
|
+
requiredCumulativeAmount: cumulativeSpend.toString(),
|
|
813
|
+
currentAcceptedCumulative: spm.getAcceptedCumulative(channel.sessionId).toString(),
|
|
814
|
+
deposit: spm.getReserveMax(channel.sessionId).toString(),
|
|
815
|
+
requestId,
|
|
816
|
+
lastRequestCost: charge.amount.toString(),
|
|
817
|
+
inputTokens: '0',
|
|
818
|
+
outputTokens: '0',
|
|
819
|
+
cachedInputTokens: '0',
|
|
820
|
+
freshInputTokens: '0',
|
|
821
|
+
service: charge.service,
|
|
822
|
+
billingUsage: charge.billingUsage,
|
|
823
|
+
}, buyerPeerId, 'video-delivered');
|
|
824
|
+
}
|
|
825
|
+
catch (err) {
|
|
826
|
+
debugWarn(`[SellerHandler] Failed to charge delivered video ${route.resourceId}: ${err instanceof Error ? err.message : err}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* After a video create, save the job's owner and its price (charged later,
|
|
831
|
+
* when the buyer downloads the video) against the job's one-off channel.
|
|
832
|
+
* Returns the reply to send: the provider's reply, or a 503 if the job could
|
|
833
|
+
* not be saved, so no job is handed out that could never be charged. When
|
|
834
|
+
* no job was accepted, the one-off channel is closed at once so the buyer
|
|
835
|
+
* gets back everything but the already-settled serious fee.
|
|
836
|
+
*/
|
|
837
|
+
_acceptVideoCreate(route, request, response, buyerPeerId, service, requestBilling, model, channelId) {
|
|
838
|
+
const resourceId = nativeVideoAcceptance(route.protocol, response);
|
|
839
|
+
if (!resourceId) {
|
|
840
|
+
this._closeOneOffBestEffort(channelId, 'video not accepted');
|
|
841
|
+
return response;
|
|
842
|
+
}
|
|
843
|
+
const billing = requestBilling && model && channelId
|
|
844
|
+
? computeFinalUnitBilling(model, requestBilling.context, response, requestBilling.requestFacts)
|
|
845
|
+
: null;
|
|
846
|
+
const facts = requestBilling?.requestFacts;
|
|
847
|
+
const durationSeconds = facts?.kind === 'video' ? facts.video.duration : undefined;
|
|
848
|
+
const charge = billing && channelId && billing.costUsdc > 0n
|
|
849
|
+
? { channelId, service, amount: billing.costUsdc, billingUsage: billing.billingUsage, ...(durationSeconds ? { durationSeconds } : {}) }
|
|
850
|
+
: undefined;
|
|
851
|
+
try {
|
|
852
|
+
this._deps.resourceOwnershipStore?.recordAcceptedCreate(route.protocol, resourceId, buyerPeerId.toLowerCase(), charge);
|
|
853
|
+
if (!charge)
|
|
854
|
+
this._closeOneOffBestEffort(channelId, 'free video');
|
|
855
|
+
return response;
|
|
856
|
+
}
|
|
857
|
+
catch (err) {
|
|
858
|
+
debugWarn(`[SellerHandler] Failed to record video job ownership: ${err instanceof Error ? err.message : err}`);
|
|
859
|
+
this._closeOneOffBestEffort(channelId, 'video job not recorded');
|
|
860
|
+
return {
|
|
861
|
+
requestId: request.requestId,
|
|
862
|
+
statusCode: 503,
|
|
863
|
+
headers: { 'content-type': 'application/json' },
|
|
864
|
+
body: new TextEncoder().encode(JSON.stringify({ error: { code: 'resource_ownership_unavailable', message: 'Seller cannot record video job ownership' } })),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Close the one-off channel of a video job that failed upstream, so the
|
|
870
|
+
* buyer's reserve is released without waiting for the channel TTL.
|
|
871
|
+
*/
|
|
872
|
+
_closeFailedVideoJob(route, response, buyerPeerId) {
|
|
873
|
+
if (!route.resourceId || response.streamedBody)
|
|
874
|
+
return;
|
|
875
|
+
const body = tryParseJsonObject(response.body);
|
|
876
|
+
const status = typeof body?.status === 'string' ? body.status.toUpperCase() : '';
|
|
877
|
+
if (status !== 'FAILED' && status !== 'ERROR' && status !== 'CANCELLED')
|
|
878
|
+
return;
|
|
879
|
+
try {
|
|
880
|
+
const charge = this._deps.resourceOwnershipStore?.getPendingCharge(route.protocol, route.resourceId, buyerPeerId.toLowerCase());
|
|
881
|
+
if (charge)
|
|
882
|
+
this._closeOneOffBestEffort(charge.channelId, `video job ${status.toLowerCase()}`);
|
|
883
|
+
}
|
|
884
|
+
catch (err) {
|
|
885
|
+
debugWarn(`[SellerHandler] Failed to look up failed video job ${route.resourceId}: ${err instanceof Error ? err.message : err}`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
_closeOneOffBestEffort(channelId, reason) {
|
|
889
|
+
const spm = this._deps.sellerPaymentManager;
|
|
890
|
+
if (!channelId || !spm?.isOneOffChannel(channelId))
|
|
891
|
+
return;
|
|
892
|
+
void spm.closeOneOffChannel(channelId, reason).catch((err) => {
|
|
893
|
+
debugWarn(`[SellerHandler] Failed to close one-off channel ${channelId.slice(0, 18)}...: ${err instanceof Error ? err.message : err}`);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
_sendJsonError(mux, requestId, statusCode, code, message) {
|
|
897
|
+
mux.sendProxyResponse({
|
|
898
|
+
requestId,
|
|
899
|
+
statusCode,
|
|
900
|
+
headers: { 'content-type': 'application/json' },
|
|
901
|
+
body: new TextEncoder().encode(JSON.stringify({ error: { code, message } })),
|
|
902
|
+
});
|
|
903
|
+
}
|
|
664
904
|
// -- Local /v1/models handler --
|
|
665
905
|
_handleModelsRequest(request) {
|
|
666
906
|
const allServices = this._deps.providers.flatMap((p) => p.services);
|
|
@@ -708,13 +948,14 @@ export class SellerRequestHandler {
|
|
|
708
948
|
return undefined;
|
|
709
949
|
}
|
|
710
950
|
const requestedProvider = this._extractRequestedProvider(request);
|
|
951
|
+
const videoRoute = nativeVideoRoute(request);
|
|
711
952
|
const providers = this._deps.providers;
|
|
712
|
-
const matchesService = (provider) => provider.services.includes(requestedService);
|
|
953
|
+
const matchesService = (provider) => provider.services.includes(requestedService) && (!videoRoute || Boolean(provider.serviceApiProtocols?.[requestedService]?.includes(videoRoute.protocol)));
|
|
713
954
|
let provider;
|
|
714
955
|
if (requestedProvider) {
|
|
715
956
|
provider = providers.find((candidate) => candidate.name.toLowerCase() === requestedProvider && matchesService(candidate));
|
|
716
957
|
}
|
|
717
|
-
if (!provider) {
|
|
958
|
+
if (!provider && !(videoRoute && requestedProvider)) {
|
|
718
959
|
provider = providers.find((candidate) => matchesService(candidate));
|
|
719
960
|
}
|
|
720
961
|
return provider;
|
|
@@ -755,6 +996,8 @@ export class SellerRequestHandler {
|
|
|
755
996
|
return hasJsonContentType(request.headers);
|
|
756
997
|
}
|
|
757
998
|
_extractRequestedService(request) {
|
|
999
|
+
if (nativeVideoRoute(request))
|
|
1000
|
+
return requestService(request) ?? null;
|
|
758
1001
|
const body = extractRequestBodyFields(request.headers, request.body);
|
|
759
1002
|
const service = body?.["service"] ?? body?.["model"];
|
|
760
1003
|
if (typeof service !== "string" || service.trim().length === 0) {
|
|
@@ -794,17 +1037,40 @@ export class SellerRequestHandler {
|
|
|
794
1037
|
return billingProtocols[0] ?? "openai-chat-completions";
|
|
795
1038
|
}
|
|
796
1039
|
_estimateUnitRequestCostUsdc(requestBilling, model) {
|
|
797
|
-
const usage = {
|
|
798
|
-
units: {
|
|
799
|
-
output_images: Math.floor(requestBilling.requestUsage.units.output_images ?? 0),
|
|
800
|
-
},
|
|
801
|
-
};
|
|
802
1040
|
return {
|
|
803
|
-
cost:
|
|
1041
|
+
cost: estimateUnitRequestCost(model, requestBilling.context, requestBilling.requestUsage),
|
|
804
1042
|
inputTokens: 0,
|
|
805
1043
|
maxOutputTokens: 0,
|
|
806
1044
|
};
|
|
807
1045
|
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Terms of a one-off channel for one video. The contract caps a fresh
|
|
1048
|
+
* reserve at FIRST_SIGN_CAP, so a dearer video opens at that cap and tops up
|
|
1049
|
+
* to its price; topUp() first needs TOP_UP_SETTLED_THRESHOLD_BPS of the
|
|
1050
|
+
* opening reserve settled (the serious fee, credited toward the price).
|
|
1051
|
+
*/
|
|
1052
|
+
async _buildOneOffPlan(requestCost) {
|
|
1053
|
+
let firstSignCap = DEFAULT_FIRST_SIGN_CAP;
|
|
1054
|
+
let thresholdBps = DEFAULT_TOP_UP_SETTLED_THRESHOLD_BPS;
|
|
1055
|
+
const channelsClient = this._deps.channelsClient;
|
|
1056
|
+
try {
|
|
1057
|
+
const cap = await channelsClient?.getFirstSignCap();
|
|
1058
|
+
if (cap != null && cap > 0n)
|
|
1059
|
+
firstSignCap = cap;
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
debugWarn(`[SellerHandler] Failed to read first-sign cap; using ${firstSignCap}: ${err instanceof Error ? err.message : err}`);
|
|
1063
|
+
}
|
|
1064
|
+
try {
|
|
1065
|
+
const configured = await channelsClient?.getTopUpSettledThresholdBps();
|
|
1066
|
+
if (configured != null && configured > 0n && configured <= 10000n)
|
|
1067
|
+
thresholdBps = configured;
|
|
1068
|
+
}
|
|
1069
|
+
catch (err) {
|
|
1070
|
+
debugWarn(`[SellerHandler] Failed to read top-up threshold; using ${thresholdBps}: ${err instanceof Error ? err.message : err}`);
|
|
1071
|
+
}
|
|
1072
|
+
return computeOneOffChannelPlan(requestCost, firstSignCap, thresholdBps);
|
|
1073
|
+
}
|
|
808
1074
|
_estimateRequestCostUsdc(request, requestBilling, pricing, unitModel) {
|
|
809
1075
|
const tokenEstimate = this._estimateMaxTokenRequestCostUsdc(request, pricing);
|
|
810
1076
|
const unitEstimate = unitModel
|