@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.
- package/README.md +89 -60
- package/dist/anonymous_actions.js +32 -10
- package/dist/anonymous_actions.js.map +1 -1
- package/dist/auto_router.d.ts +4 -1
- package/dist/auto_router.js +3 -3
- package/dist/auto_router.js.map +1 -1
- package/dist/cache_behavior.d.ts +8 -6
- package/dist/cache_behavior.js +56 -30
- package/dist/cache_behavior.js.map +1 -1
- package/dist/cache_scope.d.ts +2 -2
- package/dist/cache_scope.js +23 -18
- package/dist/cache_scope.js.map +1 -1
- package/dist/cost.d.ts +3 -2
- package/dist/cost.js +32 -23
- package/dist/cost.js.map +1 -1
- package/dist/dari_policy.d.ts +8 -2
- package/dist/dari_policy.js +39 -1
- package/dist/dari_policy.js.map +1 -1
- package/dist/eval_score_imputation.d.ts +26 -0
- package/dist/eval_score_imputation.js +83 -0
- package/dist/eval_score_imputation.js.map +1 -0
- package/dist/framework_lifecycle.d.ts +1 -1
- package/dist/framework_lifecycle.js +4 -1
- package/dist/framework_lifecycle.js.map +1 -1
- package/dist/framework_models.js +6 -0
- package/dist/framework_models.js.map +1 -1
- package/dist/framework_router.js +22 -11
- package/dist/framework_router.js.map +1 -1
- package/dist/framework_types.d.ts +9 -5
- package/dist/index.d.ts +9 -35
- package/dist/index.js +6 -37
- package/dist/index.js.map +1 -1
- package/dist/model_ids.js +4 -0
- package/dist/model_ids.js.map +1 -1
- package/dist/pi_runtime.js +19 -11
- package/dist/pi_runtime.js.map +1 -1
- package/dist/pi_types.d.ts +2 -2
- package/{src/index.ts → dist/policy-engine.d.ts} +2 -14
- package/dist/policy-engine.js +25 -0
- package/dist/policy-engine.js.map +1 -0
- package/dist/prompts.d.ts +1 -1
- package/dist/prompts.js +3 -3
- package/dist/prompts.js.map +1 -1
- package/dist/protocols.d.ts +5 -0
- package/dist/protocols.js +6 -0
- package/dist/protocols.js.map +1 -0
- package/dist/route.d.ts +3 -4
- package/dist/route.js +17 -10
- package/dist/route.js.map +1 -1
- package/dist/selector_input.d.ts +1 -0
- package/dist/selector_input.js +66 -78
- package/dist/selector_input.js.map +1 -1
- package/dist/selector_parse.d.ts +1 -0
- package/dist/selector_parse.js +2 -1
- package/dist/selector_parse.js.map +1 -1
- package/dist/selector_request.d.ts +1 -0
- package/dist/selector_request.js +1 -0
- package/dist/selector_request.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/examples/auto_router.ts +40 -0
- package/examples/basic_route.ts +130 -0
- package/examples/create_router.ts +114 -0
- package/examples/dari_policy.ts +86 -0
- package/examples/lease_stores.ts +159 -0
- package/examples/managed/README.md +50 -0
- package/examples/managed/byok-router/README.md +13 -0
- package/examples/managed/byok-router/router.yml +13 -0
- package/examples/managed/custom-rules-router/README.md +12 -0
- package/examples/managed/custom-rules-router/router.yml +24 -0
- package/examples/managed/eval-router/README.md +18 -0
- package/examples/managed/eval-router/router.yml +11 -0
- package/examples/managed/slm-router/README.md +12 -0
- package/examples/managed/slm-router/router.yml +8 -0
- package/examples/pi_runtime.ts +55 -0
- package/package.json +26 -7
- package/src/anonymous_actions.ts +0 -434
- package/src/auto_router.ts +0 -95
- package/src/cache_behavior.ts +0 -130
- package/src/cache_scope.ts +0 -58
- package/src/cache_switch_decision.ts +0 -53
- package/src/candidate_pruning.ts +0 -257
- package/src/candidate_resolution.ts +0 -208
- package/src/compatibility.ts +0 -162
- package/src/continuation_state.ts +0 -368
- package/src/cost.ts +0 -455
- package/src/dari_policy.ts +0 -360
- package/src/errors.ts +0 -27
- package/src/fingerprint.ts +0 -148
- package/src/fixed_turn_cost_config.ts +0 -24
- package/src/framework_error.ts +0 -38
- package/src/framework_lifecycle.ts +0 -74
- package/src/framework_models.ts +0 -186
- package/src/framework_router.ts +0 -490
- package/src/framework_streaming.ts +0 -613
- package/src/framework_types.ts +0 -372
- package/src/framework_validation.ts +0 -235
- package/src/json.ts +0 -43
- package/src/model_ids.ts +0 -29
- package/src/pi_context.ts +0 -251
- package/src/pi_options.ts +0 -295
- package/src/pi_output.ts +0 -387
- package/src/pi_reasoning_signature.ts +0 -41
- package/src/pi_runtime.ts +0 -238
- package/src/pi_types.ts +0 -71
- package/src/portable_reasoning_state.ts +0 -129
- package/src/previous_decision.ts +0 -109
- package/src/prompts.ts +0 -69
- package/src/protocol_anthropic.ts +0 -571
- package/src/protocol_openai_chat.ts +0 -645
- package/src/protocol_validation.ts +0 -202
- package/src/route.ts +0 -490
- package/src/routing_history.ts +0 -157
- package/src/selector_input.ts +0 -246
- package/src/selector_parse.ts +0 -138
- package/src/selector_request.ts +0 -130
- package/src/selector_truncation.ts +0 -48
- package/src/selector_wire.ts +0 -167
- package/src/types.ts +0 -231
package/src/cost.ts
DELETED
|
@@ -1,455 +0,0 @@
|
|
|
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
|
-
}
|