@mupt-ai/dari-router 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +89 -60
  2. package/dist/anonymous_actions.js +32 -10
  3. package/dist/anonymous_actions.js.map +1 -1
  4. package/dist/auto_router.d.ts +4 -1
  5. package/dist/auto_router.js +3 -3
  6. package/dist/auto_router.js.map +1 -1
  7. package/dist/cache_behavior.d.ts +8 -6
  8. package/dist/cache_behavior.js +60 -30
  9. package/dist/cache_behavior.js.map +1 -1
  10. package/dist/cache_scope.d.ts +2 -2
  11. package/dist/cache_scope.js +23 -18
  12. package/dist/cache_scope.js.map +1 -1
  13. package/dist/cost.d.ts +3 -2
  14. package/dist/cost.js +32 -23
  15. package/dist/cost.js.map +1 -1
  16. package/dist/dari_policy.d.ts +8 -2
  17. package/dist/dari_policy.js +39 -1
  18. package/dist/dari_policy.js.map +1 -1
  19. package/dist/eval_score_imputation.d.ts +26 -0
  20. package/dist/eval_score_imputation.js +121 -0
  21. package/dist/eval_score_imputation.js.map +1 -0
  22. package/dist/framework_lifecycle.d.ts +1 -1
  23. package/dist/framework_lifecycle.js +4 -1
  24. package/dist/framework_lifecycle.js.map +1 -1
  25. package/dist/framework_models.js +6 -0
  26. package/dist/framework_models.js.map +1 -1
  27. package/dist/framework_router.js +22 -11
  28. package/dist/framework_router.js.map +1 -1
  29. package/dist/framework_types.d.ts +9 -5
  30. package/dist/index.d.ts +9 -35
  31. package/dist/index.js +6 -37
  32. package/dist/index.js.map +1 -1
  33. package/dist/model_ids.js +4 -0
  34. package/dist/model_ids.js.map +1 -1
  35. package/dist/pi_runtime.js +19 -11
  36. package/dist/pi_runtime.js.map +1 -1
  37. package/dist/pi_types.d.ts +2 -2
  38. package/{src/index.ts → dist/policy-engine.d.ts} +2 -14
  39. package/dist/policy-engine.js +25 -0
  40. package/dist/policy-engine.js.map +1 -0
  41. package/dist/prompts.d.ts +1 -1
  42. package/dist/prompts.js +3 -3
  43. package/dist/prompts.js.map +1 -1
  44. package/dist/protocols.d.ts +5 -0
  45. package/dist/protocols.js +6 -0
  46. package/dist/protocols.js.map +1 -0
  47. package/dist/route.d.ts +3 -4
  48. package/dist/route.js +17 -10
  49. package/dist/route.js.map +1 -1
  50. package/dist/selector_input.d.ts +1 -0
  51. package/dist/selector_input.js +66 -78
  52. package/dist/selector_input.js.map +1 -1
  53. package/dist/selector_parse.d.ts +1 -0
  54. package/dist/selector_parse.js +2 -1
  55. package/dist/selector_parse.js.map +1 -1
  56. package/dist/selector_request.d.ts +1 -0
  57. package/dist/selector_request.js +1 -0
  58. package/dist/selector_request.js.map +1 -1
  59. package/dist/types.d.ts +1 -1
  60. package/examples/auto_router.ts +40 -0
  61. package/examples/basic_route.ts +130 -0
  62. package/examples/create_router.ts +114 -0
  63. package/examples/dari_policy.ts +86 -0
  64. package/examples/lease_stores.ts +159 -0
  65. package/examples/managed/README.md +50 -0
  66. package/examples/managed/byok-router/README.md +13 -0
  67. package/examples/managed/byok-router/router.yml +13 -0
  68. package/examples/managed/custom-rules-router/README.md +12 -0
  69. package/examples/managed/custom-rules-router/router.yml +24 -0
  70. package/examples/managed/eval-router/README.md +18 -0
  71. package/examples/managed/eval-router/router.yml +11 -0
  72. package/examples/managed/slm-router/README.md +12 -0
  73. package/examples/managed/slm-router/router.yml +8 -0
  74. package/examples/pi_runtime.ts +55 -0
  75. package/package.json +26 -7
  76. package/src/anonymous_actions.ts +0 -434
  77. package/src/auto_router.ts +0 -95
  78. package/src/cache_behavior.ts +0 -130
  79. package/src/cache_scope.ts +0 -58
  80. package/src/cache_switch_decision.ts +0 -53
  81. package/src/candidate_pruning.ts +0 -257
  82. package/src/candidate_resolution.ts +0 -208
  83. package/src/compatibility.ts +0 -162
  84. package/src/continuation_state.ts +0 -368
  85. package/src/cost.ts +0 -455
  86. package/src/dari_policy.ts +0 -360
  87. package/src/errors.ts +0 -27
  88. package/src/fingerprint.ts +0 -148
  89. package/src/fixed_turn_cost_config.ts +0 -24
  90. package/src/framework_error.ts +0 -38
  91. package/src/framework_lifecycle.ts +0 -74
  92. package/src/framework_models.ts +0 -186
  93. package/src/framework_router.ts +0 -490
  94. package/src/framework_streaming.ts +0 -613
  95. package/src/framework_types.ts +0 -372
  96. package/src/framework_validation.ts +0 -235
  97. package/src/json.ts +0 -43
  98. package/src/model_ids.ts +0 -29
  99. package/src/pi_context.ts +0 -251
  100. package/src/pi_options.ts +0 -295
  101. package/src/pi_output.ts +0 -387
  102. package/src/pi_reasoning_signature.ts +0 -41
  103. package/src/pi_runtime.ts +0 -238
  104. package/src/pi_types.ts +0 -71
  105. package/src/portable_reasoning_state.ts +0 -129
  106. package/src/previous_decision.ts +0 -109
  107. package/src/prompts.ts +0 -69
  108. package/src/protocol_anthropic.ts +0 -571
  109. package/src/protocol_openai_chat.ts +0 -645
  110. package/src/protocol_validation.ts +0 -202
  111. package/src/route.ts +0 -490
  112. package/src/routing_history.ts +0 -157
  113. package/src/selector_input.ts +0 -246
  114. package/src/selector_parse.ts +0 -138
  115. package/src/selector_request.ts +0 -130
  116. package/src/selector_truncation.ts +0 -48
  117. package/src/selector_wire.ts +0 -167
  118. package/src/types.ts +0 -231
@@ -1,186 +0,0 @@
1
- import { providerForModel } from "./model_ids.js";
2
- import { RouterFrameworkError } from "./framework_error.js";
3
- import { isReasoningEffort } from "./types.js";
4
- import type {
5
- RouterCandidate,
6
- RouterExecutor,
7
- RouterModel,
8
- RouterRequest,
9
- } from "./framework_types.js";
10
-
11
- const DEFAULT_CAPABILITIES = {
12
- imageInput: false,
13
- toolUse: false,
14
- structuredOutput: false,
15
- streaming: false,
16
- } as const;
17
-
18
- export function normalizeRouterModels<Metadata>(
19
- models: readonly RouterModel<Metadata>[],
20
- executors: Readonly<Record<string, RouterExecutor<Metadata>>>,
21
- ): RouterCandidate<Metadata>[] {
22
- if (!Array.isArray(models) || models.length === 0) {
23
- throw configurationError("A router requires at least one model.", "models_empty");
24
- }
25
- if (!executors || typeof executors !== "object") {
26
- throw configurationError("executors must be an object.", "executors_invalid");
27
- }
28
-
29
- const ids = new Set<string>();
30
- return models.map((model, index) => {
31
- if (typeof model?.id !== "string" || !model.id.trim() || model.id !== model.id.trim()) {
32
- throw configurationError(`models[${index}].id must be a trimmed non-empty string.`, "model_invalid");
33
- }
34
- if (ids.has(model.id)) {
35
- throw configurationError(`Duplicate router model '${model.id}'.`, "model_duplicate");
36
- }
37
- ids.add(model.id);
38
- if (
39
- typeof model.executor !== "string" ||
40
- !model.executor.trim() ||
41
- model.executor !== model.executor.trim() ||
42
- !Object.hasOwn(executors, model.executor)
43
- ) {
44
- throw configurationError(
45
- `Model '${model.id}' references missing executor '${String(model.executor)}'.`,
46
- "executor_missing",
47
- );
48
- }
49
- const executor = executors[model.executor];
50
- if (!executor || typeof executor.execute !== "function") {
51
- throw configurationError(
52
- `Executor '${model.executor}' must define an execute function.`,
53
- "executor_invalid",
54
- );
55
- }
56
-
57
- const reasoningEfforts = model.reasoningEfforts === undefined
58
- ? ["off" as const]
59
- : [...model.reasoningEfforts];
60
- if (
61
- reasoningEfforts.length === 0 ||
62
- reasoningEfforts.some((effort) => !isReasoningEffort(effort)) ||
63
- new Set(reasoningEfforts).size !== reasoningEfforts.length
64
- ) {
65
- throw configurationError(
66
- `Model '${model.id}' must declare unique supported reasoning efforts.`,
67
- "model_reasoning_invalid",
68
- );
69
- }
70
- const defaultReasoningEffort = model.defaultReasoningEffort ?? reasoningEfforts[0]!;
71
- if (!reasoningEfforts.includes(defaultReasoningEffort)) {
72
- throw configurationError(
73
- `Model '${model.id}' default reasoning effort is not supported by that model.`,
74
- "model_reasoning_invalid",
75
- );
76
- }
77
-
78
- let provider = model.provider;
79
- if (provider !== undefined && (
80
- typeof provider !== "string" || !provider.trim() || provider !== provider.trim()
81
- )) {
82
- throw configurationError(
83
- `Model '${model.id}' provider must be a trimmed non-empty string.`,
84
- "model_provider_invalid",
85
- );
86
- }
87
- if (provider === undefined) {
88
- try {
89
- provider = providerForModel(model.id);
90
- } catch (error) {
91
- throw configurationError(
92
- `Model '${model.id}' needs an explicit provider because its id has no provider prefix.`,
93
- "model_provider_missing",
94
- error,
95
- );
96
- }
97
- }
98
-
99
- const api = model.api ?? model.executor;
100
- if (typeof api !== "string" || !api.trim() || api !== api.trim()) {
101
- throw configurationError(
102
- `Model '${model.id}' api must be a trimmed non-empty string.`,
103
- "model_api_invalid",
104
- );
105
- }
106
- const capabilities = { ...DEFAULT_CAPABILITIES, ...model.capabilities };
107
- if (Object.values(capabilities).some((value) => typeof value !== "boolean")) {
108
- throw configurationError(
109
- `Model '${model.id}' capabilities must be booleans.`,
110
- "model_capabilities_invalid",
111
- );
112
- }
113
-
114
- return {
115
- id: model.id,
116
- executor: model.executor,
117
- provider,
118
- api,
119
- reasoningEfforts,
120
- defaultReasoningEffort,
121
- capabilities,
122
- ...(model.metadata === undefined ? {} : { metadata: model.metadata }),
123
- };
124
- });
125
- }
126
-
127
- export function eligibleRouterModels<Metadata>(
128
- request: RouterRequest,
129
- models: readonly RouterCandidate<Metadata>[],
130
- ): RouterCandidate<Metadata>[] {
131
- const requiresImage = request.items.some((item) =>
132
- item.type === "message"
133
- ? item.content.some((part) => part.type === "image")
134
- : item.type === "tool_result" && item.content.some((part) => part.type === "image"),
135
- );
136
- const requiresTools = request.tools.length > 0 || request.items.some((item) =>
137
- item.type === "tool_call" || item.type === "tool_result" || item.type === "hosted_tool_call",
138
- );
139
- const requiresStructuredOutput =
140
- request.responseFormat !== undefined && request.responseFormat.type !== "text";
141
- const requestedEffort = request.reasoning?.effort;
142
-
143
- const eligible = models.flatMap((model) => {
144
- if (requiresImage && !model.capabilities.imageInput) return [];
145
- if (requiresTools && !model.capabilities.toolUse) return [];
146
- if (requiresStructuredOutput && !model.capabilities.structuredOutput) return [];
147
- if (request.stream && !model.capabilities.streaming) return [];
148
-
149
- const efforts = requestedEffort === undefined
150
- ? request.reasoning?.enabled === true
151
- ? model.reasoningEfforts.filter((effort) => effort !== "off")
152
- : [...model.reasoningEfforts]
153
- : model.reasoningEfforts.includes(requestedEffort)
154
- ? [requestedEffort]
155
- : [];
156
- if (efforts.length === 0) return [];
157
- return [{
158
- ...model,
159
- reasoningEfforts: efforts,
160
- defaultReasoningEffort: efforts.includes(model.defaultReasoningEffort)
161
- ? model.defaultReasoningEffort
162
- : efforts[0]!,
163
- }];
164
- });
165
-
166
- if (eligible.length > 0) return eligible;
167
- throw new RouterFrameworkError(
168
- "invalid_request",
169
- "No configured model supports this request's protocol features and reasoning settings.",
170
- "no_eligible_models",
171
- );
172
- }
173
-
174
- function configurationError(
175
- message: string,
176
- code: string,
177
- cause?: unknown,
178
- ): RouterFrameworkError {
179
- return new RouterFrameworkError(
180
- "configuration",
181
- message,
182
- code,
183
- undefined,
184
- cause === undefined ? undefined : { cause },
185
- );
186
- }
@@ -1,490 +0,0 @@
1
- import { RouterFrameworkError } from "./framework_error.js";
2
- import { eligibleRouterModels, normalizeRouterModels } from "./framework_models.js";
3
- import {
4
- anthropicRequest,
5
- anthropicResponse,
6
- } from "./protocol_anthropic.js";
7
- import {
8
- openAIChatRequest,
9
- openAIChatResponse,
10
- } from "./protocol_openai_chat.js";
11
- import {
12
- primeStream,
13
- streamResponse,
14
- type StartedExecution,
15
- } from "./framework_streaming.js";
16
- import {
17
- cancelled,
18
- closeAsyncIterable,
19
- closeAsyncIterator,
20
- errorMessage,
21
- executorStartError,
22
- normalizeError,
23
- safeHook,
24
- throwIfAborted,
25
- } from "./framework_lifecycle.js";
26
- import {
27
- validateCompletion,
28
- validatePolicyDecision,
29
- } from "./framework_validation.js";
30
- import type { ReasoningEffort } from "./types.js";
31
- import type {
32
- CreateRouterOptions,
33
- LeaseStore,
34
- Router,
35
- RouterCandidate,
36
- RouterExecutor,
37
- RouterExecutorResult,
38
- RouterLease,
39
- RouterProtocol,
40
- RouterRequest,
41
- RouterSelection,
42
- RoutingPolicyDecision,
43
- } from "./framework_types.js";
44
-
45
- export function createRouter<Metadata = unknown>(
46
- options: CreateRouterOptions<Metadata>,
47
- ): Router<Metadata> {
48
- if (
49
- typeof options.policy !== "function" &&
50
- (!options.policy || typeof options.policy.select !== "function")
51
- ) {
52
- throw new RouterFrameworkError(
53
- "configuration",
54
- "policy must be a function or define a select function.",
55
- "policy_invalid",
56
- );
57
- }
58
- if (options.generateId !== undefined && typeof options.generateId !== "function") {
59
- throw new RouterFrameworkError(
60
- "configuration",
61
- "generateId must be a function.",
62
- "id_generator_invalid",
63
- );
64
- }
65
- const executors: Record<string, RouterExecutor<Metadata>> = {
66
- ...(options.runtime === undefined ? {} : { pi: options.runtime }),
67
- ...(options.executors ?? {}),
68
- };
69
- const modelDeclarations = options.models.map((model) =>
70
- model.executor === undefined ? { ...model, executor: "pi" } : model
71
- );
72
- const models = Object.freeze(normalizeRouterModels(modelDeclarations, executors));
73
- // In-memory lease commitments keyed by request cacheKey. When a policy
74
- // returns leaseTurnsRemaining > 0, the router serves the same model for
75
- // subsequent requests sharing the cacheKey without calling the policy
76
- // again, decrementing the remaining turns each time. Leases dissolve when
77
- // the leased model is no longer an eligible candidate, when their turns run
78
- // out, or when they sit unused past LEASE_TTL_MS (abandoned conversations
79
- // must not accumulate).
80
- const LEASE_TTL_MS = 30 * 60_000;
81
- // Default in-memory lease store; callers can inject a persistent store.
82
- const leaseStore: LeaseStore = options.leaseStore ?? inMemoryLeaseStore();
83
- // Store calls are advisory: an injected store that throws must never fail the request.
84
- const tryLeaseStore = async <T>(op: () => T | Promise<T>): Promise<T | undefined> => {
85
- try {
86
- return await op();
87
- } catch {
88
- return undefined;
89
- }
90
- };
91
- const hooks = options.hooks ?? {};
92
- const fallbackConfig = options.fallback ?? {};
93
- const idGenerator = options.generateId ?? (() => globalThis.crypto.randomUUID());
94
- const generateId = (): string => {
95
- const id = idGenerator();
96
- if (typeof id !== "string" || !id.trim()) {
97
- throw new RouterFrameworkError(
98
- "configuration",
99
- "generateId must return a non-empty string.",
100
- "id_generator_invalid",
101
- );
102
- }
103
- return id;
104
- };
105
-
106
- const selectWithoutLeases = async (
107
- request: RouterRequest,
108
- signal: AbortSignal = new AbortController().signal,
109
- ): Promise<RouterSelection<Metadata>> => {
110
- throwIfAborted(signal);
111
- const candidates = eligibleRouterModels(request, models);
112
- let chosen: RoutingPolicyDecision;
113
- try {
114
- chosen = await (typeof options.policy === "function"
115
- ? options.policy({ request, candidates, signal })
116
- : options.policy.select({ request, candidates, signal }));
117
- } catch (error) {
118
- if (signal.aborted) throw cancelled(signal.reason);
119
- if (error instanceof RouterFrameworkError) throw error;
120
- throw new RouterFrameworkError(
121
- "policy",
122
- errorMessage(error, "Routing policy failed."),
123
- "policy_failed",
124
- undefined,
125
- { cause: error },
126
- );
127
- }
128
- throwIfAborted(signal);
129
- return validatePolicyDecision(chosen, candidates);
130
- };
131
-
132
- // Real serving path: consumes and commits leases keyed by cacheKey. The
133
- // public select() above never mutates lease state, so callers can inspect
134
- // decisions (e.g. auto-router thinking traces) without burning selection
135
- // turns or skipping committed leases.
136
- const selectWithLeases = async (
137
- request: RouterRequest,
138
- signal: AbortSignal,
139
- ): Promise<RouterSelection<Metadata>> => {
140
- throwIfAborted(signal);
141
- const candidates = eligibleRouterModels(request, models);
142
-
143
- // Lease short-circuit: if a prior decision committed turns to a model that
144
- // is still eligible for this request, serve it without calling the policy.
145
- const leaseKey = leaseKeyFor(request);
146
- if (leaseKey !== undefined) {
147
- const now = Date.now();
148
- await tryLeaseStore(() => leaseStore.pruneExpired(now));
149
- const lease = await tryLeaseStore(() => leaseStore.get(leaseKey));
150
- if (lease !== undefined) {
151
- // Defensive expiry check: pruneExpired is advisory and injected
152
- // stores may not implement it strictly.
153
- const candidate = lease.expiresAt > now
154
- ? candidates.find(
155
- (item) => item.id === lease.model && item.reasoningEfforts.includes(lease.reasoningEffort),
156
- )
157
- : undefined;
158
- if (candidate !== undefined) {
159
- const turnsRemaining = lease.turnsRemaining - 1;
160
- if (turnsRemaining <= 0) await tryLeaseStore(() => leaseStore.delete(leaseKey));
161
- else {
162
- await tryLeaseStore(() =>
163
- leaseStore.set(leaseKey, {
164
- ...lease,
165
- turnsRemaining,
166
- expiresAt: now + LEASE_TTL_MS,
167
- })
168
- );
169
- }
170
- return {
171
- decision: {
172
- selectedModel: candidate.id,
173
- reasoningEffort: lease.reasoningEffort,
174
- reason: `Serving committed lease (${turnsRemaining <= 0 ? "final" : `${turnsRemaining} left`}).`,
175
- ...(turnsRemaining <= 0 ? {} : { leaseTurnsRemaining: turnsRemaining }),
176
- },
177
- candidates,
178
- };
179
- }
180
- await tryLeaseStore(() => leaseStore.delete(leaseKey));
181
- }
182
- }
183
-
184
- const selection = await selectWithoutLeases(request, signal);
185
-
186
- // Record a new lease when the policy committed turns and the request
187
- // carries a cacheKey to correlate subsequent turns.
188
- if (
189
- leaseKey !== undefined &&
190
- selection.decision.leaseTurnsRemaining !== undefined &&
191
- selection.decision.leaseTurnsRemaining > 0
192
- ) {
193
- await tryLeaseStore(() =>
194
- leaseStore.set(leaseKey, {
195
- model: selection.decision.selectedModel,
196
- reasoningEffort: selection.decision.reasoningEffort,
197
- turnsRemaining: selection.decision.leaseTurnsRemaining!,
198
- expiresAt: Date.now() + LEASE_TTL_MS,
199
- })
200
- );
201
- }
202
-
203
- return selection;
204
- };
205
-
206
- const fetch = async (request: Request): Promise<Response> => {
207
- const protocol = protocolFor(request);
208
- if (protocol === null) {
209
- return errorResponse(
210
- "openai_chat_completions",
211
- new RouterFrameworkError("not_found", "Not found.", "not_found"),
212
- );
213
- }
214
- if (request.method !== "POST") {
215
- return Response.json(
216
- protocol === "anthropic_messages"
217
- ? {
218
- type: "error",
219
- error: { type: "invalid_request_error", message: "Method not allowed. Use POST." },
220
- }
221
- : {
222
- error: {
223
- message: "Method not allowed. Use POST.",
224
- type: "invalid_request_error",
225
- code: "method_not_allowed",
226
- },
227
- },
228
- { status: 405, headers: { Allow: "POST" } },
229
- );
230
- }
231
-
232
- const controller = new AbortController();
233
- const abort = () => controller.abort(request.signal.reason);
234
- if (request.signal.aborted) abort();
235
- else request.signal.addEventListener("abort", abort, { once: true });
236
-
237
- let streamOwnsAbortListener = false;
238
- let effectiveSelection: RouterSelection<Metadata> | undefined;
239
- try {
240
- const payload = await parseJson(request);
241
- const normalized = protocol === "openai_chat_completions"
242
- ? openAIChatRequest(payload)
243
- : anthropicRequest(payload);
244
- const selection = await selectWithLeases(normalized, controller.signal);
245
- effectiveSelection = selection;
246
- safeHook(() => hooks.onSelection?.(selection, normalized));
247
- const model = selection.candidates.find(
248
- (candidate) => candidate.id === selection.decision.selectedModel,
249
- )!;
250
- // eligibleRouterModels guarantees each candidate's default effort is in
251
- // its (filtered) reasoningEfforts.
252
- const effectiveEffort = (candidate: RouterCandidate<Metadata>): ReasoningEffort =>
253
- candidate.reasoningEfforts.includes(selection.decision.reasoningEffort)
254
- ? selection.decision.reasoningEffort
255
- : candidate.defaultReasoningEffort;
256
- // Primes streams here: first-pull provider failures must surface inside
257
- // the fallback-covered region, before anything reaches the client.
258
- const startWithModel = async (
259
- candidate: RouterCandidate<Metadata>,
260
- ): Promise<StartedExecution> => {
261
- const attempt: RouterExecutorResult = await executors[candidate.executor]!.execute({
262
- request: normalized,
263
- model: candidate,
264
- decision: {
265
- ...selection.decision,
266
- selectedModel: candidate.id,
267
- reasoningEffort: effectiveEffort(candidate),
268
- },
269
- signal: controller.signal,
270
- });
271
- if (normalized.stream) {
272
- if (attempt.type !== "stream") {
273
- throw new RouterFrameworkError(
274
- "executor",
275
- `Executor '${candidate.executor}' returned a non-streaming result for a streaming request.`,
276
- "executor_mode_mismatch",
277
- );
278
- }
279
- return { type: "stream", primed: await primeStream(attempt.events, controller) };
280
- }
281
- if (attempt.type !== "complete") {
282
- await closeAsyncIterable(attempt.events);
283
- throw new RouterFrameworkError(
284
- "executor",
285
- `Executor '${candidate.executor}' returned a stream for a non-streaming request.`,
286
- "executor_mode_mismatch",
287
- );
288
- }
289
- validateCompletion(attempt.output);
290
- return { type: "complete", output: attempt.output };
291
- };
292
- const leaseKey = leaseKeyFor(normalized);
293
- let started: StartedExecution;
294
- let executedModel = model;
295
- try {
296
- started = await startWithModel(model);
297
- } catch (error) {
298
- if (controller.signal.aborted) throw cancelled(controller.signal.reason);
299
- const startError = executorStartError(error, model.executor);
300
- // Pure cancellation keeps the lease: the user aborted, the model did
301
- // not fail.
302
- if (startError.kind === "cancelled") throw startError;
303
- // Drop before the fallback attempt: any lease pins the failing primary.
304
- if (leaseKey !== undefined) await tryLeaseStore(() => leaseStore.delete(leaseKey));
305
- // Fallback: try a different model if configured and available.
306
- const fallback = fallbackCandidate(selection, model, fallbackConfig);
307
- if (fallback === undefined) throw startError;
308
- try {
309
- started = await startWithModel(fallback);
310
- } catch (fallbackError) {
311
- if (controller.signal.aborted) throw cancelled(controller.signal.reason);
312
- throw executorStartError(fallbackError, fallback.executor);
313
- }
314
- executedModel = fallback;
315
- effectiveSelection = {
316
- ...selection,
317
- decision: {
318
- selectedModel: fallback.id,
319
- reasoningEffort: effectiveEffort(fallback),
320
- reason: `Fell back to '${fallback.id}' after executor for '${selection.decision.selectedModel}' failed. Original reason: ${selection.decision.reason}`,
321
- },
322
- };
323
- }
324
-
325
- const served = effectiveSelection;
326
-
327
- if (started.type === "stream") {
328
- const primedStream = started.primed;
329
- try {
330
- throwIfAborted(controller.signal);
331
- const response = streamResponse({
332
- protocol,
333
- id: generateId(),
334
- model: executedModel.id,
335
- requestedModel: normalized.requestedModel,
336
- selection: served,
337
- primed: primedStream,
338
- controller,
339
- headers: selectionHeaders(served),
340
- // Not awaited: stream teardown must not stall on the store; turns/TTL backstop.
341
- onStreamFailure: leaseKey === undefined ? undefined : () => {
342
- void tryLeaseStore(() => leaseStore.delete(leaseKey));
343
- },
344
- onFinalize: (completion, error) => {
345
- request.signal.removeEventListener("abort", abort);
346
- if (hooks.onStreamClose) {
347
- safeHook(() => hooks.onStreamClose!(completion, served, error));
348
- }
349
- },
350
- });
351
- streamOwnsAbortListener = true;
352
- return response;
353
- } catch (error) {
354
- await closeAsyncIterator(primedStream.iterator);
355
- throw error;
356
- }
357
- }
358
- throwIfAborted(controller.signal);
359
- const id = generateId();
360
- const output = started.output;
361
- safeHook(() => hooks.onCompletion?.(output, served));
362
- return Response.json(
363
- protocol === "openai_chat_completions"
364
- ? openAIChatResponse({
365
- id,
366
- model: executedModel.id,
367
- requestedModel: normalized.requestedModel,
368
- output,
369
- selection: served,
370
- })
371
- : anthropicResponse({
372
- id,
373
- model: executedModel.id,
374
- requestedModel: normalized.requestedModel,
375
- output,
376
- selection: served,
377
- }),
378
- { headers: selectionHeaders(served) },
379
- );
380
- } catch (error) {
381
- const normalizedError = request.signal.aborted
382
- ? cancelled(request.signal.reason)
383
- : normalizeError(error);
384
- const errorSelection = effectiveSelection;
385
- if (errorSelection !== undefined) {
386
- safeHook(() => hooks.onError?.(normalizedError, errorSelection));
387
- }
388
- return errorResponse(protocol, normalizedError);
389
- } finally {
390
- if (!streamOwnsAbortListener) request.signal.removeEventListener("abort", abort);
391
- }
392
- };
393
-
394
- return { models, select: selectWithoutLeases, fetch };
395
- }
396
- function protocolFor(request: Request): RouterProtocol | null {
397
- const path = new URL(request.url).pathname.replace(/\/+$/, "");
398
- if (path === "/v1/chat/completions" || path === "/chat/completions") {
399
- return "openai_chat_completions";
400
- }
401
- if (path === "/v1/messages" || path === "/messages") return "anthropic_messages";
402
- return null;
403
- }
404
-
405
- async function parseJson(request: Request): Promise<unknown> {
406
- try {
407
- return await request.json();
408
- } catch (error) {
409
- throw new RouterFrameworkError(
410
- "invalid_request",
411
- "Request body is not valid JSON.",
412
- "invalid_json",
413
- undefined,
414
- { cause: error },
415
- );
416
- }
417
- }
418
-
419
- function selectionHeaders<Metadata>(selection: RouterSelection<Metadata>): Headers {
420
- return new Headers({
421
- "X-Router-Selected-Model": selection.decision.selectedModel,
422
- "X-Router-Reasoning-Effort": selection.decision.reasoningEffort,
423
- });
424
- }
425
-
426
- function errorResponse(protocol: RouterProtocol, error: RouterFrameworkError): Response {
427
- if (protocol === "anthropic_messages") {
428
- return Response.json(
429
- {
430
- type: "error",
431
- error: {
432
- type: error.status >= 500 ? "api_error" : "invalid_request_error",
433
- message: error.message,
434
- },
435
- },
436
- { status: error.status },
437
- );
438
- }
439
- return Response.json(
440
- {
441
- error: {
442
- message: error.message,
443
- type: error.status >= 500 ? "server_error" : "invalid_request_error",
444
- code: error.code,
445
- ...(error.param === undefined ? {} : { param: error.param }),
446
- },
447
- },
448
- { status: error.status },
449
- );
450
- }
451
-
452
- function fallbackCandidate<Metadata>(
453
- selection: RouterSelection<Metadata>,
454
- primary: RouterCandidate<Metadata>,
455
- config: { enabled?: boolean; requiresDifferentProvider?: boolean },
456
- ): RouterCandidate<Metadata> | undefined {
457
- if (!config.enabled) return undefined;
458
- return selection.candidates.find((candidate) => {
459
- if (candidate.id === primary.id) return false;
460
- if (config.requiresDifferentProvider && candidate.provider === primary.provider) return false;
461
- return true;
462
- });
463
- }
464
-
465
- // Protocol adapters normalize empty cache keys away, but programmatic
466
- // callers can still pass "": treat it as no cache key so unrelated requests
467
- // never share a lease bucket.
468
- function leaseKeyFor(request: RouterRequest): string | undefined {
469
- return request.cacheKey !== undefined && request.cacheKey.trim()
470
- ? request.cacheKey
471
- : undefined;
472
- }
473
-
474
- function inMemoryLeaseStore(): LeaseStore {
475
- const leases = new Map<string, RouterLease>();
476
- return {
477
- get: (cacheKey) => leases.get(cacheKey),
478
- set: (cacheKey, lease) => {
479
- leases.set(cacheKey, lease);
480
- },
481
- delete: (cacheKey) => {
482
- leases.delete(cacheKey);
483
- },
484
- pruneExpired: (nowMs) => {
485
- for (const [cacheKey, lease] of leases) {
486
- if (lease.expiresAt <= nowMs) leases.delete(cacheKey);
487
- }
488
- },
489
- };
490
- }