@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.
- package/README.md +90 -3
- package/dist/chunk-C7Z2BRYV.js +5693 -0
- package/dist/chunk-C7Z2BRYV.js.map +1 -0
- package/dist/chunk-GITV7CPT.js +84 -0
- package/dist/chunk-GITV7CPT.js.map +1 -0
- package/dist/chunk-J5SDVHOL.js +104 -0
- package/dist/chunk-J5SDVHOL.js.map +1 -0
- package/dist/index.d.ts +70 -10
- package/dist/index.js +303 -21
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +7 -2
- package/dist/middleware.js +3 -2
- package/dist/nonce-store.d.ts +47 -11
- package/dist/nonce-store.js +9 -3
- package/dist/observer-types-A0RtA8uL.d.ts +95 -0
- package/dist/observer.d.ts +79 -0
- package/dist/observer.js +11 -0
- package/dist/observer.js.map +1 -0
- package/dist/{types-DEsMmS-X.d.ts → types-oQ58UakD.d.ts} +447 -172
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/src/a2a/execution-fence.ts +162 -0
- package/src/a2a/handler.ts +506 -560
- package/src/a2a/message-send-execution.ts +241 -0
- package/src/a2a/message-stream-execution.ts +392 -0
- package/src/a2a/payment-recovery.ts +431 -0
- package/src/a2a/push-config-methods.ts +158 -0
- package/src/a2a/push-notifications.ts +172 -22
- package/src/a2a/task-cancellation.ts +50 -0
- package/src/a2a/task-finalization.ts +451 -0
- package/src/a2a/task-lifecycle.ts +54 -0
- package/src/a2a/task-methods.ts +163 -0
- package/src/a2a/task-push-delivery.ts +119 -0
- package/src/a2a/task-recovery.ts +11 -0
- package/src/a2a/task-state.ts +99 -0
- package/src/a2a/task-store-sql.ts +222 -24
- package/src/a2a/task-store.ts +58 -1
- package/src/a2a/task-submission-recovery.ts +178 -0
- package/src/a2a/types.ts +1 -0
- package/src/dispatch-authorization.ts +468 -0
- package/src/dispatch-payment-recovery.ts +248 -0
- package/src/dispatch-payment.ts +425 -0
- package/src/dispatch-pricing.ts +108 -0
- package/src/dispatch-sandbox.ts +424 -0
- package/src/dispatch-settlement.ts +139 -0
- package/src/dispatch-types.ts +84 -0
- package/src/dispatch.ts +35 -483
- package/src/index.ts +59 -1
- package/src/middleware.ts +339 -35
- package/src/mpp-payment.ts +117 -0
- package/src/nonce-store.ts +122 -20
- package/src/observer-types.ts +63 -0
- package/src/observer.ts +3 -63
- package/src/payment-operations.ts +485 -0
- package/src/payment-recovery-sql.ts +108 -0
- package/src/payment-recovery-worker.ts +488 -0
- package/src/payment-recovery.ts +331 -0
- package/src/payment-types.ts +48 -0
- package/src/types.ts +188 -49
- package/src/verify.ts +240 -71
- package/dist/chunk-M7ZJAK4K.js +0 -53
- package/dist/chunk-M7ZJAK4K.js.map +0 -1
- package/dist/chunk-Q4YAIEZY.js +0 -1763
- 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 }
|