@nifrajs/agent 3.1.0 → 3.3.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 +36 -0
- package/dist/deployment.d.ts +157 -0
- package/dist/deployment.d.ts.map +1 -0
- package/dist/deployment.js +374 -0
- package/dist/deployment.js.map +1 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +75 -0
- package/dist/events.js.map +1 -1
- package/dist/execution-policy.d.ts.map +1 -1
- package/dist/execution-policy.js +11 -7
- package/dist/execution-policy.js.map +1 -1
- package/dist/gateway.d.ts +153 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +409 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +86 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +163 -1
- package/dist/index.js.map +1 -1
- package/dist/mount.d.ts +59 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +247 -0
- package/dist/mount.js.map +1 -0
- package/dist/registry.d.ts +112 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +326 -0
- package/dist/registry.js.map +1 -0
- package/package.json +12 -2
- package/src/deployment.ts +621 -0
- package/src/events.ts +125 -0
- package/src/execution-policy.ts +9 -5
- package/src/gateway.ts +607 -0
- package/src/index.ts +303 -1
- package/src/mount.ts +344 -0
- package/src/registry.ts +444 -0
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-neutral model gateway contracts.
|
|
3
|
+
*
|
|
4
|
+
* This module is deliberately an adapter seam. It knows how to bound an attempt, validate an
|
|
5
|
+
* untrusted adapter result, and record safe evidence; it does not know provider SDKs, credentials,
|
|
6
|
+
* prompts, pricing, or a routing service. Request input and model output are transient caller data.
|
|
7
|
+
* Only the returned evidence may cross a public reference boundary.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { StandardSchemaV1 } from "@nifrajs/core/schema"
|
|
11
|
+
import { validateStandard } from "@nifrajs/core/schema"
|
|
12
|
+
|
|
13
|
+
export const MODEL_GATEWAY_ERROR_CODES = [
|
|
14
|
+
"malformed_output",
|
|
15
|
+
"refusal",
|
|
16
|
+
"timeout",
|
|
17
|
+
"rate_limit",
|
|
18
|
+
"unavailable",
|
|
19
|
+
"policy_denied",
|
|
20
|
+
"cancelled",
|
|
21
|
+
"internal",
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
export type ModelGatewayErrorCode = (typeof MODEL_GATEWAY_ERROR_CODES)[number]
|
|
25
|
+
|
|
26
|
+
const ERROR_CODES: ReadonlySet<string> = new Set(MODEL_GATEWAY_ERROR_CODES)
|
|
27
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
28
|
+
const MAX_ATTEMPTS = 512
|
|
29
|
+
const MAX_TOKENS = 10_000_000
|
|
30
|
+
const MAX_EVIDENCE = 512
|
|
31
|
+
|
|
32
|
+
export interface ModelRoute {
|
|
33
|
+
/** Caller-owned opaque route identifier. It is not a provider or model name. */
|
|
34
|
+
readonly id: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ModelRouteRef = string | ModelRoute
|
|
38
|
+
|
|
39
|
+
export interface ModelGatewayBudget {
|
|
40
|
+
readonly maxAttempts: number
|
|
41
|
+
readonly maxInputTokens?: number
|
|
42
|
+
readonly maxOutputTokens?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ModelGatewayUsage {
|
|
46
|
+
readonly inputTokens?: number
|
|
47
|
+
readonly outputTokens?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ModelGatewayEnvelope {
|
|
51
|
+
readonly attempt: number
|
|
52
|
+
readonly attemptsRemaining: number
|
|
53
|
+
readonly inputTokensRemaining?: number
|
|
54
|
+
readonly outputTokensRemaining?: number
|
|
55
|
+
readonly deadlineAt?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The transient request given to a leaf adapter. Never copy `input` into evidence. */
|
|
59
|
+
export interface ModelGatewayAttemptRequest<Input = unknown> {
|
|
60
|
+
readonly routeId: string
|
|
61
|
+
readonly input: Input
|
|
62
|
+
readonly signal: AbortSignal
|
|
63
|
+
readonly envelope: ModelGatewayEnvelope
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ModelGatewayRequest<Input = unknown, Output = unknown> {
|
|
67
|
+
readonly input: Input
|
|
68
|
+
/** Optional initial route. If omitted, the first route in the caller policy is used. */
|
|
69
|
+
readonly routeId?: string
|
|
70
|
+
readonly parser?: StructuredOutputParser<Output>
|
|
71
|
+
readonly signal?: AbortSignal
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ModelGatewaySuccess {
|
|
75
|
+
readonly ok: true
|
|
76
|
+
readonly output: unknown
|
|
77
|
+
readonly usage?: ModelGatewayUsage
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ModelGatewayFailure {
|
|
81
|
+
readonly ok: false
|
|
82
|
+
readonly error: ModelGatewayError
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type ModelGatewayRawResult = ModelGatewaySuccess | ModelGatewayFailure
|
|
86
|
+
|
|
87
|
+
/** A leaf adapter may be backed by any provider, but it must return this parsed envelope. */
|
|
88
|
+
export interface ModelGateway {
|
|
89
|
+
complete(
|
|
90
|
+
request: ModelGatewayAttemptRequest,
|
|
91
|
+
): ModelGatewayRawResult | PromiseLike<ModelGatewayRawResult> | unknown
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ModelGatewayError {
|
|
95
|
+
readonly code: ModelGatewayErrorCode
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class ModelGatewayFailureError extends Error {
|
|
99
|
+
readonly code: ModelGatewayErrorCode
|
|
100
|
+
|
|
101
|
+
constructor(code: ModelGatewayErrorCode) {
|
|
102
|
+
super(`model gateway ${code}`)
|
|
103
|
+
this.name = "ModelGatewayFailureError"
|
|
104
|
+
this.code = code
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function isModelGatewayErrorCode(value: unknown): value is ModelGatewayErrorCode {
|
|
109
|
+
return typeof value === "string" && ERROR_CODES.has(value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Normalize arbitrary adapter failures without retaining their message, stack, or cause. */
|
|
113
|
+
export function parseModelGatewayError(value: unknown): ModelGatewayError {
|
|
114
|
+
if (value instanceof ModelGatewayFailureError) return Object.freeze({ code: value.code })
|
|
115
|
+
if (isRecord(value) && isModelGatewayErrorCode(value.code)) {
|
|
116
|
+
if (Object.keys(value).some((key) => key !== "code")) return Object.freeze({ code: "internal" })
|
|
117
|
+
return Object.freeze({ code: value.code })
|
|
118
|
+
}
|
|
119
|
+
return Object.freeze({ code: "internal" })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Parse the leaf result before policy or structured-output code sees it. */
|
|
123
|
+
export function parseModelGatewayResult(value: unknown): ModelGatewayRawResult {
|
|
124
|
+
if (!isRecord(value) || typeof value.ok !== "boolean")
|
|
125
|
+
return { ok: false, error: { code: "internal" } }
|
|
126
|
+
if (!value.ok) {
|
|
127
|
+
if (Object.keys(value).some((key) => key !== "ok" && key !== "error"))
|
|
128
|
+
return { ok: false, error: { code: "malformed_output" } }
|
|
129
|
+
return { ok: false, error: parseModelGatewayError(value.error) }
|
|
130
|
+
}
|
|
131
|
+
if (Object.keys(value).some((key) => key !== "ok" && key !== "output" && key !== "usage"))
|
|
132
|
+
return { ok: false, error: { code: "malformed_output" } }
|
|
133
|
+
if (!Object.hasOwn(value, "output")) return { ok: false, error: { code: "malformed_output" } }
|
|
134
|
+
let usage: ModelGatewayUsage | undefined
|
|
135
|
+
if (value.usage !== undefined) {
|
|
136
|
+
const parsed = parseUsage(value.usage)
|
|
137
|
+
if (parsed === undefined) return { ok: false, error: { code: "malformed_output" } }
|
|
138
|
+
usage = parsed
|
|
139
|
+
}
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
ok: true,
|
|
142
|
+
output: value.output,
|
|
143
|
+
...(usage === undefined ? {} : { usage }),
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface StructuredOutputParser<Output> {
|
|
148
|
+
readonly parse: (value: unknown) => Output | PromiseLike<Output>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Adapt any Standard Schema validator into a strict structured-output parser. */
|
|
152
|
+
export function createStructuredOutputParser<Schema extends StandardSchemaV1>(
|
|
153
|
+
schema: Schema,
|
|
154
|
+
): StructuredOutputParser<NonNullable<Schema["~standard"]["types"]>["output"]> {
|
|
155
|
+
return {
|
|
156
|
+
async parse(value) {
|
|
157
|
+
const result = await validateStandard(schema, value)
|
|
158
|
+
if (!result.ok) throw new ModelGatewayFailureError("malformed_output")
|
|
159
|
+
return result.value
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function structuredOutputParser<Output>(
|
|
165
|
+
parse: (value: unknown) => Output | PromiseLike<Output>,
|
|
166
|
+
): StructuredOutputParser<Output> {
|
|
167
|
+
if (typeof parse !== "function") throw new TypeError("structured output parser must be callable")
|
|
168
|
+
return Object.freeze({ parse })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface ModelGatewayEvidenceBase {
|
|
172
|
+
readonly kind: "attempt" | "fallback" | "parse" | "terminal"
|
|
173
|
+
readonly routeId: string
|
|
174
|
+
readonly attempt: number
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type ModelGatewayEvidence =
|
|
178
|
+
| (ModelGatewayEvidenceBase & {
|
|
179
|
+
readonly kind: "attempt"
|
|
180
|
+
readonly outcome: "started" | "passed" | "failed"
|
|
181
|
+
readonly code?: ModelGatewayErrorCode
|
|
182
|
+
})
|
|
183
|
+
| (ModelGatewayEvidenceBase & {
|
|
184
|
+
readonly kind: "fallback"
|
|
185
|
+
readonly fromRouteId: string
|
|
186
|
+
readonly toRouteId: string
|
|
187
|
+
})
|
|
188
|
+
| (ModelGatewayEvidenceBase & {
|
|
189
|
+
readonly kind: "parse"
|
|
190
|
+
readonly outcome: "passed" | "failed"
|
|
191
|
+
readonly code?: "malformed_output"
|
|
192
|
+
})
|
|
193
|
+
| (ModelGatewayEvidenceBase & {
|
|
194
|
+
readonly kind: "terminal"
|
|
195
|
+
readonly outcome: "passed" | "failed"
|
|
196
|
+
readonly code?: ModelGatewayErrorCode
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
export interface ModelGatewayResult<Output> {
|
|
200
|
+
readonly ok: true
|
|
201
|
+
readonly output: Output
|
|
202
|
+
readonly routeId: string
|
|
203
|
+
readonly attempts: number
|
|
204
|
+
readonly usage: ModelGatewayUsage
|
|
205
|
+
readonly evidence: readonly ModelGatewayEvidence[]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface ModelGatewayTerminalFailure {
|
|
209
|
+
readonly ok: false
|
|
210
|
+
readonly error: ModelGatewayError
|
|
211
|
+
readonly routeId: string
|
|
212
|
+
readonly attempts: number
|
|
213
|
+
readonly usage: ModelGatewayUsage
|
|
214
|
+
readonly evidence: readonly ModelGatewayEvidence[]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type ModelGatewayExecutionResult<Output> =
|
|
218
|
+
| ModelGatewayResult<Output>
|
|
219
|
+
| ModelGatewayTerminalFailure
|
|
220
|
+
|
|
221
|
+
export interface ModelRoutePolicy {
|
|
222
|
+
readonly routes: readonly ModelRouteRef[]
|
|
223
|
+
readonly retryableCodes: readonly ModelGatewayErrorCode[]
|
|
224
|
+
readonly maxAttempts?: number
|
|
225
|
+
readonly allowFallback?: boolean
|
|
226
|
+
readonly budget?: ModelGatewayBudget
|
|
227
|
+
readonly deadlineAt?: number
|
|
228
|
+
readonly now?: () => number
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
232
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function routeId(value: ModelRouteRef): string {
|
|
236
|
+
const id = typeof value === "string" ? value : value.id
|
|
237
|
+
if (typeof id !== "string" || !TOKEN.test(id)) throw new TypeError("model route id is invalid")
|
|
238
|
+
return id
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function integer(value: unknown, max: number): value is number {
|
|
242
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= max
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parseUsage(value: unknown): ModelGatewayUsage | undefined {
|
|
246
|
+
if (!isRecord(value)) return undefined
|
|
247
|
+
const inputTokens = value.inputTokens
|
|
248
|
+
const outputTokens = value.outputTokens
|
|
249
|
+
if (inputTokens !== undefined && !integer(inputTokens, MAX_TOKENS)) return undefined
|
|
250
|
+
if (outputTokens !== undefined && !integer(outputTokens, MAX_TOKENS)) return undefined
|
|
251
|
+
return Object.freeze({
|
|
252
|
+
...(inputTokens === undefined ? {} : { inputTokens }),
|
|
253
|
+
...(outputTokens === undefined ? {} : { outputTokens }),
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function validateBudget(budget: ModelGatewayBudget | undefined): ModelGatewayBudget {
|
|
258
|
+
const value = budget ?? { maxAttempts: 1 }
|
|
259
|
+
if (!integer(value.maxAttempts, MAX_ATTEMPTS) || value.maxAttempts < 1)
|
|
260
|
+
throw new TypeError("model gateway maxAttempts is invalid")
|
|
261
|
+
if (value.maxInputTokens !== undefined && !integer(value.maxInputTokens, MAX_TOKENS))
|
|
262
|
+
throw new TypeError("model gateway maxInputTokens is invalid")
|
|
263
|
+
if (value.maxOutputTokens !== undefined && !integer(value.maxOutputTokens, MAX_TOKENS))
|
|
264
|
+
throw new TypeError("model gateway maxOutputTokens is invalid")
|
|
265
|
+
return Object.freeze({ ...value })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function validatePolicy(policy: ModelRoutePolicy): {
|
|
269
|
+
routes: readonly string[]
|
|
270
|
+
retryableCodes: ReadonlySet<ModelGatewayErrorCode>
|
|
271
|
+
maxAttempts: number
|
|
272
|
+
budget: ModelGatewayBudget
|
|
273
|
+
now: () => number
|
|
274
|
+
} {
|
|
275
|
+
if (
|
|
276
|
+
!Array.isArray(policy.routes) ||
|
|
277
|
+
policy.routes.length === 0 ||
|
|
278
|
+
policy.routes.length > MAX_ATTEMPTS
|
|
279
|
+
)
|
|
280
|
+
throw new TypeError("model gateway policy requires bounded routes")
|
|
281
|
+
const routes = policy.routes.map(routeId)
|
|
282
|
+
if (new Set(routes).size !== routes.length)
|
|
283
|
+
throw new TypeError("model gateway routes must be unique")
|
|
284
|
+
const retryableCodes = new Set<ModelGatewayErrorCode>()
|
|
285
|
+
for (const code of policy.retryableCodes) {
|
|
286
|
+
if (!isModelGatewayErrorCode(code)) throw new TypeError("model gateway retry code is invalid")
|
|
287
|
+
retryableCodes.add(code)
|
|
288
|
+
}
|
|
289
|
+
const budget = validateBudget(policy.budget)
|
|
290
|
+
const maxAttempts = policy.maxAttempts ?? budget.maxAttempts
|
|
291
|
+
if (!integer(maxAttempts, MAX_ATTEMPTS) || maxAttempts < 1)
|
|
292
|
+
throw new TypeError("model gateway policy maxAttempts is invalid")
|
|
293
|
+
if (
|
|
294
|
+
policy.deadlineAt !== undefined &&
|
|
295
|
+
(!Number.isSafeInteger(policy.deadlineAt) || policy.deadlineAt <= 0)
|
|
296
|
+
)
|
|
297
|
+
throw new TypeError("model gateway deadlineAt is invalid")
|
|
298
|
+
return { routes, retryableCodes, maxAttempts, budget, now: policy.now ?? Date.now }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function evidenceLimit(evidence: ModelGatewayEvidence[]): void {
|
|
302
|
+
if (evidence.length >= MAX_EVIDENCE) throw new ModelGatewayFailureError("policy_denied")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function addEvidence(evidence: ModelGatewayEvidence[], value: ModelGatewayEvidence): void {
|
|
306
|
+
evidenceLimit(evidence)
|
|
307
|
+
evidence.push(Object.freeze(value))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function terminal(
|
|
311
|
+
error: ModelGatewayError,
|
|
312
|
+
route: string,
|
|
313
|
+
attempts: number,
|
|
314
|
+
usage: ModelGatewayUsage,
|
|
315
|
+
evidence: ModelGatewayEvidence[],
|
|
316
|
+
): ModelGatewayTerminalFailure {
|
|
317
|
+
addEvidence(evidence, {
|
|
318
|
+
kind: "terminal",
|
|
319
|
+
routeId: route,
|
|
320
|
+
attempt: attempts,
|
|
321
|
+
outcome: "failed",
|
|
322
|
+
code: error.code,
|
|
323
|
+
})
|
|
324
|
+
return Object.freeze({
|
|
325
|
+
ok: false,
|
|
326
|
+
error: Object.freeze({ code: error.code }),
|
|
327
|
+
routeId: route,
|
|
328
|
+
attempts,
|
|
329
|
+
usage: Object.freeze({ ...usage }),
|
|
330
|
+
evidence: Object.freeze([...evidence]),
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function timedFailure(
|
|
335
|
+
signal: AbortSignal,
|
|
336
|
+
deadlineAt: number | undefined,
|
|
337
|
+
now: () => number,
|
|
338
|
+
): ModelGatewayError | undefined {
|
|
339
|
+
if (signal.aborted) return { code: "cancelled" }
|
|
340
|
+
if (deadlineAt !== undefined && now() >= deadlineAt) return { code: "timeout" }
|
|
341
|
+
return undefined
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function settleAttempt(
|
|
345
|
+
gateway: ModelGateway,
|
|
346
|
+
request: ModelGatewayAttemptRequest,
|
|
347
|
+
deadlineAt: number | undefined,
|
|
348
|
+
now: () => number,
|
|
349
|
+
): Promise<ModelGatewayRawResult> {
|
|
350
|
+
const before = timedFailure(request.signal, deadlineAt, now)
|
|
351
|
+
if (before !== undefined) return { ok: false, error: before }
|
|
352
|
+
const remaining = deadlineAt === undefined ? undefined : Math.max(0, deadlineAt - now())
|
|
353
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
354
|
+
let abort: (() => void) | undefined
|
|
355
|
+
try {
|
|
356
|
+
const operation = Promise.resolve()
|
|
357
|
+
.then(() => gateway.complete(request))
|
|
358
|
+
.then(parseModelGatewayResult, () => ({
|
|
359
|
+
ok: false as const,
|
|
360
|
+
error: { code: "internal" as const },
|
|
361
|
+
}))
|
|
362
|
+
const races: Promise<ModelGatewayRawResult>[] = [operation]
|
|
363
|
+
if (remaining !== undefined) {
|
|
364
|
+
races.push(
|
|
365
|
+
new Promise((resolve) => {
|
|
366
|
+
timer = setTimeout(() => resolve({ ok: false, error: { code: "timeout" } }), remaining)
|
|
367
|
+
}),
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
if (!request.signal.aborted) {
|
|
371
|
+
races.push(
|
|
372
|
+
new Promise((resolve) => {
|
|
373
|
+
abort = () => resolve({ ok: false, error: { code: "cancelled" } })
|
|
374
|
+
request.signal.addEventListener("abort", abort!, { once: true })
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
return await Promise.race(races)
|
|
379
|
+
} finally {
|
|
380
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
381
|
+
if (abort !== undefined) request.signal.removeEventListener("abort", abort)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Execute a gateway under an explicit route, retry, fallback, budget, and deadline policy. */
|
|
386
|
+
export async function runModelGateway<Input, Output>(
|
|
387
|
+
gateway: ModelGateway,
|
|
388
|
+
request: ModelGatewayRequest<Input, Output>,
|
|
389
|
+
policy: ModelRoutePolicy,
|
|
390
|
+
): Promise<ModelGatewayExecutionResult<Output>> {
|
|
391
|
+
if (gateway === null || typeof gateway !== "object" || typeof gateway.complete !== "function")
|
|
392
|
+
throw new TypeError("model gateway must implement complete")
|
|
393
|
+
const checked = validatePolicy(policy)
|
|
394
|
+
if (request.routeId !== undefined && !checked.routes.includes(request.routeId))
|
|
395
|
+
return terminal({ code: "policy_denied" }, checked.routes[0]!, 0, {}, [])
|
|
396
|
+
const start = request.routeId === undefined ? 0 : checked.routes.indexOf(request.routeId)
|
|
397
|
+
const routes = checked.routes.slice(start)
|
|
398
|
+
const maxAttempts = Math.min(checked.maxAttempts, checked.budget.maxAttempts)
|
|
399
|
+
const signal = request.signal ?? new AbortController().signal
|
|
400
|
+
const evidence: ModelGatewayEvidence[] = []
|
|
401
|
+
const usage = { inputTokens: 0, outputTokens: 0 }
|
|
402
|
+
let attempts = 0
|
|
403
|
+
let routeIndex = 0
|
|
404
|
+
let lastRoute = routes[0]!
|
|
405
|
+
|
|
406
|
+
while (attempts < maxAttempts) {
|
|
407
|
+
const route = routes[routeIndex]!
|
|
408
|
+
lastRoute = route
|
|
409
|
+
attempts += 1
|
|
410
|
+
const attemptsRemaining = maxAttempts - attempts
|
|
411
|
+
const inputTokensRemaining =
|
|
412
|
+
checked.budget.maxInputTokens === undefined
|
|
413
|
+
? undefined
|
|
414
|
+
: Math.max(0, checked.budget.maxInputTokens - usage.inputTokens)
|
|
415
|
+
const outputTokensRemaining =
|
|
416
|
+
checked.budget.maxOutputTokens === undefined
|
|
417
|
+
? undefined
|
|
418
|
+
: Math.max(0, checked.budget.maxOutputTokens - usage.outputTokens)
|
|
419
|
+
const envelope = Object.freeze({
|
|
420
|
+
attempt: attempts,
|
|
421
|
+
attemptsRemaining,
|
|
422
|
+
...(inputTokensRemaining === undefined ? {} : { inputTokensRemaining }),
|
|
423
|
+
...(outputTokensRemaining === undefined ? {} : { outputTokensRemaining }),
|
|
424
|
+
...(policy.deadlineAt === undefined ? {} : { deadlineAt: policy.deadlineAt }),
|
|
425
|
+
})
|
|
426
|
+
addEvidence(evidence, {
|
|
427
|
+
kind: "attempt",
|
|
428
|
+
routeId: route,
|
|
429
|
+
attempt: attempts,
|
|
430
|
+
outcome: "started",
|
|
431
|
+
})
|
|
432
|
+
const raw = await settleAttempt(
|
|
433
|
+
gateway,
|
|
434
|
+
{ routeId: route, input: request.input, signal, envelope },
|
|
435
|
+
policy.deadlineAt,
|
|
436
|
+
checked.now,
|
|
437
|
+
)
|
|
438
|
+
const parsed = parseModelGatewayResult(raw)
|
|
439
|
+
if (parsed.ok) {
|
|
440
|
+
const inputTokens = parsed.usage?.inputTokens ?? 0
|
|
441
|
+
const outputTokens = parsed.usage?.outputTokens ?? 0
|
|
442
|
+
if (
|
|
443
|
+
usage.inputTokens + inputTokens > (checked.budget.maxInputTokens ?? MAX_TOKENS) ||
|
|
444
|
+
usage.outputTokens + outputTokens > (checked.budget.maxOutputTokens ?? MAX_TOKENS)
|
|
445
|
+
) {
|
|
446
|
+
addEvidence(evidence, {
|
|
447
|
+
kind: "attempt",
|
|
448
|
+
routeId: route,
|
|
449
|
+
attempt: attempts,
|
|
450
|
+
outcome: "failed",
|
|
451
|
+
code: "policy_denied",
|
|
452
|
+
})
|
|
453
|
+
return terminal({ code: "policy_denied" }, route, attempts, usage, evidence)
|
|
454
|
+
}
|
|
455
|
+
usage.inputTokens += inputTokens
|
|
456
|
+
usage.outputTokens += outputTokens
|
|
457
|
+
addEvidence(evidence, {
|
|
458
|
+
kind: "attempt",
|
|
459
|
+
routeId: route,
|
|
460
|
+
attempt: attempts,
|
|
461
|
+
outcome: "passed",
|
|
462
|
+
})
|
|
463
|
+
if (request.parser !== undefined) {
|
|
464
|
+
try {
|
|
465
|
+
const output = await request.parser.parse(parsed.output)
|
|
466
|
+
addEvidence(evidence, {
|
|
467
|
+
kind: "parse",
|
|
468
|
+
routeId: route,
|
|
469
|
+
attempt: attempts,
|
|
470
|
+
outcome: "passed",
|
|
471
|
+
})
|
|
472
|
+
addEvidence(evidence, {
|
|
473
|
+
kind: "terminal",
|
|
474
|
+
routeId: route,
|
|
475
|
+
attempt: attempts,
|
|
476
|
+
outcome: "passed",
|
|
477
|
+
})
|
|
478
|
+
return Object.freeze({
|
|
479
|
+
ok: true,
|
|
480
|
+
output,
|
|
481
|
+
routeId: route,
|
|
482
|
+
attempts,
|
|
483
|
+
usage: Object.freeze({ ...usage }),
|
|
484
|
+
evidence: Object.freeze([...evidence]),
|
|
485
|
+
})
|
|
486
|
+
} catch {
|
|
487
|
+
addEvidence(evidence, {
|
|
488
|
+
kind: "parse",
|
|
489
|
+
routeId: route,
|
|
490
|
+
attempt: attempts,
|
|
491
|
+
outcome: "failed",
|
|
492
|
+
code: "malformed_output",
|
|
493
|
+
})
|
|
494
|
+
const parseError: ModelGatewayError = { code: "malformed_output" }
|
|
495
|
+
addEvidence(evidence, {
|
|
496
|
+
kind: "attempt",
|
|
497
|
+
routeId: route,
|
|
498
|
+
attempt: attempts,
|
|
499
|
+
outcome: "failed",
|
|
500
|
+
code: parseError.code,
|
|
501
|
+
})
|
|
502
|
+
if (!checked.retryableCodes.has(parseError.code))
|
|
503
|
+
return terminal(parseError, route, attempts, usage, evidence)
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
506
|
+
addEvidence(evidence, {
|
|
507
|
+
kind: "terminal",
|
|
508
|
+
routeId: route,
|
|
509
|
+
attempt: attempts,
|
|
510
|
+
outcome: "passed",
|
|
511
|
+
})
|
|
512
|
+
return Object.freeze({
|
|
513
|
+
ok: true,
|
|
514
|
+
output: parsed.output as Output,
|
|
515
|
+
routeId: route,
|
|
516
|
+
attempts,
|
|
517
|
+
usage: Object.freeze({ ...usage }),
|
|
518
|
+
evidence: Object.freeze([...evidence]),
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
addEvidence(evidence, {
|
|
523
|
+
kind: "attempt",
|
|
524
|
+
routeId: route,
|
|
525
|
+
attempt: attempts,
|
|
526
|
+
outcome: "failed",
|
|
527
|
+
code: parsed.error.code,
|
|
528
|
+
})
|
|
529
|
+
const retryable = checked.retryableCodes.has(parsed.error.code)
|
|
530
|
+
if (!retryable) return terminal(parsed.error, route, attempts, usage, evidence)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (attempts >= maxAttempts) break
|
|
534
|
+
if (routeIndex + 1 < routes.length && policy.allowFallback === true) {
|
|
535
|
+
const nextRoute = routes[routeIndex + 1]!
|
|
536
|
+
addEvidence(evidence, {
|
|
537
|
+
kind: "fallback",
|
|
538
|
+
routeId: nextRoute,
|
|
539
|
+
fromRouteId: route,
|
|
540
|
+
toRouteId: nextRoute,
|
|
541
|
+
attempt: attempts,
|
|
542
|
+
})
|
|
543
|
+
routeIndex += 1
|
|
544
|
+
}
|
|
545
|
+
const stopped = timedFailure(signal, policy.deadlineAt, checked.now)
|
|
546
|
+
if (stopped !== undefined) return terminal(stopped, lastRoute, attempts, usage, evidence)
|
|
547
|
+
}
|
|
548
|
+
return terminal({ code: "unavailable" }, lastRoute, attempts, usage, evidence)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export interface FakeModelGatewayOptions {
|
|
552
|
+
readonly responses: readonly ModelGatewayRawResult[]
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Deterministic, network-free gateway for tests and local examples. It records no request input. */
|
|
556
|
+
export class FakeModelGateway implements ModelGateway {
|
|
557
|
+
private readonly responses: readonly ModelGatewayRawResult[]
|
|
558
|
+
private cursor = 0
|
|
559
|
+
|
|
560
|
+
constructor(options: FakeModelGatewayOptions) {
|
|
561
|
+
if (!Array.isArray(options.responses) || options.responses.length > MAX_ATTEMPTS)
|
|
562
|
+
throw new RangeError("fake gateway responses are unbounded")
|
|
563
|
+
this.responses = Object.freeze(
|
|
564
|
+
options.responses.map((response) => parseModelGatewayResult(response)),
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
get calls(): number {
|
|
569
|
+
return this.cursor
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
complete(_request: ModelGatewayAttemptRequest): ModelGatewayRawResult {
|
|
573
|
+
const response = this.responses[this.cursor++]
|
|
574
|
+
return response ?? { ok: false, error: { code: "unavailable" } }
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export interface ReplayModelGatewayOptions {
|
|
579
|
+
readonly replayId: string
|
|
580
|
+
readonly responses: readonly ModelGatewayRawResult[]
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Network-free deterministic gateway for a caller-owned, already prepared response sequence. */
|
|
584
|
+
export class ReplayModelGateway implements ModelGateway {
|
|
585
|
+
readonly replayId: string
|
|
586
|
+
private readonly responses: readonly ModelGatewayRawResult[]
|
|
587
|
+
private cursor = 0
|
|
588
|
+
|
|
589
|
+
constructor(options: ReplayModelGatewayOptions) {
|
|
590
|
+
if (!TOKEN.test(options.replayId)) throw new TypeError("replay gateway id is invalid")
|
|
591
|
+
if (!Array.isArray(options.responses) || options.responses.length > MAX_ATTEMPTS)
|
|
592
|
+
throw new RangeError("replay gateway responses are unbounded")
|
|
593
|
+
this.replayId = options.replayId
|
|
594
|
+
this.responses = Object.freeze(
|
|
595
|
+
options.responses.map((response) => parseModelGatewayResult(response)),
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
get calls(): number {
|
|
600
|
+
return this.cursor
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
complete(_request: ModelGatewayAttemptRequest): ModelGatewayRawResult {
|
|
604
|
+
const response = this.responses[this.cursor++]
|
|
605
|
+
return response ?? { ok: false, error: { code: "unavailable" } }
|
|
606
|
+
}
|
|
607
|
+
}
|