@broberg/ai-sdk 0.38.0 → 0.40.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/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  export { AvailabilitySource, AvailabilityStatus, ModelStatus, ModelUnavailableError, ResolveOptions, ResolveResult, listModels, resolveModel } from './registry.js';
3
- export { PRICING_STALE_AFTER_DAYS, PricingFreshness, pricingFreshness, pricingGeneratedAt } from './pricing.js';
3
+ export { DEFAULT_CLIP_SEC, MEDIA_PRICING_CHECKED_AT, MediaPrice, MediaUnit, PRICING_STALE_AFTER_DAYS, PricingFreshness, UnitFreshness, getMediaPrice, listMediaPrices, pricingFreshness, pricingGeneratedAt } from './pricing.js';
4
4
 
5
5
  /** Coarse data-residency of the endpoint a call went to.
6
6
  *
@@ -39,6 +39,18 @@ declare function classifyRegionName(name: string | undefined): Region;
39
39
  * is it?" — a narrower question than "where will my call go?". */
40
40
  declare function regionOfProvider(provider: string): Region;
41
41
 
42
+ /** One dictionary entry. `alias` says it differently; `ipa` says it precisely. */
43
+ interface Pronunciation {
44
+ /** The word as it appears in the text. Matched whole-word, case-insensitively. */
45
+ word: string;
46
+ /** Say this instead. Azure `<sub alias>`; ElevenLabs plain substitution. */
47
+ alias?: string;
48
+ /** IPA phonemes. Azure `<phoneme alphabet="ipa" ph>`. ElevenLabs cannot do this. */
49
+ ipa?: string;
50
+ /** Reserved for a future per-entry language switch; carried but not yet emitted. */
51
+ lang?: string;
52
+ }
53
+
42
54
  /** How a call reaches the model. `http` = provider REST API; `subprocess` = local
43
55
  * `claude -p` CLI (Max plan, costUsd 0). */
44
56
  type Transport = "http" | "subprocess";
@@ -147,6 +159,24 @@ interface Usage {
147
159
  cacheReadTokens: number;
148
160
  cacheCreationTokens: number;
149
161
  costUsd: number;
162
+ /** How `costUsd` was arrived at (F050). Absent means the same as `"computed"`;
163
+ * it is optional only so that adding it could not break a consumer building a
164
+ * Usage by hand.
165
+ *
166
+ * - `"reported"` — the PROVIDER told us what the call cost (BFL returns credits).
167
+ * - `"computed"` — a price table rate × a quantity we actually measured (tokens).
168
+ * - `"estimated"` — a quantity or a rate was ASSUMED. A video call with no
169
+ * `durationSec` is billed at {@link DEFAULT_CLIP_SEC} seconds regardless of the
170
+ * clip that came back, and fal's per-image rates are fal's own estimates.
171
+ * - `"unpriced"` — we have NO price for this model, so `costUsd` is 0 because we
172
+ * could not answer, not because the call was free. Those two were the same
173
+ * number, and the free one is much rarer than the unknown one.
174
+ *
175
+ * This is a DATA field on purpose. upmetrics distinguishes reported / computed /
176
+ * unpriced / untokened, and it reads fields — a JSDoc note saying "this one is a
177
+ * guess" never reaches it, so an assumed number arrived there indistinguishable
178
+ * from a measured one. super did exactly that and passed it on as measured. */
179
+ costBasis?: "reported" | "computed" | "estimated" | "unpriced";
150
180
  toolCalls?: {
151
181
  name: string;
152
182
  count: number;
@@ -423,6 +453,7 @@ interface PodcastResult {
423
453
  mimeType: string;
424
454
  usage: Usage;
425
455
  }
456
+
426
457
  interface TtsRequest {
427
458
  text: string;
428
459
  voiceId: string;
@@ -432,6 +463,14 @@ interface TtsRequest {
432
463
  format?: string;
433
464
  /** Speaking-rate multiplier (Azure): 1 = normal, 0.9 = 10% slower, 1.1 = faster. ElevenLabs ignores it. */
434
465
  rate?: number;
466
+ /** F051 — pronunciation dictionary. A Danish voice says English jargon wrongly:
467
+ * measured on da-DK, "AI" comes out as the word "aj", "native" as "nativ".
468
+ *
469
+ * Azure renders these as SSML (`<sub alias>` / `<phoneme alphabet="ipa" ph>`);
470
+ * ElevenLabs has no SSML and can only apply `alias`. **This is the CONTROLLED DOOR
471
+ * into SSML** — the substitution happens adapter-side AFTER the text is escaped, so
472
+ * `text` can never inject markup, and `alias`/`ipa` are escaped too. */
473
+ pronunciations?: Pronunciation[];
435
474
  spec: TierSpec;
436
475
  }
437
476
  interface BatchRequestItem {
@@ -1807,6 +1846,24 @@ declare const ttsInputSchema: z.ZodObject<{
1807
1846
  labels: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
1808
1847
  text: z.ZodString;
1809
1848
  voice: z.ZodString;
1849
+ /** F051 — see TtsRequest.pronunciations. The alias/ipa exclusivity is enforced in
1850
+ * the adapter, not here: the message must name the provider that cannot do it. */
1851
+ pronunciations: z.ZodOptional<z.ZodArray<z.ZodObject<{
1852
+ word: z.ZodString;
1853
+ alias: z.ZodOptional<z.ZodString>;
1854
+ ipa: z.ZodOptional<z.ZodString>;
1855
+ lang: z.ZodOptional<z.ZodString>;
1856
+ }, "strip", z.ZodTypeAny, {
1857
+ word: string;
1858
+ alias?: string | undefined;
1859
+ ipa?: string | undefined;
1860
+ lang?: string | undefined;
1861
+ }, {
1862
+ word: string;
1863
+ alias?: string | undefined;
1864
+ ipa?: string | undefined;
1865
+ lang?: string | undefined;
1866
+ }>, "many">>;
1810
1867
  /** F037: voice to use if `voice` is one we know the provider has retired. Without
1811
1868
  * it a retired voice throws VoiceUnavailableError rather than reaching the API.
1812
1869
  *
@@ -1838,8 +1895,14 @@ declare const ttsInputSchema: z.ZodObject<{
1838
1895
  })[] | undefined;
1839
1896
  labels?: Record<string, string> | undefined;
1840
1897
  format?: string | undefined;
1841
- voiceFallback?: string | undefined;
1842
1898
  lang?: string | undefined;
1899
+ pronunciations?: {
1900
+ word: string;
1901
+ alias?: string | undefined;
1902
+ ipa?: string | undefined;
1903
+ lang?: string | undefined;
1904
+ }[] | undefined;
1905
+ voiceFallback?: string | undefined;
1843
1906
  rate?: number | undefined;
1844
1907
  }, {
1845
1908
  text: string;
@@ -1858,8 +1921,14 @@ declare const ttsInputSchema: z.ZodObject<{
1858
1921
  })[] | undefined;
1859
1922
  labels?: Record<string, string> | undefined;
1860
1923
  format?: string | undefined;
1861
- voiceFallback?: string | undefined;
1862
1924
  lang?: string | undefined;
1925
+ pronunciations?: {
1926
+ word: string;
1927
+ alias?: string | undefined;
1928
+ ipa?: string | undefined;
1929
+ lang?: string | undefined;
1930
+ }[] | undefined;
1931
+ voiceFallback?: string | undefined;
1863
1932
  rate?: number | undefined;
1864
1933
  }>;
1865
1934
  declare const aiConfigSchema: z.ZodObject<{
@@ -2251,8 +2320,8 @@ declare const falStubAdapter: ProviderAdapter;
2251
2320
  * wires the live adapters. */
2252
2321
  declare const stubProviders: Record<string, ProviderAdapter>;
2253
2322
 
2254
- declare const VERSION: "0.38.0";
2255
- declare const SDK_TAG: "@broberg/ai-sdk@0.38.0";
2323
+ declare const VERSION: "0.40.0";
2324
+ declare const SDK_TAG: "@broberg/ai-sdk@0.40.0";
2256
2325
 
2257
2326
  /** Built-in defaults. Every entry is overridable via AiConfig.defaults or a
2258
2327
  * per-call override.
package/dist/index.js CHANGED
@@ -10,11 +10,15 @@ import {
10
10
  setAvailability
11
11
  } from "./chunk-ZFWSLSE7.js";
12
12
  import {
13
+ DEFAULT_CLIP_SEC,
14
+ MEDIA_PRICING_CHECKED_AT,
13
15
  PRICING_STALE_AFTER_DAYS,
16
+ getMediaPrice,
14
17
  getPrice,
18
+ listMediaPrices,
15
19
  pricingFreshness,
16
20
  pricingGeneratedAt
17
- } from "./chunk-QUOZWQ7G.js";
21
+ } from "./chunk-KXTCLLVF.js";
18
22
 
19
23
  // src/transport/http.ts
20
24
  async function httpTransport(req) {
@@ -355,6 +359,11 @@ function freshUsage(args) {
355
359
  cacheReadTokens,
356
360
  cacheCreationTokens,
357
361
  costUsd,
362
+ // F050 — say HOW we got the number. A $0 because the model is not in the price
363
+ // table and a $0 because the call was genuinely free were the same value, and the
364
+ // free case is much rarer than the unknown one. Adapters that bill a non-token
365
+ // unit overwrite this with "estimated"/"reported" right after.
366
+ costBasis: args.subprocess ? "computed" : getPrice(args.provider, args.model) ? "computed" : "unpriced",
358
367
  latencyMs: 0,
359
368
  capability: args.capability,
360
369
  ts: ""
@@ -882,9 +891,6 @@ function mapFinishReason(reason) {
882
891
  }
883
892
 
884
893
  // src/providers/openai.ts
885
- var WHISPER_PRICE_PER_MIN = {
886
- "whisper-1": 6e-3
887
- };
888
894
  function openaiAdapter(config = {}) {
889
895
  const baseUrl = config.baseUrl ?? "https://api.openai.com/v1";
890
896
  const base = makeOpenAICompatibleAdapter({ name: "openai", baseUrl, apiKey: config.apiKey });
@@ -945,7 +951,7 @@ function openaiAdapter(config = {}) {
945
951
  // Whisper is per-minute, not token-priced.
946
952
  });
947
953
  if (req.durationSec !== void 0) {
948
- const perMinute = WHISPER_PRICE_PER_MIN[req.spec.model] ?? 0;
954
+ const perMinute = getMediaPrice("openai", req.spec.model)?.usd ?? 0;
949
955
  usage.costUsd = req.durationSec / 60 * perMinute;
950
956
  }
951
957
  const result = { text: data.text ?? "", usage };
@@ -985,17 +991,6 @@ function sniffMime(b) {
985
991
  }
986
992
 
987
993
  // src/providers/gemini.ts
988
- var GEMINI_IMAGE_PRICE_PER_IMAGE = {
989
- "gemini-2.5-flash-image": 0.039,
990
- // "nano-banana" — 1024px = 1290 tok
991
- "gemini-3.1-flash-image": 0.067,
992
- // 1K=$0.067, 2K=$0.101, 4K=$0.151
993
- "gemini-3.1-flash-image-preview": 0.067,
994
- "gemini-3-pro-image": 0.134,
995
- // premium — 1K/2K=$0.134, 4K=$0.24
996
- "gemini-3-pro-image-preview": 0.134
997
- // was $0.039 — wrong (that's the flash price); pro is $0.134
998
- };
999
994
  function splitCached(meta) {
1000
995
  const prompt = meta?.promptTokenCount ?? 0;
1001
996
  const cached = meta?.cachedContentTokenCount;
@@ -1014,16 +1009,6 @@ function partsFrom(content) {
1014
1009
  return { inlineData: { mimeType: p.mimeType ?? "image/png", data } };
1015
1010
  });
1016
1011
  }
1017
- var VEO_PRICE_PER_SEC = {
1018
- "veo-3.1-generate-preview": 0.4,
1019
- // standard; 4K = 0.60
1020
- "veo-3.1-fast-generate-preview": 0.1,
1021
- // 720p; 1080p = 0.12, 4K = 0.30
1022
- "veo-3.1-lite-generate-preview": 0.05,
1023
- // 720p; 1080p = 0.08
1024
- "veo-3.0-generate-001": 0.4,
1025
- "veo-3.0-fast-generate-001": 0.1
1026
- };
1027
1012
  function geminiAdapter(config = {}) {
1028
1013
  const baseUrl = config.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
1029
1014
  function resolveKey() {
@@ -1180,7 +1165,7 @@ function geminiAdapter(config = {}) {
1180
1165
  ...splitCached(data.usageMetadata),
1181
1166
  outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0
1182
1167
  });
1183
- usage.costUsd = config.pricePerImage ?? GEMINI_IMAGE_PRICE_PER_IMAGE[req.spec.model] ?? 0;
1168
+ usage.costUsd = config.pricePerImage ?? getMediaPrice("gemini", req.spec.model)?.usd ?? 0;
1184
1169
  return { url, usage };
1185
1170
  }
1186
1171
  async function animate(req) {
@@ -1232,8 +1217,9 @@ function geminiAdapter(config = {}) {
1232
1217
  inputTokens: 0,
1233
1218
  outputTokens: 0
1234
1219
  });
1235
- const perSec = config.pricePerSecond ?? VEO_PRICE_PER_SEC[req.spec.model] ?? 0;
1236
- usage.costUsd = perSec * (req.durationSec ?? 8);
1220
+ const perSec = config.pricePerSecond ?? getMediaPrice("gemini", req.spec.model)?.usd ?? 0;
1221
+ usage.costUsd = perSec * (req.durationSec ?? DEFAULT_CLIP_SEC);
1222
+ usage.costBasis = req.durationSec === void 0 ? "estimated" : "computed";
1237
1223
  return { url: videoUri, bytes, mimeType: "video/mp4", usage };
1238
1224
  }
1239
1225
  return { name: "gemini", chat, chatStream, image, animate, vision: chat };
@@ -1259,10 +1245,6 @@ function deepinfraAdapter(config = {}) {
1259
1245
  }
1260
1246
 
1261
1247
  // src/providers/openrouter.ts
1262
- var OPENROUTER_IMAGE_PRICE_ESTIMATE = {
1263
- "recraft/recraft-v4.1": 0.035,
1264
- "recraft/recraft-v4.1-vector": 0.08
1265
- };
1266
1248
  function openrouterAdapter(config = {}) {
1267
1249
  const baseUrl = config.baseUrl ?? "https://openrouter.ai/api/v1";
1268
1250
  const headers = {
@@ -1317,7 +1299,7 @@ function openrouterAdapter(config = {}) {
1317
1299
  inputTokens: 0,
1318
1300
  outputTokens: 0
1319
1301
  });
1320
- usage.costUsd = data.usage?.cost ?? config.pricePerImage ?? OPENROUTER_IMAGE_PRICE_ESTIMATE[req.spec.model] ?? 0;
1302
+ usage.costUsd = data.usage?.cost ?? config.pricePerImage ?? getMediaPrice("openrouter", req.spec.model)?.usd ?? 0;
1321
1303
  return { url: `data:${first.media_type ?? "image/png"};base64,${first.b64_json}`, usage };
1322
1304
  }
1323
1305
  return { ...base, image };
@@ -1354,12 +1336,6 @@ function deepseekAdapter(config = {}) {
1354
1336
  }
1355
1337
 
1356
1338
  // src/providers/mistral.ts
1357
- var MISTRAL_OCR_PRICE_PER_PAGE = 2e-3;
1358
- var VOXTRAL_PRICE_PER_MIN = {
1359
- "voxtral-mini-latest": 2e-3,
1360
- "voxtral-mini-2507": 2e-3,
1361
- "voxtral-mini-2602": 2e-3
1362
- };
1363
1339
  function mistralAdapter(config = {}) {
1364
1340
  const baseUrl = config.baseUrl ?? "https://api.mistral.ai/v1";
1365
1341
  const base = makeOpenAICompatibleAdapter({ name: "mistral", baseUrl, apiKey: config.apiKey, supportsPromptCacheKey: true, supportsPrefix: true });
@@ -1401,7 +1377,7 @@ function mistralAdapter(config = {}) {
1401
1377
  inputTokens: 0,
1402
1378
  outputTokens: 0
1403
1379
  });
1404
- usage.costUsd = pagesProcessed * (config.pricePerPage ?? MISTRAL_OCR_PRICE_PER_PAGE);
1380
+ usage.costUsd = pagesProcessed * (config.pricePerPage ?? getMediaPrice("mistral", "ocr")?.usd ?? 0);
1405
1381
  return { pages, usage };
1406
1382
  }
1407
1383
  async function moderate(req) {
@@ -1495,7 +1471,7 @@ function mistralAdapter(config = {}) {
1495
1471
  outputTokens: 0
1496
1472
  });
1497
1473
  if (req.durationSec !== void 0) {
1498
- usage.costUsd = req.durationSec / 60 * (VOXTRAL_PRICE_PER_MIN[req.spec.model] ?? 0);
1474
+ usage.costUsd = req.durationSec / 60 * (getMediaPrice("mistral", req.spec.model)?.usd ?? 0);
1499
1475
  }
1500
1476
  return { text: data.text ?? "", usage };
1501
1477
  }
@@ -1547,8 +1523,48 @@ function mistralAdapter(config = {}) {
1547
1523
  return { ...base, ocr, moderate, embedding, transcribe, batchSubmit, batchStatus, batchResults };
1548
1524
  }
1549
1525
 
1526
+ // src/providers/pronunciation.ts
1527
+ function xmlEscape(s) {
1528
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1529
+ }
1530
+ function escapeRegex(s) {
1531
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1532
+ }
1533
+ function assertPronunciations(list, provider) {
1534
+ for (const p of list ?? []) {
1535
+ if (!p.word.trim()) {
1536
+ throw new Error(`${provider} adapter: a pronunciation entry has an empty "word".`);
1537
+ }
1538
+ if (p.alias !== void 0 && p.ipa !== void 0) {
1539
+ throw new Error(
1540
+ `${provider} adapter: pronunciation "${p.word}" sets BOTH alias and ipa. They are different instructions \u2014 alias says it differently, ipa says it precisely. Pick one.`
1541
+ );
1542
+ }
1543
+ if (p.alias === void 0 && p.ipa === void 0) {
1544
+ throw new Error(
1545
+ `${provider} adapter: pronunciation "${p.word}" sets neither alias nor ipa, so there is nothing to say instead.`
1546
+ );
1547
+ }
1548
+ }
1549
+ }
1550
+ function applyPronunciations(haystack, list, render, escape = (s) => s) {
1551
+ const entries = (list ?? []).filter((p) => p.word.trim().length > 0);
1552
+ if (entries.length === 0) return haystack;
1553
+ const sorted = [...entries].sort((a, b) => escape(b.word).length - escape(a.word).length);
1554
+ const byLower = /* @__PURE__ */ new Map();
1555
+ for (const p of sorted) {
1556
+ const k = escape(p.word).toLowerCase();
1557
+ if (!byLower.has(k)) byLower.set(k, p);
1558
+ }
1559
+ const alternation = sorted.map((p) => escapeRegex(escape(p.word))).join("|");
1560
+ const re = new RegExp(`(?<![\\w-])(${alternation})(?![\\w-])`, "gi");
1561
+ return haystack.replace(re, (matched) => {
1562
+ const entry = byLower.get(matched.toLowerCase());
1563
+ return entry ? render(entry, matched) : matched;
1564
+ });
1565
+ }
1566
+
1550
1567
  // src/providers/elevenlabs.ts
1551
- var ELEVENLABS_PRICE_PER_1K_CHARS = 0.15;
1552
1568
  var ELEVENLABS_DANISH_VOICES = {
1553
1569
  soren: "xj6X4BCUsv9oxohm1E8o",
1554
1570
  jesper: "Bl1YwS3uJac5zEOSNESn",
@@ -1559,6 +1575,17 @@ var ELEVENLABS_DANISH_VOICES = {
1559
1575
  function resolveVoice(nameOrId) {
1560
1576
  return ELEVENLABS_DANISH_VOICES[nameOrId] ?? nameOrId;
1561
1577
  }
1578
+ function ttsText(req) {
1579
+ assertPronunciations(req.pronunciations, "elevenlabs");
1580
+ for (const p of req.pronunciations ?? []) {
1581
+ if (p.ipa !== void 0) {
1582
+ throw new Error(
1583
+ `elevenlabs adapter: pronunciation "${p.word}" uses ipa, which needs SSML \u2014 ElevenLabs has none. Use { alias } here, or route this call to azure.`
1584
+ );
1585
+ }
1586
+ }
1587
+ return applyPronunciations(req.text, req.pronunciations, (p) => p.alias);
1588
+ }
1562
1589
  function elevenlabsAdapter(config = {}) {
1563
1590
  const baseUrl = config.baseUrl ?? "https://api.elevenlabs.io/v1";
1564
1591
  const fetchImpl = config.fetch ?? fetch;
@@ -1576,7 +1603,8 @@ function elevenlabsAdapter(config = {}) {
1576
1603
  inputTokens: 0,
1577
1604
  outputTokens: 0
1578
1605
  });
1579
- usage.costUsd = chars / 1e3 * (config.pricePer1kChars ?? ELEVENLABS_PRICE_PER_1K_CHARS);
1606
+ usage.costUsd = chars / 1e3 * (config.pricePer1kChars ?? getMediaPrice("elevenlabs", "tts")?.usd ?? 0);
1607
+ usage.costBasis = config.pricePer1kChars !== void 0 ? "computed" : "estimated";
1580
1608
  return usage;
1581
1609
  }
1582
1610
  async function dialogue(req) {
@@ -1602,7 +1630,7 @@ function elevenlabsAdapter(config = {}) {
1602
1630
  const res = await fetchImpl(`${baseUrl}/text-to-speech/${req.voiceId}`, {
1603
1631
  method: "POST",
1604
1632
  headers: { "xi-api-key": key(), "content-type": "application/json", accept: "audio/mpeg" },
1605
- body: JSON.stringify({ text: req.text, model_id: model })
1633
+ body: JSON.stringify({ text: ttsText(req), model_id: model })
1606
1634
  });
1607
1635
  if (!res.ok) {
1608
1636
  const body = await res.text().catch(() => "");
@@ -1621,8 +1649,6 @@ function elevenlabsAdapter(config = {}) {
1621
1649
  }
1622
1650
 
1623
1651
  // src/providers/azure.ts
1624
- var AZURE_TTS_PRICE_PER_1K_CHARS = 0.016;
1625
- var AZURE_STT_PRICE_PER_MIN = 0.0167;
1626
1652
  var DEFAULT_STT_API_VERSION = "2025-10-15";
1627
1653
  var DEFAULT_REGION = "westeurope";
1628
1654
  var DEFAULT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
@@ -1662,9 +1688,6 @@ function listAzureDanishVoices() {
1662
1688
  function resolveAzureVoice(nameOrVoice) {
1663
1689
  return AZURE_DANISH_VOICES[nameOrVoice] ?? nameOrVoice;
1664
1690
  }
1665
- function xmlEscape(s) {
1666
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1667
- }
1668
1691
  function localeOf(voice) {
1669
1692
  const parts = voice.split("-");
1670
1693
  return parts.length >= 2 ? `${parts[0]}-${parts[1]}` : "en-US";
@@ -1703,14 +1726,20 @@ function azureAdapter(config = {}) {
1703
1726
  inputTokens: 0,
1704
1727
  outputTokens: 0
1705
1728
  });
1706
- usage.costUsd = chars / 1e3 * (config.pricePer1kChars ?? AZURE_TTS_PRICE_PER_1K_CHARS);
1729
+ usage.costUsd = chars / 1e3 * (config.pricePer1kChars ?? getMediaPrice("azure", "tts")?.usd ?? 0);
1707
1730
  return usage;
1708
1731
  }
1709
1732
  async function tts(req) {
1710
1733
  const voice = resolveAzureVoice(req.voiceId);
1711
1734
  const lang = req.lang ?? localeOf(voice);
1712
1735
  const format = req.format ?? DEFAULT_FORMAT;
1713
- const escaped = xmlEscape(req.text);
1736
+ assertPronunciations(req.pronunciations, "azure");
1737
+ const escaped = applyPronunciations(
1738
+ xmlEscape(req.text),
1739
+ req.pronunciations,
1740
+ (p, matched) => p.ipa !== void 0 ? `<phoneme alphabet='ipa' ph='${xmlEscape(p.ipa)}'>${matched}</phoneme>` : `<sub alias='${xmlEscape(p.alias)}'>${matched}</sub>`,
1741
+ xmlEscape
1742
+ );
1714
1743
  const effRate = req.rate ?? AZURE_DANISH_VOICE_LIST.find((v) => v.voiceId === voice)?.defaultRate;
1715
1744
  const inner = effRate != null && effRate !== 1 ? `<prosody rate='${effRate}'>${escaped}</prosody>` : escaped;
1716
1745
  const ssml = `<speak version='1.0' xml:lang='${lang}'><voice name='${voice}'>${inner}</voice></speak>`;
@@ -1764,7 +1793,7 @@ function azureAdapter(config = {}) {
1764
1793
  inputTokens: 0,
1765
1794
  outputTokens: 0
1766
1795
  });
1767
- usage.costUsd = minutes * (config.sttPricePerMin ?? AZURE_STT_PRICE_PER_MIN);
1796
+ usage.costUsd = minutes * (config.sttPricePerMin ?? getMediaPrice("azure", "stt")?.usd ?? 0);
1768
1797
  const result = { text, usage };
1769
1798
  if (req.timestamps && req.timestamps.length > 0 && data.phrases) {
1770
1799
  const toSec = (ms) => (ms ?? 0) / 1e3;
@@ -1795,13 +1824,6 @@ import { readFileSync } from "fs";
1795
1824
  var TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
1796
1825
  var DEFAULT_REGION2 = "europe-west1";
1797
1826
  var CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
1798
- var VERTEX_VEO_PRICE_PER_SEC = {
1799
- "veo-3.1-generate-preview": 0.4,
1800
- "veo-3.1-fast-generate-preview": 0.1,
1801
- "veo-3.1-lite-generate-preview": 0.05,
1802
- "veo-3.0-generate-001": 0.4,
1803
- "veo-3.0-fast-generate-001": 0.1
1804
- };
1805
1827
  function base64url(input) {
1806
1828
  const buf = typeof input === "string" ? Buffer.from(input) : input;
1807
1829
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@@ -1941,8 +1963,9 @@ function vertexAdapter(config = {}) {
1941
1963
  inputTokens: 0,
1942
1964
  outputTokens: 0
1943
1965
  });
1944
- const perSec = config.pricePerSecond ?? VERTEX_VEO_PRICE_PER_SEC[req.spec.model] ?? 0;
1945
- usage.costUsd = perSec * (req.durationSec ?? 8);
1966
+ const perSec = config.pricePerSecond ?? getMediaPrice("vertex", req.spec.model)?.usd ?? 0;
1967
+ usage.costUsd = perSec * (req.durationSec ?? DEFAULT_CLIP_SEC);
1968
+ usage.costBasis = req.durationSec === void 0 ? "estimated" : "computed";
1946
1969
  return { url: `vertex://${op.name}`, bytes: Buffer.from(videoB64, "base64"), mimeType: videoMime, usage };
1947
1970
  }
1948
1971
  async function vision(req) {
@@ -1994,7 +2017,6 @@ function vertexAdapter(config = {}) {
1994
2017
  }
1995
2018
 
1996
2019
  // src/providers/deepl.ts
1997
- var DEEPL_PRICE_PER_1K_CHARS_ESTIMATE = 0.0217;
1998
2020
  function deeplAdapter(config = {}) {
1999
2021
  const fetchImpl = config.fetch ?? fetch;
2000
2022
  function key() {
@@ -2030,7 +2052,8 @@ function deeplAdapter(config = {}) {
2030
2052
  inputTokens: 0,
2031
2053
  outputTokens: 0
2032
2054
  });
2033
- usage.costUsd = req.text.length / 1e3 * (config.pricePer1kChars ?? DEEPL_PRICE_PER_1K_CHARS_ESTIMATE);
2055
+ usage.costUsd = req.text.length / 1e3 * (config.pricePer1kChars ?? getMediaPrice("deepl", "translate")?.usd ?? 0);
2056
+ usage.costBasis = config.pricePer1kChars !== void 0 ? "computed" : "estimated";
2034
2057
  return { text, usage };
2035
2058
  }
2036
2059
  return { name: "deepl", translate };
@@ -2038,21 +2061,6 @@ function deeplAdapter(config = {}) {
2038
2061
 
2039
2062
  // src/providers/fal.ts
2040
2063
  import { deflateRawSync, crc32 } from "zlib";
2041
- var FAL_IMAGE_PRICE_ESTIMATE = {
2042
- "fal-ai/flux/schnell": 3e-3,
2043
- "fal-ai/flux/dev": 0.025,
2044
- "fal-ai/flux-lora": 0.025,
2045
- "fal-ai/flux-pro": 0.05,
2046
- "fal-ai/flux-pro/v1.1": 0.04
2047
- };
2048
- var FAL_TRAIN_PRICE_ESTIMATE = 2;
2049
- var FAL_VIDEO_PRICE_PER_SEC = {
2050
- // Kling 2.5 Turbo Pro i2v — the blessed FAL_KEY-only image→video route (F014
2051
- // contentpush spike). $0.35 for the first 5 s + $0.07/additional s = a flat
2052
- // $0.07/s (1080p); per-second-billed, commercial-use, not deprecated.
2053
- "fal-ai/kling-video/v2.5-turbo/pro/image-to-video": 0.07
2054
- };
2055
- var FAL_VIDEO_DEFAULT_SEC = 8;
2056
2064
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2057
2065
  function falAdapter(config = {}) {
2058
2066
  const doFetch = config.fetch ?? fetch;
@@ -2092,7 +2100,9 @@ function falAdapter(config = {}) {
2092
2100
  inputTokens: 0,
2093
2101
  outputTokens: 0
2094
2102
  });
2095
- usage.costUsd = (config.pricePerImage ?? FAL_IMAGE_PRICE_ESTIMATE[req.spec.model] ?? 0) * calls;
2103
+ const perImage = config.pricePerImage ?? getMediaPrice("fal", req.spec.model)?.usd;
2104
+ usage.costUsd = (perImage ?? 0) * calls;
2105
+ usage.costBasis = config.pricePerImage !== void 0 ? "computed" : "estimated";
2096
2106
  return { url, usage };
2097
2107
  }
2098
2108
  async function animate(req) {
@@ -2121,8 +2131,9 @@ function falAdapter(config = {}) {
2121
2131
  inputTokens: 0,
2122
2132
  outputTokens: 0
2123
2133
  });
2124
- const perSec = config.pricePerSecond ?? FAL_VIDEO_PRICE_PER_SEC[req.spec.model] ?? 0;
2125
- usage.costUsd = perSec * (req.durationSec ?? FAL_VIDEO_DEFAULT_SEC);
2134
+ const perSec = config.pricePerSecond ?? getMediaPrice("fal", req.spec.model)?.usd ?? 0;
2135
+ usage.costUsd = perSec * (req.durationSec ?? DEFAULT_CLIP_SEC);
2136
+ usage.costBasis = req.durationSec === void 0 ? "estimated" : "computed";
2126
2137
  return { url, usage };
2127
2138
  }
2128
2139
  async function trainStyle(req) {
@@ -2158,7 +2169,8 @@ function falAdapter(config = {}) {
2158
2169
  inputTokens: 0,
2159
2170
  outputTokens: 0
2160
2171
  });
2161
- usage.costUsd = config.pricePerTraining ?? FAL_TRAIN_PRICE_ESTIMATE;
2172
+ usage.costUsd = config.pricePerTraining ?? getMediaPrice("fal", "train")?.usd ?? 0;
2173
+ usage.costBasis = config.pricePerTraining !== void 0 ? "computed" : "estimated";
2162
2174
  return { loraUrl, configUrl: configUrl ?? "", usage };
2163
2175
  }
2164
2176
  async function resolveImagesUrl(images, apiKey) {
@@ -2356,7 +2368,6 @@ function buildZip(files) {
2356
2368
  // src/providers/bfl.ts
2357
2369
  var EU_BASE2 = "https://api.eu.bfl.ai";
2358
2370
  var BFL_CREDIT_USD = 0.01;
2359
- var BFL_IMAGE_PRICE = 0.06;
2360
2371
  var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
2361
2372
  function gcd(a, b) {
2362
2373
  return b === 0 ? a : gcd(b, a % b);
@@ -2432,7 +2443,13 @@ function bflAdapter(config = {}) {
2432
2443
  inputTokens: 0,
2433
2444
  outputTokens: 0
2434
2445
  });
2435
- usage.costUsd = typeof submit.cost === "number" ? submit.cost * BFL_CREDIT_USD : config.pricePerImage ?? BFL_IMAGE_PRICE;
2446
+ if (typeof submit.cost === "number") {
2447
+ usage.costUsd = submit.cost * BFL_CREDIT_USD;
2448
+ usage.costBasis = "reported";
2449
+ } else {
2450
+ usage.costUsd = config.pricePerImage ?? getMediaPrice("bfl", "flux-pro-1.1-ultra-finetuned")?.usd ?? 0;
2451
+ usage.costBasis = config.pricePerImage !== void 0 ? "computed" : "estimated";
2452
+ }
2436
2453
  return { url: sample, usage };
2437
2454
  }
2438
2455
  function toBflImage(img) {
@@ -3023,9 +3040,18 @@ var podcastInputSchema = z.object({
3023
3040
  format: z.string().optional(),
3024
3041
  ...callOptions
3025
3042
  });
3043
+ var pronunciationSchema = z.object({
3044
+ word: z.string(),
3045
+ alias: z.string().optional(),
3046
+ ipa: z.string().optional(),
3047
+ lang: z.string().optional()
3048
+ });
3026
3049
  var ttsInputSchema = z.object({
3027
3050
  text: z.string(),
3028
3051
  voice: z.string(),
3052
+ /** F051 — see TtsRequest.pronunciations. The alias/ipa exclusivity is enforced in
3053
+ * the adapter, not here: the message must name the provider that cannot do it. */
3054
+ pronunciations: z.array(pronunciationSchema).optional(),
3029
3055
  /** F037: voice to use if `voice` is one we know the provider has retired. Without
3030
3056
  * it a retired voice throws VoiceUnavailableError rather than reaching the API.
3031
3057
  *
@@ -3067,8 +3093,8 @@ var aiConfigSchema = z.object({
3067
3093
  });
3068
3094
 
3069
3095
  // src/version.ts
3070
- var VERSION = "0.38.0";
3071
- var SDK_TAG = "@broberg/ai-sdk@0.38.0";
3096
+ var VERSION = "0.40.0";
3097
+ var SDK_TAG = "@broberg/ai-sdk@0.40.0";
3072
3098
 
3073
3099
  // src/cost/sinks/upmetrics.ts
3074
3100
  function upmetricsSink(config) {
@@ -3108,6 +3134,11 @@ function upmetricsSink(config) {
3108
3134
  // capability/transport — no ingest-schema change, and without it the one
3109
3135
  // field built for auditability existed only in memory.
3110
3136
  region: usage.region,
3137
+ // F050: HOW cost_usd was arrived at. upmetrics already distinguishes
3138
+ // reported / computed / unpriced — we were sending an assumed number in
3139
+ // the same field as a measured one, so their labels could not be right
3140
+ // about our rows however carefully they were applied.
3141
+ cost_basis: usage.costBasis ?? "computed",
3111
3142
  sdk: SDK_TAG
3112
3143
  }
3113
3144
  };
@@ -3611,7 +3642,15 @@ function createAI(config = {}) {
3611
3642
  invoke: async (spec) => {
3612
3643
  const adapter = pickProvider(spec.provider);
3613
3644
  if (!adapter.tts) throw new Error(`createAI: provider "${spec.provider}" does not support tts`);
3614
- return adapter.tts({ text: input.text, voiceId, lang: input.lang, format: input.format, rate: input.rate, spec });
3645
+ return adapter.tts({
3646
+ text: input.text,
3647
+ voiceId,
3648
+ lang: input.lang,
3649
+ format: input.format,
3650
+ rate: input.rate,
3651
+ pronunciations: input.pronunciations,
3652
+ spec
3653
+ });
3615
3654
  }
3616
3655
  });
3617
3656
  },
@@ -3926,6 +3965,7 @@ CREATE TABLE IF NOT EXISTS ai_usage (
3926
3965
  cache_read_tokens INTEGER NOT NULL,
3927
3966
  cache_creation_tokens INTEGER NOT NULL,
3928
3967
  cost_usd REAL NOT NULL,
3968
+ cost_basis TEXT NOT NULL DEFAULT 'unknown',
3929
3969
  latency_ms INTEGER NOT NULL,
3930
3970
  subprocess INTEGER NOT NULL DEFAULT 0
3931
3971
  )`;
@@ -3942,13 +3982,16 @@ function sqliteSink(config) {
3942
3982
  if (!cols.some((c) => c.name === "region")) {
3943
3983
  db.run(`ALTER TABLE ai_usage ADD COLUMN region TEXT NOT NULL DEFAULT 'unknown'`);
3944
3984
  }
3985
+ if (!cols.some((c) => c.name === "cost_basis")) {
3986
+ db.run(`ALTER TABLE ai_usage ADD COLUMN cost_basis TEXT NOT NULL DEFAULT 'unknown'`);
3987
+ }
3945
3988
  const insert = db.prepare(
3946
3989
  `INSERT INTO ai_usage
3947
3990
  (ts, provider, model, tier, transport, capability, region, purpose,
3948
3991
  input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
3949
- cost_usd, latency_ms, subprocess)
3992
+ cost_usd, cost_basis, latency_ms, subprocess)
3950
3993
  VALUES ($ts, $provider, $model, $tier, $transport, $capability, $region, $purpose,
3951
- $input, $output, $cacheRead, $cacheCreation, $cost, $latency, $subprocess)`
3994
+ $input, $output, $cacheRead, $cacheCreation, $cost, $costBasis, $latency, $subprocess)`
3952
3995
  );
3953
3996
  return insert;
3954
3997
  };
@@ -3969,6 +4012,7 @@ function sqliteSink(config) {
3969
4012
  $cacheRead: usage.cacheReadTokens,
3970
4013
  $cacheCreation: usage.cacheCreationTokens,
3971
4014
  $cost: usage.costUsd,
4015
+ $costBasis: usage.costBasis ?? "computed",
3972
4016
  $latency: usage.latencyMs,
3973
4017
  $subprocess: usage.subprocess ? 1 : 0
3974
4018
  });
@@ -4061,8 +4105,10 @@ export {
4061
4105
  AZURE_DANISH_VOICE_LIST,
4062
4106
  BudgetExceededError,
4063
4107
  BudgetGuard,
4108
+ DEFAULT_CLIP_SEC,
4064
4109
  DEFAULT_TIER_MAP,
4065
4110
  ELEVENLABS_DANISH_VOICES,
4111
+ MEDIA_PRICING_CHECKED_AT,
4066
4112
  ModelUnavailableError,
4067
4113
  PRICING_STALE_AFTER_DAYS,
4068
4114
  SDK_TAG,
@@ -4095,10 +4141,12 @@ export {
4095
4141
  fromProviderToolCall,
4096
4142
  geminiAdapter,
4097
4143
  getCostSummary,
4144
+ getMediaPrice,
4098
4145
  getPrice,
4099
4146
  httpTransport,
4100
4147
  imageInputSchema,
4101
4148
  listAzureDanishVoices,
4149
+ listMediaPrices,
4102
4150
  listModels,
4103
4151
  listVoices,
4104
4152
  makeContracts,