@nifrajs/agent 3.1.0 → 3.2.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/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
+ }