@tangle-network/agent-gateway 0.7.0 → 0.8.0

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 (65) hide show
  1. package/README.md +108 -6
  2. package/dist/chunk-GITV7CPT.js +84 -0
  3. package/dist/chunk-GITV7CPT.js.map +1 -0
  4. package/dist/chunk-J5SDVHOL.js +104 -0
  5. package/dist/chunk-J5SDVHOL.js.map +1 -0
  6. package/dist/chunk-MP6IIAIA.js +5651 -0
  7. package/dist/chunk-MP6IIAIA.js.map +1 -0
  8. package/dist/index.d.ts +76 -12
  9. package/dist/index.js +307 -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-CX2V06cN.d.ts → types-BHISsm7D.d.ts} +423 -166
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/agent-card.ts +4 -3
  23. package/src/a2a/execution-fence.ts +162 -0
  24. package/src/a2a/handler.ts +507 -562
  25. package/src/a2a/message-send-execution.ts +241 -0
  26. package/src/a2a/message-stream-execution.ts +392 -0
  27. package/src/a2a/payment-recovery.ts +431 -0
  28. package/src/a2a/push-config-methods.ts +158 -0
  29. package/src/a2a/push-notifications.ts +172 -22
  30. package/src/a2a/task-cancellation.ts +50 -0
  31. package/src/a2a/task-finalization.ts +451 -0
  32. package/src/a2a/task-lifecycle.ts +54 -0
  33. package/src/a2a/task-methods.ts +163 -0
  34. package/src/a2a/task-push-delivery.ts +119 -0
  35. package/src/a2a/task-recovery.ts +11 -0
  36. package/src/a2a/task-state.ts +99 -0
  37. package/src/a2a/task-store-sql.ts +222 -24
  38. package/src/a2a/task-store.ts +58 -1
  39. package/src/a2a/task-submission-recovery.ts +178 -0
  40. package/src/a2a/types.ts +1 -0
  41. package/src/dispatch-authorization.ts +437 -0
  42. package/src/dispatch-payment-recovery.ts +248 -0
  43. package/src/dispatch-payment.ts +425 -0
  44. package/src/dispatch-pricing.ts +108 -0
  45. package/src/dispatch-sandbox.ts +422 -0
  46. package/src/dispatch-settlement.ts +139 -0
  47. package/src/dispatch-types.ts +81 -0
  48. package/src/dispatch.ts +35 -462
  49. package/src/index.ts +64 -2
  50. package/src/middleware.ts +313 -32
  51. package/src/mpp-payment.ts +117 -0
  52. package/src/nonce-store.ts +122 -20
  53. package/src/observer-types.ts +63 -0
  54. package/src/observer.ts +3 -63
  55. package/src/payment-operations.ts +485 -0
  56. package/src/payment-recovery-sql.ts +108 -0
  57. package/src/payment-recovery-worker.ts +488 -0
  58. package/src/payment-recovery.ts +331 -0
  59. package/src/payment-types.ts +48 -0
  60. package/src/types.ts +153 -42
  61. package/src/verify.ts +265 -36
  62. package/dist/chunk-3IKQWFKX.js +0 -1703
  63. package/dist/chunk-3IKQWFKX.js.map +0 -1
  64. package/dist/chunk-M7ZJAK4K.js +0 -53
  65. package/dist/chunk-M7ZJAK4K.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,422 @@
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
+ SandboxExecutionBudget,
11
+ SandboxStreamEvent,
12
+ SandboxUsageReceipt,
13
+ } from './types'
14
+
15
+ export async function* dispatchSandboxStream(
16
+ agent: AgentMeta,
17
+ userMessage: string,
18
+ consumerId: string,
19
+ config: GatewayConfig,
20
+ signal?: AbortSignal,
21
+ sessionId?: string,
22
+ maxOutputTokens?: number,
23
+ ): AsyncIterable<string> {
24
+ for await (const event of dispatchSandboxStreamRich(
25
+ agent,
26
+ userMessage,
27
+ consumerId,
28
+ config,
29
+ signal,
30
+ sessionId,
31
+ maxOutputTokens,
32
+ )) {
33
+ if (event.kind === 'text') yield event.delta
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Like `dispatchSandboxStream` but yields a discriminated union so callers can
39
+ * react to `input-required` signals from the sandbox. The sandbox opts in by
40
+ * emitting `{ type: 'input-required', data: { inputRequired: { prompt? } } }`
41
+ * (or by setting `data.inputRequired` on any event); sandboxes that don't emit
42
+ * such events see identical behavior.
43
+ *
44
+ * `sessionId` defaults to `consumer:<id>` matching the existing single-turn
45
+ * path; multi-turn continuations pass an explicit `taskId` so the sandbox can
46
+ * keep per-task conversation memory.
47
+ */
48
+ export async function* dispatchSandboxStreamRich(
49
+ agent: AgentMeta,
50
+ userMessage: string,
51
+ consumerId: string,
52
+ config: GatewayConfig,
53
+ signal?: AbortSignal,
54
+ sessionId?: string,
55
+ maxOutputTokens?: number,
56
+ onExecutionStart?: () => Promise<void>,
57
+ requiresReceipt = config.x402.paymentOperations !== undefined,
58
+ onSandboxStart?: () => void | Promise<void>,
59
+ maxInputTokens?: number,
60
+ onExecutionHeartbeat?: () => Promise<void>,
61
+ ): AsyncIterable<A2ADispatchEvent> {
62
+ if (signal?.aborted) return
63
+ const box = await config.getSandbox(agent)
64
+ if (signal?.aborted) return
65
+ const outputLimit = maxOutputTokens ?? config.defaultOutputTokens ?? 1024
66
+ if (!Number.isSafeInteger(outputLimit) || outputLimit <= 0) {
67
+ throw new Error('max output tokens must be a positive safe integer')
68
+ }
69
+ let outputBytes = 0
70
+ // Bound untrusted adapters while the final receipt is pending. The receipt
71
+ // remains authoritative for token count, so over-limit output is never sent.
72
+ const maxOutputBytes = outputLimit * 4
73
+ if (!Number.isSafeInteger(maxOutputBytes)) {
74
+ throw new Error('max output token bound exceeds safe integer range')
75
+ }
76
+ const encoder = new TextEncoder()
77
+ let usageParts: Partial<SandboxUsageReceipt> = {}
78
+ let observedReasoningTokens = 0
79
+ let observedToolTokens = 0
80
+ let observedToolCalls = 0
81
+ let legacyOutputText = ''
82
+ const executionController = new AbortController()
83
+ const forwardAbort = () => executionController.abort()
84
+ if (signal?.aborted) return
85
+ signal?.addEventListener('abort', forwardAbort, { once: true })
86
+ const executionBudget: SandboxExecutionBudget = {
87
+ maxInputTokens: maxInputTokens ?? maximumBillableInputTokens(agent, userMessage),
88
+ maxOutputTokens: outputLimit,
89
+ maxReasoningTokens: config.executionBudget?.maxReasoningTokens ?? outputLimit,
90
+ maxToolTokens: config.executionBudget?.maxToolTokens ?? outputLimit,
91
+ maxToolCalls: config.executionBudget?.maxToolCalls ?? 8,
92
+ maxProviderCostUsd: config.executionBudget?.maxProviderCostUsd ?? (
93
+ (maxInputTokens ?? maximumBillableInputTokens(agent, userMessage)) + outputLimit +
94
+ (config.executionBudget?.maxReasoningTokens ?? outputLimit) +
95
+ (config.executionBudget?.maxToolTokens ?? outputLimit)
96
+ ) * agent.pricePerTokenUsd,
97
+ }
98
+ if (executionController.signal.aborted) return
99
+ await onExecutionStart?.()
100
+ if (executionController.signal.aborted) return
101
+ let heartbeatError: unknown
102
+ let heartbeatInFlight: Promise<void> | undefined
103
+ let heartbeatTimer: ReturnType<typeof setInterval> | undefined
104
+ let iterator: AsyncIterator<SandboxStreamEvent> | undefined
105
+ try {
106
+ // This durable handoff is after sandbox acquisition and immediately before
107
+ // the adapter call that may start paid work.
108
+ await onSandboxStart?.()
109
+ const promptStream = box.streamPrompt(userMessage, {
110
+ sessionId: sessionId ?? `consumer:${consumerId}`,
111
+ systemPrompt: agent.systemPrompt,
112
+ maxOutputTokens: outputLimit,
113
+ executionBudget,
114
+ signal: executionController.signal,
115
+ })
116
+ iterator = promptStream[Symbol.asyncIterator]()
117
+ const heartbeatMs = onExecutionHeartbeat
118
+ ? Math.max(100, Math.min(
119
+ Math.floor((config.paymentRecovery?.receiptTimeoutMs ?? 5 * 60_000) / 3),
120
+ 5_000,
121
+ ))
122
+ : 0
123
+ if (onExecutionHeartbeat) {
124
+ heartbeatTimer = setInterval(() => {
125
+ if (heartbeatInFlight || heartbeatError !== undefined) return
126
+ heartbeatInFlight = onExecutionHeartbeat()
127
+ .catch((error: unknown) => {
128
+ heartbeatError = error
129
+ executionController.abort()
130
+ })
131
+ .finally(() => {
132
+ heartbeatInFlight = undefined
133
+ })
134
+ }, heartbeatMs)
135
+ }
136
+ while (true) {
137
+ const next = await readSandboxEvent(iterator, executionController.signal)
138
+ if (next === ABORTED_SANDBOX_READ) {
139
+ if (heartbeatError !== undefined) throw heartbeatError
140
+ return
141
+ }
142
+ if (next.done) break
143
+ const event = next.value
144
+ if (event.data?.usage) usageParts = mergeUsage(usageParts, event.data.usage)
145
+ if (event.data?.reasoning?.tokens !== undefined) {
146
+ observedReasoningTokens += nonNegativeSafeInteger(event.data.reasoning.tokens, 'reasoning tokens')
147
+ yield { kind: 'activity' }
148
+ }
149
+ if (event.data?.tool) {
150
+ observedToolCalls += 1
151
+ observedToolTokens +=
152
+ nonNegativeSafeInteger(event.data.tool.inputTokens ?? 0, 'tool input tokens') +
153
+ nonNegativeSafeInteger(event.data.tool.outputTokens ?? 0, 'tool output tokens')
154
+ yield { kind: 'activity' }
155
+ }
156
+ enforceUsageBudget(withObservedUsage(
157
+ usageParts,
158
+ observedReasoningTokens,
159
+ observedToolTokens,
160
+ observedToolCalls,
161
+ ), executionBudget)
162
+ if (
163
+ event.type === 'message.part.updated' &&
164
+ event.data?.part?.type === 'text' &&
165
+ event.data.delta
166
+ ) {
167
+ const remainingBytes = maxOutputBytes - outputBytes
168
+ if (remainingBytes <= 0) throw new Error('sandbox exceeded max output tokens')
169
+ const bounded = truncateUtf8(event.data.delta, remainingBytes, encoder)
170
+ if (bounded.truncated) {
171
+ yield { kind: 'activity' }
172
+ throw new Error('sandbox exceeded max output tokens')
173
+ }
174
+ outputBytes += bounded.bytes
175
+ legacyOutputText += bounded.text
176
+ yield { kind: 'activity' }
177
+ yield { kind: 'text', delta: redactSystemPromptFromOutput(bounded.text, agent.systemPrompt) }
178
+ continue
179
+ }
180
+ if (event.type === 'input-required' || event.data?.inputRequired) {
181
+ const usage = completeUsage(
182
+ usageParts,
183
+ observedReasoningTokens,
184
+ observedToolTokens,
185
+ observedToolCalls,
186
+ userMessage,
187
+ legacyOutputText,
188
+ executionBudget,
189
+ requiresReceipt,
190
+ )
191
+ yield { kind: 'input-required', prompt: event.data?.inputRequired?.prompt }
192
+ // Terminal for the sandbox stream — sandbox SHOULD stop emitting until
193
+ // the gateway dispatches a continuation message with the new user input.
194
+ yield { kind: 'usage', usage }
195
+ return
196
+ }
197
+ }
198
+ const usage = completeUsage(
199
+ usageParts,
200
+ observedReasoningTokens,
201
+ observedToolTokens,
202
+ observedToolCalls,
203
+ userMessage,
204
+ legacyOutputText,
205
+ executionBudget,
206
+ requiresReceipt,
207
+ )
208
+ yield { kind: 'usage', usage }
209
+ } finally {
210
+ if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer)
211
+ const pendingHeartbeat = heartbeatInFlight
212
+ if (pendingHeartbeat) await pendingHeartbeat
213
+ signal?.removeEventListener('abort', forwardAbort)
214
+ if (iterator) await closeSandboxIterator(iterator)
215
+ if (heartbeatError !== undefined && !signal?.aborted) throw heartbeatError
216
+ }
217
+ }
218
+
219
+ const ABORTED_SANDBOX_READ = Symbol('aborted-sandbox-read')
220
+
221
+ async function readSandboxEvent(
222
+ iterator: AsyncIterator<SandboxStreamEvent>,
223
+ signal?: AbortSignal,
224
+ ): Promise<IteratorResult<SandboxStreamEvent> | typeof ABORTED_SANDBOX_READ> {
225
+ if (!signal) return iterator.next()
226
+ if (signal.aborted) return ABORTED_SANDBOX_READ
227
+ return new Promise((resolve, reject) => {
228
+ const onAbort = () => {
229
+ signal.removeEventListener('abort', onAbort)
230
+ resolve(ABORTED_SANDBOX_READ)
231
+ }
232
+ signal.addEventListener('abort', onAbort, { once: true })
233
+ iterator.next().then(
234
+ (result) => {
235
+ signal.removeEventListener('abort', onAbort)
236
+ resolve(result)
237
+ },
238
+ (error: unknown) => {
239
+ signal.removeEventListener('abort', onAbort)
240
+ reject(error)
241
+ },
242
+ )
243
+ })
244
+ }
245
+
246
+ const SANDBOX_CLEANUP_TIMEOUT_MS = 50
247
+
248
+ async function closeSandboxIterator(iterator: AsyncIterator<SandboxStreamEvent>): Promise<void> {
249
+ let closing: PromiseLike<unknown> | undefined
250
+ try {
251
+ const result = iterator.return?.()
252
+ if (result) closing = Promise.resolve(result)
253
+ } catch {
254
+ return
255
+ }
256
+ if (!closing) return
257
+ let timeout: ReturnType<typeof setTimeout> | undefined
258
+ try {
259
+ await Promise.race([
260
+ Promise.resolve(closing).catch(() => undefined),
261
+ new Promise<void>((resolve) => {
262
+ timeout = setTimeout(resolve, SANDBOX_CLEANUP_TIMEOUT_MS)
263
+ }),
264
+ ])
265
+ } finally {
266
+ if (timeout !== undefined) clearTimeout(timeout)
267
+ }
268
+ }
269
+
270
+ function mergeUsage(
271
+ current: Partial<SandboxUsageReceipt>,
272
+ update: Partial<SandboxUsageReceipt>,
273
+ ): Partial<SandboxUsageReceipt> {
274
+ const merged = { ...current, ...update }
275
+ for (const key of ['inputTokens', 'outputTokens', 'reasoningTokens', 'toolTokens', 'toolCallCount', 'providerCostUsd'] as const) {
276
+ const value = update[key]
277
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
278
+ throw new Error(`sandbox usage field ${key} is invalid`)
279
+ }
280
+ if (value !== undefined && current[key] !== undefined) {
281
+ // Usage events are cumulative receipts. Never let a later partial or
282
+ // final event erase spend observed earlier in the same execution.
283
+ merged[key] = Math.max(current[key]!, value)
284
+ }
285
+ }
286
+ if (current.budgetEnforced === false || update.budgetEnforced === false) {
287
+ merged.budgetEnforced = false
288
+ }
289
+ return merged
290
+ }
291
+
292
+ function withObservedUsage(
293
+ usage: Partial<SandboxUsageReceipt>,
294
+ reasoningTokens: number,
295
+ toolTokens: number,
296
+ toolCallCount: number,
297
+ ): Partial<SandboxUsageReceipt> {
298
+ return {
299
+ ...usage,
300
+ ...(usage.reasoningTokens !== undefined || reasoningTokens > 0
301
+ ? { reasoningTokens: Math.max(usage.reasoningTokens ?? 0, reasoningTokens) }
302
+ : {}),
303
+ ...(usage.toolTokens !== undefined || toolTokens > 0
304
+ ? { toolTokens: Math.max(usage.toolTokens ?? 0, toolTokens) }
305
+ : {}),
306
+ ...(usage.toolCallCount !== undefined || toolCallCount > 0
307
+ ? { toolCallCount: Math.max(usage.toolCallCount ?? 0, toolCallCount) }
308
+ : {}),
309
+ }
310
+ }
311
+
312
+ function nonNegativeSafeInteger(value: number, name: string): number {
313
+ if (!Number.isSafeInteger(value) || value < 0) {
314
+ throw new Error(`sandbox ${name} is invalid`)
315
+ }
316
+ return value
317
+ }
318
+
319
+ function enforceUsageBudget(
320
+ usage: Partial<SandboxUsageReceipt>,
321
+ budget: SandboxExecutionBudget,
322
+ ): void {
323
+ if (usage.inputTokens !== undefined && usage.inputTokens > budget.maxInputTokens) {
324
+ throw new Error('sandbox exceeded max input tokens')
325
+ }
326
+ if (usage.outputTokens !== undefined && usage.outputTokens > budget.maxOutputTokens) {
327
+ throw new Error('sandbox exceeded max output tokens')
328
+ }
329
+ if (usage.reasoningTokens !== undefined && usage.reasoningTokens > budget.maxReasoningTokens) {
330
+ throw new Error('sandbox exceeded max reasoning tokens')
331
+ }
332
+ if (usage.toolTokens !== undefined && usage.toolTokens > budget.maxToolTokens) {
333
+ throw new Error('sandbox exceeded max tool tokens')
334
+ }
335
+ if (usage.toolCallCount !== undefined && usage.toolCallCount > budget.maxToolCalls) {
336
+ throw new Error('sandbox exceeded max tool calls')
337
+ }
338
+ if (usage.providerCostUsd !== undefined && usage.providerCostUsd > budget.maxProviderCostUsd) {
339
+ throw new Error('sandbox exceeded max provider cost')
340
+ }
341
+ }
342
+
343
+ function finalizeUsage(
344
+ parts: Partial<SandboxUsageReceipt>,
345
+ budget: SandboxExecutionBudget,
346
+ ): SandboxUsageReceipt {
347
+ const fields = ['inputTokens', 'outputTokens', 'reasoningTokens', 'toolTokens', 'toolCallCount', 'providerCostUsd', 'budgetEnforced'] as const
348
+ if (fields.some((field) => parts[field] === undefined)) {
349
+ throw new Error('sandbox did not provide a complete usage receipt')
350
+ }
351
+ const usage = parts as SandboxUsageReceipt
352
+ for (const field of ['inputTokens', 'outputTokens', 'reasoningTokens', 'toolTokens', 'toolCallCount'] as const) {
353
+ if (!Number.isSafeInteger(usage[field]) || usage[field] < 0) {
354
+ throw new Error(`sandbox usage field ${field} is invalid`)
355
+ }
356
+ }
357
+ if (!Number.isFinite(usage.providerCostUsd) || usage.providerCostUsd < 0) {
358
+ throw new Error('sandbox usage provider cost is invalid')
359
+ }
360
+ if (typeof usage.budgetEnforced !== 'boolean') {
361
+ throw new Error('sandbox usage budget flag is invalid')
362
+ }
363
+ if (!Number.isSafeInteger(
364
+ usage.inputTokens + usage.outputTokens + usage.reasoningTokens + usage.toolTokens,
365
+ )) {
366
+ throw new Error('sandbox usage token total exceeds safe integer range')
367
+ }
368
+ enforceUsageBudget(usage, budget)
369
+ if (!usage.budgetEnforced) throw new Error('sandbox did not enforce the execution budget')
370
+ return usage
371
+ }
372
+
373
+ function completeUsage(
374
+ parts: Partial<SandboxUsageReceipt>,
375
+ reasoningTokens: number,
376
+ toolTokens: number,
377
+ toolCallCount: number,
378
+ userMessage: string,
379
+ outputText: string,
380
+ budget: SandboxExecutionBudget,
381
+ requiresReceipt: boolean,
382
+ ): SandboxUsageReceipt {
383
+ const observed = withObservedUsage(parts, reasoningTokens, toolTokens, toolCallCount)
384
+ if (
385
+ !requiresReceipt &&
386
+ Object.keys(parts).length === 0 &&
387
+ reasoningTokens === 0 &&
388
+ toolTokens === 0 &&
389
+ toolCallCount === 0
390
+ ) {
391
+ // Preserve the pre-receipt SandboxBox contract for legacy API-key
392
+ // adapters. Durable payment operations must use provider-enforced usage.
393
+ return {
394
+ inputTokens: estimateTokens(userMessage),
395
+ outputTokens: estimateTokens(outputText),
396
+ reasoningTokens: 0,
397
+ toolTokens: 0,
398
+ toolCallCount: 0,
399
+ providerCostUsd: 0,
400
+ budgetEnforced: false,
401
+ }
402
+ }
403
+ return finalizeUsage(observed, budget)
404
+ }
405
+
406
+ function truncateUtf8(
407
+ value: string,
408
+ maxBytes: number,
409
+ encoder: TextEncoder,
410
+ ): { text: string; bytes: number; truncated: boolean } {
411
+ const bytes = encoder.encode(value).byteLength
412
+ if (bytes <= maxBytes) return { text: value, bytes, truncated: false }
413
+ let text = ''
414
+ let used = 0
415
+ for (const character of value) {
416
+ const characterBytes = encoder.encode(character).byteLength
417
+ if (used + characterBytes > maxBytes) break
418
+ text += character
419
+ used += characterBytes
420
+ }
421
+ return { text, bytes: used, truncated: true }
422
+ }
@@ -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 }