@mupt-ai/dari-router 0.1.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 (179) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +100 -0
  3. package/dist/anonymous_actions.d.ts +55 -0
  4. package/dist/anonymous_actions.js +277 -0
  5. package/dist/anonymous_actions.js.map +1 -0
  6. package/dist/auto_router.d.ts +10 -0
  7. package/dist/auto_router.js +56 -0
  8. package/dist/auto_router.js.map +1 -0
  9. package/dist/cache_behavior.d.ts +14 -0
  10. package/dist/cache_behavior.js +112 -0
  11. package/dist/cache_behavior.js.map +1 -0
  12. package/dist/cache_scope.d.ts +5 -0
  13. package/dist/cache_scope.js +49 -0
  14. package/dist/cache_scope.js.map +1 -0
  15. package/dist/cache_switch_decision.d.ts +24 -0
  16. package/dist/cache_switch_decision.js +21 -0
  17. package/dist/cache_switch_decision.js.map +1 -0
  18. package/dist/candidate_pruning.d.ts +45 -0
  19. package/dist/candidate_pruning.js +154 -0
  20. package/dist/candidate_pruning.js.map +1 -0
  21. package/dist/candidate_resolution.d.ts +41 -0
  22. package/dist/candidate_resolution.js +111 -0
  23. package/dist/candidate_resolution.js.map +1 -0
  24. package/dist/compatibility.d.ts +22 -0
  25. package/dist/compatibility.js +76 -0
  26. package/dist/compatibility.js.map +1 -0
  27. package/dist/continuation_state.d.ts +36 -0
  28. package/dist/continuation_state.js +249 -0
  29. package/dist/continuation_state.js.map +1 -0
  30. package/dist/cost.d.ts +31 -0
  31. package/dist/cost.js +314 -0
  32. package/dist/cost.js.map +1 -0
  33. package/dist/dari_policy.d.ts +34 -0
  34. package/dist/dari_policy.js +267 -0
  35. package/dist/dari_policy.js.map +1 -0
  36. package/dist/errors.d.ts +7 -0
  37. package/dist/errors.js +14 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/fingerprint.d.ts +13 -0
  40. package/dist/fingerprint.js +130 -0
  41. package/dist/fingerprint.js.map +1 -0
  42. package/dist/fixed_turn_cost_config.d.ts +9 -0
  43. package/dist/fixed_turn_cost_config.js +22 -0
  44. package/dist/fixed_turn_cost_config.js.map +1 -0
  45. package/dist/framework_error.d.ts +8 -0
  46. package/dist/framework_error.js +24 -0
  47. package/dist/framework_error.js.map +1 -0
  48. package/dist/framework_lifecycle.d.ts +11 -0
  49. package/dist/framework_lifecycle.js +57 -0
  50. package/dist/framework_lifecycle.js.map +1 -0
  51. package/dist/framework_models.d.ts +3 -0
  52. package/dist/framework_models.js +120 -0
  53. package/dist/framework_models.js.map +1 -0
  54. package/dist/framework_router.d.ts +2 -0
  55. package/dist/framework_router.js +384 -0
  56. package/dist/framework_router.js.map +1 -0
  57. package/dist/framework_streaming.d.ts +26 -0
  58. package/dist/framework_streaming.js +486 -0
  59. package/dist/framework_streaming.js.map +1 -0
  60. package/dist/framework_types.d.ts +272 -0
  61. package/dist/framework_types.js +5 -0
  62. package/dist/framework_types.js.map +1 -0
  63. package/dist/framework_validation.d.ts +7 -0
  64. package/dist/framework_validation.js +169 -0
  65. package/dist/framework_validation.js.map +1 -0
  66. package/dist/index.d.ts +35 -0
  67. package/dist/index.js +38 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/json.d.ts +10 -0
  70. package/dist/json.js +29 -0
  71. package/dist/json.js.map +1 -0
  72. package/dist/model_ids.d.ts +4 -0
  73. package/dist/model_ids.js +26 -0
  74. package/dist/model_ids.js.map +1 -0
  75. package/dist/pi_context.d.ts +4 -0
  76. package/dist/pi_context.js +206 -0
  77. package/dist/pi_context.js.map +1 -0
  78. package/dist/pi_options.d.ts +3 -0
  79. package/dist/pi_options.js +240 -0
  80. package/dist/pi_options.js.map +1 -0
  81. package/dist/pi_output.d.ts +4 -0
  82. package/dist/pi_output.js +328 -0
  83. package/dist/pi_output.js.map +1 -0
  84. package/dist/pi_reasoning_signature.d.ts +10 -0
  85. package/dist/pi_reasoning_signature.js +33 -0
  86. package/dist/pi_reasoning_signature.js.map +1 -0
  87. package/dist/pi_runtime.d.ts +3 -0
  88. package/dist/pi_runtime.js +155 -0
  89. package/dist/pi_runtime.js.map +1 -0
  90. package/dist/pi_types.d.ts +35 -0
  91. package/dist/pi_types.js +2 -0
  92. package/dist/pi_types.js.map +1 -0
  93. package/dist/portable_reasoning_state.d.ts +7 -0
  94. package/dist/portable_reasoning_state.js +81 -0
  95. package/dist/portable_reasoning_state.js.map +1 -0
  96. package/dist/previous_decision.d.ts +13 -0
  97. package/dist/previous_decision.js +82 -0
  98. package/dist/previous_decision.js.map +1 -0
  99. package/dist/prompts.d.ts +3 -0
  100. package/dist/prompts.js +65 -0
  101. package/dist/prompts.js.map +1 -0
  102. package/dist/protocol_anthropic.d.ts +18 -0
  103. package/dist/protocol_anthropic.js +443 -0
  104. package/dist/protocol_anthropic.js.map +1 -0
  105. package/dist/protocol_openai_chat.d.ts +25 -0
  106. package/dist/protocol_openai_chat.js +503 -0
  107. package/dist/protocol_openai_chat.js.map +1 -0
  108. package/dist/protocol_validation.d.ts +23 -0
  109. package/dist/protocol_validation.js +134 -0
  110. package/dist/protocol_validation.js.map +1 -0
  111. package/dist/route.d.ts +105 -0
  112. package/dist/route.js +226 -0
  113. package/dist/route.js.map +1 -0
  114. package/dist/routing_history.d.ts +7 -0
  115. package/dist/routing_history.js +132 -0
  116. package/dist/routing_history.js.map +1 -0
  117. package/dist/selector_input.d.ts +42 -0
  118. package/dist/selector_input.js +153 -0
  119. package/dist/selector_input.js.map +1 -0
  120. package/dist/selector_parse.d.ts +13 -0
  121. package/dist/selector_parse.js +95 -0
  122. package/dist/selector_parse.js.map +1 -0
  123. package/dist/selector_request.d.ts +26 -0
  124. package/dist/selector_request.js +85 -0
  125. package/dist/selector_request.js.map +1 -0
  126. package/dist/selector_truncation.d.ts +2 -0
  127. package/dist/selector_truncation.js +48 -0
  128. package/dist/selector_truncation.js.map +1 -0
  129. package/dist/selector_wire.d.ts +10 -0
  130. package/dist/selector_wire.js +113 -0
  131. package/dist/selector_wire.js.map +1 -0
  132. package/dist/types.d.ts +152 -0
  133. package/dist/types.js +14 -0
  134. package/dist/types.js.map +1 -0
  135. package/package.json +64 -0
  136. package/src/anonymous_actions.ts +434 -0
  137. package/src/auto_router.ts +95 -0
  138. package/src/cache_behavior.ts +130 -0
  139. package/src/cache_scope.ts +58 -0
  140. package/src/cache_switch_decision.ts +53 -0
  141. package/src/candidate_pruning.ts +257 -0
  142. package/src/candidate_resolution.ts +208 -0
  143. package/src/compatibility.ts +162 -0
  144. package/src/continuation_state.ts +368 -0
  145. package/src/cost.ts +455 -0
  146. package/src/dari_policy.ts +360 -0
  147. package/src/errors.ts +27 -0
  148. package/src/fingerprint.ts +148 -0
  149. package/src/fixed_turn_cost_config.ts +24 -0
  150. package/src/framework_error.ts +38 -0
  151. package/src/framework_lifecycle.ts +74 -0
  152. package/src/framework_models.ts +186 -0
  153. package/src/framework_router.ts +490 -0
  154. package/src/framework_streaming.ts +613 -0
  155. package/src/framework_types.ts +372 -0
  156. package/src/framework_validation.ts +235 -0
  157. package/src/index.ts +37 -0
  158. package/src/json.ts +43 -0
  159. package/src/model_ids.ts +29 -0
  160. package/src/pi_context.ts +251 -0
  161. package/src/pi_options.ts +295 -0
  162. package/src/pi_output.ts +387 -0
  163. package/src/pi_reasoning_signature.ts +41 -0
  164. package/src/pi_runtime.ts +238 -0
  165. package/src/pi_types.ts +71 -0
  166. package/src/portable_reasoning_state.ts +129 -0
  167. package/src/previous_decision.ts +109 -0
  168. package/src/prompts.ts +69 -0
  169. package/src/protocol_anthropic.ts +571 -0
  170. package/src/protocol_openai_chat.ts +645 -0
  171. package/src/protocol_validation.ts +202 -0
  172. package/src/route.ts +490 -0
  173. package/src/routing_history.ts +157 -0
  174. package/src/selector_input.ts +246 -0
  175. package/src/selector_parse.ts +138 -0
  176. package/src/selector_request.ts +130 -0
  177. package/src/selector_truncation.ts +48 -0
  178. package/src/selector_wire.ts +167 -0
  179. package/src/types.ts +231 -0
@@ -0,0 +1,95 @@
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
+ }
@@ -0,0 +1,130 @@
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
+ }
@@ -0,0 +1,58 @@
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
+ }
@@ -0,0 +1,53 @@
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
+ }
@@ -0,0 +1,257 @@
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
+ }