@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.
- package/README.md +4 -0
- package/dist/chunk-3IKQWFKX.js +1703 -0
- package/dist/chunk-3IKQWFKX.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +108 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +2 -2
- package/dist/middleware.js +1 -1
- package/dist/types-CX2V06cN.d.ts +862 -0
- package/dist/types.d.ts +1 -1
- package/package.json +14 -10
- package/src/a2a/agent-card.ts +54 -0
- package/src/a2a/handler.ts +798 -0
- package/src/a2a/jsonrpc.ts +65 -0
- package/src/a2a/push-notifications.ts +299 -0
- package/src/a2a/task-store-sql.ts +189 -0
- package/src/a2a/task-store.ts +53 -0
- package/src/a2a/translate.ts +77 -0
- package/src/a2a/types.ts +217 -0
- package/src/dispatch.ts +465 -0
- package/src/index.ts +51 -0
- package/src/middleware.ts +134 -289
- package/src/types.ts +95 -0
- package/dist/chunk-373QHRKV.js +0 -635
- package/dist/chunk-373QHRKV.js.map +0 -1
- package/dist/types-BbTSfNhx.d.ts +0 -328
package/src/index.ts
CHANGED
|
@@ -57,3 +57,54 @@ export type {
|
|
|
57
57
|
ChatCompletionRequest,
|
|
58
58
|
ChatCompletionChunk,
|
|
59
59
|
} from './types'
|
|
60
|
+
|
|
61
|
+
// --- A2A protocol surface (Google Agent-to-Agent) ---
|
|
62
|
+
// Types + task-store adapter. Handlers are wired automatically by
|
|
63
|
+
// createAgentGateway when `GatewayConfig.a2a` (or its default) is honored;
|
|
64
|
+
// consumers only import these to BYO a durable TaskStore (D1, postgres, DO)
|
|
65
|
+
// or to declare richer AgentMeta.skills for the Agent Card.
|
|
66
|
+
export { InMemoryTaskStore, type TaskStore } from './a2a/task-store'
|
|
67
|
+
export {
|
|
68
|
+
type D1DatabaseLike,
|
|
69
|
+
type D1StmtLike,
|
|
70
|
+
d1ToSqlAdapter,
|
|
71
|
+
type SqlAdapter,
|
|
72
|
+
SqlTaskStore,
|
|
73
|
+
} from './a2a/task-store-sql'
|
|
74
|
+
export {
|
|
75
|
+
deliverPushNotifications,
|
|
76
|
+
InMemoryPushNotificationStore,
|
|
77
|
+
type PushDeliveryResult,
|
|
78
|
+
type PushNotificationAuthentication,
|
|
79
|
+
type PushNotificationConfig,
|
|
80
|
+
type PushNotificationStore,
|
|
81
|
+
SqlPushNotificationStore,
|
|
82
|
+
type TaskPushNotificationConfig,
|
|
83
|
+
} from './a2a/push-notifications'
|
|
84
|
+
export type {
|
|
85
|
+
AgentCard,
|
|
86
|
+
AgentCapabilities,
|
|
87
|
+
AgentCardAuthentication,
|
|
88
|
+
AgentProvider,
|
|
89
|
+
AgentSkill,
|
|
90
|
+
Artifact,
|
|
91
|
+
DataPart,
|
|
92
|
+
FilePart,
|
|
93
|
+
JSONRPCErrorResponse,
|
|
94
|
+
JSONRPCRequest,
|
|
95
|
+
JSONRPCResponse,
|
|
96
|
+
JSONRPCSuccessResponse,
|
|
97
|
+
Message,
|
|
98
|
+
MessageSendParams,
|
|
99
|
+
Part,
|
|
100
|
+
StreamingEvent,
|
|
101
|
+
Task,
|
|
102
|
+
TaskArtifactUpdateEvent,
|
|
103
|
+
TaskIdParams,
|
|
104
|
+
TaskPushNotificationConfigGetParams,
|
|
105
|
+
TaskState,
|
|
106
|
+
TaskStatus,
|
|
107
|
+
TaskStatusUpdateEvent,
|
|
108
|
+
TextPart,
|
|
109
|
+
} from './a2a/types'
|
|
110
|
+
export { A2A_ERROR_CODES } from './a2a/types'
|
package/src/middleware.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { Hono } from 'hono'
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 {
|
|
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'
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Create a Hono router that serves the agent gateway.
|
|
@@ -19,7 +22,7 @@ import { generateRequestId, type GatewayObserver, type RequestContext } from './
|
|
|
19
22
|
* app.route('/v1/agents', createAgentGateway(config))
|
|
20
23
|
*
|
|
21
24
|
* Exposes:
|
|
22
|
-
* GET /:slug/chat/completions — agent discovery metadata
|
|
25
|
+
* GET /:slug/chat/completions — agent discovery metadata (Tangle-native shape)
|
|
23
26
|
* POST /:slug/chat/completions — OpenAI-compatible chat endpoint (paid)
|
|
24
27
|
*/
|
|
25
28
|
export function createAgentGateway(config: GatewayConfig) {
|
|
@@ -32,12 +35,16 @@ export function createAgentGateway(config: GatewayConfig) {
|
|
|
32
35
|
)
|
|
33
36
|
}
|
|
34
37
|
const gw = new Hono()
|
|
35
|
-
const maxLen = config.maxMessageLength ?? 8000
|
|
36
38
|
const rateLimitStore: RateLimitStore = config.rateLimitStore ?? new MemoryRateLimitStore()
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const state: GatewayState = {
|
|
40
|
+
rateLimitStore,
|
|
41
|
+
nonceStore: config.nonceStore ?? new MemoryNonceStore(),
|
|
42
|
+
globalRateLimit: config.rateLimit ?? { limit: 60, windowSeconds: 60 },
|
|
43
|
+
requiredScope: config.requiredScope ?? 'chat',
|
|
44
|
+
maxLen: config.maxMessageLength ?? 8000,
|
|
45
|
+
obs: config.observer,
|
|
46
|
+
}
|
|
47
|
+
const obs: GatewayObserver | undefined = state.obs
|
|
41
48
|
|
|
42
49
|
// --- Discovery endpoint (no auth) ---
|
|
43
50
|
|
|
@@ -84,24 +91,22 @@ export function createAgentGateway(config: GatewayConfig) {
|
|
|
84
91
|
|
|
85
92
|
gw.post('/:slug/chat/completions', async (c) => {
|
|
86
93
|
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
94
|
|
|
93
|
-
//
|
|
94
|
-
const
|
|
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)
|
|
95
|
+
// Body size limit (before parsing — DoS prevention).
|
|
96
|
+
const contentLength = Number.parseInt(c.req.header('Content-Length') ?? '0', 10)
|
|
101
97
|
if (contentLength > 65536) {
|
|
102
|
-
|
|
98
|
+
const requestId = generateRequestId()
|
|
99
|
+
await obs?.onBodyTooLarge?.(
|
|
100
|
+
{ requestId, agentSlug: slug, startMs: Date.now() },
|
|
101
|
+
contentLength,
|
|
102
|
+
)
|
|
103
103
|
return c.json(
|
|
104
|
-
{
|
|
104
|
+
{
|
|
105
|
+
error: {
|
|
106
|
+
message: 'Request body too large (max 64KB)',
|
|
107
|
+
type: 'invalid_request',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
105
110
|
413,
|
|
106
111
|
)
|
|
107
112
|
}
|
|
@@ -113,277 +118,117 @@ export function createAgentGateway(config: GatewayConfig) {
|
|
|
113
118
|
return c.json({ error: { message: 'Invalid JSON', type: 'invalid_request' } }, 400)
|
|
114
119
|
}
|
|
115
120
|
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
121
|
return c.json(
|
|
220
|
-
{ error: { message: '
|
|
221
|
-
|
|
122
|
+
{ error: { message: 'messages array required', type: 'invalid_request' } },
|
|
123
|
+
400,
|
|
222
124
|
)
|
|
223
125
|
}
|
|
224
126
|
|
|
225
|
-
|
|
226
|
-
|
|
127
|
+
const guard = await authenticateAndGuard(c, slug, body.messages, config, state)
|
|
128
|
+
if (guard instanceof Response) return guard
|
|
129
|
+
const authz = guard
|
|
227
130
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
consumerId: consumerId!,
|
|
231
|
-
patterns: injectionWarnings,
|
|
232
|
-
blocked: !!config.blockInjection,
|
|
233
|
-
})
|
|
131
|
+
return streamChatCompletions(c, authz, config, obs)
|
|
132
|
+
})
|
|
234
133
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
134
|
+
// --- A2A protocol surface (Google Agent-to-Agent, JSON-RPC 2.0 + AgentCard) ---
|
|
135
|
+
// Mounted alongside the OpenAI-compat routes so a single agent speaks both.
|
|
136
|
+
// Both surfaces share authenticateAndGuard + dispatchSandboxStream +
|
|
137
|
+
// settleAndRecord, so every security and billing guarantee applies uniformly
|
|
138
|
+
// regardless of which protocol the caller used.
|
|
139
|
+
const taskStore = config.a2a?.taskStore ?? new InMemoryTaskStore()
|
|
140
|
+
const pushStore = config.a2a?.pushStore
|
|
141
|
+
const a2a = createA2AHandlers({ config, state, taskStore, pushStore })
|
|
142
|
+
gw.get('/:slug/.well-known/agent.json', a2a.handleAgentCard)
|
|
143
|
+
gw.post('/:slug', a2a.handleJsonRpc)
|
|
243
144
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
.map((m) => m.content)
|
|
247
|
-
.join('\n\n')
|
|
145
|
+
return gw
|
|
146
|
+
}
|
|
248
147
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Drain the sandbox stream into an OpenAI-shaped SSE response, settle the
|
|
150
|
+
* payment, fire observer hooks. Identical pre-refactor behavior, just lifted
|
|
151
|
+
* out of the handler so the A2A wrapper can reach the same dispatch path
|
|
152
|
+
* without duplicating it.
|
|
153
|
+
*/
|
|
154
|
+
function streamChatCompletions(
|
|
155
|
+
c: import('hono').Context,
|
|
156
|
+
authz: AuthorizedRequest,
|
|
157
|
+
config: GatewayConfig,
|
|
158
|
+
obs: GatewayObserver | undefined,
|
|
159
|
+
): Response {
|
|
160
|
+
const { agent, consumerId, paymentMethod, requestId, userMessage, rateLimitRemaining } = authz
|
|
161
|
+
const inputTokens = estimateTokens(userMessage)
|
|
162
|
+
let outputTokens = 0
|
|
163
|
+
const ctx: RequestContext = {
|
|
164
|
+
requestId,
|
|
165
|
+
agentSlug: agent.slug,
|
|
166
|
+
startMs: authz.startMs,
|
|
167
|
+
}
|
|
252
168
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
message: authz.reason,
|
|
267
|
-
type: 'authorization_denied',
|
|
268
|
-
code: authz.code,
|
|
269
|
-
},
|
|
270
|
-
},
|
|
271
|
-
{ status: 403, headers: { 'X-Request-Id': requestId } },
|
|
272
|
-
)
|
|
169
|
+
const stream = new ReadableStream({
|
|
170
|
+
async start(controller) {
|
|
171
|
+
const encoder = new TextEncoder()
|
|
172
|
+
const sendChunk = (delta: string) => {
|
|
173
|
+
outputTokens += estimateTokens(delta)
|
|
174
|
+
const chunk: ChatCompletionChunk = {
|
|
175
|
+
id: `chatcmpl-${Date.now()}`,
|
|
176
|
+
object: 'chat.completion.chunk',
|
|
177
|
+
created: Math.floor(Date.now() / 1000),
|
|
178
|
+
model: agent.slug,
|
|
179
|
+
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
|
|
180
|
+
}
|
|
181
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
|
|
273
182
|
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// 6. Get sandbox and stream response with output filtering
|
|
277
|
-
const inputTokens = Math.ceil(userMessage.length / 4)
|
|
278
|
-
let outputTokens = 0
|
|
279
183
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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`))
|
|
184
|
+
try {
|
|
185
|
+
for await (const delta of dispatchSandboxStream(agent, userMessage, consumerId, config)) {
|
|
186
|
+
sendChunk(delta)
|
|
295
187
|
}
|
|
296
188
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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()
|
|
189
|
+
const done: ChatCompletionChunk = {
|
|
190
|
+
id: `chatcmpl-${Date.now()}`,
|
|
191
|
+
object: 'chat.completion.chunk',
|
|
192
|
+
created: Math.floor(Date.now() / 1000),
|
|
193
|
+
model: agent.slug,
|
|
194
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
370
195
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
196
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
|
|
197
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
198
|
+
|
|
199
|
+
await settleAndRecord(agent, authz, inputTokens, outputTokens, config, obs)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const rawMessage = err instanceof Error ? err.message : String(err)
|
|
202
|
+
// Never expose stack traces / absolute paths from sandbox internals.
|
|
203
|
+
const safeMessage =
|
|
204
|
+
rawMessage.includes('/') || rawMessage.includes('\\')
|
|
205
|
+
? 'Internal agent error'
|
|
206
|
+
: rawMessage
|
|
207
|
+
await obs?.onStreamError?.(ctx, { consumerId, errorMessage: rawMessage })
|
|
208
|
+
controller.enqueue(
|
|
209
|
+
encoder.encode(
|
|
210
|
+
`data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`,
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
} finally {
|
|
214
|
+
controller.close()
|
|
215
|
+
}
|
|
216
|
+
},
|
|
386
217
|
})
|
|
387
218
|
|
|
388
|
-
return
|
|
219
|
+
return new Response(stream, {
|
|
220
|
+
headers: {
|
|
221
|
+
'Content-Type': 'text/event-stream',
|
|
222
|
+
'Cache-Control': 'no-cache',
|
|
223
|
+
'X-Request-Id': requestId,
|
|
224
|
+
'X-Agent-Slug': agent.slug,
|
|
225
|
+
'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
|
|
226
|
+
'X-Payment-Method': paymentMethod,
|
|
227
|
+
'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',
|
|
228
|
+
...(rateLimitRemaining !== undefined
|
|
229
|
+
? { 'X-RateLimit-Remaining': String(rateLimitRemaining) }
|
|
230
|
+
: {}),
|
|
231
|
+
},
|
|
232
|
+
})
|
|
389
233
|
}
|
|
234
|
+
|
package/src/types.ts
CHANGED
|
@@ -21,6 +21,52 @@ export interface AgentMeta {
|
|
|
21
21
|
remoteBearerToken: string | null
|
|
22
22
|
/** Whether agent is published and accepting requests */
|
|
23
23
|
enabled: boolean
|
|
24
|
+
/**
|
|
25
|
+
* CLI harness backend that runs this agent inside the sandbox sidecar.
|
|
26
|
+
*
|
|
27
|
+
* When set, the host's `getSandbox()` SHOULD return a `SandboxBox`
|
|
28
|
+
* whose `streamPrompt` POSTs to the sidecar's
|
|
29
|
+
* `POST /agent/invoke/chat/completions` endpoint with
|
|
30
|
+
* `model: "<harness>/<harnessModel>"` — that endpoint runs the
|
|
31
|
+
* harness against the sandbox workspace and streams OpenAI-shape
|
|
32
|
+
* `chat.completion.chunk` frames back.
|
|
33
|
+
*
|
|
34
|
+
* When unset (legacy / template mode), the host's `streamPrompt`
|
|
35
|
+
* falls back to the template's own `/api/chat/completions` (proxied
|
|
36
|
+
* via the sidecar's `/agent/invoke`).
|
|
37
|
+
*
|
|
38
|
+
* Known harnesses (registered in agent-dev-container's
|
|
39
|
+
* cli-agent-bindings.ts): opencode, claude-code, codex, kimi-code,
|
|
40
|
+
* amp, factory-droids, pi, hermes, openclaw, forge, acp, cursor.
|
|
41
|
+
* Aliases the sidecar canonicalizes: claude → claude-code,
|
|
42
|
+
* kimi → kimi-code, factory → factory-droids.
|
|
43
|
+
*/
|
|
44
|
+
harness?: string
|
|
45
|
+
/**
|
|
46
|
+
* Model identifier to pass after the harness in the
|
|
47
|
+
* `<harness>/<model>` slash form. Format is harness-specific:
|
|
48
|
+
* claude-code: "sonnet", "opus", or a versioned id like
|
|
49
|
+
* "claude-sonnet-4-20250514"
|
|
50
|
+
* opencode: "anthropic/claude-sonnet-4-5", "openai/gpt-4o", …
|
|
51
|
+
* (opencode embeds provider before model)
|
|
52
|
+
* codex: "gpt-5-codex"
|
|
53
|
+
* kimi-code: "kimi-for-coding"
|
|
54
|
+
*
|
|
55
|
+
* Only meaningful when `harness` is set; ignored otherwise.
|
|
56
|
+
*/
|
|
57
|
+
harnessModel?: string
|
|
58
|
+
/**
|
|
59
|
+
* Optional human description surfaced in the A2A Agent Card. Defaults to
|
|
60
|
+
* `"{slug} agent"` when absent.
|
|
61
|
+
*/
|
|
62
|
+
description?: string
|
|
63
|
+
/**
|
|
64
|
+
* Optional A2A skill descriptors. Each entry advertises what the agent
|
|
65
|
+
* can do so non-Tangle A2A clients can select agents by capability. When
|
|
66
|
+
* absent, the gateway synthesizes a single default `chat` skill from
|
|
67
|
+
* `slug` + `description`.
|
|
68
|
+
*/
|
|
69
|
+
skills?: import('./a2a/types').AgentSkill[]
|
|
24
70
|
}
|
|
25
71
|
|
|
26
72
|
// --- Payment ---
|
|
@@ -107,6 +153,15 @@ export interface SandboxStreamEvent {
|
|
|
107
153
|
part?: { type?: string; text?: string }
|
|
108
154
|
delta?: string
|
|
109
155
|
finalText?: string
|
|
156
|
+
/**
|
|
157
|
+
* Optional sandbox-side signal that the agent has paused and is waiting
|
|
158
|
+
* for additional input from the caller. The A2A gateway translates this
|
|
159
|
+
* into an `input-required` task status; the caller can then submit a
|
|
160
|
+
* follow-up `message/send` with the same `taskId` to continue. Ignored
|
|
161
|
+
* by the OpenAI-compat path. Carry an optional `prompt` to surface to
|
|
162
|
+
* the caller (rendered as the input-required message body).
|
|
163
|
+
*/
|
|
164
|
+
inputRequired?: { prompt?: string }
|
|
110
165
|
}
|
|
111
166
|
}
|
|
112
167
|
|
|
@@ -184,6 +239,46 @@ export interface GatewayConfig {
|
|
|
184
239
|
* ConsoleObserver / CompositeObserver implementations.
|
|
185
240
|
*/
|
|
186
241
|
observer?: import('./observer').GatewayObserver
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A2A protocol configuration. When set, the gateway exposes the A2A
|
|
245
|
+
* surface alongside its OpenAI-compatible endpoints:
|
|
246
|
+
* GET /:slug/.well-known/agent.json — AgentCard discovery
|
|
247
|
+
* POST /:slug — JSON-RPC 2.0 endpoint
|
|
248
|
+
* methods: message/send, message/stream, tasks/get, tasks/cancel
|
|
249
|
+
* Auth + rate-limit + injection-filter + authorization all share the
|
|
250
|
+
* same pipeline as the OpenAI-compat path. `taskStore` defaults to
|
|
251
|
+
* `InMemoryTaskStore`; swap in D1/postgres/DO for durable deployments.
|
|
252
|
+
*/
|
|
253
|
+
a2a?: {
|
|
254
|
+
/**
|
|
255
|
+
* Where tasks live. Defaults to `InMemoryTaskStore`; swap in
|
|
256
|
+
* `SqlTaskStore` (D1, postgres, sqlite, libSQL) for durability across
|
|
257
|
+
* gateway restarts.
|
|
258
|
+
*/
|
|
259
|
+
taskStore?: import('./a2a/task-store').TaskStore
|
|
260
|
+
/**
|
|
261
|
+
* Where push notification configs live. When set, the gateway advertises
|
|
262
|
+
* `capabilities.pushNotifications: true` and exposes the four
|
|
263
|
+
* `tasks/pushNotificationConfig/*` JSON-RPC methods. Defaults to
|
|
264
|
+
* undefined (push support disabled), so the agent card honestly reflects
|
|
265
|
+
* what the gateway will actually do.
|
|
266
|
+
*/
|
|
267
|
+
pushStore?: import('./a2a/push-notifications').PushNotificationStore
|
|
268
|
+
/**
|
|
269
|
+
* Shared HMAC secret used to sign webhook deliveries (`X-A2A-Signature:
|
|
270
|
+
* sha256=<hex>`). The consumer's webhook verifies the body against this
|
|
271
|
+
* secret to confirm the call originated from this gateway. Required when
|
|
272
|
+
* `pushStore` is set; without it, deliveries fire unsigned and a
|
|
273
|
+
* malicious party that knows the webhook URL can forge deliveries.
|
|
274
|
+
*/
|
|
275
|
+
webhookSecret?: string
|
|
276
|
+
/**
|
|
277
|
+
* Optional fetcher override for webhook delivery. Defaults to global
|
|
278
|
+
* `fetch`. Override for tests or to wire a queue-backed sender.
|
|
279
|
+
*/
|
|
280
|
+
pushFetcher?: typeof fetch
|
|
281
|
+
}
|
|
187
282
|
}
|
|
188
283
|
|
|
189
284
|
// --- Chat completion types (OpenAI-compatible) ---
|