@gentbajko/slopify 0.7.0 → 0.8.1

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.
Files changed (42) hide show
  1. package/dist/adapters/llm/codex.js +3 -0
  2. package/dist/adapters/llm/gemini-workspace.js +24 -1
  3. package/dist/adapters/llm/gemini.js +1 -1
  4. package/dist/adapters/llm/openrouter.js +1 -0
  5. package/dist/assets/models.yaml +470 -0
  6. package/dist/catalog/registry.js +147 -0
  7. package/dist/catalog/schema.js +91 -0
  8. package/dist/catalog/store.js +90 -0
  9. package/dist/catalog/validate.js +43 -0
  10. package/dist/edge/http/actions.js +1 -1
  11. package/dist/edge/http/app.js +4 -0
  12. package/dist/edge/http/open-folder.js +61 -0
  13. package/dist/edge/http/planning.js +101 -0
  14. package/dist/edge/http/projects.js +4 -2
  15. package/dist/edge/http/providers.js +28 -0
  16. package/dist/edge/open-folder.js +25 -0
  17. package/dist/kernel/db/migrations/0003-batch-queue.sql +11 -0
  18. package/dist/kernel/ports/llm.js +1 -0
  19. package/dist/kernel/ports/text.js +26 -0
  20. package/dist/kernel/runner/providers.js +8 -6
  21. package/dist/kernel/runner/queue.js +65 -0
  22. package/dist/main.js +43 -6
  23. package/dist/slices/admission/repo.js +13 -2
  24. package/dist/slices/admission/start.js +9 -8
  25. package/dist/slices/article/continuation.js +8 -1
  26. package/dist/slices/article/segments.js +1 -0
  27. package/dist/slices/batch/index.js +82 -0
  28. package/dist/slices/control/index.js +4 -1
  29. package/dist/slices/control/providers.js +18 -1
  30. package/dist/slices/estimate/index.js +81 -0
  31. package/dist/slices/narration/chunk.js +21 -22
  32. package/dist/slices/narration/plan.js +58 -0
  33. package/dist/slices/narration/run.js +6 -27
  34. package/dist/slices/research/run.js +4 -0
  35. package/dist/slices/storage/staging.js +2 -1
  36. package/dist/slices/thumbnail/run.js +3 -0
  37. package/dist/web/assets/index-DkC4WXhk.css +1 -0
  38. package/dist/web/assets/index-GszhBVNn.js +130 -0
  39. package/dist/web/index.html +2 -2
  40. package/package.json +2 -1
  41. package/dist/web/assets/index-6QIQ-TzO.css +0 -1
  42. package/dist/web/assets/index-CNgWL-ct.js +0 -130
@@ -25,6 +25,9 @@ export function codexArgs(req) {
25
25
  "--skip-git-repo-check",
26
26
  "-c",
27
27
  `web_search="${req.webSearch === true ? "live" : "disabled"}"`,
28
+ ...(req.thinkingConfig?.effort
29
+ ? ["-c", `model_reasoning_effort="${req.thinkingConfig.effort}"`]
30
+ : []),
28
31
  ...(req.model === "" ? [] : ["-m", req.model]),
29
32
  // The prompt is one argv element after `--`, so a leading dash is text, not a flag.
30
33
  "--",
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  // Gemini 0.16 supports stream-json and tools.core, but headless default mode still
5
5
  // exposes file-reading tools. Isolate each writing call while retaining ~/.gemini
6
6
  // authentication. Nothing is written into the user's settings or project.
7
- export function geminiWorkspace(webSearch) {
7
+ export function geminiWorkspace(webSearch, request) {
8
8
  const directory = mkdtempSync(join(tmpdir(), "slopify-gemini-"));
9
9
  try {
10
10
  const settings = join(directory, "settings.json");
@@ -12,6 +12,29 @@ export function geminiWorkspace(webSearch) {
12
12
  const trust = join(directory, "trusted-folders.json");
13
13
  const mcpAllowlist = `slopify-disabled-${directory.split(/[\\/]/).at(-1)}`;
14
14
  writeFileSync(settings, JSON.stringify({
15
+ ...(request?.thinkingConfig
16
+ ? {
17
+ modelConfigs: {
18
+ customOverrides: [
19
+ {
20
+ match: { model: request.model },
21
+ modelConfig: {
22
+ generateContentConfig: {
23
+ thinkingConfig: {
24
+ ...(request.thinkingConfig.level === undefined
25
+ ? {}
26
+ : { thinkingLevel: request.thinkingConfig.level }),
27
+ ...(request.thinkingConfig.budget === undefined
28
+ ? {}
29
+ : { thinkingBudget: request.thinkingConfig.budget }),
30
+ },
31
+ },
32
+ },
33
+ },
34
+ ],
35
+ },
36
+ }
37
+ : {}),
15
38
  tools: {
16
39
  core: webSearch ? ["google_web_search"] : [],
17
40
  allowed: webSearch ? ["google_web_search"] : [],
@@ -43,7 +43,7 @@ export function geminiLlm(deps) {
43
43
  const binary = deps.binary ?? geminiBinary;
44
44
  async function* complete(req) {
45
45
  req.signal.throwIfAborted();
46
- const workspace = geminiWorkspace(req.webSearch === true);
46
+ const workspace = geminiWorkspace(req.webSearch === true, req);
47
47
  let run;
48
48
  try {
49
49
  run = deps.run(binary, geminiArgs(req, workspace.mcpAllowlist), req.signal, workspace.options);
@@ -47,6 +47,7 @@ export function openRouterLlm(deps) {
47
47
  role: message.role,
48
48
  content: message.content,
49
49
  })),
50
+ ...(req.thinkingConfig?.effort ? { reasoning: { effort: req.thinkingConfig.effort } } : {}),
50
51
  stream: true,
51
52
  // The usage-accounting flag: without it the final chunk carries no token counts
52
53
  // and the Usage page would have nothing to count.
@@ -0,0 +1,470 @@
1
+ # Curated adapter-compatible models. Edit this file to change the published defaults.
2
+ # A private copy in the Slopify data directory reloads automatically.
3
+ # Omitted pricing means unknown, never free. Provider concurrency is capped at five.
4
+ schemaVersion: 1
5
+ updatedAt: 2026-09-10
6
+ providers:
7
+ openrouter:
8
+ maxConcurrent: 3
9
+ claude-code:
10
+ maxConcurrent: 3
11
+ codex:
12
+ maxConcurrent: 3
13
+ gemini:
14
+ maxConcurrent: 3
15
+ elevenlabs:
16
+ maxConcurrent: 3
17
+ openai-tts:
18
+ maxConcurrent: 3
19
+ cartesia:
20
+ maxConcurrent: 3
21
+ inworld:
22
+ maxConcurrent: 5
23
+ fal:
24
+ maxConcurrent: 3
25
+ replicate:
26
+ maxConcurrent: 3
27
+ openai-image:
28
+ maxConcurrent: 3
29
+ google-image:
30
+ maxConcurrent: 3
31
+ llm:
32
+ - provider: claude-code
33
+ id: opus
34
+ name: Claude Opus (latest)
35
+ source: https://code.claude.com/docs/en/model-config
36
+ keywords:
37
+ - llm
38
+ pricing: &a1
39
+ note: Uses your CLI login and account plan. No per-run price is available; paid
40
+ overage may apply.
41
+ llm:
42
+ webSearch: true
43
+ - provider: claude-code
44
+ id: sonnet
45
+ name: Claude Sonnet (latest)
46
+ source: https://code.claude.com/docs/en/model-config
47
+ keywords:
48
+ - llm
49
+ pricing: *a1
50
+ llm:
51
+ webSearch: true
52
+ - provider: claude-code
53
+ id: haiku
54
+ name: Claude Haiku (latest)
55
+ source: https://code.claude.com/docs/en/model-config
56
+ keywords:
57
+ - llm
58
+ pricing: *a1
59
+ llm:
60
+ webSearch: true
61
+ - provider: codex
62
+ id: gpt-6-astra
63
+ name: GPT-6-Astra
64
+ source: https://developers.openai.com/codex/models
65
+ keywords:
66
+ - llm
67
+ pricing: *a1
68
+ llm:
69
+ webSearch: true
70
+ thinking: &a2
71
+ low:
72
+ effort: low
73
+ medium:
74
+ effort: medium
75
+ high:
76
+ effort: high
77
+ - provider: codex
78
+ id: gpt-5.6-sol
79
+ name: GPT-5.6-Sol
80
+ source: https://developers.openai.com/codex/models
81
+ keywords:
82
+ - llm
83
+ pricing: *a1
84
+ llm:
85
+ webSearch: true
86
+ thinking: *a2
87
+ - provider: codex
88
+ id: gpt-5.6-terra
89
+ name: GPT-5.6-Terra
90
+ source: https://developers.openai.com/codex/models
91
+ keywords:
92
+ - llm
93
+ pricing: *a1
94
+ llm:
95
+ webSearch: true
96
+ thinking: *a2
97
+ - provider: codex
98
+ id: gpt-5.6-luna
99
+ name: GPT-5.6-Luna
100
+ source: https://developers.openai.com/codex/models
101
+ keywords:
102
+ - llm
103
+ pricing: *a1
104
+ llm:
105
+ webSearch: true
106
+ thinking: *a2
107
+ - provider: codex
108
+ id: gpt-5.5
109
+ name: GPT-5.5
110
+ source: https://developers.openai.com/codex/models
111
+ keywords:
112
+ - llm
113
+ pricing: *a1
114
+ llm:
115
+ webSearch: true
116
+ thinking: *a2
117
+ - provider: codex
118
+ id: gpt-5.3-codex-spark
119
+ name: GPT-5.3-Codex-Spark
120
+ source: https://developers.openai.com/codex/models
121
+ keywords:
122
+ - llm
123
+ pricing: *a1
124
+ llm:
125
+ webSearch: true
126
+ thinking: *a2
127
+ - provider: gemini
128
+ id: gemini-3.8-flash
129
+ name: Gemini 3.8 Flash
130
+ source: https://ai.google.dev/gemini-api/docs/models
131
+ keywords:
132
+ - llm
133
+ pricing: *a1
134
+ llm:
135
+ webSearch: true
136
+ thinking: &a3
137
+ low:
138
+ level: low
139
+ medium:
140
+ level: medium
141
+ high:
142
+ level: high
143
+ - provider: gemini
144
+ id: gemini-3.1-pro-preview
145
+ name: Gemini 3.1 Pro (preview)
146
+ source: https://ai.google.dev/gemini-api/docs/models
147
+ keywords:
148
+ - llm
149
+ pricing: *a1
150
+ llm:
151
+ webSearch: true
152
+ thinking: *a3
153
+ - provider: openrouter
154
+ id: google/gemini-3.8-flash
155
+ name: "Google: Gemini 3.8 Flash"
156
+ source: https://openrouter.ai/api/v1/models
157
+ keywords:
158
+ - llm
159
+ pricing:
160
+ inputPerMillionTokens: 0.75
161
+ outputPerMillionTokens: 3.75
162
+ note: Public OpenRouter rates verified 2026-09-10; routing, long context,
163
+ reasoning and web search may add charges.
164
+ llm:
165
+ webSearch: true
166
+ contextTokens: 1048576
167
+ thinking: *a2
168
+ - provider: openrouter
169
+ id: google/gemini-3.1-pro-preview
170
+ name: "Google: Gemini 3.1 Pro Preview"
171
+ source: https://openrouter.ai/api/v1/models
172
+ keywords:
173
+ - llm
174
+ pricing:
175
+ inputPerMillionTokens: 2
176
+ outputPerMillionTokens: 12
177
+ note: Public OpenRouter rates verified 2026-09-10; routing, long context,
178
+ reasoning and web search may add charges.
179
+ llm:
180
+ webSearch: true
181
+ contextTokens: 1048576
182
+ thinking: *a2
183
+ - provider: openrouter
184
+ id: openai/gpt-5.6-sol
185
+ name: "OpenAI: GPT-5.6 Sol"
186
+ source: https://openrouter.ai/api/v1/models
187
+ keywords:
188
+ - llm
189
+ pricing:
190
+ inputPerMillionTokens: 2
191
+ outputPerMillionTokens: 10
192
+ note: Public OpenRouter rates verified 2026-09-10; routing, long context,
193
+ reasoning and web search may add charges.
194
+ llm:
195
+ webSearch: true
196
+ contextTokens: 1050000
197
+ thinking: *a2
198
+ - provider: openrouter
199
+ id: openai/gpt-5.6-terra
200
+ name: "OpenAI: GPT-5.6 Terra"
201
+ source: https://openrouter.ai/api/v1/models
202
+ keywords:
203
+ - llm
204
+ pricing:
205
+ inputPerMillionTokens: 2
206
+ outputPerMillionTokens: 12
207
+ note: Public OpenRouter rates verified 2026-09-10; routing, long context,
208
+ reasoning and web search may add charges.
209
+ llm:
210
+ webSearch: true
211
+ contextTokens: 1050000
212
+ thinking: *a2
213
+ - provider: openrouter
214
+ id: openai/gpt-5.6-luna
215
+ name: "OpenAI: GPT-5.6 Luna"
216
+ source: https://openrouter.ai/api/v1/models
217
+ keywords:
218
+ - llm
219
+ pricing:
220
+ inputPerMillionTokens: 0.19999999999999998
221
+ outputPerMillionTokens: 1.2
222
+ note: Public OpenRouter rates verified 2026-09-10; routing, long context,
223
+ reasoning and web search may add charges.
224
+ llm:
225
+ webSearch: true
226
+ contextTokens: 1050000
227
+ thinking: *a2
228
+ - provider: openrouter
229
+ id: anthropic/claude-sonnet-4.6
230
+ name: "Anthropic: Claude Sonnet 4.6"
231
+ source: https://openrouter.ai/api/v1/models
232
+ keywords:
233
+ - llm
234
+ pricing:
235
+ inputPerMillionTokens: 3
236
+ outputPerMillionTokens: 15
237
+ note: Public OpenRouter rates verified 2026-09-10; routing, long context,
238
+ reasoning and web search may add charges.
239
+ llm:
240
+ webSearch: true
241
+ contextTokens: 1000000
242
+ thinking: *a2
243
+ - provider: openrouter
244
+ id: anthropic/claude-opus-4.7
245
+ name: "Anthropic: Claude Opus 4.7"
246
+ source: https://openrouter.ai/api/v1/models
247
+ keywords:
248
+ - llm
249
+ pricing:
250
+ inputPerMillionTokens: 5
251
+ outputPerMillionTokens: 25
252
+ note: Public OpenRouter rates verified 2026-09-10; routing, long context,
253
+ reasoning and web search may add charges.
254
+ llm:
255
+ webSearch: true
256
+ contextTokens: 1000000
257
+ thinking: *a2
258
+ image:
259
+ - provider: openai-image
260
+ id: gpt-image-2.5-sunburst
261
+ name: GPT Image 2.5 Sunburst
262
+ source: https://developers.openai.com/api/docs/guides/image-generation
263
+ keywords:
264
+ - image
265
+ pricing:
266
+ note: Automatic quality and generated image tokens determine the charge; a fixed
267
+ image price is unavailable.
268
+ image: &a4
269
+ aspectRatios: &a5
270
+ - 16:9
271
+ - 9:16
272
+ - provider: openai-image
273
+ id: gpt-image-2.5-flare
274
+ name: GPT Image 2.5 Flare
275
+ source: https://developers.openai.com/api/docs/guides/image-generation
276
+ keywords:
277
+ - image
278
+ pricing:
279
+ note: Automatic quality and generated image tokens determine the charge; a fixed
280
+ image price is unavailable.
281
+ image: *a4
282
+ - provider: openai-image
283
+ id: gpt-image-2
284
+ name: GPT Image 2
285
+ source: https://developers.openai.com/api/docs/guides/image-generation
286
+ keywords:
287
+ - image
288
+ pricing:
289
+ note: Automatic quality and generated image tokens determine the charge; a fixed
290
+ image price is unavailable.
291
+ image: *a4
292
+ - provider: google-image
293
+ id: gemini-3.1-flash-image
294
+ name: Nano Banana 2
295
+ source: https://ai.google.dev/gemini-api/docs/pricing
296
+ keywords:
297
+ - image
298
+ pricing:
299
+ perImage: 0.101
300
+ note: 2K image output price; prompt, thinking and other input tokens are
301
+ additional.
302
+ image:
303
+ aspectRatios: *a5
304
+ resolution: 2K
305
+ - provider: google-image
306
+ id: gemini-3-pro-image
307
+ name: Nano Banana Pro
308
+ source: https://ai.google.dev/gemini-api/docs/models
309
+ keywords:
310
+ - image
311
+ pricing:
312
+ note: Price depends on your account or generated output; estimate unavailable.
313
+ image:
314
+ aspectRatios: *a5
315
+ resolution: 2K
316
+ - provider: fal
317
+ id: fal-ai/flux-2
318
+ name: FLUX.2
319
+ source: https://fal.ai/models/fal-ai/flux-2
320
+ keywords:
321
+ - image
322
+ pricing:
323
+ note: Price depends on your account or generated output; estimate unavailable.
324
+ image: *a4
325
+ - provider: fal
326
+ id: fal-ai/nano-banana-2
327
+ name: Nano Banana 2
328
+ source: https://fal.ai/models/fal-ai/nano-banana-2
329
+ keywords:
330
+ - image
331
+ pricing:
332
+ note: Price depends on your account or generated output; estimate unavailable.
333
+ image: *a4
334
+ - provider: replicate
335
+ id: black-forest-labs/flux-1.1-pro
336
+ name: FLUX 1.1 Pro
337
+ source: https://replicate.com/black-forest-labs/flux-1.1-pro
338
+ keywords:
339
+ - image
340
+ pricing:
341
+ note: Price depends on your account or generated output; estimate unavailable.
342
+ image: *a4
343
+ - provider: replicate
344
+ id: black-forest-labs/flux-dev
345
+ name: FLUX.1 Dev
346
+ source: https://replicate.com/black-forest-labs/flux-dev
347
+ keywords:
348
+ - image
349
+ pricing:
350
+ note: Price depends on your account or generated output; estimate unavailable.
351
+ image: *a4
352
+ - provider: replicate
353
+ id: black-forest-labs/flux-schnell
354
+ name: FLUX.1 Schnell
355
+ source: https://replicate.com/black-forest-labs/flux-schnell
356
+ keywords:
357
+ - image
358
+ pricing:
359
+ note: Price depends on your account or generated output; estimate unavailable.
360
+ image: *a4
361
+ tts:
362
+ - provider: elevenlabs
363
+ id: eleven_v3
364
+ name: Eleven v3
365
+ source: https://elevenlabs.io/docs/overview/models
366
+ keywords:
367
+ - tts
368
+ pricing:
369
+ perMillionCharacters: 100
370
+ note: Advertised starting API rate; your plan, voice and account credits affect
371
+ actual cost.
372
+ tts:
373
+ maxCharacters: 5000
374
+ streaming: true
375
+ - provider: elevenlabs
376
+ id: eleven_multilingual_v2
377
+ name: Eleven Multilingual v2
378
+ source: https://elevenlabs.io/docs/overview/models
379
+ keywords:
380
+ - tts
381
+ pricing:
382
+ perMillionCharacters: 100
383
+ note: Advertised starting API rate; your plan, voice and account credits affect
384
+ actual cost.
385
+ tts:
386
+ maxCharacters: 10000
387
+ streaming: true
388
+ - provider: elevenlabs
389
+ id: eleven_flash_v2_5
390
+ name: Eleven Flash v2.5
391
+ source: https://elevenlabs.io/docs/overview/models
392
+ keywords:
393
+ - tts
394
+ pricing:
395
+ perMillionCharacters: 50
396
+ note: Advertised starting API rate; your plan, voice and account credits affect
397
+ actual cost.
398
+ tts:
399
+ maxCharacters: 40000
400
+ streaming: true
401
+ - provider: inworld
402
+ id: inworld-tts-2
403
+ name: Inworld TTS-2
404
+ source: https://inworld.ai/pricing
405
+ keywords:
406
+ - tts
407
+ pricing:
408
+ perMillionCharacters: 25
409
+ note: On-Demand rate before discounts or credits. Requests are split at 10,000
410
+ characters for account compatibility.
411
+ tts:
412
+ maxCharacters: 10000
413
+ asyncMaxCharacters: 100000
414
+ streaming: true
415
+ - provider: inworld
416
+ id: inworld-tts-2-flash
417
+ name: Inworld TTS-2 Flash
418
+ source: https://inworld.ai/pricing
419
+ keywords:
420
+ - tts
421
+ pricing:
422
+ perMillionCharacters: 15
423
+ note: On-Demand rate before plan discounts or credits.
424
+ tts:
425
+ maxCharacters: 4000
426
+ streaming: true
427
+ - provider: cartesia
428
+ id: sonic-3.6
429
+ name: Sonic 3.6
430
+ source: https://docs.cartesia.ai/build-with-cartesia/tts-models/latest
431
+ keywords:
432
+ - tts
433
+ pricing:
434
+ note: Price depends on your account or generated output; estimate unavailable.
435
+ tts:
436
+ maxCharacters: 4000
437
+ streaming: true
438
+ - provider: openai-tts
439
+ id: gpt-4o-mini-tts
440
+ name: GPT-4o mini TTS
441
+ source: https://developers.openai.com/api/docs/guides/text-to-speech
442
+ keywords:
443
+ - tts
444
+ pricing:
445
+ note: Price depends on your account or generated output; estimate unavailable.
446
+ tts:
447
+ maxCharacters: 4096
448
+ streaming: true
449
+ - provider: openai-tts
450
+ id: tts-1
451
+ name: TTS-1
452
+ source: https://developers.openai.com/api/docs/guides/text-to-speech
453
+ keywords:
454
+ - tts
455
+ pricing:
456
+ note: Price depends on your account or generated output; estimate unavailable.
457
+ tts:
458
+ maxCharacters: 4096
459
+ streaming: true
460
+ - provider: openai-tts
461
+ id: tts-1-hd
462
+ name: TTS-1 HD
463
+ source: https://developers.openai.com/api/docs/guides/text-to-speech
464
+ keywords:
465
+ - tts
466
+ pricing:
467
+ note: Price depends on your account or generated output; estimate unavailable.
468
+ tts:
469
+ maxCharacters: 4096
470
+ streaming: true
@@ -0,0 +1,147 @@
1
+ import { providerError } from "../kernel/ports/model.js";
2
+ import { splitText } from "../kernel/ports/text.js";
3
+ export function curateRegistry(registry, catalogue) {
4
+ const requireModel = (provider, family, id) => {
5
+ const models = catalogue.models(provider, family);
6
+ const model = id === undefined ? models[0] : models.find((m) => m.id === id);
7
+ if (!model)
8
+ throw providerError({
9
+ kind: "unsupported",
10
+ message: `${provider}: this model is not enabled in the current catalogue. Choose a supported model in Run settings.`,
11
+ });
12
+ return model;
13
+ };
14
+ return {
15
+ list: registry.list,
16
+ llm: (id) => {
17
+ const port = registry.llm(id);
18
+ return {
19
+ ...port,
20
+ models: async () => catalogue.models(id, "llm").map((m) => ({
21
+ ...m,
22
+ ...("llm" in m && m.llm.thinking
23
+ ? {
24
+ thinkingModes: Object.keys(m.llm.thinking).filter((mode) => ["off", "low", "medium", "high", "xhigh"].includes(mode)),
25
+ }
26
+ : {}),
27
+ })),
28
+ complete: async function* (request) {
29
+ const model = requireModel(id, "llm", request.model);
30
+ if (!("llm" in model))
31
+ throw new Error("Invalid LLM catalogue entry");
32
+ if (request.webSearch && !model.llm.webSearch)
33
+ throw providerError({
34
+ kind: "unsupported",
35
+ message: "This model does not support research web search.",
36
+ });
37
+ const thinkingConfig = request.thinking === undefined ? undefined : model.llm.thinking?.[request.thinking];
38
+ if (request.thinking !== undefined && !thinkingConfig)
39
+ throw providerError({
40
+ kind: "unsupported",
41
+ message: "This model does not support the selected thinking setting. Choose an available setting.",
42
+ });
43
+ yield* port.complete({ ...request, ...(thinkingConfig ? { thinkingConfig } : {}) });
44
+ },
45
+ };
46
+ },
47
+ image: (id) => {
48
+ const port = registry.image(id);
49
+ return {
50
+ ...port,
51
+ models: async () => catalogue.models(id, "image"),
52
+ generate: async (request) => {
53
+ const model = requireModel(id, "image", request.model);
54
+ if (!("image" in model) || !model.image.aspectRatios.includes(request.aspect))
55
+ throw providerError({
56
+ kind: "unsupported",
57
+ message: "This model does not support the selected video shape.",
58
+ });
59
+ return port.generate(request);
60
+ },
61
+ };
62
+ },
63
+ tts: (id) => {
64
+ const port = registry.tts(id);
65
+ return {
66
+ ...port,
67
+ models: async () => catalogue.models(id, "tts"),
68
+ synthesize: async (request) => {
69
+ const model = requireModel(id, "tts", request.model);
70
+ if (!("tts" in model))
71
+ throw new Error("Invalid TTS catalogue entry");
72
+ const parts = splitText(request.text, model.tts.maxCharacters);
73
+ if (parts.length <= 1)
74
+ return port.synthesize({ ...request, model: model.id });
75
+ const abort = new AbortController();
76
+ const signal = AbortSignal.any([request.signal, abort.signal]);
77
+ const iterator = audioParts(parts, { ...request, model: model.id, signal }, port.synthesize);
78
+ return {
79
+ container: "mp3",
80
+ audio: new ReadableStream({
81
+ async pull(controller) {
82
+ try {
83
+ const next = await iterator.next();
84
+ if (next.done)
85
+ controller.close();
86
+ else
87
+ controller.enqueue(next.value);
88
+ }
89
+ catch (error) {
90
+ controller.error(error);
91
+ }
92
+ },
93
+ async cancel() {
94
+ abort.abort();
95
+ await iterator.return();
96
+ },
97
+ }, { highWaterMark: 0 }),
98
+ };
99
+ },
100
+ };
101
+ },
102
+ };
103
+ }
104
+ async function* audioParts(parts, request, synthesize) {
105
+ let tokens = {};
106
+ const saved = request.continuation?.read();
107
+ if (saved) {
108
+ try {
109
+ const parsed = JSON.parse(saved);
110
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
111
+ tokens = Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === "string"));
112
+ }
113
+ catch {
114
+ /* A legacy single-job token cannot identify several split jobs. */
115
+ }
116
+ }
117
+ for (const [index, text] of parts.entries()) {
118
+ request.signal.throwIfAborted();
119
+ // Body chunks are persisted individually by narration. Long intro/outro text
120
+ // is bounded here as well; each split has its own remote continuation.
121
+ const response = await synthesize({
122
+ ...request,
123
+ text,
124
+ continuation: {
125
+ read: () => tokens[String(index)],
126
+ write: (value) => {
127
+ tokens[String(index)] = value;
128
+ request.continuation?.write(JSON.stringify(tokens));
129
+ },
130
+ },
131
+ });
132
+ const reader = response.audio.getReader();
133
+ try {
134
+ for (;;) {
135
+ const next = await reader.read();
136
+ request.signal.throwIfAborted();
137
+ if (next.done)
138
+ break;
139
+ yield next.value;
140
+ }
141
+ }
142
+ finally {
143
+ await reader.cancel().catch(() => { });
144
+ reader.releaseLock();
145
+ }
146
+ }
147
+ }