@shop-os/modelkit 0.7.11 → 0.9.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/CHANGELOG.md CHANGED
@@ -9,6 +9,33 @@ header in the release PR.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.9.0] - Unreleased
13
+
14
+ Fish Audio: text to speech, voice cloning and voice design (ENG-3400).
15
+
16
+ ### Added
17
+ - Adapter `fish` for the Fish Audio REST API (`https://api.fish.audio`, base-url override `endpoints.fish`). Key `fish` from `FISH_AUDIO_API_KEY`, with the `ENTERPRISE_` / `SLOOSH_` prefixes; a sloosh call without `SLOOSH_FISH_AUDIO_API_KEY` fails with `auth`.
18
+ - Speech rows `fish-s2.1-pro`, `fish-s2.1-pro-free` and `fish-drama-3` (the Fish `model` header `s2.1-pro`, `s2.1-pro-free`, `drama-3`). 24 Fish Official library voices (voice-designed or licensed, none a real-person clone; default Hannah), `mp3` (default) or `wav` output returned as bytes, a 10,000-character kit cap (Fish states none). Fish auto-detects the language, so the rows list none and `language` is dropped with a warning. Usage is one `images`-unit call, like the other speech rows; Fish bills per UTF-8 byte of the script ($15 / M bytes, Drama 3 $50 / M list, the free tier $0).
19
+ - `SpeechRequest.customVoice`: a voice id `createVoice` returned, on rows with the new `capabilities.customVoices` (the Fish speech rows). It replaces `voice`; sending both, or a custom voice to another row, is `unsupported_option`.
20
+ - Row types `voice-clone` and `voice-design`, and kit methods `createVoice` (row `fish-voice-clone`: 1-20 samples, optional transcripts and voice-design signatures on every sample or none, a private fast clone; returns `voiceId` and, when Fish reports a known one, `state`), `designVoice` (row `fish-voice-design-1`: a description, an optional preview line of up to 150 characters (the Fish OpenAPI limit), 1-4 candidates returned as WAV bytes with a `signature`; $0.01 per request) and `deleteVoice` (on the clone row; a 404 is `provider_rejected`, because Fish also answers 404 for a private voice of another account, except after a failed attempt of the same call, which may have deleted it). Usage unit `requests`. `resolveVoiceCloneOptions` / `resolveVoiceDesignOptions` and `resolveOptions` cover the new methods.
21
+ - A cloned voice lives in the Fish account of the key set that made it: create, speak and delete with the same `tier`.
22
+ - `constraints.voiceDesign` (`maxCandidates`, `previewMaxChars`).
23
+
24
+ ### Fixed
25
+ - A 403 whose text says "Top up API credit" (Fish, out of API credit) is `provider_billing`, not `auth`.
26
+
27
+ ### Notes
28
+ - Every Fish row ships with `exposure: []`: callable by id, listed nowhere, unpriced. Price and expose them in pro-backend per env when ENG-3400 is ready.
29
+ - `createVoice` retries only a 429: after a 5xx Fish may already hold the voice, and a retry would make a second one.
30
+ - A kit before 0.9.0 drops `voice-clone` / `voice-design` rows on load with a warning, and refuses the `fish` adapter id, so those rows stay invisible to consumers that have not upgraded.
31
+
32
+ ## [0.8.0] - Unreleased
33
+
34
+ The BytePlus ModelArk key has one env name.
35
+
36
+ ### Removed
37
+ - `keysFromEnv` reads the `ark` key from `BYTEDANCE_API_KEY` only (with the `ENTERPRISE_` / `SLOOSH_` prefixes); the two older alias names are no longer read. An env that sets only an alias gets no `ark` key, so an Ark call fails with a `ModelKitError` code `auth`. Set `BYTEDANCE_API_KEY` before a consumer upgrades.
38
+
12
39
  ## [0.7.11] - Unreleased
13
40
 
14
41
  Kling 4.0 on fal.
package/README.md CHANGED
@@ -47,6 +47,12 @@ const synced = await kit.lipsync({ model: "lipsync-2", sourceVideo: { url }, aud
47
47
  const dubbed = await kit.dub({ model: "elevenlabs-dubbing", sourceVideo: { url }, targetLanguage: "es" });
48
48
  const transcript = await kit.transcribe({ model: "elevenlabs-scribe-v2", source: { url }, timestamps: true });
49
49
 
50
+ // Custom voices (Fish Audio): clone once per workspace, keep `voiceId`, speak with it on the
51
+ // same tier. `designVoice` returns candidates to keep the same way (pass `signature`).
52
+ const { voiceId } = await kit.createVoice({ model: "fish-voice-clone", title, samples: [{ audio: { url } }] }, { tier });
53
+ const mine = await kit.generateSpeech({ model: "fish-s2.1-pro", text, customVoice: voiceId }, { tier });
54
+ await kit.deleteVoice({ model: "fish-voice-clone", voiceId }, { tier });
55
+
50
56
  const clip = await kit.prepareMediaPart(
51
57
  "gemini-2.5-flash",
52
58
  { bytes, mime: "video/mp4" },
@@ -108,7 +114,7 @@ before submit), and `model3dPricingSku(row, options, inputs)` names the flat pri
108
114
  bills under; `model3dSkus(row)` lists them all, and pro-backend keeps a `credit_pricing_flat`
109
115
  row for each. All three are in the browser-safe catalog entry.
110
116
 
111
- Keys come from `process.env` by default: bare names for pro (`OPENAI_API_KEY`, `FAL_KEY`, `ARK_API_KEY`, `LUMA_API_KEY` ...), the `ENTERPRISE_` prefix for enterprise and the `SLOOSH_` prefix for sloosh (sloosh reads `SLOOSH_LUMA_API_KEY` for Luma). Pass `keys: { pro, enterprise, sloosh }` to override. `tier: "sloosh"` uses the sloosh keys only: a missing key is a `ModelKitError` with code `auth`, never a fallback to pro.
117
+ Keys come from `process.env` by default: bare names for pro (`OPENAI_API_KEY`, `FAL_KEY`, `BYTEDANCE_API_KEY`, `LUMA_API_KEY` ...), the `ENTERPRISE_` prefix for enterprise and the `SLOOSH_` prefix for sloosh (sloosh reads `SLOOSH_LUMA_API_KEY` for Luma). Pass `keys: { pro, enterprise, sloosh }` to override. `tier: "sloosh"` uses the sloosh keys only: a missing key is a `ModelKitError` with code `auth`, never a fallback to pro.
112
118
 
113
119
  ## Browser-safe catalog
114
120
 
@@ -344,7 +350,7 @@ the app's secrets, not a Cloudflare gateway token.
344
350
 
345
351
  ## Adapters
346
352
 
347
- `ai-sdk-text`, `google-image`, `openai-image`, `google-genai` (Veo, Omni with `delivery: "uri"`, Lyria 3, Files API media parts), `ark` (Seedream, Seedance), `fal`, `replicate` (images, video, Lyria 2, MMAudio), `prodia`, `higgsfield`, `xai-video`, `luma` (Uni images, Ray 3.2 video via the Agents API; `LUMA_API_KEY`, base-url override `endpoints.luma`). See `src/adapters/`.
353
+ `ai-sdk-text`, `google-image`, `openai-image`, `google-genai` (Veo, Omni with `delivery: "uri"`, Lyria 3, Files API media parts), `ark` (Seedream, Seedance), `fal`, `replicate` (images, video, Lyria 2, MMAudio), `prodia`, `higgsfield`, `xai-video`, `luma` (Uni images, Ray 3.2 video via the Agents API; `LUMA_API_KEY`, base-url override `endpoints.luma`), `fish` (Fish Audio speech, `createVoice` / `designVoice` / `deleteVoice`; `FISH_AUDIO_API_KEY`, base-url override `endpoints.fish`). See `src/adapters/`.
348
354
 
349
355
  ## Not in the package
350
356
 
package/dist/catalog.cjs CHANGED
@@ -74,6 +74,8 @@ __export(catalog_entry_exports, {
74
74
  resolveTranscriptionOptions: () => resolveTranscriptionOptions,
75
75
  resolveVideoOptions: () => resolveVideoOptions,
76
76
  resolveVoiceChangeOptions: () => resolveVoiceChangeOptions,
77
+ resolveVoiceCloneOptions: () => resolveVoiceCloneOptions,
78
+ resolveVoiceDesignOptions: () => resolveVoiceDesignOptions,
77
79
  sortCatalogRows: () => sortCatalogRows,
78
80
  takesTargetAspectRatio: () => takesTargetAspectRatio,
79
81
  videoLengthViolation: () => videoLengthViolation,
@@ -1302,6 +1304,303 @@ var VEED_SUBTITLES = {
1302
1304
  }
1303
1305
  };
1304
1306
 
1307
+ // src/catalog/voices-fish.ts
1308
+ var FISH_VOICES = [
1309
+ {
1310
+ id: "933563129e564b19a115bedd57b7406a",
1311
+ name: "Sarah",
1312
+ gender: "female",
1313
+ languages: ["en"],
1314
+ description: "An engaged speaker.",
1315
+ previewUrl: "https://platform.r2.fish.audio/task/4e204945670c460c86fdab57512cb308.mp3"
1316
+ },
1317
+ {
1318
+ id: "bf322df2096a46f18c579d0baa36f41d",
1319
+ name: "Adrian",
1320
+ gender: "male",
1321
+ languages: ["en"],
1322
+ description: "A steady and reliable narrator.",
1323
+ previewUrl: "https://platform.r2.fish.audio/task/d730b09f4a484316a77ecc86137e85fb.mp3"
1324
+ },
1325
+ {
1326
+ id: "536d3a5e000945adb7038665781a4aca",
1327
+ name: "Ethan",
1328
+ gender: "male",
1329
+ languages: ["en"],
1330
+ description: "A curious explainer.",
1331
+ previewUrl: "https://platform.r2.fish.audio/task/bdd677ed767744fc91f166468786264b.mp3"
1332
+ },
1333
+ {
1334
+ id: "b347db033a6549378b48d00acb0d06cd",
1335
+ name: "Selene",
1336
+ gender: "female",
1337
+ languages: ["en"],
1338
+ description: "A meditative female voice.",
1339
+ previewUrl: "https://platform.r2.fish.audio/task/fcf629b4a0b343558f461338ba1b9da4.mp3"
1340
+ },
1341
+ {
1342
+ id: "9a9cf47702da476aa4629e2506d4a857",
1343
+ name: "Hannah",
1344
+ gender: "female",
1345
+ languages: ["en"],
1346
+ description: "A conversation specialist.",
1347
+ previewUrl: "https://platform.r2.fish.audio/task/08d658835feb427db808e37060b78eb0.mp3"
1348
+ },
1349
+ {
1350
+ id: "79d0bd3e4e5444b18f7b6d89b5927bf1",
1351
+ name: "Jordan",
1352
+ gender: "male",
1353
+ languages: ["en"],
1354
+ description: "A motivational speaker.",
1355
+ previewUrl: "https://platform.r2.fish.audio/task/edecbc0bdd4c46c39eda8520a0f729ef.mp3"
1356
+ },
1357
+ {
1358
+ id: "e3cd384158934cc9a01029cd7d278634",
1359
+ name: "Laura",
1360
+ gender: "female",
1361
+ languages: ["en"],
1362
+ description: "A confident female narrator.",
1363
+ previewUrl: "https://platform.r2.fish.audio/task/e821bef995344d519caa1c93d808db23.mp3"
1364
+ },
1365
+ {
1366
+ id: "f3a2b90078d54a65af4b95f63bc7798e",
1367
+ name: "Sheila",
1368
+ gender: "female",
1369
+ accent: "american",
1370
+ languages: ["en"],
1371
+ description: "A young American English woman's voice.",
1372
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-24/9bb9e1a1cf3f47f38700da1c5c0a678a.mp3"
1373
+ },
1374
+ {
1375
+ id: "0db6e93af420470b9d9b9a886dbaa954",
1376
+ name: "Kai",
1377
+ gender: "male",
1378
+ languages: ["en"],
1379
+ description: "A friendly English man's voice for companion apps and everyday conversation.",
1380
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-24/13306f1172544c3ba3106d1e363feaa6.mp3"
1381
+ },
1382
+ {
1383
+ id: "8abfa9ebd4c24a3399b68c3cdac8c7b5",
1384
+ name: "Freya",
1385
+ gender: "female",
1386
+ accent: "british",
1387
+ languages: ["en"],
1388
+ description: "A natural, professional British English voice with a brisk, conversational pace. Clear and easy to follow, suited to voice assistants, customer support and everyday narration.",
1389
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/07918705a56b42daaab149f65392cea7.mp3"
1390
+ },
1391
+ {
1392
+ id: "2f64e528a4f2406691925ab1e0ed5b7f",
1393
+ name: "Ananya",
1394
+ gender: "female",
1395
+ languages: ["hi"],
1396
+ description: "\u0938\u093E\u092B\u093C \u0914\u0930 \u0906\u0924\u094D\u092E\u0935\u093F\u0936\u094D\u0935\u093E\u0938 \u092D\u0930\u0940 \u0906\u0935\u093E\u091C\u093C, \u091C\u094B \u0935\u0949\u0907\u0938 \u0905\u0938\u093F\u0938\u094D\u091F\u0947\u0902\u091F, \u0917\u094D\u0930\u093E\u0939\u0915 \u0938\u0947\u0935\u093E \u0914\u0930 \u092A\u094D\u0930\u0938\u094D\u0924\u0941\u0924\u093F\u092F\u094B\u0902 \u0915\u0947 \u0932\u093F\u090F \u0909\u092A\u092F\u0941\u0915\u094D\u0924 \u0939\u0948\u0964",
1397
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/a17a700668d8405f8316db120733145c.mp3"
1398
+ },
1399
+ {
1400
+ id: "cefd91a07b99468f9148e2580990082d",
1401
+ name: "Arjun",
1402
+ gender: "male",
1403
+ languages: ["hi"],
1404
+ description: "\u0938\u093E\u092B\u093C \u0914\u0930 \u0906\u0924\u094D\u092E\u0935\u093F\u0936\u094D\u0935\u093E\u0938 \u092D\u0930\u0940 \u0906\u0935\u093E\u091C\u093C, \u091C\u094B \u0935\u0949\u0907\u0938 \u0905\u0938\u093F\u0938\u094D\u091F\u0947\u0902\u091F, \u0917\u094D\u0930\u093E\u0939\u0915 \u0938\u0947\u0935\u093E \u0914\u0930 \u092A\u094D\u0930\u0938\u094D\u0924\u0941\u0924\u093F\u092F\u094B\u0902 \u0915\u0947 \u0932\u093F\u090F \u0909\u092A\u092F\u0941\u0915\u094D\u0924 \u0939\u0948\u0964",
1405
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-24/a0c4a8eadb7841cdae8e5e68031a2dfb.mp3"
1406
+ },
1407
+ {
1408
+ id: "3f065d660212478a9c697fa965b4d098",
1409
+ name: "Carmen",
1410
+ gender: "female",
1411
+ languages: ["es"],
1412
+ description: "Una voz clara y segura para asistentes de voz, atenci\xF3n al cliente y presentaciones.",
1413
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/3421be48b6cb4c28bf829c25188e7e74.mp3"
1414
+ },
1415
+ {
1416
+ id: "ec0718028a2445fb9be8e8bfb56a0150",
1417
+ name: "Rodrigo",
1418
+ gender: "male",
1419
+ languages: ["es"],
1420
+ description: "Una voz clara y segura para asistentes de voz, atenci\xF3n al cliente y presentaciones.",
1421
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/5a77c32365604800ad5b4eb13c5585df.mp3"
1422
+ },
1423
+ {
1424
+ id: "10a3a20742114a4ea6dd441e7591850f",
1425
+ name: "Manon",
1426
+ gender: "female",
1427
+ languages: ["fr"],
1428
+ description: "Une voix claire et vive, adapt\xE9e aux assistants vocaux et au support client.",
1429
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/f7b75ee1fa524e48a8cefb003cf72952.mp3"
1430
+ },
1431
+ {
1432
+ id: "f69bca092b674168a8d02d61ca20943c",
1433
+ name: "Lucas",
1434
+ gender: "male",
1435
+ languages: ["fr"],
1436
+ description: "Une voix claire et assur\xE9e, adapt\xE9e aux assistants vocaux et au service client.",
1437
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/86fca3e56a014a3d8a11450854bb8311.mp3"
1438
+ },
1439
+ {
1440
+ id: "3937b44b604249f18b94913daef94043",
1441
+ name: "Lena",
1442
+ gender: "female",
1443
+ languages: ["de"],
1444
+ description: "Eine klare, souver\xE4ne Stimme f\xFCr Sprachassistenten, Kundenservice und Pr\xE4sentationen.",
1445
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/8659fec80b74442dab68a7755716a401.mp3"
1446
+ },
1447
+ {
1448
+ id: "c1e7cc926aa5469aa966c353094d4b0b",
1449
+ name: "Lukas",
1450
+ gender: "male",
1451
+ languages: ["de"],
1452
+ description: "Eine klare, souver\xE4ne Stimme f\xFCr Sprachassistenten, Kundenservice und Pr\xE4sentationen.",
1453
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/fb1d538126bb4fe38f8366250f6a2136.mp3"
1454
+ },
1455
+ {
1456
+ id: "b3c060cc41544a7da94a2c487647c902",
1457
+ name: "Let\xEDcia",
1458
+ gender: "female",
1459
+ accent: "brazilian",
1460
+ languages: ["pt"],
1461
+ description: "A Brazilian-accented Portuguese woman's voice, in her late twenties. Soft, warm, intimate. A freelance designer from Belo Horizonte who enjoys long walks in the park. Best suited to companion apps and casual everyday conversation.",
1462
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-10-12/13c6c0d96b5c4069951f89868ee15360.mp3"
1463
+ },
1464
+ {
1465
+ id: "add7bcb8d3c8423bbfa152ca232aad6e",
1466
+ name: "Ricardo",
1467
+ gender: "male",
1468
+ accent: "brazilian",
1469
+ languages: ["pt"],
1470
+ description: "A Brazilian-accented Portuguese man's voice, in his late forties. Clear, professional, monotone. A high school history teacher from Curitiba who loves classical music. Best suited to lessons, tutoring and explainer content.",
1471
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-10-12/67475fc717ae45cbb38e647a2dd37fe1.mp3"
1472
+ },
1473
+ {
1474
+ id: "b2d9d8db057042688a5e318b8f405bc2",
1475
+ name: "Kyoko",
1476
+ gender: "female",
1477
+ languages: ["ja"],
1478
+ description: "\u4E01\u5BE7\u3067\u843D\u3061\u7740\u3044\u305F\u8A71\u3057\u65B9\u304C\u7279\u5FB4\u306E\u5973\u6027\u306E\u58F0\u3002\u304A\u5BA2\u69D8\u5BFE\u5FDC\u3084\u60C5\u5831\u63D0\u4F9B\u306B\u6700\u9069\u3002",
1479
+ previewUrl: "https://platform.r2.fish.audio/task/993b3ccb52124341aff79580f1ce5a5a.mp3"
1480
+ },
1481
+ {
1482
+ id: "297a6fd278df47c3b9da9bfdf55ac89a",
1483
+ name: "Satoru",
1484
+ gender: "male",
1485
+ languages: ["ja"],
1486
+ description: "\u82E5\u3005\u3057\u304F\u3082\u843D\u3061\u7740\u304D\u306E\u3042\u308B\u7537\u6027\u306E\u58F0\u3002\u65E5\u5E38\u306E\u8A9E\u308A\u3084\u30EA\u30E9\u30C3\u30AF\u30B9\u3057\u305F\u30CA\u30EC\u30FC\u30B7\u30E7\u30F3\u306B\u6700\u9069\u3002",
1487
+ previewUrl: "https://platform.r2.fish.audio/task/1b4712fb831f4c149eef6b0133157418.mp3"
1488
+ },
1489
+ {
1490
+ id: "0b14f34a13a94ed88fe6113193749bb0",
1491
+ name: "Fatima",
1492
+ gender: "female",
1493
+ languages: ["ar"],
1494
+ description: "\u0635\u0648\u062A \u0648\u0627\u0636\u062D \u0648\u0648\u0627\u062B\u0642 \u064A\u0646\u0627\u0633\u0628 \u0627\u0644\u0645\u0633\u0627\u0639\u062F\u0627\u062A \u0627\u0644\u0635\u0648\u062A\u064A\u0629 \u0648\u062E\u062F\u0645\u0629 \u0627\u0644\u0639\u0645\u0644\u0627\u0621 \u0648\u0627\u0644\u0639\u0631\u0648\u0636 \u0627\u0644\u062A\u0642\u062F\u064A\u0645\u064A\u0629.",
1495
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-24/93406e1b905a4c4994306e3421243390.mp3"
1496
+ },
1497
+ {
1498
+ id: "74c6aba5cbf94a15bbdc547ffce5cb38",
1499
+ name: "Yutong",
1500
+ gender: "female",
1501
+ languages: ["zh"],
1502
+ description: "\u58F0\u97F3\u6E05\u6670\u81EA\u7136\uFF0C\u8868\u8FBE\u5229\u843D\uFF0C\u9002\u5408\u8BED\u97F3\u52A9\u624B\u3001\u5BA2\u670D\u548C\u65E5\u5E38\u64AD\u62A5\u3002",
1503
+ previewUrl: "https://platform.r2.fish.audio/tasks/2026-08-25/a48320f37fe44c4699549a1afc3778e8.mp3"
1504
+ }
1505
+ ];
1506
+
1507
+ // src/catalog/speech-fish.ts
1508
+ var FISH_LOGO = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.97.0/light/fishaudio.png";
1509
+ var PER_CALL = { unit: "images" };
1510
+ var PER_REQUEST = { unit: "requests" };
1511
+ var TTS_MAX_CHARS = 1e4;
1512
+ function fishTts(id, label, providerModelId, description) {
1513
+ return {
1514
+ id,
1515
+ aliases: [],
1516
+ label,
1517
+ type: "speech",
1518
+ adapter: "fish",
1519
+ providerModelId,
1520
+ lifecycle: "active",
1521
+ exposure: [],
1522
+ constraints: {
1523
+ voices: FISH_VOICES,
1524
+ outputFormats: ["mp3", "wav"],
1525
+ supports: [],
1526
+ prompt: { referenceSyntax: "numbered", maxChars: TTS_MAX_CHARS }
1527
+ },
1528
+ defaults: { voice: "9a9cf47702da476aa4629e2506d4a857", outputFormat: "mp3" },
1529
+ usage: PER_CALL,
1530
+ pricingKind: "flat",
1531
+ display: { provider: "fishaudio", logo: FISH_LOGO, description },
1532
+ capabilities: { customVoices: true }
1533
+ };
1534
+ }
1535
+ var FISH_MODELS = [
1536
+ fishTts(
1537
+ "fish-s2.1-pro",
1538
+ "Fish Audio S2.1 Pro",
1539
+ "s2.1-pro",
1540
+ "Fish Audio S2.1 Pro text to speech in 83 languages, with [bracket] directions and your own cloned voices."
1541
+ ),
1542
+ fishTts(
1543
+ "fish-s2.1-pro-free",
1544
+ "Fish Audio S2.1 Pro Free",
1545
+ "s2.1-pro-free",
1546
+ "Fish Audio S2.1 Pro on the free developer tier: the same voices at no cost, without latency or data-processing guarantees."
1547
+ ),
1548
+ fishTts(
1549
+ "fish-drama-3",
1550
+ "Fish Audio Drama 3",
1551
+ "drama-3",
1552
+ "Fish Audio Drama 3 text to speech you direct like an actor: [directions], sounds, pauses and per-word tags."
1553
+ ),
1554
+ {
1555
+ id: "fish-voice-clone",
1556
+ aliases: [],
1557
+ label: "Fish Audio Voice Clone",
1558
+ type: "voice-clone",
1559
+ adapter: "fish",
1560
+ providerModelId: "voice-clone",
1561
+ lifecycle: "active",
1562
+ exposure: [],
1563
+ constraints: {
1564
+ // POST /model takes 1-20 voice files; Fish advises 10 s or more of clean speech each.
1565
+ references: { audios: 20, audioFormats: ["wav", "mp3", "m4a", "opus"] },
1566
+ supports: []
1567
+ },
1568
+ defaults: {},
1569
+ usage: PER_REQUEST,
1570
+ pricingKind: "flat",
1571
+ display: {
1572
+ provider: "fishaudio",
1573
+ logo: FISH_LOGO,
1574
+ description: "Clone a private, reusable voice from one or more clean recordings, then speak with it on the Fish Audio speech models."
1575
+ },
1576
+ capabilities: {}
1577
+ },
1578
+ {
1579
+ id: "fish-voice-design-1",
1580
+ aliases: [],
1581
+ label: "Fish Audio Voice Design",
1582
+ type: "voice-design",
1583
+ adapter: "fish",
1584
+ providerModelId: "voice-design-1",
1585
+ lifecycle: "active",
1586
+ exposure: [],
1587
+ constraints: {
1588
+ voiceDesign: { maxCandidates: 4, previewMaxChars: 150 },
1589
+ supports: [],
1590
+ prompt: { referenceSyntax: "numbered", maxChars: 2e3 }
1591
+ },
1592
+ defaults: {},
1593
+ usage: PER_REQUEST,
1594
+ pricingKind: "flat",
1595
+ display: {
1596
+ provider: "fishaudio",
1597
+ logo: FISH_LOGO,
1598
+ description: "Describe a voice and get up to four short candidates; keep one as a cloned voice."
1599
+ },
1600
+ capabilities: {}
1601
+ }
1602
+ ];
1603
+
1305
1604
  // src/catalog/languages-audio.ts
1306
1605
  var ELEVENLABS_V4_LANGUAGES = [
1307
1606
  { code: "af", name: "Afrikaans" },
@@ -3383,7 +3682,7 @@ var ELEVENLABS_LOGO = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.
3383
3682
  var GOOGLE_LOGO = "https://d31vvfpk2ppdyq.cloudfront.net/assets/image/192485d2-a76a-4657-9c06-930b2c70badd_compressed.png";
3384
3683
  var MINIMAX_LOGO = "https://d31vvfpk2ppdyq.cloudfront.net/assets/image/f4c2e906-28b7-4a4a-b491-df8fef7808ed.png";
3385
3684
  var SYNC_LOGO = "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-png@1.97.0/light/sync.png";
3386
- var PER_CALL = { unit: "images" };
3685
+ var PER_CALL2 = { unit: "images" };
3387
3686
  var EXPOSURE4 = ["cowork", "space_builder"];
3388
3687
  var LIPSYNC_AUDIO = { audioSeconds: { max: 30 } };
3389
3688
  function elevenLabsTts(tts) {
@@ -3404,7 +3703,7 @@ function elevenLabsTts(tts) {
3404
3703
  prompt: { referenceSyntax: "numbered", maxChars: tts.maxChars }
3405
3704
  },
3406
3705
  defaults: { voice: "Rachel", ...tts.formats && { outputFormat: "mp3_44100_128" } },
3407
- usage: PER_CALL,
3706
+ usage: PER_CALL2,
3408
3707
  pricingKind: "flat",
3409
3708
  display: { provider: "elevenlabs", logo: ELEVENLABS_LOGO, description: tts.description },
3410
3709
  capabilities: {}
@@ -3428,7 +3727,7 @@ function geminiTts(id, label, description) {
3428
3727
  prompt: { referenceSyntax: "numbered", maxChars: 8192 }
3429
3728
  },
3430
3729
  defaults: { voice: "Kore" },
3431
- usage: PER_CALL,
3730
+ usage: PER_CALL2,
3432
3731
  pricingKind: "flat",
3433
3732
  display: { provider: "google", logo: GOOGLE_LOGO, description },
3434
3733
  capabilities: {}
@@ -3451,7 +3750,7 @@ function syncLipsync(id, label, providerModelId, syncModel, description) {
3451
3750
  },
3452
3751
  defaults: {},
3453
3752
  ...syncModel !== void 0 && { providerOptions: { fal: { model: syncModel } } },
3454
- usage: PER_CALL,
3753
+ usage: PER_CALL2,
3455
3754
  pricingKind: "flat",
3456
3755
  display: { provider: "sync", logo: SYNC_LOGO, description },
3457
3756
  capabilities: { acceptsSourceVideo: true }
@@ -3512,7 +3811,7 @@ var MEDIA_NODE_MODELS = [
3512
3811
  prompt: { referenceSyntax: "numbered", maxChars: 1e4 }
3513
3812
  },
3514
3813
  defaults: { voice: "Wise_Woman" },
3515
- usage: PER_CALL,
3814
+ usage: PER_CALL2,
3516
3815
  pricingKind: "flat",
3517
3816
  display: {
3518
3817
  provider: "minimax",
@@ -3539,7 +3838,7 @@ var MEDIA_NODE_MODELS = [
3539
3838
  supports: []
3540
3839
  },
3541
3840
  defaults: { voice: "Rachel", outputFormat: "mp3_44100_128" },
3542
- usage: PER_CALL,
3841
+ usage: PER_CALL2,
3543
3842
  pricingKind: "flat",
3544
3843
  display: {
3545
3844
  provider: "elevenlabs",
@@ -3566,7 +3865,7 @@ var MEDIA_NODE_MODELS = [
3566
3865
  supports: []
3567
3866
  },
3568
3867
  defaults: {},
3569
- usage: PER_CALL,
3868
+ usage: PER_CALL2,
3570
3869
  pricingKind: "flat",
3571
3870
  display: {
3572
3871
  provider: "elevenlabs",
@@ -3613,7 +3912,7 @@ var MEDIA_NODE_MODELS = [
3613
3912
  supports: []
3614
3913
  },
3615
3914
  defaults: {},
3616
- usage: PER_CALL,
3915
+ usage: PER_CALL2,
3617
3916
  pricingKind: "flat",
3618
3917
  display: {
3619
3918
  provider: "veed",
@@ -3665,7 +3964,7 @@ var MEDIA_NODE_MODELS = [
3665
3964
  supports: []
3666
3965
  },
3667
3966
  defaults: {},
3668
- usage: PER_CALL,
3967
+ usage: PER_CALL2,
3669
3968
  pricingKind: "flat",
3670
3969
  display: {
3671
3970
  provider: "elevenlabs",
@@ -3674,7 +3973,8 @@ var MEDIA_NODE_MODELS = [
3674
3973
  },
3675
3974
  capabilities: {}
3676
3975
  },
3677
- SAM_3_IMAGE
3976
+ SAM_3_IMAGE,
3977
+ ...FISH_MODELS
3678
3978
  ];
3679
3979
 
3680
3980
  // src/catalog/model3d.ts
@@ -7315,7 +7615,8 @@ var ADAPTER_IDS = [
7315
7615
  "prodia",
7316
7616
  "higgsfield",
7317
7617
  "xai-video",
7318
- "luma"
7618
+ "luma",
7619
+ "fish"
7319
7620
  ];
7320
7621
  var ModelTypeSchema = import_zod3.z.enum([
7321
7622
  "text",
@@ -7332,6 +7633,8 @@ var ModelTypeSchema = import_zod3.z.enum([
7332
7633
  "dubbing",
7333
7634
  "lipsync",
7334
7635
  "transcription",
7636
+ "voice-clone",
7637
+ "voice-design",
7335
7638
  "model3d"
7336
7639
  ]);
7337
7640
  var LifecycleSchema = import_zod3.z.enum(["active", "deprecated", "retired"]);
@@ -7507,6 +7810,10 @@ var ModelConstraintsSchema = import_zod3.z.looseObject({
7507
7810
  supports: import_zod3.z.array(SupportedOptionSchema).optional(),
7508
7811
  ratioIgnoredWith: import_zod3.z.array(import_zod3.z.enum(["firstFrame", "sourceVideo"])).optional(),
7509
7812
  voices: import_zod3.z.array(VoiceOptionSchema).optional(),
7813
+ voiceDesign: import_zod3.z.object({
7814
+ maxCandidates: import_zod3.z.number().int().positive(),
7815
+ previewMaxChars: import_zod3.z.number().int().positive()
7816
+ }).optional(),
7510
7817
  languages: import_zod3.z.array(LanguageOptionSchema).optional(),
7511
7818
  outputFormats: import_zod3.z.array(import_zod3.z.string().min(1)).min(1).optional(),
7512
7819
  sourceVideoSeconds: VideoSecondsLimitsSchema.omit({ total: true }).extend({ inReferenceTotal: import_zod3.z.boolean().optional() }).optional(),
@@ -7605,6 +7912,7 @@ var CatalogDisplaySchema = import_zod3.z.looseObject({
7605
7912
  });
7606
7913
  var CatalogCapabilitiesSchema = import_zod3.z.looseObject({
7607
7914
  acceptsSourceVideo: import_zod3.z.boolean().optional(),
7915
+ customVoices: import_zod3.z.boolean().optional(),
7608
7916
  videoEdit: import_zod3.z.boolean().optional(),
7609
7917
  framesExcludeReferences: import_zod3.z.boolean().optional(),
7610
7918
  sourceVideoExcludesFrames: import_zod3.z.boolean().optional(),
@@ -9483,8 +9791,17 @@ function resolveSpeechOptions(model, request, onUnsupported = "error") {
9483
9791
  const warnings = [];
9484
9792
  checkScript(model, request.text);
9485
9793
  const resolved = { model: model.id, text: request.text };
9486
- const voice = resolveVoice(model, request.voice, onUnsupported, warnings);
9487
- if (voice !== void 0) resolved.voice = voice;
9794
+ const customVoice = request.customVoice?.trim();
9795
+ if (customVoice) {
9796
+ if (model.capabilities?.customVoices !== true) {
9797
+ throw unsupported3(model, "does not take a custom voice");
9798
+ }
9799
+ if (request.voice?.trim()) throw unsupported3(model, "takes voice or customVoice, not both");
9800
+ resolved.customVoice = customVoice;
9801
+ } else {
9802
+ const voice = resolveVoice(model, request.voice, onUnsupported, warnings);
9803
+ if (voice !== void 0) resolved.voice = voice;
9804
+ }
9488
9805
  const language = resolveListed(
9489
9806
  model,
9490
9807
  {
@@ -9606,6 +9923,106 @@ function resolveTranscriptionOptions(model, request, onUnsupported = "error") {
9606
9923
  return { request: resolved, warnings };
9607
9924
  }
9608
9925
 
9926
+ // src/resolve-voice.ts
9927
+ function refuse(model, code, message) {
9928
+ return new ModelKitError({ code, message: `${model.label} ${message}`, model: model.id });
9929
+ }
9930
+ function checkSample(model, sample, index) {
9931
+ const limits = model.constraints.references?.audioSeconds;
9932
+ const length = sample.audio.durationSeconds;
9933
+ if (limits === void 0 || length === void 0) return;
9934
+ if (limits.min !== void 0 && length < limits.min || limits.max !== void 0 && length > limits.max) {
9935
+ const range2 = `${limits.min ?? 0}-${limits.max ?? "any"} s`;
9936
+ throw refuse(
9937
+ model,
9938
+ "unsupported_option",
9939
+ `takes samples of ${range2}; sample ${index + 1} is ${length} s`
9940
+ );
9941
+ }
9942
+ }
9943
+ function resolveVoiceCloneOptions(model, request) {
9944
+ const title = request.title.trim();
9945
+ if (!title) throw refuse(model, "missing_input", "needs a title");
9946
+ if (request.samples.length === 0) throw refuse(model, "missing_input", "needs a voice sample");
9947
+ const max = model.constraints.references?.audios;
9948
+ if (max !== void 0 && request.samples.length > max) {
9949
+ throw refuse(
9950
+ model,
9951
+ "unsupported_option",
9952
+ `takes up to ${max} samples; got ${request.samples.length}`
9953
+ );
9954
+ }
9955
+ request.samples.forEach((sample, index) => checkSample(model, sample, index));
9956
+ const total = model.constraints.references?.audioSeconds?.total;
9957
+ const seconds3 = request.samples.reduce((sum, s) => sum + (s.audio.durationSeconds ?? 0), 0);
9958
+ if (total !== void 0 && seconds3 > total) {
9959
+ throw refuse(
9960
+ model,
9961
+ "unsupported_option",
9962
+ `takes up to ${total} s of samples; got ${seconds3} s`
9963
+ );
9964
+ }
9965
+ for (const field of ["transcript", "designSignature"]) {
9966
+ const set = request.samples.filter((s) => s[field]?.trim()).length;
9967
+ if (set !== 0 && set !== request.samples.length) {
9968
+ throw refuse(model, "missing_input", `needs ${field} on every sample or on none`);
9969
+ }
9970
+ }
9971
+ return { request: { ...request, model: model.id, title }, warnings: [] };
9972
+ }
9973
+ function resolveVoiceDesignOptions(model, request, onUnsupported = "error") {
9974
+ const warnings = [];
9975
+ const prompt = request.prompt.trim();
9976
+ if (!prompt) throw refuse(model, "missing_input", "needs a voice description");
9977
+ const maxChars = promptConventions(model).maxChars;
9978
+ if (maxChars !== void 0 && prompt.length > maxChars) {
9979
+ throw refuse(
9980
+ model,
9981
+ "unsupported_option",
9982
+ `takes up to ${maxChars} characters; got ${prompt.length}`
9983
+ );
9984
+ }
9985
+ const limits = model.constraints.voiceDesign;
9986
+ const resolved = { model: model.id, prompt };
9987
+ const preview2 = request.previewText?.trim();
9988
+ if (preview2) {
9989
+ if (limits !== void 0 && preview2.length > limits.previewMaxChars) {
9990
+ throw refuse(
9991
+ model,
9992
+ "unsupported_option",
9993
+ `takes a preview line of up to ${limits.previewMaxChars} characters; got ${preview2.length}`
9994
+ );
9995
+ }
9996
+ resolved.previewText = preview2;
9997
+ }
9998
+ const language = request.language?.trim();
9999
+ if (language) resolved.language = language;
10000
+ if (request.seed !== void 0) resolved.seed = request.seed;
10001
+ const count2 = request.count ?? model.defaults.count;
10002
+ if (count2 !== void 0) {
10003
+ const cap = limits?.maxCandidates ?? count2;
10004
+ if (!Number.isInteger(count2) || count2 < 1 || count2 > cap) {
10005
+ if (onUnsupported === "error" || !Number.isInteger(count2)) {
10006
+ throw refuse(
10007
+ model,
10008
+ "unsupported_option",
10009
+ `returns 1-${cap} candidates; got ${count2}`
10010
+ );
10011
+ }
10012
+ const to = Math.min(Math.max(count2, 1), cap);
10013
+ warnings.push({
10014
+ code: "option_ignored",
10015
+ field: "count",
10016
+ from: String(count2),
10017
+ to: String(to),
10018
+ message: `count ${count2} replaced by ${to} for ${model.id}`
10019
+ });
10020
+ resolved.count = to;
10021
+ } else resolved.count = count2;
10022
+ }
10023
+ return { request: resolved, warnings };
10024
+ }
10025
+
9609
10026
  // src/resolve-call.ts
9610
10027
  var MEDIA_METHODS = {
9611
10028
  text: [],
@@ -9621,6 +10038,9 @@ var MEDIA_METHODS = {
9621
10038
  lipsync: ["lipsync"],
9622
10039
  dubbing: ["dub"],
9623
10040
  transcription: ["transcribe"],
10041
+ // deleteVoice runs on the voice-clone row too; it checks with assertMethod("createVoice").
10042
+ "voice-clone": ["createVoice"],
10043
+ "voice-design": ["designVoice"],
9624
10044
  "video-tool": ["submitVideoTool"],
9625
10045
  // 3D jobs run through submitModel3d, which checks the row type itself; their preflight
9626
10046
  // is model3dInputRefusal, not resolveOptions.
@@ -9635,6 +10055,8 @@ var METHOD_NOUN = {
9635
10055
  lipsync: "a lipsync model",
9636
10056
  dub: "a dubbing model",
9637
10057
  transcribe: "a transcription model",
10058
+ createVoice: "a voice cloning model",
10059
+ designVoice: "a voice design model",
9638
10060
  submitVideoTool: "a video tool"
9639
10061
  };
9640
10062
  function assertMethod(model, method) {
@@ -9677,6 +10099,14 @@ function resolveTranscriptionCall(model, request, onUnsupported = "error") {
9677
10099
  assertMethod(model, "transcribe");
9678
10100
  return resolveTranscriptionOptions(model, request, onUnsupported);
9679
10101
  }
10102
+ function resolveVoiceCloneCall(model, request) {
10103
+ assertMethod(model, "createVoice");
10104
+ return resolveVoiceCloneOptions(model, request);
10105
+ }
10106
+ function resolveVoiceDesignCall(model, request, onUnsupported = "error") {
10107
+ assertMethod(model, "designVoice");
10108
+ return resolveVoiceDesignOptions(model, request, onUnsupported);
10109
+ }
9680
10110
  function resolveVideoToolCall(model, request) {
9681
10111
  assertMethod(model, "submitVideoTool");
9682
10112
  const plan = planVideoTool(model, request);
@@ -9723,6 +10153,13 @@ function resolveOptions(model, call, onUnsupported = "error") {
9723
10153
  method: call.method,
9724
10154
  ...resolveTranscriptionCall(model, call.request, onUnsupported)
9725
10155
  };
10156
+ case "createVoice":
10157
+ return { method: call.method, ...resolveVoiceCloneCall(model, call.request) };
10158
+ case "designVoice":
10159
+ return {
10160
+ method: call.method,
10161
+ ...resolveVoiceDesignCall(model, call.request, onUnsupported)
10162
+ };
9726
10163
  case "submitVideoTool":
9727
10164
  return { method: call.method, ...resolveVideoToolCall(model, call.request) };
9728
10165
  }