@tangle-network/agent-gateway 0.5.0 → 0.7.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.
@@ -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,465 @@
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 { defaultVerifyApiKey, verifyMpp, verifyX402 } from './verify'
25
+
26
+ /** Single bundle of long-lived gateway state shared across all handlers in one createAgentGateway call. */
27
+ export interface GatewayState {
28
+ rateLimitStore: RateLimitStore
29
+ nonceStore: NonceStore
30
+ globalRateLimit: { limit: number; windowSeconds: number }
31
+ requiredScope: string
32
+ maxLen: number
33
+ obs?: GatewayObserver
34
+ }
35
+
36
+ /** Returned by {@link authenticateAndGuard} on the success path. */
37
+ export interface AuthorizedRequest {
38
+ agent: AgentMeta
39
+ consumerId: string
40
+ paymentMethod: PaymentMethod
41
+ keyInfo: ApiKeyInfo | null
42
+ userMessage: string
43
+ rateLimitRemaining: number | undefined
44
+ requestId: string
45
+ startMs: number
46
+ }
47
+
48
+ /**
49
+ * Resolve the agent, then run the full pre-dispatch pipeline: payment +
50
+ * rate-limit + injection filter + user-message extraction + optional
51
+ * `authorizeConsumer` hook. Returns the success record on the happy path
52
+ * or a fully-formed `Response` (402/404/429/400/403) on any short-circuit.
53
+ *
54
+ * Body parsing is the caller's responsibility — different wire formats
55
+ * (OpenAI chat completions vs A2A JSON-RPC) have different envelopes; both
56
+ * still ultimately produce a `ChatMessage[]`.
57
+ */
58
+ export async function authenticateAndGuard(
59
+ c: Context,
60
+ slug: string,
61
+ messages: ChatMessage[],
62
+ config: GatewayConfig,
63
+ state: GatewayState,
64
+ ): Promise<AuthorizedRequest | Response> {
65
+ const startMs = Date.now()
66
+ const requestId = generateRequestId()
67
+ const ctx: RequestContext = { requestId, agentSlug: slug, startMs }
68
+ await state.obs?.onRequestStart?.(ctx)
69
+
70
+ const agent = await config.resolveAgent(slug)
71
+ if (!agent) {
72
+ return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)
73
+ }
74
+ if (!messages?.length) {
75
+ return c.json(
76
+ { error: { message: 'messages array required', type: 'invalid_request' } },
77
+ 400,
78
+ )
79
+ }
80
+
81
+ // Payment / auth.
82
+ const spendAuthHeader = c.req.header('X-Payment-Signature')
83
+ const authHeader = c.req.header('Authorization') ?? ''
84
+ let consumerId: string | null = null
85
+ let paymentMethod: PaymentMethod = 'none'
86
+ let keyInfo: ApiKeyInfo | null = null
87
+
88
+ if (spendAuthHeader) {
89
+ const signer = await verifyX402(spendAuthHeader, config.x402, state.nonceStore)
90
+ if (!signer) {
91
+ await state.obs?.onAuthFailure?.(ctx, {
92
+ method: 'x402',
93
+ code: 'invalid_spend_auth',
94
+ httpStatus: 402,
95
+ })
96
+ return c.json(
97
+ {
98
+ error: {
99
+ message: 'Invalid X-Payment-Signature',
100
+ type: 'authentication_error',
101
+ code: 'invalid_spend_auth',
102
+ },
103
+ },
104
+ {
105
+ status: 402,
106
+ headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': requestId },
107
+ },
108
+ )
109
+ }
110
+ consumerId = signer
111
+ paymentMethod = 'x402'
112
+ } else if (config.mpp && authHeader.toLowerCase().startsWith('payment ')) {
113
+ const signer = await verifyMpp(authHeader, config.mpp, config.x402)
114
+ if (!signer) {
115
+ const realm = config.mpp.realm
116
+ const method = config.mpp.method ?? 'blueprintevm'
117
+ await state.obs?.onAuthFailure?.(ctx, {
118
+ method: 'mpp',
119
+ code: 'invalid_mpp_credential',
120
+ httpStatus: 401,
121
+ })
122
+ return c.json(
123
+ {
124
+ error: {
125
+ message: 'Invalid Payment credential',
126
+ type: 'authentication_error',
127
+ code: 'invalid_mpp_credential',
128
+ },
129
+ },
130
+ {
131
+ status: 401,
132
+ headers: {
133
+ 'WWW-Authenticate': `Payment realm="${realm}", method="${method}"`,
134
+ 'X-Request-Id': requestId,
135
+ },
136
+ },
137
+ )
138
+ }
139
+ consumerId = signer
140
+ paymentMethod = 'mpp'
141
+ } else if (authHeader.startsWith('Bearer ')) {
142
+ const verify = config.verifyApiKey ?? defaultVerifyApiKey
143
+ const key = await verify(authHeader)
144
+ if (!key) {
145
+ await state.obs?.onAuthFailure?.(ctx, {
146
+ method: 'apikey',
147
+ code: 'invalid_api_key',
148
+ httpStatus: 401,
149
+ })
150
+ return c.json(
151
+ { error: { message: 'Invalid API key', type: 'authentication_error' } },
152
+ { status: 401, headers: { 'X-Request-Id': requestId } },
153
+ )
154
+ }
155
+ if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(state.requiredScope)) {
156
+ await state.obs?.onAuthFailure?.(ctx, {
157
+ method: 'apikey',
158
+ code: 'insufficient_scope',
159
+ httpStatus: 403,
160
+ })
161
+ return c.json(
162
+ {
163
+ error: {
164
+ message: `API key missing required scope: ${state.requiredScope}`,
165
+ type: 'forbidden',
166
+ code: 'insufficient_scope',
167
+ },
168
+ },
169
+ { status: 403, headers: { 'X-Request-Id': requestId } },
170
+ )
171
+ }
172
+ consumerId = key.consumerId
173
+ paymentMethod = 'apikey'
174
+ keyInfo = key
175
+ } else {
176
+ await state.obs?.onAuthFailure?.(ctx, {
177
+ method: 'none',
178
+ code: 'payment_required',
179
+ httpStatus: 402,
180
+ })
181
+ const methods: string[] = ['x402']
182
+ if (config.mpp) methods.push('mpp')
183
+ methods.push('api_key')
184
+ const headers: Record<string, string> = {
185
+ 'X-Payment-Required': methods.join(', '),
186
+ 'X-Request-Id': requestId,
187
+ }
188
+ if (config.mpp) {
189
+ headers['WWW-Authenticate'] =
190
+ `Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
191
+ }
192
+ return c.json(
193
+ {
194
+ error: {
195
+ message: 'Payment required',
196
+ type: 'payment_required',
197
+ payment_methods: methods,
198
+ x402: {
199
+ operator: config.x402.operatorAddress,
200
+ chain_id: config.x402.chainId,
201
+ credits_address: config.x402.creditsAddress,
202
+ estimated_amount_per_request: '20000',
203
+ },
204
+ ...(config.mpp
205
+ ? { mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' } }
206
+ : {}),
207
+ api_key: {
208
+ purchase_url: config.baseUrl
209
+ ? `${config.baseUrl}/agents/${slug}/api-keys`
210
+ : undefined,
211
+ },
212
+ },
213
+ },
214
+ { status: 402, headers },
215
+ )
216
+ }
217
+
218
+ await state.obs?.onPaymentVerified?.(ctx, {
219
+ method: paymentMethod,
220
+ consumerId: consumerId,
221
+ keyId: keyInfo?.keyId,
222
+ })
223
+
224
+ // Rate limit.
225
+ const effectiveRateLimit = keyInfo?.rateLimitPerMinute
226
+ ? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }
227
+ : state.globalRateLimit
228
+ const rl = await checkRateLimit(consumerId, effectiveRateLimit, state.rateLimitStore)
229
+ if (!rl.allowed) {
230
+ await state.obs?.onRateLimited?.(ctx, {
231
+ consumerId: consumerId,
232
+ retryAfterSeconds: rl.retryAfterSeconds ?? 60,
233
+ })
234
+ return c.json(
235
+ {
236
+ error: {
237
+ message: 'Rate limit exceeded',
238
+ type: 'rate_limit_error',
239
+ retry_after: rl.retryAfterSeconds,
240
+ },
241
+ },
242
+ {
243
+ status: 429,
244
+ headers: {
245
+ 'Retry-After': String(rl.retryAfterSeconds ?? 60),
246
+ 'X-Request-Id': requestId,
247
+ },
248
+ },
249
+ )
250
+ }
251
+
252
+ // Filter consumer messages — strip consumer-side system, length-cap, injection scan.
253
+ const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(
254
+ messages,
255
+ state.maxLen,
256
+ )
257
+ if (injectionWarnings.length > 0) {
258
+ await state.obs?.onInjectionDetected?.(ctx, {
259
+ consumerId: consumerId,
260
+ patterns: injectionWarnings,
261
+ blocked: !!config.blockInjection,
262
+ })
263
+ if (config.blockInjection) {
264
+ return c.json(
265
+ {
266
+ error: {
267
+ message: 'Request rejected: potential prompt injection detected',
268
+ type: 'content_policy_violation',
269
+ },
270
+ },
271
+ { status: 400, headers: { 'X-Request-Id': requestId } },
272
+ )
273
+ }
274
+ }
275
+
276
+ const userMessage = filtered
277
+ .filter((m) => m.role === 'user')
278
+ .map((m) => m.content)
279
+ .join('\n\n')
280
+ if (!userMessage) {
281
+ return c.json(
282
+ { error: { message: 'No user message provided', type: 'invalid_request' } },
283
+ 400,
284
+ )
285
+ }
286
+
287
+ if (config.authorizeConsumer) {
288
+ const authz = await config.authorizeConsumer(agent, {
289
+ method: paymentMethod,
290
+ consumerId: consumerId,
291
+ keyId: keyInfo?.keyId,
292
+ requestId,
293
+ })
294
+ if (!authz.allow) {
295
+ return c.json(
296
+ {
297
+ error: {
298
+ message: authz.reason,
299
+ type: 'authorization_denied',
300
+ code: authz.code,
301
+ },
302
+ },
303
+ { status: 403, headers: { 'X-Request-Id': requestId } },
304
+ )
305
+ }
306
+ }
307
+
308
+ return {
309
+ agent,
310
+ consumerId,
311
+ paymentMethod,
312
+ keyInfo,
313
+ userMessage,
314
+ rateLimitRemaining: rl.remaining,
315
+ requestId,
316
+ startMs,
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Yield the inner sandbox's response as text deltas, applying the
322
+ * system-prompt redaction filter on each delta so leakage of the agent's
323
+ * system prompt back through the model's output is suppressed identically
324
+ * whether the caller is on the OpenAI-compat path or A2A.
325
+ *
326
+ * Aborts when `signal` fires (used by A2A `tasks/cancel`).
327
+ */
328
+ export async function* dispatchSandboxStream(
329
+ agent: AgentMeta,
330
+ userMessage: string,
331
+ consumerId: string,
332
+ config: GatewayConfig,
333
+ signal?: AbortSignal,
334
+ sessionId?: string,
335
+ ): AsyncIterable<string> {
336
+ for await (const event of dispatchSandboxStreamRich(
337
+ agent,
338
+ userMessage,
339
+ consumerId,
340
+ config,
341
+ signal,
342
+ sessionId,
343
+ )) {
344
+ if (event.kind === 'text') yield event.delta
345
+ }
346
+ }
347
+
348
+ /**
349
+ * A2A-shaped dispatch event. Distinguishes text deltas from sandbox-signalled
350
+ * pause-for-input events. The A2A handler uses this richer variant so it can
351
+ * emit `input-required` status updates; the OpenAI-compat path consumes the
352
+ * text-only `dispatchSandboxStream` adapter above.
353
+ */
354
+ export type A2ADispatchEvent =
355
+ | { kind: 'text'; delta: string }
356
+ | { kind: 'input-required'; prompt?: string }
357
+
358
+ /**
359
+ * Like `dispatchSandboxStream` but yields a discriminated union so callers can
360
+ * react to `input-required` signals from the sandbox. The sandbox opts in by
361
+ * emitting `{ type: 'input-required', data: { inputRequired: { prompt? } } }`
362
+ * (or by setting `data.inputRequired` on any event); sandboxes that don't
363
+ * emit such events see identical behavior.
364
+ *
365
+ * `sessionId` defaults to `consumer:<id>` matching the existing single-turn
366
+ * path; multi-turn continuations pass an explicit `taskId` so the sandbox can
367
+ * keep per-task conversation memory.
368
+ */
369
+ export async function* dispatchSandboxStreamRich(
370
+ agent: AgentMeta,
371
+ userMessage: string,
372
+ consumerId: string,
373
+ config: GatewayConfig,
374
+ signal?: AbortSignal,
375
+ sessionId?: string,
376
+ ): AsyncIterable<A2ADispatchEvent> {
377
+ const box = await config.getSandbox(agent)
378
+ const promptStream = box.streamPrompt(userMessage, {
379
+ sessionId: sessionId ?? `consumer:${consumerId}`,
380
+ systemPrompt: agent.systemPrompt,
381
+ })
382
+ for await (const event of promptStream) {
383
+ if (signal?.aborted) return
384
+ if (
385
+ event.type === 'message.part.updated' &&
386
+ event.data?.part?.type === 'text' &&
387
+ event.data.delta
388
+ ) {
389
+ yield {
390
+ kind: 'text',
391
+ delta: redactSystemPromptFromOutput(event.data.delta, agent.systemPrompt),
392
+ }
393
+ continue
394
+ }
395
+ if (event.type === 'input-required' || event.data?.inputRequired) {
396
+ yield { kind: 'input-required', prompt: event.data?.inputRequired?.prompt }
397
+ // Terminal for the sandbox stream — sandbox SHOULD stop emitting until
398
+ // the gateway dispatches a continuation message with the new user input.
399
+ return
400
+ }
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Record usage event + settle payment + invoke the observer. Both wire
406
+ * formats call this once their stream has drained, so settlement happens
407
+ * exactly once per request regardless of protocol.
408
+ */
409
+ export async function settleAndRecord(
410
+ agent: AgentMeta,
411
+ authz: AuthorizedRequest,
412
+ inputTokens: number,
413
+ outputTokens: number,
414
+ config: GatewayConfig,
415
+ obs: GatewayObserver | undefined,
416
+ ): Promise<void> {
417
+ const totalCost = (inputTokens + outputTokens) * agent.pricePerTokenUsd
418
+ const ownerEarned = totalCost * (1 - agent.platformFeePercent)
419
+ const platformFee = totalCost * agent.platformFeePercent
420
+ const usageEvent = {
421
+ requestId: authz.requestId,
422
+ agentId: agent.id,
423
+ agentSlug: agent.slug,
424
+ consumerId: authz.consumerId,
425
+ paymentMethod: authz.paymentMethod,
426
+ inputTokens,
427
+ outputTokens,
428
+ totalCostUsd: totalCost,
429
+ ownerEarnedUsd: ownerEarned,
430
+ platformFeeUsd: platformFee,
431
+ durationMs: Date.now() - authz.startMs,
432
+ }
433
+ await config.recordUsage(usageEvent)
434
+ const ctx: RequestContext = {
435
+ requestId: authz.requestId,
436
+ agentSlug: agent.slug,
437
+ startMs: authz.startMs,
438
+ }
439
+ await obs?.onRequestComplete?.(ctx, usageEvent)
440
+ if (config.settlePayment) {
441
+ await config
442
+ .settlePayment(
443
+ {
444
+ method: authz.paymentMethod,
445
+ consumerId: authz.consumerId,
446
+ requestId: authz.requestId,
447
+ },
448
+ totalCost,
449
+ )
450
+ .catch(async (err) => {
451
+ const msg = err instanceof Error ? err.message : String(err)
452
+ console.error(`[agent-gateway] settlement failed for ${authz.consumerId}: ${msg}`)
453
+ await obs?.onSettlementError?.(ctx, {
454
+ consumerId: authz.consumerId,
455
+ method: authz.paymentMethod,
456
+ errorMessage: msg,
457
+ })
458
+ })
459
+ }
460
+ }
461
+
462
+ /** Token estimate matching the existing chat-completions handler (4 chars ≈ 1 token). */
463
+ export function estimateTokens(text: string): number {
464
+ return Math.ceil(text.length / 4)
465
+ }