@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
package/src/cost.ts ADDED
@@ -0,0 +1,455 @@
1
+ // Routing-only cost estimates for comparing candidate models before selection.
2
+ import {
3
+ ANTHROPIC_LOOKBACK_BLOCKS,
4
+ CROSS_TOKENIZER_RATIO,
5
+ isAnthropicFamily,
6
+ isOpenAiFamily,
7
+ openAiCachedPrefixTokens,
8
+ promptCacheProviderForModel,
9
+ providerCacheableTokens,
10
+ providerMinCacheTokens,
11
+ tokenizerFamily,
12
+ type PromptCacheProvider,
13
+ } from "./cache_behavior.js";
14
+ import { reasoningCacheScope, type ReasoningCacheScopeLookup } from "./cache_scope.js";
15
+ import {
16
+ FIXED_TURN_CACHE_HIT_PROBABILITY,
17
+ FIXED_TURN_COST_PROJECTED_TURNS,
18
+ } from "./fixed_turn_cost_config.js";
19
+ import { routingCandidateKey } from "./types.js";
20
+ import type {
21
+ CandidateCostEstimate,
22
+ ChainHits,
23
+ FixedTurnCostEstimate,
24
+ PrefixHit,
25
+ ReasoningEffort,
26
+ RouterPrefixHit,
27
+ RoutingCandidate,
28
+ TurnCostProjection,
29
+ } from "./types.js";
30
+
31
+ export const CHARS_PER_TOKEN = 4;
32
+ const MTOK = 1_000_000;
33
+
34
+ export type ModelPricing = {
35
+ input: number;
36
+ output: number;
37
+ cacheRead: number;
38
+ cacheWrite: number;
39
+ };
40
+
41
+ // A model missing from the resolved catalog must surface as null (pricing
42
+ // unknown), never as $0.
43
+ export type PricingLookup = (model: string) => ModelPricing | null;
44
+
45
+ export type CandidatePromptEstimate = {
46
+ chars: number;
47
+ reusesStoredPromptTokens: boolean;
48
+ };
49
+
50
+ // perModel/perModelBuckets key on raw model-id strings deliberately:
51
+ // entry.model is a verbatim copy of the router's enabled_models entry that
52
+ // served the turn, and candidate lookups use members of the same list, so
53
+ // both sides are byte-identical by construction. Canonicalizing here could
54
+ // merge two distinct custom model ids differing only in provider-prefix case
55
+ // and cross-contaminate their warm prefixes (an unsafe over-claim), whereas
56
+ // raw keying can at worst under-claim warmth if an id is ever re-spelled
57
+ // mid-conversation.
58
+ // Provider-visible chains can differ when one candidate receives compatible
59
+ // opaque continuation state and another sees readable history only. A hit is
60
+ // accepted solely against the chain for the model that originally wrote it;
61
+ // client-declared continuation provenance cannot make another candidate warm.
62
+ export function deriveModelChainHits(
63
+ chainsByModel: ReadonlyMap<string, string[]>,
64
+ hits: RouterPrefixHit[] | undefined
65
+ ): ChainHits {
66
+ const depthsByModel = new Map(
67
+ [...chainsByModel].map(([model, chain]) => [
68
+ model,
69
+ new Map(chain.map((hash, index) => [hash, index + 1])),
70
+ ])
71
+ );
72
+ return collectChainHits(hits, (entry) => depthsByModel.get(entry.model)?.get(entry.hash));
73
+ }
74
+
75
+ // Conversation identity accepts either hash tier: the strict per-model chain
76
+ // (byte-faithful echoes) or the host's loose chain (clients that drop
77
+ // reasoning payloads or rewrite item ids). Warmth estimation must NOT use
78
+ // this — a loose-only match means the provider-visible bytes diverged, so
79
+ // the cache is cold even though the conversation is the same.
80
+ // Loose hashes are not unique: two transcript twins (identical runs whose
81
+ // bytes differ only in reasoning payloads, item ids, or whitespace) can both
82
+ // match at the same depth. Ties are content-equivalent, but the pick must be
83
+ // deterministic: a strict match (byte-identical, provably this transcript)
84
+ // beats a loose-only one, then the most recently active entry wins.
85
+ export function deepestIdentityHit(
86
+ chainsByModel: ReadonlyMap<string, string[]>,
87
+ looseChain: readonly string[] | undefined,
88
+ hits: RouterPrefixHit[] | undefined
89
+ ): PrefixHit | undefined {
90
+ const depthsByModel = new Map(
91
+ [...chainsByModel].map(([model, chain]) => [
92
+ model,
93
+ new Map(chain.map((hash, index) => [hash, index + 1])),
94
+ ])
95
+ );
96
+ const looseDepths = new Map((looseChain ?? []).map((hash, index) => [hash, index + 1]));
97
+ let deepest: PrefixHit | undefined;
98
+ let deepestStrict = false;
99
+ let deepestUpdatedAtMs = Number.NEGATIVE_INFINITY;
100
+ for (const entry of hits ?? []) {
101
+ const strictDepth = depthsByModel.get(entry.model)?.get(entry.hash) ?? 0;
102
+ const looseDepth = (entry.loose_hash && looseDepths.get(entry.loose_hash)) || 0;
103
+ const depth = Math.max(strictDepth, looseDepth);
104
+ if (depth === 0) continue;
105
+ const strict = strictDepth > 0;
106
+ const parsedUpdatedAtMs = Date.parse(entry.updated_at);
107
+ const updatedAtMs = Number.isFinite(parsedUpdatedAtMs)
108
+ ? parsedUpdatedAtMs
109
+ : Number.NEGATIVE_INFINITY;
110
+ const wins =
111
+ !deepest ||
112
+ depth > deepest.depth ||
113
+ (depth === deepest.depth &&
114
+ (strict !== deepestStrict
115
+ ? strict
116
+ : updatedAtMs > deepestUpdatedAtMs));
117
+ if (wins) {
118
+ deepest = { entry, depth };
119
+ deepestStrict = strict;
120
+ deepestUpdatedAtMs = updatedAtMs;
121
+ }
122
+ }
123
+ return deepest;
124
+ }
125
+
126
+ function collectChainHits(
127
+ hits: RouterPrefixHit[] | undefined,
128
+ depthFor: (entry: RouterPrefixHit) => number | undefined
129
+ ): ChainHits {
130
+ let deepest: PrefixHit | undefined;
131
+ const perModel = new Map<string, PrefixHit>();
132
+ const perModelBuckets = new Map<string, Map<string | null, PrefixHit>>();
133
+ for (const entry of hits ?? []) {
134
+ const depth = depthFor(entry);
135
+ if (depth === undefined) continue;
136
+ const hit: PrefixHit = { entry, depth };
137
+ if (!deepest || depth > deepest.depth) deepest = hit;
138
+ const existing = perModel.get(entry.model);
139
+ if (!existing || depth > existing.depth) perModel.set(entry.model, hit);
140
+ const bucket = entry.reasoning_bucket ?? null;
141
+ let byBucket = perModelBuckets.get(entry.model);
142
+ if (!byBucket) {
143
+ byBucket = new Map();
144
+ perModelBuckets.set(entry.model, byBucket);
145
+ }
146
+ const existingBucketHit = byBucket.get(bucket);
147
+ if (!existingBucketHit || depth > existingBucketHit.depth) byBucket.set(bucket, hit);
148
+ }
149
+ return { deepest, perModel, perModelBuckets };
150
+ }
151
+
152
+ // The warm-prefix hit a candidate can actually re-read, given how its
153
+ // provider scopes cache identity across reasoning payloads. Shared-scope
154
+ // providers reuse one partition regardless of effort; effort-keyed providers
155
+ // only re-read the partition warmed under the same effective effort, so the
156
+ // hit must come from the matching bucket. Entries written before buckets
157
+ // existed (null) are accepted for any effort so a rollout never estimates a
158
+ // genuinely warm prefix as cold.
159
+ export function selectPrefixHit(
160
+ hits: ChainHits,
161
+ model: string,
162
+ reasoningBucket: string,
163
+ cacheScope: ReasoningCacheScopeLookup = reasoningCacheScope
164
+ ): PrefixHit | undefined {
165
+ if (cacheScope(model) === "shared") return hits.perModel.get(model);
166
+ const byBucket = hits.perModelBuckets.get(model);
167
+ if (!byBucket) return undefined;
168
+ const matched = byBucket.get(reasoningBucket);
169
+ const legacy = byBucket.get(null);
170
+ if (matched) return matched;
171
+ return legacy;
172
+ }
173
+
174
+ export function selectPromptAnchor(
175
+ hits: ChainHits,
176
+ model: string,
177
+ reasoningBucket: string,
178
+ cacheScope: ReasoningCacheScopeLookup = reasoningCacheScope
179
+ ): PrefixHit | undefined {
180
+ // Prompt-token baselines are provider/model-specific. Cross-model hits can
181
+ // recover conversation identity elsewhere, but cannot anchor this model's
182
+ // provider-visible prompt estimate.
183
+ return selectPrefixHit(hits, model, reasoningBucket, cacheScope);
184
+ }
185
+
186
+ export function estimateCandidateCosts(args: {
187
+ candidates: RoutingCandidate[];
188
+ hits: ChainHits;
189
+ incomingBlockCount: number;
190
+ incomingProviderBlockCountFor?: (model: string) => number;
191
+ promptEstimatesByCandidate: ReadonlyMap<string, CandidatePromptEstimate>;
192
+ toolChoiceFp: string;
193
+ responseFormatFp: string;
194
+ pricing: PricingLookup;
195
+ averageOutputTokensByModel?: Readonly<
196
+ Record<string, Partial<Record<ReasoningEffort, number>> | null>
197
+ >;
198
+ cacheScope?: ReasoningCacheScopeLookup;
199
+ }): CandidateCostEstimate[] {
200
+ const cacheScope = args.cacheScope ?? reasoningCacheScope;
201
+ return args.candidates.map((candidate) => {
202
+ const { model, reasoningEffort } = candidate;
203
+ const price = args.pricing(model);
204
+ const anthropicStyle = isAnthropicFamily(model);
205
+ const openAiStyle = isOpenAiFamily(model);
206
+ const promptEstimate = args.promptEstimatesByCandidate.get(
207
+ routingCandidateKey(candidate),
208
+ );
209
+ if (promptEstimate === undefined) {
210
+ throw new Error(
211
+ `missing prompt estimate for ${model}/${reasoningEffort}`,
212
+ );
213
+ }
214
+ const anchor = selectPromptAnchor(args.hits, model, reasoningEffort, cacheScope);
215
+ const reusesStoredPromptTokens = promptEstimate.reusesStoredPromptTokens;
216
+ const serializedTokens = Math.ceil(
217
+ promptEstimate.chars / CHARS_PER_TOKEN,
218
+ );
219
+ const tokenizerReference = anchor ?? args.hits.deepest;
220
+ const crossesTokenizerFamilies =
221
+ tokenizerReference !== undefined
222
+ && tokenizerFamily(model)
223
+ !== tokenizerFamily(tokenizerReference.entry.model);
224
+ // Candidate-specific serialization captures provider wire-shape
225
+ // differences. Preserve the separate pessimistic margin for tokenizer
226
+ // differences without borrowing the foreign model's measured token count.
227
+ const newTokens = crossesTokenizerFamilies
228
+ ? Math.round(serializedTokens * CROSS_TOKENIZER_RATIO)
229
+ : serializedTokens;
230
+
231
+ let estPrompt = newTokens;
232
+ if (anchor && reusesStoredPromptTokens) {
233
+ const knownTokens = anchor.entry.prompt_tokens;
234
+ estPrompt = knownTokens + newTokens;
235
+ }
236
+
237
+ const hit = selectPrefixHit(args.hits, model, reasoningEffort, cacheScope);
238
+ let warmTokens = 0;
239
+ if (hit) {
240
+ // tool_choice flips bust Anthropic's message cache but not automatic
241
+ // vendor caches; response_format schemas are prompt content everywhere.
242
+ const fingerprintsMatch = anthropicStyle
243
+ ? hit.entry.tool_choice_fp === args.toolChoiceFp &&
244
+ hit.entry.response_format_fp === args.responseFormatFp
245
+ : hit.entry.response_format_fp === args.responseFormatFp;
246
+ const withinLookback =
247
+ !anthropicStyle
248
+ || withinAnthropicLookback({
249
+ hit: hit.entry,
250
+ incomingLegacyDepth: args.incomingBlockCount,
251
+ incomingProviderDepth:
252
+ args.incomingProviderBlockCountFor?.(model),
253
+ });
254
+ // Provider minimums apply to the cached prefix being re-read — this
255
+ // candidate's own hit, counted by its own provider — not to the global
256
+ // prompt estimate (which may anchor on another provider's deeper entry).
257
+ const meetsMinimum =
258
+ hit.entry.prompt_tokens >= providerMinCacheTokens(model);
259
+ if (fingerprintsMatch && withinLookback && meetsMinimum) {
260
+ warmTokens = openAiStyle
261
+ ? openAiCachedPrefixTokens(hit.entry.prompt_tokens)
262
+ : hit.entry.prompt_tokens;
263
+ }
264
+ }
265
+ // Anchored estimates contain only a fresh suffix; complete-prompt
266
+ // estimates (used for legacy rows without an input depth) already include
267
+ // the warm prefix and must not add it a second time.
268
+ estPrompt = Math.max(
269
+ estPrompt,
270
+ reusesStoredPromptTokens ? warmTokens + newTokens : warmTokens,
271
+ );
272
+ // An unanchored hit (for example, a legacy row) leaves only a coarse
273
+ // complete-prompt estimate. If that estimate undercuts the confirmed warm
274
+ // prefix, its fresh/cached split is unknowable. Conservatively charge the
275
+ // coarse estimate as fresh instead of making the fresh portion free.
276
+ if (
277
+ warmTokens > 0
278
+ && !reusesStoredPromptTokens
279
+ && newTokens <= warmTokens
280
+ ) {
281
+ estPrompt = warmTokens + newTokens;
282
+ }
283
+
284
+ let estCost: number | null = null;
285
+ if (price) {
286
+ const remainder = Math.max(0, estPrompt - warmTokens);
287
+ estCost =
288
+ (warmTokens * price.cacheRead + remainder * freshInputRate(model, price, estPrompt)) /
289
+ 1_000_000;
290
+ }
291
+
292
+ return {
293
+ model,
294
+ reasoning_effort: reasoningEffort,
295
+ warm_tokens: warmTokens,
296
+ est_prompt_tokens: estPrompt,
297
+ est_input_cost_usd: estCost,
298
+ output_cost_per_mtok: price?.output ?? null,
299
+ pricing_known: price !== null,
300
+ fixed_turn_cost_estimate: price
301
+ ? fixedTurnCostEstimate({
302
+ model,
303
+ price,
304
+ estPromptTokens: estPrompt,
305
+ warmTokens,
306
+ reasoningEffort,
307
+ outputTokens: args.averageOutputTokensByModel?.[model]?.[reasoningEffort],
308
+ })
309
+ : null,
310
+ } satisfies CandidateCostEstimate;
311
+ });
312
+ }
313
+
314
+ function withinAnthropicLookback(args: {
315
+ hit: RouterPrefixHit;
316
+ incomingLegacyDepth: number;
317
+ incomingProviderDepth: number | undefined;
318
+ }): boolean {
319
+ const storedProviderDepth = args.hit.provider_block_depth;
320
+ if (
321
+ storedProviderDepth !== undefined
322
+ && storedProviderDepth !== null
323
+ ) {
324
+ if (args.incomingProviderDepth === undefined) return false;
325
+ const delta = args.incomingProviderDepth - storedProviderDepth;
326
+ return delta >= 0 && delta <= ANTHROPIC_LOOKBACK_BLOCKS;
327
+ }
328
+ const delta = args.incomingLegacyDepth - args.hit.block_depth;
329
+ return delta >= 0 && delta <= ANTHROPIC_LOOKBACK_BLOCKS;
330
+ }
331
+
332
+ function fixedTurnCostEstimate(args: {
333
+ model: string;
334
+ price: ModelPricing;
335
+ estPromptTokens: number;
336
+ warmTokens: number;
337
+ reasoningEffort: ReasoningEffort;
338
+ outputTokens?: number;
339
+ }): FixedTurnCostEstimate | null {
340
+ const provider = promptCacheProviderForModel(args.model);
341
+ if (!provider || args.outputTokens === undefined) return null;
342
+
343
+ // One pass to the longest horizon, snapshotting the running total at each
344
+ // shorter one: turn N's cost depends on every turn before it, so the
345
+ // horizons are prefixes of the same accumulation rather than separate runs.
346
+ const horizons: number[] = [...FIXED_TURN_COST_PROJECTED_TURNS].sort(
347
+ (left, right) => left - right,
348
+ );
349
+ const longestHorizon = horizons[horizons.length - 1] ?? 0;
350
+ const projections: TurnCostProjection[] = [];
351
+ const outputTokens = args.outputTokens;
352
+ let totalCost = 0;
353
+
354
+ for (let turn = 1; turn <= longestHorizon; turn += 1) {
355
+ const inputTokens = args.estPromptTokens + (turn - 1) * outputTokens;
356
+ totalCost +=
357
+ turn === 1
358
+ ? deterministicTurnCost({
359
+ model: args.model,
360
+ price: args.price,
361
+ inputTokens,
362
+ cachedTokens: args.warmTokens,
363
+ outputTokens,
364
+ })
365
+ : laterTurnCost({
366
+ model: args.model,
367
+ price: args.price,
368
+ provider,
369
+ inputTokens,
370
+ // Later requests can only read prefixes that appeared in prior
371
+ // request inputs. The immediately previous output is fresh on the
372
+ // next request.
373
+ reusablePrefix: args.estPromptTokens + (turn - 2) * outputTokens,
374
+ outputTokens,
375
+ });
376
+ if (horizons.includes(turn)) {
377
+ projections.push({ projected_turns: turn, total_cost_usd: totalCost });
378
+ }
379
+ }
380
+
381
+ return {
382
+ output_tokens_per_turn: outputTokens,
383
+ assumed_reasoning_effort: args.reasoningEffort,
384
+ projections,
385
+ };
386
+ }
387
+
388
+ function laterTurnCost(args: {
389
+ model: string;
390
+ price: ModelPricing;
391
+ provider: PromptCacheProvider;
392
+ inputTokens: number;
393
+ reusablePrefix: number;
394
+ outputTokens: number;
395
+ }): number {
396
+ const cacheableTokens = providerCacheableTokens(
397
+ args.model,
398
+ args.provider,
399
+ args.reusablePrefix,
400
+ );
401
+ const missCost = deterministicTurnCost({
402
+ model: args.model,
403
+ price: args.price,
404
+ inputTokens: args.inputTokens,
405
+ cachedTokens: 0,
406
+ outputTokens: args.outputTokens,
407
+ });
408
+ if (cacheableTokens <= 0) return missCost;
409
+
410
+ const hitCost = deterministicTurnCost({
411
+ model: args.model,
412
+ price: args.price,
413
+ inputTokens: args.inputTokens,
414
+ cachedTokens: cacheableTokens,
415
+ outputTokens: args.outputTokens,
416
+ });
417
+ const hitProbability = FIXED_TURN_CACHE_HIT_PROBABILITY[args.provider];
418
+ return hitCost * hitProbability + missCost * (1 - hitProbability);
419
+ }
420
+
421
+ // The loop cost at one horizon, for callers that need a single scalar rather
422
+ // than the whole curve.
423
+ export function turnCostUsdAt(
424
+ estimate: FixedTurnCostEstimate | null | undefined,
425
+ turns: number,
426
+ ): number | null {
427
+ const projection = estimate?.projections.find(
428
+ (entry) => entry.projected_turns === turns,
429
+ );
430
+ return projection?.total_cost_usd ?? null;
431
+ }
432
+
433
+ // A caching provider writes whatever the prompt did not read, and several bill
434
+ // that write above their input rate. An effort-keyed cache partitions on effort,
435
+ // so a switch of either model or effort leaves nothing warm and writes it all.
436
+ function freshInputRate(model: string, price: ModelPricing, promptTokens: number): number {
437
+ return price.cacheWrite > 0 && promptTokens >= providerMinCacheTokens(model)
438
+ ? price.cacheWrite
439
+ : price.input;
440
+ }
441
+
442
+ function deterministicTurnCost(args: {
443
+ model: string;
444
+ price: ModelPricing;
445
+ inputTokens: number;
446
+ cachedTokens: number;
447
+ outputTokens: number;
448
+ }): number {
449
+ const freshTokens = args.inputTokens - args.cachedTokens;
450
+ return (
451
+ (args.cachedTokens * args.price.cacheRead) / MTOK +
452
+ (freshTokens * freshInputRate(args.model, args.price, args.inputTokens)) / MTOK +
453
+ (args.outputTokens * args.price.output) / MTOK
454
+ );
455
+ }