@genesislcap/foundation-ai 15.11.0 → 15.12.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.
@@ -2,6 +2,7 @@ import { __awaiter } from "tslib";
2
2
  import { SUPPORTED_GEMINI_MODEL_IDS, } from '../types';
3
3
  import { logger } from '../utils/logger';
4
4
  import { scaleTemperature } from '../utils/temperature';
5
+ import { geminiTokenCost } from '../utils/token-cost';
5
6
  import { toGeminiSchema } from '../utils/tool-schema';
6
7
  import { ResponseTruncatedError } from './anthropic-transport';
7
8
  import { VENDOR_LABELS } from './budget-exhausted-error';
@@ -44,57 +45,6 @@ function assertSupportedGeminiModel(model) {
44
45
  throw new Error(`GeminiTransport: unsupported model "${model}". Use one of: ${SUPPORTED_GEMINI_MODEL_IDS.join(', ')}.`);
45
46
  }
46
47
  }
47
- /**
48
- * Prompt size (tokens) at or below which the standard pricing tier applies.
49
- * Above it, Gemini's long-context tier kicks in. Google applies a single tier
50
- * to the whole request based on prompt size — it is not a marginal/blended rate.
51
- */
52
- const GEMINI_LONG_CONTEXT_THRESHOLD = 200000;
53
- /**
54
- * Cached / context-cache input tokens bill at a flat ~10% of the model's normal input
55
- * rate — consistent across models (flash $0.30→$0.03, flash-lite $0.10→$0.01,
56
- * pro $1.25→$0.125). Source: https://ai.google.dev/gemini-api/docs/pricing.
57
- * (Explicit context caching also has an hourly storage fee; implicit caching — what we
58
- * rely on — has none, so only this read discount applies.)
59
- */
60
- const GEMINI_CACHED_INPUT_MULTIPLIER = 0.1;
61
- /**
62
- * Paid Standard tier rates (USD per million tokens) — https://ai.google.dev/gemini-api/docs/pricing
63
- */
64
- const GEMINI_PRICING = {
65
- 'gemini-2.5-flash-lite': {
66
- kind: 'flat',
67
- rate: { promptPerMillion: 0.1, candidatePerMillion: 0.4 },
68
- },
69
- 'gemini-2.5-flash': { kind: 'flat', rate: { promptPerMillion: 0.3, candidatePerMillion: 2.5 } },
70
- 'gemini-2.5-pro': {
71
- kind: 'tiered',
72
- standard: { promptPerMillion: 1.25, candidatePerMillion: 10.0 },
73
- longContext: { promptPerMillion: 2.5, candidatePerMillion: 15.0 },
74
- },
75
- 'gemini-3.1-flash-lite': {
76
- kind: 'flat',
77
- rate: { promptPerMillion: 0.25, candidatePerMillion: 1.5 },
78
- },
79
- 'gemini-3.5-flash': { kind: 'flat', rate: { promptPerMillion: 1.5, candidatePerMillion: 9.0 } },
80
- 'gemini-3.1-pro-preview': {
81
- kind: 'tiered',
82
- standard: { promptPerMillion: 2.0, candidatePerMillion: 12.0 },
83
- longContext: { promptPerMillion: 4.0, candidatePerMillion: 18.0 },
84
- },
85
- };
86
- /**
87
- * Resolves the per-million-token rates for a model given the request's prompt
88
- * size, selecting the long-context tier for tiered models when the prompt
89
- * exceeds {@link GEMINI_LONG_CONTEXT_THRESHOLD}.
90
- */
91
- function estimatedGeminiPaidRatesUsdPerMillion(model, promptTokens) {
92
- const pricing = GEMINI_PRICING[model];
93
- if (pricing.kind === 'flat') {
94
- return pricing.rate;
95
- }
96
- return promptTokens > GEMINI_LONG_CONTEXT_THRESHOLD ? pricing.longContext : pricing.standard;
97
- }
98
48
  /**
99
49
  * Per-model warnings emitted on construction. flash-lite tiers are the cheap
100
50
  * default and warn-free; higher-cost, frontier, and preview models flag their
@@ -106,6 +56,41 @@ const GEMINI_MODEL_WARNINGS = {
106
56
  'gemini-3.5-flash': 'GeminiTransport: using gemini-3.5-flash — frontier model, materially higher cost than flash-lite; use for harder reasoning or agent tasks.',
107
57
  'gemini-3.1-pro-preview': 'GeminiTransport: using gemini-3.1-pro-preview — PREVIEW model (no stability guarantee, may be withdrawn) with significantly higher, prompt-size-tiered cost; reserve for tasks where flash reliability is insufficient.',
108
58
  };
59
+ /**
60
+ * Gemini's `thinkingConfig` for a turn, given the model and the caller's
61
+ * {@link ChatThinkingPolicy}.
62
+ *
63
+ * `includeThoughts` is about what is **returned**, not what is billed, and stays on for every
64
+ * posture that thinks — a thinking-only turn would otherwise surface as blank, and the thought
65
+ * summary is what `logTokenUsage` reconciles its thinking-token accounting against.
66
+ *
67
+ * `thinkingBudget` is the posture itself: `0` off, `-1` model-decides. **Omitting it is the third
68
+ * state** and is what an undefined policy sends — the model's own default, exactly the request
69
+ * shape this transport issued before the option existed.
70
+ *
71
+ * Only the flash tiers can actually be turned off. `gemini-2.5-pro` always thinks (its minimum
72
+ * budget is 128; `0` is rejected), which is the same constraint Anthropic's Fable 5 has — so a
73
+ * clamp here is the norm across providers, not a Gemini quirk. The 3.x tiers moved to a
74
+ * `thinkingLevel` enum and do not take a numeric budget on every tier, so they are deliberately
75
+ * left on their default rather than sent a value that may not apply: a clamp costs money, but a
76
+ * guess costs a 400 on a turn the user is waiting for. Wire `thinkingLevel` through when the
77
+ * per-tier support is confirmed.
78
+ */
79
+ const GEMINI_THINKING_DISABLEABLE = [
80
+ 'gemini-2.5-flash',
81
+ 'gemini-2.5-flash-lite',
82
+ ];
83
+ function geminiThinkingConfig(model, policy) {
84
+ if (policy === 'off' && GEMINI_THINKING_DISABLEABLE.includes(model)) {
85
+ // No reasoning to return once there is none to bill for.
86
+ return { includeThoughts: false, thinkingBudget: 0 };
87
+ }
88
+ if (policy === 'auto' && GEMINI_THINKING_DISABLEABLE.includes(model)) {
89
+ return { includeThoughts: true, thinkingBudget: -1 };
90
+ }
91
+ // Undefined policy, or a model whose posture we cannot safely set: the default.
92
+ return { includeThoughts: true };
93
+ }
109
94
  /**
110
95
  * Token accepted by the Gemini 3 API in place of a real `thoughtSignature` to
111
96
  * skip signature validation. Used when replaying a tool call whose signature
@@ -144,6 +129,25 @@ export class MalformedFunctionCallError extends Error {
144
129
  * @beta
145
130
  */
146
131
  export class GeminiTransport {
132
+ /**
133
+ * Warn once when a requested policy is silently clamped. Turning thinking off is a *cost*
134
+ * decision, so a caller who asked for it and kept paying for reasoning tokens needs to hear
135
+ * about it — but only once, not per turn.
136
+ *
137
+ * Only `'off'` can actually be denied. `'auto'` is already what an omitted budget produces on
138
+ * every model here — dynamic thinking is the documented default — so warning that it was
139
+ * "ignored" would be false, and latching on it would spend the one warning the genuinely
140
+ * unhonourable `'off'` needs.
141
+ */
142
+ warnIfThinkingUnclampable(policy) {
143
+ if (policy !== 'off' ||
144
+ GEMINI_THINKING_DISABLEABLE.includes(this.model) ||
145
+ this.warnedThinkingClamped) {
146
+ return;
147
+ }
148
+ this.warnedThinkingClamped = true;
149
+ logger.warn(`GeminiTransport: thinkingPolicy 'off' ignored — ${this.model} runs its default thinking posture and takes no budget we can safely set. Reasoning tokens are still billed at the candidate rate; use a flash tier if you need them gone.`);
150
+ }
147
151
  constructor(config = {}) {
148
152
  var _a, _b, _c;
149
153
  /**
@@ -159,6 +163,11 @@ export class GeminiTransport {
159
163
  * Surfaced alongside `getLifetimeCost`.
160
164
  */
161
165
  this.lifetimeSavingsUsd = 0;
166
+ /**
167
+ * Whether we have already told the caller their `thinkingPolicy` cannot be honoured on this
168
+ * model. Latched, because it would otherwise fire on every turn of a tool loop.
169
+ */
170
+ this.warnedThinkingClamped = false;
162
171
  const model = (_a = config.model) !== null && _a !== void 0 ? _a : DEFAULT_MODEL;
163
172
  assertSupportedGeminiModel(model);
164
173
  this.model = model;
@@ -248,12 +257,13 @@ export class GeminiTransport {
248
257
  // it can confidently parse the call (see `repairMalformedFunctionCall`),
249
258
  // falling back to the caller's retry otherwise.
250
259
  const toolConfig = tools ? toGeminiToolConfig(options === null || options === void 0 ? void 0 : options.toolChoice) : undefined;
251
- // Request thought summaries so the model's official reasoning is returned
252
- // (and a thinking-only turn surfaces *something* rather than going silently
253
- // blank). Gemini 2.5 Pro always thinks regardless, so this only changes what
254
- // is RETURNED, not what is billed — see `logTokenUsage` for the thinking-token
255
- // accounting it lets us finally capture.
256
- const generationConfig = { thinkingConfig: { includeThoughts: true } };
260
+ // Thinking posture for this turn. By default (no `thinkingPolicy`) this asks only for thought
261
+ // summaries, so the model's official reasoning is returned and a thinking-only turn surfaces
262
+ // *something* rather than going silently blank — see `logTokenUsage` for the thinking-token
263
+ // accounting it lets us capture. A policy adds an explicit `thinkingBudget` where the model
264
+ // supports one; Gemini 2.5 Pro always thinks regardless. See `geminiThinkingConfig`.
265
+ this.warnIfThinkingUnclampable(options === null || options === void 0 ? void 0 : options.thinkingPolicy);
266
+ const generationConfig = { thinkingConfig: geminiThinkingConfig(this.model, options === null || options === void 0 ? void 0 : options.thinkingPolicy) };
257
267
  // Normalized [0,1] temperature → Gemini's native range, anchored so 0.5
258
268
  // maps to its default (native 1) and 1 to its ceiling (native 2).
259
269
  if ((options === null || options === void 0 ? void 0 : options.temperature) != null) {
@@ -288,34 +298,27 @@ export class GeminiTransport {
288
298
  * message.
289
299
  */
290
300
  logTokenUsage(promptTokens, candidateTokens, thoughtTokens, cachedTokens) {
291
- const { promptPerMillion, candidatePerMillion } = estimatedGeminiPaidRatesUsdPerMillion(this.model, promptTokens);
292
- const m = GeminiTransport.TOKENS_PER_MILLION;
293
- // `promptTokenCount` INCLUDES the cached portion (opposite of Anthropic's `input_tokens`),
294
- // so split it: the uncached remainder bills at the full input rate and the cached slice at
295
- // ~0.1×. The pricing TIER is still chosen on the full prompt size (above). `cachedTokens`
296
- // is 0 when caching wasn't active, so this is a no-op then.
297
- const uncachedPromptTokens = Math.max(0, promptTokens - cachedTokens);
298
- const promptCost = (uncachedPromptTokens / m) * promptPerMillion;
299
- const cacheReadCost = (cachedTokens / m) * promptPerMillion * GEMINI_CACHED_INPUT_MULTIPLIER;
300
- // Thinking tokens are billed at the output (candidate) rate. They are
301
- // incurred whenever the model thinks (always, on 2.5 Pro) — counting them
302
- // here corrects a long-standing undercount, it does not raise the bill.
303
- const candidateCost = ((candidateTokens + thoughtTokens) / m) * candidatePerMillion;
304
- const totalCost = promptCost + cacheReadCost + candidateCost;
305
- this.lifetimeCostUsd += totalCost;
306
- // Savings vs no caching: the cached tokens would have cost full input price; we paid ~0.1×.
307
- // Implicit caching has no write premium, so this is always ≥ 0.
308
- const saved = (cachedTokens / m) * promptPerMillion - cacheReadCost;
309
- this.lifetimeSavingsUsd += saved;
301
+ // The arithmetic, the rate table and the long-context tiering rule live in
302
+ // `utils/token-cost` so a host pricing its own requests uses the same numbers
303
+ // instead of a hand-copied table that drifts. This method keeps only what is
304
+ // instance-scoped: the lifetime accumulators and the log.
305
+ const { costUsd, savedUsd, breakdown } = geminiTokenCost(this.model, {
306
+ promptTokens,
307
+ candidateTokens,
308
+ thoughtTokens,
309
+ cachedTokens,
310
+ });
311
+ this.lifetimeCostUsd += costUsd;
312
+ this.lifetimeSavingsUsd += savedUsd;
310
313
  const dp = GeminiTransport.COST_DECIMAL_PLACES;
311
314
  console.log(`--- Gemini Token Usage (${this.model}) ---`);
312
- console.log(`Prompt Tokens: ${promptTokens} (${cachedTokens} cached) ($${promptCost.toFixed(dp)} + $${cacheReadCost.toFixed(dp)})`);
313
- console.log(`Candidate Tokens: ${candidateTokens} (+${thoughtTokens} thinking) ($${candidateCost.toFixed(dp)})`);
314
- console.log(`Total Cost: $${totalCost.toFixed(dp)}`);
315
- console.log(`Cache Saved: $${saved.toFixed(dp)} (lifetime $${this.lifetimeSavingsUsd.toFixed(dp)})`);
315
+ console.log(`Prompt Tokens: ${promptTokens} (${cachedTokens} cached) ($${breakdown.promptUsd.toFixed(dp)} + $${breakdown.cacheReadUsd.toFixed(dp)})`);
316
+ console.log(`Candidate Tokens: ${candidateTokens} (+${thoughtTokens} thinking) ($${breakdown.candidateUsd.toFixed(dp)})`);
317
+ console.log(`Total Cost: $${costUsd.toFixed(dp)}`);
318
+ console.log(`Cache Saved: $${savedUsd.toFixed(dp)} (lifetime $${this.lifetimeSavingsUsd.toFixed(dp)})`);
316
319
  console.log(`Lifetime Cost: $${this.lifetimeCostUsd.toFixed(dp)}`);
317
320
  console.log('--------------------------');
318
- return totalCost;
321
+ return costUsd;
319
322
  }
320
323
  toGeminiContents(history, userMessage, attachments) {
321
324
  var _a, _b, _c;
@@ -16,3 +16,35 @@ export const SUPPORTED_ANTHROPIC_MODEL_IDS = [
16
16
  'claude-sonnet-4-6',
17
17
  'claude-haiku-4-5-20251001',
18
18
  ];
19
+ /**
20
+ * Which vendor a model id belongs to, or `undefined` when no supported provider claims it.
21
+ *
22
+ * @remarks
23
+ * Derived from {@link SUPPORTED_ANTHROPIC_MODEL_IDS} and {@link SUPPORTED_GEMINI_MODEL_IDS},
24
+ * so it cannot drift from them the way a hand-maintained map does — adding a model to an
25
+ * allowlist is enough to teach this too.
26
+ *
27
+ * Exists because callers hold a **model id**, not a vendor. A usage ledger reads a message's
28
+ * `model` and then has to pick the matching pricing function — `anthropicTokenCost` vs
29
+ * `geminiTokenCost` — whose usage records are deliberately not interchangeable, because the
30
+ * two providers disagree about whether the prompt figure includes the cached part. Without
31
+ * this, every such caller writes its own model→vendor map, which is the same duplication that
32
+ * exporting the pricing removes.
33
+ *
34
+ * Returns `undefined` rather than falling back to a default vendor so an unrecognised model
35
+ * is a decision the caller has to make out loud. Silently defaulting is precisely how an
36
+ * unlisted model gets priced at some other tier's rates — a consumer has already had Haiku
37
+ * billed at Sonnet rates that way.
38
+ *
39
+ * Takes a plain `string`, not a union: the interesting callers are reading a model id back
40
+ * off a stored message or a config file, where it is untrusted text.
41
+ *
42
+ * @beta
43
+ */
44
+ export function vendorOfModel(modelId) {
45
+ if (SUPPORTED_ANTHROPIC_MODEL_IDS.includes(modelId))
46
+ return 'anthropic';
47
+ if (SUPPORTED_GEMINI_MODEL_IDS.includes(modelId))
48
+ return 'gemini';
49
+ return undefined;
50
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Per-request cost pricing for the models this package can call.
3
+ *
4
+ * @remarks
5
+ * Extracted from the two transports' private cost paths so a host that prices
6
+ * requests of its own — a server proxy, a usage ledger, a benchmark — computes the
7
+ * same numbers instead of hand-copying the rate tables. Duplicated tables drift:
8
+ * an unlisted model silently prices at whatever the copy's fallback is, and a
9
+ * missing multiplier misprices every cached request.
10
+ *
11
+ * **Typed over raw provider usage, deliberately.** These take the token counts a
12
+ * provider response reports, NOT `ChatMessage`/`AggregateUsage`. Two reasons:
13
+ *
14
+ * 1. `ChatMessage.cacheWriteTokens` is a single total, but Anthropic bills cache
15
+ * writes per TTL (5-minute at 1.25×, 1-hour at 2×). The split exists only in
16
+ * the provider response, so pricing from a message cannot be exact.
17
+ * 2. The path that actually needs to compute a cost is the one where no transport
18
+ * stamped one — a proxied or server-side call holding a raw usage block.
19
+ *
20
+ * When a `ChatMessage` already carries `cost`, use that; it was computed here at
21
+ * request time with information the message no longer has. Re-deriving a cost
22
+ * from a message's token fields is wrong in the expensive direction, because
23
+ * `inputTokens` is total prompt size *including* the cached part.
24
+ *
25
+ * **What this model cannot express, by construction.** Everything here is
26
+ * *per-request*: a function of the tokens one call consumed. Caching priced by
27
+ * **elapsed time** does not fit, and no additional bucket would make it fit —
28
+ * Gemini's explicit context caching, for instance, charges an hourly storage fee
29
+ * that accrues while you are making no requests at all. Nothing to attribute it to.
30
+ *
31
+ * A provider whose caching is billed that way needs a **separate ledger entry**,
32
+ * recorded against a cache's lifetime rather than against a request, and summed
33
+ * alongside these figures rather than inside them. Do not fold it into
34
+ * {@link TokenCostBreakdown} — a storage fee divided across whatever requests
35
+ * happened to occur is an invented number, and it would make two runs with
36
+ * identical token usage report different costs for reasons neither run caused.
37
+ *
38
+ * The vendors also differ in shape more than they differ in rates, which is why
39
+ * there is a function per provider rather than one with a normalised record:
40
+ * Anthropic charges a premium for the cache *write* and lets you choose the TTL;
41
+ * Gemini's implicit caching has no write charge at all; a provider with automatic,
42
+ * non-configurable caching has neither a write bucket nor a TTL to record. The
43
+ * shared vocabulary is the RESULT ({@link TokenCost}), not the input.
44
+ */
45
+ /** One million — the unit every published rate is quoted in. */
46
+ const TOKENS_PER_MILLION = 1000000;
47
+ // ---------------------------------------------------------------------------
48
+ // Anthropic
49
+ // ---------------------------------------------------------------------------
50
+ /**
51
+ * Standard tier pricing per million tokens —
52
+ * https://docs.claude.com/en/docs/about-claude/pricing
53
+ *
54
+ * @beta
55
+ */
56
+ const ANTHROPIC_PRICING = {
57
+ 'claude-haiku-4-5-20251001': { promptPerMillion: 1, candidatePerMillion: 5 },
58
+ // Fable 5 — Anthropic's most capable widely-released model; priced above Opus tier.
59
+ 'claude-fable-5': { promptPerMillion: 10, candidatePerMillion: 50 },
60
+ // Opus 4.7 / 4.8 — same $5 / $25 per MTok. Stated per model rather than shared through a
61
+ // fall-through, so neither is ever "whatever was left over".
62
+ 'claude-opus-4-8': { promptPerMillion: 5, candidatePerMillion: 25 },
63
+ 'claude-opus-4-7': { promptPerMillion: 5, candidatePerMillion: 25 },
64
+ // Sonnet 5 is $2 / $10 — its OWN tier, and CHEAPER than the older Sonnet 4.6 below.
65
+ //
66
+ // This launched as an introductory rate through 2026-08-31, and an earlier version of this
67
+ // table deliberately charged the $3 / $15 standard rate instead, on the reasoning that the
68
+ // introductory period would lapse. Anthropic has since confirmed $2 / $10 as the permanent
69
+ // standard price and cancelled the scheduled increase, so that reasoning is dead and the old
70
+ // figure over-charged every Sonnet 5 request by 50%.
71
+ // https://platform.claude.com/docs/en/about-claude/pricing#model-pricing
72
+ 'claude-sonnet-5': { promptPerMillion: 2, candidatePerMillion: 10 },
73
+ // Sonnet 4.6 remains on the older $3 / $15 Sonnet tier — the newer model is the cheaper one.
74
+ 'claude-sonnet-4-6': { promptPerMillion: 3, candidatePerMillion: 15 },
75
+ };
76
+ /**
77
+ * Standard tier pricing per million tokens —
78
+ * https://docs.claude.com/en/docs/about-claude/pricing
79
+ *
80
+ * @remarks
81
+ * Backed by a total {@link https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type | Record}
82
+ * over `AnthropicModelId` rather than a chain of checks with a default. That is the whole
83
+ * point: adding a model to `SUPPORTED_ANTHROPIC_MODEL_IDS` without pricing it becomes a
84
+ * COMPILE error instead of a silent charge at whatever the fall-through happened to be — the
85
+ * exact failure this module's header warns about, and one a consumer has already been bitten
86
+ * by. The Gemini half of this file has always had the guarantee; this half now matches.
87
+ *
88
+ * @beta
89
+ */
90
+ export function anthropicRatesFor(model) {
91
+ return ANTHROPIC_PRICING[model];
92
+ }
93
+ /**
94
+ * Prompt-cache pricing multipliers, applied to the model's base input rate
95
+ * (`promptPerMillion`) — https://docs.claude.com/en/docs/build-with-claude/prompt-caching
96
+ * Reads bill at ~0.1× base input; writes bill by TTL — 5-minute at ~1.25× and 1-hour at 2×.
97
+ * Both write TTLs are reachable (`CachePolicy.ttl` is `'5m' | '1h'`), so each TTL bucket is
98
+ * costed from the response's per-TTL `cache_creation` breakdown rather than assuming one rate.
99
+ *
100
+ * @beta
101
+ */
102
+ export const ANTHROPIC_CACHE_READ_MULTIPLIER = 0.1;
103
+ /** @beta */
104
+ export const ANTHROPIC_CACHE_WRITE_5M_MULTIPLIER = 1.25;
105
+ /** @beta */
106
+ export const ANTHROPIC_CACHE_WRITE_1H_MULTIPLIER = 2;
107
+ /**
108
+ * Cost one Anthropic request.
109
+ *
110
+ * @beta
111
+ */
112
+ export function anthropicTokenCost(model, usage) {
113
+ const { promptPerMillion, candidatePerMillion } = anthropicRatesFor(model);
114
+ const m = TOKENS_PER_MILLION;
115
+ // Split the creation total into its 1-hour portion and the 5-minute remainder and
116
+ // cost each at its own rate. All buckets are multiples of the same base input rate,
117
+ // so summing them stays correct whether or not caching was active (every cache
118
+ // field is 0 when it wasn't).
119
+ const cacheWrite5mTokens = Math.max(0, usage.cacheWriteTokens - usage.cacheWrite1hTokens);
120
+ const promptCost = (usage.uncachedInputTokens / m) * promptPerMillion;
121
+ const cacheReadCost = (usage.cacheReadTokens / m) * promptPerMillion * ANTHROPIC_CACHE_READ_MULTIPLIER;
122
+ const cacheWriteCost = (cacheWrite5mTokens / m) * promptPerMillion * ANTHROPIC_CACHE_WRITE_5M_MULTIPLIER +
123
+ (usage.cacheWrite1hTokens / m) * promptPerMillion * ANTHROPIC_CACHE_WRITE_1H_MULTIPLIER;
124
+ const candidateCost = (usage.outputTokens / m) * candidatePerMillion;
125
+ // Net saving vs no caching: the cache-read tokens would have cost full input price
126
+ // (we paid ~0.1× of that), while the write premium is an upfront cost that pays back
127
+ // on later reads — so the net dips negative on a write-heavy request and climbs
128
+ // positive as reads accrue.
129
+ const cacheReadFull = (usage.cacheReadTokens / m) * promptPerMillion;
130
+ const cacheWriteFull = (usage.cacheWriteTokens / m) * promptPerMillion;
131
+ return {
132
+ costUsd: promptCost + cacheReadCost + cacheWriteCost + candidateCost,
133
+ savedUsd: cacheReadFull - cacheReadCost + (cacheWriteFull - cacheWriteCost),
134
+ breakdown: {
135
+ promptUsd: promptCost,
136
+ cacheReadUsd: cacheReadCost,
137
+ cacheWriteUsd: cacheWriteCost,
138
+ candidateUsd: candidateCost,
139
+ },
140
+ };
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Gemini
144
+ // ---------------------------------------------------------------------------
145
+ /**
146
+ * Prompt size (tokens) at or below which the standard pricing tier applies.
147
+ * Above it, Gemini's long-context tier kicks in. Google applies a single tier
148
+ * to the whole request based on prompt size — it is not a marginal/blended rate.
149
+ *
150
+ * @beta
151
+ */
152
+ export const GEMINI_LONG_CONTEXT_THRESHOLD = 200000;
153
+ /**
154
+ * Cached / context-cache input tokens bill at a flat ~10% of the model's normal input
155
+ * rate — consistent across models (flash $0.30→$0.03, flash-lite $0.10→$0.01,
156
+ * pro $1.25→$0.125). Source: https://ai.google.dev/gemini-api/docs/pricing.
157
+ * (Explicit context caching also has an hourly storage fee; implicit caching — what the
158
+ * transport relies on — has none, so only this read discount applies. There is no write
159
+ * premium on this provider, which is why its savings figure is never negative.)
160
+ *
161
+ * @beta
162
+ */
163
+ export const GEMINI_CACHED_INPUT_MULTIPLIER = 0.1;
164
+ /**
165
+ * Paid Standard tier rates (USD per million tokens) —
166
+ * https://ai.google.dev/gemini-api/docs/pricing
167
+ */
168
+ const GEMINI_PRICING = {
169
+ 'gemini-2.5-flash-lite': {
170
+ kind: 'flat',
171
+ rate: { promptPerMillion: 0.1, candidatePerMillion: 0.4 },
172
+ },
173
+ 'gemini-2.5-flash': { kind: 'flat', rate: { promptPerMillion: 0.3, candidatePerMillion: 2.5 } },
174
+ 'gemini-2.5-pro': {
175
+ kind: 'tiered',
176
+ standard: { promptPerMillion: 1.25, candidatePerMillion: 10.0 },
177
+ longContext: { promptPerMillion: 2.5, candidatePerMillion: 15.0 },
178
+ },
179
+ 'gemini-3.1-flash-lite': {
180
+ kind: 'flat',
181
+ rate: { promptPerMillion: 0.25, candidatePerMillion: 1.5 },
182
+ },
183
+ 'gemini-3.5-flash': { kind: 'flat', rate: { promptPerMillion: 1.5, candidatePerMillion: 9.0 } },
184
+ 'gemini-3.1-pro-preview': {
185
+ kind: 'tiered',
186
+ standard: { promptPerMillion: 2.0, candidatePerMillion: 12.0 },
187
+ longContext: { promptPerMillion: 4.0, candidatePerMillion: 18.0 },
188
+ },
189
+ };
190
+ /**
191
+ * Resolve the per-million-token rates for a model given the request's prompt size,
192
+ * selecting the long-context tier for tiered models when the prompt exceeds
193
+ * {@link GEMINI_LONG_CONTEXT_THRESHOLD}.
194
+ *
195
+ * @remarks
196
+ * `promptTokens` must be the **full** prompt size, cached slice included — the tier
197
+ * is chosen on what was sent, not on what was billed at full rate. A mostly-cached
198
+ * long prompt is still a long prompt.
199
+ *
200
+ * Unlike Anthropic's, this accessor is not a pure function of the model, which is
201
+ * why there is no single `ratesFor(model)` across both providers.
202
+ *
203
+ * @beta
204
+ */
205
+ export function geminiRatesFor(model, promptTokens) {
206
+ const pricing = GEMINI_PRICING[model];
207
+ if (pricing.kind === 'flat') {
208
+ return pricing.rate;
209
+ }
210
+ return promptTokens > GEMINI_LONG_CONTEXT_THRESHOLD ? pricing.longContext : pricing.standard;
211
+ }
212
+ /**
213
+ * Cost one Gemini request.
214
+ *
215
+ * @beta
216
+ */
217
+ export function geminiTokenCost(model, usage) {
218
+ // The pricing TIER is chosen on the full prompt size, BEFORE the cache split.
219
+ const { promptPerMillion, candidatePerMillion } = geminiRatesFor(model, usage.promptTokens);
220
+ const m = TOKENS_PER_MILLION;
221
+ const uncachedPromptTokens = Math.max(0, usage.promptTokens - usage.cachedTokens);
222
+ const promptCost = (uncachedPromptTokens / m) * promptPerMillion;
223
+ const cacheReadCost = (usage.cachedTokens / m) * promptPerMillion * GEMINI_CACHED_INPUT_MULTIPLIER;
224
+ // Thinking tokens bill at the output rate, and are incurred whenever the model
225
+ // thinks (always, on 2.5 Pro).
226
+ const candidateCost = ((usage.candidateTokens + usage.thoughtTokens) / m) * candidatePerMillion;
227
+ return {
228
+ costUsd: promptCost + cacheReadCost + candidateCost,
229
+ // Implicit caching has no write premium, so this is always >= 0.
230
+ savedUsd: (usage.cachedTokens / m) * promptPerMillion - cacheReadCost,
231
+ breakdown: {
232
+ promptUsd: promptCost,
233
+ cacheReadUsd: cacheReadCost,
234
+ cacheWriteUsd: 0,
235
+ candidateUsd: candidateCost,
236
+ },
237
+ };
238
+ }