@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/anonymous_actions.ts
DELETED
|
@@ -1,434 +0,0 @@
|
|
|
1
|
-
// Anonymous candidate-action selector protocol. Candidates are shuffled into
|
|
2
|
-
// anonymous action slots ("A", "B", ..., "AA", ...) and every model-identifying
|
|
3
|
-
// field in the selector input is replaced by its slot, so a trained policy
|
|
4
|
-
// cannot memorize model names or list positions. This is the canonical
|
|
5
|
-
// open-weight selector protocol used by Dari's SLM routing strategy; the
|
|
6
|
-
// named-candidate protocol in selector_input.ts/prompts.ts remains available
|
|
7
|
-
// for custom routers that use a general-purpose LLM selector.
|
|
8
|
-
|
|
9
|
-
import { ANONYMOUS_ACTION_SYSTEM_PROMPT } from "./prompts.js";
|
|
10
|
-
import {
|
|
11
|
-
jsonRecord,
|
|
12
|
-
jsonValue,
|
|
13
|
-
requiredString,
|
|
14
|
-
type JsonObject,
|
|
15
|
-
type JsonValue,
|
|
16
|
-
} from "./json.js";
|
|
17
|
-
import type { TurnCostProjection } from "./types.js";
|
|
18
|
-
|
|
19
|
-
// Deterministic random source in [0, 1). Callers own seeding so experiment
|
|
20
|
-
// harnesses can reproduce recorded permutations with their own PRNG.
|
|
21
|
-
export type Rng = () => number;
|
|
22
|
-
|
|
23
|
-
export type AnonymousActionCandidate = {
|
|
24
|
-
model: string;
|
|
25
|
-
reasoningEffort: string;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export type AnonymousActionSlot = {
|
|
29
|
-
action: string;
|
|
30
|
-
candidate: AnonymousActionCandidate;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export type AnonymousSelectorMessage = {
|
|
34
|
-
role: "system" | "user";
|
|
35
|
-
content: string;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export type AnonymousPolicyPrompt = {
|
|
39
|
-
messages: [system: AnonymousSelectorMessage, user: AnonymousSelectorMessage];
|
|
40
|
-
actions: string[];
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export type AnonymousActionSelection = {
|
|
44
|
-
action: string;
|
|
45
|
-
candidate: AnonymousActionCandidate;
|
|
46
|
-
// Turns the selection commits to when the caller offered a lease menu;
|
|
47
|
-
// absent for single-turn selectors.
|
|
48
|
-
turns?: number;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// The de-identified selector input. Every field is JSON-serializable so it can
|
|
52
|
-
// cross a process boundary, but it is a concrete type rather than a bag: it is
|
|
53
|
-
// produced by anonymizeSelectorInput and consumed by the prompt formatter, so
|
|
54
|
-
// nothing downstream needs to re-parse it.
|
|
55
|
-
export type AnonymousEvalScore = {
|
|
56
|
-
action: string;
|
|
57
|
-
rank: number;
|
|
58
|
-
rank_total: number;
|
|
59
|
-
z_score: number;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
export type AnonymousEvalCard = {
|
|
63
|
-
name: string;
|
|
64
|
-
description: string | null;
|
|
65
|
-
scores: AnonymousEvalScore[];
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
export type AnonymousActionCost = {
|
|
69
|
-
action: string;
|
|
70
|
-
projections: TurnCostProjection[];
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export type AnonymousSelectorInput = {
|
|
74
|
-
candidate_actions: Array<{ action: string }>;
|
|
75
|
-
imported_evals: AnonymousEvalCard[];
|
|
76
|
-
previous_action: { action: string } | null;
|
|
77
|
-
cost_estimates: AnonymousActionCost[];
|
|
78
|
-
messages: JsonValue;
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// Shuffles candidates into spreadsheet-style action slots A..Z, AA..AZ, ...
|
|
82
|
-
// with the injected rng.
|
|
83
|
-
export function assignAnonymousActions(
|
|
84
|
-
candidates: readonly AnonymousActionCandidate[],
|
|
85
|
-
rng: Rng,
|
|
86
|
-
): AnonymousActionSlot[] {
|
|
87
|
-
if (candidates.length < 2) {
|
|
88
|
-
throw new Error(`Anonymous action routing requires at least two candidates, got ${candidates.length}`);
|
|
89
|
-
}
|
|
90
|
-
const keys = new Set<string>();
|
|
91
|
-
for (const candidate of candidates) {
|
|
92
|
-
const key = candidateKey(candidate.model, candidate.reasoningEffort);
|
|
93
|
-
if (keys.has(key)) throw new Error(`Duplicate anonymous action candidate ${key}`);
|
|
94
|
-
keys.add(key);
|
|
95
|
-
}
|
|
96
|
-
return shuffled(candidates, rng).map((candidate, index) => ({
|
|
97
|
-
action: actionForIndex(index),
|
|
98
|
-
candidate,
|
|
99
|
-
}));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function actionForIndex(index: number): string {
|
|
103
|
-
let remaining = index;
|
|
104
|
-
let action = "";
|
|
105
|
-
do {
|
|
106
|
-
action = String.fromCharCode("A".charCodeAt(0) + (remaining % 26)) + action;
|
|
107
|
-
remaining = Math.floor(remaining / 26) - 1;
|
|
108
|
-
} while (remaining >= 0);
|
|
109
|
-
return action;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Rewrites a named-candidate selector input (see selector_input.ts) into its
|
|
113
|
-
// anonymous-action form. The input must reference exactly the candidates in
|
|
114
|
-
// `slots`; any mismatch is an error rather than a silent partial rewrite.
|
|
115
|
-
export function anonymizeSelectorInput(
|
|
116
|
-
selectorInput: JsonObject,
|
|
117
|
-
slots: readonly AnonymousActionSlot[],
|
|
118
|
-
): AnonymousSelectorInput {
|
|
119
|
-
const input = jsonRecord(structuredClone(selectorInput), "selector input");
|
|
120
|
-
const byCandidate = new Map(
|
|
121
|
-
slots.map((slot) => [candidateKey(slot.candidate.model, slot.candidate.reasoningEffort), slot.action]),
|
|
122
|
-
);
|
|
123
|
-
assertSelectorCandidates(input["candidate_pairs"], byCandidate);
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
candidate_actions: slots.map((slot) => ({ action: slot.action })),
|
|
127
|
-
imported_evals: anonymizeEvals(input["imported_evals"], byCandidate),
|
|
128
|
-
previous_action: anonymizePreviousDecision(input["previous_decision"], byCandidate),
|
|
129
|
-
cost_estimates: anonymizeCosts(input["cost_estimates"], byCandidate),
|
|
130
|
-
messages: jsonValue(input["messages"], "selector input messages"),
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function buildAnonymousPolicyPrompt(
|
|
135
|
-
anonymousSelectorInput: AnonymousSelectorInput,
|
|
136
|
-
slots: readonly AnonymousActionSlot[],
|
|
137
|
-
): AnonymousPolicyPrompt {
|
|
138
|
-
return {
|
|
139
|
-
messages: [
|
|
140
|
-
{ role: "system", content: ANONYMOUS_ACTION_SYSTEM_PROMPT },
|
|
141
|
-
{ role: "user", content: formatAnonymousPolicyInput(anonymousSelectorInput) },
|
|
142
|
-
],
|
|
143
|
-
actions: slots.map((slot) => slot.action),
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Renders the anonymous selector input as sectioned text rather than raw JSON.
|
|
148
|
-
// One block per action carries that action's benchmark standing and its
|
|
149
|
-
// projected cost, so the policy compares like against like instead of
|
|
150
|
-
// re-deriving comparisons from scores on incomparable scales and six cost
|
|
151
|
-
// fields. How to read those blocks lives in ANONYMOUS_ACTION_SYSTEM_PROMPT,
|
|
152
|
-
// keeping this message pure data. The conversation goes last: it is the only
|
|
153
|
-
// section that grows turn over turn, so keeping it at the end preserves the
|
|
154
|
-
// shared prefix.
|
|
155
|
-
export function formatAnonymousPolicyInput(input: AnonymousSelectorInput): string {
|
|
156
|
-
const sections = [
|
|
157
|
-
...(input.imported_evals.length > 0
|
|
158
|
-
? [section("benchmarks", input.imported_evals.map(glossaryLine).join("\n"))]
|
|
159
|
-
: []),
|
|
160
|
-
section(
|
|
161
|
-
"candidates",
|
|
162
|
-
input.candidate_actions
|
|
163
|
-
.map((candidate) => candidateBlock(candidate.action, input))
|
|
164
|
-
.join("\n\n"),
|
|
165
|
-
),
|
|
166
|
-
...(input.previous_action === null
|
|
167
|
-
? []
|
|
168
|
-
: [section("previous_action", input.previous_action.action)]),
|
|
169
|
-
section("conversation", JSON.stringify(input.messages)),
|
|
170
|
-
];
|
|
171
|
-
return sections.join("\n\n");
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function section(name: string, body: string): string {
|
|
175
|
-
return `<${name}>\n${body}\n</${name}>`;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function candidateBlock(action: string, input: AnonymousSelectorInput): string {
|
|
179
|
-
const cost = input.cost_estimates.find((entry) => entry.action === action);
|
|
180
|
-
const lines = [`## Action ${action}`];
|
|
181
|
-
if (cost !== undefined) lines.push(costLine(cost));
|
|
182
|
-
for (const card of input.imported_evals) {
|
|
183
|
-
const score = card.scores.find((entry) => entry.action === action);
|
|
184
|
-
if (score !== undefined) {
|
|
185
|
-
lines.push(
|
|
186
|
-
`- ${card.name}: Rank ${score.rank}/${score.rank_total}, Z ${signed(score.z_score)}`,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
return lines.join("\n");
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// One line, one unit, the same horizons for every action.
|
|
194
|
-
function costLine(cost: AnonymousActionCost): string {
|
|
195
|
-
const horizons = cost.projections.map(
|
|
196
|
-
(projection) =>
|
|
197
|
-
`${projection.projected_turns} turn${projection.projected_turns === 1 ? "" : "s"} ${usd(projection.total_cost_usd)}`,
|
|
198
|
-
);
|
|
199
|
-
return `- Cost: ${horizons.join(", ")}`;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function signed(value: number): string {
|
|
203
|
-
return value >= 0 ? `+${value.toFixed(2)}` : value.toFixed(2);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function usd(value: number): string {
|
|
207
|
-
if (value >= 1) return `$${value.toFixed(2)}`;
|
|
208
|
-
const trimmed = value.toPrecision(3).replace(/0+$/, "").replace(/\.$/, "");
|
|
209
|
-
return `$${trimmed}`;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function glossaryLine(card: AnonymousEvalCard): string {
|
|
213
|
-
return card.description === null ? `- ${card.name}` : `- ${card.name}: ${card.description}`;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function previousAction(value: unknown): string | null {
|
|
217
|
-
if (value === null || value === undefined) return null;
|
|
218
|
-
return requiredString(
|
|
219
|
-
jsonRecord(value, "anonymous previous_action")["action"],
|
|
220
|
-
"anonymous previous_action.action",
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function isRecordValue(value: unknown): value is Record<string, JsonValue> {
|
|
225
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Parses a policy completion against the permutation's slots. Without a lease
|
|
229
|
-
// menu the exact shape is {"action":"B"}; with one it is
|
|
230
|
-
// {"action":"B","turns":10} and turns must be on the menu.
|
|
231
|
-
export function parseAnonymousActionSelection(
|
|
232
|
-
completion: string,
|
|
233
|
-
slots: readonly AnonymousActionSlot[],
|
|
234
|
-
leaseTurns?: readonly number[],
|
|
235
|
-
): AnonymousActionSelection {
|
|
236
|
-
let parsed: unknown;
|
|
237
|
-
try {
|
|
238
|
-
parsed = JSON.parse(completion);
|
|
239
|
-
} catch {
|
|
240
|
-
throw new Error("Anonymous action completion is not valid JSON");
|
|
241
|
-
}
|
|
242
|
-
const record = jsonRecord(parsed, "anonymous action completion");
|
|
243
|
-
const keys = Object.keys(record);
|
|
244
|
-
// An empty menu means the caller routes one turn at a time, same as no menu.
|
|
245
|
-
const offeredLeaseTurns =
|
|
246
|
-
leaseTurns !== undefined && leaseTurns.length > 0 ? leaseTurns : undefined;
|
|
247
|
-
const expected = offeredLeaseTurns === undefined ? ["action"] : ["action", "turns"];
|
|
248
|
-
if (keys.length !== expected.length || expected.some((key, i) => keys[i] !== key)) {
|
|
249
|
-
throw new Error(
|
|
250
|
-
`Anonymous action completion must contain exactly {${expected
|
|
251
|
-
.map((key) => `"${key}":...`)
|
|
252
|
-
.join(",")}}`,
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
const action = record["action"];
|
|
256
|
-
if (typeof action !== "string") {
|
|
257
|
-
throw new Error("Anonymous action completion action must be a string");
|
|
258
|
-
}
|
|
259
|
-
const slot = slots.find((candidate) => candidate.action === action);
|
|
260
|
-
if (slot === undefined) {
|
|
261
|
-
throw new Error(`Anonymous action completion selected unavailable action ${action}`);
|
|
262
|
-
}
|
|
263
|
-
if (offeredLeaseTurns === undefined) return { action, candidate: slot.candidate };
|
|
264
|
-
const turns = record["turns"];
|
|
265
|
-
if (typeof turns !== "number" || !offeredLeaseTurns.includes(turns)) {
|
|
266
|
-
throw new Error(
|
|
267
|
-
`Anonymous action completion turns must be one of ${offeredLeaseTurns.join(", ")}`,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
return { action, candidate: slot.candidate, turns };
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Fisher-Yates copy, drawing from the end, matching d3-array's shuffler so an
|
|
274
|
-
// injected rng reproduces permutations recorded by harnesses that used d3.
|
|
275
|
-
function shuffled<T>(items: readonly T[], rng: Rng): T[] {
|
|
276
|
-
const array = [...items];
|
|
277
|
-
let m = array.length;
|
|
278
|
-
while (m) {
|
|
279
|
-
const i = (rng() * m--) | 0;
|
|
280
|
-
const t = array[m]!;
|
|
281
|
-
array[m] = array[i]!;
|
|
282
|
-
array[i] = t;
|
|
283
|
-
}
|
|
284
|
-
return array;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function assertSelectorCandidates(
|
|
288
|
-
value: unknown,
|
|
289
|
-
byCandidate: ReadonlyMap<string, string>,
|
|
290
|
-
): void {
|
|
291
|
-
if (!Array.isArray(value)) {
|
|
292
|
-
throw new Error("Selector input candidate_pairs must be an array");
|
|
293
|
-
}
|
|
294
|
-
const selectorKeys = new Set(
|
|
295
|
-
value.map((candidate, index) => {
|
|
296
|
-
const record = jsonRecord(candidate, `selector candidate[${index}]`);
|
|
297
|
-
return candidateKey(
|
|
298
|
-
requiredString(record["model"], `selector candidate[${index}].model`),
|
|
299
|
-
requiredString(
|
|
300
|
-
record["thinking_level"],
|
|
301
|
-
`selector candidate[${index}].thinking_level`,
|
|
302
|
-
),
|
|
303
|
-
);
|
|
304
|
-
}),
|
|
305
|
-
);
|
|
306
|
-
if (selectorKeys.size !== byCandidate.size || [...byCandidate.keys()].some((key) => !selectorKeys.has(key))) {
|
|
307
|
-
throw new Error("Routing and selector candidates do not match");
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function anonymizeEvals(
|
|
312
|
-
value: unknown,
|
|
313
|
-
byCandidate: ReadonlyMap<string, string>,
|
|
314
|
-
): AnonymousEvalCard[] {
|
|
315
|
-
if (!Array.isArray(value)) {
|
|
316
|
-
throw new Error("Selector input imported_evals must be an array");
|
|
317
|
-
}
|
|
318
|
-
return value.map((entry, evalIndex) => {
|
|
319
|
-
const card = jsonRecord(entry, `selector eval[${evalIndex}]`);
|
|
320
|
-
const scores = card["scores"];
|
|
321
|
-
if (!Array.isArray(scores)) {
|
|
322
|
-
throw new Error(`selector eval[${evalIndex}].scores must be an array`);
|
|
323
|
-
}
|
|
324
|
-
return {
|
|
325
|
-
name: requiredString(card["name"], `selector eval[${evalIndex}].name`),
|
|
326
|
-
description: typeof card["description"] === "string" ? card["description"] : null,
|
|
327
|
-
// Explicit allowlist: notes and any future field can name the model.
|
|
328
|
-
scores: scores.map((entryScore, scoreIndex) => {
|
|
329
|
-
const label = `selector eval[${evalIndex}].scores[${scoreIndex}]`;
|
|
330
|
-
const score = jsonRecord(entryScore, label);
|
|
331
|
-
return {
|
|
332
|
-
action: actionForCandidate(
|
|
333
|
-
byCandidate,
|
|
334
|
-
requiredString(score["model_id"], `${label}.model_id`),
|
|
335
|
-
requiredString(score["thinking_level"], `${label}.thinking_level`),
|
|
336
|
-
),
|
|
337
|
-
rank: requiredNumber(score["rank"], `${label}.rank`),
|
|
338
|
-
rank_total: requiredNumber(score["rank_total"], `${label}.rank_total`),
|
|
339
|
-
z_score: requiredNumber(score["z_score"], `${label}.z_score`),
|
|
340
|
-
};
|
|
341
|
-
}),
|
|
342
|
-
};
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function anonymizePreviousDecision(
|
|
347
|
-
value: unknown,
|
|
348
|
-
byCandidate: ReadonlyMap<string, string>,
|
|
349
|
-
): { action: string } | null {
|
|
350
|
-
if (value === null || value === undefined) return null;
|
|
351
|
-
const previous = jsonRecord(value, "selector previous_decision");
|
|
352
|
-
const model = requiredString(previous["model"], "selector previous_decision.model");
|
|
353
|
-
const thinkingLevel = requiredString(
|
|
354
|
-
previous["thinking_level"],
|
|
355
|
-
"selector previous_decision.thinking_level",
|
|
356
|
-
);
|
|
357
|
-
return {
|
|
358
|
-
action: actionForCandidate(byCandidate, model, thinkingLevel),
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function anonymizeCosts(
|
|
363
|
-
value: unknown,
|
|
364
|
-
byCandidate: ReadonlyMap<string, string>,
|
|
365
|
-
): AnonymousActionCost[] {
|
|
366
|
-
if (value === null || value === undefined) return [];
|
|
367
|
-
if (!Array.isArray(value)) {
|
|
368
|
-
throw new Error("Selector input cost_estimates must be an array or null");
|
|
369
|
-
}
|
|
370
|
-
// Only the loop projections survive. The per-request fields the prompt does
|
|
371
|
-
// not show would be dead weight, and assumed_reasoning_effort names the
|
|
372
|
-
// candidate's thinking level, which the protocol exists to hide.
|
|
373
|
-
return value.flatMap((entry, index) => {
|
|
374
|
-
const label = `selector cost_estimates[${index}]`;
|
|
375
|
-
const cost = jsonRecord(entry, label);
|
|
376
|
-
const action = actionForCandidate(
|
|
377
|
-
byCandidate,
|
|
378
|
-
requiredString(cost["model"], `${label}.model`),
|
|
379
|
-
requiredString(cost["reasoning_effort"], `${label}.reasoning_effort`),
|
|
380
|
-
);
|
|
381
|
-
const fixedTurn = cost["fixed_turn_cost_estimate"];
|
|
382
|
-
if (fixedTurn === null || fixedTurn === undefined) return [];
|
|
383
|
-
if (!isRecordValue(fixedTurn) || !Array.isArray(fixedTurn["projections"])) {
|
|
384
|
-
// A present estimate without projections is the pre-projection schema.
|
|
385
|
-
// Reject it loudly: silently dropping it would show "Cost: unavailable"
|
|
386
|
-
// for a candidate whose cost the caller thinks it supplied.
|
|
387
|
-
throw new Error(
|
|
388
|
-
`${label}.fixed_turn_cost_estimate must carry a projections array`,
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
return [
|
|
392
|
-
{
|
|
393
|
-
action,
|
|
394
|
-
projections: fixedTurn["projections"].map((projection, projectionIndex) => {
|
|
395
|
-
const projectionLabel = `${label}.fixed_turn_cost_estimate.projections[${projectionIndex}]`;
|
|
396
|
-
const record = jsonRecord(projection, projectionLabel);
|
|
397
|
-
return {
|
|
398
|
-
projected_turns: requiredNumber(
|
|
399
|
-
record["projected_turns"],
|
|
400
|
-
`${projectionLabel}.projected_turns`,
|
|
401
|
-
),
|
|
402
|
-
total_cost_usd: requiredNumber(
|
|
403
|
-
record["total_cost_usd"],
|
|
404
|
-
`${projectionLabel}.total_cost_usd`,
|
|
405
|
-
),
|
|
406
|
-
};
|
|
407
|
-
}),
|
|
408
|
-
},
|
|
409
|
-
];
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function requiredNumber(value: JsonValue | undefined, label: string): number {
|
|
414
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
415
|
-
throw new Error(`${label} must be a finite number`);
|
|
416
|
-
}
|
|
417
|
-
return value;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function actionForCandidate(
|
|
421
|
-
byCandidate: ReadonlyMap<string, string>,
|
|
422
|
-
model: string,
|
|
423
|
-
thinkingLevel: string,
|
|
424
|
-
): string {
|
|
425
|
-
const action = byCandidate.get(candidateKey(model, thinkingLevel));
|
|
426
|
-
if (action === undefined) {
|
|
427
|
-
throw new Error(`Selector field references unavailable candidate ${model}/${thinkingLevel}`);
|
|
428
|
-
}
|
|
429
|
-
return action;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function candidateKey(model: string, thinkingLevel: string): string {
|
|
433
|
-
return `${model}\u0000${thinkingLevel}`;
|
|
434
|
-
}
|
package/src/auto_router.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
// Dari Auto Router — a hosted routing policy for self-hosted createRouter
|
|
2
|
-
// users. Dari bills the calling org per selection call.
|
|
3
|
-
|
|
4
|
-
import { RouterFrameworkError } from "./framework_error.js";
|
|
5
|
-
import { createDariRoutingPolicy, type DariRoutingPolicyOptions } from "./dari_policy.js";
|
|
6
|
-
import type { RoutingPolicy } from "./framework_types.js";
|
|
7
|
-
|
|
8
|
-
export const DEFAULT_AUTO_ROUTER_ENDPOINT = "https://routing.dari.dev/v1/auto-router";
|
|
9
|
-
export const DEFAULT_AUTO_ROUTER_MODEL = "dari/auto-router";
|
|
10
|
-
export const DEFAULT_AUTO_ROUTER_CONTEXT_WINDOW_CHARS = 32_000;
|
|
11
|
-
|
|
12
|
-
export type CreateAutoRouterOptions<Metadata = unknown> = {
|
|
13
|
-
apiKey: string;
|
|
14
|
-
endpoint?: string;
|
|
15
|
-
} & Omit<
|
|
16
|
-
DariRoutingPolicyOptions<Metadata>,
|
|
17
|
-
| "selector"
|
|
18
|
-
| "runtime"
|
|
19
|
-
| "selectorModel"
|
|
20
|
-
| "selectorContextWindowChars"
|
|
21
|
-
| "strategy"
|
|
22
|
-
| "customConfig"
|
|
23
|
-
>;
|
|
24
|
-
|
|
25
|
-
export function createAutoRouter<Metadata = unknown>(
|
|
26
|
-
options: CreateAutoRouterOptions<Metadata>,
|
|
27
|
-
): RoutingPolicy<Metadata> {
|
|
28
|
-
if (!options.apiKey?.trim()) {
|
|
29
|
-
throw new RouterFrameworkError(
|
|
30
|
-
"configuration",
|
|
31
|
-
"createAutoRouter requires an apiKey.",
|
|
32
|
-
"auto_router_api_key_missing",
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
// The hosted Auto Router serves only the default routing policy and drops
|
|
36
|
-
// custom rules server-side, so accepting them here would silently ignore
|
|
37
|
-
// them. Reject loudly instead of routing with rules the user thinks apply.
|
|
38
|
-
const unsupported = options as { strategy?: unknown; customConfig?: unknown };
|
|
39
|
-
if (unsupported.strategy !== undefined || unsupported.customConfig !== undefined) {
|
|
40
|
-
throw new RouterFrameworkError(
|
|
41
|
-
"configuration",
|
|
42
|
-
"createAutoRouter does not support custom routing rules (strategy/customConfig): " +
|
|
43
|
-
"the hosted Dari Auto Router serves only the default policy. Use " +
|
|
44
|
-
"createDariRoutingPolicy with a self-hosted selector or the Dari managed " +
|
|
45
|
-
"platform for custom routing.",
|
|
46
|
-
"auto_router_custom_rules_unsupported",
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
const endpoint = (options.endpoint ?? DEFAULT_AUTO_ROUTER_ENDPOINT)
|
|
50
|
-
.replace(/\/+$/, "")
|
|
51
|
-
.replace(/\/select$/, "");
|
|
52
|
-
const {
|
|
53
|
-
apiKey,
|
|
54
|
-
endpoint: _endpoint,
|
|
55
|
-
...policyOptions
|
|
56
|
-
} = options;
|
|
57
|
-
|
|
58
|
-
return createDariRoutingPolicy<Metadata>({
|
|
59
|
-
...policyOptions,
|
|
60
|
-
selectorModel: DEFAULT_AUTO_ROUTER_MODEL,
|
|
61
|
-
selectorContextWindowChars: DEFAULT_AUTO_ROUTER_CONTEXT_WINDOW_CHARS,
|
|
62
|
-
selector: async (request, signal) => {
|
|
63
|
-
let response: Response;
|
|
64
|
-
try {
|
|
65
|
-
response = await fetch(`${endpoint}/select`, {
|
|
66
|
-
method: "POST",
|
|
67
|
-
headers: {
|
|
68
|
-
Authorization: `Bearer ${apiKey}`,
|
|
69
|
-
"Content-Type": "application/json",
|
|
70
|
-
},
|
|
71
|
-
body: JSON.stringify(request),
|
|
72
|
-
signal,
|
|
73
|
-
});
|
|
74
|
-
} catch (error) {
|
|
75
|
-
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
76
|
-
throw new RouterFrameworkError(
|
|
77
|
-
"policy",
|
|
78
|
-
`Dari Auto Router request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
79
|
-
"auto_router_request_failed",
|
|
80
|
-
undefined,
|
|
81
|
-
{ cause: error },
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
if (!response.ok) {
|
|
85
|
-
const body = await response.text().catch(() => "");
|
|
86
|
-
throw new RouterFrameworkError(
|
|
87
|
-
"policy",
|
|
88
|
-
`Dari Auto Router returned HTTP ${response.status}: ${body}`,
|
|
89
|
-
"auto_router_http_error",
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
return await response.text();
|
|
93
|
-
},
|
|
94
|
-
});
|
|
95
|
-
}
|
package/src/cache_behavior.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { nativeModelId, providerForModel } from "./model_ids.js";
|
|
2
|
-
import type { RouterPrefixHit } from "./types.js";
|
|
3
|
-
|
|
4
|
-
// Provider prompt caches stop serving a prefix roughly five minutes after it
|
|
5
|
-
// was last written. Stored prefix entries outlive that: a host's lookback
|
|
6
|
-
// keeps them long enough to still name the conversation and the decision it
|
|
7
|
-
// continued from. Only entries inside this window describe warmth.
|
|
8
|
-
export const PREFIX_WARMTH_WINDOW_MS = 5 * 60 * 1000;
|
|
9
|
-
|
|
10
|
-
// Anthropic checks 20 positions per breakpoint, counting the breakpoint
|
|
11
|
-
// itself as the first, so an old cached breakpoint hits only when at most
|
|
12
|
-
// 19 content blocks were appended after it. These are routing estimates, never
|
|
13
|
-
// billing.
|
|
14
|
-
export const ANTHROPIC_LOOKBACK_BLOCKS = 19;
|
|
15
|
-
export const CROSS_TOKENIZER_RATIO = 1.1;
|
|
16
|
-
const OPENAI_MIN_CACHE_TOKENS = 1024;
|
|
17
|
-
const OPENAI_CACHE_INCREMENT_TOKENS = 128;
|
|
18
|
-
|
|
19
|
-
export type PromptCacheProvider = "openai" | "anthropic" | "fireworks" | "meta";
|
|
20
|
-
|
|
21
|
-
// providerForModel() lowercases the provider prefix, so family checks must
|
|
22
|
-
// compare case-insensitively too. Unlike providerForModel this never throws;
|
|
23
|
-
// malformed ids classify as no family.
|
|
24
|
-
function providerPrefix(model: string): string {
|
|
25
|
-
const slash = model.indexOf("/");
|
|
26
|
-
return slash <= 0 ? "" : model.slice(0, slash).toLowerCase();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function isAnthropicFamily(model: string): boolean {
|
|
30
|
-
return providerPrefix(model) === "anthropic";
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function isOpenAiFamily(model: string): boolean {
|
|
34
|
-
return providerPrefix(model) === "openai";
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function isFireworksFamily(model: string): boolean {
|
|
38
|
-
return providerPrefix(model) === "fireworks";
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isMetaFamily(model: string): boolean {
|
|
42
|
-
return providerPrefix(model) === "meta";
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function promptCacheProviderForModel(model: string): PromptCacheProvider | null {
|
|
46
|
-
if (isOpenAiFamily(model)) return "openai";
|
|
47
|
-
if (isAnthropicFamily(model)) return "anthropic";
|
|
48
|
-
if (isFireworksFamily(model)) return "fireworks";
|
|
49
|
-
if (isMetaFamily(model)) return "meta";
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function openAiCachedPrefixTokens(promptTokens: number): number {
|
|
54
|
-
if (promptTokens < OPENAI_MIN_CACHE_TOKENS) return 0;
|
|
55
|
-
const extra = promptTokens - OPENAI_MIN_CACHE_TOKENS;
|
|
56
|
-
return (
|
|
57
|
-
OPENAI_MIN_CACHE_TOKENS +
|
|
58
|
-
Math.floor(extra / OPENAI_CACHE_INCREMENT_TOKENS) * OPENAI_CACHE_INCREMENT_TOKENS
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Anthropic cache minimums vary by model family. Keep this routing-side
|
|
63
|
-
// estimate conservative for known higher-minimum models; unknown future
|
|
64
|
-
// direct Anthropic models fall back to the common 1024-token threshold.
|
|
65
|
-
export function anthropicMinCacheTokens(model: string): number {
|
|
66
|
-
if (!isAnthropicFamily(model)) return OPENAI_MIN_CACHE_TOKENS;
|
|
67
|
-
const native = nativeModelId(model)
|
|
68
|
-
.toLowerCase()
|
|
69
|
-
.replace(/(?<=\d)\.(?=\d)/g, "-");
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
native.includes("opus-4-6") ||
|
|
73
|
-
native.includes("opus-4-5") ||
|
|
74
|
-
native.includes("haiku-4-5")
|
|
75
|
-
) {
|
|
76
|
-
return 4096;
|
|
77
|
-
}
|
|
78
|
-
if (native.includes("fable-5")) {
|
|
79
|
-
return 512;
|
|
80
|
-
}
|
|
81
|
-
return OPENAI_MIN_CACHE_TOKENS;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function tokenizerFamily(model: string): string {
|
|
85
|
-
const provider = providerForModel(model);
|
|
86
|
-
if (provider === "fireworks") {
|
|
87
|
-
const native = nativeModelId(model).toLowerCase();
|
|
88
|
-
const accountPrefix = "accounts/fireworks/models/";
|
|
89
|
-
const modelFamily = native.startsWith(accountPrefix)
|
|
90
|
-
? native.slice(accountPrefix.length)
|
|
91
|
-
: native;
|
|
92
|
-
const slash = modelFamily.indexOf("/");
|
|
93
|
-
return `${provider}/${slash === -1 ? modelFamily : modelFamily.slice(0, slash)}`;
|
|
94
|
-
}
|
|
95
|
-
return provider;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function providerMinCacheTokens(model: string): number {
|
|
99
|
-
if (isAnthropicFamily(model)) return anthropicMinCacheTokens(model);
|
|
100
|
-
if (isFireworksFamily(model) || isMetaFamily(model)) return 0;
|
|
101
|
-
return OPENAI_MIN_CACHE_TOKENS;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// The stored entries a provider cache can still be re-reading at nowMs. This
|
|
105
|
-
// is the definition of "warm", and every cost estimate rests on it, so an
|
|
106
|
-
// unparseable timestamp reads as cold: warmth is proven, never assumed.
|
|
107
|
-
// nowMs is supplied by the caller because this package reads no clock.
|
|
108
|
-
export function warmPrefixHits(
|
|
109
|
-
hits: readonly RouterPrefixHit[] | undefined,
|
|
110
|
-
nowMs: number
|
|
111
|
-
): RouterPrefixHit[] {
|
|
112
|
-
const cutoffMs = nowMs - PREFIX_WARMTH_WINDOW_MS;
|
|
113
|
-
return (hits ?? []).filter((hit) => {
|
|
114
|
-
const updatedAtMs = Date.parse(hit.updated_at);
|
|
115
|
-
return Number.isFinite(updatedAtMs) && updatedAtMs >= cutoffMs;
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function providerCacheableTokens(
|
|
120
|
-
model: string,
|
|
121
|
-
provider: PromptCacheProvider,
|
|
122
|
-
prefixTokens: number
|
|
123
|
-
): number {
|
|
124
|
-
if (prefixTokens <= 0) return 0;
|
|
125
|
-
if (provider === "openai") return openAiCachedPrefixTokens(prefixTokens);
|
|
126
|
-
if (provider === "anthropic") {
|
|
127
|
-
return prefixTokens >= anthropicMinCacheTokens(model) ? Math.floor(prefixTokens) : 0;
|
|
128
|
-
}
|
|
129
|
-
return Math.floor(prefixTokens);
|
|
130
|
-
}
|