@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/a2a/types.ts
ADDED
|
@@ -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
|
+
}
|
package/src/dispatch.ts
ADDED
|
@@ -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
|
+
}
|