@almadar/llm 2.51.0 → 2.52.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/src/client.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  getGlobalRateLimiter,
23
23
  type RateLimiterOptions,
24
24
  } from './rate-limiter.js';
25
- import { TokenTracker, getGlobalTokenTracker } from './token-tracker.js';
25
+ import { TokenTracker, getGlobalTokenTracker, estimateCostUSD } from './token-tracker.js';
26
26
  import { parseJsonResponse } from './json-parser.js';
27
27
  import {
28
28
  parseChatCompletionResponse,
@@ -203,6 +203,15 @@ export interface LLMUsage {
203
203
  * `prompt_tokens_details.cached_tokens`, deepseek-native
204
204
  * `prompt_cache_hit_tokens`). Absent when the provider reports neither. */
205
205
  cachedPromptTokens?: number;
206
+ /**
207
+ * USD cost of this one call. OpenRouter's authoritative `usage.cost`
208
+ * (real, routing+cache-adjusted charge) when the provider is
209
+ * `openrouter`; otherwise `estimateCostUSD` priced from the same
210
+ * OpenRouter-fetched table `TokenTracker` uses for the whole-run total.
211
+ * Absent — never `0` — when neither is available (pricing fetch
212
+ * pending, or the model isn't in OpenRouter's catalog).
213
+ */
214
+ costUSD?: number;
206
215
  }
207
216
 
208
217
  export type LLMFinishReason =
@@ -1329,11 +1338,23 @@ export class LLMClient {
1329
1338
  };
1330
1339
  const cachedTokens =
1331
1340
  rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens;
1341
+ // Prefer OpenRouter's authoritative charge; otherwise price the
1342
+ // tokens from the same pricing table the tracker uses (absent,
1343
+ // never 0, when the model has no known pricing row yet).
1344
+ const costUSD =
1345
+ typeof rawUsage.cost === 'number'
1346
+ ? rawUsage.cost
1347
+ : estimateCostUSD(this.modelName, {
1348
+ promptTokens: parsed.usage.prompt_tokens,
1349
+ completionTokens: parsed.usage.completion_tokens,
1350
+ cachedPromptTokens: cachedTokens ?? 0,
1351
+ });
1332
1352
  usage = {
1333
1353
  promptTokens: parsed.usage.prompt_tokens,
1334
1354
  completionTokens: parsed.usage.completion_tokens,
1335
1355
  totalTokens: parsed.usage.total_tokens,
1336
1356
  ...(cachedTokens !== undefined ? { cachedPromptTokens: cachedTokens } : {}),
1357
+ ...(costUSD !== undefined ? { costUSD } : {}),
1337
1358
  };
1338
1359
  if (this.tokenTracker) {
1339
1360
  this.tokenTracker.addUsage(usage.promptTokens, usage.completionTokens, {
package/src/index.ts CHANGED
@@ -59,7 +59,9 @@ export {
59
59
  TokenTracker,
60
60
  getGlobalTokenTracker,
61
61
  resetGlobalTokenTracker,
62
+ estimateCostUSD,
62
63
  type TokenUsage,
64
+ type EstimateCostTokens,
63
65
  } from './token-tracker.js';
64
66
 
65
67
  export {
@@ -158,4 +160,27 @@ export {
158
160
  type RankedEdit,
159
161
  type RankEditsResult,
160
162
  type MasarHealthResult,
163
+ JevProvider,
164
+ JevError,
165
+ getJevProvider,
166
+ resetJevProvider,
167
+ isJevAvailable,
168
+ JEV_MODELS,
169
+ JEV_DECISIONS_URL,
170
+ type JevModelId,
171
+ type JevJsonValue,
172
+ type JevState,
173
+ type JevNoulQuestion,
174
+ type JevChoiceQuestion,
175
+ type JevScoreQuestion,
176
+ type JevQuestion,
177
+ type JevNoulAnswer,
178
+ type JevChoiceAnswer,
179
+ type JevScoreAnswer,
180
+ type JevAnswer,
181
+ type JevDecideRequest,
182
+ type JevUsage,
183
+ type JevDecideResult,
184
+ type JevProviderOptions,
185
+ type JevHealthResult,
161
186
  } from './providers/index.js';
@@ -22,3 +22,29 @@ export {
22
22
  type RankEditsResult,
23
23
  type MasarHealthResult,
24
24
  } from './masar.js';
25
+
26
+ export {
27
+ JevProvider,
28
+ JevError,
29
+ getJevProvider,
30
+ resetJevProvider,
31
+ isJevAvailable,
32
+ JEV_MODELS,
33
+ JEV_DECISIONS_URL,
34
+ type JevModelId,
35
+ type JevJsonValue,
36
+ type JevState,
37
+ type JevNoulQuestion,
38
+ type JevChoiceQuestion,
39
+ type JevScoreQuestion,
40
+ type JevQuestion,
41
+ type JevNoulAnswer,
42
+ type JevChoiceAnswer,
43
+ type JevScoreAnswer,
44
+ type JevAnswer,
45
+ type JevDecideRequest,
46
+ type JevUsage,
47
+ type JevDecideResult,
48
+ type JevProviderOptions,
49
+ type JevHealthResult,
50
+ } from './jev.js';
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Jev Provider
3
+ *
4
+ * Thin HTTP client for TypeSafe's "System One" decision model on OpenRouter.
5
+ * Not a chat model — `POST /api/alpha/decisions`, one or more `noul` / `choice`
6
+ * / `score` questions batched into a single round-trip. No temperature/top_p/
7
+ * max_tokens, no streaming, no system prompt.
8
+ *
9
+ * See docs/Almadar_Rabit.md § "Jev decision provider" for the verified contract.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import { getGlobalTokenTracker } from '../token-tracker.js';
15
+
16
+ // ============================================================================
17
+ // Models
18
+ // ============================================================================
19
+
20
+ export const JEV_MODELS = {
21
+ JEV_1_13: 'typesafe/jev-1.13',
22
+ JEV_LATEST: '~typesafe/jev-latest',
23
+ } as const;
24
+
25
+ export type JevModelId = (typeof JEV_MODELS)[keyof typeof JEV_MODELS];
26
+
27
+ export const JEV_DECISIONS_URL = 'https://openrouter.ai/api/alpha/decisions';
28
+
29
+ const JEV_MODELS_BASE_URL = 'https://openrouter.ai/api/v1/models';
30
+ const DEFAULT_TIMEOUT_MS = 30_000;
31
+
32
+ // ============================================================================
33
+ // JSON value (recursive, not Record<string, unknown> — repo lint bans that)
34
+ // ============================================================================
35
+
36
+ export type JevJsonValue =
37
+ | string
38
+ | number
39
+ | boolean
40
+ | null
41
+ | JevJsonValue[]
42
+ | { [key: string]: JevJsonValue };
43
+
44
+ export type JevState = string | Record<string, JevJsonValue>;
45
+
46
+ // ============================================================================
47
+ // Questions
48
+ // ============================================================================
49
+
50
+ export interface JevNoulQuestion {
51
+ type: 'noul';
52
+ /** Must pose the yes/no question directly (docs.typesafe.ai/api, e.g. "Does this convey urgency?") — the noul answer is P(yes) TO THIS TEXT, never a policy paragraph. */
53
+ instructions: string;
54
+ /** Optional descriptions of what a yes and a no mean (docs-verified request shape); passed through verbatim to `/api/alpha/decisions`. */
55
+ criteria?: { true: string; false: string };
56
+ }
57
+
58
+ export interface JevChoiceQuestion {
59
+ type: 'choice';
60
+ instructions: string;
61
+ criteria: Record<string, string>;
62
+ }
63
+
64
+ export interface JevScoreQuestion {
65
+ type: 'score';
66
+ instructions: string;
67
+ /** Ordered level descriptions, 2–10 entries (verified docs.typesafe.ai/api). */
68
+ criteria: string[];
69
+ }
70
+
71
+ export type JevQuestion = JevNoulQuestion | JevChoiceQuestion | JevScoreQuestion;
72
+
73
+ // ============================================================================
74
+ // Answers
75
+ // ============================================================================
76
+
77
+ export interface JevNoulAnswer {
78
+ type: 'noul';
79
+ noul: number;
80
+ }
81
+
82
+ export interface JevChoiceAnswer {
83
+ type: 'choice';
84
+ choice: string;
85
+ probabilities: Record<string, number>;
86
+ confidence: number;
87
+ }
88
+
89
+ export interface JevScoreAnswer {
90
+ type: 'score';
91
+ score: number;
92
+ legend: Record<string, string>;
93
+ probabilities: Record<string, number>;
94
+ confidence: number;
95
+ }
96
+
97
+ export type JevAnswer = JevNoulAnswer | JevChoiceAnswer | JevScoreAnswer;
98
+
99
+ // ============================================================================
100
+ // Request / Result
101
+ // ============================================================================
102
+
103
+ export interface JevDecideRequest<Q extends Record<string, JevQuestion>> {
104
+ state: JevState;
105
+ questions: Q;
106
+ model?: JevModelId;
107
+ }
108
+
109
+ export interface JevUsage {
110
+ inputTokens: number;
111
+ outputTokens: number;
112
+ costUSD: number;
113
+ }
114
+
115
+ // The extends-check must run on JevAnswerFor's OWN naked type parameter, not
116
+ // inline on the indexed access `Q[K]` — a conditional keyed off an indexed
117
+ // access type isn't distributive, so a wide Q (e.g. Record<string,
118
+ // JevQuestion>) would collapse every key to the final `never`/last branch
119
+ // instead of distributing per union member.
120
+ type JevAnswerFor<TQ extends JevQuestion> = TQ extends JevNoulQuestion
121
+ ? JevNoulAnswer
122
+ : TQ extends JevChoiceQuestion
123
+ ? JevChoiceAnswer
124
+ : TQ extends JevScoreQuestion
125
+ ? JevScoreAnswer
126
+ : never;
127
+
128
+ type JevAnswersOf<Q extends Record<string, JevQuestion>> = {
129
+ [K in keyof Q]: JevAnswerFor<Q[K]>;
130
+ };
131
+
132
+ export interface JevDecideResult<Q extends Record<string, JevQuestion>> {
133
+ answers: JevAnswersOf<Q>;
134
+ model: string;
135
+ id: string;
136
+ provider: string;
137
+ usage: JevUsage;
138
+ durationMs: number;
139
+ }
140
+
141
+ export interface JevProviderOptions {
142
+ apiKey?: string;
143
+ model?: JevModelId;
144
+ baseUrl?: string;
145
+ timeoutMs?: number;
146
+ fetchImpl?: typeof fetch;
147
+ }
148
+
149
+ export interface JevHealthResult {
150
+ ok: boolean;
151
+ model: string;
152
+ endpoints: number;
153
+ }
154
+
155
+ // ============================================================================
156
+ // Error
157
+ // ============================================================================
158
+
159
+ export class JevError extends Error {
160
+ constructor(
161
+ message: string,
162
+ public readonly status?: number,
163
+ public readonly body?: string,
164
+ ) {
165
+ super(message);
166
+ this.name = 'JevError';
167
+ }
168
+ }
169
+
170
+ // ============================================================================
171
+ // Runtime validation — parse untrusted JSON into narrow local types via
172
+ // explicit checks, never `as any` / `as unknown as X`.
173
+ // ============================================================================
174
+
175
+ function isRecord(value: unknown): value is { [key: string]: unknown } {
176
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
177
+ }
178
+
179
+ function isNumberRecord(value: unknown): value is Record<string, number> {
180
+ return isRecord(value) && Object.values(value).every((v) => typeof v === 'number');
181
+ }
182
+
183
+ function isStringRecord(value: unknown): value is Record<string, string> {
184
+ return isRecord(value) && Object.values(value).every((v) => typeof v === 'string');
185
+ }
186
+
187
+ function isNoulAnswer(value: unknown): value is JevNoulAnswer {
188
+ return isRecord(value) && value.type === 'noul' && typeof value.noul === 'number';
189
+ }
190
+
191
+ function isChoiceAnswer(value: unknown): value is JevChoiceAnswer {
192
+ return (
193
+ isRecord(value) &&
194
+ value.type === 'choice' &&
195
+ typeof value.choice === 'string' &&
196
+ typeof value.confidence === 'number' &&
197
+ isNumberRecord(value.probabilities)
198
+ );
199
+ }
200
+
201
+ function isScoreAnswer(value: unknown): value is JevScoreAnswer {
202
+ return (
203
+ isRecord(value) &&
204
+ value.type === 'score' &&
205
+ typeof value.score === 'number' &&
206
+ typeof value.confidence === 'number' &&
207
+ isStringRecord(value.legend) &&
208
+ isNumberRecord(value.probabilities)
209
+ );
210
+ }
211
+
212
+ function isJevAnswer(value: unknown): value is JevAnswer {
213
+ return isNoulAnswer(value) || isChoiceAnswer(value) || isScoreAnswer(value);
214
+ }
215
+
216
+ interface JevWireBody {
217
+ model: string;
218
+ id: string;
219
+ provider: string;
220
+ answers: Record<string, JevAnswer>;
221
+ usage: { inputTokens: number; outputTokens: number; cost: number };
222
+ }
223
+
224
+ function parseWireBody(value: unknown, status: number, rawBody: string): JevWireBody {
225
+ if (!isRecord(value)) {
226
+ throw new JevError('Jev decide: response body is not a JSON object', status, rawBody);
227
+ }
228
+ const { model, id, provider, answers, usage } = value;
229
+ if (typeof model !== 'string') {
230
+ throw new JevError('Jev decide: response is missing string "model"', status, rawBody);
231
+ }
232
+ if (typeof id !== 'string') {
233
+ throw new JevError('Jev decide: response is missing string "id"', status, rawBody);
234
+ }
235
+ if (typeof provider !== 'string') {
236
+ throw new JevError('Jev decide: response is missing string "provider"', status, rawBody);
237
+ }
238
+ if (!isRecord(answers)) {
239
+ throw new JevError('Jev decide: response is missing an "answers" object', status, rawBody);
240
+ }
241
+ if (!isRecord(usage)) {
242
+ throw new JevError('Jev decide: response is missing a "usage" object', status, rawBody);
243
+ }
244
+ const { input_tokens: inputTokens, output_tokens: outputTokens, cost } = usage;
245
+ if (typeof inputTokens !== 'number' || typeof outputTokens !== 'number' || typeof cost !== 'number') {
246
+ throw new JevError('Jev decide: "usage" is missing input_tokens/output_tokens/cost', status, rawBody);
247
+ }
248
+
249
+ const parsedAnswers: Record<string, JevAnswer> = {};
250
+ for (const [key, answer] of Object.entries(answers)) {
251
+ if (!isJevAnswer(answer)) {
252
+ throw new JevError(`Jev decide: answer "${key}" has an invalid or unrecognized shape`, status, rawBody);
253
+ }
254
+ parsedAnswers[key] = answer;
255
+ }
256
+
257
+ return { model, id, provider, answers: parsedAnswers, usage: { inputTokens, outputTokens, cost } };
258
+ }
259
+
260
+ // ============================================================================
261
+ // Provider
262
+ // ============================================================================
263
+
264
+ export class JevProvider {
265
+ private readonly apiKeyOverride: string | undefined;
266
+ private readonly defaultModel: JevModelId;
267
+ private readonly baseUrl: string;
268
+ private readonly timeoutMs: number;
269
+ private readonly fetchImpl: typeof fetch;
270
+
271
+ constructor(options?: JevProviderOptions) {
272
+ this.apiKeyOverride = options?.apiKey;
273
+ this.defaultModel = options?.model ?? JEV_MODELS.JEV_1_13;
274
+ this.baseUrl = options?.baseUrl ?? JEV_DECISIONS_URL;
275
+ this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
276
+ this.fetchImpl = options?.fetchImpl ?? fetch;
277
+ }
278
+
279
+ /**
280
+ * Answer one or more `noul` / `choice` / `score` questions about `state`
281
+ * in a single round-trip.
282
+ *
283
+ * POST /api/alpha/decisions
284
+ */
285
+ async decide<Q extends Record<string, JevQuestion>>(
286
+ req: JevDecideRequest<Q>,
287
+ ): Promise<JevDecideResult<Q>> {
288
+ const apiKey = this.resolveApiKey();
289
+ const model = req.model ?? this.defaultModel;
290
+
291
+ const controller = new AbortController();
292
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
293
+ const startedAt = Date.now();
294
+
295
+ let response: Response;
296
+ try {
297
+ response = await this.fetchImpl(this.baseUrl, {
298
+ method: 'POST',
299
+ headers: {
300
+ Authorization: `Bearer ${apiKey}`,
301
+ 'Content-Type': 'application/json',
302
+ },
303
+ body: JSON.stringify({ model, state: req.state, questions: req.questions }),
304
+ signal: controller.signal,
305
+ });
306
+ } catch (error) {
307
+ clearTimeout(timer);
308
+ if (error instanceof DOMException && error.name === 'AbortError') {
309
+ throw new JevError(`Jev decide timed out after ${this.timeoutMs}ms`);
310
+ }
311
+ const message = error instanceof Error ? error.message : String(error);
312
+ throw new JevError(`Jev decide request failed: ${message}`);
313
+ }
314
+ clearTimeout(timer);
315
+
316
+ const rawBody = await response.text();
317
+ const durationMs = Date.now() - startedAt;
318
+
319
+ if (!response.ok) {
320
+ throw new JevError(`Jev decide failed with status ${response.status}`, response.status, rawBody);
321
+ }
322
+
323
+ let parsedJson: unknown;
324
+ try {
325
+ parsedJson = JSON.parse(rawBody);
326
+ } catch {
327
+ throw new JevError('Jev decide: response body is not valid JSON', response.status, rawBody);
328
+ }
329
+
330
+ const wire = parseWireBody(parsedJson, response.status, rawBody);
331
+
332
+ const answers: Record<string, JevAnswer> = {};
333
+ for (const key of Object.keys(req.questions)) {
334
+ const question = req.questions[key];
335
+ const answer = wire.answers[key];
336
+ if (!answer) {
337
+ throw new JevError(`Jev decide: missing answer for question "${key}"`, response.status, rawBody);
338
+ }
339
+ if (answer.type !== question.type) {
340
+ throw new JevError(
341
+ `Jev decide: answer "${key}" has type "${answer.type}", expected "${question.type}"`,
342
+ response.status,
343
+ rawBody,
344
+ );
345
+ }
346
+ if (answer.type === 'choice' && question.type === 'choice' && !(answer.choice in question.criteria)) {
347
+ throw new JevError(
348
+ `Jev decide: answer "${key}" chose "${answer.choice}", which is not one of the declared criteria`,
349
+ response.status,
350
+ rawBody,
351
+ );
352
+ }
353
+ answers[key] = answer;
354
+ }
355
+
356
+ getGlobalTokenTracker(model).addUsage(wire.usage.inputTokens, wire.usage.outputTokens, {
357
+ provider: 'jev',
358
+ durationMs,
359
+ // Jev is absent from OpenRouter's /api/v1/models catalog, so cost must
360
+ // come from the decisions endpoint's own authoritative usage.cost.
361
+ costUSD: wire.usage.cost,
362
+ });
363
+
364
+ return {
365
+ // Unavoidable: Object.keys(req.questions) erases each key to plain
366
+ // `string`, so TS can't prove a loop-built object satisfies the
367
+ // generic mapped type JevAnswersOf<Q> per key — this one assertion
368
+ // follows the per-key runtime validation (present, type, criteria) above.
369
+ answers: answers as JevAnswersOf<Q>,
370
+ model: wire.model,
371
+ id: wire.id,
372
+ provider: wire.provider,
373
+ usage: {
374
+ inputTokens: wire.usage.inputTokens,
375
+ outputTokens: wire.usage.outputTokens,
376
+ costUSD: wire.usage.cost,
377
+ },
378
+ durationMs,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Check the model is servable. `~typesafe/jev-latest` legitimately reports
384
+ * `endpoints: []` yet still works as a `model` value, so `ok` tracks HTTP
385
+ * 200 only — an empty endpoint list is never treated as "down".
386
+ *
387
+ * GET /api/v1/models/<model>/endpoints
388
+ */
389
+ async health(): Promise<JevHealthResult> {
390
+ const apiKey = this.resolveApiKey();
391
+ const model = this.defaultModel;
392
+ const url = `${JEV_MODELS_BASE_URL}/${model}/endpoints`;
393
+
394
+ let response: Response;
395
+ try {
396
+ response = await this.fetchImpl(url, {
397
+ headers: { Authorization: `Bearer ${apiKey}` },
398
+ });
399
+ } catch {
400
+ return { ok: false, model, endpoints: 0 };
401
+ }
402
+ if (!response.ok) {
403
+ return { ok: false, model, endpoints: 0 };
404
+ }
405
+
406
+ let endpoints = 0;
407
+ try {
408
+ const parsed: unknown = JSON.parse(await response.text());
409
+ if (isRecord(parsed) && isRecord(parsed.data) && Array.isArray(parsed.data.endpoints)) {
410
+ endpoints = parsed.data.endpoints.length;
411
+ }
412
+ } catch {
413
+ // Non-JSON body: still HTTP 200, so `ok` stays true with endpoints 0.
414
+ }
415
+ return { ok: true, model, endpoints };
416
+ }
417
+
418
+ private resolveApiKey(): string {
419
+ const apiKey = this.apiKeyOverride ?? process.env.OPENROUTER_API_KEY ?? process.env.OPEN_ROUTER_API_KEY;
420
+ if (!apiKey) {
421
+ throw new JevError(
422
+ 'Jev: no API key. Set OPENROUTER_API_KEY (or OPEN_ROUTER_API_KEY) in the environment, or pass { apiKey } to JevProvider.',
423
+ );
424
+ }
425
+ return apiKey;
426
+ }
427
+ }
428
+
429
+ // ============================================================================
430
+ // Singleton
431
+ // ============================================================================
432
+
433
+ let sharedInstance: JevProvider | null = null;
434
+
435
+ /**
436
+ * Get the singleton Jev provider instance.
437
+ *
438
+ * Creates the instance on first call, returns cached instance thereafter.
439
+ *
440
+ * @param {JevProviderOptions} [options] - Provider configuration options
441
+ * @returns {JevProvider} The Jev provider instance
442
+ */
443
+ export function getJevProvider(options?: JevProviderOptions): JevProvider {
444
+ if (!sharedInstance) {
445
+ sharedInstance = new JevProvider(options);
446
+ }
447
+ return sharedInstance;
448
+ }
449
+
450
+ export function resetJevProvider(): void {
451
+ sharedInstance = null;
452
+ }
453
+
454
+ export function isJevAvailable(): boolean {
455
+ return Boolean(process.env.OPENROUTER_API_KEY ?? process.env.OPEN_ROUTER_API_KEY);
456
+ }
@@ -169,7 +169,15 @@ function refreshPricingCache(): void {
169
169
  });
170
170
  }
171
171
 
172
- function getCostForModel(model: string): TokenCost {
172
+ /**
173
+ * Look up a model's pricing row without a zero-cost fallback — `undefined`
174
+ * means "no pricing known yet" (OpenRouter fetch pending, or the model
175
+ * isn't listed), distinct from a model that is genuinely free. Single
176
+ * lookup path: `getCostForModel` (zero-fallback, for the tracker's own
177
+ * running totals) and `estimateCostUSD` (pure, callers outside the
178
+ * tracker) both resolve through this.
179
+ */
180
+ function getCostForModelIfKnown(model: string): TokenCost | undefined {
173
181
  const pricing = getPricing();
174
182
  // Try direct match on OpenRouter ID
175
183
  const orId = MODEL_ID_MAP[model];
@@ -180,8 +188,64 @@ function getCostForModel(model: string): TokenCost {
180
188
  for (const [key, cost] of Object.entries(pricing)) {
181
189
  if (key.includes(model) || model.includes(key.split('/')[1] ?? '')) return cost;
182
190
  }
191
+ return undefined;
192
+ }
193
+
194
+ function getCostForModel(model: string): TokenCost {
183
195
  // No pricing available — return zero (OpenRouter fetch pending or model not listed)
184
- return { promptCostPer1K: 0, completionCostPer1K: 0 };
196
+ return getCostForModelIfKnown(model) ?? { promptCostPer1K: 0, completionCostPer1K: 0 };
197
+ }
198
+
199
+ /** Cache-aware cost formula shared by `TokenTracker.costFor` (instance,
200
+ * zero-fallback pricing) and `estimateCostUSD` (pure, `undefined` pricing
201
+ * propagates to the caller instead of silently pricing at $0). */
202
+ function priceTokens(
203
+ costs: TokenCost,
204
+ promptTokens: number,
205
+ completionTokens: number,
206
+ cached: number,
207
+ written: number,
208
+ ): number {
209
+ const cacheReadRate = costs.cacheReadCostPer1K ?? costs.promptCostPer1K;
210
+ const cacheWriteRate = costs.cacheWriteCostPer1K ?? costs.promptCostPer1K;
211
+ const uncached = Math.max(0, promptTokens - cached - written);
212
+ return (
213
+ (uncached / 1000) * costs.promptCostPer1K +
214
+ (cached / 1000) * cacheReadRate +
215
+ (written / 1000) * cacheWriteRate +
216
+ (completionTokens / 1000) * costs.completionCostPer1K
217
+ );
218
+ }
219
+
220
+ export interface EstimateCostTokens {
221
+ promptTokens: number;
222
+ completionTokens: number;
223
+ /** Subset of `promptTokens` served from the provider's prefix cache. */
224
+ cachedPromptTokens?: number;
225
+ /** Subset of `promptTokens` written to cache (Anthropic cache-write). */
226
+ cacheWriteTokens?: number;
227
+ }
228
+
229
+ /**
230
+ * Pure per-call cost estimate from token counts alone, priced from the SAME
231
+ * OpenRouter-fetched table `TokenTracker` uses for the whole-run total (the
232
+ * 24h disk cache in `getPricing` — no second pricing source). Callers with
233
+ * tokens + a model name but no `TokenTracker` instance (e.g. a trace-event
234
+ * emitter) use this instead of reimplementing the cache-aware math.
235
+ *
236
+ * Returns `undefined` when the model has no known pricing row — the
237
+ * caller's job to render that as "unknown", never as `$0`.
238
+ */
239
+ export function estimateCostUSD(model: string, tokens: EstimateCostTokens): number | undefined {
240
+ const costs = getCostForModelIfKnown(model);
241
+ if (costs === undefined) return undefined;
242
+ return priceTokens(
243
+ costs,
244
+ tokens.promptTokens,
245
+ tokens.completionTokens,
246
+ Math.max(0, tokens.cachedPromptTokens ?? 0),
247
+ Math.max(0, tokens.cacheWriteTokens ?? 0),
248
+ );
185
249
  }
186
250
 
187
251
  // ---------------------------------------------------------------------------
@@ -220,16 +284,7 @@ export class TokenTracker {
220
284
 
221
285
  /** Cache-aware cost for one (or an aggregate of) call(s), in USD. */
222
286
  private costFor(model: string, promptTokens: number, completionTokens: number, cached: number, written: number): number {
223
- const costs = getCostForModel(model);
224
- const cacheReadRate = costs.cacheReadCostPer1K ?? costs.promptCostPer1K;
225
- const cacheWriteRate = costs.cacheWriteCostPer1K ?? costs.promptCostPer1K;
226
- const uncached = Math.max(0, promptTokens - cached - written);
227
- return (
228
- (uncached / 1000) * costs.promptCostPer1K +
229
- (cached / 1000) * cacheReadRate +
230
- (written / 1000) * cacheWriteRate +
231
- (completionTokens / 1000) * costs.completionCostPer1K
232
- );
287
+ return priceTokens(getCostForModel(model), promptTokens, completionTokens, cached, written);
233
288
  }
234
289
 
235
290
  /**