@broberg/ai-sdk 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,43 @@ const { data } = await ai.contracts.extract({
81
81
  // also: ai.contracts.{ mockup, design, classify, rerank }
82
82
  ```
83
83
 
84
+ ## Read-aloud: what was actually SPOKEN is not your `text`
85
+
86
+ `ai.tts({ pronunciations })` rewrites your text before the provider ever sees it —
87
+ `broberg.ai` becomes four spoken words. So **`text` is a retelling of the audio**, and a
88
+ word-highlighter built on `text` alone will drift at every rewritten word.
89
+
90
+ Two fields close that gap:
91
+
92
+ ```ts
93
+ const { audio, wordTimings, ssml } = await ai.tts({
94
+ text, voice, wordTimings: true, pronunciations,
95
+ override: { provider: "azure" },
96
+ });
97
+ wordTimings.words // [{ text, startMs, endMs, sourceStart, sourceEnd }]
98
+ wordTimings.unaligned // spoken words we could NOT place — never a guessed offset
99
+ ssml // the EXACT markup we sent, verbatim — your ground truth
100
+ ```
101
+
102
+ `sourceStart`/`sourceEnd` index **your original text**, because Azure reports audio time
103
+ only and carries no text offset at all; the link back to the manuscript is derived here.
104
+ `ssml` exists so you can check that derivation instead of trusting it — it is the string
105
+ we sent, not a rebuild (filed by a consumer who could not verify what was spoken, F055.4).
106
+ It is `undefined` on routes that build no markup; an empty string would be a different claim.
107
+
108
+ **Two matcher rules that surprise people**, both measured in `pronunciations`:
109
+
110
+ - **Case-insensitive.** `ai`, `Ai` and `AI` all match a rule for `AI`.
111
+ - **A word touching a hyphen keeps its spelling.** `broberg.ai-drevet` is NOT rewritten
112
+ unless that entry sets `matchInCompounds: true` — the rule that keeps `mail` out of
113
+ `e-mail`. A consumer deriving their own expected-word list got exactly these two
114
+ occurrences wrong before the field existed.
115
+
116
+ **Word timings need Azure's BATCH route**, a different call shape (submit → poll → ZIP),
117
+ and that route requires the resource's custom subdomain — the regional host answers 401
118
+ with a valid key, blaming the key. Set `AZURE_SPEECH_RESOURCE`; without it `wordTimings`
119
+ fails before the call, naming the fix.
120
+
84
121
  ## Providers & tiers
85
122
 
86
123
  Adapters: **Anthropic** (HTTP + `claude -p` subprocess), **OpenAI**, **Google
package/dist/index.d.ts CHANGED
@@ -555,6 +555,17 @@ interface PodcastResult {
555
555
  * — Azure reports only audio time, so the link back to the manuscript is derived
556
556
  * here. `unaligned` names any spoken word that could not be placed. */
557
557
  wordTimings?: AlignedWordTimings;
558
+ /** F055.4 — the EXACT markup we sent the provider, verbatim, not rebuilt.
559
+ *
560
+ * Why it exists: the text you passed is not what was spoken. A pronunciation
561
+ * dictionary rewrites it, so `text` is a RETELLING of the audio — and without this
562
+ * field a consumer cannot check our retelling any more than we could check theirs.
563
+ * cms filed exactly that (11 September 2026) while we were asking them for markup
564
+ * only we could produce.
565
+ *
566
+ * UNDEFINED when the route builds no markup (ElevenLabs routes by voice, not SSML).
567
+ * An empty string would say "we sent empty markup", which is a different claim. */
568
+ ssml?: string;
558
569
  usage: Usage;
559
570
  }
560
571
 
@@ -2385,8 +2396,17 @@ declare function azureAdapter(config?: {
2385
2396
  sttPricePerMin?: number;
2386
2397
  /** STT base URL override (e.g. a resource custom domain). */
2387
2398
  sttBaseUrl?: string;
2388
- /** Resource name → custom-domain STT host `{resource}.cognitiveservices.azure.com`
2389
- * (or env AZURE_SPEECH_RESOURCE). Without it, STT uses the regional host. */
2399
+ /** Resource name → custom-domain host `{resource}.cognitiveservices.azure.com`
2400
+ * (or env AZURE_SPEECH_RESOURCE).
2401
+ *
2402
+ * **STT works without it** — the regional host is a legitimate route for
2403
+ * `speechtotext/transcriptions` and is the tested default (F029).
2404
+ *
2405
+ * **BATCH SYNTHESIS REQUIRES IT.** Every example in Microsoft's batch-synthesis
2406
+ * docs uses the resource host; the regional host is shown nowhere, and cms measured
2407
+ * it answering 401 with a valid key (F055.3). Without this set, `wordTimings` fails
2408
+ * before the call rather than spending your time on Azure's "invalid subscription
2409
+ * key or wrong API endpoint" — which names the key first and sends you the wrong way. */
2390
2410
  resource?: string;
2391
2411
  /** Fast-transcription api-version (overrides the GA default). */
2392
2412
  sttApiVersion?: string;
@@ -2514,8 +2534,8 @@ declare const falStubAdapter: ProviderAdapter;
2514
2534
  * wires the live adapters. */
2515
2535
  declare const stubProviders: Record<string, ProviderAdapter>;
2516
2536
 
2517
- declare const VERSION: "0.45.0";
2518
- declare const SDK_TAG: "@broberg/ai-sdk@0.45.0";
2537
+ declare const VERSION: "0.46.0";
2538
+ declare const SDK_TAG: "@broberg/ai-sdk@0.46.0";
2519
2539
 
2520
2540
  /** Built-in defaults. Every entry is overridable via AiConfig.defaults or a
2521
2541
  * per-call override.
package/dist/index.js CHANGED
@@ -1836,11 +1836,14 @@ function azureAdapter(config = {}) {
1836
1836
  }
1837
1837
  return classifyRegionName(region());
1838
1838
  }
1839
- function sttBaseUrl() {
1840
- if (config.sttBaseUrl) return config.sttBaseUrl.replace(/\/$/, "");
1839
+ function sttHost() {
1840
+ if (config.sttBaseUrl) return { url: config.sttBaseUrl.replace(/\/$/, ""), source: "explicit" };
1841
1841
  const resource = config.resource ?? process.env.AZURE_SPEECH_RESOURCE;
1842
- if (resource) return `https://${resource}.cognitiveservices.azure.com`;
1843
- return `https://${region()}.api.cognitive.microsoft.com`;
1842
+ if (resource) return { url: `https://${resource}.cognitiveservices.azure.com`, source: "resource" };
1843
+ return { url: `https://${region()}.api.cognitive.microsoft.com`, source: "regional-fallback" };
1844
+ }
1845
+ function sttBaseUrl() {
1846
+ return sttHost().url;
1844
1847
  }
1845
1848
  function priceFor(chars, model) {
1846
1849
  const usage = freshUsage({
@@ -1874,6 +1877,7 @@ function azureAdapter(config = {}) {
1874
1877
  async function tts(req) {
1875
1878
  if (req.wordTimings) return ttsBatch(req);
1876
1879
  const format = req.format ?? DEFAULT_FORMAT;
1880
+ const ssml = buildSsml(req);
1877
1881
  const res = await fetchImpl(
1878
1882
  `https://${region()}.tts.speech.microsoft.com/cognitiveservices/v1`,
1879
1883
  {
@@ -1883,7 +1887,7 @@ function azureAdapter(config = {}) {
1883
1887
  "Content-Type": "application/ssml+xml",
1884
1888
  "X-Microsoft-OutputFormat": format
1885
1889
  },
1886
- body: buildSsml(req)
1890
+ body: ssml
1887
1891
  }
1888
1892
  );
1889
1893
  if (!res.ok) {
@@ -1891,10 +1895,17 @@ function azureAdapter(config = {}) {
1891
1895
  throw new Error(`azure tts ${res.status}: ${body.slice(0, 300)}`);
1892
1896
  }
1893
1897
  const audio = new Uint8Array(await res.arrayBuffer());
1894
- return { audio, mimeType: "audio/mpeg", usage: priceFor(req.text.length, req.spec.model) };
1898
+ return { audio, mimeType: "audio/mpeg", ssml, usage: priceFor(req.text.length, req.spec.model) };
1895
1899
  }
1896
1900
  async function ttsBatch(req) {
1897
- const host = sttBaseUrl();
1901
+ const ssml = buildSsml(req);
1902
+ const picked = sttHost();
1903
+ if (picked.source === "regional-fallback") {
1904
+ throw new Error(
1905
+ `azure batch synthesis (wordTimings) needs the resource's custom subdomain, not the regional host ${picked.url}. Set AZURE_SPEECH_RESOURCE (or config.resource) to your Speech resource name so the call goes to {resource}.cognitiveservices.azure.com, and make sure custom subdomain is enabled on that resource. Azure answers 401 here with a VALID key, and its message blames the key \u2014 measured by cms 11 September 2026.`
1906
+ );
1907
+ }
1908
+ const host = picked.url;
1898
1909
  const api = "api-version=2024-04-01";
1899
1910
  const id = `wt-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
1900
1911
  const headers = { "Ocp-Apim-Subscription-Key": key(), "Content-Type": "application/json" };
@@ -1903,7 +1914,7 @@ function azureAdapter(config = {}) {
1903
1914
  headers,
1904
1915
  body: JSON.stringify({
1905
1916
  inputKind: "SSML",
1906
- inputs: [{ content: buildSsml(req) }],
1917
+ inputs: [{ content: ssml }],
1907
1918
  properties: {
1908
1919
  wordBoundaryEnabled: true,
1909
1920
  // ONE audio file and ONE word list for the whole text. Without it a chunked
@@ -1963,6 +1974,7 @@ function azureAdapter(config = {}) {
1963
1974
  // Batch defaults to riff PCM, not mp3 — saying audio/mpeg here would be a lie the
1964
1975
  // browser would act on.
1965
1976
  mimeType: req.format?.includes("mp3") ? "audio/mpeg" : "audio/wav",
1977
+ ssml,
1966
1978
  // Aligned against the ORIGINAL text, with the dictionary, so the offsets index the
1967
1979
  // manuscript rather than the SSML we sent.
1968
1980
  wordTimings: alignWordTimings(req.text, boundaries, { pronunciations: req.pronunciations }),
@@ -3325,8 +3337,8 @@ var aiConfigSchema = z.object({
3325
3337
  });
3326
3338
 
3327
3339
  // src/version.ts
3328
- var VERSION = "0.45.0";
3329
- var SDK_TAG = "@broberg/ai-sdk@0.45.0";
3340
+ var VERSION = "0.46.0";
3341
+ var SDK_TAG = "@broberg/ai-sdk@0.46.0";
3330
3342
 
3331
3343
  // src/cost/sinks/upmetrics.ts
3332
3344
  function upmetricsSink(config) {