@mupt-ai/dari-router 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +89 -60
  2. package/dist/anonymous_actions.js +32 -10
  3. package/dist/anonymous_actions.js.map +1 -1
  4. package/dist/auto_router.d.ts +4 -1
  5. package/dist/auto_router.js +3 -3
  6. package/dist/auto_router.js.map +1 -1
  7. package/dist/cache_behavior.d.ts +8 -6
  8. package/dist/cache_behavior.js +60 -30
  9. package/dist/cache_behavior.js.map +1 -1
  10. package/dist/cache_scope.d.ts +2 -2
  11. package/dist/cache_scope.js +23 -18
  12. package/dist/cache_scope.js.map +1 -1
  13. package/dist/cost.d.ts +3 -2
  14. package/dist/cost.js +32 -23
  15. package/dist/cost.js.map +1 -1
  16. package/dist/dari_policy.d.ts +8 -2
  17. package/dist/dari_policy.js +39 -1
  18. package/dist/dari_policy.js.map +1 -1
  19. package/dist/eval_score_imputation.d.ts +26 -0
  20. package/dist/eval_score_imputation.js +121 -0
  21. package/dist/eval_score_imputation.js.map +1 -0
  22. package/dist/framework_lifecycle.d.ts +1 -1
  23. package/dist/framework_lifecycle.js +4 -1
  24. package/dist/framework_lifecycle.js.map +1 -1
  25. package/dist/framework_models.js +6 -0
  26. package/dist/framework_models.js.map +1 -1
  27. package/dist/framework_router.js +22 -11
  28. package/dist/framework_router.js.map +1 -1
  29. package/dist/framework_types.d.ts +9 -5
  30. package/dist/index.d.ts +9 -35
  31. package/dist/index.js +6 -37
  32. package/dist/index.js.map +1 -1
  33. package/dist/model_ids.js +4 -0
  34. package/dist/model_ids.js.map +1 -1
  35. package/dist/pi_runtime.js +19 -11
  36. package/dist/pi_runtime.js.map +1 -1
  37. package/dist/pi_types.d.ts +2 -2
  38. package/{src/index.ts → dist/policy-engine.d.ts} +2 -14
  39. package/dist/policy-engine.js +25 -0
  40. package/dist/policy-engine.js.map +1 -0
  41. package/dist/prompts.d.ts +1 -1
  42. package/dist/prompts.js +3 -3
  43. package/dist/prompts.js.map +1 -1
  44. package/dist/protocols.d.ts +5 -0
  45. package/dist/protocols.js +6 -0
  46. package/dist/protocols.js.map +1 -0
  47. package/dist/route.d.ts +3 -4
  48. package/dist/route.js +17 -10
  49. package/dist/route.js.map +1 -1
  50. package/dist/selector_input.d.ts +1 -0
  51. package/dist/selector_input.js +66 -78
  52. package/dist/selector_input.js.map +1 -1
  53. package/dist/selector_parse.d.ts +1 -0
  54. package/dist/selector_parse.js +2 -1
  55. package/dist/selector_parse.js.map +1 -1
  56. package/dist/selector_request.d.ts +1 -0
  57. package/dist/selector_request.js +1 -0
  58. package/dist/selector_request.js.map +1 -1
  59. package/dist/types.d.ts +1 -1
  60. package/examples/auto_router.ts +40 -0
  61. package/examples/basic_route.ts +130 -0
  62. package/examples/create_router.ts +114 -0
  63. package/examples/dari_policy.ts +86 -0
  64. package/examples/lease_stores.ts +159 -0
  65. package/examples/managed/README.md +50 -0
  66. package/examples/managed/byok-router/README.md +13 -0
  67. package/examples/managed/byok-router/router.yml +13 -0
  68. package/examples/managed/custom-rules-router/README.md +12 -0
  69. package/examples/managed/custom-rules-router/router.yml +24 -0
  70. package/examples/managed/eval-router/README.md +18 -0
  71. package/examples/managed/eval-router/router.yml +11 -0
  72. package/examples/managed/slm-router/README.md +12 -0
  73. package/examples/managed/slm-router/router.yml +8 -0
  74. package/examples/pi_runtime.ts +55 -0
  75. package/package.json +26 -7
  76. package/src/anonymous_actions.ts +0 -434
  77. package/src/auto_router.ts +0 -95
  78. package/src/cache_behavior.ts +0 -130
  79. package/src/cache_scope.ts +0 -58
  80. package/src/cache_switch_decision.ts +0 -53
  81. package/src/candidate_pruning.ts +0 -257
  82. package/src/candidate_resolution.ts +0 -208
  83. package/src/compatibility.ts +0 -162
  84. package/src/continuation_state.ts +0 -368
  85. package/src/cost.ts +0 -455
  86. package/src/dari_policy.ts +0 -360
  87. package/src/errors.ts +0 -27
  88. package/src/fingerprint.ts +0 -148
  89. package/src/fixed_turn_cost_config.ts +0 -24
  90. package/src/framework_error.ts +0 -38
  91. package/src/framework_lifecycle.ts +0 -74
  92. package/src/framework_models.ts +0 -186
  93. package/src/framework_router.ts +0 -490
  94. package/src/framework_streaming.ts +0 -613
  95. package/src/framework_types.ts +0 -372
  96. package/src/framework_validation.ts +0 -235
  97. package/src/json.ts +0 -43
  98. package/src/model_ids.ts +0 -29
  99. package/src/pi_context.ts +0 -251
  100. package/src/pi_options.ts +0 -295
  101. package/src/pi_output.ts +0 -387
  102. package/src/pi_reasoning_signature.ts +0 -41
  103. package/src/pi_runtime.ts +0 -238
  104. package/src/pi_types.ts +0 -71
  105. package/src/portable_reasoning_state.ts +0 -129
  106. package/src/previous_decision.ts +0 -109
  107. package/src/prompts.ts +0 -69
  108. package/src/protocol_anthropic.ts +0 -571
  109. package/src/protocol_openai_chat.ts +0 -645
  110. package/src/protocol_validation.ts +0 -202
  111. package/src/route.ts +0 -490
  112. package/src/routing_history.ts +0 -157
  113. package/src/selector_input.ts +0 -246
  114. package/src/selector_parse.ts +0 -138
  115. package/src/selector_request.ts +0 -130
  116. package/src/selector_truncation.ts +0 -48
  117. package/src/selector_wire.ts +0 -167
  118. package/src/types.ts +0 -231
@@ -1,58 +0,0 @@
1
- import { canonicalModelId } from "./model_ids.js";
2
-
3
- export type ReasoningCacheScope = "effort_keyed" | "shared";
4
-
5
- // How a model's provider prompt cache behaves when the reasoning payload
6
- // changes between otherwise identical requests, from live-provider and
7
- // router-path validation:
8
- // effort_keyed - each effective reasoning effort warms its own cache
9
- // partition; switching effort misses/rewrites, switching back
10
- // reuses that effort's still-warm partition.
11
- // shared - one cache partition regardless of reasoning effort.
12
- // OpenAI reasoning models are effort_keyed (a first-ever effort missed a
13
- // steady-warm prefix on every model tested). Anthropic models are effort_keyed
14
- // because pi-ai places cache_control on message blocks, which Anthropic
15
- // invalidates on thinking changes; budget-thinking Haiku would share under a
16
- // system-block breakpoint, so revalidate its entry if pi-ai's Anthropic cache
17
- // placement changes. Fireworks caching ignored reasoning entirely.
18
- // Unlisted models default to effort_keyed: the safe direction is to
19
- // under-claim warmth, never to price a cold prefix as a cache read.
20
- export const DEFAULT_REASONING_CACHE_SCOPES: Readonly<Record<string, ReasoningCacheScope>> = {
21
- "openai/gpt-5.6-sol": "effort_keyed",
22
- "openai/gpt-5.6-terra": "effort_keyed",
23
- "openai/gpt-5.6-luna": "effort_keyed",
24
- "openai/gpt-5.5": "effort_keyed",
25
- "openai/gpt-4.1-mini": "shared", // single reasoning mode; nothing to key on
26
- "anthropic/claude-opus-4-8": "effort_keyed",
27
- "anthropic/claude-sonnet-4-6": "effort_keyed",
28
- "anthropic/claude-haiku-4-5": "effort_keyed", // via pi-ai message-block cache_control; see note above
29
- "fireworks/deepseek-ai/DeepSeek-V4-Pro": "shared",
30
- "fireworks/deepseek-ai/DeepSeek-V4-Flash": "shared",
31
- "fireworks/deepseek-ai/DeepSeek-V4-Flash-0731": "shared",
32
- "fireworks/zai-org/GLM-5.2": "shared",
33
- };
34
-
35
- export type ReasoningCacheScopeLookup = (modelId: string) => ReasoningCacheScope;
36
-
37
- export function reasoningCacheScope(modelId: string): ReasoningCacheScope {
38
- return DEFAULT_REASONING_CACHE_SCOPES[canonicalModelId(modelId)] ?? "effort_keyed";
39
- }
40
-
41
- // The provider cache partition a prefix entry belongs to. An effort-keyed
42
- // provider warms one partition per effective reasoning effort, so the pair
43
- // identifies it; a shared-scope provider holds a single partition per model,
44
- // and keying that by effort would remember several entries describing one
45
- // cache. Hosts that store warmth per partition derive their key here so a
46
- // reclassified provider moves the estimator and their storage together.
47
- // modelId is used verbatim, matching how ChainHits keys warm prefixes: ids that
48
- // differ only in spelling stay separate rather than cross-contaminating. The
49
- // separator cannot appear in a model id or a bucket, so the pair is injective
50
- // and can never collide with a shared-scope key.
51
- export function cachePartitionKey(
52
- modelId: string,
53
- reasoningBucket: string | null | undefined,
54
- cacheScope: ReasoningCacheScopeLookup = reasoningCacheScope
55
- ): string {
56
- if (cacheScope(modelId) === "shared") return modelId;
57
- return `${modelId}\u0000${reasoningBucket ?? ""}`;
58
- }
@@ -1,53 +0,0 @@
1
- export type WarmIncumbentCost = {
2
- fixedTurnCostUsd: number;
3
- outputCostPerMtok: number;
4
- };
5
-
6
- export type SwitchCandidateCost = {
7
- fixedTurnCostUsd: number | null;
8
- outputCostPerMtok: number | null;
9
- };
10
-
11
- export type CacheSwitchDecision =
12
- | {
13
- action: "keep";
14
- reason: "unknown_switch_cost" | "unknown_switch_output_price" | "not_cheaper_output_tier" | "saves_more_than_threshold";
15
- fixedTurnCostUsd?: number;
16
- savingsRatio?: number;
17
- }
18
- | {
19
- action: "prune";
20
- reason: "insufficient_savings";
21
- fixedTurnCostUsd: number;
22
- savingsRatio: number;
23
- };
24
-
25
- export function decideCacheSwitch(args: {
26
- warmIncumbent: WarmIncumbentCost;
27
- switchCandidate: SwitchCandidateCost;
28
- minSwitchSavingsRatio: number;
29
- }): CacheSwitchDecision {
30
- const fixedTurnCostUsd = args.switchCandidate.fixedTurnCostUsd;
31
- if (fixedTurnCostUsd === null) {
32
- return { action: "keep", reason: "unknown_switch_cost" };
33
- }
34
-
35
- const outputCostPerMtok = args.switchCandidate.outputCostPerMtok;
36
- if (outputCostPerMtok === null) {
37
- return { action: "keep", reason: "unknown_switch_output_price" };
38
- }
39
-
40
- // Higher output price is our capability-upgrade guard. The pruning rule is
41
- // only for cheaper-tier switches that look cheap before cache effects.
42
- if (outputCostPerMtok >= args.warmIncumbent.outputCostPerMtok) {
43
- return { action: "keep", reason: "not_cheaper_output_tier", fixedTurnCostUsd };
44
- }
45
-
46
- const savingsRatio =
47
- (args.warmIncumbent.fixedTurnCostUsd - fixedTurnCostUsd) / args.warmIncumbent.fixedTurnCostUsd;
48
- if (savingsRatio > args.minSwitchSavingsRatio) {
49
- return { action: "keep", reason: "saves_more_than_threshold", fixedTurnCostUsd, savingsRatio };
50
- }
51
-
52
- return { action: "prune", reason: "insufficient_savings", fixedTurnCostUsd, savingsRatio };
53
- }
@@ -1,257 +0,0 @@
1
- // Cache-aware pruning for selector-based routing strategies. When the previous
2
- // model/thinking-level pair is still warm, a nominally cheaper pair can be a
3
- // fake-cheap switch: the cold start erases most of the sticker-price saving.
4
-
5
- import {
6
- decideCacheSwitch,
7
- type CacheSwitchDecision,
8
- } from "./cache_switch_decision.js";
9
- import { turnCostUsdAt } from "./cost.js";
10
- import { FIXED_TURN_COST_COMPARISON_TURNS } from "./fixed_turn_cost_config.js";
11
- import {
12
- REASONING_EFFORTS,
13
- routingCandidateKey,
14
- type CandidateCostEstimate,
15
- type PreviousDecision,
16
- type ReasoningEffort,
17
- type RoutingCandidate,
18
- } from "./types.js";
19
-
20
- export const MIN_SWITCH_SAVINGS_RATIO = 0.1;
21
-
22
- type PrunedCandidate = {
23
- model: string;
24
- reasoning_effort: ReasoningEffort;
25
- fixed_turn_cost_usd: number;
26
- savings_ratio: number;
27
- };
28
-
29
- type CandidateSwitchKeepDecision = {
30
- action: "keep";
31
- reason:
32
- | Extract<CacheSwitchDecision, { action: "keep" }>["reason"]
33
- | "same_model_upgrade";
34
- fixedTurnCostUsd?: number;
35
- savingsRatio?: number;
36
- };
37
-
38
- type CandidateSwitchDecision =
39
- | CandidateSwitchKeepDecision
40
- | Extract<CacheSwitchDecision, { action: "prune" }>;
41
-
42
- type KeptReason = "incumbent" | CandidateSwitchKeepDecision["reason"];
43
-
44
- type KeptCandidate = {
45
- model: string;
46
- reasoning_effort: ReasoningEffort;
47
- reason: KeptReason;
48
- savings_ratio?: number;
49
- };
50
-
51
- export type CandidatePruningAudit = {
52
- min_switch_savings_ratio: number;
53
- incumbent_model: string | null;
54
- incumbent_reasoning_effort: ReasoningEffort | null;
55
- incumbent_fixed_turn_cost_usd: number | null;
56
- skip_reason:
57
- | "no_previous_decision"
58
- | "incumbent_not_warm"
59
- | "incumbent_unpriced"
60
- | null;
61
- pruned_candidates: PrunedCandidate[];
62
- kept_candidates: KeptCandidate[];
63
- };
64
-
65
- export type CandidatePruning = {
66
- candidates: RoutingCandidate[];
67
- costEstimates: CandidateCostEstimate[];
68
- audit: CandidatePruningAudit;
69
- };
70
-
71
- export function pruneSwitchCandidates(args: {
72
- candidates: RoutingCandidate[];
73
- costEstimates: CandidateCostEstimate[];
74
- previousDecision?: PreviousDecision;
75
- minSwitchSavingsRatio: number;
76
- }): CandidatePruning {
77
- const incumbentCandidate = args.previousDecision
78
- ? {
79
- model: args.previousDecision.model,
80
- reasoningEffort: args.previousDecision.reasoningEffort,
81
- }
82
- : null;
83
- const candidateKeys = new Set(args.candidates.map(routingCandidateKey));
84
- const incumbentKey = incumbentCandidate
85
- ? routingCandidateKey(incumbentCandidate)
86
- : null;
87
- const estimateByKey = new Map(
88
- args.costEstimates.map((estimate) => [
89
- routingCandidateKey({
90
- model: estimate.model,
91
- reasoningEffort: estimate.reasoning_effort,
92
- }),
93
- estimate,
94
- ]),
95
- );
96
- const incumbent = incumbentKey && candidateKeys.has(incumbentKey)
97
- ? estimateByKey.get(incumbentKey)
98
- : undefined;
99
- if (!incumbent || !incumbentCandidate) {
100
- return keepAll(args, incumbentCandidate, "no_previous_decision");
101
- }
102
- if (incumbent.warm_tokens <= 0) {
103
- return keepAll(args, incumbentCandidate, "incumbent_not_warm");
104
- }
105
- const incumbentCost = turnCostUsdAt(
106
- incumbent.fixed_turn_cost_estimate,
107
- FIXED_TURN_COST_COMPARISON_TURNS,
108
- );
109
- const incumbentOutputPrice = incumbent.output_cost_per_mtok;
110
- if (
111
- incumbentCost === null ||
112
- incumbentCost <= 0 ||
113
- incumbentOutputPrice === null
114
- ) {
115
- return keepAll(args, incumbentCandidate, "incumbent_unpriced");
116
- }
117
-
118
- const prunedCandidates: PrunedCandidate[] = [];
119
- const keptCandidates: KeptCandidate[] = [];
120
- const survivors = args.candidates.filter((candidate) => {
121
- const key = routingCandidateKey(candidate);
122
- if (key === incumbentKey) {
123
- keptCandidates.push({
124
- model: candidate.model,
125
- reasoning_effort: candidate.reasoningEffort,
126
- reason: "incumbent",
127
- });
128
- return true;
129
- }
130
- const decision = candidateSwitchDecision({
131
- candidate,
132
- estimate: estimateByKey.get(key),
133
- incumbent: incumbentCandidate,
134
- incumbentCost,
135
- incumbentOutputPrice,
136
- minSwitchSavingsRatio: args.minSwitchSavingsRatio,
137
- });
138
- if (decision.action === "keep") {
139
- keptCandidates.push({
140
- model: candidate.model,
141
- reasoning_effort: candidate.reasoningEffort,
142
- reason: decision.reason,
143
- ...(decision.savingsRatio === undefined
144
- ? {}
145
- : { savings_ratio: decision.savingsRatio }),
146
- });
147
- return true;
148
- }
149
- prunedCandidates.push({
150
- model: candidate.model,
151
- reasoning_effort: candidate.reasoningEffort,
152
- fixed_turn_cost_usd: decision.fixedTurnCostUsd,
153
- savings_ratio: decision.savingsRatio,
154
- });
155
- return false;
156
- });
157
- const survivorKeys = new Set(survivors.map(routingCandidateKey));
158
-
159
- return {
160
- candidates: survivors,
161
- costEstimates: args.costEstimates.filter((estimate) =>
162
- survivorKeys.has(
163
- routingCandidateKey({
164
- model: estimate.model,
165
- reasoningEffort: estimate.reasoning_effort,
166
- }),
167
- )
168
- ),
169
- audit: {
170
- min_switch_savings_ratio: args.minSwitchSavingsRatio,
171
- incumbent_model: incumbentCandidate.model,
172
- incumbent_reasoning_effort: incumbentCandidate.reasoningEffort,
173
- incumbent_fixed_turn_cost_usd: incumbentCost,
174
- skip_reason: null,
175
- pruned_candidates: prunedCandidates,
176
- kept_candidates: keptCandidates,
177
- },
178
- };
179
- }
180
-
181
- function candidateSwitchDecision(args: {
182
- candidate: RoutingCandidate;
183
- estimate?: CandidateCostEstimate;
184
- incumbent: RoutingCandidate;
185
- incumbentCost: number;
186
- incumbentOutputPrice: number;
187
- minSwitchSavingsRatio: number;
188
- }): CandidateSwitchDecision {
189
- const candidateCost = turnCostUsdAt(
190
- args.estimate?.fixed_turn_cost_estimate,
191
- FIXED_TURN_COST_COMPARISON_TURNS,
192
- );
193
- if (args.candidate.model !== args.incumbent.model) {
194
- return decideCacheSwitch({
195
- warmIncumbent: {
196
- fixedTurnCostUsd: args.incumbentCost,
197
- outputCostPerMtok: args.incumbentOutputPrice,
198
- },
199
- switchCandidate: {
200
- fixedTurnCostUsd: candidateCost,
201
- outputCostPerMtok: args.estimate?.output_cost_per_mtok ?? null,
202
- },
203
- minSwitchSavingsRatio: args.minSwitchSavingsRatio,
204
- });
205
- }
206
-
207
- // Output price cannot distinguish thinking levels on one model, so preserve
208
- // capability upgrades and judge lower-effort switches by projected cost.
209
- if (
210
- REASONING_EFFORTS.indexOf(args.candidate.reasoningEffort) >=
211
- REASONING_EFFORTS.indexOf(args.incumbent.reasoningEffort)
212
- ) {
213
- return { action: "keep", reason: "same_model_upgrade" };
214
- }
215
- if (candidateCost === null) {
216
- return { action: "keep", reason: "unknown_switch_cost" };
217
- }
218
- const savingsRatio =
219
- (args.incumbentCost - candidateCost) / args.incumbentCost;
220
- return savingsRatio > args.minSwitchSavingsRatio
221
- ? {
222
- action: "keep",
223
- reason: "saves_more_than_threshold",
224
- fixedTurnCostUsd: candidateCost,
225
- savingsRatio,
226
- }
227
- : {
228
- action: "prune",
229
- reason: "insufficient_savings",
230
- fixedTurnCostUsd: candidateCost,
231
- savingsRatio,
232
- };
233
- }
234
-
235
- function keepAll(
236
- args: {
237
- candidates: RoutingCandidate[];
238
- costEstimates: CandidateCostEstimate[];
239
- minSwitchSavingsRatio: number;
240
- },
241
- incumbent: RoutingCandidate | null,
242
- skipReason: NonNullable<CandidatePruningAudit["skip_reason"]>,
243
- ): CandidatePruning {
244
- return {
245
- candidates: args.candidates,
246
- costEstimates: args.costEstimates,
247
- audit: {
248
- min_switch_savings_ratio: args.minSwitchSavingsRatio,
249
- incumbent_model: incumbent?.model ?? null,
250
- incumbent_reasoning_effort: incumbent?.reasoningEffort ?? null,
251
- incumbent_fixed_turn_cost_usd: null,
252
- skip_reason: skipReason,
253
- pruned_candidates: [],
254
- kept_candidates: [],
255
- },
256
- };
257
- }
@@ -1,208 +0,0 @@
1
- import {
2
- MIN_SWITCH_SAVINGS_RATIO,
3
- pruneSwitchCandidates,
4
- type CandidatePruningAudit,
5
- } from "./candidate_pruning.js";
6
- import {
7
- routingCandidateKey,
8
- type CandidateCostEstimate,
9
- type CustomRouterConfig,
10
- type CustomRouterRule,
11
- type PreviousDecision,
12
- type ReasoningEffort,
13
- type RouterModelPrice,
14
- type RoutingCandidate,
15
- type RoutingStrategy,
16
- } from "./types.js";
17
-
18
- export type CustomDefaultTarget = {
19
- model: string;
20
- thinkingLevel: ReasoningEffort | null;
21
- };
22
-
23
- type CandidateSet = {
24
- candidates: RoutingCandidate[];
25
- costEstimates: CandidateCostEstimate[];
26
- };
27
-
28
- type CustomDetails = {
29
- rules: CustomRouterRule[];
30
- defaultTarget: CustomDefaultTarget | null;
31
- audit: {
32
- dropped_rules: CustomRouterRule[];
33
- fallback_all_candidates: boolean;
34
- no_routable_rule_models: boolean;
35
- };
36
- };
37
-
38
- export type StrategyCandidateResolution = CandidateSet & (
39
- | { strategy: "slm"; pruning: CandidatePruningAudit }
40
- | { strategy: "custom"; pruning: null; custom: null }
41
- | {
42
- strategy: "custom";
43
- pruning: CandidatePruningAudit;
44
- custom: CustomDetails;
45
- }
46
- );
47
-
48
- type CustomCandidateResolution = Extract<
49
- StrategyCandidateResolution,
50
- { strategy: "custom"; custom: CustomDetails }
51
- >;
52
-
53
- type CandidateResolutionArgs = {
54
- strategy: RoutingStrategy;
55
- candidates: RoutingCandidate[];
56
- costEstimates: CandidateCostEstimate[];
57
- previousDecision?: PreviousDecision;
58
- customConfig?: CustomRouterConfig | null;
59
- modelPrices: Record<string, RouterModelPrice>;
60
- };
61
-
62
- export function resolveStrategyCandidates(
63
- args: CandidateResolutionArgs,
64
- ): StrategyCandidateResolution {
65
- if (args.strategy === "slm") {
66
- const pruned = pruneSwitchCandidates({
67
- candidates: args.candidates,
68
- costEstimates: args.costEstimates,
69
- previousDecision: args.previousDecision,
70
- minSwitchSavingsRatio: MIN_SWITCH_SAVINGS_RATIO,
71
- });
72
- return {
73
- strategy: "slm",
74
- candidates: pruned.candidates,
75
- costEstimates: pruned.costEstimates,
76
- pruning: pruned.audit,
77
- };
78
- }
79
-
80
- const config = args.customConfig;
81
- if (!config || !Array.isArray(config.rules) || config.rules.length === 0) {
82
- return {
83
- strategy: "custom",
84
- candidates: [],
85
- costEstimates: [],
86
- pruning: null,
87
- custom: null,
88
- };
89
- }
90
-
91
- return resolveCustomCandidates(args, config);
92
- }
93
-
94
- function resolveCustomCandidates(
95
- args: CandidateResolutionArgs,
96
- config: CustomRouterConfig,
97
- ): CustomCandidateResolution {
98
- const usableRules = config.rules.filter((rule) =>
99
- args.candidates.some((candidate) => matchesRule(candidate, rule)),
100
- );
101
- const defaultModel = typeof config.default === "string" ? config.default : null;
102
- const defaultTarget =
103
- defaultModel !== null &&
104
- args.candidates.some((candidate) =>
105
- matchesTarget(candidate, defaultModel, config.default_thinking_level),
106
- )
107
- ? {
108
- model: defaultModel,
109
- thinkingLevel: config.default_thinking_level ?? null,
110
- }
111
- : null;
112
- const matchesDefault = (candidate: RoutingCandidate) =>
113
- defaultTarget !== null &&
114
- matchesTarget(candidate, defaultTarget.model, defaultTarget.thinkingLevel);
115
- const configuredCandidates = orderCandidatesByCostDesc(
116
- args.candidates.filter(
117
- (candidate) =>
118
- usableRules.some((rule) => matchesRule(candidate, rule)) ||
119
- matchesDefault(candidate),
120
- ),
121
- args.modelPrices,
122
- );
123
- const pruned = pruneSwitchCandidates({
124
- candidates: configuredCandidates,
125
- costEstimates: reorderEstimates(args.costEstimates, configuredCandidates),
126
- previousDecision: args.previousDecision,
127
- minSwitchSavingsRatio: MIN_SWITCH_SAVINGS_RATIO,
128
- });
129
- const ruleOrder = (rule: CustomRouterRule) =>
130
- pruned.candidates.findIndex((candidate) => matchesRule(candidate, rule));
131
-
132
- return {
133
- strategy: "custom",
134
- candidates: pruned.candidates,
135
- costEstimates: pruned.costEstimates,
136
- pruning: pruned.audit,
137
- custom: {
138
- rules: usableRules
139
- .filter((rule) => ruleOrder(rule) >= 0)
140
- .sort((a, b) => ruleOrder(a) - ruleOrder(b)),
141
- defaultTarget: pruned.candidates.some(matchesDefault)
142
- ? defaultTarget
143
- : null,
144
- audit: {
145
- dropped_rules: config.rules.filter((rule) => !usableRules.includes(rule)),
146
- fallback_all_candidates: false,
147
- no_routable_rule_models: configuredCandidates.length === 0,
148
- },
149
- },
150
- };
151
- }
152
-
153
- function matchesRule(
154
- candidate: RoutingCandidate,
155
- rule: CustomRouterRule,
156
- ): boolean {
157
- return matchesTarget(candidate, rule.use, rule.thinking_level);
158
- }
159
-
160
- function matchesTarget(
161
- candidate: RoutingCandidate,
162
- model: string,
163
- thinkingLevel: ReasoningEffort | null | undefined,
164
- ): boolean {
165
- return candidate.model === model &&
166
- (thinkingLevel == null || candidate.reasoningEffort === thinkingLevel);
167
- }
168
-
169
- // Catalog rates are model-scoped, so sorting remains stable across a model's
170
- // thinking-level variants.
171
- function orderCandidatesByCostDesc(
172
- candidates: RoutingCandidate[],
173
- modelPrices: Record<string, RouterModelPrice>,
174
- ): RoutingCandidate[] {
175
- return [...candidates].sort((a, b) => {
176
- const priceA = usablePrice(modelPrices[a.model]);
177
- const priceB = usablePrice(modelPrices[b.model]);
178
- if (priceA === null && priceB === null) return 0;
179
- if (priceA === null) return 1;
180
- if (priceB === null) return -1;
181
- return priceB.output - priceA.output || priceB.input - priceA.input;
182
- });
183
- }
184
-
185
- function usablePrice(price: RouterModelPrice | undefined): RouterModelPrice | null {
186
- return price && Number.isFinite(price.input) && Number.isFinite(price.output)
187
- ? price
188
- : null;
189
- }
190
-
191
- function reorderEstimates(
192
- estimates: CandidateCostEstimate[],
193
- candidates: RoutingCandidate[],
194
- ): CandidateCostEstimate[] {
195
- const byCandidate = new Map(
196
- estimates.map((estimate) => [
197
- routingCandidateKey({
198
- model: estimate.model,
199
- reasoningEffort: estimate.reasoning_effort,
200
- }),
201
- estimate,
202
- ]),
203
- );
204
- return candidates.flatMap((candidate) => {
205
- const estimate = byCandidate.get(routingCandidateKey(candidate));
206
- return estimate ? [estimate] : [];
207
- });
208
- }