@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.
Files changed (118) hide show
  1. package/README.md +89 -60
  2. package/dist/anonymous_actions.js +32 -10
  3. package/dist/anonymous_actions.js.map +1 -1
  4. package/dist/auto_router.d.ts +4 -1
  5. package/dist/auto_router.js +3 -3
  6. package/dist/auto_router.js.map +1 -1
  7. package/dist/cache_behavior.d.ts +8 -6
  8. package/dist/cache_behavior.js +56 -30
  9. package/dist/cache_behavior.js.map +1 -1
  10. package/dist/cache_scope.d.ts +2 -2
  11. package/dist/cache_scope.js +23 -18
  12. package/dist/cache_scope.js.map +1 -1
  13. package/dist/cost.d.ts +3 -2
  14. package/dist/cost.js +32 -23
  15. package/dist/cost.js.map +1 -1
  16. package/dist/dari_policy.d.ts +8 -2
  17. package/dist/dari_policy.js +39 -1
  18. package/dist/dari_policy.js.map +1 -1
  19. package/dist/eval_score_imputation.d.ts +26 -0
  20. package/dist/eval_score_imputation.js +83 -0
  21. package/dist/eval_score_imputation.js.map +1 -0
  22. package/dist/framework_lifecycle.d.ts +1 -1
  23. package/dist/framework_lifecycle.js +4 -1
  24. package/dist/framework_lifecycle.js.map +1 -1
  25. package/dist/framework_models.js +6 -0
  26. package/dist/framework_models.js.map +1 -1
  27. package/dist/framework_router.js +22 -11
  28. package/dist/framework_router.js.map +1 -1
  29. package/dist/framework_types.d.ts +9 -5
  30. package/dist/index.d.ts +9 -35
  31. package/dist/index.js +6 -37
  32. package/dist/index.js.map +1 -1
  33. package/dist/model_ids.js +4 -0
  34. package/dist/model_ids.js.map +1 -1
  35. package/dist/pi_runtime.js +19 -11
  36. package/dist/pi_runtime.js.map +1 -1
  37. package/dist/pi_types.d.ts +2 -2
  38. package/{src/index.ts → dist/policy-engine.d.ts} +2 -14
  39. package/dist/policy-engine.js +25 -0
  40. package/dist/policy-engine.js.map +1 -0
  41. package/dist/prompts.d.ts +1 -1
  42. package/dist/prompts.js +3 -3
  43. package/dist/prompts.js.map +1 -1
  44. package/dist/protocols.d.ts +5 -0
  45. package/dist/protocols.js +6 -0
  46. package/dist/protocols.js.map +1 -0
  47. package/dist/route.d.ts +3 -4
  48. package/dist/route.js +17 -10
  49. package/dist/route.js.map +1 -1
  50. package/dist/selector_input.d.ts +1 -0
  51. package/dist/selector_input.js +66 -78
  52. package/dist/selector_input.js.map +1 -1
  53. package/dist/selector_parse.d.ts +1 -0
  54. package/dist/selector_parse.js +2 -1
  55. package/dist/selector_parse.js.map +1 -1
  56. package/dist/selector_request.d.ts +1 -0
  57. package/dist/selector_request.js +1 -0
  58. package/dist/selector_request.js.map +1 -1
  59. package/dist/types.d.ts +1 -1
  60. package/examples/auto_router.ts +40 -0
  61. package/examples/basic_route.ts +130 -0
  62. package/examples/create_router.ts +114 -0
  63. package/examples/dari_policy.ts +86 -0
  64. package/examples/lease_stores.ts +159 -0
  65. package/examples/managed/README.md +50 -0
  66. package/examples/managed/byok-router/README.md +13 -0
  67. package/examples/managed/byok-router/router.yml +13 -0
  68. package/examples/managed/custom-rules-router/README.md +12 -0
  69. package/examples/managed/custom-rules-router/router.yml +24 -0
  70. package/examples/managed/eval-router/README.md +18 -0
  71. package/examples/managed/eval-router/router.yml +11 -0
  72. package/examples/managed/slm-router/README.md +12 -0
  73. package/examples/managed/slm-router/router.yml +8 -0
  74. package/examples/pi_runtime.ts +55 -0
  75. package/package.json +26 -7
  76. package/src/anonymous_actions.ts +0 -434
  77. package/src/auto_router.ts +0 -95
  78. package/src/cache_behavior.ts +0 -130
  79. package/src/cache_scope.ts +0 -58
  80. package/src/cache_switch_decision.ts +0 -53
  81. package/src/candidate_pruning.ts +0 -257
  82. package/src/candidate_resolution.ts +0 -208
  83. package/src/compatibility.ts +0 -162
  84. package/src/continuation_state.ts +0 -368
  85. package/src/cost.ts +0 -455
  86. package/src/dari_policy.ts +0 -360
  87. package/src/errors.ts +0 -27
  88. package/src/fingerprint.ts +0 -148
  89. package/src/fixed_turn_cost_config.ts +0 -24
  90. package/src/framework_error.ts +0 -38
  91. package/src/framework_lifecycle.ts +0 -74
  92. package/src/framework_models.ts +0 -186
  93. package/src/framework_router.ts +0 -490
  94. package/src/framework_streaming.ts +0 -613
  95. package/src/framework_types.ts +0 -372
  96. package/src/framework_validation.ts +0 -235
  97. package/src/json.ts +0 -43
  98. package/src/model_ids.ts +0 -29
  99. package/src/pi_context.ts +0 -251
  100. package/src/pi_options.ts +0 -295
  101. package/src/pi_output.ts +0 -387
  102. package/src/pi_reasoning_signature.ts +0 -41
  103. package/src/pi_runtime.ts +0 -238
  104. package/src/pi_types.ts +0 -71
  105. package/src/portable_reasoning_state.ts +0 -129
  106. package/src/previous_decision.ts +0 -109
  107. package/src/prompts.ts +0 -69
  108. package/src/protocol_anthropic.ts +0 -571
  109. package/src/protocol_openai_chat.ts +0 -645
  110. package/src/protocol_validation.ts +0 -202
  111. package/src/route.ts +0 -490
  112. package/src/routing_history.ts +0 -157
  113. package/src/selector_input.ts +0 -246
  114. package/src/selector_parse.ts +0 -138
  115. package/src/selector_request.ts +0 -130
  116. package/src/selector_truncation.ts +0 -48
  117. package/src/selector_wire.ts +0 -167
  118. package/src/types.ts +0 -231
@@ -1,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
- }
@@ -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
- }
@@ -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
- }