@broberg/ai-sdk 0.27.0 → 0.29.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.js CHANGED
@@ -1,35 +1,17 @@
1
1
  import {
2
+ DEFAULT_TIER_MAP,
2
3
  ModelUnavailableError,
3
4
  listModels,
4
5
  providerIds,
5
6
  resetRegistry,
6
7
  resolveModel,
8
+ resolveTier,
7
9
  setAvailability
8
- } from "./chunk-IT7HNKLY.js";
10
+ } from "./chunk-V2PD522L.js";
9
11
  import {
10
12
  getPrice
11
13
  } from "./chunk-LUPUAAHZ.js";
12
14
 
13
- // src/routing/tier-map.ts
14
- var DEFAULT_TIER_MAP = {
15
- fast: { provider: "mistral", model: "mistral-small-latest", transport: "http" },
16
- smart: { provider: "mistral", model: "mistral-large-latest", transport: "http" },
17
- powerful: { provider: "mistral", model: "mistral-large-latest", transport: "http" },
18
- cheap: { provider: "mistral", model: "mistral-small-latest", transport: "http" },
19
- // Vision: small-latest (vision-capable, cheap EU) is the default; override to
20
- // mistral-large-latest for demanding image/spatial/composition work.
21
- vision: { provider: "mistral", model: "mistral-small-latest", transport: "http" },
22
- // Native video understanding — Gemini leads; flash-lite is the cheap default (F019).
23
- // NOT Anthropic → out of the F030 phase-out (its own EU epic if/when needed).
24
- video: { provider: "gemini", model: "gemini-2.5-flash-lite", transport: "http" },
25
- // NOT Anthropic → out of F030 (EU-embedding migration is its own future epic).
26
- embedding: { provider: "openai", model: "text-embedding-3-small", transport: "http" }
27
- };
28
- function resolveTier(tier, override, configMap) {
29
- const base = configMap?.[tier] ?? DEFAULT_TIER_MAP[tier];
30
- return { ...base, ...override };
31
- }
32
-
33
15
  // src/transport/http.ts
34
16
  async function httpTransport(req) {
35
17
  if (!req.http) {
@@ -1390,13 +1372,13 @@ function elevenlabsAdapter(config = {}) {
1390
1372
  const audio = new Uint8Array(await res.arrayBuffer());
1391
1373
  return { audio, mimeType: "audio/mpeg", usage: priceFor(req.text.length, model) };
1392
1374
  }
1393
- async function listVoices() {
1375
+ async function listVoices2() {
1394
1376
  const res = await fetchImpl(`${baseUrl}/voices`, { headers: { "xi-api-key": key() } });
1395
1377
  if (!res.ok) throw new Error(`elevenlabs voices ${res.status}`);
1396
1378
  const data = await res.json();
1397
1379
  return (data.voices ?? []).map((v) => ({ voiceId: v.voice_id, name: v.name, language: v.labels?.language }));
1398
1380
  }
1399
- return { name: "elevenlabs", dialogue, tts, listVoices };
1381
+ return { name: "elevenlabs", dialogue, tts, listVoices: listVoices2 };
1400
1382
  }
1401
1383
 
1402
1384
  // src/providers/azure.ts
@@ -2449,6 +2431,143 @@ ${JSON.stringify(input.items)}`,
2449
2431
  };
2450
2432
  }
2451
2433
 
2434
+ // src/voices/registry.ts
2435
+ var CHECKED_AT = {
2436
+ // All 5 curated ids confirmed by SYNTHESIS — POST /v1/text-to-speech/{id} returned
2437
+ // 200 with distinct audio per voice (5 distinct sha256), and a fabricated id
2438
+ // returned 404, so a 200 means something.
2439
+ //
2440
+ // NB: GET /v1/voices/{id} is NOT a liveness test and must not be used as one. It
2441
+ // answers "is this voice saved in our account", and returns voice_not_found for a
2442
+ // public/shared voice that synthesizes perfectly well. Using it cost us a false
2443
+ // retirement (see the note on `mads` below) — the only honest liveness check for
2444
+ // ElevenLabs is a real synthesis call, which is exactly why v1 does no live check.
2445
+ elevenlabs: "2026-08-11",
2446
+ // F026 (6ca38c4) verified the 6 curated names against Azure's voices/list. Not
2447
+ // re-probed since: this machine has no Azure Speech key, and inventing a fresher
2448
+ // date than the last real check is exactly the lie checkedAt exists to prevent.
2449
+ azure: "2026-06-23"
2450
+ };
2451
+ var RETIRED_DEFAULT = {};
2452
+ var RETIRED = RETIRED_DEFAULT;
2453
+ var ELEVENLABS_GENDER = {
2454
+ soren: "male",
2455
+ jesper: "male",
2456
+ noam: "male",
2457
+ camilla: "female"
2458
+ };
2459
+ var NOTES = {
2460
+ mads: "usable, but not saved to the ElevenLabs account, so the voices endpoint publishes no metadata for it (verified by synthesis 2026-08-11)"
2461
+ };
2462
+ function build() {
2463
+ const rows = [];
2464
+ for (const [name, id] of Object.entries(ELEVENLABS_DANISH_VOICES)) {
2465
+ const retired = RETIRED[name];
2466
+ rows.push({
2467
+ id,
2468
+ name,
2469
+ provider: "elevenlabs",
2470
+ // The curated ElevenLabs roster IS the Danish one (see the constant's name
2471
+ // + F020); the multilingual model speaks it as da-DK.
2472
+ locale: "da-DK",
2473
+ gender: ELEVENLABS_GENDER[name],
2474
+ available: retired === void 0,
2475
+ status: retired === void 0 ? "available" : "retired",
2476
+ note: retired ?? NOTES[name],
2477
+ checkedAt: CHECKED_AT.elevenlabs
2478
+ });
2479
+ }
2480
+ for (const v of AZURE_DANISH_VOICE_LIST) {
2481
+ const retired = RETIRED[v.name];
2482
+ rows.push({
2483
+ id: v.voiceId,
2484
+ name: v.name,
2485
+ provider: "azure",
2486
+ locale: localeOf(v.voiceId),
2487
+ gender: v.gender,
2488
+ available: retired === void 0,
2489
+ status: retired === void 0 ? "available" : "retired",
2490
+ note: retired ?? NOTES[v.name],
2491
+ checkedAt: CHECKED_AT.azure
2492
+ });
2493
+ }
2494
+ return rows;
2495
+ }
2496
+ var ROWS = build();
2497
+ function allVoices(provider) {
2498
+ return provider ? ROWS.filter((v) => v.provider === provider) : [...ROWS];
2499
+ }
2500
+ function getVoice(nameOrId) {
2501
+ return ROWS.find((v) => v.name === nameOrId || v.id === nameOrId);
2502
+ }
2503
+
2504
+ // src/voices/types.ts
2505
+ var VoiceUnavailableError = class extends Error {
2506
+ code = "voice_unavailable";
2507
+ requested;
2508
+ provider;
2509
+ note;
2510
+ constructor(requested, note, provider) {
2511
+ super(`voice "${requested}" is unavailable${note ? ` (${note})` : ""}`);
2512
+ this.name = "VoiceUnavailableError";
2513
+ this.requested = requested;
2514
+ this.note = note;
2515
+ this.provider = provider;
2516
+ }
2517
+ };
2518
+
2519
+ // src/voices/resolve.ts
2520
+ function listVoices(opts = {}) {
2521
+ return allVoices(opts.provider);
2522
+ }
2523
+ function usable(nameOrId) {
2524
+ const v = getVoice(nameOrId);
2525
+ return v ? v.available : true;
2526
+ }
2527
+ function idFor(nameOrId) {
2528
+ return getVoice(nameOrId)?.id ?? nameOrId;
2529
+ }
2530
+ function checkVoice(requested, opts = {}) {
2531
+ const entry = getVoice(requested);
2532
+ const provider = entry?.provider;
2533
+ if (usable(requested)) {
2534
+ return {
2535
+ ok: true,
2536
+ voiceId: idFor(requested),
2537
+ requested,
2538
+ provider,
2539
+ fellBack: false,
2540
+ status: entry?.status ?? "unknown"
2541
+ };
2542
+ }
2543
+ const chain = opts.fallback === void 0 ? [] : Array.isArray(opts.fallback) ? opts.fallback : [opts.fallback];
2544
+ for (const fb of chain) {
2545
+ if (usable(fb)) {
2546
+ return {
2547
+ ok: false,
2548
+ voiceId: idFor(fb),
2549
+ requested,
2550
+ provider: getVoice(fb)?.provider ?? provider,
2551
+ fellBack: true,
2552
+ status: entry?.status ?? "retired",
2553
+ reason: entry?.note ?? `${requested} is unavailable`
2554
+ };
2555
+ }
2556
+ }
2557
+ if (opts.throwIfUnavailable) {
2558
+ throw new VoiceUnavailableError(requested, entry?.note, provider);
2559
+ }
2560
+ return {
2561
+ ok: false,
2562
+ voiceId: idFor(requested),
2563
+ requested,
2564
+ provider,
2565
+ fellBack: false,
2566
+ status: entry?.status ?? "retired",
2567
+ reason: entry?.note ?? `${requested} is unavailable`
2568
+ };
2569
+ }
2570
+
2452
2571
  // src/schema/inputs.ts
2453
2572
  import { z } from "zod";
2454
2573
  var transportSchema = z.enum(["http", "subprocess"]);
@@ -2623,6 +2742,17 @@ var podcastInputSchema = z.object({
2623
2742
  var ttsInputSchema = z.object({
2624
2743
  text: z.string(),
2625
2744
  voice: z.string(),
2745
+ /** F037: voice to use if `voice` is one we know the provider has retired. Without
2746
+ * it a retired voice throws VoiceUnavailableError rather than reaching the API.
2747
+ *
2748
+ * THINK BEFORE SETTING THIS. It is right for batch work where any acceptable voice
2749
+ * will do (a generated podcast, a bulk render). It is WRONG where the voice is an
2750
+ * identity a human recognises — a brand voice, a house narrator: falling back means
2751
+ * your product suddenly speaks with a stranger's voice and says nothing about why.
2752
+ * There, leave it unset and let the throw alarm you. Silence WITH a message beats
2753
+ * the wrong audio WITHOUT one. (torrent-search-api's call on their own house voice,
2754
+ * 2026-08-11 — they took the alarm over the fallback, deliberately.) */
2755
+ voiceFallback: z.string().optional(),
2626
2756
  lang: z.string().optional(),
2627
2757
  format: z.string().optional(),
2628
2758
  rate: z.number().positive().optional(),
@@ -2650,8 +2780,8 @@ var aiConfigSchema = z.object({
2650
2780
  });
2651
2781
 
2652
2782
  // src/version.ts
2653
- var VERSION = "0.27.0";
2654
- var SDK_TAG = "@broberg/ai-sdk@0.27.0";
2783
+ var VERSION = "0.29.0";
2784
+ var SDK_TAG = "@broberg/ai-sdk@0.29.0";
2655
2785
 
2656
2786
  // src/cost/sinks/upmetrics.ts
2657
2787
  function upmetricsSink(config) {
@@ -3133,7 +3263,9 @@ function createAI(config = {}) {
3133
3263
  const inputs = input.script.map((turn) => {
3134
3264
  const mapped = input.voices[turn.speaker];
3135
3265
  if (!mapped) throw new Error(`ai.podcast: no voice mapped for speaker "${turn.speaker}"`);
3136
- return { text: turn.text, voiceId: resolveVoice(mapped) };
3266
+ const res = checkVoice(mapped);
3267
+ if (!res.ok) throw new VoiceUnavailableError(mapped, `${res.reason} \u2014 speaker "${turn.speaker}"`, res.provider);
3268
+ return { text: turn.text, voiceId: res.voiceId };
3137
3269
  });
3138
3270
  const chars = input.script.reduce((n, t) => n + t.text.length, 0);
3139
3271
  return runCapability({
@@ -3154,6 +3286,7 @@ function createAI(config = {}) {
3154
3286
  },
3155
3287
  async tts(input) {
3156
3288
  input = ttsInputSchema.parse(input);
3289
+ const { voiceId } = checkVoice(input.voice, { fallback: input.voiceFallback, throwIfUnavailable: true });
3157
3290
  return runCapability({
3158
3291
  primary: { ...DEFAULT_TTS_SPEC, ...input.override },
3159
3292
  fallback: input.fallback,
@@ -3166,7 +3299,7 @@ function createAI(config = {}) {
3166
3299
  invoke: async (spec) => {
3167
3300
  const adapter = pickProvider(spec.provider);
3168
3301
  if (!adapter.tts) throw new Error(`createAI: provider "${spec.provider}" does not support tts`);
3169
- return adapter.tts({ text: input.text, voiceId: resolveVoice(input.voice), lang: input.lang, format: input.format, rate: input.rate, spec });
3302
+ return adapter.tts({ text: input.text, voiceId, lang: input.lang, format: input.format, rate: input.rate, spec });
3170
3303
  }
3171
3304
  });
3172
3305
  },
@@ -3614,6 +3747,7 @@ export {
3614
3747
  StreamHttpError,
3615
3748
  UpmetricsCostError,
3616
3749
  VERSION,
3750
+ VoiceUnavailableError,
3617
3751
  aiConfigSchema,
3618
3752
  anthropicAdapter,
3619
3753
  anthropicApiAdapter,
@@ -3622,6 +3756,7 @@ export {
3622
3756
  bflAdapter,
3623
3757
  bflCredits,
3624
3758
  chatInputSchema,
3759
+ checkVoice,
3625
3760
  computeCost,
3626
3761
  createAI,
3627
3762
  deepinfraAdapter,
@@ -3642,6 +3777,7 @@ export {
3642
3777
  imageInputSchema,
3643
3778
  listAzureDanishVoices,
3644
3779
  listModels,
3780
+ listVoices,
3645
3781
  makeContracts,
3646
3782
  makeOpenAICompatibleAdapter,
3647
3783
  messageSchema,