@tangle-network/agent-gateway 0.7.1 → 0.8.1

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 (64) hide show
  1. package/README.md +90 -3
  2. package/dist/chunk-C7Z2BRYV.js +5693 -0
  3. package/dist/chunk-C7Z2BRYV.js.map +1 -0
  4. package/dist/chunk-GITV7CPT.js +84 -0
  5. package/dist/chunk-GITV7CPT.js.map +1 -0
  6. package/dist/chunk-J5SDVHOL.js +104 -0
  7. package/dist/chunk-J5SDVHOL.js.map +1 -0
  8. package/dist/index.d.ts +70 -10
  9. package/dist/index.js +303 -21
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +7 -2
  12. package/dist/middleware.js +3 -2
  13. package/dist/nonce-store.d.ts +47 -11
  14. package/dist/nonce-store.js +9 -3
  15. package/dist/observer-types-A0RtA8uL.d.ts +95 -0
  16. package/dist/observer.d.ts +79 -0
  17. package/dist/observer.js +11 -0
  18. package/dist/observer.js.map +1 -0
  19. package/dist/{types-DEsMmS-X.d.ts → types-oQ58UakD.d.ts} +447 -172
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/execution-fence.ts +162 -0
  23. package/src/a2a/handler.ts +506 -560
  24. package/src/a2a/message-send-execution.ts +241 -0
  25. package/src/a2a/message-stream-execution.ts +392 -0
  26. package/src/a2a/payment-recovery.ts +431 -0
  27. package/src/a2a/push-config-methods.ts +158 -0
  28. package/src/a2a/push-notifications.ts +172 -22
  29. package/src/a2a/task-cancellation.ts +50 -0
  30. package/src/a2a/task-finalization.ts +451 -0
  31. package/src/a2a/task-lifecycle.ts +54 -0
  32. package/src/a2a/task-methods.ts +163 -0
  33. package/src/a2a/task-push-delivery.ts +119 -0
  34. package/src/a2a/task-recovery.ts +11 -0
  35. package/src/a2a/task-state.ts +99 -0
  36. package/src/a2a/task-store-sql.ts +222 -24
  37. package/src/a2a/task-store.ts +58 -1
  38. package/src/a2a/task-submission-recovery.ts +178 -0
  39. package/src/a2a/types.ts +1 -0
  40. package/src/dispatch-authorization.ts +468 -0
  41. package/src/dispatch-payment-recovery.ts +248 -0
  42. package/src/dispatch-payment.ts +425 -0
  43. package/src/dispatch-pricing.ts +108 -0
  44. package/src/dispatch-sandbox.ts +424 -0
  45. package/src/dispatch-settlement.ts +139 -0
  46. package/src/dispatch-types.ts +84 -0
  47. package/src/dispatch.ts +35 -483
  48. package/src/index.ts +59 -1
  49. package/src/middleware.ts +339 -35
  50. package/src/mpp-payment.ts +117 -0
  51. package/src/nonce-store.ts +122 -20
  52. package/src/observer-types.ts +63 -0
  53. package/src/observer.ts +3 -63
  54. package/src/payment-operations.ts +485 -0
  55. package/src/payment-recovery-sql.ts +108 -0
  56. package/src/payment-recovery-worker.ts +488 -0
  57. package/src/payment-recovery.ts +331 -0
  58. package/src/payment-types.ts +48 -0
  59. package/src/types.ts +188 -49
  60. package/src/verify.ts +240 -71
  61. package/dist/chunk-M7ZJAK4K.js +0 -53
  62. package/dist/chunk-M7ZJAK4K.js.map +0 -1
  63. package/dist/chunk-Q4YAIEZY.js +0 -1763
  64. package/dist/chunk-Q4YAIEZY.js.map +0 -1
package/src/middleware.ts CHANGED
@@ -6,15 +6,30 @@ import {
6
6
  type AuthorizedRequest,
7
7
  type GatewayState,
8
8
  authenticateAndGuard,
9
- dispatchSandboxStream,
10
- estimateTokens,
9
+ beginPaymentExecution,
10
+ markPaymentExecutionStarted,
11
+ renewPaymentExecution,
12
+ claimPayment,
13
+ dispatchSandboxStreamRich,
14
+ releasePayment,
15
+ releasePaymentAfterFailure,
11
16
  settleAndRecord,
12
17
  } from './dispatch'
13
- import { MemoryNonceStore } from './nonce-store'
18
+ import { isAtomicNonceStore, MemoryNonceStore } from './nonce-store'
14
19
  import { type GatewayObserver, type RequestContext, generateRequestId } from './observer'
20
+ import {
21
+ MemoryPaymentRecoveryStore,
22
+ PaymentRecoveryReplayError,
23
+ assertPaymentRecoveryConfig,
24
+ } from './payment-recovery'
15
25
  import { MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
16
- import type { ChatCompletionChunk, ChatCompletionRequest, GatewayConfig } from './types'
17
- import { isApiKeyAuthEnabled, isMppAuthEnabled } from './verify'
26
+ import type {
27
+ ChatCompletionChunk,
28
+ ChatCompletionRequest,
29
+ CreateAgentGatewayConfig,
30
+ GatewayConfig,
31
+ } from './types'
32
+ import { isApiKeyAuthEnabled, isMppAuthEnabled, isX402AuthEnabled } from './verify'
18
33
 
19
34
  /**
20
35
  * Create a Hono router that serves the agent gateway.
@@ -26,15 +41,136 @@ import { isApiKeyAuthEnabled, isMppAuthEnabled } from './verify'
26
41
  * GET /:slug/chat/completions — agent discovery metadata (Tangle-native shape)
27
42
  * POST /:slug/chat/completions — OpenAI-compatible chat endpoint (paid)
28
43
  */
29
- export function createAgentGateway(config: GatewayConfig) {
44
+ export function createAgentGateway(inputConfig: CreateAgentGatewayConfig) {
45
+ let config: GatewayConfig = inputConfig.x402
46
+ ? inputConfig
47
+ : {
48
+ ...inputConfig,
49
+ x402: { operatorAddress: '', chainId: 0 },
50
+ }
30
51
  // Production gateways must verify x402 signatures. Tests and local
31
52
  // dev can opt into the explicit demo path.
32
- if (!config.x402.verifySigner && !config.x402.demoMode) {
53
+ if (!isX402AuthEnabled(config) && !config.verifyApiKey) {
33
54
  throw new Error(
34
- 'createAgentGateway: x402.verifySigner is required in production. ' +
55
+ 'createAgentGateway: verifySigner is required in production unless verifyApiKey is configured; ' +
56
+ 'configure x402.verifySigner or verifyApiKey. ' +
35
57
  'For tests, set x402.demoMode: true explicitly.',
36
58
  )
37
59
  }
60
+ const maxOutputTokens = config.maxOutputTokens ?? 4096
61
+ const defaultOutputTokens = config.defaultOutputTokens ?? 1024
62
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) {
63
+ throw new Error('createAgentGateway: maxOutputTokens must be a positive integer')
64
+ }
65
+ if (
66
+ !Number.isSafeInteger(defaultOutputTokens) ||
67
+ defaultOutputTokens <= 0 ||
68
+ defaultOutputTokens > maxOutputTokens
69
+ ) {
70
+ throw new Error(
71
+ 'createAgentGateway: defaultOutputTokens must be a positive integer no greater than maxOutputTokens',
72
+ )
73
+ }
74
+ if (
75
+ config.x402.currencyDecimals !== undefined &&
76
+ (!Number.isInteger(config.x402.currencyDecimals) ||
77
+ config.x402.currencyDecimals < 0 ||
78
+ config.x402.currencyDecimals > 18)
79
+ ) {
80
+ throw new Error('createAgentGateway: x402.currencyDecimals must be an integer between 0 and 18')
81
+ }
82
+ const executionBudget = config.executionBudget
83
+ for (const [name, value] of [
84
+ ['maxReasoningTokens', executionBudget?.maxReasoningTokens ?? maxOutputTokens],
85
+ ['maxToolTokens', executionBudget?.maxToolTokens ?? maxOutputTokens],
86
+ ['maxToolCalls', executionBudget?.maxToolCalls ?? 8],
87
+ ] as const) {
88
+ if (!Number.isSafeInteger(value) || value < 0) {
89
+ throw new Error(`createAgentGateway: executionBudget.${name} must be a non-negative safe integer`)
90
+ }
91
+ }
92
+ if (
93
+ executionBudget?.maxProviderCostUsd !== undefined &&
94
+ (!Number.isFinite(executionBudget.maxProviderCostUsd) || executionBudget.maxProviderCostUsd < 0)
95
+ ) {
96
+ throw new Error('createAgentGateway: executionBudget.maxProviderCostUsd must be finite and non-negative')
97
+ }
98
+ if (config.x402.paymentOperations && config.x402.paymentProtocolVersion === undefined) {
99
+ throw new Error('createAgentGateway: paymentProtocolVersion must be explicit when durable payment operations are configured')
100
+ }
101
+ if (
102
+ config.x402.authorizePayment &&
103
+ config.x402.paymentProtocolVersion !== 2 &&
104
+ !config.x402.demoMode
105
+ ) {
106
+ throw new Error(
107
+ 'createAgentGateway: production x402 version 1 cannot use authorizePayment; ' +
108
+ 'use paymentProtocolVersion: 2 with paymentOperations for durable payment ownership',
109
+ )
110
+ }
111
+ if (config.x402.paymentProtocolVersion === 2 &&
112
+ (!config.x402.paymentOperations || config.x402.paymentOperations.protocolVersion !== 2)) {
113
+ throw new Error('createAgentGateway: payment protocol version 2 requires durable payment operations')
114
+ }
115
+ if (config.x402.paymentProtocolVersion === 1 && config.x402.paymentOperations) {
116
+ throw new Error('createAgentGateway: version 1 cannot be combined with version 2 payment operations')
117
+ }
118
+ if (
119
+ config.a2a?.pushStore &&
120
+ !config.x402.demoMode &&
121
+ (!config.a2a.webhookSecret || config.a2a.webhookSecret.trim().length === 0)
122
+ ) {
123
+ throw new Error('createAgentGateway: production A2A push requires a webhookSecret')
124
+ }
125
+ const mppMethod = (config.mpp?.method ?? 'blueprintevm').toLowerCase()
126
+ if (config.mpp?.authenticateCredential !== undefined &&
127
+ typeof config.mpp.authenticateCredential !== 'function') {
128
+ throw new Error('createAgentGateway: mpp.authenticateCredential must be a function')
129
+ }
130
+ if (config.mpp?.verifySigner !== undefined && typeof config.mpp.verifySigner !== 'function') {
131
+ throw new Error('createAgentGateway: mpp.verifySigner must be a function')
132
+ }
133
+ const mppAuthenticator = typeof config.mpp?.authenticateCredential === 'function'
134
+ ? config.mpp.authenticateCredential
135
+ : typeof config.mpp?.verifySigner === 'function'
136
+ ? config.mpp.verifySigner
137
+ : undefined
138
+ if (config.mpp?.charge && config.mpp.charge.protocolVersion !== 1) {
139
+ throw new Error('createAgentGateway: unsupported MPP charge lifecycle version')
140
+ }
141
+ if (
142
+ config.mpp?.charge &&
143
+ mppMethod !== 'blueprintevm' &&
144
+ !mppAuthenticator
145
+ ) {
146
+ throw new Error('createAgentGateway: generic MPP methods require credential authentication')
147
+ }
148
+ if (
149
+ config.mpp &&
150
+ mppMethod !== 'blueprintevm' &&
151
+ mppAuthenticator &&
152
+ !config.mpp.charge
153
+ ) {
154
+ throw new Error('createAgentGateway: generic MPP methods require a charge lifecycle')
155
+ }
156
+ if (config.mpp && mppMethod !== 'blueprintevm' && !mppAuthenticator) {
157
+ throw new Error('createAgentGateway: generic MPP methods require credential authentication')
158
+ }
159
+ if (config.nonceStore && !isAtomicNonceStore(config.nonceStore)) {
160
+ throw new Error('createAgentGateway: durable payment ownership requires an atomic nonce store')
161
+ }
162
+ const needsRecovery = config.x402.paymentProtocolVersion === 2 ||
163
+ (mppMethod !== 'blueprintevm' && config.mpp?.charge !== undefined)
164
+ if (needsRecovery && !config.paymentRecovery) {
165
+ if (!config.x402.demoMode) {
166
+ throw new Error('createAgentGateway: durable payment recovery is required in production')
167
+ }
168
+ config = {
169
+ ...config,
170
+ paymentRecovery: { store: new MemoryPaymentRecoveryStore() },
171
+ }
172
+ }
173
+ if (config.paymentRecovery) assertPaymentRecoveryConfig(config.paymentRecovery)
38
174
  const gw = new Hono()
39
175
  const rateLimitStore: RateLimitStore = config.rateLimitStore ?? new MemoryRateLimitStore()
40
176
  const state: GatewayState = {
@@ -43,6 +179,12 @@ export function createAgentGateway(config: GatewayConfig) {
43
179
  globalRateLimit: config.rateLimit ?? { limit: 60, windowSeconds: 60 },
44
180
  requiredScope: config.requiredScope ?? 'chat',
45
181
  maxLen: config.maxMessageLength ?? 8000,
182
+ maxOutputTokens,
183
+ defaultOutputTokens,
184
+ maxReasoningTokens: config.executionBudget?.maxReasoningTokens ?? maxOutputTokens,
185
+ maxToolTokens: config.executionBudget?.maxToolTokens ?? maxOutputTokens,
186
+ maxToolCalls: config.executionBudget?.maxToolCalls ?? 8,
187
+ maxProviderCostUsd: config.executionBudget?.maxProviderCostUsd,
46
188
  obs: config.observer,
47
189
  }
48
190
  const obs: GatewayObserver | undefined = state.obs
@@ -54,14 +196,15 @@ export function createAgentGateway(config: GatewayConfig) {
54
196
  const agent = await config.resolveAgent(slug)
55
197
  if (!agent || !agent.enabled) return c.json({ error: 'Agent not found or not published' }, 404)
56
198
 
57
- const paymentMethods: Array<Record<string, unknown>> = [
58
- {
199
+ const paymentMethods: Array<Record<string, unknown>> = []
200
+ if (isX402AuthEnabled(config)) {
201
+ paymentMethods.push({
59
202
  type: 'x402',
60
203
  operator: config.x402.operatorAddress,
61
204
  chain_id: config.x402.chainId,
62
205
  credits_contract: config.x402.creditsAddress,
63
- },
64
- ]
206
+ })
207
+ }
65
208
  if (isMppAuthEnabled(config)) {
66
209
  paymentMethods.push({
67
210
  type: 'mpp',
@@ -69,7 +212,9 @@ export function createAgentGateway(config: GatewayConfig) {
69
212
  method: config.mpp!.method ?? 'blueprintevm',
70
213
  })
71
214
  }
72
- if (isApiKeyAuthEnabled(config)) paymentMethods.push({ type: 'api_key', prefix: 'sk_agent_' })
215
+ if (isApiKeyAuthEnabled(config)) {
216
+ paymentMethods.push({ type: 'api_key', prefix: config.apiKeyPrefix ?? 'sk_agent_' })
217
+ }
73
218
 
74
219
  return c.json({
75
220
  slug: agent.slug,
@@ -125,9 +270,62 @@ export function createAgentGateway(config: GatewayConfig) {
125
270
  )
126
271
  }
127
272
 
128
- const guard = await authenticateAndGuard(c, slug, body.messages, config, state)
273
+ const guard = await authenticateAndGuard(
274
+ c,
275
+ slug,
276
+ body.messages,
277
+ config,
278
+ state,
279
+ body.max_tokens,
280
+ )
129
281
  if (guard instanceof Response) return guard
130
282
  const authz = guard
283
+ try {
284
+ await claimPayment(authz, config, state)
285
+ } catch (error) {
286
+ const replayedGenericMpp = error instanceof PaymentRecoveryReplayError &&
287
+ authz.paymentMethod === 'mpp' &&
288
+ authz.mppMethod !== 'blueprintevm'
289
+ try {
290
+ await releasePayment(authz, config, 'payment authorization failed')
291
+ } catch (releaseError) {
292
+ console.error(
293
+ `[agent-gateway] payment release failed for ${authz.requestId}:`,
294
+ releaseError instanceof Error ? releaseError.message : String(releaseError),
295
+ )
296
+ }
297
+ await obs?.onAuthFailure?.(
298
+ {
299
+ requestId: authz.requestId,
300
+ agentSlug: authz.agent.slug,
301
+ startMs: authz.startMs,
302
+ },
303
+ {
304
+ method: authz.paymentMethod,
305
+ code: replayedGenericMpp ? 'invalid_mpp_credential' : 'payment_authorization_failed',
306
+ httpStatus: replayedGenericMpp ? 401 : 402,
307
+ },
308
+ )
309
+ const status = replayedGenericMpp ? 401 : 402
310
+ return c.json(
311
+ {
312
+ error: {
313
+ message: replayedGenericMpp ? 'Invalid Payment credential' : 'Payment authorization failed',
314
+ type: replayedGenericMpp ? 'authentication_error' : 'payment_required',
315
+ code: replayedGenericMpp ? 'invalid_mpp_credential' : 'payment_authorization_failed',
316
+ },
317
+ },
318
+ replayedGenericMpp
319
+ ? {
320
+ status,
321
+ headers: {
322
+ 'WWW-Authenticate': `Payment realm="${config.mpp!.realm}", method="${config.mpp!.method ?? 'blueprintevm'}"`,
323
+ 'X-Request-Id': authz.requestId,
324
+ },
325
+ }
326
+ : { status, headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': authz.requestId } },
327
+ )
328
+ }
131
329
 
132
330
  return streamChatCompletions(c, authz, config, obs)
133
331
  })
@@ -139,13 +337,37 @@ export function createAgentGateway(config: GatewayConfig) {
139
337
  // regardless of which protocol the caller used.
140
338
  const taskStore = config.a2a?.taskStore ?? new InMemoryTaskStore()
141
339
  const pushStore = config.a2a?.pushStore
142
- const a2a = createA2AHandlers({ config, state, taskStore, pushStore })
143
- gw.get('/:slug/.well-known/agent.json', a2a.handleAgentCard)
144
- gw.post('/:slug', a2a.handleJsonRpc)
340
+ try {
341
+ const a2a = createA2AHandlers({ config, state, taskStore, pushStore })
342
+ gw.get('/:slug/.well-known/agent.json', a2a.handleAgentCard)
343
+ gw.post('/:slug', a2a.handleJsonRpc)
344
+ } catch (error) {
345
+ // An older custom store must not take down the OpenAI surface. Keep the
346
+ // A2A surface unavailable until its owner supplies atomic methods.
347
+ console.error(
348
+ '[agent-gateway] A2A is unavailable until its task store is upgraded:',
349
+ error instanceof Error ? error.message : String(error),
350
+ )
351
+ const unavailable = (c: import('hono').Context) => c.json(
352
+ { error: 'A2A task persistence is not configured for concurrent workers' },
353
+ 503,
354
+ )
355
+ gw.get('/:slug/.well-known/agent.json', unavailable)
356
+ gw.post('/:slug', unavailable)
357
+ }
145
358
 
146
359
  return gw
147
360
  }
148
361
 
362
+ // Consumers that bind the gateway through a package boundary can fail closed
363
+ // when an old binary ignores the version 2 operation contract.
364
+ Object.assign(createAgentGateway, { paymentProtocolVersion: 2 as const })
365
+
366
+ /** Public package-boundary version marker for durable payment operations. */
367
+ export namespace createAgentGateway {
368
+ export const paymentProtocolVersion = 2 as const
369
+ }
370
+
149
371
  /**
150
372
  * Drain the sandbox stream into an OpenAI-shaped SSE response, settle the
151
373
  * payment, fire observer hooks. Identical pre-refactor behavior, just lifted
@@ -158,9 +380,23 @@ function streamChatCompletions(
158
380
  config: GatewayConfig,
159
381
  obs: GatewayObserver | undefined,
160
382
  ): Response {
161
- const { agent, consumerId, paymentMethod, requestId, userMessage, rateLimitRemaining } = authz
162
- const inputTokens = estimateTokens(userMessage)
163
- let outputTokens = 0
383
+ const {
384
+ agent,
385
+ consumerId,
386
+ paymentMethod,
387
+ requestId,
388
+ userMessage,
389
+ rateLimitRemaining,
390
+ maxOutputTokens,
391
+ } = authz
392
+ let outputText = ''
393
+ let usage: import('./types').SandboxUsageReceipt | undefined
394
+ let workObserved = false
395
+ const requestSignal = c.req.raw.signal
396
+ const abortController = new AbortController()
397
+ const abortFromRequest = () => abortController.abort()
398
+ if (requestSignal.aborted) abortFromRequest()
399
+ else requestSignal.addEventListener('abort', abortFromRequest, { once: true })
164
400
  const ctx: RequestContext = {
165
401
  requestId,
166
402
  agentSlug: agent.slug,
@@ -171,7 +407,8 @@ function streamChatCompletions(
171
407
  async start(controller) {
172
408
  const encoder = new TextEncoder()
173
409
  const sendChunk = (delta: string) => {
174
- outputTokens += estimateTokens(delta)
410
+ if (controller.desiredSize === null) return
411
+ outputText += delta
175
412
  const chunk: ChatCompletionChunk = {
176
413
  id: `chatcmpl-${Date.now()}`,
177
414
  object: 'chat.completion.chunk',
@@ -183,10 +420,49 @@ function streamChatCompletions(
183
420
  }
184
421
 
185
422
  try {
186
- for await (const delta of dispatchSandboxStream(agent, userMessage, consumerId, config)) {
187
- sendChunk(delta)
423
+ for await (const event of dispatchSandboxStreamRich(
424
+ agent,
425
+ userMessage,
426
+ consumerId,
427
+ config,
428
+ abortController.signal,
429
+ authz.threadId,
430
+ maxOutputTokens,
431
+ () => beginPaymentExecution(authz, config),
432
+ authz.paymentOperation !== undefined || authz.mppChargeOperation !== undefined,
433
+ async () => {
434
+ if (authz.paymentRecoveryId) workObserved = true
435
+ await markPaymentExecutionStarted(authz, config)
436
+ },
437
+ authz.executionBudget.maxInputTokens,
438
+ () => renewPaymentExecution(authz, config),
439
+ {
440
+ consumerId,
441
+ paymentMethod,
442
+ keyInfo: authz.keyInfo,
443
+ requestId,
444
+ messages: authz.messages ?? [],
445
+ ...(authz.threadId ? { threadId: authz.threadId } : {}),
446
+ },
447
+ )) {
448
+ if (event.kind === 'text') {
449
+ sendChunk(event.delta)
450
+ workObserved = true
451
+ }
452
+ if (event.kind === 'activity') workObserved = true
453
+ if (event.kind === 'usage') usage = event.usage
188
454
  }
189
455
 
456
+ if (!usage) throw new Error('sandbox did not provide a usage receipt')
457
+
458
+ await settleAndRecord(
459
+ agent,
460
+ authz,
461
+ usage,
462
+ config,
463
+ obs,
464
+ )
465
+
190
466
  const done: ChatCompletionChunk = {
191
467
  id: `chatcmpl-${Date.now()}`,
192
468
  object: 'chat.completion.chunk',
@@ -194,10 +470,10 @@ function streamChatCompletions(
194
470
  model: agent.slug,
195
471
  choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
196
472
  }
197
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
198
- controller.enqueue(encoder.encode('data: [DONE]\n\n'))
199
-
200
- await settleAndRecord(agent, authz, inputTokens, outputTokens, config, obs)
473
+ if (controller.desiredSize !== null) {
474
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
475
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
476
+ }
201
477
  } catch (err) {
202
478
  const rawMessage = err instanceof Error ? err.message : String(err)
203
479
  // Never expose stack traces / absolute paths from sandbox internals.
@@ -205,16 +481,37 @@ function streamChatCompletions(
205
481
  rawMessage.includes('/') || rawMessage.includes('\\')
206
482
  ? 'Internal agent error'
207
483
  : rawMessage
208
- await obs?.onStreamError?.(ctx, { consumerId, errorMessage: rawMessage })
209
- controller.enqueue(
210
- encoder.encode(
211
- `data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`,
212
- ),
213
- )
484
+ try {
485
+ await obs?.onStreamError?.(ctx, { consumerId, errorMessage: rawMessage })
486
+ } catch (observerError) {
487
+ console.error(
488
+ `[agent-gateway] stream observer failed for ${requestId}:`,
489
+ observerError instanceof Error ? observerError.message : String(observerError),
490
+ )
491
+ }
492
+ try {
493
+ await releasePaymentAfterFailure(authz, config, rawMessage, workObserved || usage !== undefined)
494
+ } catch (releaseError) {
495
+ console.error(
496
+ `[agent-gateway] payment release failed for ${authz.requestId}:`,
497
+ releaseError instanceof Error ? releaseError.message : String(releaseError),
498
+ )
499
+ }
500
+ if (!abortController.signal.aborted && controller.desiredSize !== null) {
501
+ controller.enqueue(
502
+ encoder.encode(
503
+ `data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`,
504
+ ),
505
+ )
506
+ }
214
507
  } finally {
215
- controller.close()
508
+ requestSignal.removeEventListener('abort', abortFromRequest)
509
+ if (controller.desiredSize !== null) controller.close()
216
510
  }
217
511
  },
512
+ cancel() {
513
+ abortController.abort()
514
+ },
218
515
  })
219
516
 
220
517
  return new Response(stream, {
@@ -224,8 +521,15 @@ function streamChatCompletions(
224
521
  'X-Request-Id': requestId,
225
522
  'X-Agent-Slug': agent.slug,
226
523
  'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
524
+ ...(authz.threadId ? { 'X-Tangle-Thread-Id': authz.threadId } : {}),
227
525
  'X-Payment-Method': paymentMethod,
228
- 'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',
526
+ 'X-Payment-Settled': paymentMethod === 'x402' || authz.paymentOperation ? 'pending' : 'true',
527
+ ...(authz.mppChargeOperation
528
+ ? { 'Payment-Receipt': authz.mppChargeOperation.receipt }
529
+ : {}),
530
+ ...(authz.paymentRecoveryId
531
+ ? { 'X-Payment-Operation-Id': authz.paymentRecoveryId }
532
+ : {}),
229
533
  ...(rateLimitRemaining !== undefined
230
534
  ? { 'X-RateLimit-Remaining': String(rateLimitRemaining) }
231
535
  : {}),
@@ -0,0 +1,117 @@
1
+ /** Version of the gateway's method-specific MPP charge contract. */
2
+ export const MPP_CHARGE_PROTOCOL_VERSION = 1 as const
3
+
4
+ export type MppChargeOperationState = 'confirmed' | 'releasing' | 'released'
5
+
6
+ /** Pure authentication result for one method credential. */
7
+ export interface MppAuthenticatedCredential {
8
+ consumerId: string
9
+ /**
10
+ * Stable, non-secret processor identity for this payment credential.
11
+ * Return the same value for equivalent encodings of one credential.
12
+ * The gateway hashes this value before it persists or claims it.
13
+ */
14
+ paymentIdentity: string
15
+ }
16
+
17
+ /** Durable result of one immediate MPP charge. */
18
+ export interface MppChargeOperation {
19
+ protocolVersion: typeof MPP_CHARGE_PROTOCOL_VERSION
20
+ operationId: string
21
+ acquiredByRequestId: string
22
+ method: string
23
+ /** A complete Payment-Receipt header value. */
24
+ receipt: string
25
+ state: MppChargeOperationState
26
+ }
27
+
28
+ export interface MppChargeRequest {
29
+ /**
30
+ * Stable provider idempotency key. The adapter must bind every processor
31
+ * operation to this value before it attempts confirmation.
32
+ */
33
+ operationId: string
34
+ requestId: string
35
+ agentId: string
36
+ consumerId: string
37
+ method: string
38
+ /** Original decoded credential. It is available only on the live request. */
39
+ credential: string
40
+ amount: bigint
41
+ currencyDecimals: number
42
+ }
43
+
44
+ export type MppChargeRecoveryResult =
45
+ | MppChargeOperation
46
+ /** `not-found` is final and must fence this operation ID against a later charge. */
47
+ | { operationId: string; state: 'not-found' | 'pending' }
48
+
49
+ /**
50
+ * Immediate-charge lifecycle for a non-BlueprinTEVM MPP method.
51
+ *
52
+ * `confirmPayment` runs after every request denial and before a response or
53
+ * sandbox call. It must use `operationId` as its processor idempotency key,
54
+ * confirm payment, verify final success, and only then return `confirmed`.
55
+ *
56
+ * Every method must also support id-only recovery and an idempotent release.
57
+ * Recovery must inspect the existing processor operation. It must never
58
+ * create a second charge when an acknowledgement is ambiguous.
59
+ */
60
+ export interface MppChargeLifecycle {
61
+ readonly protocolVersion: typeof MPP_CHARGE_PROTOCOL_VERSION
62
+ confirmPayment(request: MppChargeRequest): Promise<MppChargeOperation>
63
+ releasePayment(operation: MppChargeOperation, reason: string): Promise<MppChargeOperation>
64
+ recoverPayment(operationId: string): Promise<MppChargeRecoveryResult>
65
+ }
66
+
67
+ /** Stable gateway identity. Neither the credential nor adapter identity is persisted. */
68
+ export async function mppPaymentOperationId(
69
+ method: string,
70
+ paymentIdentity: string,
71
+ ): Promise<string> {
72
+ const normalizedMethod = method.trim().toLowerCase()
73
+ if (!normalizedMethod || normalizedMethod.length > 128) {
74
+ throw new Error('MPP payment method is invalid')
75
+ }
76
+ if (!paymentIdentity || paymentIdentity.length > 8192) {
77
+ throw new Error('MPP payment identity is invalid')
78
+ }
79
+ const digest = await globalThis.crypto.subtle.digest(
80
+ 'SHA-256',
81
+ new TextEncoder().encode(`${normalizedMethod}\0${paymentIdentity}`),
82
+ )
83
+ const fingerprint = [...new Uint8Array(digest)]
84
+ .map((byte) => byte.toString(16).padStart(2, '0'))
85
+ .join('')
86
+ return `mpp:${normalizedMethod}:${fingerprint}`
87
+ }
88
+
89
+ export function assertMppChargeOperation(
90
+ operation: MppChargeOperation,
91
+ expected: { operationId: string; requestId: string; method: string },
92
+ allowedStates: readonly MppChargeOperationState[],
93
+ requireReceipt = true,
94
+ ): void {
95
+ if (operation.protocolVersion !== MPP_CHARGE_PROTOCOL_VERSION) {
96
+ throw new Error('MPP charge operation protocol version mismatch')
97
+ }
98
+ if (operation.operationId !== expected.operationId) {
99
+ throw new Error('MPP charge operation id mismatch')
100
+ }
101
+ if (operation.acquiredByRequestId !== expected.requestId) {
102
+ throw new Error('MPP charge operation request owner mismatch')
103
+ }
104
+ if (operation.method.toLowerCase() !== expected.method.toLowerCase()) {
105
+ throw new Error('MPP charge operation method mismatch')
106
+ }
107
+ if (!allowedStates.includes(operation.state)) {
108
+ throw new Error(`MPP charge operation is in invalid state ${operation.state}`)
109
+ }
110
+ if (requireReceipt && (
111
+ !operation.receipt ||
112
+ operation.receipt.length > 8192 ||
113
+ /[^\x20-\x7e]/.test(operation.receipt)
114
+ )) {
115
+ throw new Error('MPP charge operation has an invalid payment receipt')
116
+ }
117
+ }