@mevdragon/vidfarm-devcli 0.2.7 → 0.2.9

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 (98) hide show
  1. package/.env.example +9 -0
  2. package/GETTING_STARTED.developers.md +1 -30
  3. package/README.md +22 -5
  4. package/SKILL.developer.md +464 -12
  5. package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +475 -0
  6. package/auto-create-templates/extractor-system-prompt.md +113 -0
  7. package/auto-create-templates/production-graph.schema.json +276 -0
  8. package/auto-create-templates/runbook.md +140 -0
  9. package/auto-create-templates/template-plan.schema.json +230 -0
  10. package/auto-create-templates/template-planner-prompt.md +75 -0
  11. package/dist/src/account-pages-legacy.js +9064 -0
  12. package/dist/src/account-pages.js +41 -620
  13. package/dist/src/app.js +7173 -494
  14. package/dist/src/cli.js +525 -29
  15. package/dist/src/config.js +14 -7
  16. package/dist/src/context.js +51 -35
  17. package/dist/src/db.js +1049 -55
  18. package/dist/src/dev-app-legacy.js +693 -0
  19. package/dist/src/dev-app.js +4 -904
  20. package/dist/src/domain.js +1 -1
  21. package/dist/src/editor-chat-history.js +72 -0
  22. package/dist/src/editor-chat.js +202 -0
  23. package/dist/src/frontend/flockposter-cache-store.js +116 -0
  24. package/dist/src/frontend/homepage-client.js +114 -0
  25. package/dist/src/frontend/homepage-shared.js +3 -0
  26. package/dist/src/frontend/homepage-store.js +27 -0
  27. package/dist/src/frontend/homepage-view.js +147 -0
  28. package/dist/src/frontend/page-runtime-client.js +100 -0
  29. package/dist/src/frontend/page-runtime-store.js +8 -0
  30. package/dist/src/frontend/template-editor-chat.js +2261 -0
  31. package/dist/src/homepage.js +695 -339
  32. package/dist/src/lib/template-paths.js +10 -4
  33. package/dist/src/lib/template-style-options.js +95 -15
  34. package/dist/src/page-runtime.js +1 -0
  35. package/dist/src/page-shell.js +941 -0
  36. package/dist/src/primitive-context.js +163 -0
  37. package/dist/src/primitive-registry.js +340 -0
  38. package/dist/src/primitive-sdk.js +3 -0
  39. package/dist/src/primitives/remotion/html-image.js +28 -0
  40. package/dist/src/react-page-shell.js +34 -0
  41. package/dist/src/ready-post-schedule-component.js +1514 -0
  42. package/dist/src/registry.js +44 -18
  43. package/dist/src/runtime.js +6 -1
  44. package/dist/src/services/api-call-history.js +245 -0
  45. package/dist/src/services/auth.js +27 -9
  46. package/dist/src/services/billing.js +248 -9
  47. package/dist/src/services/chat-threads.js +88 -0
  48. package/dist/src/services/job-logs.js +10 -3
  49. package/dist/src/services/jobs.js +13 -2
  50. package/dist/src/services/providers.js +944 -55
  51. package/dist/src/services/rate-limits.js +236 -0
  52. package/dist/src/services/remotion.js +143 -16
  53. package/dist/src/services/storage.js +23 -8
  54. package/dist/src/services/template-certification.js +26 -3
  55. package/dist/src/services/template-loader.js +19 -1
  56. package/dist/src/services/template-sources.js +316 -7
  57. package/dist/src/template-editor-pages.js +2309 -0
  58. package/dist/src/template-editor-shell.js +1507 -0
  59. package/dist/src/worker.js +120 -22
  60. package/package.json +30 -6
  61. package/public/assets/homepage-app.js +54 -0
  62. package/public/assets/homepage-client-app.js +54 -0
  63. package/public/assets/page-runtime-client-app.js +68 -0
  64. package/dist/templates/template_0000/src/lib/images.js +0 -202
  65. package/dist/templates/template_0000/src/remotion/Root.js +0 -33
  66. package/dist/templates/template_0000/src/remotion/index.js +0 -3
  67. package/dist/templates/template_0000/src/sdk.js +0 -3
  68. package/dist/templates/template_0000/src/style-options.js +0 -51
  69. package/dist/templates/template_0000/src/template-dna.js +0 -9
  70. package/dist/templates/template_0000/src/template.js +0 -1441
  71. package/templates/template_0000/README.md +0 -77
  72. package/templates/template_0000/SKILL.md +0 -225
  73. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  74. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  75. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  76. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  77. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  78. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  79. package/templates/template_0000/composition.json +0 -11
  80. package/templates/template_0000/package.json +0 -28
  81. package/templates/template_0000/research/preview/.gitkeep +0 -1
  82. package/templates/template_0000/research/source_notes.md +0 -7
  83. package/templates/template_0000/src/lib/images.js +0 -202
  84. package/templates/template_0000/src/lib/images.ts +0 -241
  85. package/templates/template_0000/src/remotion/Root.js +0 -33
  86. package/templates/template_0000/src/remotion/Root.tsx +0 -75
  87. package/templates/template_0000/src/remotion/index.js +0 -3
  88. package/templates/template_0000/src/remotion/index.tsx +0 -4
  89. package/templates/template_0000/src/sdk.js +0 -3
  90. package/templates/template_0000/src/sdk.ts +0 -122
  91. package/templates/template_0000/src/style-options.js +0 -51
  92. package/templates/template_0000/src/style-options.ts +0 -60
  93. package/templates/template_0000/src/template-dna.js +0 -9
  94. package/templates/template_0000/src/template-dna.ts +0 -15
  95. package/templates/template_0000/src/template.js +0 -1441
  96. package/templates/template_0000/src/template.ts +0 -2042
  97. package/templates/template_0000/template.config.json +0 -21
  98. package/templates/template_0000/tsconfig.json +0 -19
@@ -8,6 +8,13 @@ export class ProviderKeyUnavailableError extends Error {
8
8
  }
9
9
  export class ProviderAuthError extends Error {
10
10
  }
11
+ export class ProviderLeaseTimeoutError extends Error {
12
+ retryAfterSeconds;
13
+ constructor(message, retryAfterSeconds = 5) {
14
+ super(message);
15
+ this.retryAfterSeconds = retryAfterSeconds;
16
+ }
17
+ }
11
18
  export class ProviderRateLimitError extends Error {
12
19
  retryAfterSeconds;
13
20
  constructor(message, retryAfterSeconds = 60) {
@@ -15,6 +22,22 @@ export class ProviderRateLimitError extends Error {
15
22
  this.retryAfterSeconds = retryAfterSeconds;
16
23
  }
17
24
  }
25
+ const SUPPORTED_TTS_MODELS = {
26
+ openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
27
+ gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
28
+ openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
29
+ };
30
+ const SUPPORTED_STT_MODELS = {
31
+ openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
32
+ gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
33
+ openai: new Set(["gpt-4o-mini-transcribe-2025-12-15"])
34
+ };
35
+ const PROVIDER_KEY_VALIDATION_MODELS = {
36
+ openai: "gpt-4.1-mini",
37
+ gemini: "gemini-3.1-flash-lite-preview",
38
+ openrouter: "google/gemini-3.1-flash-lite-preview",
39
+ perplexity: "sonar"
40
+ };
18
41
  function readStoredProviderSecret(value) {
19
42
  try {
20
43
  return decryptString(value, config.ENCRYPTION_SECRET);
@@ -23,30 +46,143 @@ function readStoredProviderSecret(value) {
23
46
  return value;
24
47
  }
25
48
  }
49
+ function sleep(ms) {
50
+ return new Promise((resolve) => setTimeout(resolve, ms));
51
+ }
52
+ function zeroUsageCost(result) {
53
+ return {
54
+ ...result,
55
+ usage: {
56
+ ...result.usage,
57
+ costUsd: 0
58
+ }
59
+ };
60
+ }
61
+ const DEFAULT_PROVIDER_LEASE_PRIORITY = 100;
62
+ const CHAT_PROVIDER_LEASE_PRIORITY = 0;
63
+ const DEFAULT_PROVIDER_LEASE_TIMEOUT_MS = 120_000;
64
+ const CHAT_PROVIDER_LEASE_TIMEOUT_MS = 45_000;
65
+ const PROVIDER_LEASE_POLL_MS = 250;
66
+ const DEFAULT_PROVIDER_LEASE_SECONDS = 90;
67
+ const CHAT_PROVIDER_LEASE_SECONDS = 300;
26
68
  export class ProviderService {
69
+ handleLeasedProviderError(input) {
70
+ if (input.error instanceof ProviderRateLimitError) {
71
+ database.recordProviderKeyUsage({
72
+ id: createId("usage"),
73
+ keyId: input.lease.keyId,
74
+ jobId: input.jobId,
75
+ provider: input.provider,
76
+ model: input.model,
77
+ eventType: "rate_limit",
78
+ metadata: {
79
+ ...(input.metadata ?? {}),
80
+ retryAfterSeconds: input.error.retryAfterSeconds
81
+ }
82
+ });
83
+ database.setProviderKeyCooldown(input.lease.keyId, addSeconds(new Date(), input.error.retryAfterSeconds));
84
+ }
85
+ else if (input.error instanceof ProviderAuthError) {
86
+ database.recordProviderKeyUsage({
87
+ id: createId("usage"),
88
+ keyId: input.lease.keyId,
89
+ jobId: input.jobId,
90
+ provider: input.provider,
91
+ model: input.model,
92
+ eventType: "auth_error",
93
+ metadata: input.metadata ?? {}
94
+ });
95
+ database.setProviderKeyCooldown(input.lease.keyId, null, "invalid", "auth_error");
96
+ }
97
+ throw input.error;
98
+ }
99
+ listAvailableProviders(customerId) {
100
+ const now = Date.now();
101
+ const providers = database.listProviderKeysWithSecrets(customerId)
102
+ .map((entry) => ({
103
+ provider: String(entry.provider),
104
+ status: String(entry.status),
105
+ cooldownUntil: entry.cooldown_until ? String(entry.cooldown_until) : null,
106
+ secret: readStoredProviderSecret(String(entry.secret))
107
+ }))
108
+ .filter((entry) => entry.status === "active")
109
+ .filter((entry) => !entry.cooldownUntil || Date.parse(entry.cooldownUntil) <= now)
110
+ .filter((entry) => entry.secret.trim().length > 0)
111
+ .map((entry) => entry.provider);
112
+ return [...new Set(providers)];
113
+ }
114
+ async validateProviderKey(input) {
115
+ const model = PROVIDER_KEY_VALIDATION_MODELS[input.provider];
116
+ if (!model) {
117
+ throw new Error(`Provider ${input.provider} does not support validation.`);
118
+ }
119
+ if (config.mockProviders) {
120
+ return { provider: input.provider, model, mock: true };
121
+ }
122
+ try {
123
+ await this.callProvider({
124
+ provider: input.provider,
125
+ model,
126
+ prompt: "Reply with OK.",
127
+ temperature: 0,
128
+ apiKey: input.secret
129
+ });
130
+ return { provider: input.provider, model, mock: false };
131
+ }
132
+ catch (error) {
133
+ throw new Error(toProviderValidationMessage(input.provider, error));
134
+ }
135
+ }
27
136
  async leaseCustomerKey(input) {
28
137
  const leaseToken = createId("lease");
29
- const row = database.acquireProviderKeyLease({
30
- customerId: input.customerId,
31
- provider: input.provider,
32
- workerId: input.workerId,
33
- jobId: input.jobId,
34
- leaseToken,
35
- expiresAt: addSeconds(new Date(), 90)
36
- });
37
- if (!row) {
38
- throw new ProviderKeyUnavailableError(`No eligible ${input.provider} key available.`);
138
+ const waiterId = createId("wait");
139
+ const priority = input.priority ?? DEFAULT_PROVIDER_LEASE_PRIORITY;
140
+ const waitTimeoutMs = input.waitTimeoutMs ?? DEFAULT_PROVIDER_LEASE_TIMEOUT_MS;
141
+ const leaseDurationSeconds = input.leaseDurationSeconds ?? DEFAULT_PROVIDER_LEASE_SECONDS;
142
+ const deadline = Date.now() + waitTimeoutMs;
143
+ try {
144
+ while (Date.now() < deadline) {
145
+ const row = database.acquireProviderKeyLease({
146
+ customerId: input.customerId,
147
+ provider: input.provider,
148
+ waiterId,
149
+ workerId: input.workerId,
150
+ jobId: input.jobId,
151
+ priority,
152
+ leaseToken,
153
+ expiresAt: addSeconds(new Date(), leaseDurationSeconds),
154
+ waiterExpiresAt: addSeconds(new Date(), 10)
155
+ });
156
+ if (row) {
157
+ return {
158
+ keyId: row.keyId,
159
+ leaseToken,
160
+ provider: input.provider,
161
+ secret: readStoredProviderSecret(row.encryptedSecret)
162
+ };
163
+ }
164
+ await sleep(PROVIDER_LEASE_POLL_MS);
165
+ }
39
166
  }
40
- return {
41
- keyId: row.keyId,
42
- leaseToken,
43
- provider: input.provider,
44
- secret: readStoredProviderSecret(row.encryptedSecret)
45
- };
167
+ finally {
168
+ database.deleteProviderKeyWaiter(waiterId);
169
+ }
170
+ throw new ProviderLeaseTimeoutError(`No eligible ${input.provider} key became available in time.`);
46
171
  }
47
172
  async releaseLease(lease) {
48
173
  database.releaseProviderKeyLease({ keyId: lease.keyId, leaseToken: lease.leaseToken });
49
174
  }
175
+ async leaseEditorChatKey(input) {
176
+ return this.leaseCustomerKey({
177
+ customerId: input.customerId,
178
+ provider: input.provider,
179
+ jobId: input.chatId,
180
+ workerId: `editor-chat:${input.chatId}`,
181
+ priority: CHAT_PROVIDER_LEASE_PRIORITY,
182
+ waitTimeoutMs: CHAT_PROVIDER_LEASE_TIMEOUT_MS,
183
+ leaseDurationSeconds: CHAT_PROVIDER_LEASE_SECONDS
184
+ });
185
+ }
50
186
  async generateText(input) {
51
187
  const lease = await this.leaseCustomerKey({
52
188
  customerId: input.customerId,
@@ -71,7 +207,6 @@ export class ProviderService {
71
207
  eventType: "success",
72
208
  inputTokens: 180,
73
209
  outputTokens: 120,
74
- costUsd: 0.02,
75
210
  metadata: { mock: true }
76
211
  });
77
212
  database.touchProviderKey(lease.keyId);
@@ -80,11 +215,11 @@ export class ProviderService {
80
215
  usage: {
81
216
  inputTokens: 180,
82
217
  outputTokens: 120,
83
- costUsd: 0.02
218
+ costUsd: 0
84
219
  }
85
220
  };
86
221
  }
87
- const result = await this.callProvider({ ...input, apiKey: lease.secret });
222
+ const result = zeroUsageCost(await this.callProvider({ ...input, apiKey: lease.secret }));
88
223
  database.recordProviderKeyUsage({
89
224
  id: createId("usage"),
90
225
  keyId: lease.keyId,
@@ -93,37 +228,97 @@ export class ProviderService {
93
228
  model: input.model,
94
229
  eventType: "success",
95
230
  inputTokens: result.usage.inputTokens,
96
- outputTokens: result.usage.outputTokens,
97
- costUsd: result.usage.costUsd
231
+ outputTokens: result.usage.outputTokens
98
232
  });
99
233
  database.touchProviderKey(lease.keyId);
100
234
  return result;
101
235
  }
102
236
  catch (error) {
103
- if (error instanceof ProviderRateLimitError) {
237
+ this.handleLeasedProviderError({
238
+ error,
239
+ lease,
240
+ jobId: input.jobId,
241
+ provider: input.provider,
242
+ model: input.model
243
+ });
244
+ }
245
+ finally {
246
+ await this.releaseLease(lease);
247
+ }
248
+ }
249
+ async generateSpeech(input) {
250
+ assertSupportedSpeechModel("tts", input.provider, input.model);
251
+ const lease = await this.leaseCustomerKey({
252
+ customerId: input.customerId,
253
+ provider: input.provider,
254
+ jobId: input.jobId,
255
+ workerId: input.workerId
256
+ });
257
+ try {
258
+ if (config.mockProviders) {
259
+ const contentType = inferSpeechContentType(input.responseFormat ?? "mp3");
104
260
  database.recordProviderKeyUsage({
105
261
  id: createId("usage"),
106
262
  keyId: lease.keyId,
107
263
  jobId: input.jobId,
108
264
  provider: input.provider,
109
265
  model: input.model,
110
- eventType: "rate_limit",
111
- metadata: { retryAfterSeconds: error.retryAfterSeconds }
266
+ eventType: "success",
267
+ inputTokens: 0,
268
+ outputTokens: 0,
269
+ metadata: { mock: true, type: "speech_generation" }
112
270
  });
113
- database.setProviderKeyCooldown(lease.keyId, addSeconds(new Date(), error.retryAfterSeconds));
271
+ database.touchProviderKey(lease.keyId);
272
+ return {
273
+ bytes: Buffer.from(`mock speech:${input.text.slice(0, 120)}`, "utf8"),
274
+ contentType,
275
+ usage: {
276
+ inputTokens: 0,
277
+ outputTokens: 0,
278
+ costUsd: 0
279
+ }
280
+ };
114
281
  }
115
- else if (error instanceof ProviderAuthError) {
116
- database.recordProviderKeyUsage({
117
- id: createId("usage"),
118
- keyId: lease.keyId,
119
- jobId: input.jobId,
120
- provider: input.provider,
282
+ const result = zeroUsageCost(input.provider === "gemini"
283
+ ? await this.callGeminiSpeechGeneration({
121
284
  model: input.model,
122
- eventType: "auth_error"
123
- });
124
- database.setProviderKeyCooldown(lease.keyId, null, "invalid", "auth_error");
125
- }
126
- throw error;
285
+ text: input.text,
286
+ voice: input.voice,
287
+ instructions: input.instructions,
288
+ apiKey: lease.secret
289
+ })
290
+ : await this.callOpenAICompatibleSpeechGeneration({
291
+ provider: input.provider === "openrouter" ? "openrouter" : "openai",
292
+ model: input.model,
293
+ text: input.text,
294
+ voice: input.voice,
295
+ instructions: input.instructions,
296
+ responseFormat: input.responseFormat,
297
+ apiKey: lease.secret
298
+ }));
299
+ database.recordProviderKeyUsage({
300
+ id: createId("usage"),
301
+ keyId: lease.keyId,
302
+ jobId: input.jobId,
303
+ provider: input.provider,
304
+ model: input.model,
305
+ eventType: "success",
306
+ inputTokens: result.usage.inputTokens,
307
+ outputTokens: result.usage.outputTokens,
308
+ metadata: { type: "speech_generation" }
309
+ });
310
+ database.touchProviderKey(lease.keyId);
311
+ return result;
312
+ }
313
+ catch (error) {
314
+ this.handleLeasedProviderError({
315
+ error,
316
+ lease,
317
+ jobId: input.jobId,
318
+ provider: input.provider,
319
+ model: input.model,
320
+ metadata: { type: "speech_generation" }
321
+ });
127
322
  }
128
323
  finally {
129
324
  await this.releaseLease(lease);
@@ -148,7 +343,6 @@ export class ProviderService {
148
343
  eventType: "success",
149
344
  inputTokens: 0,
150
345
  outputTokens: 0,
151
- costUsd: 0.04,
152
346
  metadata: { mock: true, type: "image_generation" }
153
347
  });
154
348
  database.touchProviderKey(lease.keyId);
@@ -167,14 +361,103 @@ export class ProviderService {
167
361
  imageSize: input.imageSize,
168
362
  apiKey: lease.secret
169
363
  })
170
- : await this.callOpenAIImageGeneration({
364
+ : input.provider === "openrouter"
365
+ ? await this.callOpenRouterImageGeneration({
366
+ model: input.model,
367
+ prompt: input.prompt,
368
+ promptAttachments: input.promptAttachments,
369
+ aspectRatio: input.aspectRatio,
370
+ imageSize: input.imageSize,
371
+ apiKey: lease.secret
372
+ })
373
+ : await this.callOpenAIImageGeneration({
374
+ model: input.model,
375
+ prompt: input.prompt,
376
+ promptAttachments: input.promptAttachments,
377
+ size: input.size,
378
+ aspectRatio: input.aspectRatio,
379
+ apiKey: lease.secret
380
+ });
381
+ database.recordProviderKeyUsage({
382
+ id: createId("usage"),
383
+ keyId: lease.keyId,
384
+ jobId: input.jobId,
385
+ provider: input.provider,
386
+ model: input.model,
387
+ eventType: "success",
388
+ inputTokens: 0,
389
+ outputTokens: 0,
390
+ metadata: { type: "image_generation" }
391
+ });
392
+ database.touchProviderKey(lease.keyId);
393
+ return result;
394
+ }
395
+ catch (error) {
396
+ this.handleLeasedProviderError({
397
+ error,
398
+ lease,
399
+ jobId: input.jobId,
400
+ provider: input.provider,
401
+ model: input.model,
402
+ metadata: { type: "image_generation" }
403
+ });
404
+ }
405
+ finally {
406
+ await this.releaseLease(lease);
407
+ }
408
+ }
409
+ async editImage(input) {
410
+ const lease = await this.leaseCustomerKey({
411
+ customerId: input.customerId,
412
+ provider: input.provider,
413
+ jobId: input.jobId,
414
+ workerId: input.workerId
415
+ });
416
+ try {
417
+ if (config.mockProviders) {
418
+ const svg = buildMockSlideSvg(`EDIT ${input.instruction}\n${input.sourceImageUrl}`);
419
+ database.recordProviderKeyUsage({
420
+ id: createId("usage"),
421
+ keyId: lease.keyId,
422
+ jobId: input.jobId,
423
+ provider: input.provider,
171
424
  model: input.model,
172
- prompt: input.prompt,
173
- promptAttachments: input.promptAttachments,
425
+ eventType: "success",
426
+ inputTokens: 0,
427
+ outputTokens: 0,
428
+ metadata: { mock: true, type: "image_edit" }
429
+ });
430
+ database.touchProviderKey(lease.keyId);
431
+ return {
432
+ bytes: Buffer.from(svg, "utf8"),
433
+ contentType: "image/svg+xml",
434
+ revisedPrompt: input.instruction
435
+ };
436
+ }
437
+ const result = input.provider === "openai"
438
+ ? await this.callOpenAIImageEdit({
439
+ model: input.model,
440
+ sourceImageUrl: input.sourceImageUrl,
441
+ instruction: input.instruction,
442
+ maskUrl: input.maskUrl,
174
443
  size: input.size,
175
- aspectRatio: input.aspectRatio,
176
444
  apiKey: lease.secret
177
- });
445
+ })
446
+ : input.provider === "perplexity"
447
+ ? (() => {
448
+ throw new Error("Perplexity image edit is not supported.");
449
+ })()
450
+ : await this.callAttachmentDrivenImageEdit({
451
+ provider: input.provider,
452
+ model: input.model,
453
+ sourceImageUrl: input.sourceImageUrl,
454
+ instruction: input.instruction,
455
+ referenceAttachments: input.referenceAttachments,
456
+ maskUrl: input.maskUrl,
457
+ aspectRatio: input.aspectRatio,
458
+ imageSize: input.imageSize,
459
+ apiKey: lease.secret
460
+ });
178
461
  database.recordProviderKeyUsage({
179
462
  id: createId("usage"),
180
463
  keyId: lease.keyId,
@@ -184,16 +467,31 @@ export class ProviderService {
184
467
  eventType: "success",
185
468
  inputTokens: 0,
186
469
  outputTokens: 0,
187
- costUsd: 0.04,
188
- metadata: { type: "image_generation" }
470
+ metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
189
471
  });
190
472
  database.touchProviderKey(lease.keyId);
191
473
  return result;
192
474
  }
475
+ catch (error) {
476
+ this.handleLeasedProviderError({
477
+ error,
478
+ lease,
479
+ jobId: input.jobId,
480
+ provider: input.provider,
481
+ model: input.model,
482
+ metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
483
+ });
484
+ }
193
485
  finally {
194
486
  await this.releaseLease(lease);
195
487
  }
196
488
  }
489
+ async inpaintImage(input) {
490
+ return this.editImage({
491
+ ...input,
492
+ maskUrl: input.maskUrl
493
+ });
494
+ }
197
495
  async analyzeImageLayout(input) {
198
496
  const lease = await this.leaseCustomerKey({
199
497
  customerId: input.customerId,
@@ -212,7 +510,6 @@ export class ProviderService {
212
510
  eventType: "success",
213
511
  inputTokens: 0,
214
512
  outputTokens: 0,
215
- costUsd: 0.005,
216
513
  metadata: { mock: true, type: "layout_analysis" }
217
514
  });
218
515
  database.touchProviderKey(lease.keyId);
@@ -250,7 +547,6 @@ export class ProviderService {
250
547
  eventType: "success",
251
548
  inputTokens: 0,
252
549
  outputTokens: 0,
253
- costUsd: 0.005,
254
550
  metadata: { type: "layout_analysis" }
255
551
  });
256
552
  database.touchProviderKey(lease.keyId);
@@ -261,6 +557,101 @@ export class ProviderService {
261
557
  justification: parsed.justification ?? "Selected to preserve legibility and subject framing."
262
558
  };
263
559
  }
560
+ catch (error) {
561
+ this.handleLeasedProviderError({
562
+ error,
563
+ lease,
564
+ jobId: input.jobId,
565
+ provider: input.provider,
566
+ model: input.model,
567
+ metadata: { type: "layout_analysis" }
568
+ });
569
+ }
570
+ finally {
571
+ await this.releaseLease(lease);
572
+ }
573
+ }
574
+ async transcribeSpeech(input) {
575
+ assertSupportedSpeechModel("stt", input.provider, input.model);
576
+ const lease = await this.leaseCustomerKey({
577
+ customerId: input.customerId,
578
+ provider: input.provider,
579
+ jobId: input.jobId,
580
+ workerId: input.workerId
581
+ });
582
+ try {
583
+ if (config.mockProviders) {
584
+ database.recordProviderKeyUsage({
585
+ id: createId("usage"),
586
+ keyId: lease.keyId,
587
+ jobId: input.jobId,
588
+ provider: input.provider,
589
+ model: input.model,
590
+ eventType: "success",
591
+ inputTokens: 0,
592
+ outputTokens: 0,
593
+ metadata: { mock: true, type: "speech_transcription" }
594
+ });
595
+ database.touchProviderKey(lease.keyId);
596
+ return {
597
+ text: "Mock transcription output.",
598
+ usage: {
599
+ inputTokens: 0,
600
+ outputTokens: 0,
601
+ costUsd: 0
602
+ }
603
+ };
604
+ }
605
+ const result = zeroUsageCost(input.provider === "gemini"
606
+ ? await this.callGeminiSpeechTranscription({
607
+ model: input.model,
608
+ audio: input.audio,
609
+ contentType: input.contentType,
610
+ prompt: input.prompt,
611
+ language: input.language,
612
+ apiKey: lease.secret
613
+ })
614
+ : input.provider === "openrouter"
615
+ ? await this.callOpenRouterSpeechTranscription({
616
+ model: input.model,
617
+ audio: input.audio,
618
+ contentType: input.contentType,
619
+ prompt: input.prompt,
620
+ language: input.language,
621
+ apiKey: lease.secret
622
+ })
623
+ : await this.callOpenAISpeechTranscription({
624
+ model: input.model,
625
+ audio: input.audio,
626
+ contentType: input.contentType,
627
+ prompt: input.prompt,
628
+ language: input.language,
629
+ apiKey: lease.secret
630
+ }));
631
+ database.recordProviderKeyUsage({
632
+ id: createId("usage"),
633
+ keyId: lease.keyId,
634
+ jobId: input.jobId,
635
+ provider: input.provider,
636
+ model: input.model,
637
+ eventType: "success",
638
+ inputTokens: result.usage.inputTokens,
639
+ outputTokens: result.usage.outputTokens,
640
+ metadata: { type: "speech_transcription" }
641
+ });
642
+ database.touchProviderKey(lease.keyId);
643
+ return result;
644
+ }
645
+ catch (error) {
646
+ this.handleLeasedProviderError({
647
+ error,
648
+ lease,
649
+ jobId: input.jobId,
650
+ provider: input.provider,
651
+ model: input.model,
652
+ metadata: { type: "speech_transcription" }
653
+ });
654
+ }
264
655
  finally {
265
656
  await this.releaseLease(lease);
266
657
  }
@@ -308,6 +699,77 @@ export class ProviderService {
308
699
  revisedPrompt: data.data?.[0]?.revised_prompt ?? null
309
700
  };
310
701
  }
702
+ async callOpenAIImageEdit(input) {
703
+ const form = new FormData();
704
+ form.set("model", input.model);
705
+ form.set("prompt", input.instruction);
706
+ form.set("size", input.size ?? "1024x1024");
707
+ form.set("image", await remoteImageToBlob(input.sourceImageUrl));
708
+ if (input.maskUrl) {
709
+ form.set("mask", await remoteImageToBlob(input.maskUrl));
710
+ }
711
+ const response = await fetch("https://api.openai.com/v1/images/edits", {
712
+ method: "POST",
713
+ headers: {
714
+ Authorization: `Bearer ${input.apiKey}`
715
+ },
716
+ body: form
717
+ });
718
+ if (response.status === 401) {
719
+ throw new ProviderAuthError("openai authentication failed");
720
+ }
721
+ if (response.status === 429) {
722
+ throw new ProviderRateLimitError("openai rate limited");
723
+ }
724
+ if (!response.ok) {
725
+ const details = await response.text();
726
+ throw new Error(`openai image edit returned ${response.status}${details ? `: ${details}` : ""}`);
727
+ }
728
+ const data = await response.json();
729
+ const encoded = data.data?.[0]?.b64_json;
730
+ if (!encoded) {
731
+ throw new Error("openai image edit returned no image payload");
732
+ }
733
+ return {
734
+ bytes: Buffer.from(encoded, "base64"),
735
+ contentType: "image/png",
736
+ revisedPrompt: data.data?.[0]?.revised_prompt ?? null
737
+ };
738
+ }
739
+ async callAttachmentDrivenImageEdit(input) {
740
+ const promptAttachments = [
741
+ input.sourceImageUrl,
742
+ ...(input.maskUrl ? [input.maskUrl] : []),
743
+ ...(input.referenceAttachments ?? [])
744
+ ];
745
+ const prompt = [
746
+ input.maskUrl
747
+ ? "Edit this image using the provided source image and mask."
748
+ : "Edit this image using the provided source image as the base reference.",
749
+ input.maskUrl
750
+ ? "Only change the masked region. Preserve all unmasked regions exactly."
751
+ : "Preserve the overall composition unless the instruction explicitly changes it.",
752
+ `Instruction: ${input.instruction}`,
753
+ "Return one final edited image only."
754
+ ].join("\n");
755
+ return input.provider === "gemini"
756
+ ? this.callGeminiImageGeneration({
757
+ model: input.model,
758
+ prompt,
759
+ promptAttachments,
760
+ aspectRatio: input.aspectRatio,
761
+ imageSize: input.imageSize,
762
+ apiKey: input.apiKey
763
+ })
764
+ : this.callOpenRouterImageGeneration({
765
+ model: input.model,
766
+ prompt,
767
+ promptAttachments,
768
+ aspectRatio: input.aspectRatio,
769
+ imageSize: input.imageSize,
770
+ apiKey: input.apiKey
771
+ });
772
+ }
311
773
  async callGeminiImageGeneration(input) {
312
774
  const imageConfig = {};
313
775
  if (input.aspectRatio) {
@@ -355,6 +817,182 @@ export class ProviderService {
355
817
  revisedPrompt: null
356
818
  };
357
819
  }
820
+ async callOpenRouterImageGeneration(input) {
821
+ const content = [
822
+ { type: "text", text: input.prompt },
823
+ ...(input.promptAttachments ?? []).map((url) => ({
824
+ type: "image_url",
825
+ image_url: { url }
826
+ }))
827
+ ];
828
+ const imageConfig = {};
829
+ if (input.aspectRatio) {
830
+ imageConfig.aspect_ratio = input.aspectRatio;
831
+ }
832
+ if (input.imageSize) {
833
+ imageConfig.image_size = input.imageSize;
834
+ }
835
+ const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
836
+ method: "POST",
837
+ headers: {
838
+ "Content-Type": "application/json",
839
+ Authorization: `Bearer ${input.apiKey}`
840
+ },
841
+ body: JSON.stringify({
842
+ model: input.model,
843
+ messages: [
844
+ {
845
+ role: "user",
846
+ content
847
+ }
848
+ ],
849
+ modalities: ["image", "text"],
850
+ stream: false,
851
+ ...(Object.keys(imageConfig).length
852
+ ? {
853
+ image_config: imageConfig
854
+ }
855
+ : {})
856
+ })
857
+ });
858
+ if (response.status === 401 || response.status === 403) {
859
+ throw new ProviderAuthError("openrouter authentication failed");
860
+ }
861
+ if (response.status === 429) {
862
+ throw new ProviderRateLimitError("openrouter rate limited");
863
+ }
864
+ if (!response.ok) {
865
+ const details = await response.text();
866
+ throw new Error(`openrouter image generation returned ${response.status}${details ? `: ${details}` : ""}`);
867
+ }
868
+ const data = await response.json();
869
+ const imageUrl = data.choices?.[0]?.message?.images?.[0]?.image_url?.url ??
870
+ data.choices?.[0]?.message?.images?.[0]?.imageUrl?.url;
871
+ if (!imageUrl) {
872
+ throw new Error("openrouter image generation returned no image payload");
873
+ }
874
+ const parsed = decodeInlineDataUrl(imageUrl);
875
+ if (!parsed) {
876
+ throw new Error("openrouter image generation returned an unsupported image payload");
877
+ }
878
+ return {
879
+ bytes: parsed.bytes,
880
+ contentType: parsed.contentType,
881
+ revisedPrompt: data.choices?.[0]?.message?.content ?? null
882
+ };
883
+ }
884
+ async callOpenAICompatibleSpeechGeneration(input) {
885
+ const requestedFormat = input.responseFormat ?? "mp3";
886
+ const usePcmBridge = input.provider === "openrouter" &&
887
+ /gemini/i.test(input.model) &&
888
+ requestedFormat !== "mp3";
889
+ const responseFormat = usePcmBridge ? "pcm" : requestedFormat;
890
+ const endpoint = input.provider === "openrouter"
891
+ ? "https://openrouter.ai/api/v1/audio/speech"
892
+ : "https://api.openai.com/v1/audio/speech";
893
+ const response = await fetch(endpoint, {
894
+ method: "POST",
895
+ headers: {
896
+ "Content-Type": "application/json",
897
+ Authorization: `Bearer ${input.apiKey}`
898
+ },
899
+ body: JSON.stringify({
900
+ model: input.model,
901
+ input: input.text,
902
+ voice: input.voice ?? "alloy",
903
+ instructions: input.instructions,
904
+ response_format: responseFormat
905
+ })
906
+ });
907
+ if (response.status === 401 || response.status === 403) {
908
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
909
+ }
910
+ if (response.status === 429) {
911
+ throw new ProviderRateLimitError(`${input.provider} rate limited`);
912
+ }
913
+ if (!response.ok) {
914
+ const details = await response.text();
915
+ throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
916
+ }
917
+ const rawBytes = Buffer.from(await response.arrayBuffer());
918
+ const bytes = usePcmBridge ? pcm16MonoToWav(rawBytes, 24000) : rawBytes;
919
+ const contentType = usePcmBridge
920
+ ? "audio/wav"
921
+ : response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat);
922
+ return {
923
+ bytes,
924
+ contentType,
925
+ usage: {
926
+ inputTokens: 0,
927
+ outputTokens: 0,
928
+ costUsd: 0
929
+ }
930
+ };
931
+ }
932
+ async callGeminiSpeechGeneration(input) {
933
+ const prompt = input.instructions?.trim()
934
+ ? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
935
+ : input.text;
936
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
937
+ method: "POST",
938
+ headers: {
939
+ "Content-Type": "application/json"
940
+ },
941
+ body: JSON.stringify({
942
+ contents: [{ parts: [{ text: prompt }] }],
943
+ generationConfig: {
944
+ responseModalities: ["AUDIO"],
945
+ speechConfig: {
946
+ voiceConfig: {
947
+ prebuiltVoiceConfig: {
948
+ voiceName: input.voice ?? "Kore"
949
+ }
950
+ }
951
+ }
952
+ }
953
+ })
954
+ });
955
+ if (response.status === 401 || response.status === 403) {
956
+ throw new ProviderAuthError("gemini authentication failed");
957
+ }
958
+ if (response.status === 429) {
959
+ throw new ProviderRateLimitError("gemini rate limited");
960
+ }
961
+ if (!response.ok) {
962
+ const details = await response.text();
963
+ throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
964
+ }
965
+ const data = await response.json();
966
+ const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
967
+ const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
968
+ if (!encoded) {
969
+ throw new Error("gemini speech generation returned no audio payload");
970
+ }
971
+ const rawBytes = Buffer.from(encoded, "base64");
972
+ const rawContentType = String(part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000");
973
+ const pcmRateMatch = /audio\/l16;?\s*rate=(\d+)/i.exec(rawContentType);
974
+ const looksLikeWav = isWavBuffer(rawBytes);
975
+ const looksLikeMp3 = isLikelyMp3Buffer(rawBytes);
976
+ const shouldWrapPcm = Boolean(pcmRateMatch ||
977
+ (!looksLikeWav && !looksLikeMp3));
978
+ const bytes = shouldWrapPcm
979
+ ? pcm16MonoToWav(rawBytes, Number(pcmRateMatch?.[1] || "24000"))
980
+ : rawBytes;
981
+ const contentType = shouldWrapPcm
982
+ ? "audio/wav"
983
+ : looksLikeWav
984
+ ? "audio/wav"
985
+ : "audio/mpeg";
986
+ return {
987
+ bytes,
988
+ contentType,
989
+ usage: {
990
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
991
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
992
+ costUsd: 0
993
+ }
994
+ };
995
+ }
358
996
  async callOpenAICompatibleLayoutAnalysis(input) {
359
997
  const endpoint = input.provider === "openrouter"
360
998
  ? "https://openrouter.ai/api/v1/chat/completions"
@@ -458,6 +1096,128 @@ export class ProviderService {
458
1096
  const text = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "";
459
1097
  return String(text);
460
1098
  }
1099
+ async callOpenAISpeechTranscription(input) {
1100
+ const contentType = input.contentType ?? "audio/mpeg";
1101
+ const form = new FormData();
1102
+ form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
1103
+ form.set("model", input.model);
1104
+ form.set("response_format", "json");
1105
+ if (input.prompt) {
1106
+ form.set("prompt", input.prompt);
1107
+ }
1108
+ if (input.language) {
1109
+ form.set("language", input.language);
1110
+ }
1111
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
1112
+ method: "POST",
1113
+ headers: {
1114
+ Authorization: `Bearer ${input.apiKey}`
1115
+ },
1116
+ body: form
1117
+ });
1118
+ if (response.status === 401 || response.status === 403) {
1119
+ throw new ProviderAuthError("openai authentication failed");
1120
+ }
1121
+ if (response.status === 429) {
1122
+ throw new ProviderRateLimitError("openai rate limited");
1123
+ }
1124
+ if (!response.ok) {
1125
+ const details = await response.text();
1126
+ throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
1127
+ }
1128
+ const data = await response.json();
1129
+ return {
1130
+ text: String(data.text ?? ""),
1131
+ usage: {
1132
+ inputTokens: Number(data.usage?.input_tokens ?? 0),
1133
+ outputTokens: Number(data.usage?.output_tokens ?? 0),
1134
+ costUsd: Number(data.usage?.cost ?? 0)
1135
+ }
1136
+ };
1137
+ }
1138
+ async callOpenRouterSpeechTranscription(input) {
1139
+ const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
1140
+ method: "POST",
1141
+ headers: {
1142
+ "Content-Type": "application/json",
1143
+ Authorization: `Bearer ${input.apiKey}`
1144
+ },
1145
+ body: JSON.stringify({
1146
+ model: input.model,
1147
+ input_audio: {
1148
+ data: Buffer.from(input.audio).toString("base64"),
1149
+ format: audioFormatFromContentType(input.contentType)
1150
+ },
1151
+ ...(input.prompt ? { prompt: input.prompt } : {}),
1152
+ ...(input.language ? { language: input.language } : {})
1153
+ })
1154
+ });
1155
+ if (response.status === 401 || response.status === 403) {
1156
+ throw new ProviderAuthError("openrouter authentication failed");
1157
+ }
1158
+ if (response.status === 429) {
1159
+ throw new ProviderRateLimitError("openrouter rate limited");
1160
+ }
1161
+ if (!response.ok) {
1162
+ const details = await response.text();
1163
+ throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
1164
+ }
1165
+ const data = await response.json();
1166
+ return {
1167
+ text: String(data.text ?? ""),
1168
+ usage: {
1169
+ inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
1170
+ outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
1171
+ costUsd: Number(data.usage?.cost ?? 0)
1172
+ }
1173
+ };
1174
+ }
1175
+ async callGeminiSpeechTranscription(input) {
1176
+ const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
1177
+ method: "POST",
1178
+ headers: {
1179
+ "Content-Type": "application/json"
1180
+ },
1181
+ body: JSON.stringify({
1182
+ contents: [{
1183
+ parts: [
1184
+ {
1185
+ inline_data: {
1186
+ mime_type: input.contentType ?? "audio/mpeg",
1187
+ data: Buffer.from(input.audio).toString("base64")
1188
+ }
1189
+ },
1190
+ {
1191
+ text: buildGeminiTranscriptionPrompt(input.prompt, input.language)
1192
+ }
1193
+ ]
1194
+ }],
1195
+ generationConfig: {
1196
+ temperature: 0,
1197
+ responseMimeType: "text/plain"
1198
+ }
1199
+ })
1200
+ });
1201
+ if (response.status === 401 || response.status === 403) {
1202
+ throw new ProviderAuthError("gemini authentication failed");
1203
+ }
1204
+ if (response.status === 429) {
1205
+ throw new ProviderRateLimitError("gemini rate limited");
1206
+ }
1207
+ if (!response.ok) {
1208
+ const details = await response.text();
1209
+ throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
1210
+ }
1211
+ const data = await response.json();
1212
+ return {
1213
+ text: extractGeminiText(data),
1214
+ usage: {
1215
+ inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
1216
+ outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
1217
+ costUsd: 0
1218
+ }
1219
+ };
1220
+ }
461
1221
  async callProvider(input) {
462
1222
  if (input.provider === "gemini") {
463
1223
  return this.callGemini({
@@ -543,24 +1303,143 @@ export class ProviderService {
543
1303
  function clamp(value, min, max) {
544
1304
  return Math.max(min, Math.min(max, value));
545
1305
  }
1306
+ function summarizeProviderValidationError(error) {
1307
+ const rawMessage = error instanceof Error ? error.message : "Unknown provider error";
1308
+ return rawMessage
1309
+ .replace(/\s+/g, " ")
1310
+ .trim()
1311
+ .slice(0, 280);
1312
+ }
1313
+ function toProviderValidationMessage(provider, error) {
1314
+ const providerLabel = formatProviderLabel(provider);
1315
+ const summary = summarizeProviderValidationError(error);
1316
+ if (!summary) {
1317
+ return `Unable to verify ${providerLabel} API key.`;
1318
+ }
1319
+ if (/401|403|invalid api key|api key not valid|incorrect api key|unauthorized|permission denied|authentication failed/i.test(summary)) {
1320
+ return `${providerLabel} API key was rejected by the provider. ${summary}`;
1321
+ }
1322
+ return `Unable to verify ${providerLabel} API key. ${summary}`;
1323
+ }
1324
+ function formatProviderLabel(provider) {
1325
+ switch (provider) {
1326
+ case "openai":
1327
+ return "OpenAI";
1328
+ case "gemini":
1329
+ return "Gemini";
1330
+ case "openrouter":
1331
+ return "OpenRouter";
1332
+ case "perplexity":
1333
+ return "Perplexity";
1334
+ }
1335
+ }
1336
+ function assertSupportedSpeechModel(kind, provider, model) {
1337
+ const normalizedModel = model.trim();
1338
+ if (!normalizedModel) {
1339
+ throw new Error(`Missing ${kind.toUpperCase()} model for provider ${provider}.`);
1340
+ }
1341
+ const speechCapableProviders = ["openai", "gemini", "openrouter"];
1342
+ if (!speechCapableProviders.includes(provider)) {
1343
+ throw new Error(`Provider ${provider} does not support ${kind.toUpperCase()} requests.`);
1344
+ }
1345
+ }
1346
+ function inferSpeechContentType(format) {
1347
+ return format === "wav" ? "audio/wav" : "audio/mpeg";
1348
+ }
1349
+ function pcm16MonoToWav(pcmBuffer, sampleRate) {
1350
+ const header = Buffer.alloc(44);
1351
+ header.write("RIFF", 0, 4, "ascii");
1352
+ header.writeUInt32LE(36 + pcmBuffer.byteLength, 4);
1353
+ header.write("WAVE", 8, 4, "ascii");
1354
+ header.write("fmt ", 12, 4, "ascii");
1355
+ header.writeUInt32LE(16, 16);
1356
+ header.writeUInt16LE(1, 20);
1357
+ header.writeUInt16LE(1, 22);
1358
+ header.writeUInt32LE(sampleRate, 24);
1359
+ header.writeUInt32LE(sampleRate * 2, 28);
1360
+ header.writeUInt16LE(2, 32);
1361
+ header.writeUInt16LE(16, 34);
1362
+ header.write("data", 36, 4, "ascii");
1363
+ header.writeUInt32LE(pcmBuffer.byteLength, 40);
1364
+ return Buffer.concat([header, pcmBuffer]);
1365
+ }
1366
+ function isWavBuffer(buffer) {
1367
+ return buffer.byteLength >= 12
1368
+ && buffer.subarray(0, 4).toString("ascii") === "RIFF"
1369
+ && buffer.subarray(8, 12).toString("ascii") === "WAVE";
1370
+ }
1371
+ function isLikelyMp3Buffer(buffer) {
1372
+ if (buffer.byteLength < 3) {
1373
+ return false;
1374
+ }
1375
+ if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
1376
+ return true;
1377
+ }
1378
+ return buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
1379
+ }
1380
+ function audioFormatFromContentType(contentType) {
1381
+ const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
1382
+ switch (normalized) {
1383
+ case "audio/wav":
1384
+ case "audio/x-wav":
1385
+ return "wav";
1386
+ case "audio/mp4":
1387
+ case "audio/m4a":
1388
+ return "mp4";
1389
+ case "audio/webm":
1390
+ return "webm";
1391
+ case "audio/ogg":
1392
+ return "ogg";
1393
+ default:
1394
+ return "mp3";
1395
+ }
1396
+ }
1397
+ function audioExtensionFromContentType(contentType) {
1398
+ switch (audioFormatFromContentType(contentType)) {
1399
+ case "wav":
1400
+ return "wav";
1401
+ case "mp4":
1402
+ return "m4a";
1403
+ case "webm":
1404
+ return "webm";
1405
+ case "ogg":
1406
+ return "ogg";
1407
+ default:
1408
+ return "mp3";
1409
+ }
1410
+ }
1411
+ function buildGeminiTranscriptionPrompt(prompt, language) {
1412
+ const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
1413
+ return language?.trim()
1414
+ ? `${basePrompt}\n\nExpected language: ${language.trim()}.`
1415
+ : basePrompt;
1416
+ }
1417
+ function extractGeminiText(data) {
1418
+ return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
1419
+ }
546
1420
  function supportsGeminiImageSize(model) {
547
1421
  return model === "gemini-3.1-flash-image-preview" || model === "gemini-3-pro-image-preview";
548
1422
  }
1423
+ async function fetchRemoteBytes(input) {
1424
+ const response = await fetch(input.url);
1425
+ if (!response.ok) {
1426
+ throw new Error(`Could not fetch remote media ${input.url}: ${response.status}`);
1427
+ }
1428
+ const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || input.fallbackMimeType || "application/octet-stream";
1429
+ const bytes = Buffer.from(await response.arrayBuffer());
1430
+ return { bytes, mimeType };
1431
+ }
1432
+ async function remoteImageToBlob(url) {
1433
+ const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
1434
+ return new Blob([bytes], { type: mimeType });
1435
+ }
549
1436
  async function buildGeminiPromptParts(prompt, attachments) {
550
1437
  const parts = [];
551
1438
  for (const attachment of attachments) {
552
1439
  if (!/^https?:\/\//i.test(attachment)) {
553
1440
  throw new Error(`Gemini prompt attachments must be public URLs: ${attachment}`);
554
1441
  }
555
- const response = await fetch(attachment);
556
- if (!response.ok) {
557
- throw new Error(`Could not fetch Gemini prompt attachment ${attachment}: ${response.status}`);
558
- }
559
- const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim();
560
- if (!mimeType) {
561
- throw new Error(`Gemini prompt attachment did not return a content type: ${attachment}`);
562
- }
563
- const bytes = Buffer.from(await response.arrayBuffer());
1442
+ const { bytes, mimeType } = await fetchRemoteBytes({ url: attachment });
564
1443
  parts.push({
565
1444
  inlineData: {
566
1445
  mimeType,
@@ -571,6 +1450,16 @@ async function buildGeminiPromptParts(prompt, attachments) {
571
1450
  parts.push({ text: prompt });
572
1451
  return parts;
573
1452
  }
1453
+ function decodeInlineDataUrl(value) {
1454
+ const match = value.match(/^data:([^;,]+);base64,(.+)$/);
1455
+ if (!match) {
1456
+ return null;
1457
+ }
1458
+ return {
1459
+ contentType: match[1],
1460
+ bytes: Buffer.from(match[2], "base64")
1461
+ };
1462
+ }
574
1463
  function buildMockSlideSvg(prompt) {
575
1464
  const escaped = escapeXml(prompt.slice(0, 220));
576
1465
  return `