@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
@@ -0,0 +1,108 @@
1
+ import type { AgentMeta, ChatMessage } from './types'
2
+
3
+ function decimalFraction(value: number): { numerator: bigint; denominator: bigint } {
4
+ if (!Number.isFinite(value) || value < 0) {
5
+ throw new Error('agent pricePerTokenUsd must be a finite non-negative number')
6
+ }
7
+ const [mantissa, exponentText] = value.toString().toLowerCase().split('e')
8
+ const exponent = exponentText ? Number(exponentText) : 0
9
+ const [whole, fraction = ''] = mantissa.split('.')
10
+ let numerator = BigInt(`${whole}${fraction}`)
11
+ let scale = fraction.length - exponent
12
+ if (scale < 0) {
13
+ numerator *= 10n ** BigInt(-scale)
14
+ scale = 0
15
+ }
16
+ return { numerator, denominator: 10n ** BigInt(scale) }
17
+ }
18
+
19
+ function amountForTokens(
20
+ pricePerTokenUsd: number,
21
+ tokenCount: number,
22
+ currencyDecimals: number,
23
+ providerCostUsd: number,
24
+ ): bigint {
25
+ const { numerator, denominator } = decimalFraction(pricePerTokenUsd)
26
+ const scaled = BigInt(tokenCount) * numerator * 10n ** BigInt(currencyDecimals)
27
+ const tokenAmount = (scaled + denominator - 1n) / denominator
28
+ const provider = providerCostUsd === 0
29
+ ? { numerator: 0n, denominator: 1n }
30
+ : decimalFraction(providerCostUsd)
31
+ const providerScaled = provider.numerator * 10n ** BigInt(currencyDecimals)
32
+ const providerAmount = (providerScaled + provider.denominator - 1n) / provider.denominator
33
+ return tokenAmount > providerAmount ? tokenAmount : providerAmount
34
+ }
35
+
36
+ /** Exact base-unit reservation required to cover the request's token ceiling. */
37
+ export function requiredX402Amount(
38
+ pricePerTokenUsd: number,
39
+ inputTokens: number,
40
+ maxOutputTokens: number,
41
+ currencyDecimals = 6,
42
+ maxReasoningTokens = 0,
43
+ maxToolTokens = 0,
44
+ maxProviderCostUsd = 0,
45
+ ): bigint {
46
+ if (!Number.isSafeInteger(inputTokens) || inputTokens < 0) {
47
+ throw new Error('input token estimate must be a non-negative safe integer')
48
+ }
49
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) {
50
+ throw new Error('max output tokens must be a positive safe integer')
51
+ }
52
+ if (!Number.isInteger(currencyDecimals) || currencyDecimals < 0 || currencyDecimals > 18) {
53
+ throw new Error('x402 currencyDecimals must be an integer between 0 and 18')
54
+ }
55
+ for (const [name, value] of [
56
+ ['maxReasoningTokens', maxReasoningTokens],
57
+ ['maxToolTokens', maxToolTokens],
58
+ ] as const) {
59
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be a non-negative safe integer`)
60
+ }
61
+ if (!Number.isFinite(maxProviderCostUsd) || maxProviderCostUsd < 0) {
62
+ throw new Error('maxProviderCostUsd must be finite and non-negative')
63
+ }
64
+ const tokenCount = inputTokens + maxOutputTokens + maxReasoningTokens + maxToolTokens
65
+ if (!Number.isSafeInteger(tokenCount)) throw new Error('token budget exceeds safe integer range')
66
+ return amountForTokens(pricePerTokenUsd, tokenCount, currencyDecimals, maxProviderCostUsd)
67
+ }
68
+
69
+ export function actualX402Amount(
70
+ pricePerTokenUsd: number,
71
+ inputTokens: number,
72
+ outputTokens: number,
73
+ reasoningTokens: number,
74
+ toolTokens: number,
75
+ currencyDecimals = 6,
76
+ providerCostUsd = 0,
77
+ ): bigint {
78
+ return amountForTokens(
79
+ pricePerTokenUsd,
80
+ inputTokens + outputTokens + reasoningTokens + toolTokens,
81
+ currencyDecimals,
82
+ providerCostUsd,
83
+ )
84
+ }
85
+
86
+ /** Token estimate matching the existing chat-completions handler (4 chars ≈ 1 token). */
87
+ export function estimateTokens(text: string): number {
88
+ return Math.ceil(text.length / 4)
89
+ }
90
+
91
+ /** Include the host-owned system prompt because the provider bills it too. */
92
+ export function estimateBillableInputTokens(agent: AgentMeta, userMessage: string): number {
93
+ return estimateTokens(userMessage) + estimateTokens(agent.systemPrompt ?? '')
94
+ }
95
+
96
+ /** A tokenizer cannot emit more tokens than the UTF-8 bytes it consumes. */
97
+ export function maximumBillableInputTokens(agent: AgentMeta, userMessage: string): number
98
+ export function maximumBillableInputTokens(agent: AgentMeta, messages: readonly ChatMessage[]): number
99
+ export function maximumBillableInputTokens(
100
+ agent: AgentMeta,
101
+ userMessageOrMessages: string | readonly ChatMessage[],
102
+ ): number {
103
+ const encoder = new TextEncoder()
104
+ const prompt = typeof userMessageOrMessages === 'string'
105
+ ? userMessageOrMessages
106
+ : JSON.stringify(userMessageOrMessages)
107
+ return encoder.encode(prompt).byteLength + encoder.encode(agent.systemPrompt ?? '').byteLength
108
+ }
@@ -0,0 +1,424 @@
1
+ import { redactSystemPromptFromOutput } from './filter'
2
+ import type { A2ADispatchEvent } from './dispatch-types'
3
+ import {
4
+ estimateTokens,
5
+ maximumBillableInputTokens,
6
+ } from './dispatch-pricing'
7
+ import type {
8
+ AgentMeta,
9
+ GatewayConfig,
10
+ GatewaySandboxContext,
11
+ SandboxExecutionBudget,
12
+ SandboxStreamEvent,
13
+ SandboxUsageReceipt,
14
+ } from './types'
15
+
16
+ export async function* dispatchSandboxStream(
17
+ agent: AgentMeta,
18
+ userMessage: string,
19
+ consumerId: string,
20
+ config: GatewayConfig,
21
+ signal?: AbortSignal,
22
+ sessionId?: string,
23
+ maxOutputTokens?: number,
24
+ ): AsyncIterable<string> {
25
+ for await (const event of dispatchSandboxStreamRich(
26
+ agent,
27
+ userMessage,
28
+ consumerId,
29
+ config,
30
+ signal,
31
+ sessionId,
32
+ maxOutputTokens,
33
+ )) {
34
+ if (event.kind === 'text') yield event.delta
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Like `dispatchSandboxStream` but yields a discriminated union so callers can
40
+ * react to `input-required` signals from the sandbox. The sandbox opts in by
41
+ * emitting `{ type: 'input-required', data: { inputRequired: { prompt? } } }`
42
+ * (or by setting `data.inputRequired` on any event); sandboxes that don't emit
43
+ * such events see identical behavior.
44
+ *
45
+ * `sessionId` defaults to `consumer:<id>` matching the existing single-turn
46
+ * path; multi-turn continuations pass an explicit `taskId` so the sandbox can
47
+ * keep per-task conversation memory.
48
+ */
49
+ export async function* dispatchSandboxStreamRich(
50
+ agent: AgentMeta,
51
+ userMessage: string,
52
+ consumerId: string,
53
+ config: GatewayConfig,
54
+ signal?: AbortSignal,
55
+ sessionId?: string,
56
+ maxOutputTokens?: number,
57
+ onExecutionStart?: () => Promise<void>,
58
+ requiresReceipt = config.x402.paymentOperations !== undefined,
59
+ onSandboxStart?: () => void | Promise<void>,
60
+ maxInputTokens?: number,
61
+ onExecutionHeartbeat?: () => Promise<void>,
62
+ sandboxContext?: GatewaySandboxContext,
63
+ ): AsyncIterable<A2ADispatchEvent> {
64
+ if (signal?.aborted) return
65
+ const box = await config.getSandbox(agent, sandboxContext)
66
+ if (signal?.aborted) return
67
+ const outputLimit = maxOutputTokens ?? config.defaultOutputTokens ?? 1024
68
+ if (!Number.isSafeInteger(outputLimit) || outputLimit <= 0) {
69
+ throw new Error('max output tokens must be a positive safe integer')
70
+ }
71
+ let outputBytes = 0
72
+ // Bound untrusted adapters while the final receipt is pending. The receipt
73
+ // remains authoritative for token count, so over-limit output is never sent.
74
+ const maxOutputBytes = outputLimit * 4
75
+ if (!Number.isSafeInteger(maxOutputBytes)) {
76
+ throw new Error('max output token bound exceeds safe integer range')
77
+ }
78
+ const encoder = new TextEncoder()
79
+ let usageParts: Partial<SandboxUsageReceipt> = {}
80
+ let observedReasoningTokens = 0
81
+ let observedToolTokens = 0
82
+ let observedToolCalls = 0
83
+ let legacyOutputText = ''
84
+ const executionController = new AbortController()
85
+ const forwardAbort = () => executionController.abort()
86
+ if (signal?.aborted) return
87
+ signal?.addEventListener('abort', forwardAbort, { once: true })
88
+ const executionBudget: SandboxExecutionBudget = {
89
+ maxInputTokens: maxInputTokens ?? maximumBillableInputTokens(agent, userMessage),
90
+ maxOutputTokens: outputLimit,
91
+ maxReasoningTokens: config.executionBudget?.maxReasoningTokens ?? outputLimit,
92
+ maxToolTokens: config.executionBudget?.maxToolTokens ?? outputLimit,
93
+ maxToolCalls: config.executionBudget?.maxToolCalls ?? 8,
94
+ maxProviderCostUsd: config.executionBudget?.maxProviderCostUsd ?? (
95
+ (maxInputTokens ?? maximumBillableInputTokens(agent, userMessage)) + outputLimit +
96
+ (config.executionBudget?.maxReasoningTokens ?? outputLimit) +
97
+ (config.executionBudget?.maxToolTokens ?? outputLimit)
98
+ ) * agent.pricePerTokenUsd,
99
+ }
100
+ if (executionController.signal.aborted) return
101
+ await onExecutionStart?.()
102
+ if (executionController.signal.aborted) return
103
+ let heartbeatError: unknown
104
+ let heartbeatInFlight: Promise<void> | undefined
105
+ let heartbeatTimer: ReturnType<typeof setInterval> | undefined
106
+ let iterator: AsyncIterator<SandboxStreamEvent> | undefined
107
+ try {
108
+ // This durable handoff is after sandbox acquisition and immediately before
109
+ // the adapter call that may start paid work.
110
+ await onSandboxStart?.()
111
+ const promptStream = box.streamPrompt(userMessage, {
112
+ sessionId: sessionId ?? `consumer:${consumerId}`,
113
+ systemPrompt: agent.systemPrompt,
114
+ maxOutputTokens: outputLimit,
115
+ executionBudget,
116
+ signal: executionController.signal,
117
+ })
118
+ iterator = promptStream[Symbol.asyncIterator]()
119
+ const heartbeatMs = onExecutionHeartbeat
120
+ ? Math.max(100, Math.min(
121
+ Math.floor((config.paymentRecovery?.receiptTimeoutMs ?? 5 * 60_000) / 3),
122
+ 5_000,
123
+ ))
124
+ : 0
125
+ if (onExecutionHeartbeat) {
126
+ heartbeatTimer = setInterval(() => {
127
+ if (heartbeatInFlight || heartbeatError !== undefined) return
128
+ heartbeatInFlight = onExecutionHeartbeat()
129
+ .catch((error: unknown) => {
130
+ heartbeatError = error
131
+ executionController.abort()
132
+ })
133
+ .finally(() => {
134
+ heartbeatInFlight = undefined
135
+ })
136
+ }, heartbeatMs)
137
+ }
138
+ while (true) {
139
+ const next = await readSandboxEvent(iterator, executionController.signal)
140
+ if (next === ABORTED_SANDBOX_READ) {
141
+ if (heartbeatError !== undefined) throw heartbeatError
142
+ return
143
+ }
144
+ if (next.done) break
145
+ const event = next.value
146
+ if (event.data?.usage) usageParts = mergeUsage(usageParts, event.data.usage)
147
+ if (event.data?.reasoning?.tokens !== undefined) {
148
+ observedReasoningTokens += nonNegativeSafeInteger(event.data.reasoning.tokens, 'reasoning tokens')
149
+ yield { kind: 'activity' }
150
+ }
151
+ if (event.data?.tool) {
152
+ observedToolCalls += 1
153
+ observedToolTokens +=
154
+ nonNegativeSafeInteger(event.data.tool.inputTokens ?? 0, 'tool input tokens') +
155
+ nonNegativeSafeInteger(event.data.tool.outputTokens ?? 0, 'tool output tokens')
156
+ yield { kind: 'activity' }
157
+ }
158
+ enforceUsageBudget(withObservedUsage(
159
+ usageParts,
160
+ observedReasoningTokens,
161
+ observedToolTokens,
162
+ observedToolCalls,
163
+ ), executionBudget)
164
+ if (
165
+ event.type === 'message.part.updated' &&
166
+ event.data?.part?.type === 'text' &&
167
+ event.data.delta
168
+ ) {
169
+ const remainingBytes = maxOutputBytes - outputBytes
170
+ if (remainingBytes <= 0) throw new Error('sandbox exceeded max output tokens')
171
+ const bounded = truncateUtf8(event.data.delta, remainingBytes, encoder)
172
+ if (bounded.truncated) {
173
+ yield { kind: 'activity' }
174
+ throw new Error('sandbox exceeded max output tokens')
175
+ }
176
+ outputBytes += bounded.bytes
177
+ legacyOutputText += bounded.text
178
+ yield { kind: 'activity' }
179
+ yield { kind: 'text', delta: redactSystemPromptFromOutput(bounded.text, agent.systemPrompt) }
180
+ continue
181
+ }
182
+ if (event.type === 'input-required' || event.data?.inputRequired) {
183
+ const usage = completeUsage(
184
+ usageParts,
185
+ observedReasoningTokens,
186
+ observedToolTokens,
187
+ observedToolCalls,
188
+ userMessage,
189
+ legacyOutputText,
190
+ executionBudget,
191
+ requiresReceipt,
192
+ )
193
+ yield { kind: 'input-required', prompt: event.data?.inputRequired?.prompt }
194
+ // Terminal for the sandbox stream — sandbox SHOULD stop emitting until
195
+ // the gateway dispatches a continuation message with the new user input.
196
+ yield { kind: 'usage', usage }
197
+ return
198
+ }
199
+ }
200
+ const usage = completeUsage(
201
+ usageParts,
202
+ observedReasoningTokens,
203
+ observedToolTokens,
204
+ observedToolCalls,
205
+ userMessage,
206
+ legacyOutputText,
207
+ executionBudget,
208
+ requiresReceipt,
209
+ )
210
+ yield { kind: 'usage', usage }
211
+ } finally {
212
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer)
213
+ const pendingHeartbeat = heartbeatInFlight
214
+ if (pendingHeartbeat) await pendingHeartbeat
215
+ signal?.removeEventListener('abort', forwardAbort)
216
+ if (iterator) await closeSandboxIterator(iterator)
217
+ if (heartbeatError !== undefined && !signal?.aborted) throw heartbeatError
218
+ }
219
+ }
220
+
221
+ const ABORTED_SANDBOX_READ = Symbol('aborted-sandbox-read')
222
+
223
+ async function readSandboxEvent(
224
+ iterator: AsyncIterator<SandboxStreamEvent>,
225
+ signal?: AbortSignal,
226
+ ): Promise<IteratorResult<SandboxStreamEvent> | typeof ABORTED_SANDBOX_READ> {
227
+ if (!signal) return iterator.next()
228
+ if (signal.aborted) return ABORTED_SANDBOX_READ
229
+ return new Promise((resolve, reject) => {
230
+ const onAbort = () => {
231
+ signal.removeEventListener('abort', onAbort)
232
+ resolve(ABORTED_SANDBOX_READ)
233
+ }
234
+ signal.addEventListener('abort', onAbort, { once: true })
235
+ iterator.next().then(
236
+ (result) => {
237
+ signal.removeEventListener('abort', onAbort)
238
+ resolve(result)
239
+ },
240
+ (error: unknown) => {
241
+ signal.removeEventListener('abort', onAbort)
242
+ reject(error)
243
+ },
244
+ )
245
+ })
246
+ }
247
+
248
+ const SANDBOX_CLEANUP_TIMEOUT_MS = 50
249
+
250
+ async function closeSandboxIterator(iterator: AsyncIterator<SandboxStreamEvent>): Promise<void> {
251
+ let closing: PromiseLike<unknown> | undefined
252
+ try {
253
+ const result = iterator.return?.()
254
+ if (result) closing = Promise.resolve(result)
255
+ } catch {
256
+ return
257
+ }
258
+ if (!closing) return
259
+ let timeout: ReturnType<typeof setTimeout> | undefined
260
+ try {
261
+ await Promise.race([
262
+ Promise.resolve(closing).catch(() => undefined),
263
+ new Promise<void>((resolve) => {
264
+ timeout = setTimeout(resolve, SANDBOX_CLEANUP_TIMEOUT_MS)
265
+ }),
266
+ ])
267
+ } finally {
268
+ if (timeout !== undefined) clearTimeout(timeout)
269
+ }
270
+ }
271
+
272
+ function mergeUsage(
273
+ current: Partial<SandboxUsageReceipt>,
274
+ update: Partial<SandboxUsageReceipt>,
275
+ ): Partial<SandboxUsageReceipt> {
276
+ const merged = { ...current, ...update }
277
+ for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'toolTokens', 'toolCallCount', 'providerCostUsd'] as const) {
278
+ const value = update[key]
279
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
280
+ throw new Error(`sandbox usage field ${key} is invalid`)
281
+ }
282
+ if (value !== undefined && current[key] !== undefined) {
283
+ // Usage events are cumulative receipts. Never let a later partial or
284
+ // final event erase spend observed earlier in the same execution.
285
+ merged[key] = Math.max(current[key]!, value)
286
+ }
287
+ }
288
+ if (current.budgetEnforced === false || update.budgetEnforced === false) {
289
+ merged.budgetEnforced = false
290
+ }
291
+ return merged
292
+ }
293
+
294
+ function withObservedUsage(
295
+ usage: Partial<SandboxUsageReceipt>,
296
+ reasoningTokens: number,
297
+ toolTokens: number,
298
+ toolCallCount: number,
299
+ ): Partial<SandboxUsageReceipt> {
300
+ return {
301
+ ...usage,
302
+ ...(usage.reasoningTokens !== undefined || reasoningTokens > 0
303
+ ? { reasoningTokens: Math.max(usage.reasoningTokens ?? 0, reasoningTokens) }
304
+ : {}),
305
+ ...(usage.toolTokens !== undefined || toolTokens > 0
306
+ ? { toolTokens: Math.max(usage.toolTokens ?? 0, toolTokens) }
307
+ : {}),
308
+ ...(usage.toolCallCount !== undefined || toolCallCount > 0
309
+ ? { toolCallCount: Math.max(usage.toolCallCount ?? 0, toolCallCount) }
310
+ : {}),
311
+ }
312
+ }
313
+
314
+ function nonNegativeSafeInteger(value: number, name: string): number {
315
+ if (!Number.isSafeInteger(value) || value < 0) {
316
+ throw new Error(`sandbox ${name} is invalid`)
317
+ }
318
+ return value
319
+ }
320
+
321
+ function enforceUsageBudget(
322
+ usage: Partial<SandboxUsageReceipt>,
323
+ budget: SandboxExecutionBudget,
324
+ ): void {
325
+ if (usage.inputTokens !== undefined && usage.inputTokens > budget.maxInputTokens) {
326
+ throw new Error('sandbox exceeded max input tokens')
327
+ }
328
+ if (usage.outputTokens !== undefined && usage.outputTokens > budget.maxOutputTokens) {
329
+ throw new Error('sandbox exceeded max output tokens')
330
+ }
331
+ if (usage.reasoningTokens !== undefined && usage.reasoningTokens > budget.maxReasoningTokens) {
332
+ throw new Error('sandbox exceeded max reasoning tokens')
333
+ }
334
+ if (usage.toolTokens !== undefined && usage.toolTokens > budget.maxToolTokens) {
335
+ throw new Error('sandbox exceeded max tool tokens')
336
+ }
337
+ if (usage.toolCallCount !== undefined && usage.toolCallCount > budget.maxToolCalls) {
338
+ throw new Error('sandbox exceeded max tool calls')
339
+ }
340
+ if (usage.providerCostUsd !== undefined && usage.providerCostUsd > budget.maxProviderCostUsd) {
341
+ throw new Error('sandbox exceeded max provider cost')
342
+ }
343
+ }
344
+
345
+ function finalizeUsage(
346
+ parts: Partial<SandboxUsageReceipt>,
347
+ budget: SandboxExecutionBudget,
348
+ ): SandboxUsageReceipt {
349
+ const fields = ['inputTokens', 'outputTokens', 'reasoningTokens', 'toolTokens', 'toolCallCount', 'providerCostUsd', 'budgetEnforced'] as const
350
+ if (fields.some((field) => parts[field] === undefined)) {
351
+ throw new Error('sandbox did not provide a complete usage receipt')
352
+ }
353
+ const usage = parts as SandboxUsageReceipt
354
+ for (const field of ['inputTokens', 'outputTokens', 'reasoningTokens', 'toolTokens', 'toolCallCount'] as const) {
355
+ if (!Number.isSafeInteger(usage[field]) || usage[field] < 0) {
356
+ throw new Error(`sandbox usage field ${field} is invalid`)
357
+ }
358
+ }
359
+ if (!Number.isFinite(usage.providerCostUsd) || usage.providerCostUsd < 0) {
360
+ throw new Error('sandbox usage provider cost is invalid')
361
+ }
362
+ if (typeof usage.budgetEnforced !== 'boolean') {
363
+ throw new Error('sandbox usage budget flag is invalid')
364
+ }
365
+ if (!Number.isSafeInteger(
366
+ usage.inputTokens + usage.outputTokens + usage.reasoningTokens + usage.toolTokens,
367
+ )) {
368
+ throw new Error('sandbox usage token total exceeds safe integer range')
369
+ }
370
+ enforceUsageBudget(usage, budget)
371
+ if (!usage.budgetEnforced) throw new Error('sandbox did not enforce the execution budget')
372
+ return usage
373
+ }
374
+
375
+ function completeUsage(
376
+ parts: Partial<SandboxUsageReceipt>,
377
+ reasoningTokens: number,
378
+ toolTokens: number,
379
+ toolCallCount: number,
380
+ userMessage: string,
381
+ outputText: string,
382
+ budget: SandboxExecutionBudget,
383
+ requiresReceipt: boolean,
384
+ ): SandboxUsageReceipt {
385
+ const observed = withObservedUsage(parts, reasoningTokens, toolTokens, toolCallCount)
386
+ if (
387
+ !requiresReceipt &&
388
+ Object.keys(parts).length === 0 &&
389
+ reasoningTokens === 0 &&
390
+ toolTokens === 0 &&
391
+ toolCallCount === 0
392
+ ) {
393
+ // Preserve the pre-receipt SandboxBox contract for legacy API-key
394
+ // adapters. Durable payment operations must use provider-enforced usage.
395
+ return {
396
+ inputTokens: estimateTokens(userMessage),
397
+ outputTokens: estimateTokens(outputText),
398
+ reasoningTokens: 0,
399
+ toolTokens: 0,
400
+ toolCallCount: 0,
401
+ providerCostUsd: 0,
402
+ budgetEnforced: false,
403
+ }
404
+ }
405
+ return finalizeUsage(observed, budget)
406
+ }
407
+
408
+ function truncateUtf8(
409
+ value: string,
410
+ maxBytes: number,
411
+ encoder: TextEncoder,
412
+ ): { text: string; bytes: number; truncated: boolean } {
413
+ const bytes = encoder.encode(value).byteLength
414
+ if (bytes <= maxBytes) return { text: value, bytes, truncated: false }
415
+ let text = ''
416
+ let used = 0
417
+ for (const character of value) {
418
+ const characterBytes = encoder.encode(character).byteLength
419
+ if (used + characterBytes > maxBytes) break
420
+ text += character
421
+ used += characterBytes
422
+ }
423
+ return { text, bytes: used, truncated: true }
424
+ }
@@ -0,0 +1,139 @@
1
+ import { type GatewayObserver, type RequestContext } from './observer'
2
+ import { actualX402Amount } from './dispatch-pricing'
3
+ import {
4
+ assertX402V1SettlementSafe,
5
+ markRecoveryReconciled,
6
+ markRecoverySettling,
7
+ markRecoveryUsageRecorded,
8
+ } from './dispatch-payment-recovery'
9
+ import type {
10
+ AuthorizedRequest,
11
+ SettleAndRecordOptions,
12
+ } from './dispatch-types'
13
+ import type { AgentMeta, GatewayConfig, SandboxUsageReceipt } from './types'
14
+
15
+ /**
16
+ * Record usage, settle payment, and invoke the observer. Both wire formats
17
+ * call this once their stream has drained, so settlement happens exactly once
18
+ * per request regardless of protocol.
19
+ */
20
+ export async function settleAndRecord(
21
+ agent: AgentMeta,
22
+ authz: AuthorizedRequest,
23
+ usage: SandboxUsageReceipt,
24
+ config: GatewayConfig,
25
+ obs: GatewayObserver | undefined,
26
+ options: SettleAndRecordOptions = {},
27
+ ): Promise<void> {
28
+ assertX402V1SettlementSafe(authz, config)
29
+ const settlementBasis = options.settlementBasis ?? 'usage-receipt'
30
+ await markRecoverySettling(authz, usage, settlementBasis, config)
31
+ if (options.usageAlreadyRecorded) await markRecoveryUsageRecorded(authz, config)
32
+ const tokenCost = (
33
+ usage.inputTokens + usage.outputTokens + usage.reasoningTokens + usage.toolTokens
34
+ ) * agent.pricePerTokenUsd
35
+ const totalCost = Math.max(tokenCost, usage.providerCostUsd)
36
+ const ownerEarned = totalCost * (1 - agent.platformFeePercent)
37
+ const platformFee = totalCost * agent.platformFeePercent
38
+ const usageEvent = {
39
+ requestId: authz.requestId,
40
+ agentId: agent.id,
41
+ agentSlug: agent.slug,
42
+ consumerId: authz.consumerId,
43
+ paymentMethod: authz.paymentMethod,
44
+ inputTokens: usage.inputTokens,
45
+ outputTokens: usage.outputTokens,
46
+ reasoningTokens: usage.reasoningTokens,
47
+ toolTokens: usage.toolTokens,
48
+ toolCallCount: usage.toolCallCount,
49
+ providerCostUsd: usage.providerCostUsd,
50
+ totalCostUsd: totalCost,
51
+ ownerEarnedUsd: ownerEarned,
52
+ platformFeeUsd: platformFee,
53
+ durationMs: Date.now() - authz.startMs,
54
+ settlementBasis,
55
+ }
56
+ const ctx: RequestContext = {
57
+ requestId: authz.requestId,
58
+ agentSlug: agent.slug,
59
+ startMs: authz.startMs,
60
+ }
61
+ try {
62
+ if (authz.paymentOperation && config.x402.paymentOperations) {
63
+ const amount = options.paymentAmount ?? actualX402Amount(
64
+ agent.pricePerTokenUsd,
65
+ usage.inputTokens,
66
+ usage.outputTokens,
67
+ usage.reasoningTokens,
68
+ usage.toolTokens,
69
+ config.x402.currencyDecimals,
70
+ usage.providerCostUsd,
71
+ )
72
+ if (options.paymentAlreadySettled) {
73
+ if (
74
+ authz.paymentOperation.state !== 'settled' ||
75
+ authz.paymentOperation.settledAmount !== amount
76
+ ) {
77
+ throw new Error('authoritative payment state does not match finalization')
78
+ }
79
+ } else {
80
+ authz.paymentOperation = await config.x402.paymentOperations.settlePayment(
81
+ authz.paymentOperation,
82
+ { amount, totalCostUsd: totalCost, usage, basis: settlementBasis },
83
+ )
84
+ }
85
+ // Durable settlement happens first. If attribution storage is
86
+ // unavailable, recovery must never refund delivered work.
87
+ if (!options.usageAlreadyRecorded) {
88
+ await config.recordUsage(usageEvent)
89
+ await options.onUsageRecorded?.()
90
+ await markRecoveryUsageRecorded(authz, config)
91
+ }
92
+ } else if (authz.mppChargeOperation) {
93
+ // Generic MPP charge methods confirm before the response. Finalization
94
+ // records attribution only; it never invokes the legacy settlement hook.
95
+ if (!options.usageAlreadyRecorded) {
96
+ await config.recordUsage(usageEvent)
97
+ await options.onUsageRecorded?.()
98
+ await markRecoveryUsageRecorded(authz, config)
99
+ }
100
+ } else {
101
+ // Legacy adapters retain attribution-before-charge because their
102
+ // settlement callback may resolve that usage row.
103
+ if (!options.usageAlreadyRecorded) {
104
+ await config.recordUsage(usageEvent)
105
+ await options.onUsageRecorded?.()
106
+ }
107
+ if (config.settlePayment) {
108
+ await config.settlePayment(
109
+ {
110
+ method: authz.paymentMethod,
111
+ consumerId: authz.consumerId,
112
+ requestId: authz.requestId,
113
+ },
114
+ totalCost,
115
+ )
116
+ }
117
+ }
118
+ await markRecoveryReconciled(authz, config)
119
+ } catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err)
121
+ console.error(`[agent-gateway] settlement failed for ${authz.consumerId}: ${msg}`)
122
+ await obs?.onSettlementError?.(ctx, {
123
+ consumerId: authz.consumerId,
124
+ method: authz.paymentMethod,
125
+ errorMessage: msg,
126
+ })
127
+ throw err
128
+ }
129
+ try {
130
+ await obs?.onRequestComplete?.(ctx, usageEvent)
131
+ } catch (error) {
132
+ console.error(
133
+ `[agent-gateway] completion observer failed for ${authz.requestId}:`,
134
+ error instanceof Error ? error.message : String(error),
135
+ )
136
+ }
137
+ }
138
+
139
+ export type { SettleAndRecordOptions }