@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.
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export { createAgentGateway } from './middleware'
2
- export { verifyX402, verifyMpp, defaultVerifyApiKey } from './verify'
2
+ export {
3
+ verifyX402,
4
+ verifyMpp,
5
+ defaultVerifyApiKey,
6
+ isApiKeyAuthEnabled,
7
+ isMppAuthEnabled,
8
+ } from './verify'
3
9
  export {
4
10
  filterConsumerMessages,
5
11
  filterConsumerMessagesStrict,
@@ -57,3 +63,54 @@ export type {
57
63
  ChatCompletionRequest,
58
64
  ChatCompletionChunk,
59
65
  } from './types'
66
+
67
+ // --- A2A protocol surface (Google Agent-to-Agent) ---
68
+ // Types + task-store adapter. Handlers are wired automatically by
69
+ // createAgentGateway when `GatewayConfig.a2a` (or its default) is honored;
70
+ // consumers only import these to BYO a durable TaskStore (D1, postgres, DO)
71
+ // or to declare richer AgentMeta.skills for the Agent Card.
72
+ export { InMemoryTaskStore, type TaskStore } from './a2a/task-store'
73
+ export {
74
+ type D1DatabaseLike,
75
+ type D1StmtLike,
76
+ d1ToSqlAdapter,
77
+ type SqlAdapter,
78
+ SqlTaskStore,
79
+ } from './a2a/task-store-sql'
80
+ export {
81
+ deliverPushNotifications,
82
+ InMemoryPushNotificationStore,
83
+ type PushDeliveryResult,
84
+ type PushNotificationAuthentication,
85
+ type PushNotificationConfig,
86
+ type PushNotificationStore,
87
+ SqlPushNotificationStore,
88
+ type TaskPushNotificationConfig,
89
+ } from './a2a/push-notifications'
90
+ export type {
91
+ AgentCard,
92
+ AgentCapabilities,
93
+ AgentCardAuthentication,
94
+ AgentProvider,
95
+ AgentSkill,
96
+ Artifact,
97
+ DataPart,
98
+ FilePart,
99
+ JSONRPCErrorResponse,
100
+ JSONRPCRequest,
101
+ JSONRPCResponse,
102
+ JSONRPCSuccessResponse,
103
+ Message,
104
+ MessageSendParams,
105
+ Part,
106
+ StreamingEvent,
107
+ Task,
108
+ TaskArtifactUpdateEvent,
109
+ TaskIdParams,
110
+ TaskPushNotificationConfigGetParams,
111
+ TaskState,
112
+ TaskStatus,
113
+ TaskStatusUpdateEvent,
114
+ TextPart,
115
+ } from './a2a/types'
116
+ export { A2A_ERROR_CODES } from './a2a/types'
package/src/middleware.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  import { Hono } from 'hono'
2
- import type {
3
- GatewayConfig,
4
- ChatCompletionRequest,
5
- ChatCompletionChunk,
6
- PaymentMethod,
7
- ApiKeyInfo,
8
- } from './types'
9
- import { verifyX402, verifyMpp, defaultVerifyApiKey } from './verify'
10
- import { filterConsumerMessagesStrict, redactSystemPromptFromOutput } from './filter'
11
- import { checkRateLimit, MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
2
+
3
+ import { createA2AHandlers } from './a2a/handler'
4
+ import { InMemoryTaskStore } from './a2a/task-store'
5
+ import {
6
+ type AuthorizedRequest,
7
+ type GatewayState,
8
+ authenticateAndGuard,
9
+ dispatchSandboxStream,
10
+ estimateTokens,
11
+ settleAndRecord,
12
+ } from './dispatch'
12
13
  import { MemoryNonceStore } from './nonce-store'
13
- import { generateRequestId, type GatewayObserver, type RequestContext } from './observer'
14
+ import { type GatewayObserver, type RequestContext, generateRequestId } from './observer'
15
+ import { MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
16
+ import type { ChatCompletionChunk, ChatCompletionRequest, GatewayConfig } from './types'
17
+ import { isApiKeyAuthEnabled, isMppAuthEnabled } from './verify'
14
18
 
15
19
  /**
16
20
  * Create a Hono router that serves the agent gateway.
@@ -19,7 +23,7 @@ import { generateRequestId, type GatewayObserver, type RequestContext } from './
19
23
  * app.route('/v1/agents', createAgentGateway(config))
20
24
  *
21
25
  * Exposes:
22
- * GET /:slug/chat/completions — agent discovery metadata
26
+ * GET /:slug/chat/completions — agent discovery metadata (Tangle-native shape)
23
27
  * POST /:slug/chat/completions — OpenAI-compatible chat endpoint (paid)
24
28
  */
25
29
  export function createAgentGateway(config: GatewayConfig) {
@@ -32,19 +36,23 @@ export function createAgentGateway(config: GatewayConfig) {
32
36
  )
33
37
  }
34
38
  const gw = new Hono()
35
- const maxLen = config.maxMessageLength ?? 8000
36
39
  const rateLimitStore: RateLimitStore = config.rateLimitStore ?? new MemoryRateLimitStore()
37
- const globalRateLimit = config.rateLimit ?? { limit: 60, windowSeconds: 60 }
38
- const nonceStore = config.nonceStore ?? new MemoryNonceStore()
39
- const requiredScope = config.requiredScope ?? 'chat'
40
- const obs: GatewayObserver | undefined = config.observer
40
+ const state: GatewayState = {
41
+ rateLimitStore,
42
+ nonceStore: config.nonceStore ?? new MemoryNonceStore(),
43
+ globalRateLimit: config.rateLimit ?? { limit: 60, windowSeconds: 60 },
44
+ requiredScope: config.requiredScope ?? 'chat',
45
+ maxLen: config.maxMessageLength ?? 8000,
46
+ obs: config.observer,
47
+ }
48
+ const obs: GatewayObserver | undefined = state.obs
41
49
 
42
50
  // --- Discovery endpoint (no auth) ---
43
51
 
44
52
  gw.get('/:slug/chat/completions', async (c) => {
45
53
  const slug = c.req.param('slug')
46
54
  const agent = await config.resolveAgent(slug)
47
- if (!agent) return c.json({ error: 'Agent not found or not published' }, 404)
55
+ if (!agent || !agent.enabled) return c.json({ error: 'Agent not found or not published' }, 404)
48
56
 
49
57
  const paymentMethods: Array<Record<string, unknown>> = [
50
58
  {
@@ -54,14 +62,14 @@ export function createAgentGateway(config: GatewayConfig) {
54
62
  credits_contract: config.x402.creditsAddress,
55
63
  },
56
64
  ]
57
- if (config.mpp) {
65
+ if (isMppAuthEnabled(config)) {
58
66
  paymentMethods.push({
59
67
  type: 'mpp',
60
- realm: config.mpp.realm,
61
- method: config.mpp.method ?? 'blueprintevm',
68
+ realm: config.mpp!.realm,
69
+ method: config.mpp!.method ?? 'blueprintevm',
62
70
  })
63
71
  }
64
- paymentMethods.push({ type: 'api_key', prefix: 'sk_agent_' })
72
+ if (isApiKeyAuthEnabled(config)) paymentMethods.push({ type: 'api_key', prefix: 'sk_agent_' })
65
73
 
66
74
  return c.json({
67
75
  slug: agent.slug,
@@ -84,24 +92,22 @@ export function createAgentGateway(config: GatewayConfig) {
84
92
 
85
93
  gw.post('/:slug/chat/completions', async (c) => {
86
94
  const slug = c.req.param('slug')
87
- const startMs = Date.now()
88
- const requestId = generateRequestId()
89
- const ctx: RequestContext = { requestId, agentSlug: slug, startMs }
90
-
91
- await obs?.onRequestStart?.(ctx)
92
95
 
93
- // 1. Resolve agent
94
- const agent = await config.resolveAgent(slug)
95
- if (!agent) {
96
- return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)
97
- }
98
-
99
- // 2. Body size limit (before parsing — DoS prevention)
100
- const contentLength = parseInt(c.req.header('Content-Length') ?? '0', 10)
96
+ // Body size limit (before parsing — DoS prevention).
97
+ const contentLength = Number.parseInt(c.req.header('Content-Length') ?? '0', 10)
101
98
  if (contentLength > 65536) {
102
- await obs?.onBodyTooLarge?.(ctx, contentLength)
99
+ const requestId = generateRequestId()
100
+ await obs?.onBodyTooLarge?.(
101
+ { requestId, agentSlug: slug, startMs: Date.now() },
102
+ contentLength,
103
+ )
103
104
  return c.json(
104
- { error: { message: 'Request body too large (max 64KB)', type: 'invalid_request' } },
105
+ {
106
+ error: {
107
+ message: 'Request body too large (max 64KB)',
108
+ type: 'invalid_request',
109
+ },
110
+ },
105
111
  413,
106
112
  )
107
113
  }
@@ -113,277 +119,116 @@ export function createAgentGateway(config: GatewayConfig) {
113
119
  return c.json({ error: { message: 'Invalid JSON', type: 'invalid_request' } }, 400)
114
120
  }
115
121
  if (!body.messages?.length) {
116
- return c.json({ error: { message: 'messages array required', type: 'invalid_request' } }, 400)
117
- }
118
-
119
- // 3. Authenticate — x402 SpendAuth, MPP, or API key
120
- const spendAuthHeader = c.req.header('X-Payment-Signature')
121
- const authHeader = c.req.header('Authorization') ?? ''
122
- let consumerId: string | null = null
123
- let paymentMethod: PaymentMethod = 'none'
124
- let keyInfo: ApiKeyInfo | null = null
125
-
126
- if (spendAuthHeader) {
127
- const signer = await verifyX402(spendAuthHeader, config.x402, nonceStore)
128
- if (!signer) {
129
- await obs?.onAuthFailure?.(ctx, { method: 'x402', code: 'invalid_spend_auth', httpStatus: 402 })
130
- return c.json(
131
- { error: { message: 'Invalid X-Payment-Signature', type: 'authentication_error', code: 'invalid_spend_auth' } },
132
- { status: 402, headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': requestId } },
133
- )
134
- }
135
- consumerId = signer
136
- paymentMethod = 'x402'
137
- } else if (config.mpp && authHeader.toLowerCase().startsWith('payment ')) {
138
- const signer = await verifyMpp(authHeader, config.mpp, config.x402)
139
- if (!signer) {
140
- const realm = config.mpp.realm
141
- const method = config.mpp.method ?? 'blueprintevm'
142
- await obs?.onAuthFailure?.(ctx, { method: 'mpp', code: 'invalid_mpp_credential', httpStatus: 401 })
143
- return c.json(
144
- { error: { message: 'Invalid Payment credential', type: 'authentication_error', code: 'invalid_mpp_credential' } },
145
- { status: 401, headers: { 'WWW-Authenticate': `Payment realm="${realm}", method="${method}"`, 'X-Request-Id': requestId } },
146
- )
147
- }
148
- consumerId = signer
149
- paymentMethod = 'mpp'
150
- } else if (authHeader.startsWith('Bearer ')) {
151
- const verify = config.verifyApiKey ?? defaultVerifyApiKey
152
- const key = await verify(authHeader)
153
- if (!key) {
154
- await obs?.onAuthFailure?.(ctx, { method: 'apikey', code: 'invalid_api_key', httpStatus: 401 })
155
- return c.json(
156
- { error: { message: 'Invalid API key', type: 'authentication_error' } },
157
- { status: 401, headers: { 'X-Request-Id': requestId } },
158
- )
159
- }
160
-
161
- // Scope enforcement — API key must include the required scope
162
- if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(requiredScope)) {
163
- await obs?.onAuthFailure?.(ctx, { method: 'apikey', code: 'insufficient_scope', httpStatus: 403 })
164
- return c.json(
165
- { error: { message: `API key missing required scope: ${requiredScope}`, type: 'forbidden', code: 'insufficient_scope' } },
166
- { status: 403, headers: { 'X-Request-Id': requestId } },
167
- )
168
- }
169
-
170
- consumerId = key.consumerId
171
- paymentMethod = 'apikey'
172
- keyInfo = key
173
- } else {
174
- // No payment — return 402 with instructions
175
- await obs?.onAuthFailure?.(ctx, { method: 'none', code: 'payment_required', httpStatus: 402 })
176
- const methods: string[] = ['x402']
177
- if (config.mpp) methods.push('mpp')
178
- methods.push('api_key')
179
-
180
- const headers: Record<string, string> = {
181
- 'X-Payment-Required': methods.join(', '),
182
- 'X-Request-Id': requestId,
183
- }
184
- if (config.mpp) {
185
- headers['WWW-Authenticate'] = `Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
186
- }
187
-
188
- return c.json({
189
- error: {
190
- message: 'Payment required',
191
- type: 'payment_required',
192
- payment_methods: methods,
193
- x402: {
194
- operator: config.x402.operatorAddress,
195
- chain_id: config.x402.chainId,
196
- credits_address: config.x402.creditsAddress,
197
- estimated_amount_per_request: '20000',
198
- },
199
- ...(config.mpp ? {
200
- mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' },
201
- } : {}),
202
- api_key: {
203
- purchase_url: config.baseUrl ? `${config.baseUrl}/agents/${slug}/api-keys` : undefined,
204
- },
205
- },
206
- }, { status: 402, headers })
207
- }
208
-
209
- await obs?.onPaymentVerified?.(ctx, { method: paymentMethod, consumerId: consumerId!, keyId: keyInfo?.keyId })
210
-
211
- // 4. Rate limit — per-key override or global
212
- const effectiveRateLimit = keyInfo?.rateLimitPerMinute
213
- ? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }
214
- : globalRateLimit
215
-
216
- const rl = await checkRateLimit(consumerId!, effectiveRateLimit, rateLimitStore)
217
- if (!rl.allowed) {
218
- await obs?.onRateLimited?.(ctx, { consumerId: consumerId!, retryAfterSeconds: rl.retryAfterSeconds ?? 60 })
219
122
  return c.json(
220
- { error: { message: 'Rate limit exceeded', type: 'rate_limit_error', retry_after: rl.retryAfterSeconds } },
221
- { status: 429, headers: { 'Retry-After': String(rl.retryAfterSeconds ?? 60), 'X-Request-Id': requestId } },
123
+ { error: { message: 'messages array required', type: 'invalid_request' } },
124
+ 400,
222
125
  )
223
126
  }
224
127
 
225
- // 5. Filter messages injection detection + sanitization
226
- const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(body.messages, maxLen)
128
+ const guard = await authenticateAndGuard(c, slug, body.messages, config, state)
129
+ if (guard instanceof Response) return guard
130
+ const authz = guard
227
131
 
228
- if (injectionWarnings.length > 0) {
229
- await obs?.onInjectionDetected?.(ctx, {
230
- consumerId: consumerId!,
231
- patterns: injectionWarnings,
232
- blocked: !!config.blockInjection,
233
- })
132
+ return streamChatCompletions(c, authz, config, obs)
133
+ })
234
134
 
235
- if (config.blockInjection) {
236
- return c.json(
237
- { error: { message: 'Request rejected: potential prompt injection detected', type: 'content_policy_violation' } },
238
- { status: 400, headers: { 'X-Request-Id': requestId } },
239
- )
240
- }
241
- // In non-blocking mode, continue but the warning is logged for auditing
242
- }
135
+ // --- A2A protocol surface (Google Agent-to-Agent, JSON-RPC 2.0 + AgentCard) ---
136
+ // Mounted alongside the OpenAI-compat routes so a single agent speaks both.
137
+ // Both surfaces share authenticateAndGuard + dispatchSandboxStream +
138
+ // settleAndRecord, so every security and billing guarantee applies uniformly
139
+ // regardless of which protocol the caller used.
140
+ const taskStore = config.a2a?.taskStore ?? new InMemoryTaskStore()
141
+ const pushStore = config.a2a?.pushStore
142
+ const a2a = createA2AHandlers({ config, state, taskStore, pushStore })
143
+ gw.get('/:slug/.well-known/agent.json', a2a.handleAgentCard)
144
+ gw.post('/:slug', a2a.handleJsonRpc)
243
145
 
244
- const userMessage = filtered
245
- .filter((m) => m.role === 'user')
246
- .map((m) => m.content)
247
- .join('\n\n')
146
+ return gw
147
+ }
248
148
 
249
- if (!userMessage) {
250
- return c.json({ error: { message: 'No user message provided', type: 'invalid_request' } }, 400)
251
- }
149
+ /**
150
+ * Drain the sandbox stream into an OpenAI-shaped SSE response, settle the
151
+ * payment, fire observer hooks. Identical pre-refactor behavior, just lifted
152
+ * out of the handler so the A2A wrapper can reach the same dispatch path
153
+ * without duplicating it.
154
+ */
155
+ function streamChatCompletions(
156
+ c: import('hono').Context,
157
+ authz: AuthorizedRequest,
158
+ config: GatewayConfig,
159
+ obs: GatewayObserver | undefined,
160
+ ): Response {
161
+ const { agent, consumerId, paymentMethod, requestId, userMessage, rateLimitRemaining } = authz
162
+ const inputTokens = estimateTokens(userMessage)
163
+ let outputTokens = 0
164
+ const ctx: RequestContext = {
165
+ requestId,
166
+ agentSlug: agent.slug,
167
+ startMs: authz.startMs,
168
+ }
252
169
 
253
- // 5b. Optional host authorization. Runs after payment/rate-limit
254
- // checks and before sandbox allocation.
255
- if (config.authorizeConsumer) {
256
- const authz = await config.authorizeConsumer(agent, {
257
- method: paymentMethod,
258
- consumerId: consumerId!,
259
- keyId: keyInfo?.keyId,
260
- requestId,
261
- })
262
- if (!authz.allow) {
263
- return c.json(
264
- {
265
- error: {
266
- message: authz.reason,
267
- type: 'authorization_denied',
268
- code: authz.code,
269
- },
270
- },
271
- { status: 403, headers: { 'X-Request-Id': requestId } },
272
- )
170
+ const stream = new ReadableStream({
171
+ async start(controller) {
172
+ const encoder = new TextEncoder()
173
+ const sendChunk = (delta: string) => {
174
+ outputTokens += estimateTokens(delta)
175
+ const chunk: ChatCompletionChunk = {
176
+ id: `chatcmpl-${Date.now()}`,
177
+ object: 'chat.completion.chunk',
178
+ created: Math.floor(Date.now() / 1000),
179
+ model: agent.slug,
180
+ choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
181
+ }
182
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
273
183
  }
274
- }
275
184
 
276
- // 6. Get sandbox and stream response with output filtering
277
- const inputTokens = Math.ceil(userMessage.length / 4)
278
- let outputTokens = 0
279
-
280
- const stream = new ReadableStream({
281
- async start(controller) {
282
- const encoder = new TextEncoder()
283
- const sendChunk = (rawDelta: string) => {
284
- // Redact system prompt leakage from output
285
- const delta = redactSystemPromptFromOutput(rawDelta, agent.systemPrompt)
286
- outputTokens += Math.ceil(delta.length / 4)
287
- const chunk: ChatCompletionChunk = {
288
- id: `chatcmpl-${Date.now()}`,
289
- object: 'chat.completion.chunk',
290
- created: Math.floor(Date.now() / 1000),
291
- model: agent.slug,
292
- choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
293
- }
294
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
185
+ try {
186
+ for await (const delta of dispatchSandboxStream(agent, userMessage, consumerId, config)) {
187
+ sendChunk(delta)
295
188
  }
296
189
 
297
- try {
298
- const box = await config.getSandbox(agent)
299
- const promptStream = box.streamPrompt(userMessage, {
300
- sessionId: `consumer:${consumerId}`,
301
- systemPrompt: agent.systemPrompt,
302
- })
303
-
304
- for await (const event of promptStream) {
305
- if (
306
- event.type === 'message.part.updated' &&
307
- event.data?.part?.type === 'text' &&
308
- event.data.delta
309
- ) {
310
- sendChunk(event.data.delta)
311
- }
312
- }
313
-
314
- // Final chunk
315
- const done: ChatCompletionChunk = {
316
- id: `chatcmpl-${Date.now()}`,
317
- object: 'chat.completion.chunk',
318
- created: Math.floor(Date.now() / 1000),
319
- model: agent.slug,
320
- choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
321
- }
322
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
323
- controller.enqueue(encoder.encode('data: [DONE]\n\n'))
324
-
325
- // 7. Record usage + settle payment
326
- const totalCost = (inputTokens + outputTokens) * agent.pricePerTokenUsd
327
- const ownerEarned = totalCost * (1 - agent.platformFeePercent)
328
- const platformFee = totalCost * agent.platformFeePercent
329
-
330
- const usageEvent = {
331
- requestId: ctx.requestId,
332
- agentId: agent.id,
333
- agentSlug: agent.slug,
334
- consumerId: consumerId!,
335
- paymentMethod,
336
- inputTokens,
337
- outputTokens,
338
- totalCostUsd: totalCost,
339
- ownerEarnedUsd: ownerEarned,
340
- platformFeeUsd: platformFee,
341
- durationMs: Date.now() - startMs,
342
- }
343
-
344
- await config.recordUsage(usageEvent)
345
- await obs?.onRequestComplete?.(ctx, usageEvent)
346
-
347
- if (config.settlePayment) {
348
- await config.settlePayment(
349
- { method: paymentMethod, consumerId: consumerId!, requestId: ctx.requestId },
350
- totalCost,
351
- ).catch(async err => {
352
- const msg = err instanceof Error ? err.message : String(err)
353
- console.error(`[agent-gateway] settlement failed for ${consumerId}: ${msg}`)
354
- await obs?.onSettlementError?.(ctx, { consumerId: consumerId!, method: paymentMethod, errorMessage: msg })
355
- })
356
- }
357
- } catch (err) {
358
- // Sanitize error — never expose stack traces or internal paths
359
- const rawMessage = err instanceof Error ? err.message : String(err)
360
- const safeMessage =
361
- rawMessage.includes('/') || rawMessage.includes('\\')
362
- ? 'Internal agent error'
363
- : rawMessage
364
- await obs?.onStreamError?.(ctx, { consumerId: consumerId!, errorMessage: rawMessage })
365
- controller.enqueue(
366
- encoder.encode(`data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`),
367
- )
368
- } finally {
369
- controller.close()
190
+ const done: ChatCompletionChunk = {
191
+ id: `chatcmpl-${Date.now()}`,
192
+ object: 'chat.completion.chunk',
193
+ created: Math.floor(Date.now() / 1000),
194
+ model: agent.slug,
195
+ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
370
196
  }
371
- },
372
- })
373
-
374
- return new Response(stream, {
375
- headers: {
376
- 'Content-Type': 'text/event-stream',
377
- 'Cache-Control': 'no-cache',
378
- 'X-Request-Id': requestId,
379
- 'X-Agent-Slug': agent.slug,
380
- 'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
381
- 'X-Payment-Method': paymentMethod,
382
- 'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',
383
- ...(rl.remaining !== undefined ? { 'X-RateLimit-Remaining': String(rl.remaining) } : {}),
384
- },
385
- })
197
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
198
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
199
+
200
+ await settleAndRecord(agent, authz, inputTokens, outputTokens, config, obs)
201
+ } catch (err) {
202
+ const rawMessage = err instanceof Error ? err.message : String(err)
203
+ // Never expose stack traces / absolute paths from sandbox internals.
204
+ const safeMessage =
205
+ rawMessage.includes('/') || rawMessage.includes('\\')
206
+ ? 'Internal agent error'
207
+ : rawMessage
208
+ await obs?.onStreamError?.(ctx, { consumerId, errorMessage: rawMessage })
209
+ controller.enqueue(
210
+ encoder.encode(
211
+ `data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`,
212
+ ),
213
+ )
214
+ } finally {
215
+ controller.close()
216
+ }
217
+ },
386
218
  })
387
219
 
388
- return gw
220
+ return new Response(stream, {
221
+ headers: {
222
+ 'Content-Type': 'text/event-stream',
223
+ 'Cache-Control': 'no-cache',
224
+ 'X-Request-Id': requestId,
225
+ 'X-Agent-Slug': agent.slug,
226
+ 'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
227
+ 'X-Payment-Method': paymentMethod,
228
+ 'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',
229
+ ...(rateLimitRemaining !== undefined
230
+ ? { 'X-RateLimit-Remaining': String(rateLimitRemaining) }
231
+ : {}),
232
+ },
233
+ })
389
234
  }