@broberg/ai-sdk 0.39.0 → 0.41.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.
@@ -48,9 +48,13 @@ var PRICING = {
48
48
  "openai:text-embedding-3-large": { inputPer1M: 0.13, outputPer1M: 0, version: V },
49
49
  "openai:gpt-4o": { inputPer1M: 2.5, cacheReadPer1M: 1.25, outputPer1M: 10, version: "2026-08-27-developers.openai.com" },
50
50
  "openai:gpt-4o-mini": { inputPer1M: 0.15, cacheReadPer1M: 0.075, outputPer1M: 0.6, version: "2026-08-27-developers.openai.com" },
51
- // Whisper is priced per minute, not per token not representable here; transcribe
52
- // (F5.6) computes its own cost. Listed as 0 so token-based compute never charges it.
53
- "openai:whisper-1": { inputPer1M: 0, outputPer1M: 0, version: V },
51
+ // Whisper is priced per MINUTE see MEDIA_PRICING in ./media-pricing.ts. It used to
52
+ // sit here as 0/0 "so token-based compute never charges it", and inwardly that worked.
53
+ // Outwardly it did not: getModelPrice("whisper-1") answered `{unit:"per_1m_tokens",
54
+ // inputPer1M: 0}` — i.e. "this model is free" — because media rows are consulted LAST
55
+ // and a token row was already there. The per-minute price was unreachable by its own
56
+ // id. computeCost still returns 0 for it (no entry) and transcribe still computes its
57
+ // own cost, so nothing about billing changed; only the public answer stopped lying.
54
58
  // OpenRouter (meta-router — model slugs include the upstream vendor). Slugs use
55
59
  // dots (claude-sonnet-4.6) to match OpenRouter's live ids; the dashed forms
56
60
  // never matched a real call. Caught by the F014 catalogue research.
@@ -140,6 +144,93 @@ function getPrice(provider, model) {
140
144
  return void 0;
141
145
  }
142
146
 
147
+ // src/cost/media-pricing.ts
148
+ var MEDIA_PRICING_CHECKED_AT = "2026-09-05";
149
+ var VEO_PER_SEC = {
150
+ "veo-3.1-generate-preview": { usd: 0.4, source: "ai.google.dev/gemini-api/docs/pricing \u2014 video with audio, 720p/1080p (4K = 0.60)" },
151
+ "veo-3.1-fast-generate-preview": { usd: 0.1, source: "ai.google.dev/gemini-api/docs/pricing \u2014 720p (1080p = 0.12, 4K = 0.30)" },
152
+ "veo-3.1-lite-generate-preview": { usd: 0.05, source: "ai.google.dev/gemini-api/docs/pricing \u2014 720p (1080p = 0.08)" },
153
+ "veo-3.0-generate-001": { usd: 0.4, source: "ai.google.dev/gemini-api/docs/pricing" },
154
+ "veo-3.0-fast-generate-001": { usd: 0.1, source: "ai.google.dev/gemini-api/docs/pricing" }
155
+ };
156
+ var veoRows = (provider) => Object.fromEntries(
157
+ Object.entries(VEO_PER_SEC).map(([model, v]) => [
158
+ `${provider}:${model}`,
159
+ { unit: "per_sec", usd: v.usd, checkedAt: MEDIA_PRICING_CHECKED_AT, source: v.source }
160
+ ])
161
+ );
162
+ var MEDIA_PRICING = {
163
+ ...veoRows("gemini"),
164
+ ...veoRows("vertex"),
165
+ // fal video — fal's OFFICIAL published per-second rate, not a guess. Kling 2.5
166
+ // Turbo Pro i2v is the blessed FAL_KEY-only image→video route: $0.35 for the
167
+ // first 5 s + $0.07/additional s = a flat $0.07/s at 1080p.
168
+ "fal:fal-ai/kling-video/v2.5-turbo/pro/image-to-video": {
169
+ unit: "per_sec",
170
+ usd: 0.07,
171
+ checkedAt: MEDIA_PRICING_CHECKED_AT,
172
+ source: "fal.ai/models/fal-ai/kling-video/v2.5-turbo/pro/image-to-video"
173
+ },
174
+ // fal images — ESTIMATES. fal bills by megapixel and changes often; fal returns
175
+ // no price with the result, so a call costed from these carries
176
+ // `costBasis: "estimated"`. Override per call with config.pricePerImage.
177
+ "fal:fal-ai/flux/schnell": { unit: "per_image", usd: 3e-3, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "fal.ai pricing \u2014 estimate, billed by megapixel" },
178
+ "fal:fal-ai/flux/dev": { unit: "per_image", usd: 0.025, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "fal.ai pricing \u2014 estimate, billed by megapixel" },
179
+ "fal:fal-ai/flux-lora": { unit: "per_image", usd: 0.025, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "fal.ai pricing \u2014 estimate, billed by megapixel" },
180
+ "fal:fal-ai/flux-pro": { unit: "per_image", usd: 0.05, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "fal.ai pricing \u2014 estimate, billed by megapixel" },
181
+ "fal:fal-ai/flux-pro/v1.1": { unit: "per_image", usd: 0.04, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "fal.ai pricing \u2014 estimate, billed by megapixel" },
182
+ // BFL — the fallback only. BFL RETURNS the real billed cost in credits, so a
183
+ // normal call is `costBasis: "reported"` and never reads this row.
184
+ "bfl:flux-pro-1.1-ultra-finetuned": {
185
+ unit: "per_image",
186
+ usd: 0.06,
187
+ checkedAt: MEDIA_PRICING_CHECKED_AT,
188
+ source: "bfl.ai/pricing \u2014 estimate; used only when BFL omits the credit cost"
189
+ },
190
+ // Gemini images — billed per image, not per token. Google's price RISES with
191
+ // resolution; these are the common 1K/1024px default.
192
+ "gemini:gemini-2.5-flash-image": { unit: "per_image", usd: 0.039, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "ai.google.dev/gemini-api/docs/pricing \u2014 'nano-banana', 1024px = 1290 tok" },
193
+ "gemini:gemini-3.1-flash-image": { unit: "per_image", usd: 0.067, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "ai.google.dev/gemini-api/docs/pricing \u2014 1K; 2K=0.101, 4K=0.151" },
194
+ "gemini:gemini-3.1-flash-image-preview": { unit: "per_image", usd: 0.067, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "ai.google.dev/gemini-api/docs/pricing \u2014 1K; 2K=0.101, 4K=0.151" },
195
+ "gemini:gemini-3-pro-image": { unit: "per_image", usd: 0.134, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "ai.google.dev/gemini-api/docs/pricing \u2014 premium; 1K/2K=0.134, 4K=0.24" },
196
+ "gemini:gemini-3-pro-image-preview": { unit: "per_image", usd: 0.134, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "ai.google.dev/gemini-api/docs/pricing \u2014 premium; was mistakenly the flash price once" },
197
+ // OpenRouter images — the FALLBACK only. OpenRouter usually returns usage.cost.
198
+ "openrouter:recraft/recraft-v4.1": { unit: "per_image", usd: 0.035, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "openrouter.ai/recraft/recraft-v4.1 \u2014 fallback when usage.cost is omitted" },
199
+ "openrouter:recraft/recraft-v4.1-vector": { unit: "per_image", usd: 0.08, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "openrouter.ai/recraft/recraft-v4.1-vector \u2014 fallback when usage.cost is omitted" },
200
+ // Speech OUT — billed per 1000 characters of input text.
201
+ "azure:tts": { unit: "per_1k_chars", usd: 0.016, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "azure.microsoft.com/pricing \u2014 neural standard, $16/1M chars" },
202
+ "elevenlabs:tts": { unit: "per_1k_chars", usd: 0.15, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "elevenlabs.io/pricing \u2014 API overage approx $0.10-0.18/1k chars; ESTIMATE" },
203
+ // Translation — per 1000 characters. Free tier (":fx" key, within quota) is truly $0;
204
+ // this rate only bites past the Pro allowance.
205
+ "deepl:translate": { unit: "per_1k_chars", usd: 0.0217, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "deepl.com/pro-api \u2014 ESTIMATE, conflicting tier reports; verify before a budget decision" },
206
+ // Speech IN — billed per audio-minute.
207
+ "azure:stt": { unit: "per_min", usd: 0.0167, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "azure.microsoft.com/pricing \u2014 standard STT, $1/audio-hour" },
208
+ "openai:whisper-1": { unit: "per_min", usd: 6e-3, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "openai.com/api/pricing \u2014 Whisper" },
209
+ "mistral:voxtral-mini-latest": { unit: "per_min", usd: 2e-3, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "mistral.ai/pricing \u2014 Voxtral" },
210
+ "mistral:voxtral-mini-2507": { unit: "per_min", usd: 2e-3, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "mistral.ai/pricing \u2014 Voxtral" },
211
+ "mistral:voxtral-mini-2602": { unit: "per_min", usd: 2e-3, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "mistral.ai/pricing \u2014 Voxtral" },
212
+ // OCR — per page.
213
+ "mistral:ocr": { unit: "per_page", usd: 2e-3, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "mistral.ai/pricing \u2014 OCR, $2/1000 pages" },
214
+ // A flat fee per training run, not per anything produced.
215
+ "fal:train": { unit: "per_training", usd: 2, checkedAt: MEDIA_PRICING_CHECKED_AT, source: "fal.ai/models/fal-ai/flux-lora-fast-training \u2014 approx $2, ESTIMATE" }
216
+ };
217
+ var DEFAULT_CLIP_SEC = 8;
218
+ function getMediaPrice(provider, model) {
219
+ return MEDIA_PRICING[`${provider}:${model}`];
220
+ }
221
+ function mediaUnitCounts() {
222
+ const counts = {
223
+ per_sec: 0,
224
+ per_image: 0,
225
+ per_1k_chars: 0,
226
+ per_min: 0,
227
+ per_page: 0,
228
+ per_training: 0
229
+ };
230
+ for (const p of Object.values(MEDIA_PRICING)) counts[p.unit]++;
231
+ return counts;
232
+ }
233
+
143
234
  // src/catalogue/pricing-data.ts
144
235
  var PRICING_GENERATED_AT = "2026-09-03T19:06:51.817Z";
145
236
  var PRICING_CHECKED_AT = "2026-09-03T19:06:58.096Z";
@@ -171,6 +262,34 @@ function regionForProvider(provider) {
171
262
  return "other";
172
263
  }
173
264
  }
265
+ var _media = null;
266
+ function ensureMedia() {
267
+ if (_media) return _media;
268
+ const m = /* @__PURE__ */ new Map();
269
+ for (const [key, p] of Object.entries(MEDIA_PRICING)) {
270
+ const ci = key.indexOf(":");
271
+ const provider = ci >= 0 ? key.slice(0, ci) : "";
272
+ const model = ci >= 0 ? key.slice(ci + 1) : key;
273
+ const row = {
274
+ provider,
275
+ model,
276
+ unit: p.unit,
277
+ usd: p.usd,
278
+ ...p.unit === "per_sec" ? { perSec: p.usd } : {},
279
+ ...p.unit === "per_image" ? { perImage: p.usd } : {},
280
+ checkedAt: p.checkedAt,
281
+ region: regionForProvider(provider),
282
+ source: "curated"
283
+ };
284
+ if (!m.has(model.toLowerCase())) m.set(model.toLowerCase(), row);
285
+ m.set(key.toLowerCase(), row);
286
+ }
287
+ _media = m;
288
+ return m;
289
+ }
290
+ function listMediaPrices() {
291
+ return [...new Set(ensureMedia().values())];
292
+ }
174
293
  var _list = null;
175
294
  var _full = null;
176
295
  var _base = null;
@@ -186,7 +305,7 @@ function ensure() {
186
305
  name: r.name,
187
306
  inputPer1M: r.input,
188
307
  outputPer1M: r.output,
189
- unit: r.unit,
308
+ unit: "per_1m_tokens",
190
309
  region: ["eu", "us", "cn", "other"].includes(r.region) ? r.region : "other",
191
310
  source: "inventory"
192
311
  };
@@ -220,12 +339,21 @@ function ensure() {
220
339
  _list = list;
221
340
  _base = byBase;
222
341
  _full = new Map(list.map((e) => [e.model.toLowerCase(), e]));
342
+ for (const [key, mp] of Object.entries(MEDIA_PRICING)) {
343
+ const model = key.slice(key.indexOf(":") + 1).toLowerCase();
344
+ const tok = _full.get(model) ?? byBase.get(basename(model));
345
+ if (tok) tok.alsoBilled = { unit: mp.unit, usd: mp.usd, checkedAt: mp.checkedAt };
346
+ }
223
347
  }
224
348
  function getModelPrice(modelId) {
225
349
  ensure();
226
350
  const s = modelId.trim().toLowerCase();
227
351
  const dated = stripDatedSuffix(s);
228
- return _full.get(s) ?? (s.includes(":") ? _full.get(s.slice(s.indexOf(":") + 1)) : void 0) ?? _base.get(basename(s)) ?? (dated !== s ? _full.get(dated) ?? _base.get(basename(dated)) : void 0);
352
+ const media = ensureMedia();
353
+ return _full.get(s) ?? (s.includes(":") ? _full.get(s.slice(s.indexOf(":") + 1)) : void 0) ?? _base.get(basename(s)) ?? (dated !== s ? _full.get(dated) ?? _base.get(basename(dated)) : void 0) ?? // Media LAST, so a token model never loses its own row to a media id collision.
354
+ // Before F050 this returned undefined for every video model, which a caller could
355
+ // not tell apart from "we have no price for that anywhere".
356
+ media.get(s) ?? (s.includes(":") ? media.get(s.slice(s.indexOf(":") + 1)) : void 0);
229
357
  }
230
358
  function listModelPrices() {
231
359
  ensure();
@@ -250,33 +378,68 @@ function pricingGeneratedAt() {
250
378
  return PRICING_GENERATED_AT;
251
379
  }
252
380
  var PRICING_STALE_AFTER_DAYS = 35;
253
- function computeFreshness(generatedAt, checkedAt, nowMs) {
254
- const t = checkedAt ? Date.parse(checkedAt) : NaN;
255
- const ageDays = Number.isFinite(t) ? Math.floor((nowMs - t) / 864e5) : null;
381
+ function computeFreshness(generatedAt, checkedAt, nowMs, units = []) {
382
+ const age = (iso) => {
383
+ const t = iso ? Date.parse(iso) : NaN;
384
+ return Number.isFinite(t) ? Math.max(0, Math.floor((nowMs - t) / 864e5)) : null;
385
+ };
386
+ const isStale = (d) => d === null || d > PRICING_STALE_AFTER_DAYS;
387
+ const ageDays = age(checkedAt);
388
+ const unitRows = units.map((u) => {
389
+ const a = age(u.checkedAt);
390
+ return { unit: u.unit, count: u.count, checkedAt: u.checkedAt, ageDays: a, stale: isStale(a) };
391
+ });
392
+ const caveats = unitRows.flatMap(
393
+ (u) => u.count === 0 ? [`${u.unit}: 0 rows \u2014 this table cannot price anything billed in ${u.unit}, so "stale: false" says nothing about it`] : u.stale ? [
394
+ `${u.unit}: ${u.count} row(s) last verified ${u.checkedAt || "never"}` + (u.ageDays === null ? "" : ` (${u.ageDays} days ago)`) + `, past the ${PRICING_STALE_AFTER_DAYS}d threshold`
395
+ ] : []
396
+ );
256
397
  return {
257
398
  generatedAt,
258
399
  checkedAt,
259
400
  ageDays,
260
- // No check date → stale. The absent case must not read as the healthy one; that is
261
- // the whole failure this feature exists to remove.
262
- stale: ageDays === null || ageDays > PRICING_STALE_AFTER_DAYS,
263
- thresholdDays: PRICING_STALE_AFTER_DAYS
401
+ stale: isStale(ageDays),
402
+ thresholdDays: PRICING_STALE_AFTER_DAYS,
403
+ units: unitRows,
404
+ caveats
264
405
  };
265
406
  }
266
407
  function pricingFreshness(nowMs = Date.now()) {
267
- return computeFreshness(PRICING_GENERATED_AT, PRICING_CHECKED_AT, nowMs);
408
+ return computeFreshness(PRICING_GENERATED_AT, PRICING_CHECKED_AT, nowMs, [
409
+ { unit: "per_1m_tokens", count: PRICING_DATA.length, checkedAt: PRICING_CHECKED_AT },
410
+ // Every media unit, derived from the table rather than listed here — a unit added
411
+ // to MEDIA_PRICING and forgotten here would be a unit the freshness API silently
412
+ // does not report, which is this whole feature's own bug wearing a new hat.
413
+ // Hand-maintained, and the monthly job cannot reach them, hence their own date:
414
+ // inheriting the token snapshot's would make an un-revised video price look freshly
415
+ // verified every time the token job ran.
416
+ ...Object.entries(mediaUnitCounts()).map(([unit, count]) => ({
417
+ unit,
418
+ count,
419
+ checkedAt: MEDIA_PRICING_CHECKED_AT
420
+ }))
421
+ ]);
268
422
  }
269
423
  var warned = false;
270
424
  function warnIfPricingStale(nowMs = Date.now()) {
271
425
  if (warned) return;
272
426
  if (globalThis.process?.env?.BROBERG_AI_SDK_SILENCE_PRICING_WARNING) return;
273
427
  const f = pricingFreshness(nowMs);
274
- if (!f.stale) return;
428
+ if (!f.stale && f.caveats.length === 0) return;
275
429
  warned = true;
276
- const age = f.ageDays === null ? "of unknown age" : `${f.ageDays} days old`;
277
- console.warn(
278
- `[@broberg/ai-sdk] price table is ${age} (last verified ${f.checkedAt || "never"}, threshold ${f.thresholdDays}d). Prices drift: one week has produced 34 changes before. Refresh with \`bun run scripts/build-inventory.ts\` in ai-sdk, or take a newer release.`
279
- );
430
+ const parts = [];
431
+ if (f.stale) {
432
+ const age = f.ageDays === null ? "of unknown age" : `${f.ageDays} days old`;
433
+ parts.push(
434
+ `price table is ${age} (last verified ${f.checkedAt || "never"}, threshold ${f.thresholdDays}d). Prices drift: one week has produced 34 changes before. Refresh with \`bun run scripts/build-inventory.ts\` in ai-sdk, or take a newer release.`
435
+ );
436
+ }
437
+ if (f.caveats.length > 0) {
438
+ parts.push(
439
+ `${f.caveats.length} pricing caveat(s): ${f.caveats.join("; ")}. Non-token prices are HAND-maintained (no vendor catalogue API serves them), so refreshing them means a human re-reading the vendor's pricing page and bumping MEDIA_PRICING_CHECKED_AT in src/cost/media-pricing.ts.`
440
+ );
441
+ }
442
+ console.warn(`[@broberg/ai-sdk] ${parts.join(" | ")}`);
280
443
  }
281
444
  function resetPricingWarningForTests() {
282
445
  warned = false;
@@ -284,6 +447,10 @@ function resetPricingWarningForTests() {
284
447
 
285
448
  export {
286
449
  getPrice,
450
+ MEDIA_PRICING_CHECKED_AT,
451
+ DEFAULT_CLIP_SEC,
452
+ getMediaPrice,
453
+ listMediaPrices,
287
454
  getModelPrice,
288
455
  listModelPrices,
289
456
  findModelPrices,
@@ -294,4 +461,4 @@ export {
294
461
  warnIfPricingStale,
295
462
  resetPricingWarningForTests
296
463
  };
297
- //# sourceMappingURL=chunk-QUOZWQ7G.js.map
464
+ //# sourceMappingURL=chunk-B74RSBT5.js.map