@tangle-network/agent-gateway 0.6.0 → 0.7.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.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * A2A protocol types (Google Agent-to-Agent, April 2025).
3
+ *
4
+ * Subset shipped by this gateway:
5
+ * - Discovery: AgentCard via `.well-known/agent.json`
6
+ * - Messaging: `message/send`, `message/stream`
7
+ * - Task control: `tasks/get`, `tasks/cancel`, `tasks/resubscribe`
8
+ * - Push: `tasks/pushNotificationConfig/{set,get,list,delete}` (gated on `pushStore`)
9
+ * - Multi-turn: `input-required` state + follow-up `message/send` with the same `taskId`
10
+ * - Capabilities: streaming = true; pushNotifications gated on config; stateTransitionHistory = false
11
+ * - Parts: text only on input/output (data/file parts rejected with CONTENT_TYPE_NOT_SUPPORTED)
12
+ *
13
+ * Deferred until a real consumer needs them: authenticated extended card,
14
+ * data/file parts, OAuth2/mTLS auth schemes.
15
+ */
16
+
17
+ // ── JSON-RPC 2.0 envelopes ───────────────────────────────────────────────
18
+
19
+ export interface JSONRPCRequest {
20
+ jsonrpc: '2.0'
21
+ id: string | number | null
22
+ method: string
23
+ params?: unknown
24
+ }
25
+
26
+ export interface JSONRPCSuccessResponse<T = unknown> {
27
+ jsonrpc: '2.0'
28
+ id: string | number | null
29
+ result: T
30
+ }
31
+
32
+ export interface JSONRPCErrorResponse {
33
+ jsonrpc: '2.0'
34
+ id: string | number | null
35
+ error: { code: number; message: string; data?: unknown }
36
+ }
37
+
38
+ export type JSONRPCResponse<T = unknown> = JSONRPCSuccessResponse<T> | JSONRPCErrorResponse
39
+
40
+ /** Standard JSON-RPC + A2A-specific codes. Negative ints per JSON-RPC spec. */
41
+ export const A2A_ERROR_CODES = {
42
+ PARSE_ERROR: -32700,
43
+ INVALID_REQUEST: -32600,
44
+ METHOD_NOT_FOUND: -32601,
45
+ INVALID_PARAMS: -32602,
46
+ INTERNAL_ERROR: -32603,
47
+ TASK_NOT_FOUND: -32001,
48
+ TASK_NOT_CANCELABLE: -32002,
49
+ PUSH_NOT_SUPPORTED: -32003,
50
+ UNSUPPORTED_OPERATION: -32004,
51
+ CONTENT_TYPE_NOT_SUPPORTED: -32005,
52
+ INVALID_AGENT_RESPONSE: -32006,
53
+ AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED: -32007,
54
+ } as const
55
+
56
+ // ── Message parts ────────────────────────────────────────────────────────
57
+
58
+ export interface TextPart {
59
+ kind: 'text'
60
+ text: string
61
+ metadata?: Record<string, unknown>
62
+ }
63
+
64
+ export interface DataPart {
65
+ kind: 'data'
66
+ data: Record<string, unknown>
67
+ metadata?: Record<string, unknown>
68
+ }
69
+
70
+ export interface FilePart {
71
+ kind: 'file'
72
+ file: { name?: string; mimeType?: string; bytes?: string; uri?: string }
73
+ metadata?: Record<string, unknown>
74
+ }
75
+
76
+ export type Part = TextPart | DataPart | FilePart
77
+
78
+ // ── Message + Task + Artifact ────────────────────────────────────────────
79
+
80
+ export interface Message {
81
+ kind: 'message'
82
+ role: 'user' | 'agent'
83
+ parts: Part[]
84
+ messageId: string
85
+ taskId?: string
86
+ contextId?: string
87
+ metadata?: Record<string, unknown>
88
+ }
89
+
90
+ export type TaskState =
91
+ | 'submitted'
92
+ | 'working'
93
+ | 'input-required'
94
+ | 'completed'
95
+ | 'canceled'
96
+ | 'failed'
97
+ | 'rejected'
98
+ | 'auth-required'
99
+
100
+ export interface TaskStatus {
101
+ state: TaskState
102
+ message?: Message
103
+ timestamp: string
104
+ }
105
+
106
+ export interface Artifact {
107
+ artifactId: string
108
+ name?: string
109
+ description?: string
110
+ parts: Part[]
111
+ metadata?: Record<string, unknown>
112
+ }
113
+
114
+ export interface Task {
115
+ kind: 'task'
116
+ id: string
117
+ contextId: string
118
+ status: TaskStatus
119
+ history?: Message[]
120
+ artifacts?: Artifact[]
121
+ metadata?: Record<string, unknown>
122
+ }
123
+
124
+ // ── Streaming events (carried as JSON-RPC result over SSE) ──────────────
125
+
126
+ export interface TaskStatusUpdateEvent {
127
+ kind: 'status-update'
128
+ taskId: string
129
+ contextId: string
130
+ status: TaskStatus
131
+ /** True on the terminal event; clients close the stream after this. */
132
+ final: boolean
133
+ metadata?: Record<string, unknown>
134
+ }
135
+
136
+ export interface TaskArtifactUpdateEvent {
137
+ kind: 'artifact-update'
138
+ taskId: string
139
+ contextId: string
140
+ artifact: Artifact
141
+ /** True when this artifact's parts should be appended to the prior emit (incremental streaming). */
142
+ append?: boolean
143
+ /** True on the artifact's final chunk. */
144
+ lastChunk?: boolean
145
+ metadata?: Record<string, unknown>
146
+ }
147
+
148
+ export type StreamingEvent = TaskStatusUpdateEvent | TaskArtifactUpdateEvent
149
+
150
+ // ── Method-specific params ───────────────────────────────────────────────
151
+
152
+ export interface MessageSendParams {
153
+ message: Message
154
+ configuration?: {
155
+ acceptedOutputModes?: string[]
156
+ blocking?: boolean
157
+ historyLength?: number
158
+ }
159
+ }
160
+
161
+ export interface TaskIdParams {
162
+ id: string
163
+ metadata?: Record<string, unknown>
164
+ }
165
+
166
+ export interface TaskPushNotificationConfigGetParams {
167
+ /** Task id whose configs are being queried. */
168
+ id: string
169
+ /** Specific config id to fetch. Required for `set` and `delete`; omitted for `list`. */
170
+ pushNotificationConfigId?: string
171
+ metadata?: Record<string, unknown>
172
+ }
173
+
174
+ // ── Agent Card ──────────────────────────────────────────────────────────
175
+
176
+ export interface AgentSkill {
177
+ id: string
178
+ name: string
179
+ description: string
180
+ tags?: string[]
181
+ examples?: string[]
182
+ inputModes?: string[]
183
+ outputModes?: string[]
184
+ }
185
+
186
+ export interface AgentCapabilities {
187
+ streaming?: boolean
188
+ pushNotifications?: boolean
189
+ stateTransitionHistory?: boolean
190
+ }
191
+
192
+ export interface AgentProvider {
193
+ organization: string
194
+ url?: string
195
+ }
196
+
197
+ export interface AgentCardAuthentication {
198
+ /** Auth scheme names the agent accepts (e.g. 'Bearer', 'x402', 'mpp'). */
199
+ schemes: string[]
200
+ /** Optional human-readable hint about obtaining credentials. */
201
+ credentials?: string
202
+ }
203
+
204
+ export interface AgentCard {
205
+ name: string
206
+ description: string
207
+ /** JSON-RPC endpoint URL — clients POST methods here. */
208
+ url: string
209
+ version: string
210
+ documentationUrl?: string
211
+ provider?: AgentProvider
212
+ capabilities: AgentCapabilities
213
+ authentication: AgentCardAuthentication
214
+ defaultInputModes: string[]
215
+ defaultOutputModes: string[]
216
+ skills: AgentSkill[]
217
+ }
@@ -0,0 +1,486 @@
1
+ /**
2
+ * Shared inner pipeline used by every wire-format the gateway exposes
3
+ * (OpenAI-compatible chat completions, A2A JSON-RPC). Each handler parses its
4
+ * own protocol's request body into a canonical `messages[]` form + headers,
5
+ * then calls into here for auth → rate-limit → injection filter →
6
+ * authorize → sandbox stream → settle. Keeping the pipeline single-sourced
7
+ * means every protocol surface gets the same security and billing guarantees
8
+ * for free; bugs fixed here fix every wrapper.
9
+ */
10
+
11
+ import type { Context } from 'hono'
12
+
13
+ import { filterConsumerMessagesStrict, redactSystemPromptFromOutput } from './filter'
14
+ import { type GatewayObserver, type RequestContext, generateRequestId } from './observer'
15
+ import { type RateLimitStore, checkRateLimit } from './rate-limit'
16
+ import type { NonceStore } from './nonce-store'
17
+ import type {
18
+ AgentMeta,
19
+ ApiKeyInfo,
20
+ ChatMessage,
21
+ GatewayConfig,
22
+ PaymentMethod,
23
+ } from './types'
24
+ import {
25
+ defaultVerifyApiKey,
26
+ isApiKeyAuthEnabled,
27
+ isMppAuthEnabled,
28
+ verifyMpp,
29
+ verifyX402,
30
+ } from './verify'
31
+
32
+ /** Single bundle of long-lived gateway state shared across all handlers in one createAgentGateway call. */
33
+ export interface GatewayState {
34
+ rateLimitStore: RateLimitStore
35
+ nonceStore: NonceStore
36
+ globalRateLimit: { limit: number; windowSeconds: number }
37
+ requiredScope: string
38
+ maxLen: number
39
+ obs?: GatewayObserver
40
+ }
41
+
42
+ /** Returned by {@link authenticateAndGuard} on the success path. */
43
+ export interface AuthorizedRequest {
44
+ agent: AgentMeta
45
+ consumerId: string
46
+ paymentMethod: PaymentMethod
47
+ keyInfo: ApiKeyInfo | null
48
+ userMessage: string
49
+ rateLimitRemaining: number | undefined
50
+ requestId: string
51
+ startMs: number
52
+ }
53
+
54
+ /**
55
+ * Resolve the agent, then run the full pre-dispatch pipeline: payment +
56
+ * rate-limit + injection filter + user-message extraction + optional
57
+ * `authorizeConsumer` hook. Returns the success record on the happy path
58
+ * or a fully-formed `Response` (402/404/429/400/403) on any short-circuit.
59
+ *
60
+ * Body parsing is the caller's responsibility — different wire formats
61
+ * (OpenAI chat completions vs A2A JSON-RPC) have different envelopes; both
62
+ * still ultimately produce a `ChatMessage[]`.
63
+ */
64
+ export async function authenticateAndGuard(
65
+ c: Context,
66
+ slug: string,
67
+ messages: ChatMessage[],
68
+ config: GatewayConfig,
69
+ state: GatewayState,
70
+ ): Promise<AuthorizedRequest | Response> {
71
+ const startMs = Date.now()
72
+ const requestId = generateRequestId()
73
+ const ctx: RequestContext = { requestId, agentSlug: slug, startMs }
74
+ await state.obs?.onRequestStart?.(ctx)
75
+
76
+ const agent = await config.resolveAgent(slug)
77
+ if (!agent || !agent.enabled) {
78
+ return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)
79
+ }
80
+ if (!messages?.length) {
81
+ return c.json(
82
+ { error: { message: 'messages array required', type: 'invalid_request' } },
83
+ 400,
84
+ )
85
+ }
86
+
87
+ // Payment / auth.
88
+ const spendAuthHeader = c.req.header('X-Payment-Signature')
89
+ const authHeader = c.req.header('Authorization') ?? ''
90
+ let consumerId: string | null = null
91
+ let paymentMethod: PaymentMethod = 'none'
92
+ let keyInfo: ApiKeyInfo | null = null
93
+
94
+ if (spendAuthHeader) {
95
+ const signer = await verifyX402(spendAuthHeader, config.x402, state.nonceStore)
96
+ if (!signer) {
97
+ await state.obs?.onAuthFailure?.(ctx, {
98
+ method: 'x402',
99
+ code: 'invalid_spend_auth',
100
+ httpStatus: 402,
101
+ })
102
+ return c.json(
103
+ {
104
+ error: {
105
+ message: 'Invalid X-Payment-Signature',
106
+ type: 'authentication_error',
107
+ code: 'invalid_spend_auth',
108
+ },
109
+ },
110
+ {
111
+ status: 402,
112
+ headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': requestId },
113
+ },
114
+ )
115
+ }
116
+ consumerId = signer
117
+ paymentMethod = 'x402'
118
+ } else if (isMppAuthEnabled(config) && authHeader.toLowerCase().startsWith('payment ')) {
119
+ const signer = await verifyMpp(authHeader, config.mpp!, config.x402, state.nonceStore)
120
+ if (!signer) {
121
+ const realm = config.mpp!.realm
122
+ const method = config.mpp!.method ?? 'blueprintevm'
123
+ await state.obs?.onAuthFailure?.(ctx, {
124
+ method: 'mpp',
125
+ code: 'invalid_mpp_credential',
126
+ httpStatus: 401,
127
+ })
128
+ return c.json(
129
+ {
130
+ error: {
131
+ message: 'Invalid Payment credential',
132
+ type: 'authentication_error',
133
+ code: 'invalid_mpp_credential',
134
+ },
135
+ },
136
+ {
137
+ status: 401,
138
+ headers: {
139
+ 'WWW-Authenticate': `Payment realm="${realm}", method="${method}"`,
140
+ 'X-Request-Id': requestId,
141
+ },
142
+ },
143
+ )
144
+ }
145
+ consumerId = signer
146
+ paymentMethod = 'mpp'
147
+ } else if (authHeader.startsWith('Bearer ')) {
148
+ const verify = config.verifyApiKey ?? (config.x402.demoMode ? defaultVerifyApiKey : null)
149
+ if (!verify || !isApiKeyAuthEnabled(config)) {
150
+ await state.obs?.onAuthFailure?.(ctx, {
151
+ method: 'apikey',
152
+ code: 'api_keys_not_configured',
153
+ httpStatus: 401,
154
+ })
155
+ return c.json(
156
+ { error: { message: 'API key authentication is not configured', type: 'authentication_error' } },
157
+ { status: 401, headers: { 'X-Request-Id': requestId } },
158
+ )
159
+ }
160
+ const key = await verify(authHeader)
161
+ if (!key) {
162
+ await state.obs?.onAuthFailure?.(ctx, {
163
+ method: 'apikey',
164
+ code: 'invalid_api_key',
165
+ httpStatus: 401,
166
+ })
167
+ return c.json(
168
+ { error: { message: 'Invalid API key', type: 'authentication_error' } },
169
+ { status: 401, headers: { 'X-Request-Id': requestId } },
170
+ )
171
+ }
172
+ if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(state.requiredScope)) {
173
+ await state.obs?.onAuthFailure?.(ctx, {
174
+ method: 'apikey',
175
+ code: 'insufficient_scope',
176
+ httpStatus: 403,
177
+ })
178
+ return c.json(
179
+ {
180
+ error: {
181
+ message: `API key missing required scope: ${state.requiredScope}`,
182
+ type: 'forbidden',
183
+ code: 'insufficient_scope',
184
+ },
185
+ },
186
+ { status: 403, headers: { 'X-Request-Id': requestId } },
187
+ )
188
+ }
189
+ consumerId = key.consumerId
190
+ paymentMethod = 'apikey'
191
+ keyInfo = key
192
+ } else {
193
+ await state.obs?.onAuthFailure?.(ctx, {
194
+ method: 'none',
195
+ code: 'payment_required',
196
+ httpStatus: 402,
197
+ })
198
+ const methods: string[] = ['x402']
199
+ if (isMppAuthEnabled(config)) methods.push('mpp')
200
+ if (isApiKeyAuthEnabled(config)) methods.push('api_key')
201
+ const headers: Record<string, string> = {
202
+ 'X-Payment-Required': methods.join(', '),
203
+ 'X-Request-Id': requestId,
204
+ }
205
+ if (isMppAuthEnabled(config) && config.mpp) {
206
+ headers['WWW-Authenticate'] =
207
+ `Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
208
+ }
209
+ return c.json(
210
+ {
211
+ error: {
212
+ message: 'Payment required',
213
+ type: 'payment_required',
214
+ payment_methods: methods,
215
+ x402: {
216
+ operator: config.x402.operatorAddress,
217
+ chain_id: config.x402.chainId,
218
+ credits_address: config.x402.creditsAddress,
219
+ estimated_amount_per_request: '20000',
220
+ },
221
+ ...(isMppAuthEnabled(config) && config.mpp
222
+ ? { mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' } }
223
+ : {}),
224
+ ...(isApiKeyAuthEnabled(config)
225
+ ? {
226
+ api_key: {
227
+ purchase_url: config.baseUrl
228
+ ? `${config.baseUrl}/agents/${slug}/api-keys`
229
+ : undefined,
230
+ },
231
+ }
232
+ : {}),
233
+ },
234
+ },
235
+ { status: 402, headers },
236
+ )
237
+ }
238
+
239
+ await state.obs?.onPaymentVerified?.(ctx, {
240
+ method: paymentMethod,
241
+ consumerId: consumerId,
242
+ keyId: keyInfo?.keyId,
243
+ })
244
+
245
+ // Rate limit.
246
+ const effectiveRateLimit = keyInfo?.rateLimitPerMinute
247
+ ? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }
248
+ : state.globalRateLimit
249
+ const rl = await checkRateLimit(consumerId, effectiveRateLimit, state.rateLimitStore)
250
+ if (!rl.allowed) {
251
+ await state.obs?.onRateLimited?.(ctx, {
252
+ consumerId: consumerId,
253
+ retryAfterSeconds: rl.retryAfterSeconds ?? 60,
254
+ })
255
+ return c.json(
256
+ {
257
+ error: {
258
+ message: 'Rate limit exceeded',
259
+ type: 'rate_limit_error',
260
+ retry_after: rl.retryAfterSeconds,
261
+ },
262
+ },
263
+ {
264
+ status: 429,
265
+ headers: {
266
+ 'Retry-After': String(rl.retryAfterSeconds ?? 60),
267
+ 'X-Request-Id': requestId,
268
+ },
269
+ },
270
+ )
271
+ }
272
+
273
+ // Filter consumer messages — strip consumer-side system, length-cap, injection scan.
274
+ const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(
275
+ messages,
276
+ state.maxLen,
277
+ )
278
+ if (injectionWarnings.length > 0) {
279
+ await state.obs?.onInjectionDetected?.(ctx, {
280
+ consumerId: consumerId,
281
+ patterns: injectionWarnings,
282
+ blocked: !!config.blockInjection,
283
+ })
284
+ if (config.blockInjection) {
285
+ return c.json(
286
+ {
287
+ error: {
288
+ message: 'Request rejected: potential prompt injection detected',
289
+ type: 'content_policy_violation',
290
+ },
291
+ },
292
+ { status: 400, headers: { 'X-Request-Id': requestId } },
293
+ )
294
+ }
295
+ }
296
+
297
+ const userMessage = filtered
298
+ .filter((m) => m.role === 'user')
299
+ .map((m) => m.content)
300
+ .join('\n\n')
301
+ if (!userMessage) {
302
+ return c.json(
303
+ { error: { message: 'No user message provided', type: 'invalid_request' } },
304
+ 400,
305
+ )
306
+ }
307
+
308
+ if (config.authorizeConsumer) {
309
+ const authz = await config.authorizeConsumer(agent, {
310
+ method: paymentMethod,
311
+ consumerId: consumerId,
312
+ keyId: keyInfo?.keyId,
313
+ requestId,
314
+ })
315
+ if (!authz.allow) {
316
+ return c.json(
317
+ {
318
+ error: {
319
+ message: authz.reason,
320
+ type: 'authorization_denied',
321
+ code: authz.code,
322
+ },
323
+ },
324
+ { status: 403, headers: { 'X-Request-Id': requestId } },
325
+ )
326
+ }
327
+ }
328
+
329
+ return {
330
+ agent,
331
+ consumerId,
332
+ paymentMethod,
333
+ keyInfo,
334
+ userMessage,
335
+ rateLimitRemaining: rl.remaining,
336
+ requestId,
337
+ startMs,
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Yield the inner sandbox's response as text deltas, applying the
343
+ * system-prompt redaction filter on each delta so leakage of the agent's
344
+ * system prompt back through the model's output is suppressed identically
345
+ * whether the caller is on the OpenAI-compat path or A2A.
346
+ *
347
+ * Aborts when `signal` fires (used by A2A `tasks/cancel`).
348
+ */
349
+ export async function* dispatchSandboxStream(
350
+ agent: AgentMeta,
351
+ userMessage: string,
352
+ consumerId: string,
353
+ config: GatewayConfig,
354
+ signal?: AbortSignal,
355
+ sessionId?: string,
356
+ ): AsyncIterable<string> {
357
+ for await (const event of dispatchSandboxStreamRich(
358
+ agent,
359
+ userMessage,
360
+ consumerId,
361
+ config,
362
+ signal,
363
+ sessionId,
364
+ )) {
365
+ if (event.kind === 'text') yield event.delta
366
+ }
367
+ }
368
+
369
+ /**
370
+ * A2A-shaped dispatch event. Distinguishes text deltas from sandbox-signalled
371
+ * pause-for-input events. The A2A handler uses this richer variant so it can
372
+ * emit `input-required` status updates; the OpenAI-compat path consumes the
373
+ * text-only `dispatchSandboxStream` adapter above.
374
+ */
375
+ export type A2ADispatchEvent =
376
+ | { kind: 'text'; delta: string }
377
+ | { kind: 'input-required'; prompt?: string }
378
+
379
+ /**
380
+ * Like `dispatchSandboxStream` but yields a discriminated union so callers can
381
+ * react to `input-required` signals from the sandbox. The sandbox opts in by
382
+ * emitting `{ type: 'input-required', data: { inputRequired: { prompt? } } }`
383
+ * (or by setting `data.inputRequired` on any event); sandboxes that don't
384
+ * emit such events see identical behavior.
385
+ *
386
+ * `sessionId` defaults to `consumer:<id>` matching the existing single-turn
387
+ * path; multi-turn continuations pass an explicit `taskId` so the sandbox can
388
+ * keep per-task conversation memory.
389
+ */
390
+ export async function* dispatchSandboxStreamRich(
391
+ agent: AgentMeta,
392
+ userMessage: string,
393
+ consumerId: string,
394
+ config: GatewayConfig,
395
+ signal?: AbortSignal,
396
+ sessionId?: string,
397
+ ): AsyncIterable<A2ADispatchEvent> {
398
+ const box = await config.getSandbox(agent)
399
+ const promptStream = box.streamPrompt(userMessage, {
400
+ sessionId: sessionId ?? `consumer:${consumerId}`,
401
+ systemPrompt: agent.systemPrompt,
402
+ })
403
+ for await (const event of promptStream) {
404
+ if (signal?.aborted) return
405
+ if (
406
+ event.type === 'message.part.updated' &&
407
+ event.data?.part?.type === 'text' &&
408
+ event.data.delta
409
+ ) {
410
+ yield {
411
+ kind: 'text',
412
+ delta: redactSystemPromptFromOutput(event.data.delta, agent.systemPrompt),
413
+ }
414
+ continue
415
+ }
416
+ if (event.type === 'input-required' || event.data?.inputRequired) {
417
+ yield { kind: 'input-required', prompt: event.data?.inputRequired?.prompt }
418
+ // Terminal for the sandbox stream — sandbox SHOULD stop emitting until
419
+ // the gateway dispatches a continuation message with the new user input.
420
+ return
421
+ }
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Record usage event + settle payment + invoke the observer. Both wire
427
+ * formats call this once their stream has drained, so settlement happens
428
+ * exactly once per request regardless of protocol.
429
+ */
430
+ export async function settleAndRecord(
431
+ agent: AgentMeta,
432
+ authz: AuthorizedRequest,
433
+ inputTokens: number,
434
+ outputTokens: number,
435
+ config: GatewayConfig,
436
+ obs: GatewayObserver | undefined,
437
+ ): Promise<void> {
438
+ const totalCost = (inputTokens + outputTokens) * agent.pricePerTokenUsd
439
+ const ownerEarned = totalCost * (1 - agent.platformFeePercent)
440
+ const platformFee = totalCost * agent.platformFeePercent
441
+ const usageEvent = {
442
+ requestId: authz.requestId,
443
+ agentId: agent.id,
444
+ agentSlug: agent.slug,
445
+ consumerId: authz.consumerId,
446
+ paymentMethod: authz.paymentMethod,
447
+ inputTokens,
448
+ outputTokens,
449
+ totalCostUsd: totalCost,
450
+ ownerEarnedUsd: ownerEarned,
451
+ platformFeeUsd: platformFee,
452
+ durationMs: Date.now() - authz.startMs,
453
+ }
454
+ await config.recordUsage(usageEvent)
455
+ const ctx: RequestContext = {
456
+ requestId: authz.requestId,
457
+ agentSlug: agent.slug,
458
+ startMs: authz.startMs,
459
+ }
460
+ await obs?.onRequestComplete?.(ctx, usageEvent)
461
+ if (config.settlePayment) {
462
+ await config
463
+ .settlePayment(
464
+ {
465
+ method: authz.paymentMethod,
466
+ consumerId: authz.consumerId,
467
+ requestId: authz.requestId,
468
+ },
469
+ totalCost,
470
+ )
471
+ .catch(async (err) => {
472
+ const msg = err instanceof Error ? err.message : String(err)
473
+ console.error(`[agent-gateway] settlement failed for ${authz.consumerId}: ${msg}`)
474
+ await obs?.onSettlementError?.(ctx, {
475
+ consumerId: authz.consumerId,
476
+ method: authz.paymentMethod,
477
+ errorMessage: msg,
478
+ })
479
+ })
480
+ }
481
+ }
482
+
483
+ /** Token estimate matching the existing chat-completions handler (4 chars ≈ 1 token). */
484
+ export function estimateTokens(text: string): number {
485
+ return Math.ceil(text.length / 4)
486
+ }