@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.
- 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 +60 -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 +121 -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/routing_history.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
// Router-only history compaction shared by production routing and GRPO
|
|
2
|
-
// training. Provider-bound requests keep the complete conversation.
|
|
3
|
-
|
|
4
|
-
type RoutingHistoryMessage = {
|
|
5
|
-
role?: unknown;
|
|
6
|
-
content?: unknown;
|
|
7
|
-
[key: string]: unknown;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export function compactRoutingHistory<T extends RoutingHistoryMessage>(
|
|
11
|
-
messages: T[],
|
|
12
|
-
historyCharBudget: number | undefined,
|
|
13
|
-
): T[] {
|
|
14
|
-
if (historyCharBudget === undefined || messages.length <= 1) return messages;
|
|
15
|
-
if (!Number.isSafeInteger(historyCharBudget) || historyCharBudget < 512) {
|
|
16
|
-
throw new Error("GRPO routing history character budget must be an integer of at least 512");
|
|
17
|
-
}
|
|
18
|
-
const first = messages[0] as T;
|
|
19
|
-
const history = messages.slice(1);
|
|
20
|
-
const totalChars = history.reduce(
|
|
21
|
-
(total, message) => total + JSON.stringify(message).length,
|
|
22
|
-
0,
|
|
23
|
-
);
|
|
24
|
-
if (totalChars <= historyCharBudget) return messages;
|
|
25
|
-
|
|
26
|
-
// The final marker embeds omission counts, so budget the tail against a
|
|
27
|
-
// worst-case-width placeholder to keep the result within the char budget.
|
|
28
|
-
const markerContent = (omittedMessages: number, omittedChars: number): string =>
|
|
29
|
-
`[Router-only compaction omitted ${omittedMessages} earlier messages (~${omittedChars} chars); ` +
|
|
30
|
-
"retained the task and most recent context.]";
|
|
31
|
-
const markerReserve = JSON.stringify({
|
|
32
|
-
role: "system",
|
|
33
|
-
content: markerContent(99_999_999, 999_999_999),
|
|
34
|
-
}).length;
|
|
35
|
-
let remainingChars = historyCharBudget - markerReserve;
|
|
36
|
-
const tail: T[] = [];
|
|
37
|
-
for (let index = history.length - 1; index >= 0 && remainingChars > 0; index -= 1) {
|
|
38
|
-
const message = history[index] as T;
|
|
39
|
-
const serializedChars = JSON.stringify(message).length;
|
|
40
|
-
if (serializedChars <= remainingChars) {
|
|
41
|
-
tail.unshift(message);
|
|
42
|
-
remainingChars -= serializedChars;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
const compacted = compactRoutingMessage(message, remainingChars);
|
|
46
|
-
if (compacted !== undefined) tail.unshift(compacted);
|
|
47
|
-
break;
|
|
48
|
-
}
|
|
49
|
-
// Providers reject tool results whose originating assistant tool_calls
|
|
50
|
-
// message was compacted away, so the retained tail must never lead with one.
|
|
51
|
-
while (tail.length > 0 && tail[0]?.role === "tool") {
|
|
52
|
-
tail.shift();
|
|
53
|
-
}
|
|
54
|
-
const retainedChars = tail.reduce(
|
|
55
|
-
(total, message) => total + JSON.stringify(message).length,
|
|
56
|
-
0,
|
|
57
|
-
);
|
|
58
|
-
const marker = {
|
|
59
|
-
role: "system",
|
|
60
|
-
content: markerContent(
|
|
61
|
-
history.length - tail.length,
|
|
62
|
-
Math.max(totalChars - retainedChars, 0),
|
|
63
|
-
),
|
|
64
|
-
} as T;
|
|
65
|
-
return [first, marker, ...tail];
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function compactRoutingMessage<T extends RoutingHistoryMessage>(
|
|
69
|
-
message: T,
|
|
70
|
-
maxChars: number,
|
|
71
|
-
): T | undefined {
|
|
72
|
-
const content = message.content;
|
|
73
|
-
if (Array.isArray(content)) {
|
|
74
|
-
return compactRoutingMessageParts(message, content, maxChars);
|
|
75
|
-
}
|
|
76
|
-
const text = contentText(content);
|
|
77
|
-
const shell = { ...message, content: "" };
|
|
78
|
-
// Leave room for JSON escaping introduced by the truncation marker.
|
|
79
|
-
const availableChars = maxChars - JSON.stringify(shell).length - 32;
|
|
80
|
-
if (availableChars < 128 || text.length === 0) return undefined;
|
|
81
|
-
const compacted = { ...message, content: elideMiddle(text, availableChars) };
|
|
82
|
-
return JSON.stringify(compacted).length <= maxChars ? compacted : undefined;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function compactRoutingMessageParts<T extends RoutingHistoryMessage>(
|
|
86
|
-
message: T,
|
|
87
|
-
content: unknown[],
|
|
88
|
-
maxChars: number,
|
|
89
|
-
): T | undefined {
|
|
90
|
-
const text = contentText(content);
|
|
91
|
-
const nonTextContent = content.filter((part) => !isTextPart(part));
|
|
92
|
-
const shell = { ...message, content: nonTextContent };
|
|
93
|
-
const shellChars = JSON.stringify(shell).length;
|
|
94
|
-
if (shellChars > maxChars) return undefined;
|
|
95
|
-
if (text.length === 0) return shell;
|
|
96
|
-
|
|
97
|
-
const availableChars = maxChars - shellChars - 32;
|
|
98
|
-
if (availableChars < 128) {
|
|
99
|
-
return nonTextContent.length > 0 ? shell : undefined;
|
|
100
|
-
}
|
|
101
|
-
const textPart = { type: "text", text: elideMiddle(text, availableChars) };
|
|
102
|
-
const compacted = {
|
|
103
|
-
...message,
|
|
104
|
-
content: compactContentParts(content, textPart),
|
|
105
|
-
};
|
|
106
|
-
if (JSON.stringify(compacted).length <= maxChars) return compacted;
|
|
107
|
-
return nonTextContent.length > 0 ? shell : undefined;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function compactContentParts(
|
|
111
|
-
content: unknown[],
|
|
112
|
-
textPart: Record<string, unknown>,
|
|
113
|
-
): unknown[] {
|
|
114
|
-
let insertedText = false;
|
|
115
|
-
const compacted: unknown[] = [];
|
|
116
|
-
for (const part of content) {
|
|
117
|
-
if (isTextPart(part)) {
|
|
118
|
-
if (!insertedText) compacted.push(textPart);
|
|
119
|
-
insertedText = true;
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
compacted.push(part);
|
|
123
|
-
}
|
|
124
|
-
if (!insertedText) compacted.push(textPart);
|
|
125
|
-
return compacted;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function isTextPart(value: unknown): value is Record<string, unknown> {
|
|
129
|
-
return (
|
|
130
|
-
typeof value === "object" &&
|
|
131
|
-
value !== null &&
|
|
132
|
-
!Array.isArray(value) &&
|
|
133
|
-
(value as Record<string, unknown>).type === "text" &&
|
|
134
|
-
typeof (value as Record<string, unknown>).text === "string"
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function contentText(content: unknown): string {
|
|
139
|
-
if (typeof content === "string") return content;
|
|
140
|
-
if (!Array.isArray(content)) return "";
|
|
141
|
-
return content
|
|
142
|
-
.flatMap((block) => {
|
|
143
|
-
if (typeof block !== "object" || block === null || Array.isArray(block)) return [];
|
|
144
|
-
const text = (block as Record<string, unknown>).text;
|
|
145
|
-
return typeof text === "string" ? [text] : [];
|
|
146
|
-
})
|
|
147
|
-
.join("\n");
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function elideMiddle(value: string, maxChars: number): string {
|
|
151
|
-
if (value.length <= maxChars) return value;
|
|
152
|
-
const marker = "\n...[router history truncated]...\n";
|
|
153
|
-
if (maxChars <= marker.length) return value.slice(-maxChars);
|
|
154
|
-
const retainedChars = maxChars - marker.length;
|
|
155
|
-
const prefixChars = Math.floor(retainedChars / 2);
|
|
156
|
-
return `${value.slice(0, prefixChars)}${marker}${value.slice(-(retainedChars - prefixChars))}`;
|
|
157
|
-
}
|
package/src/selector_input.ts
DELETED
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
// Builds the selector input: candidate pairs, matched eval scores, cost
|
|
2
|
-
// estimates, the previous decision, and the (image-scrubbed) conversation.
|
|
3
|
-
// The system prompts live in prompts.ts.
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
isRecord,
|
|
7
|
-
REASONING_EFFORTS,
|
|
8
|
-
routingCandidateKey,
|
|
9
|
-
type CandidateCostEstimate,
|
|
10
|
-
type ChatMessage,
|
|
11
|
-
type CustomRouterRule,
|
|
12
|
-
type PreviousDecision,
|
|
13
|
-
type ReasoningEffort,
|
|
14
|
-
type RouterEval,
|
|
15
|
-
type RouterEvalScore,
|
|
16
|
-
type RoutingCandidate,
|
|
17
|
-
} from "./types.js";
|
|
18
|
-
|
|
19
|
-
const IMAGE_OMITTED_PLACEHOLDER = "<image omitted>";
|
|
20
|
-
|
|
21
|
-
type SelectorCandidate = {
|
|
22
|
-
model: string;
|
|
23
|
-
thinking_level: RoutingCandidate["reasoningEffort"];
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
type SelectorPreviousDecision = SelectorCandidate & { reason: string };
|
|
27
|
-
|
|
28
|
-
export type SelectorInput = {
|
|
29
|
-
candidate_pairs: SelectorCandidate[];
|
|
30
|
-
imported_evals: Array<Record<string, unknown>>;
|
|
31
|
-
previous_decision: SelectorPreviousDecision | null;
|
|
32
|
-
cost_estimates: CandidateCostEstimate[] | null;
|
|
33
|
-
messages: ChatMessage[];
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
type SelectorCustomRule = Omit<CustomRouterRule, "thinking_level"> & {
|
|
37
|
-
thinking_level: ReasoningEffort | null;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type CustomSelectorInput = SelectorInput & {
|
|
41
|
-
custom_rules: SelectorCustomRule[];
|
|
42
|
-
default_target: {
|
|
43
|
-
model: string;
|
|
44
|
-
thinking_level: ReasoningEffort | null;
|
|
45
|
-
} | null;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Selector serialization must not include image bytes. The provider-facing
|
|
49
|
-
// request and prefix fingerprint still use the original content, while this
|
|
50
|
-
// stable placeholder preserves the selector's own prompt cache behavior.
|
|
51
|
-
export function selectorSafeMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
52
|
-
return messages.map((message) => {
|
|
53
|
-
if (!Array.isArray(message.content)) return message;
|
|
54
|
-
return {
|
|
55
|
-
...message,
|
|
56
|
-
content: message.content.map((part) =>
|
|
57
|
-
isRecord(part) && part.type === "image_url"
|
|
58
|
-
? { type: "image_url", image_url: { url: IMAGE_OMITTED_PLACEHOLDER } }
|
|
59
|
-
: part
|
|
60
|
-
),
|
|
61
|
-
};
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function buildSelectorInput(args: {
|
|
66
|
-
candidates: RoutingCandidate[];
|
|
67
|
-
evals: RouterEval[];
|
|
68
|
-
previousDecision: PreviousDecision | null;
|
|
69
|
-
costEstimates: CandidateCostEstimate[] | null;
|
|
70
|
-
messages: ChatMessage[];
|
|
71
|
-
customRules?: CustomRouterRule[];
|
|
72
|
-
defaultTarget?: {
|
|
73
|
-
model: string;
|
|
74
|
-
thinkingLevel: ReasoningEffort | null;
|
|
75
|
-
} | null;
|
|
76
|
-
modelFallbackEnabled?: boolean;
|
|
77
|
-
fallbackRequiresDifferentProvider?: boolean;
|
|
78
|
-
// When true, candidates with no exact-level and no generic "Any" score get a
|
|
79
|
-
// linear interpolation between their two bracketing known levels instead of
|
|
80
|
-
// an empty row. Off by default so existing routers keep measured-only scores.
|
|
81
|
-
imputeEvalScores?: boolean;
|
|
82
|
-
}): SelectorInput | CustomSelectorInput {
|
|
83
|
-
const input: SelectorInput = {
|
|
84
|
-
candidate_pairs: args.candidates.map(({ model, reasoningEffort }) => ({
|
|
85
|
-
model,
|
|
86
|
-
thinking_level: reasoningEffort,
|
|
87
|
-
})),
|
|
88
|
-
imported_evals: formatImportedEvals(
|
|
89
|
-
args.evals,
|
|
90
|
-
args.candidates,
|
|
91
|
-
args.imputeEvalScores ?? false,
|
|
92
|
-
),
|
|
93
|
-
previous_decision: args.previousDecision
|
|
94
|
-
? {
|
|
95
|
-
model: args.previousDecision.model,
|
|
96
|
-
thinking_level: args.previousDecision.reasoningEffort,
|
|
97
|
-
reason: args.previousDecision.reason,
|
|
98
|
-
}
|
|
99
|
-
: null,
|
|
100
|
-
cost_estimates: args.costEstimates,
|
|
101
|
-
messages: args.messages,
|
|
102
|
-
};
|
|
103
|
-
if (args.customRules !== undefined) {
|
|
104
|
-
(input as CustomSelectorInput).custom_rules = args.customRules.map(
|
|
105
|
-
(rule) => ({
|
|
106
|
-
...rule,
|
|
107
|
-
thinking_level: rule.thinking_level ?? null,
|
|
108
|
-
}),
|
|
109
|
-
);
|
|
110
|
-
(input as CustomSelectorInput).default_target = args.defaultTarget
|
|
111
|
-
? {
|
|
112
|
-
model: args.defaultTarget.model,
|
|
113
|
-
thinking_level: args.defaultTarget.thinkingLevel,
|
|
114
|
-
}
|
|
115
|
-
: null;
|
|
116
|
-
}
|
|
117
|
-
return input;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function formatImportedEvals(
|
|
121
|
-
evals: RouterEval[],
|
|
122
|
-
candidates: RoutingCandidate[],
|
|
123
|
-
imputeEvalScores: boolean,
|
|
124
|
-
): Array<Record<string, unknown>> {
|
|
125
|
-
return evals.flatMap((evalCard) => {
|
|
126
|
-
const scores = matchingEvalScores(
|
|
127
|
-
evalCard.scores,
|
|
128
|
-
candidates,
|
|
129
|
-
imputeEvalScores,
|
|
130
|
-
);
|
|
131
|
-
if (scores.length === 0) return [];
|
|
132
|
-
return [
|
|
133
|
-
{
|
|
134
|
-
id: evalCard.id,
|
|
135
|
-
name: evalCard.name,
|
|
136
|
-
description: evalCard.description ?? null,
|
|
137
|
-
min_score: evalCard.min_score,
|
|
138
|
-
max_score: evalCard.max_score,
|
|
139
|
-
scores,
|
|
140
|
-
},
|
|
141
|
-
];
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function matchingEvalScores(
|
|
146
|
-
scores: RouterEvalScore[],
|
|
147
|
-
candidates: RoutingCandidate[],
|
|
148
|
-
imputeEvalScores: boolean,
|
|
149
|
-
): Array<Record<string, unknown>> {
|
|
150
|
-
const uniqueCandidates = new Map(
|
|
151
|
-
candidates.map((candidate) => [routingCandidateKey(candidate), candidate]),
|
|
152
|
-
);
|
|
153
|
-
return [...uniqueCandidates.values()].flatMap((candidate) => {
|
|
154
|
-
const modelScores = scores.filter(
|
|
155
|
-
(score) => score.model_id === candidate.model,
|
|
156
|
-
);
|
|
157
|
-
const exact = modelScores.find(
|
|
158
|
-
(score) => score.thinking_level === candidate.reasoningEffort,
|
|
159
|
-
);
|
|
160
|
-
const generic = modelScores.find((score) => score.thinking_level == null);
|
|
161
|
-
const match = exact ?? generic;
|
|
162
|
-
if (match) {
|
|
163
|
-
return [
|
|
164
|
-
{
|
|
165
|
-
model_id: candidate.model,
|
|
166
|
-
thinking_level: candidate.reasoningEffort,
|
|
167
|
-
score: match.score,
|
|
168
|
-
...normalizedScore(match.score, scores),
|
|
169
|
-
notes: match.notes ?? null,
|
|
170
|
-
},
|
|
171
|
-
];
|
|
172
|
-
}
|
|
173
|
-
if (!imputeEvalScores) return [];
|
|
174
|
-
const imputed = interpolatedScore(candidate, modelScores);
|
|
175
|
-
if (imputed === null) return [];
|
|
176
|
-
return [
|
|
177
|
-
{
|
|
178
|
-
model_id: candidate.model,
|
|
179
|
-
thinking_level: candidate.reasoningEffort,
|
|
180
|
-
score: imputed,
|
|
181
|
-
...normalizedScore(imputed, scores),
|
|
182
|
-
notes: null,
|
|
183
|
-
imputed: true,
|
|
184
|
-
},
|
|
185
|
-
];
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Linear interpolation between the two per-level scores that bracket the
|
|
190
|
-
// requested thinking level. A single known level (or zero) is not enough to
|
|
191
|
-
// pin a line, so we only interpolate when the candidate has scores on both
|
|
192
|
-
// sides of the requested level; one-sided cases stay empty.
|
|
193
|
-
function interpolatedScore(
|
|
194
|
-
candidate: RoutingCandidate,
|
|
195
|
-
modelScores: RouterEvalScore[],
|
|
196
|
-
): number | null {
|
|
197
|
-
const requestedIndex = REASONING_EFFORTS.indexOf(candidate.reasoningEffort);
|
|
198
|
-
if (requestedIndex < 0) return null;
|
|
199
|
-
|
|
200
|
-
let below: { index: number; score: number } | null = null;
|
|
201
|
-
let above: { index: number; score: number } | null = null;
|
|
202
|
-
for (const entry of modelScores) {
|
|
203
|
-
if (entry.thinking_level == null) continue;
|
|
204
|
-
const index = REASONING_EFFORTS.indexOf(entry.thinking_level);
|
|
205
|
-
if (index < 0) continue;
|
|
206
|
-
if (index < requestedIndex && (below === null || index > below.index)) {
|
|
207
|
-
below = { index, score: entry.score };
|
|
208
|
-
} else if (
|
|
209
|
-
index > requestedIndex &&
|
|
210
|
-
(above === null || index < above.index)
|
|
211
|
-
) {
|
|
212
|
-
above = { index, score: entry.score };
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
if (below === null || above === null) return null;
|
|
216
|
-
|
|
217
|
-
const ratio =
|
|
218
|
-
(requestedIndex - below.index) / (above.index - below.index);
|
|
219
|
-
return round2(below.score + (above.score - below.score) * ratio);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Positions a candidate's score against every model on the scorecard, not just
|
|
223
|
-
// the candidates that survived pruning. Candidate-relative normalization would
|
|
224
|
-
// move with the pruned set (with two candidates a z-score is always +/-1), so
|
|
225
|
-
// the selector would read pure ordering as if it carried magnitude.
|
|
226
|
-
function normalizedScore(
|
|
227
|
-
score: number,
|
|
228
|
-
population: RouterEvalScore[],
|
|
229
|
-
): { rank: number; rank_total: number; z_score: number } {
|
|
230
|
-
const values = population.map((entry) => entry.score);
|
|
231
|
-
const mean = values.reduce((total, value) => total + value, 0) / values.length;
|
|
232
|
-
const variance =
|
|
233
|
-
values.reduce((total, value) => total + (value - mean) ** 2, 0) /
|
|
234
|
-
values.length;
|
|
235
|
-
const stdDev = Math.sqrt(variance);
|
|
236
|
-
return {
|
|
237
|
-
// Competition ranking, best score first; tied scores share the low rank.
|
|
238
|
-
rank: 1 + values.filter((value) => value > score).length,
|
|
239
|
-
rank_total: values.length,
|
|
240
|
-
z_score: stdDev === 0 ? 0 : round2((score - mean) / stdDev),
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function round2(value: number): number {
|
|
245
|
-
return Math.round(value * 100) / 100;
|
|
246
|
-
}
|
package/src/selector_parse.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { RouterCoreError } from "./errors.js";
|
|
2
|
-
import { providerForModel } from "./model_ids.js";
|
|
3
|
-
import {
|
|
4
|
-
isReasoningEffort,
|
|
5
|
-
sameRoutingCandidate,
|
|
6
|
-
type RoutingCandidate,
|
|
7
|
-
type RoutingDecision,
|
|
8
|
-
} from "./types.js";
|
|
9
|
-
|
|
10
|
-
export type ParsedSelectorDecision = {
|
|
11
|
-
decision: RoutingDecision;
|
|
12
|
-
fallbackDecision?: RoutingDecision;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
// Parses the selector's raw output text into a routing decision (plus an
|
|
16
|
-
// optional fallback). Tolerates a fenced ```json block; everything else must
|
|
17
|
-
// be strict JSON matching the routing_decision schema.
|
|
18
|
-
export function parseSelectorDecision(text: string): ParsedSelectorDecision {
|
|
19
|
-
const trimmed = text.trim().replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```$/i, "").trim();
|
|
20
|
-
let parsed: unknown;
|
|
21
|
-
try {
|
|
22
|
-
parsed = JSON.parse(trimmed);
|
|
23
|
-
} catch {
|
|
24
|
-
throw new RouterCoreError("selector_output", "Selector returned invalid JSON.", "selector_invalid_json");
|
|
25
|
-
}
|
|
26
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
27
|
-
throw new RouterCoreError("selector_output", "Selector returned invalid routing decision.", "selector_invalid_response");
|
|
28
|
-
}
|
|
29
|
-
const record = parsed as Record<string, unknown>;
|
|
30
|
-
if (typeof record.selected_model !== "string" || record.selected_model === "") {
|
|
31
|
-
throw new RouterCoreError("selector_output", "Selector response missing selected_model.", "selector_invalid_response");
|
|
32
|
-
}
|
|
33
|
-
if (!isReasoningEffort(record.reasoning_effort)) {
|
|
34
|
-
throw new RouterCoreError(
|
|
35
|
-
"selector_output",
|
|
36
|
-
"Selector response missing a valid reasoning_effort.",
|
|
37
|
-
"selector_invalid_response",
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
const reason = typeof record.reason === "string" ? record.reason.trim() : "";
|
|
41
|
-
const leaseTurnsRemaining =
|
|
42
|
-
typeof record.lease_turns_remaining === "number" && Number.isFinite(record.lease_turns_remaining)
|
|
43
|
-
? Math.trunc(record.lease_turns_remaining)
|
|
44
|
-
: undefined;
|
|
45
|
-
const decision = {
|
|
46
|
-
selectedModel: record.selected_model,
|
|
47
|
-
reasoningEffort: record.reasoning_effort,
|
|
48
|
-
reason:
|
|
49
|
-
reason ||
|
|
50
|
-
`Selector selected ${record.selected_model}/${record.reasoning_effort}; no reason was provided.`,
|
|
51
|
-
...(leaseTurnsRemaining === undefined ? {} : { leaseTurnsRemaining }),
|
|
52
|
-
};
|
|
53
|
-
const fallbackModel = record.fallback_model;
|
|
54
|
-
const fallbackEffort = record.fallback_reasoning_effort;
|
|
55
|
-
const fallbackReason = record.fallback_reason;
|
|
56
|
-
// Selectors that decline a fallback often still explain why in
|
|
57
|
-
// fallback_reason; a missing fallback_model alone means "no fallback".
|
|
58
|
-
const noFallback = fallbackModel === null || fallbackModel === undefined;
|
|
59
|
-
if (noFallback) return { decision };
|
|
60
|
-
if (
|
|
61
|
-
typeof fallbackModel !== "string" ||
|
|
62
|
-
!fallbackModel ||
|
|
63
|
-
!isReasoningEffort(fallbackEffort) ||
|
|
64
|
-
(fallbackReason !== undefined &&
|
|
65
|
-
fallbackReason !== null &&
|
|
66
|
-
typeof fallbackReason !== "string")
|
|
67
|
-
) {
|
|
68
|
-
throw new RouterCoreError(
|
|
69
|
-
"selector_output",
|
|
70
|
-
"Selector response contains an incomplete fallback decision.",
|
|
71
|
-
"selector_invalid_response",
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
const normalizedFallbackReason = fallbackReason?.trim() ?? "";
|
|
75
|
-
return {
|
|
76
|
-
decision,
|
|
77
|
-
fallbackDecision: {
|
|
78
|
-
selectedModel: fallbackModel,
|
|
79
|
-
reasoningEffort: fallbackEffort,
|
|
80
|
-
reason:
|
|
81
|
-
normalizedFallbackReason ||
|
|
82
|
-
`Selector ranked ${fallbackModel}/${fallbackEffort} as the fallback; no reason was provided.`,
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Validates a parsed selector decision against the candidate set and fallback
|
|
88
|
-
// policy: the selection must be a real candidate pair, and when fallback is
|
|
89
|
-
// enabled a fallback must be present whenever an eligible one exists and must
|
|
90
|
-
// itself be an eligible candidate on a distinct model (and provider, when
|
|
91
|
-
// required).
|
|
92
|
-
export function validateSelectorDecisions(args: {
|
|
93
|
-
decision: RoutingDecision;
|
|
94
|
-
fallbackDecision?: RoutingDecision;
|
|
95
|
-
candidates: RoutingCandidate[];
|
|
96
|
-
modelFallbackEnabled?: boolean;
|
|
97
|
-
fallbackRequiresDifferentProvider?: boolean;
|
|
98
|
-
}): void {
|
|
99
|
-
const selected = {
|
|
100
|
-
model: args.decision.selectedModel,
|
|
101
|
-
reasoningEffort: args.decision.reasoningEffort,
|
|
102
|
-
};
|
|
103
|
-
if (!args.candidates.some((candidate) => sameRoutingCandidate(candidate, selected))) {
|
|
104
|
-
throw new RouterCoreError(
|
|
105
|
-
"selector_output",
|
|
106
|
-
"Selector returned a model/thinking-level pair outside this router's candidates.",
|
|
107
|
-
"selector_invalid_candidate",
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
const eligibleFallbacks = args.candidates.filter(
|
|
111
|
-
(candidate) =>
|
|
112
|
-
candidate.model !== selected.model &&
|
|
113
|
-
(!args.fallbackRequiresDifferentProvider ||
|
|
114
|
-
providerForModel(candidate.model) !== providerForModel(selected.model)),
|
|
115
|
-
);
|
|
116
|
-
if (args.modelFallbackEnabled) {
|
|
117
|
-
if (!args.fallbackDecision && eligibleFallbacks.length > 0) {
|
|
118
|
-
throw new RouterCoreError(
|
|
119
|
-
"selector_output",
|
|
120
|
-
"Selector response missing an eligible fallback model.",
|
|
121
|
-
"selector_invalid_response",
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
if (args.fallbackDecision) {
|
|
125
|
-
const fallback = {
|
|
126
|
-
model: args.fallbackDecision.selectedModel,
|
|
127
|
-
reasoningEffort: args.fallbackDecision.reasoningEffort,
|
|
128
|
-
};
|
|
129
|
-
if (!eligibleFallbacks.some((candidate) => sameRoutingCandidate(candidate, fallback))) {
|
|
130
|
-
throw new RouterCoreError(
|
|
131
|
-
"selector_output",
|
|
132
|
-
"Selector returned an ineligible fallback model/thinking-level pair.",
|
|
133
|
-
"selector_invalid_candidate",
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
package/src/selector_request.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { RouterCoreError } from "./errors.js";
|
|
2
|
-
import { CUSTOM_SELECTOR_SYSTEM_PROMPT, SELECTOR_SYSTEM_PROMPT } from "./prompts.js";
|
|
3
|
-
import {
|
|
4
|
-
buildSelectorInput,
|
|
5
|
-
type CustomSelectorInput,
|
|
6
|
-
type SelectorInput,
|
|
7
|
-
} from "./selector_input.js";
|
|
8
|
-
import { trimMessagesFromFront } from "./selector_truncation.js";
|
|
9
|
-
import { REASONING_EFFORTS } from "./types.js";
|
|
10
|
-
import type {
|
|
11
|
-
CandidateCostEstimate,
|
|
12
|
-
ChatCompletionRequest,
|
|
13
|
-
ChatMessage,
|
|
14
|
-
CustomRouterRule,
|
|
15
|
-
PreviousDecision,
|
|
16
|
-
ReasoningEffort,
|
|
17
|
-
RouterEval,
|
|
18
|
-
RoutingCandidate,
|
|
19
|
-
} from "./types.js";
|
|
20
|
-
|
|
21
|
-
export type BuiltSelectorRequest = {
|
|
22
|
-
selectorInput: SelectorInput | CustomSelectorInput;
|
|
23
|
-
selectorRequest: ChatCompletionRequest;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export type SelectorRequestArgs = {
|
|
27
|
-
candidates: RoutingCandidate[];
|
|
28
|
-
evals?: RouterEval[];
|
|
29
|
-
previousDecision?: PreviousDecision;
|
|
30
|
-
costEstimates?: CandidateCostEstimate[];
|
|
31
|
-
customRules?: CustomRouterRule[];
|
|
32
|
-
defaultTarget?: {
|
|
33
|
-
model: string;
|
|
34
|
-
thinkingLevel: ReasoningEffort | null;
|
|
35
|
-
} | null;
|
|
36
|
-
selectorModel: string;
|
|
37
|
-
messages: ChatMessage[];
|
|
38
|
-
modelFallbackEnabled?: boolean;
|
|
39
|
-
fallbackRequiresDifferentProvider?: boolean;
|
|
40
|
-
imputeEvalScores?: boolean;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
// Builds the selector request, front-trimming conversation messages until the
|
|
44
|
-
// serialized prompt fits the selector's context window. The window size is an
|
|
45
|
-
// explicit input (in characters) so the core stays free of model-registry
|
|
46
|
-
// lookups.
|
|
47
|
-
export function buildSizedSelectorRequest(
|
|
48
|
-
args: SelectorRequestArgs & { contextWindowChars: number },
|
|
49
|
-
): BuiltSelectorRequest {
|
|
50
|
-
let messages = args.messages;
|
|
51
|
-
let built = buildSelectorRequest({ ...args, messages });
|
|
52
|
-
let previousInputChars = Number.POSITIVE_INFINITY;
|
|
53
|
-
|
|
54
|
-
while (true) {
|
|
55
|
-
const inputChars = selectorRequestInputChars(built.selectorRequest);
|
|
56
|
-
if (inputChars <= args.contextWindowChars) return built;
|
|
57
|
-
if (inputChars >= previousInputChars) {
|
|
58
|
-
throw new RouterCoreError(
|
|
59
|
-
"configuration",
|
|
60
|
-
`Selector prompt cannot fit configured context window for ${args.selectorModel}.`,
|
|
61
|
-
"configuration_error"
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
previousInputChars = inputChars;
|
|
65
|
-
messages = trimMessagesFromFront(messages, inputChars - args.contextWindowChars);
|
|
66
|
-
built = buildSelectorRequest({ ...args, messages });
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function selectorRequestInputChars(request: ChatCompletionRequest): number {
|
|
71
|
-
return JSON.stringify(request.messages ?? []).length;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function buildSelectorRequest(args: SelectorRequestArgs): BuiltSelectorRequest {
|
|
75
|
-
const isCustom = args.customRules !== undefined;
|
|
76
|
-
const selectorInput = buildSelectorInput({
|
|
77
|
-
candidates: args.candidates,
|
|
78
|
-
evals: args.evals ?? [],
|
|
79
|
-
previousDecision: args.previousDecision ?? null,
|
|
80
|
-
costEstimates: args.costEstimates ?? null,
|
|
81
|
-
messages: args.messages,
|
|
82
|
-
customRules: args.customRules,
|
|
83
|
-
defaultTarget: args.defaultTarget,
|
|
84
|
-
modelFallbackEnabled: args.modelFallbackEnabled,
|
|
85
|
-
fallbackRequiresDifferentProvider:
|
|
86
|
-
args.fallbackRequiresDifferentProvider,
|
|
87
|
-
imputeEvalScores: args.imputeEvalScores,
|
|
88
|
-
});
|
|
89
|
-
return {
|
|
90
|
-
selectorInput,
|
|
91
|
-
selectorRequest: {
|
|
92
|
-
model: args.selectorModel,
|
|
93
|
-
messages: [
|
|
94
|
-
{ role: "system", content: isCustom ? CUSTOM_SELECTOR_SYSTEM_PROMPT : SELECTOR_SYSTEM_PROMPT },
|
|
95
|
-
{
|
|
96
|
-
role: "user",
|
|
97
|
-
content: JSON.stringify(selectorInput),
|
|
98
|
-
},
|
|
99
|
-
],
|
|
100
|
-
// The selector is an internal fixed-purpose JSON pick; it must not inherit
|
|
101
|
-
// the client-facing implicit-medium reasoning default. Selector models
|
|
102
|
-
// must support a true off mode.
|
|
103
|
-
reasoning_effort: "off",
|
|
104
|
-
response_format: {
|
|
105
|
-
type: "json_schema",
|
|
106
|
-
json_schema: {
|
|
107
|
-
name: "routing_decision",
|
|
108
|
-
strict: true,
|
|
109
|
-
schema: {
|
|
110
|
-
type: "object",
|
|
111
|
-
additionalProperties: false,
|
|
112
|
-
properties: {
|
|
113
|
-
selected_model: { type: "string" },
|
|
114
|
-
reasoning_effort: {
|
|
115
|
-
type: "string",
|
|
116
|
-
enum: [...REASONING_EFFORTS],
|
|
117
|
-
},
|
|
118
|
-
reason: { type: "string" },
|
|
119
|
-
},
|
|
120
|
-
required: [
|
|
121
|
-
"selected_model",
|
|
122
|
-
"reasoning_effort",
|
|
123
|
-
"reason",
|
|
124
|
-
],
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
}
|