@mupt-ai/dari-router 0.1.0 → 0.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/README.md +89 -60
- package/dist/anonymous_actions.js +32 -10
- package/dist/anonymous_actions.js.map +1 -1
- package/dist/auto_router.d.ts +4 -1
- package/dist/auto_router.js +3 -3
- package/dist/auto_router.js.map +1 -1
- package/dist/cache_behavior.d.ts +8 -6
- package/dist/cache_behavior.js +56 -30
- package/dist/cache_behavior.js.map +1 -1
- package/dist/cache_scope.d.ts +2 -2
- package/dist/cache_scope.js +23 -18
- package/dist/cache_scope.js.map +1 -1
- package/dist/cost.d.ts +3 -2
- package/dist/cost.js +32 -23
- package/dist/cost.js.map +1 -1
- package/dist/dari_policy.d.ts +8 -2
- package/dist/dari_policy.js +39 -1
- package/dist/dari_policy.js.map +1 -1
- package/dist/eval_score_imputation.d.ts +26 -0
- package/dist/eval_score_imputation.js +83 -0
- package/dist/eval_score_imputation.js.map +1 -0
- package/dist/framework_lifecycle.d.ts +1 -1
- package/dist/framework_lifecycle.js +4 -1
- package/dist/framework_lifecycle.js.map +1 -1
- package/dist/framework_models.js +6 -0
- package/dist/framework_models.js.map +1 -1
- package/dist/framework_router.js +22 -11
- package/dist/framework_router.js.map +1 -1
- package/dist/framework_types.d.ts +9 -5
- package/dist/index.d.ts +9 -35
- package/dist/index.js +6 -37
- package/dist/index.js.map +1 -1
- package/dist/model_ids.js +4 -0
- package/dist/model_ids.js.map +1 -1
- package/dist/pi_runtime.js +19 -11
- package/dist/pi_runtime.js.map +1 -1
- package/dist/pi_types.d.ts +2 -2
- package/{src/index.ts → dist/policy-engine.d.ts} +2 -14
- package/dist/policy-engine.js +25 -0
- package/dist/policy-engine.js.map +1 -0
- package/dist/prompts.d.ts +1 -1
- package/dist/prompts.js +3 -3
- package/dist/prompts.js.map +1 -1
- package/dist/protocols.d.ts +5 -0
- package/dist/protocols.js +6 -0
- package/dist/protocols.js.map +1 -0
- package/dist/route.d.ts +3 -4
- package/dist/route.js +17 -10
- package/dist/route.js.map +1 -1
- package/dist/selector_input.d.ts +1 -0
- package/dist/selector_input.js +66 -78
- package/dist/selector_input.js.map +1 -1
- package/dist/selector_parse.d.ts +1 -0
- package/dist/selector_parse.js +2 -1
- package/dist/selector_parse.js.map +1 -1
- package/dist/selector_request.d.ts +1 -0
- package/dist/selector_request.js +1 -0
- package/dist/selector_request.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/examples/auto_router.ts +40 -0
- package/examples/basic_route.ts +130 -0
- package/examples/create_router.ts +114 -0
- package/examples/dari_policy.ts +86 -0
- package/examples/lease_stores.ts +159 -0
- package/examples/managed/README.md +50 -0
- package/examples/managed/byok-router/README.md +13 -0
- package/examples/managed/byok-router/router.yml +13 -0
- package/examples/managed/custom-rules-router/README.md +12 -0
- package/examples/managed/custom-rules-router/router.yml +24 -0
- package/examples/managed/eval-router/README.md +18 -0
- package/examples/managed/eval-router/router.yml +11 -0
- package/examples/managed/slm-router/README.md +12 -0
- package/examples/managed/slm-router/router.yml +8 -0
- package/examples/pi_runtime.ts +55 -0
- package/package.json +26 -7
- package/src/anonymous_actions.ts +0 -434
- package/src/auto_router.ts +0 -95
- package/src/cache_behavior.ts +0 -130
- package/src/cache_scope.ts +0 -58
- package/src/cache_switch_decision.ts +0 -53
- package/src/candidate_pruning.ts +0 -257
- package/src/candidate_resolution.ts +0 -208
- package/src/compatibility.ts +0 -162
- package/src/continuation_state.ts +0 -368
- package/src/cost.ts +0 -455
- package/src/dari_policy.ts +0 -360
- package/src/errors.ts +0 -27
- package/src/fingerprint.ts +0 -148
- package/src/fixed_turn_cost_config.ts +0 -24
- package/src/framework_error.ts +0 -38
- package/src/framework_lifecycle.ts +0 -74
- package/src/framework_models.ts +0 -186
- package/src/framework_router.ts +0 -490
- package/src/framework_streaming.ts +0 -613
- package/src/framework_types.ts +0 -372
- package/src/framework_validation.ts +0 -235
- package/src/json.ts +0 -43
- package/src/model_ids.ts +0 -29
- package/src/pi_context.ts +0 -251
- package/src/pi_options.ts +0 -295
- package/src/pi_output.ts +0 -387
- package/src/pi_reasoning_signature.ts +0 -41
- package/src/pi_runtime.ts +0 -238
- package/src/pi_types.ts +0 -71
- package/src/portable_reasoning_state.ts +0 -129
- package/src/previous_decision.ts +0 -109
- package/src/prompts.ts +0 -69
- package/src/protocol_anthropic.ts +0 -571
- package/src/protocol_openai_chat.ts +0 -645
- package/src/protocol_validation.ts +0 -202
- package/src/route.ts +0 -490
- package/src/routing_history.ts +0 -157
- package/src/selector_input.ts +0 -246
- package/src/selector_parse.ts +0 -138
- package/src/selector_request.ts +0 -130
- package/src/selector_truncation.ts +0 -48
- package/src/selector_wire.ts +0 -167
- package/src/types.ts +0 -231
package/src/framework_models.ts
DELETED
|
@@ -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
|
-
}
|
package/src/framework_router.ts
DELETED
|
@@ -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
|
-
}
|