@broberg/ai-sdk 0.45.1 → 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
 
@@ -2523,8 +2534,8 @@ declare const falStubAdapter: ProviderAdapter;
2523
2534
  * wires the live adapters. */
2524
2535
  declare const stubProviders: Record<string, ProviderAdapter>;
2525
2536
 
2526
- declare const VERSION: "0.45.1";
2527
- declare const SDK_TAG: "@broberg/ai-sdk@0.45.1";
2537
+ declare const VERSION: "0.46.0";
2538
+ declare const SDK_TAG: "@broberg/ai-sdk@0.46.0";
2528
2539
 
2529
2540
  /** Built-in defaults. Every entry is overridable via AiConfig.defaults or a
2530
2541
  * per-call override.
package/dist/index.js CHANGED
@@ -1877,6 +1877,7 @@ function azureAdapter(config = {}) {
1877
1877
  async function tts(req) {
1878
1878
  if (req.wordTimings) return ttsBatch(req);
1879
1879
  const format = req.format ?? DEFAULT_FORMAT;
1880
+ const ssml = buildSsml(req);
1880
1881
  const res = await fetchImpl(
1881
1882
  `https://${region()}.tts.speech.microsoft.com/cognitiveservices/v1`,
1882
1883
  {
@@ -1886,7 +1887,7 @@ function azureAdapter(config = {}) {
1886
1887
  "Content-Type": "application/ssml+xml",
1887
1888
  "X-Microsoft-OutputFormat": format
1888
1889
  },
1889
- body: buildSsml(req)
1890
+ body: ssml
1890
1891
  }
1891
1892
  );
1892
1893
  if (!res.ok) {
@@ -1894,9 +1895,10 @@ function azureAdapter(config = {}) {
1894
1895
  throw new Error(`azure tts ${res.status}: ${body.slice(0, 300)}`);
1895
1896
  }
1896
1897
  const audio = new Uint8Array(await res.arrayBuffer());
1897
- 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) };
1898
1899
  }
1899
1900
  async function ttsBatch(req) {
1901
+ const ssml = buildSsml(req);
1900
1902
  const picked = sttHost();
1901
1903
  if (picked.source === "regional-fallback") {
1902
1904
  throw new Error(
@@ -1912,7 +1914,7 @@ function azureAdapter(config = {}) {
1912
1914
  headers,
1913
1915
  body: JSON.stringify({
1914
1916
  inputKind: "SSML",
1915
- inputs: [{ content: buildSsml(req) }],
1917
+ inputs: [{ content: ssml }],
1916
1918
  properties: {
1917
1919
  wordBoundaryEnabled: true,
1918
1920
  // ONE audio file and ONE word list for the whole text. Without it a chunked
@@ -1972,6 +1974,7 @@ function azureAdapter(config = {}) {
1972
1974
  // Batch defaults to riff PCM, not mp3 — saying audio/mpeg here would be a lie the
1973
1975
  // browser would act on.
1974
1976
  mimeType: req.format?.includes("mp3") ? "audio/mpeg" : "audio/wav",
1977
+ ssml,
1975
1978
  // Aligned against the ORIGINAL text, with the dictionary, so the offsets index the
1976
1979
  // manuscript rather than the SSML we sent.
1977
1980
  wordTimings: alignWordTimings(req.text, boundaries, { pronunciations: req.pronunciations }),
@@ -3334,8 +3337,8 @@ var aiConfigSchema = z.object({
3334
3337
  });
3335
3338
 
3336
3339
  // src/version.ts
3337
- var VERSION = "0.45.1";
3338
- var SDK_TAG = "@broberg/ai-sdk@0.45.1";
3340
+ var VERSION = "0.46.0";
3341
+ var SDK_TAG = "@broberg/ai-sdk@0.46.0";
3339
3342
 
3340
3343
  // src/cost/sinks/upmetrics.ts
3341
3344
  function upmetricsSink(config) {