@mevdragon/vidfarm-devcli 0.20.6 → 0.20.8

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 (154) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +15 -1
  2. package/.agents/skills/vidfarm-director/SKILL.md +106 -0
  3. package/.agents/skills/vidfarm-director/recipes/find-and-fork-template.md +16 -0
  4. package/.agents/skills/vidfarm-director/recipes/local-edit-render-approve.md +13 -0
  5. package/.agents/skills/vidfarm-director/recipes/onboard-a-new-director.md +13 -0
  6. package/.agents/skills/vidfarm-director/recipes/retheme-template.md +17 -0
  7. package/.agents/skills/vidfarm-director/references/assets-and-sourcing.md +111 -0
  8. package/.agents/skills/vidfarm-director/references/automation-and-local-dev.md +243 -0
  9. package/.agents/skills/vidfarm-director/references/core-workflows.md +273 -0
  10. package/.agents/skills/vidfarm-director/references/editor-workflows.md +365 -0
  11. package/.agents/skills/vidfarm-director/references/onboarding.md +28 -0
  12. package/.agents/skills/vidfarm-director/references/primitives.md +265 -0
  13. package/SKILL.director.md +476 -260
  14. package/dist/src/cli.js +362 -96
  15. package/dist/src/devcli/clips.js +15 -10
  16. package/dist/src/devcli/doctor.js +2 -2
  17. package/dist/src/devcli/local-backend.js +46 -2
  18. package/dist/src/services/clip-curation/cost.js +5 -1
  19. package/dist/src/services/clip-curation/gemini.js +10 -0
  20. package/package.json +30 -10
  21. package/SKILL.platform.md +0 -432
  22. package/demo/README.md +0 -28
  23. package/demo/dist/app.css +0 -1
  24. package/demo/dist/app.js +0 -1850
  25. package/demo/dist/chunks/chunk-DXB73IDG.js +0 -1
  26. package/demo/dist/chunks/chunk-S7OWAJDS.js +0 -36
  27. package/demo/dist/chunks/chunk-VTIBZ6AN.js +0 -1
  28. package/demo/dist/chunks/dist-ADSJKBVE.js +0 -332
  29. package/demo/dist/chunks/domEditingLayers-VZMLL4AP-SGHWPND4.js +0 -1
  30. package/demo/dist/chunks/hyperframes-player-XB65TCD6.js +0 -425
  31. package/demo/dist/chunks/lib-XAQ37YOE.js +0 -1
  32. package/demo/dist/chunks/src-TJ2QYA4U.js +0 -207
  33. package/demo/dist/favicon.ico +0 -0
  34. package/demo/dist/icons/timeline/audio.svg +0 -7
  35. package/demo/dist/icons/timeline/captions.svg +0 -5
  36. package/demo/dist/icons/timeline/composition.svg +0 -12
  37. package/demo/dist/icons/timeline/image.svg +0 -18
  38. package/demo/dist/icons/timeline/music.svg +0 -10
  39. package/demo/dist/icons/timeline/text.svg +0 -3
  40. package/demo/dist/index.html +0 -15
  41. package/dist/src/account-pages-legacy.js +0 -9396
  42. package/dist/src/account-pages.js +0 -61
  43. package/dist/src/app.js +0 -21603
  44. package/dist/src/composition-runtime.js +0 -1053
  45. package/dist/src/config.js +0 -217
  46. package/dist/src/context.js +0 -447
  47. package/dist/src/dev-app-legacy.js +0 -739
  48. package/dist/src/dev-app.js +0 -6
  49. package/dist/src/devcli/migrate-local.js +0 -140
  50. package/dist/src/devcli/sync.js +0 -368
  51. package/dist/src/domain.js +0 -5
  52. package/dist/src/editor-chat-history.js +0 -82
  53. package/dist/src/editor-chat.js +0 -828
  54. package/dist/src/editor-dark-theme.js +0 -1128
  55. package/dist/src/frontend/debug.js +0 -71
  56. package/dist/src/frontend/discover-client.js +0 -130
  57. package/dist/src/frontend/discover-store.js +0 -23
  58. package/dist/src/frontend/file-directory.js +0 -1018
  59. package/dist/src/frontend/flockposter-cache-store.js +0 -124
  60. package/dist/src/frontend/homepage-client.js +0 -446
  61. package/dist/src/frontend/homepage-shared.js +0 -201
  62. package/dist/src/frontend/homepage-store.js +0 -66
  63. package/dist/src/frontend/homepage-view.js +0 -705
  64. package/dist/src/frontend/page-runtime-client.js +0 -132
  65. package/dist/src/frontend/page-runtime-store.js +0 -9
  66. package/dist/src/frontend/sentry.js +0 -42
  67. package/dist/src/frontend/template-editor-chat.js +0 -4181
  68. package/dist/src/help-page.js +0 -346
  69. package/dist/src/homepage.js +0 -1458
  70. package/dist/src/index.js +0 -16
  71. package/dist/src/instrument.js +0 -30
  72. package/dist/src/landing-page.js +0 -384
  73. package/dist/src/page-runtime.js +0 -2
  74. package/dist/src/page-shell.js +0 -1452
  75. package/dist/src/primitive-context.js +0 -416
  76. package/dist/src/primitive-registry.js +0 -3940
  77. package/dist/src/primitive-sdk.js +0 -4
  78. package/dist/src/primitives/hyperframes-media.js +0 -108
  79. package/dist/src/react-page-shell.js +0 -35
  80. package/dist/src/ready-post-schedule-component.js +0 -1540
  81. package/dist/src/registry.js +0 -296
  82. package/dist/src/reskin/agency-page.js +0 -299
  83. package/dist/src/reskin/calendar-page.js +0 -568
  84. package/dist/src/reskin/chat-page.js +0 -942
  85. package/dist/src/reskin/discover-page.js +0 -1788
  86. package/dist/src/reskin/document.js +0 -1587
  87. package/dist/src/reskin/help-page.js +0 -357
  88. package/dist/src/reskin/index-page.js +0 -62
  89. package/dist/src/reskin/inpaint-clipper-page.js +0 -890
  90. package/dist/src/reskin/inpaint-page.js +0 -2554
  91. package/dist/src/reskin/inpaint-video-page.js +0 -1339
  92. package/dist/src/reskin/job-runs-page.js +0 -477
  93. package/dist/src/reskin/library-page.js +0 -1634
  94. package/dist/src/reskin/login-page.js +0 -262
  95. package/dist/src/reskin/portfolio-page.js +0 -687
  96. package/dist/src/reskin/pricing-page.js +0 -390
  97. package/dist/src/reskin/settings-page.js +0 -732
  98. package/dist/src/reskin/theme.js +0 -711
  99. package/dist/src/runtime.js +0 -35
  100. package/dist/src/services/api-call-history.js +0 -249
  101. package/dist/src/services/auth.js +0 -152
  102. package/dist/src/services/billing-pricing.js +0 -39
  103. package/dist/src/services/billing.js +0 -241
  104. package/dist/src/services/cast.js +0 -127
  105. package/dist/src/services/chat-threads.js +0 -92
  106. package/dist/src/services/clip-records.js +0 -250
  107. package/dist/src/services/clip-search.js +0 -77
  108. package/dist/src/services/clip-vectors.js +0 -125
  109. package/dist/src/services/composition-sanitize.js +0 -124
  110. package/dist/src/services/composition-watch.js +0 -79
  111. package/dist/src/services/elevenlabs.js +0 -222
  112. package/dist/src/services/file-directory.js +0 -117
  113. package/dist/src/services/fork-access.js +0 -93
  114. package/dist/src/services/fork-manifest.js +0 -42
  115. package/dist/src/services/ghostcut.js +0 -179
  116. package/dist/src/services/hyperframes.js +0 -3654
  117. package/dist/src/services/job-capacity.js +0 -14
  118. package/dist/src/services/job-logs.js +0 -197
  119. package/dist/src/services/jobs.js +0 -136
  120. package/dist/src/services/local-dynamo.js +0 -0
  121. package/dist/src/services/media-processing.js +0 -766
  122. package/dist/src/services/primitive-media-lambda.js +0 -280
  123. package/dist/src/services/providers.js +0 -2748
  124. package/dist/src/services/rate-limits.js +0 -262
  125. package/dist/src/services/scene-annotations.js +0 -32
  126. package/dist/src/services/serverless-auth.js +0 -382
  127. package/dist/src/services/serverless-jobs.js +0 -1084
  128. package/dist/src/services/serverless-provider-keys.js +0 -409
  129. package/dist/src/services/serverless-records.js +0 -1515
  130. package/dist/src/services/serverless-template-configs.js +0 -75
  131. package/dist/src/services/storage.js +0 -461
  132. package/dist/src/services/swipe-customize.js +0 -437
  133. package/dist/src/services/template-certification.js +0 -413
  134. package/dist/src/services/template-loader.js +0 -99
  135. package/dist/src/services/template-runtime-bundles.js +0 -217
  136. package/dist/src/services/template-sources.js +0 -1017
  137. package/dist/src/services/upstream.js +0 -248
  138. package/dist/src/services/video-normalization.js +0 -2
  139. package/dist/src/services/webhooks.js +0 -62
  140. package/dist/src/template-editor-pages.js +0 -2576
  141. package/dist/src/template-editor-shell.js +0 -2893
  142. package/dist/src/template-sdk.js +0 -4
  143. package/dist/src/worker.js +0 -17
  144. package/public/assets/discover-client-app.js +0 -1
  145. package/public/assets/file-directory-app.js +0 -3
  146. package/public/assets/homepage-app.js +0 -54
  147. package/public/assets/homepage-client-app.js +0 -80
  148. package/public/assets/page-runtime-client-app.js +0 -94
  149. package/public/assets/placeholders/scene-placeholder.png +0 -0
  150. package/src/assets/SELLING_AWARENESS_STAGES.md +0 -579
  151. package/src/assets/SELLING_WITH_HOOKS.md +0 -377
  152. package/src/assets/SELLING_WITH_VSLS.md +0 -606
  153. package/src/assets/favicon.ico +0 -0
  154. package/src/assets/logo-vidfarm.png +0 -0
@@ -1,2748 +0,0 @@
1
- import { config } from "../config.js";
2
- import { parseJson } from "../lib/json.js";
3
- import { decryptString } from "../lib/crypto.js";
4
- import { createId } from "../lib/ids.js";
5
- import { addSeconds } from "../lib/time.js";
6
- import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
7
- import sharp from "sharp";
8
- import { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError, SpeechVoiceMismatchError, conciseProviderDetails, parseRetryAfterSeconds, rateLimitOrQuotaError } from "./provider-errors.js";
9
- import { SUPPORTED_STT_MODELS, SUPPORTED_TTS_MODELS, defaultSpeechModelFor, generateSpeechWithKey, inferSpeechContentType, speechVoiceFamilyFor, speechVoiceMismatch, transcribeSpeechWithKey } from "./speech.js";
10
- import { DEFAULT_ELEVENLABS_STT_MODEL, DEFAULT_ELEVENLABS_TTS_MODEL, elevenLabsListVoices, elevenLabsMusicCompose, elevenLabsSpeechToText, elevenLabsTextToSpeech, validateElevenLabsKey } from "./elevenlabs.js";
11
- export { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError } from "./provider-errors.js";
12
- const SUPPORTED_TEXT_MODELS = {
13
- openai: new Set(["gpt-5.4"]),
14
- gemini: new Set(["gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
15
- openrouter: new Set(["qwen/qwen3.6-plus"]),
16
- perplexity: new Set(["sonar"])
17
- };
18
- const SUPPORTED_IMAGE_MODELS = {
19
- openai: new Set(["gpt-image-2", "gpt-image-1"]),
20
- gemini: new Set(["gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview", "gemini-2.5-flash-image"]),
21
- openrouter: new Set([
22
- "google/gemini-3.1-flash-image-preview",
23
- "google/gemini-2.5-flash-image-preview",
24
- "bytedance/seedance-2.0",
25
- "bytedance-seed/seedream-4.5"
26
- ])
27
- };
28
- const SUPPORTED_VIDEO_MODELS = {
29
- openai: new Set(["sora-2", "sora-2-pro"]),
30
- gemini: new Set(["veo-3.0-generate-001", "veo-3.0-fast-generate-001"]),
31
- openrouter: new Set([
32
- "bytedance/seedance-2.0-fast",
33
- "bytedance/seedance-2.0",
34
- "google/veo-3.1-lite",
35
- "google/veo-3.1-fast",
36
- "google/veo-3.1",
37
- "kwaivgi/kling-v3.0-std",
38
- "kwaivgi/kling-v3.0-pro",
39
- "openai/sora-2-pro"
40
- ])
41
- };
42
- // Video renders poll in-process; the job-runner Lambda times out at 15 minutes, so the
43
- // in-process poll budget must stay safely below that (24 attempts x 30s = 12 minutes).
44
- const VIDEO_POLL_MAX_ATTEMPTS = 24;
45
- const VIDEO_POLL_INTERVAL_MS = 30_000;
46
- const DEFAULT_TEXT_MODELS = {
47
- openai: "gpt-5.4",
48
- gemini: "gemini-3.1-flash-lite",
49
- openrouter: "qwen/qwen3.6-plus",
50
- perplexity: "sonar"
51
- };
52
- const DEFAULT_IMAGE_MODELS = {
53
- openai: "gpt-image-2",
54
- gemini: "gemini-3-pro-image-preview",
55
- openrouter: "google/gemini-3.1-flash-image-preview"
56
- };
57
- const DEFAULT_VIDEO_MODELS = {
58
- openai: "sora-2",
59
- gemini: "veo-3.0-generate-001",
60
- openrouter: "bytedance/seedance-2.0"
61
- };
62
- const PROVIDER_FALLBACK_PRIORITY = ["openai", "gemini", "openrouter", "perplexity"];
63
- const OPENAI_IMAGE_SIZES = new Set([
64
- "1024x1024",
65
- "1536x1024",
66
- "1024x1536",
67
- "2048x2048",
68
- "2048x1152",
69
- "3840x2160",
70
- "2160x3840",
71
- "auto"
72
- ]);
73
- const GEMINI_IMAGE_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]);
74
- // elevenlabs is intentionally absent — it validates against its own /v1/user
75
- // endpoint (see validateProviderKey), not a chat-completion probe.
76
- const PROVIDER_KEY_VALIDATION_MODELS = {
77
- openai: "gpt-4.1-mini",
78
- gemini: "gemini-3.1-flash-lite-preview",
79
- openrouter: "google/gemini-3.1-flash-lite-preview",
80
- perplexity: "sonar"
81
- };
82
- function readStoredProviderSecret(value) {
83
- try {
84
- return decryptString(value, config.ENCRYPTION_SECRET);
85
- }
86
- catch {
87
- return value;
88
- }
89
- }
90
- function sleep(ms) {
91
- return new Promise((resolve) => setTimeout(resolve, ms));
92
- }
93
- function formatDurationSeconds(totalSeconds) {
94
- if (totalSeconds >= 3600) {
95
- const hours = Math.round(totalSeconds / 3600);
96
- return `${hours} hour${hours === 1 ? "" : "s"}`;
97
- }
98
- if (totalSeconds >= 60) {
99
- const minutes = Math.round(totalSeconds / 60);
100
- return `${minutes} minute${minutes === 1 ? "" : "s"}`;
101
- }
102
- return `${Math.max(1, Math.round(totalSeconds))} second${Math.round(totalSeconds) === 1 ? "" : "s"}`;
103
- }
104
- function conciseProviderResponse(operation, response, details) {
105
- return `${operation} returned ${response.status}${conciseProviderDetails(details)}`;
106
- }
107
- function isTransientProviderStatus(status) {
108
- return status === 408 || status === 409 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
109
- }
110
- function isTransientRemoteMediaStatus(status) {
111
- return status === 408 || status === 425 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
112
- }
113
- function remoteMediaRetryDelayMs(response, attempt) {
114
- const retryAfter = response?.headers.get("retry-after") ?? null;
115
- if (retryAfter) {
116
- return Math.min(parseRetryAfterSeconds(retryAfter) * 1000, 5_000);
117
- }
118
- return Math.min(250 * (2 ** attempt), 2_000);
119
- }
120
- async function transientProviderError(provider, operation, response) {
121
- const details = await response.text().catch(() => "");
122
- return new ProviderTransientError(`${provider} ${operation} temporarily failed with ${response.status}${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
123
- }
124
- async function waitForRetryableProviderRateLimit(error, deadlineMs) {
125
- const waitMs = error.retryAfterSeconds * 1000;
126
- if (Date.now() + waitMs > deadlineMs) {
127
- throw new ProviderWaitExceededError(`Provider remained rate limited for ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
128
- }
129
- await sleep(waitMs);
130
- }
131
- async function waitForRetryableProviderError(error, deadlineMs) {
132
- if (error instanceof ProviderQuotaExceededError || error instanceof ProviderAuthError) {
133
- return false;
134
- }
135
- if (error instanceof ProviderRateLimitError) {
136
- await waitForRetryableProviderRateLimit(error, deadlineMs);
137
- return true;
138
- }
139
- if (error instanceof ProviderLeaseTimeoutError) {
140
- const waitMs = error.retryAfterSeconds * 1000;
141
- if (Date.now() + waitMs > deadlineMs) {
142
- throw new ProviderWaitExceededError(`No eligible provider key became available within ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
143
- }
144
- await sleep(waitMs);
145
- return true;
146
- }
147
- if (error instanceof ProviderTransientError) {
148
- const waitMs = error.retryAfterSeconds * 1000;
149
- if (Date.now() + waitMs > deadlineMs) {
150
- throw new ProviderWaitExceededError(`Provider remained temporarily unavailable within ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
151
- }
152
- await sleep(waitMs);
153
- return true;
154
- }
155
- return false;
156
- }
157
- function isRecoverableProviderAttemptError(error) {
158
- return error instanceof ProviderLeaseTimeoutError ||
159
- error instanceof ProviderKeyUnavailableError ||
160
- error instanceof ProviderRateLimitError ||
161
- error instanceof ProviderQuotaExceededError ||
162
- error instanceof ProviderAuthError ||
163
- error instanceof ProviderTransientError;
164
- }
165
- function zeroUsageCost(result) {
166
- return {
167
- ...result,
168
- usage: {
169
- ...result.usage,
170
- costUsd: 0
171
- }
172
- };
173
- }
174
- function providerSupportsModel(kind, provider, model) {
175
- const supported = kind === "text" ? SUPPORTED_TEXT_MODELS : kind === "image" ? SUPPORTED_IMAGE_MODELS : SUPPORTED_VIDEO_MODELS;
176
- return providerSupportsModelFrom(supported, provider, model);
177
- }
178
- function defaultModelFor(kind, provider) {
179
- return kind === "text" ? DEFAULT_TEXT_MODELS[provider] : kind === "image" ? DEFAULT_IMAGE_MODELS[provider] : DEFAULT_VIDEO_MODELS[provider];
180
- }
181
- function providerSupportsModelFrom(supported, provider, model) {
182
- return Boolean(supported[provider]?.has(normalizeProviderModel(model)));
183
- }
184
- function uniqueProviders(requested, supported) {
185
- return [
186
- requested,
187
- ...PROVIDER_FALLBACK_PRIORITY.filter((provider) => provider !== requested)
188
- ].filter((provider) => supported[provider]?.size);
189
- }
190
- async function availableProviderCounts(providerKeyService, customerId) {
191
- return new Map((await providerKeyService.listAvailableProviderKeyCounts(customerId))
192
- .map((entry) => [entry.provider, entry.availableCount]));
193
- }
194
- async function configuredProviderCounts(providerKeyService, customerId) {
195
- const counts = new Map();
196
- for (const entry of await providerKeyService.listProviderKeysWithSecrets(customerId)) {
197
- const provider = String(entry.provider);
198
- const status = String(entry.status);
199
- const secret = readStoredProviderSecret(String(entry.secret ?? ""));
200
- if (status !== "active" || !secret.trim()) {
201
- continue;
202
- }
203
- counts.set(provider, (counts.get(provider) ?? 0) + 1);
204
- }
205
- return counts;
206
- }
207
- async function providerAttempts(input) {
208
- const keyCounts = await configuredProviderCounts(input.providerKeyService, input.customerId);
209
- const availableCounts = await availableProviderCounts(input.providerKeyService, input.customerId);
210
- const attempts = uniqueProviders(input.requestedProvider, input.supportedModels)
211
- .map((provider, index) => ({ provider, index }))
212
- .filter(({ provider }) => (keyCounts.get(provider) ?? 0) > 0)
213
- .filter(({ provider }) => input.allowProvider?.(provider) ?? true)
214
- .sort((a, b) => {
215
- const aAvailable = (availableCounts.get(a.provider) ?? 0) > 0 ? 0 : 1;
216
- const bAvailable = (availableCounts.get(b.provider) ?? 0) > 0 ? 0 : 1;
217
- return aAvailable - bAvailable || a.index - b.index;
218
- })
219
- .map(({ provider }) => ({
220
- provider,
221
- model: provider === input.requestedProvider && providerSupportsModelFrom(input.supportedModels, provider, input.requestedModel)
222
- ? normalizeProviderModel(input.requestedModel)
223
- : input.defaultModelForProvider(provider)
224
- }))
225
- .filter((attempt) => Boolean(attempt.model));
226
- if (!attempts.length) {
227
- const supportedProviders = Object.entries(input.supportedModels)
228
- .filter(([, models]) => models?.size)
229
- .map(([provider]) => provider)
230
- .join(", ");
231
- const configuredProviders = [...keyCounts.entries()]
232
- .filter(([, count]) => count > 0)
233
- .map(([provider]) => provider)
234
- .join(", ") || "none";
235
- throw new ProviderKeyUnavailableError(`No active ${input.capability} provider key is configured for this customer. ` +
236
- `Configured providers: ${configuredProviders}. Supported providers for this capability: ${supportedProviders || "none"}.`);
237
- }
238
- return attempts;
239
- }
240
- function normalizeOpenAIImageSize(size, aspectRatio) {
241
- if (size && OPENAI_IMAGE_SIZES.has(size)) {
242
- return size;
243
- }
244
- if (aspectRatio === "16:9") {
245
- return "1536x1024";
246
- }
247
- if (aspectRatio === "9:16") {
248
- return "1024x1536";
249
- }
250
- if (size && /^(\d+)x(\d+)$/i.test(size)) {
251
- const [, widthRaw, heightRaw] = /^(\d+)x(\d+)$/i.exec(size);
252
- const width = Number(widthRaw);
253
- const height = Number(heightRaw);
254
- if (width > height) {
255
- return "1536x1024";
256
- }
257
- if (height > width) {
258
- return "1024x1536";
259
- }
260
- }
261
- return "auto";
262
- }
263
- function normalizeGeminiAspectRatio(aspectRatio) {
264
- return aspectRatio && GEMINI_IMAGE_ASPECT_RATIOS.has(aspectRatio) ? aspectRatio : undefined;
265
- }
266
- const CHAT_PROVIDER_LEASE_PRIORITY = 0;
267
- const DEFAULT_PROVIDER_LEASE_TIMEOUT_MS = config.PROVIDER_LEASE_WAIT_TIMEOUT_SECONDS * 1000;
268
- const CHAT_PROVIDER_LEASE_TIMEOUT_MS = 15_000;
269
- const DEFAULT_PROVIDER_LEASE_SECONDS = 90;
270
- const CHAT_PROVIDER_LEASE_SECONDS = 120;
271
- const CHAT_PROVIDER_LEASE_CONCURRENCY = 2;
272
- const PROVIDER_LEASE_RENEWAL_MIN_INTERVAL_MS = 5_000;
273
- const PROVIDER_QUOTA_COOLDOWN_SECONDS = 60 * 60;
274
- function providerLeaseRetryDeadlineMs() {
275
- return Date.now() + Math.max(0, config.PROVIDER_LEASE_INLINE_WAIT_SECONDS) * 1000;
276
- }
277
- export class ProviderService {
278
- serverlessProviderKeys = new ServerlessProviderKeyService();
279
- async handleLeasedProviderError(input) {
280
- if (input.error instanceof ProviderRateLimitError) {
281
- await this.serverlessProviderKeys.recordProviderKeyUsage({
282
- id: createId("usage"),
283
- keyId: input.lease.keyId,
284
- jobId: input.jobId,
285
- provider: input.provider,
286
- model: input.model,
287
- eventType: "rate_limit",
288
- metadata: {
289
- ...(input.metadata ?? {}),
290
- retryAfterSeconds: input.error.retryAfterSeconds
291
- }
292
- });
293
- await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, addSeconds(new Date(), input.error.retryAfterSeconds));
294
- }
295
- else if (input.error instanceof ProviderQuotaExceededError) {
296
- await this.serverlessProviderKeys.recordProviderKeyUsage({
297
- id: createId("usage"),
298
- keyId: input.lease.keyId,
299
- jobId: input.jobId,
300
- provider: input.provider,
301
- model: input.model,
302
- eventType: "quota_exceeded",
303
- metadata: input.metadata ?? {}
304
- });
305
- await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, addSeconds(new Date(), PROVIDER_QUOTA_COOLDOWN_SECONDS));
306
- }
307
- else if (input.error instanceof ProviderAuthError) {
308
- await this.serverlessProviderKeys.recordProviderKeyUsage({
309
- id: createId("usage"),
310
- keyId: input.lease.keyId,
311
- jobId: input.jobId,
312
- provider: input.provider,
313
- model: input.model,
314
- eventType: "auth_error",
315
- metadata: input.metadata ?? {}
316
- });
317
- await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, null, "invalid", "auth_error");
318
- }
319
- throw input.error;
320
- }
321
- listAvailableProviders(_customerId) {
322
- return PROVIDER_FALLBACK_PRIORITY;
323
- }
324
- async listAvailableProvidersAsync(customerId) {
325
- const now = Date.now();
326
- const providers = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(customerId))
327
- .map((entry) => ({
328
- provider: String(entry.provider),
329
- status: String(entry.status),
330
- cooldownUntil: entry.cooldown_until ? String(entry.cooldown_until) : null,
331
- secret: readStoredProviderSecret(String(entry.secret))
332
- }))
333
- .filter((entry) => entry.status === "active")
334
- .filter((entry) => !entry.cooldownUntil || Date.parse(entry.cooldownUntil) <= now)
335
- .filter((entry) => entry.secret.trim().length > 0)
336
- .map((entry) => entry.provider);
337
- return [...new Set(providers)];
338
- }
339
- async validateProviderKey(input) {
340
- // ElevenLabs is audio-only: validate against its own account endpoint, not a
341
- // chat-completion probe.
342
- if (input.provider === "elevenlabs") {
343
- if (config.mockProviders) {
344
- return { provider: input.provider, model: "elevenlabs", mock: true };
345
- }
346
- try {
347
- await validateElevenLabsKey(input.secret);
348
- return { provider: input.provider, model: "elevenlabs", mock: false };
349
- }
350
- catch (error) {
351
- throw new Error(toProviderValidationMessage(input.provider, error));
352
- }
353
- }
354
- const model = PROVIDER_KEY_VALIDATION_MODELS[input.provider];
355
- if (!model) {
356
- throw new Error(`Provider ${input.provider} does not support validation.`);
357
- }
358
- if (config.mockProviders) {
359
- return { provider: input.provider, model, mock: true };
360
- }
361
- try {
362
- await this.callProvider({
363
- provider: input.provider,
364
- model,
365
- prompt: "Reply with OK.",
366
- temperature: 0,
367
- apiKey: input.secret
368
- });
369
- return { provider: input.provider, model, mock: false };
370
- }
371
- catch (error) {
372
- throw new Error(toProviderValidationMessage(input.provider, error));
373
- }
374
- }
375
- async leaseCustomerKey(input) {
376
- const leaseToken = createId("lease");
377
- const leaseDurationSeconds = input.leaseDurationSeconds ?? DEFAULT_PROVIDER_LEASE_SECONDS;
378
- const row = await this.serverlessProviderKeys.acquireProviderKeyLease({
379
- customerId: input.customerId,
380
- provider: input.provider,
381
- leaseToken,
382
- workerId: input.workerId,
383
- jobId: input.jobId,
384
- expiresAt: addSeconds(new Date(), leaseDurationSeconds)
385
- });
386
- if (!row) {
387
- const message = `No eligible ${input.provider} key is immediately available.`;
388
- if ((input.timeoutFailure ?? "retry") === "retry") {
389
- throw new ProviderLeaseTimeoutError(message);
390
- }
391
- throw new ProviderWaitExceededError(message);
392
- }
393
- return {
394
- keyId: row.keyId,
395
- leaseToken,
396
- provider: input.provider,
397
- secret: readStoredProviderSecret(row.encryptedSecret),
398
- jobId: input.jobId,
399
- leaseDurationSeconds
400
- };
401
- }
402
- providerLeaseTimeoutMessage(waitTimeoutMs, provider) {
403
- if (waitTimeoutMs === null) {
404
- return `No eligible ${provider} key became available.`;
405
- }
406
- return `No eligible ${provider} key became available within ${formatDurationSeconds(Math.max(1, Math.floor((waitTimeoutMs ?? DEFAULT_PROVIDER_LEASE_TIMEOUT_MS) / 1000)))}.`;
407
- }
408
- throwProviderLeaseTimeout(input) {
409
- const message = this.providerLeaseTimeoutMessage(input.waitTimeoutMs, input.provider);
410
- if ((input.timeoutFailure ?? "retry") === "retry") {
411
- throw new ProviderLeaseTimeoutError(message);
412
- }
413
- throw new ProviderWaitExceededError(message);
414
- }
415
- async withLeaseRenewal(lease, run) {
416
- const renewEveryMs = Math.max(PROVIDER_LEASE_RENEWAL_MIN_INTERVAL_MS, Math.floor((lease.leaseDurationSeconds * 1000) / 2));
417
- const timer = setInterval(() => {
418
- const expiresAt = addSeconds(new Date(), lease.leaseDurationSeconds);
419
- void this.serverlessProviderKeys.extendProviderKeyLease({
420
- keyId: lease.keyId,
421
- leaseToken: lease.leaseToken,
422
- expiresAt
423
- });
424
- }, renewEveryMs);
425
- timer.unref();
426
- try {
427
- return await run();
428
- }
429
- finally {
430
- clearInterval(timer);
431
- }
432
- }
433
- async releaseLease(lease) {
434
- await this.serverlessProviderKeys.releaseProviderKeyLease({ keyId: lease.keyId, leaseToken: lease.leaseToken });
435
- }
436
- async leaseEditorChatKey(input) {
437
- const now = new Date().toISOString();
438
- const key = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(input.customerId))
439
- .filter((entry) => entry.provider === input.provider)
440
- .filter((entry) => entry.status === "active")
441
- .filter((entry) => !entry.cooldown_until || entry.cooldown_until <= now)
442
- .filter((entry) => readStoredProviderSecret(entry.secret).trim().length > 0)
443
- .sort((a, b) => {
444
- const aLast = a.last_used_at ?? "";
445
- const bLast = b.last_used_at ?? "";
446
- if (!aLast && bLast)
447
- return -1;
448
- if (aLast && !bLast)
449
- return 1;
450
- return aLast.localeCompare(bLast) || a.created_at.localeCompare(b.created_at);
451
- })[0];
452
- if (!key) {
453
- throw new ProviderLeaseTimeoutError(`No eligible ${input.provider} key is currently available for editor chat.`);
454
- }
455
- return {
456
- keyId: key.id,
457
- leaseToken: createId("chatlease"),
458
- provider: input.provider,
459
- secret: readStoredProviderSecret(key.secret),
460
- jobId: input.chatId,
461
- leaseDurationSeconds: CHAT_PROVIDER_LEASE_SECONDS
462
- };
463
- }
464
- async generateText(input) {
465
- const attempts = await providerAttempts({
466
- providerKeyService: this.serverlessProviderKeys,
467
- customerId: input.customerId,
468
- capability: "text",
469
- requestedProvider: input.provider,
470
- requestedModel: input.model,
471
- supportedModels: SUPPORTED_TEXT_MODELS,
472
- defaultModelForProvider: (provider) => defaultModelFor("text", provider)
473
- });
474
- const retryDeadlineMs = providerLeaseRetryDeadlineMs();
475
- for (;;) {
476
- let lastError = null;
477
- for (const attempt of attempts) {
478
- try {
479
- return await this.generateTextWithProvider({
480
- ...input,
481
- provider: attempt.provider,
482
- model: attempt.model
483
- });
484
- }
485
- catch (error) {
486
- lastError = error;
487
- if (!isRecoverableProviderAttemptError(error)) {
488
- throw error;
489
- }
490
- }
491
- }
492
- if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
493
- continue;
494
- }
495
- throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI provider key became available in time.");
496
- }
497
- }
498
- async generateTextWithProvider(input) {
499
- const lease = await this.leaseCustomerKey({
500
- customerId: input.customerId,
501
- provider: input.provider,
502
- jobId: input.jobId,
503
- workerId: input.workerId
504
- });
505
- try {
506
- if (config.mockProviders) {
507
- const text = [
508
- `Hook: ${input.prompt.slice(0, 80)}`,
509
- "Beat 1: Identify the pain point.",
510
- "Beat 2: Show the turning point.",
511
- "Beat 3: Close with a clear CTA."
512
- ].join("\n");
513
- await this.serverlessProviderKeys.recordProviderKeyUsage({
514
- id: createId("usage"),
515
- keyId: lease.keyId,
516
- jobId: input.jobId,
517
- provider: input.provider,
518
- model: input.model,
519
- eventType: "success",
520
- inputTokens: 180,
521
- outputTokens: 120,
522
- metadata: { mock: true }
523
- });
524
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
525
- return {
526
- text,
527
- usage: {
528
- inputTokens: 180,
529
- outputTokens: 120,
530
- costUsd: 0
531
- }
532
- };
533
- }
534
- const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => this.callProvider({ ...input, apiKey: lease.secret })));
535
- await this.serverlessProviderKeys.recordProviderKeyUsage({
536
- id: createId("usage"),
537
- keyId: lease.keyId,
538
- jobId: input.jobId,
539
- provider: input.provider,
540
- model: input.model,
541
- eventType: "success",
542
- inputTokens: result.usage.inputTokens,
543
- outputTokens: result.usage.outputTokens
544
- });
545
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
546
- return result;
547
- }
548
- catch (error) {
549
- return await this.handleLeasedProviderError({
550
- error,
551
- lease,
552
- jobId: input.jobId,
553
- provider: input.provider,
554
- model: input.model
555
- });
556
- }
557
- finally {
558
- await this.releaseLease(lease);
559
- }
560
- }
561
- // --- Audio (music / TTS / STT): platform-key + wallet vs BYOK routing ---
562
- async resolveCustomerElevenLabsKey(customerId) {
563
- const now = new Date().toISOString();
564
- const secret = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(customerId))
565
- .filter((entry) => String(entry.provider) === "elevenlabs")
566
- .filter((entry) => String(entry.status) === "active")
567
- .filter((entry) => !entry.cooldown_until || String(entry.cooldown_until) <= now)
568
- .map((entry) => readStoredProviderSecret(String(entry.secret ?? "")))
569
- .find((value) => value.trim().length > 0);
570
- return secret ?? null;
571
- }
572
- async customerHasByokAudioKey(customerId) {
573
- const providers = await this.listAvailableProvidersAsync(customerId);
574
- return providers.some((provider) => provider === "openai" || provider === "gemini" || provider === "openrouter");
575
- }
576
- /**
577
- * Decide how an audio call is authorized. Default (`use_wallet_credits` unset
578
- * or true): the platform ElevenLabs key, billed to the wallet. Explicit
579
- * `false`: the customer's own ElevenLabs key, else a BYOK openai/gemini/
580
- * openrouter key (TTS/STT only), else fall back to the platform key + wallet.
581
- */
582
- async resolveAudioAuthPlan(input) {
583
- // `undefined` = legacy BYOK-only. Internal callers (captions, voice-matched
584
- // regenerate-speech) never pass the flag, so they keep their exact behavior
585
- // and never silently draw on the platform key or the wallet. Only surfaces
586
- // that explicitly opt in (the music/tts/stt primitives) reach ElevenLabs.
587
- if (input.useWalletCredits === undefined) {
588
- return { mode: "byok" };
589
- }
590
- const platformKey = config.ELEVENLABS_API_KEY?.trim() || null;
591
- if (input.useWalletCredits) {
592
- if (platformKey) {
593
- return { mode: "elevenlabs", apiKey: platformKey, walletBilled: true, scope: "platform" };
594
- }
595
- // Operator hasn't wired a platform key — honor the intent with the user's
596
- // own ElevenLabs key (no charge) or a BYOK audio key before failing.
597
- const userKey = await this.resolveCustomerElevenLabsKey(input.customerId);
598
- if (userKey) {
599
- return { mode: "elevenlabs", apiKey: userKey, walletBilled: false, scope: "user" };
600
- }
601
- if (input.byokFallback && (await this.customerHasByokAudioKey(input.customerId))) {
602
- return { mode: "byok" };
603
- }
604
- throw new ProviderKeyUnavailableError("use_wallet_credits is on but no platform ElevenLabs key is configured, and you have no usable audio key. Ask the operator to set ELEVENLABS_API_KEY or save your own key.");
605
- }
606
- // use_wallet_credits === false: prefer the user's OWN key, then a BYOK audio
607
- // key (TTS/STT), and only then fall back to the platform key + wallet.
608
- const userKey = await this.resolveCustomerElevenLabsKey(input.customerId);
609
- if (userKey) {
610
- return { mode: "elevenlabs", apiKey: userKey, walletBilled: false, scope: "user" };
611
- }
612
- if (input.byokFallback && (await this.customerHasByokAudioKey(input.customerId))) {
613
- return { mode: "byok" };
614
- }
615
- if (platformKey) {
616
- return { mode: "elevenlabs", apiKey: platformKey, walletBilled: true, scope: "platform" };
617
- }
618
- throw new ProviderKeyUnavailableError(input.capability === "music"
619
- ? "Music needs ElevenLabs: save an ElevenLabs key in Settings, or ask the operator to configure the platform ELEVENLABS_API_KEY (use_wallet_credits)."
620
- : `No audio provider available for ${input.capability}. Enable use_wallet_credits (platform ElevenLabs key), save your own ElevenLabs key, or add an OpenAI/Gemini/OpenRouter key.`);
621
- }
622
- /** Voices available to whichever ElevenLabs account will serve this customer. */
623
- async listElevenLabsVoices(input) {
624
- const plan = await this.resolveAudioAuthPlan({
625
- customerId: input.customerId,
626
- useWalletCredits: input.useWalletCredits,
627
- byokFallback: false,
628
- capability: "tts"
629
- });
630
- if (plan.mode !== "elevenlabs") {
631
- throw new ProviderKeyUnavailableError("No ElevenLabs account is available to list voices.");
632
- }
633
- return { scope: plan.scope, voices: await elevenLabsListVoices(plan.apiKey) };
634
- }
635
- async generateSpeech(input) {
636
- const plan = await this.resolveAudioAuthPlan({
637
- customerId: input.customerId,
638
- useWalletCredits: input.useWalletCredits,
639
- byokFallback: true,
640
- capability: "tts"
641
- });
642
- if (plan.mode === "elevenlabs") {
643
- const model = /^eleven/i.test(input.model || "") ? input.model : DEFAULT_ELEVENLABS_TTS_MODEL;
644
- const generated = await elevenLabsTextToSpeech({
645
- apiKey: plan.apiKey,
646
- text: input.text,
647
- voice: input.voice,
648
- model,
649
- responseFormat: input.responseFormat
650
- });
651
- const billing = plan.walletBilled
652
- ? {
653
- costUsd: Number(((generated.characterCount / 1000) * config.ELEVENLABS_TTS_USD_PER_1K_CHARS).toFixed(4)),
654
- costCenterSlug: "elevenlabs_tts",
655
- metadata: {
656
- operation: "tts",
657
- provider: "elevenlabs",
658
- model,
659
- character_count: generated.characterCount,
660
- wallet_billed: true,
661
- scope: plan.scope
662
- }
663
- }
664
- : null;
665
- return {
666
- bytes: generated.bytes,
667
- contentType: generated.contentType,
668
- usage: generated.usage,
669
- effectiveProvider: "elevenlabs",
670
- effectiveModel: model,
671
- billing
672
- };
673
- }
674
- const byok = await this.generateSpeechByok(input);
675
- return { ...byok.result, effectiveProvider: byok.provider, effectiveModel: byok.model, billing: null };
676
- }
677
- async generateSpeechByok(input) {
678
- // Provider fallback must stay voice-aware: a Gemini preset routed to an
679
- // OpenAI key (or the reverse) would 400 opaquely at the provider.
680
- const attempts = (await providerAttempts({
681
- providerKeyService: this.serverlessProviderKeys,
682
- customerId: input.customerId,
683
- capability: "tts",
684
- requestedProvider: input.provider,
685
- requestedModel: input.model,
686
- supportedModels: SUPPORTED_TTS_MODELS,
687
- defaultModelForProvider: (provider) => defaultSpeechModelFor("tts", provider)
688
- })).filter((attempt) => !speechVoiceMismatch({ provider: attempt.provider, model: attempt.model, voice: input.voice }));
689
- if (!attempts.length) {
690
- throw new SpeechVoiceMismatchError(speechVoiceMismatch({ provider: input.provider, model: input.model, voice: input.voice }) ??
691
- `Voice "${input.voice}" needs a ${speechVoiceFamilyFor(input.provider, input.model) === "gemini" ? "Gemini-family (gemini, or openrouter with a Gemini TTS model)" : "OpenAI-family (openai)"} provider key, but none is configured. Save a compatible key or pick a different voice.`);
692
- }
693
- const retryDeadlineMs = providerLeaseRetryDeadlineMs();
694
- for (;;) {
695
- let lastError = null;
696
- for (const attempt of attempts) {
697
- try {
698
- const result = await this.generateSpeechWithProvider({
699
- ...input,
700
- provider: attempt.provider,
701
- model: attempt.model
702
- });
703
- return { result, provider: attempt.provider, model: attempt.model };
704
- }
705
- catch (error) {
706
- lastError = error;
707
- if (!isRecoverableProviderAttemptError(error)) {
708
- throw error;
709
- }
710
- }
711
- }
712
- if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
713
- continue;
714
- }
715
- throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI speech provider key became available in time.");
716
- }
717
- }
718
- /** ElevenLabs music generation. Platform key + wallet by default; the
719
- * customer's own ElevenLabs key when use_wallet_credits is false. */
720
- async generateMusic(input) {
721
- const plan = await this.resolveAudioAuthPlan({
722
- customerId: input.customerId,
723
- useWalletCredits: input.useWalletCredits,
724
- byokFallback: false,
725
- capability: "music"
726
- });
727
- if (plan.mode !== "elevenlabs") {
728
- throw new ProviderKeyUnavailableError("Music generation requires an ElevenLabs key.");
729
- }
730
- const generated = await elevenLabsMusicCompose({
731
- apiKey: plan.apiKey,
732
- prompt: input.prompt,
733
- musicLengthMs: input.musicLengthMs,
734
- compositionPlan: input.compositionPlan,
735
- model: input.model,
736
- outputFormat: input.outputFormat
737
- });
738
- const minutes = Math.max(0, generated.musicLengthMs) / 60000;
739
- const billing = plan.walletBilled
740
- ? {
741
- costUsd: Number((minutes * config.ELEVENLABS_MUSIC_USD_PER_MIN).toFixed(4)),
742
- costCenterSlug: "elevenlabs_music",
743
- metadata: {
744
- operation: "music",
745
- provider: "elevenlabs",
746
- music_length_ms: generated.musicLengthMs,
747
- wallet_billed: true,
748
- scope: plan.scope
749
- }
750
- }
751
- : null;
752
- return { ...generated, scope: plan.scope, billing };
753
- }
754
- async generateSpeechWithProvider(input) {
755
- assertSupportedSpeechModel("tts", input.provider, input.model);
756
- const lease = await this.leaseCustomerKey({
757
- customerId: input.customerId,
758
- provider: input.provider,
759
- jobId: input.jobId,
760
- workerId: input.workerId
761
- });
762
- try {
763
- if (config.mockProviders) {
764
- const contentType = inferSpeechContentType(input.responseFormat ?? "mp3");
765
- await this.serverlessProviderKeys.recordProviderKeyUsage({
766
- id: createId("usage"),
767
- keyId: lease.keyId,
768
- jobId: input.jobId,
769
- provider: input.provider,
770
- model: input.model,
771
- eventType: "success",
772
- inputTokens: 0,
773
- outputTokens: 0,
774
- metadata: { mock: true, type: "speech_generation" }
775
- });
776
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
777
- return {
778
- bytes: Buffer.from(`mock speech:${input.text.slice(0, 120)}`, "utf8"),
779
- contentType,
780
- usage: {
781
- inputTokens: 0,
782
- outputTokens: 0,
783
- costUsd: 0
784
- }
785
- };
786
- }
787
- const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => generateSpeechWithKey({
788
- provider: input.provider,
789
- model: input.model,
790
- text: input.text,
791
- voice: input.voice,
792
- instructions: input.instructions,
793
- responseFormat: input.responseFormat,
794
- apiKey: lease.secret
795
- })));
796
- await this.serverlessProviderKeys.recordProviderKeyUsage({
797
- id: createId("usage"),
798
- keyId: lease.keyId,
799
- jobId: input.jobId,
800
- provider: input.provider,
801
- model: input.model,
802
- eventType: "success",
803
- inputTokens: result.usage.inputTokens,
804
- outputTokens: result.usage.outputTokens,
805
- metadata: { type: "speech_generation" }
806
- });
807
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
808
- return result;
809
- }
810
- catch (error) {
811
- return await this.handleLeasedProviderError({
812
- error,
813
- lease,
814
- jobId: input.jobId,
815
- provider: input.provider,
816
- model: input.model,
817
- metadata: { type: "speech_generation" }
818
- });
819
- }
820
- finally {
821
- await this.releaseLease(lease);
822
- }
823
- }
824
- async generateImage(input) {
825
- const attempts = await providerAttempts({
826
- providerKeyService: this.serverlessProviderKeys,
827
- customerId: input.customerId,
828
- capability: "image",
829
- requestedProvider: input.provider,
830
- requestedModel: input.model,
831
- supportedModels: SUPPORTED_IMAGE_MODELS,
832
- defaultModelForProvider: (provider) => defaultModelFor("image", provider),
833
- allowProvider: (provider) => provider !== "perplexity"
834
- });
835
- const retryDeadlineMs = providerLeaseRetryDeadlineMs();
836
- for (;;) {
837
- let lastError = null;
838
- for (const attempt of attempts) {
839
- try {
840
- return await this.generateImageWithProvider({
841
- ...input,
842
- provider: attempt.provider,
843
- model: attempt.model
844
- });
845
- }
846
- catch (error) {
847
- lastError = error;
848
- if (!isRecoverableProviderAttemptError(error)) {
849
- throw error;
850
- }
851
- }
852
- }
853
- if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
854
- continue;
855
- }
856
- throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI image provider key became available in time.");
857
- }
858
- }
859
- async generateImageWithProvider(input) {
860
- const lease = await this.leaseCustomerKey({
861
- customerId: input.customerId,
862
- provider: input.provider,
863
- jobId: input.jobId,
864
- workerId: input.workerId
865
- });
866
- try {
867
- if (config.mockProviders) {
868
- const svg = buildMockSlideSvg(input.prompt);
869
- await this.serverlessProviderKeys.recordProviderKeyUsage({
870
- id: createId("usage"),
871
- keyId: lease.keyId,
872
- jobId: input.jobId,
873
- provider: input.provider,
874
- model: input.model,
875
- eventType: "success",
876
- inputTokens: 0,
877
- outputTokens: 0,
878
- metadata: { mock: true, type: "image_generation" }
879
- });
880
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
881
- return {
882
- bytes: Buffer.from(svg, "utf8"),
883
- contentType: "image/svg+xml",
884
- revisedPrompt: input.prompt
885
- };
886
- }
887
- const result = await this.withLeaseRenewal(lease, () => (input.provider === "gemini"
888
- ? this.callGeminiImageGeneration({
889
- model: input.model,
890
- prompt: input.prompt,
891
- promptAttachments: input.promptAttachments,
892
- aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
893
- imageSize: input.imageSize,
894
- apiKey: lease.secret
895
- })
896
- : input.provider === "openrouter"
897
- ? this.callOpenRouterImageGeneration({
898
- model: input.model,
899
- prompt: input.prompt,
900
- promptAttachments: input.promptAttachments,
901
- aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
902
- imageSize: input.imageSize,
903
- apiKey: lease.secret
904
- })
905
- : this.callOpenAIImageGeneration({
906
- model: input.model,
907
- prompt: input.prompt,
908
- promptAttachments: input.promptAttachments,
909
- size: normalizeOpenAIImageSize(input.size, input.aspectRatio),
910
- aspectRatio: input.aspectRatio,
911
- apiKey: lease.secret
912
- })));
913
- await this.serverlessProviderKeys.recordProviderKeyUsage({
914
- id: createId("usage"),
915
- keyId: lease.keyId,
916
- jobId: input.jobId,
917
- provider: input.provider,
918
- model: input.model,
919
- eventType: "success",
920
- inputTokens: 0,
921
- outputTokens: 0,
922
- metadata: { type: "image_generation" }
923
- });
924
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
925
- return result;
926
- }
927
- catch (error) {
928
- return await this.handleLeasedProviderError({
929
- error,
930
- lease,
931
- jobId: input.jobId,
932
- provider: input.provider,
933
- model: input.model,
934
- metadata: { type: "image_generation" }
935
- });
936
- }
937
- finally {
938
- await this.releaseLease(lease);
939
- }
940
- }
941
- async editImage(input) {
942
- const attempts = await providerAttempts({
943
- providerKeyService: this.serverlessProviderKeys,
944
- customerId: input.customerId,
945
- capability: "image",
946
- requestedProvider: input.provider,
947
- requestedModel: input.model,
948
- supportedModels: SUPPORTED_IMAGE_MODELS,
949
- defaultModelForProvider: (provider) => defaultModelFor("image", provider),
950
- allowProvider: (provider) => provider !== "perplexity"
951
- });
952
- const retryDeadlineMs = providerLeaseRetryDeadlineMs();
953
- for (;;) {
954
- let lastError = null;
955
- for (const attempt of attempts) {
956
- try {
957
- return await this.editImageWithProvider({
958
- ...input,
959
- provider: attempt.provider,
960
- model: attempt.model
961
- });
962
- }
963
- catch (error) {
964
- lastError = error;
965
- if (!isRecoverableProviderAttemptError(error)) {
966
- throw error;
967
- }
968
- }
969
- }
970
- if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
971
- continue;
972
- }
973
- throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI image edit provider key became available in time.");
974
- }
975
- }
976
- async editImageWithProvider(input) {
977
- assertSupportedModel("image", input.provider, input.model, SUPPORTED_IMAGE_MODELS);
978
- const lease = await this.leaseCustomerKey({
979
- customerId: input.customerId,
980
- provider: input.provider,
981
- jobId: input.jobId,
982
- workerId: input.workerId
983
- });
984
- try {
985
- if (config.mockProviders) {
986
- const svg = buildMockSlideSvg(`EDIT ${input.instruction}\n${input.sourceImageUrl}`);
987
- await this.serverlessProviderKeys.recordProviderKeyUsage({
988
- id: createId("usage"),
989
- keyId: lease.keyId,
990
- jobId: input.jobId,
991
- provider: input.provider,
992
- model: input.model,
993
- eventType: "success",
994
- inputTokens: 0,
995
- outputTokens: 0,
996
- metadata: { mock: true, type: "image_edit" }
997
- });
998
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
999
- return {
1000
- bytes: Buffer.from(svg, "utf8"),
1001
- contentType: "image/svg+xml",
1002
- revisedPrompt: input.instruction
1003
- };
1004
- }
1005
- const result = await this.withLeaseRenewal(lease, () => (input.provider === "openai"
1006
- ? this.callOpenAIImageEdit({
1007
- model: input.model,
1008
- sourceImageUrl: input.sourceImageUrl,
1009
- instruction: input.instruction,
1010
- referenceAttachments: input.referenceAttachments,
1011
- maskUrl: input.maskUrl,
1012
- size: normalizeOpenAIImageSize(input.size, input.aspectRatio),
1013
- apiKey: lease.secret
1014
- })
1015
- : input.provider === "perplexity"
1016
- ? (() => {
1017
- throw new Error("Perplexity image edit is not supported.");
1018
- })()
1019
- : this.callAttachmentDrivenImageEdit({
1020
- provider: input.provider,
1021
- model: input.model,
1022
- sourceImageUrl: input.sourceImageUrl,
1023
- instruction: input.instruction,
1024
- referenceAttachments: input.referenceAttachments,
1025
- maskUrl: input.maskUrl,
1026
- aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
1027
- imageSize: input.imageSize,
1028
- apiKey: lease.secret
1029
- })));
1030
- await this.serverlessProviderKeys.recordProviderKeyUsage({
1031
- id: createId("usage"),
1032
- keyId: lease.keyId,
1033
- jobId: input.jobId,
1034
- provider: input.provider,
1035
- model: input.model,
1036
- eventType: "success",
1037
- inputTokens: 0,
1038
- outputTokens: 0,
1039
- metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
1040
- });
1041
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1042
- return result;
1043
- }
1044
- catch (error) {
1045
- return await this.handleLeasedProviderError({
1046
- error,
1047
- lease,
1048
- jobId: input.jobId,
1049
- provider: input.provider,
1050
- model: input.model,
1051
- metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
1052
- });
1053
- }
1054
- finally {
1055
- await this.releaseLease(lease);
1056
- }
1057
- }
1058
- async inpaintImage(input) {
1059
- return this.editImage({
1060
- ...input,
1061
- maskUrl: input.maskUrl
1062
- });
1063
- }
1064
- async generateVideo(input) {
1065
- const attempts = await providerAttempts({
1066
- providerKeyService: this.serverlessProviderKeys,
1067
- customerId: input.customerId,
1068
- capability: "video",
1069
- requestedProvider: input.provider,
1070
- requestedModel: input.model,
1071
- supportedModels: SUPPORTED_VIDEO_MODELS,
1072
- defaultModelForProvider: (provider) => defaultModelFor("video", provider),
1073
- allowProvider: (provider) => provider !== "perplexity"
1074
- });
1075
- const retryDeadlineMs = providerLeaseRetryDeadlineMs();
1076
- for (;;) {
1077
- let lastError = null;
1078
- for (const attempt of attempts) {
1079
- try {
1080
- return await this.generateVideoWithProvider({
1081
- ...input,
1082
- provider: attempt.provider,
1083
- model: attempt.model
1084
- });
1085
- }
1086
- catch (error) {
1087
- lastError = error;
1088
- if (!isRecoverableProviderAttemptError(error)) {
1089
- throw error;
1090
- }
1091
- }
1092
- }
1093
- if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1094
- continue;
1095
- }
1096
- throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI video provider key became available in time.");
1097
- }
1098
- }
1099
- async generateVideoWithProvider(input) {
1100
- assertSupportedModel("video", input.provider, input.model, SUPPORTED_VIDEO_MODELS);
1101
- const lease = await this.leaseCustomerKey({
1102
- customerId: input.customerId,
1103
- provider: input.provider,
1104
- jobId: input.jobId,
1105
- workerId: input.workerId,
1106
- leaseDurationSeconds: Math.max(900, DEFAULT_PROVIDER_LEASE_SECONDS)
1107
- });
1108
- // Video providers run async render jobs server-side, so the exclusive key lease is only
1109
- // needed while submitting the job. Release it before polling so other jobs for this
1110
- // customer can use the key while the render completes. releaseProviderKeyLease is
1111
- // lease-token-guarded (double-release is a no-op), but the flag avoids redundant writes.
1112
- let leaseReleased = false;
1113
- const releaseLeaseOnce = async () => {
1114
- if (leaseReleased) {
1115
- return;
1116
- }
1117
- await this.releaseLease(lease);
1118
- leaseReleased = true;
1119
- };
1120
- try {
1121
- if (config.mockProviders) {
1122
- await this.serverlessProviderKeys.recordProviderKeyUsage({
1123
- id: createId("usage"),
1124
- keyId: lease.keyId,
1125
- jobId: input.jobId,
1126
- provider: input.provider,
1127
- model: input.model,
1128
- eventType: "success",
1129
- inputTokens: 0,
1130
- outputTokens: 0,
1131
- metadata: { mock: true, type: "video_generation" }
1132
- });
1133
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1134
- return {
1135
- bytes: Buffer.from(`mock video:${input.prompt.slice(0, 120)}`, "utf8"),
1136
- contentType: "video/mp4",
1137
- providerJob: { mock: true, model: input.model },
1138
- usageCostUsd: 0
1139
- };
1140
- }
1141
- // Submit phase: the exclusive lease (plus renewal heartbeat) is held only until the
1142
- // provider accepts the render job. withLeaseRenewal clears the heartbeat timer before
1143
- // this await resolves, so no renewal can re-extend the lease after release below.
1144
- const submission = await this.withLeaseRenewal(lease, () => {
1145
- const request = {
1146
- model: input.model,
1147
- prompt: input.prompt,
1148
- duration: input.duration,
1149
- resolution: input.resolution,
1150
- aspectRatio: input.aspectRatio,
1151
- size: input.size,
1152
- generateAudio: input.generateAudio,
1153
- frameImages: input.frameImages,
1154
- inputReferences: input.inputReferences,
1155
- seed: input.seed,
1156
- providerOptions: input.providerOptions,
1157
- apiKey: lease.secret
1158
- };
1159
- if (input.provider === "openai") {
1160
- return this.submitOpenAIVideoGeneration(request);
1161
- }
1162
- if (input.provider === "gemini") {
1163
- return this.submitGeminiVideoGeneration(request);
1164
- }
1165
- if (input.provider === "openrouter") {
1166
- return this.submitOpenRouterVideoGeneration(request);
1167
- }
1168
- throw new ProviderKeyUnavailableError(`AI video generation does not support ${input.provider} provider keys.`);
1169
- });
1170
- // The render job now runs server-side at the provider; free the key for other jobs.
1171
- await releaseLeaseOnce();
1172
- // Poll phase: runs without the lease. Errors still flow through the catch below —
1173
- // handleLeasedProviderError only writes usage/cooldown rows, which are not
1174
- // lease-token-conditioned, so rate-limit/quota/auth cooldowns keep working here.
1175
- const result = await submission.poll();
1176
- await this.serverlessProviderKeys.recordProviderKeyUsage({
1177
- id: createId("usage"),
1178
- keyId: lease.keyId,
1179
- jobId: input.jobId,
1180
- provider: input.provider,
1181
- model: input.model,
1182
- eventType: "success",
1183
- inputTokens: 0,
1184
- outputTokens: 0,
1185
- metadata: { type: "video_generation", usageCostUsd: result.usageCostUsd, providerJobId: result.providerJob.id ?? null }
1186
- });
1187
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1188
- return result;
1189
- }
1190
- catch (error) {
1191
- return await this.handleLeasedProviderError({
1192
- error,
1193
- lease,
1194
- jobId: input.jobId,
1195
- provider: input.provider,
1196
- model: input.model,
1197
- metadata: { type: "video_generation" }
1198
- });
1199
- }
1200
- finally {
1201
- await releaseLeaseOnce();
1202
- }
1203
- }
1204
- async submitOpenAIVideoGeneration(input) {
1205
- const form = new FormData();
1206
- form.set("model", input.model);
1207
- form.set("prompt", input.prompt);
1208
- const size = normalizeOpenAIVideoSize(input.model, input.size, input.aspectRatio, input.resolution);
1209
- if (size) {
1210
- form.set("size", size);
1211
- }
1212
- const seconds = openAIVideoSeconds(input.duration);
1213
- if (seconds) {
1214
- form.set("seconds", seconds);
1215
- }
1216
- const referenceUrls = [
1217
- ...(input.frameImages ?? []).map((frame) => frame.imageUrl),
1218
- ...(input.inputReferences ?? [])
1219
- ].slice(0, 1);
1220
- for (const [index, url] of referenceUrls.entries()) {
1221
- const image = await remoteImageToOpenAIVideoBlob(url, size);
1222
- form.append("input_reference", image, openAIImageFilename(index, image.type));
1223
- }
1224
- const response = await fetch("https://api.openai.com/v1/videos", {
1225
- method: "POST",
1226
- headers: {
1227
- Authorization: `Bearer ${input.apiKey}`
1228
- },
1229
- body: form
1230
- });
1231
- if (response.status === 401 || response.status === 403) {
1232
- throw new ProviderAuthError("openai authentication failed");
1233
- }
1234
- if (response.status === 402 || response.status === 429) {
1235
- throw await rateLimitOrQuotaError("openai", response);
1236
- }
1237
- if (isTransientProviderStatus(response.status)) {
1238
- throw await transientProviderError("openai", "video generation", response);
1239
- }
1240
- if (!response.ok) {
1241
- const details = await response.text();
1242
- throw new Error(conciseProviderResponse("openai video generation", response, details));
1243
- }
1244
- const submitted = await response.json();
1245
- const videoId = readStringField(submitted, "id");
1246
- if (!videoId) {
1247
- throw new Error("openai video generation returned no video id");
1248
- }
1249
- return {
1250
- poll: () => this.pollOpenAIVideoGeneration({ videoId, submitted, apiKey: input.apiKey })
1251
- };
1252
- }
1253
- async pollOpenAIVideoGeneration(input) {
1254
- let latestStatus = input.submitted;
1255
- for (let attempt = 0; attempt < VIDEO_POLL_MAX_ATTEMPTS; attempt += 1) {
1256
- if (attempt > 0) {
1257
- await sleep(VIDEO_POLL_INTERVAL_MS);
1258
- }
1259
- const statusResponse = await fetch(`https://api.openai.com/v1/videos/${encodeURIComponent(input.videoId)}`, {
1260
- headers: {
1261
- Authorization: `Bearer ${input.apiKey}`
1262
- }
1263
- });
1264
- if (statusResponse.status === 401 || statusResponse.status === 403) {
1265
- throw new ProviderAuthError("openai authentication failed");
1266
- }
1267
- if (statusResponse.status === 402 || statusResponse.status === 429) {
1268
- throw await rateLimitOrQuotaError("openai", statusResponse);
1269
- }
1270
- if (isTransientProviderStatus(statusResponse.status)) {
1271
- const transient = await transientProviderError("openai", "video polling", statusResponse);
1272
- await sleep(transient.retryAfterSeconds * 1000);
1273
- continue;
1274
- }
1275
- if (!statusResponse.ok) {
1276
- const details = await statusResponse.text();
1277
- throw new Error(conciseProviderResponse("openai video polling", statusResponse, details));
1278
- }
1279
- latestStatus = await statusResponse.json();
1280
- const status = String(latestStatus.status ?? "").toLowerCase();
1281
- if (status === "failed" || status === "cancelled" || status === "canceled" || status === "expired") {
1282
- const details = typeof latestStatus.error === "string" ? `: ${latestStatus.error}` : "";
1283
- throw new Error(`openai video generation ${status}${details}`);
1284
- }
1285
- if (status === "completed") {
1286
- const video = await downloadAuthenticatedVideo({
1287
- url: `https://api.openai.com/v1/videos/${encodeURIComponent(input.videoId)}/content`,
1288
- apiKey: input.apiKey,
1289
- provider: "openai"
1290
- });
1291
- return {
1292
- ...video,
1293
- providerJob: latestStatus,
1294
- usageCostUsd: readOpenAIUsageCost(latestStatus)
1295
- };
1296
- }
1297
- }
1298
- throw new ProviderWaitExceededError("OpenAI video render exceeded the 12-minute in-process budget.");
1299
- }
1300
- async submitGeminiVideoGeneration(input) {
1301
- const instance = {
1302
- prompt: input.prompt
1303
- };
1304
- const firstFrameUrl = input.frameImages?.find((frame) => frame.frameType === "first_frame")?.imageUrl ??
1305
- input.inputReferences?.[0];
1306
- if (firstFrameUrl) {
1307
- const { bytes, mimeType } = await fetchRemoteBytes({ url: firstFrameUrl, fallbackMimeType: "image/png" });
1308
- instance.image = {
1309
- bytesBase64Encoded: bytes.toString("base64"),
1310
- mimeType
1311
- };
1312
- }
1313
- const parameters = {
1314
- sampleCount: 1
1315
- };
1316
- const aspectRatio = geminiVideoAspectRatio(input.aspectRatio);
1317
- if (aspectRatio) {
1318
- parameters.aspectRatio = aspectRatio;
1319
- }
1320
- const resolution = geminiVideoResolution(input.resolution);
1321
- if (resolution) {
1322
- parameters.resolution = resolution;
1323
- }
1324
- const durationSeconds = geminiVideoDurationSeconds(input.duration);
1325
- if (durationSeconds) {
1326
- parameters.durationSeconds = durationSeconds;
1327
- }
1328
- if (input.generateAudio !== undefined) {
1329
- parameters.generateAudio = input.generateAudio;
1330
- }
1331
- if (input.seed !== undefined) {
1332
- parameters.seed = input.seed;
1333
- }
1334
- if (input.providerOptions) {
1335
- Object.assign(parameters, input.providerOptions);
1336
- }
1337
- const baseUrl = "https://generativelanguage.googleapis.com/v1beta";
1338
- const response = await fetch(`${baseUrl}/models/${encodeURIComponent(input.model)}:predictLongRunning`, {
1339
- method: "POST",
1340
- headers: {
1341
- "Content-Type": "application/json",
1342
- "x-goog-api-key": input.apiKey
1343
- },
1344
- body: JSON.stringify({
1345
- instances: [instance],
1346
- parameters
1347
- })
1348
- });
1349
- if (response.status === 401 || response.status === 403) {
1350
- throw new ProviderAuthError("gemini authentication failed");
1351
- }
1352
- if (response.status === 402 || response.status === 429) {
1353
- throw await rateLimitOrQuotaError("gemini", response);
1354
- }
1355
- if (!response.ok) {
1356
- const details = await response.text();
1357
- throw new Error(`gemini video generation returned ${response.status}${details ? `: ${details}` : ""}`);
1358
- }
1359
- const submitted = await response.json();
1360
- const operationName = readStringField(submitted, "name");
1361
- if (!operationName) {
1362
- throw new Error("gemini video generation returned no operation name");
1363
- }
1364
- return {
1365
- poll: () => this.pollGeminiVideoGeneration({ baseUrl, operationName, submitted, apiKey: input.apiKey })
1366
- };
1367
- }
1368
- async pollGeminiVideoGeneration(input) {
1369
- let latestStatus = input.submitted;
1370
- for (let attempt = 0; attempt < VIDEO_POLL_MAX_ATTEMPTS; attempt += 1) {
1371
- if (attempt > 0) {
1372
- await sleep(VIDEO_POLL_INTERVAL_MS);
1373
- }
1374
- const statusResponse = await fetch(`${input.baseUrl}/${input.operationName}`, {
1375
- headers: {
1376
- "x-goog-api-key": input.apiKey
1377
- }
1378
- });
1379
- if (statusResponse.status === 401 || statusResponse.status === 403) {
1380
- throw new ProviderAuthError("gemini authentication failed");
1381
- }
1382
- if (statusResponse.status === 402 || statusResponse.status === 429) {
1383
- throw await rateLimitOrQuotaError("gemini", statusResponse);
1384
- }
1385
- if (!statusResponse.ok) {
1386
- const details = await statusResponse.text();
1387
- throw new Error(`gemini video polling returned ${statusResponse.status}${details ? `: ${details}` : ""}`);
1388
- }
1389
- latestStatus = await statusResponse.json();
1390
- if (latestStatus.error) {
1391
- throw new Error(`gemini video generation failed: ${JSON.stringify(latestStatus.error).slice(0, 500)}`);
1392
- }
1393
- if (latestStatus.done === true) {
1394
- const videoUrl = findGeminiVideoUri(latestStatus);
1395
- if (videoUrl) {
1396
- const video = await downloadGeminiVideo(videoUrl, input.apiKey);
1397
- return {
1398
- ...video,
1399
- providerJob: latestStatus,
1400
- usageCostUsd: 0
1401
- };
1402
- }
1403
- const inline = findGeminiInlineVideo(latestStatus);
1404
- if (inline) {
1405
- return {
1406
- bytes: inline.bytes,
1407
- contentType: inline.contentType,
1408
- providerJob: latestStatus,
1409
- usageCostUsd: 0
1410
- };
1411
- }
1412
- throw new Error("gemini video generation completed without a video URI or inline video bytes");
1413
- }
1414
- }
1415
- throw new ProviderWaitExceededError("Gemini video render exceeded the 12-minute in-process budget.");
1416
- }
1417
- async submitOpenRouterVideoGeneration(input) {
1418
- const requestBody = {
1419
- model: input.model,
1420
- prompt: input.prompt
1421
- };
1422
- if (input.aspectRatio) {
1423
- requestBody.aspect_ratio = input.aspectRatio;
1424
- }
1425
- if (input.duration !== undefined) {
1426
- requestBody.duration = input.duration;
1427
- }
1428
- if (input.resolution) {
1429
- requestBody.resolution = input.resolution;
1430
- }
1431
- if (input.size) {
1432
- requestBody.size = input.size;
1433
- }
1434
- if (input.generateAudio !== undefined) {
1435
- requestBody.generate_audio = input.generateAudio;
1436
- }
1437
- if (input.frameImages?.length) {
1438
- requestBody.frame_images = input.frameImages.map((frame) => ({
1439
- image_url: frame.imageUrl,
1440
- frame_type: frame.frameType
1441
- }));
1442
- }
1443
- if (input.inputReferences?.length) {
1444
- requestBody.input_references = input.inputReferences;
1445
- }
1446
- if (input.seed !== undefined) {
1447
- requestBody.seed = input.seed;
1448
- }
1449
- if (input.providerOptions && Object.keys(input.providerOptions).length > 0) {
1450
- requestBody.provider = input.providerOptions;
1451
- }
1452
- const response = await fetch("https://openrouter.ai/api/v1/videos", {
1453
- method: "POST",
1454
- headers: {
1455
- "Content-Type": "application/json",
1456
- Authorization: `Bearer ${input.apiKey}`
1457
- },
1458
- body: JSON.stringify(requestBody)
1459
- });
1460
- if (response.status === 401 || response.status === 403) {
1461
- throw new ProviderAuthError("openrouter authentication failed");
1462
- }
1463
- if (response.status === 402 || response.status === 429) {
1464
- throw await rateLimitOrQuotaError("openrouter", response);
1465
- }
1466
- if (!response.ok) {
1467
- const details = await response.text();
1468
- throw new Error(`openrouter video generation returned ${response.status}${details ? `: ${details}` : ""}`);
1469
- }
1470
- const submitted = await response.json();
1471
- const immediateVideoUrl = findOpenRouterVideoUrl(submitted);
1472
- if (immediateVideoUrl) {
1473
- return {
1474
- poll: async () => {
1475
- const video = await downloadOpenRouterVideo(immediateVideoUrl, input.apiKey);
1476
- return {
1477
- ...video,
1478
- providerJob: submitted,
1479
- usageCostUsd: readOpenRouterUsageCost(submitted)
1480
- };
1481
- }
1482
- };
1483
- }
1484
- const statusUrl = normalizeOpenRouterUrl(readStringField(submitted, "polling_url") ?? readStringField(submitted, "status_url")) ??
1485
- openRouterVideoStatusUrl(readStringField(submitted, "id"));
1486
- if (!statusUrl) {
1487
- throw new Error("openrouter video generation returned no polling URL or job id");
1488
- }
1489
- return {
1490
- poll: () => this.pollOpenRouterVideoGeneration({ statusUrl, submitted, apiKey: input.apiKey })
1491
- };
1492
- }
1493
- async pollOpenRouterVideoGeneration(input) {
1494
- let latestStatus = input.submitted;
1495
- for (let attempt = 0; attempt < VIDEO_POLL_MAX_ATTEMPTS; attempt += 1) {
1496
- if (attempt > 0) {
1497
- await sleep(VIDEO_POLL_INTERVAL_MS);
1498
- }
1499
- const statusResponse = await fetch(input.statusUrl, {
1500
- headers: {
1501
- Authorization: `Bearer ${input.apiKey}`
1502
- }
1503
- });
1504
- if (statusResponse.status === 401 || statusResponse.status === 403) {
1505
- throw new ProviderAuthError("openrouter authentication failed");
1506
- }
1507
- if (statusResponse.status === 402 || statusResponse.status === 429) {
1508
- throw await rateLimitOrQuotaError("openrouter", statusResponse);
1509
- }
1510
- if (!statusResponse.ok) {
1511
- const details = await statusResponse.text();
1512
- throw new Error(`openrouter video polling returned ${statusResponse.status}${details ? `: ${details}` : ""}`);
1513
- }
1514
- latestStatus = await statusResponse.json();
1515
- const status = String(latestStatus.status ?? "").toLowerCase();
1516
- if (status === "failed" || status === "cancelled" || status === "canceled") {
1517
- const details = typeof latestStatus.error === "string" ? `: ${latestStatus.error}` : "";
1518
- throw new Error(`openrouter video generation ${status}${details}`);
1519
- }
1520
- const videoUrl = findOpenRouterVideoUrl(latestStatus);
1521
- if (status === "completed" && videoUrl) {
1522
- const video = await downloadOpenRouterVideo(videoUrl, input.apiKey);
1523
- return {
1524
- ...video,
1525
- providerJob: latestStatus,
1526
- usageCostUsd: readOpenRouterUsageCost(latestStatus)
1527
- };
1528
- }
1529
- }
1530
- throw new ProviderWaitExceededError("OpenRouter video render exceeded the 12-minute in-process budget.");
1531
- }
1532
- async analyzeImageLayout(input) {
1533
- const attempts = await providerAttempts({
1534
- providerKeyService: this.serverlessProviderKeys,
1535
- customerId: input.customerId,
1536
- capability: "layout_analysis",
1537
- requestedProvider: input.provider,
1538
- requestedModel: input.model,
1539
- supportedModels: SUPPORTED_TEXT_MODELS,
1540
- defaultModelForProvider: (provider) => defaultModelFor("text", provider),
1541
- allowProvider: (provider) => provider !== "perplexity"
1542
- });
1543
- const retryDeadlineMs = providerLeaseRetryDeadlineMs();
1544
- for (;;) {
1545
- let lastError = null;
1546
- for (const attempt of attempts) {
1547
- try {
1548
- return await this.analyzeImageLayoutWithProvider({
1549
- ...input,
1550
- provider: attempt.provider,
1551
- model: attempt.model
1552
- });
1553
- }
1554
- catch (error) {
1555
- lastError = error;
1556
- if (!isRecoverableProviderAttemptError(error)) {
1557
- throw error;
1558
- }
1559
- }
1560
- }
1561
- if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1562
- continue;
1563
- }
1564
- throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI layout-analysis provider key became available in time.");
1565
- }
1566
- }
1567
- async analyzeImageLayoutWithProvider(input) {
1568
- assertSupportedModel("layout_analysis", input.provider, input.model, SUPPORTED_TEXT_MODELS);
1569
- const lease = await this.leaseCustomerKey({
1570
- customerId: input.customerId,
1571
- provider: input.provider,
1572
- jobId: input.jobId,
1573
- workerId: input.workerId
1574
- });
1575
- try {
1576
- if (config.mockProviders) {
1577
- await this.serverlessProviderKeys.recordProviderKeyUsage({
1578
- id: createId("usage"),
1579
- keyId: lease.keyId,
1580
- jobId: input.jobId,
1581
- provider: input.provider,
1582
- model: input.model,
1583
- eventType: "success",
1584
- inputTokens: 0,
1585
- outputTokens: 0,
1586
- metadata: { mock: true, type: "layout_analysis" }
1587
- });
1588
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1589
- return {
1590
- zone: "bottom",
1591
- align: "center",
1592
- maxWidthPercent: 82,
1593
- justification: "Mock mode defaults to bottom-center for readable slideshow captions."
1594
- };
1595
- }
1596
- const raw = await this.withLeaseRenewal(lease, () => (input.provider === "gemini"
1597
- ? this.callGeminiLayoutAnalysis({
1598
- model: input.model,
1599
- imageUrl: input.imageUrl,
1600
- overlayText: input.overlayText,
1601
- apiKey: lease.secret
1602
- })
1603
- : this.callOpenAICompatibleLayoutAnalysis({
1604
- provider: input.provider === "openrouter" ? "openrouter" : "openai",
1605
- model: input.model,
1606
- imageUrl: input.imageUrl,
1607
- overlayText: input.overlayText,
1608
- apiKey: lease.secret
1609
- })));
1610
- const parsed = parseJson(raw, null);
1611
- if (!parsed?.zone || !parsed?.align) {
1612
- throw new Error(`${input.provider} layout analysis returned invalid JSON`);
1613
- }
1614
- await this.serverlessProviderKeys.recordProviderKeyUsage({
1615
- id: createId("usage"),
1616
- keyId: lease.keyId,
1617
- jobId: input.jobId,
1618
- provider: input.provider,
1619
- model: input.model,
1620
- eventType: "success",
1621
- inputTokens: 0,
1622
- outputTokens: 0,
1623
- metadata: { type: "layout_analysis" }
1624
- });
1625
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1626
- return {
1627
- zone: parsed.zone,
1628
- align: parsed.align,
1629
- maxWidthPercent: clamp(Number(parsed.maxWidthPercent ?? 76), 52, 88),
1630
- justification: parsed.justification ?? "Selected to preserve legibility and subject framing."
1631
- };
1632
- }
1633
- catch (error) {
1634
- return await this.handleLeasedProviderError({
1635
- error,
1636
- lease,
1637
- jobId: input.jobId,
1638
- provider: input.provider,
1639
- model: input.model,
1640
- metadata: { type: "layout_analysis" }
1641
- });
1642
- }
1643
- finally {
1644
- await this.releaseLease(lease);
1645
- }
1646
- }
1647
- async transcribeSpeech(input) {
1648
- const plan = await this.resolveAudioAuthPlan({
1649
- customerId: input.customerId,
1650
- useWalletCredits: input.useWalletCredits,
1651
- byokFallback: true,
1652
- capability: "stt"
1653
- });
1654
- if (plan.mode === "elevenlabs") {
1655
- const model = /^scribe/i.test(input.model || "") ? input.model : DEFAULT_ELEVENLABS_STT_MODEL;
1656
- const transcription = await elevenLabsSpeechToText({
1657
- apiKey: plan.apiKey,
1658
- audio: input.audio,
1659
- contentType: input.contentType,
1660
- model,
1661
- diarize: input.diarize,
1662
- languageCode: input.language
1663
- });
1664
- const minutes = Math.max(0, transcription.audioDurationSec ?? 0) / 60;
1665
- const billing = plan.walletBilled
1666
- ? {
1667
- costUsd: Number((minutes * config.ELEVENLABS_STT_USD_PER_MIN).toFixed(4)),
1668
- costCenterSlug: "elevenlabs_stt",
1669
- metadata: {
1670
- operation: "stt",
1671
- provider: "elevenlabs",
1672
- model,
1673
- audio_duration_sec: transcription.audioDurationSec,
1674
- wallet_billed: true,
1675
- scope: plan.scope
1676
- }
1677
- }
1678
- : null;
1679
- const { audioDurationSec, ...rest } = transcription;
1680
- void audioDurationSec;
1681
- return { ...rest, effectiveProvider: "elevenlabs", effectiveModel: model, billing };
1682
- }
1683
- const byok = await this.transcribeSpeechByok(input);
1684
- return { ...byok.result, effectiveProvider: byok.provider, effectiveModel: byok.model, billing: null };
1685
- }
1686
- async transcribeSpeechByok(input) {
1687
- const attempts = await providerAttempts({
1688
- providerKeyService: this.serverlessProviderKeys,
1689
- customerId: input.customerId,
1690
- capability: "stt",
1691
- requestedProvider: input.provider,
1692
- requestedModel: input.model,
1693
- supportedModels: SUPPORTED_STT_MODELS,
1694
- defaultModelForProvider: (provider) => defaultSpeechModelFor("stt", provider)
1695
- });
1696
- const retryDeadlineMs = providerLeaseRetryDeadlineMs();
1697
- for (;;) {
1698
- let lastError = null;
1699
- for (const attempt of attempts) {
1700
- try {
1701
- const result = await this.transcribeSpeechWithProvider({
1702
- ...input,
1703
- provider: attempt.provider,
1704
- model: attempt.model
1705
- });
1706
- return { result, provider: attempt.provider, model: attempt.model };
1707
- }
1708
- catch (error) {
1709
- lastError = error;
1710
- if (!isRecoverableProviderAttemptError(error)) {
1711
- throw error;
1712
- }
1713
- }
1714
- }
1715
- if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1716
- continue;
1717
- }
1718
- throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI speech-transcription provider key became available in time.");
1719
- }
1720
- }
1721
- async transcribeSpeechWithProvider(input) {
1722
- assertSupportedSpeechModel("stt", input.provider, input.model);
1723
- const lease = await this.leaseCustomerKey({
1724
- customerId: input.customerId,
1725
- provider: input.provider,
1726
- jobId: input.jobId,
1727
- workerId: input.workerId
1728
- });
1729
- try {
1730
- if (config.mockProviders) {
1731
- await this.serverlessProviderKeys.recordProviderKeyUsage({
1732
- id: createId("usage"),
1733
- keyId: lease.keyId,
1734
- jobId: input.jobId,
1735
- provider: input.provider,
1736
- model: input.model,
1737
- eventType: "success",
1738
- inputTokens: 0,
1739
- outputTokens: 0,
1740
- metadata: { mock: true, type: "speech_transcription" }
1741
- });
1742
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1743
- return {
1744
- text: "Mock transcription output.",
1745
- language: input.language?.trim() || null,
1746
- segments: input.diarize
1747
- ? [
1748
- { speaker: "speaker_1", start_sec: 0, end_sec: 2, text: "Mock transcription output." },
1749
- { speaker: "speaker_2", start_sec: 2, end_sec: 4, text: "Mock second narrator." }
1750
- ]
1751
- : [{ speaker: "speaker_1", start_sec: null, end_sec: null, text: "Mock transcription output." }],
1752
- diarization: input.diarize ? "diarized" : "none",
1753
- usage: {
1754
- inputTokens: 0,
1755
- outputTokens: 0,
1756
- costUsd: 0
1757
- }
1758
- };
1759
- }
1760
- const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => transcribeSpeechWithKey({
1761
- provider: input.provider,
1762
- model: input.model,
1763
- audio: input.audio,
1764
- contentType: input.contentType,
1765
- prompt: input.prompt,
1766
- language: input.language,
1767
- diarize: input.diarize,
1768
- wordTimestamps: input.wordTimestamps,
1769
- apiKey: lease.secret
1770
- })));
1771
- await this.serverlessProviderKeys.recordProviderKeyUsage({
1772
- id: createId("usage"),
1773
- keyId: lease.keyId,
1774
- jobId: input.jobId,
1775
- provider: input.provider,
1776
- model: input.model,
1777
- eventType: "success",
1778
- inputTokens: result.usage.inputTokens,
1779
- outputTokens: result.usage.outputTokens,
1780
- metadata: { type: "speech_transcription" }
1781
- });
1782
- await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1783
- return result;
1784
- }
1785
- catch (error) {
1786
- return await this.handleLeasedProviderError({
1787
- error,
1788
- lease,
1789
- jobId: input.jobId,
1790
- provider: input.provider,
1791
- model: input.model,
1792
- metadata: { type: "speech_transcription" }
1793
- });
1794
- }
1795
- finally {
1796
- await this.releaseLease(lease);
1797
- }
1798
- }
1799
- async callOpenAIImageGeneration(input) {
1800
- const prompt = input.aspectRatio === "9:16"
1801
- ? [
1802
- input.prompt,
1803
- "Return a native 9:16 vertical composition with no letterboxing, no inset landscape window, and no empty top or bottom padding."
1804
- ].join("\n")
1805
- : input.prompt;
1806
- if (input.promptAttachments?.length) {
1807
- return this.callOpenAIImageEditRequest({
1808
- model: input.model,
1809
- prompt,
1810
- imageUrls: input.promptAttachments,
1811
- size: input.size ?? "1024x1536",
1812
- apiKey: input.apiKey
1813
- });
1814
- }
1815
- const response = await fetch("https://api.openai.com/v1/images/generations", {
1816
- method: "POST",
1817
- headers: {
1818
- "Content-Type": "application/json",
1819
- Authorization: `Bearer ${input.apiKey}`
1820
- },
1821
- body: JSON.stringify({
1822
- model: input.model,
1823
- prompt,
1824
- size: input.size ?? "1024x1536"
1825
- })
1826
- });
1827
- if (response.status === 401) {
1828
- throw new ProviderAuthError("openai authentication failed");
1829
- }
1830
- if (response.status === 429) {
1831
- throw await rateLimitOrQuotaError("openai", response);
1832
- }
1833
- if (!response.ok) {
1834
- const details = await response.text();
1835
- throw new Error(`openai image generation returned ${response.status}${details ? `: ${details}` : ""}`);
1836
- }
1837
- const data = await response.json();
1838
- const encoded = data.data?.[0]?.b64_json;
1839
- if (!encoded) {
1840
- throw new Error("openai image generation returned no image payload");
1841
- }
1842
- return {
1843
- bytes: Buffer.from(encoded, "base64"),
1844
- contentType: "image/png",
1845
- revisedPrompt: data.data?.[0]?.revised_prompt ?? null
1846
- };
1847
- }
1848
- async callOpenAIImageEdit(input) {
1849
- return this.callOpenAIImageEditRequest({
1850
- model: input.model,
1851
- prompt: input.instruction,
1852
- imageUrls: [input.sourceImageUrl, ...(input.referenceAttachments ?? [])],
1853
- maskUrl: input.maskUrl,
1854
- size: input.size ?? "auto",
1855
- apiKey: input.apiKey
1856
- });
1857
- }
1858
- async callOpenAIImageEditRequest(input) {
1859
- if (input.imageUrls.length > 16) {
1860
- throw new Error("OpenAI image edits support at most 16 input images.");
1861
- }
1862
- const form = new FormData();
1863
- form.set("model", input.model);
1864
- form.set("prompt", input.prompt);
1865
- form.set("size", input.size);
1866
- const imageFieldName = input.imageUrls.length > 1 ? "image[]" : "image";
1867
- for (let index = 0; index < input.imageUrls.length; index += 1) {
1868
- const image = await remoteImageToBlob(input.imageUrls[index]);
1869
- form.append(imageFieldName, image, openAIImageFilename(index, image.type));
1870
- }
1871
- if (input.maskUrl) {
1872
- form.set("mask", await remoteImageToBlob(input.maskUrl));
1873
- }
1874
- const response = await fetch("https://api.openai.com/v1/images/edits", {
1875
- method: "POST",
1876
- headers: {
1877
- Authorization: `Bearer ${input.apiKey}`
1878
- },
1879
- body: form
1880
- });
1881
- if (response.status === 401) {
1882
- throw new ProviderAuthError("openai authentication failed");
1883
- }
1884
- if (response.status === 429) {
1885
- throw await rateLimitOrQuotaError("openai", response);
1886
- }
1887
- if (!response.ok) {
1888
- const details = await response.text();
1889
- throw new Error(`openai image edit returned ${response.status}${details ? `: ${details}` : ""}`);
1890
- }
1891
- const data = await response.json();
1892
- const encoded = data.data?.[0]?.b64_json;
1893
- if (!encoded) {
1894
- throw new Error("openai image edit returned no image payload");
1895
- }
1896
- return {
1897
- bytes: Buffer.from(encoded, "base64"),
1898
- contentType: "image/png",
1899
- revisedPrompt: data.data?.[0]?.revised_prompt ?? null
1900
- };
1901
- }
1902
- async callAttachmentDrivenImageEdit(input) {
1903
- const promptAttachments = [
1904
- input.sourceImageUrl,
1905
- ...(input.maskUrl ? [input.maskUrl] : []),
1906
- ...(input.referenceAttachments ?? [])
1907
- ];
1908
- const prompt = [
1909
- input.maskUrl
1910
- ? "Edit this image using the provided source image and mask."
1911
- : "Edit this image using the provided source image as the base reference.",
1912
- input.maskUrl
1913
- ? "Only change the masked region. Preserve all unmasked regions exactly."
1914
- : "Preserve the overall composition unless the instruction explicitly changes it.",
1915
- `Instruction: ${input.instruction}`,
1916
- "Return one final edited image only."
1917
- ].join("\n");
1918
- return input.provider === "gemini"
1919
- ? this.callGeminiImageGeneration({
1920
- model: input.model,
1921
- prompt,
1922
- promptAttachments,
1923
- aspectRatio: input.aspectRatio,
1924
- imageSize: input.imageSize,
1925
- apiKey: input.apiKey
1926
- })
1927
- : this.callOpenRouterImageGeneration({
1928
- model: input.model,
1929
- prompt,
1930
- promptAttachments,
1931
- aspectRatio: input.aspectRatio,
1932
- imageSize: input.imageSize,
1933
- apiKey: input.apiKey
1934
- });
1935
- }
1936
- async callGeminiImageGeneration(input) {
1937
- const imageConfig = {};
1938
- if (input.aspectRatio) {
1939
- imageConfig.aspectRatio = input.aspectRatio;
1940
- }
1941
- if (input.imageSize && supportsGeminiImageSize(input.model)) {
1942
- imageConfig.imageSize = input.imageSize;
1943
- }
1944
- const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
1945
- method: "POST",
1946
- headers: {
1947
- "Content-Type": "application/json"
1948
- },
1949
- body: JSON.stringify({
1950
- contents: [{ parts: await buildGeminiPromptParts(input.prompt, input.promptAttachments ?? []) }],
1951
- generationConfig: {
1952
- responseModalities: ["Image"],
1953
- ...(Object.keys(imageConfig).length
1954
- ? {
1955
- imageConfig
1956
- }
1957
- : {})
1958
- }
1959
- })
1960
- });
1961
- if (response.status === 401 || response.status === 403) {
1962
- throw new ProviderAuthError("gemini authentication failed");
1963
- }
1964
- if (response.status === 429) {
1965
- throw await rateLimitOrQuotaError("gemini", response);
1966
- }
1967
- if (!response.ok) {
1968
- const details = await response.text();
1969
- throw new Error(`gemini image generation returned ${response.status}${details ? `: ${details}` : ""}`);
1970
- }
1971
- const data = await response.json();
1972
- const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data);
1973
- const encoded = part?.inlineData?.data;
1974
- if (!encoded) {
1975
- throw new Error("gemini image generation returned no image payload");
1976
- }
1977
- return {
1978
- bytes: Buffer.from(encoded, "base64"),
1979
- contentType: part.inlineData?.mimeType ?? "image/png",
1980
- revisedPrompt: null
1981
- };
1982
- }
1983
- async callOpenRouterImageGeneration(input) {
1984
- const requestBody = {
1985
- model: input.model,
1986
- prompt: input.prompt
1987
- };
1988
- if (input.promptAttachments?.length) {
1989
- requestBody.input_references = input.promptAttachments;
1990
- }
1991
- if (input.aspectRatio) {
1992
- requestBody.aspect_ratio = input.aspectRatio;
1993
- }
1994
- if (input.imageSize) {
1995
- requestBody.image_size = input.imageSize;
1996
- }
1997
- const response = await fetch("https://openrouter.ai/api/v1/images", {
1998
- method: "POST",
1999
- headers: {
2000
- "Content-Type": "application/json",
2001
- Authorization: `Bearer ${input.apiKey}`
2002
- },
2003
- body: JSON.stringify(requestBody)
2004
- });
2005
- if (response.status === 401 || response.status === 403) {
2006
- throw new ProviderAuthError("openrouter authentication failed");
2007
- }
2008
- if (response.status === 429) {
2009
- throw await rateLimitOrQuotaError("openrouter", response);
2010
- }
2011
- if (!response.ok) {
2012
- const details = await response.text();
2013
- throw new Error(`openrouter image generation returned ${response.status}${details ? `: ${details}` : ""}`);
2014
- }
2015
- const data = await response.json();
2016
- const image = await parseOpenRouterImageGenerationPayload(data);
2017
- if (!image) {
2018
- const details = conciseOpenRouterImageResponse(data);
2019
- throw new Error(`openrouter image generation returned no image payload${details ? `: ${details}` : ""}`);
2020
- }
2021
- return image;
2022
- }
2023
- async callOpenAICompatibleLayoutAnalysis(input) {
2024
- const endpoint = input.provider === "openrouter"
2025
- ? "https://openrouter.ai/api/v1/chat/completions"
2026
- : "https://api.openai.com/v1/chat/completions";
2027
- const response = await fetch(endpoint, {
2028
- method: "POST",
2029
- headers: {
2030
- "Content-Type": "application/json",
2031
- Authorization: `Bearer ${input.apiKey}`
2032
- },
2033
- body: JSON.stringify({
2034
- model: input.model,
2035
- temperature: 0.1,
2036
- response_format: { type: "json_object" },
2037
- messages: [
2038
- {
2039
- role: "system",
2040
- content: "Analyze a 9:16 slideshow image and return JSON with zone, align, maxWidthPercent, justification. Prefer centered text placement in the middle third of the frame unless that would cover the subject. Avoid covering the subject, avoid fake caption panels, and keep the caption inside a TikTok-safe region that avoids the top tabs, right-side action rail, and bottom caption/audio UI."
2041
- },
2042
- {
2043
- role: "user",
2044
- content: [
2045
- {
2046
- type: "text",
2047
- text: `The exact overlay text is: ${input.overlayText}\nChoose zone from top, center, bottom. Choose align from left, center, right. Prefer center when viable. maxWidthPercent must be between 46 and 62.\nTreat the top 12 percent, right 22 percent, and bottom 20 percent as reserved TikTok UI chrome.\nThe visual goal is native TikTok caption placement, not poster typography.`
2048
- },
2049
- {
2050
- type: "image_url",
2051
- image_url: {
2052
- url: input.imageUrl
2053
- }
2054
- }
2055
- ]
2056
- }
2057
- ]
2058
- })
2059
- });
2060
- if (response.status === 401) {
2061
- throw new ProviderAuthError(`${input.provider} authentication failed`);
2062
- }
2063
- if (response.status === 429) {
2064
- throw await rateLimitOrQuotaError(input.provider, response);
2065
- }
2066
- if (!response.ok) {
2067
- throw new Error(`${input.provider} layout analysis returned ${response.status}`);
2068
- }
2069
- const data = await response.json();
2070
- return String(data.choices?.[0]?.message?.content ?? "");
2071
- }
2072
- async callGeminiLayoutAnalysis(input) {
2073
- const image = await fetch(input.imageUrl);
2074
- if (!image.ok) {
2075
- throw new Error(`could not fetch image for gemini layout analysis: ${image.status}`);
2076
- }
2077
- const bytes = Buffer.from(await image.arrayBuffer());
2078
- const mimeType = image.headers.get("content-type") || "image/png";
2079
- const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
2080
- method: "POST",
2081
- headers: {
2082
- "Content-Type": "application/json"
2083
- },
2084
- body: JSON.stringify({
2085
- contents: [{
2086
- parts: [
2087
- {
2088
- text: [
2089
- "Analyze this 9:16 slideshow image and return JSON with keys zone, align, maxWidthPercent, justification.",
2090
- "zone must be one of top, center, bottom.",
2091
- "align must be one of left, center, right.",
2092
- "Prefer centered text placement in the middle third unless that would cover the subject.",
2093
- "maxWidthPercent must be between 46 and 62.",
2094
- "Prefer negative space and avoid covering the subject.",
2095
- "Treat the top 12 percent, right 22 percent, and bottom 20 percent as reserved TikTok UI chrome.",
2096
- "The visual goal is native TikTok caption placement, not poster typography or large title cards.",
2097
- `Exact overlay text: ${input.overlayText}`
2098
- ].join("\n")
2099
- },
2100
- {
2101
- inline_data: {
2102
- mime_type: mimeType,
2103
- data: bytes.toString("base64")
2104
- }
2105
- }
2106
- ]
2107
- }],
2108
- generationConfig: {
2109
- responseMimeType: "application/json"
2110
- }
2111
- })
2112
- });
2113
- if (response.status === 401 || response.status === 403) {
2114
- throw new ProviderAuthError("gemini authentication failed");
2115
- }
2116
- if (response.status === 429) {
2117
- throw await rateLimitOrQuotaError("gemini", response);
2118
- }
2119
- if (!response.ok) {
2120
- throw new Error(`gemini layout analysis returned ${response.status}`);
2121
- }
2122
- const data = await response.json();
2123
- const text = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "";
2124
- return String(text);
2125
- }
2126
- async callProvider(input) {
2127
- if (input.provider === "gemini") {
2128
- return this.callGemini({
2129
- provider: "gemini",
2130
- model: input.model,
2131
- prompt: input.prompt,
2132
- temperature: input.temperature,
2133
- responseFormat: input.responseFormat,
2134
- attachments: input.attachments,
2135
- apiKey: input.apiKey
2136
- });
2137
- }
2138
- return this.callOpenAICompatible(input);
2139
- }
2140
- async callOpenAICompatible(input) {
2141
- const endpoint = input.provider === "openrouter"
2142
- ? "https://openrouter.ai/api/v1/chat/completions"
2143
- : input.provider === "perplexity"
2144
- ? "https://api.perplexity.ai/chat/completions"
2145
- : "https://api.openai.com/v1/chat/completions";
2146
- const response = await fetch(endpoint, {
2147
- method: "POST",
2148
- headers: {
2149
- "Content-Type": "application/json",
2150
- Authorization: `Bearer ${input.apiKey}`
2151
- },
2152
- body: JSON.stringify({
2153
- model: input.model,
2154
- temperature: input.temperature ?? 0.7,
2155
- ...(input.responseFormat === "json" ? { response_format: { type: "json_object" } } : {}),
2156
- messages: [{
2157
- role: "user",
2158
- content: input.attachments && input.attachments.length
2159
- ? [
2160
- { type: "text", text: input.prompt },
2161
- ...input.attachments.map((url) => ({ type: "image_url", image_url: { url } }))
2162
- ]
2163
- : input.prompt
2164
- }]
2165
- })
2166
- });
2167
- if (response.status === 401) {
2168
- throw new ProviderAuthError(`${input.provider} authentication failed`);
2169
- }
2170
- if (response.status === 429) {
2171
- throw await rateLimitOrQuotaError(input.provider, response);
2172
- }
2173
- if (!response.ok) {
2174
- throw new Error(`${input.provider} returned ${response.status}`);
2175
- }
2176
- const data = await response.json();
2177
- return {
2178
- text: data.choices?.[0]?.message?.content ?? "",
2179
- usage: {
2180
- inputTokens: Number(data.usage?.prompt_tokens ?? 0),
2181
- outputTokens: Number(data.usage?.completion_tokens ?? 0),
2182
- costUsd: 0
2183
- }
2184
- };
2185
- }
2186
- async callGemini(input) {
2187
- const parts = input.attachments && input.attachments.length
2188
- ? await buildGeminiPromptParts(input.prompt, input.attachments)
2189
- : [{ text: input.prompt }];
2190
- const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
2191
- method: "POST",
2192
- headers: {
2193
- "Content-Type": "application/json"
2194
- },
2195
- body: JSON.stringify({
2196
- contents: [{ parts }],
2197
- generationConfig: {
2198
- temperature: input.temperature ?? 0.7,
2199
- ...(input.responseFormat === "json" ? { responseMimeType: "application/json" } : {})
2200
- }
2201
- })
2202
- });
2203
- if (response.status === 401 || response.status === 403) {
2204
- throw new ProviderAuthError("Gemini authentication failed");
2205
- }
2206
- if (response.status === 429) {
2207
- throw await rateLimitOrQuotaError("gemini", response);
2208
- }
2209
- if (!response.ok) {
2210
- throw new Error(`Gemini returned ${response.status}`);
2211
- }
2212
- const data = await response.json();
2213
- return {
2214
- text: data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "",
2215
- usage: {
2216
- inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
2217
- outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
2218
- costUsd: 0
2219
- }
2220
- };
2221
- }
2222
- }
2223
- function clamp(value, min, max) {
2224
- return Math.max(min, Math.min(max, value));
2225
- }
2226
- function summarizeProviderValidationError(error) {
2227
- const rawMessage = error instanceof Error ? error.message : "Unknown provider error";
2228
- return rawMessage
2229
- .replace(/\s+/g, " ")
2230
- .trim()
2231
- .slice(0, 280);
2232
- }
2233
- function toProviderValidationMessage(provider, error) {
2234
- const providerLabel = formatProviderLabel(provider);
2235
- const summary = summarizeProviderValidationError(error);
2236
- if (!summary) {
2237
- return `Unable to verify ${providerLabel} API key.`;
2238
- }
2239
- if (/401|403|invalid api key|api key not valid|incorrect api key|unauthorized|permission denied|authentication failed/i.test(summary)) {
2240
- return `${providerLabel} API key was rejected by the provider. ${summary}`;
2241
- }
2242
- return `Unable to verify ${providerLabel} API key. ${summary}`;
2243
- }
2244
- function formatProviderLabel(provider) {
2245
- switch (provider) {
2246
- case "openai":
2247
- return "OpenAI";
2248
- case "gemini":
2249
- return "Gemini";
2250
- case "openrouter":
2251
- return "OpenRouter";
2252
- case "perplexity":
2253
- return "Perplexity";
2254
- case "elevenlabs":
2255
- return "ElevenLabs";
2256
- default:
2257
- return provider;
2258
- }
2259
- }
2260
- function readStringField(value, field) {
2261
- const raw = value[field];
2262
- return typeof raw === "string" && raw.trim() ? raw.trim() : null;
2263
- }
2264
- function normalizeOpenRouterUrl(value) {
2265
- if (!value) {
2266
- return null;
2267
- }
2268
- if (/^https?:\/\//i.test(value)) {
2269
- return value;
2270
- }
2271
- if (value.startsWith("/")) {
2272
- return `https://openrouter.ai${value}`;
2273
- }
2274
- return `https://openrouter.ai/${value}`;
2275
- }
2276
- function openRouterVideoStatusUrl(jobId) {
2277
- return jobId ? `https://openrouter.ai/api/v1/videos/${encodeURIComponent(jobId)}` : null;
2278
- }
2279
- function findOpenRouterVideoUrl(value) {
2280
- if (!value || typeof value !== "object") {
2281
- return null;
2282
- }
2283
- if (Array.isArray(value)) {
2284
- for (const item of value) {
2285
- const found = typeof item === "string" ? item : findOpenRouterVideoUrl(item);
2286
- if (found && isOpenRouterVideoContentUrl(found)) {
2287
- return normalizeOpenRouterUrl(found);
2288
- }
2289
- }
2290
- return null;
2291
- }
2292
- const record = value;
2293
- const unsignedUrls = record.unsigned_urls;
2294
- if (Array.isArray(unsignedUrls)) {
2295
- const found = unsignedUrls.find((item) => typeof item === "string" && item.trim().length > 0);
2296
- if (found) {
2297
- return normalizeOpenRouterUrl(found);
2298
- }
2299
- }
2300
- for (const key of ["video_url", "output_url", "download_url", "file_url", "content_url"]) {
2301
- const raw = record[key];
2302
- if (typeof raw === "string" && raw.trim()) {
2303
- return normalizeOpenRouterUrl(raw.trim());
2304
- }
2305
- }
2306
- return null;
2307
- }
2308
- function isOpenRouterVideoContentUrl(value) {
2309
- return /^https?:\/\//i.test(value) || value.startsWith("/");
2310
- }
2311
- function readOpenRouterUsageCost(value) {
2312
- const usage = value.usage;
2313
- if (usage && typeof usage === "object") {
2314
- const cost = Number(usage.cost ?? usage.total_cost);
2315
- if (Number.isFinite(cost) && cost > 0) {
2316
- return cost;
2317
- }
2318
- }
2319
- const cost = Number(value.cost ?? value.total_cost);
2320
- return Number.isFinite(cost) && cost > 0 ? cost : 0;
2321
- }
2322
- function readOpenAIUsageCost(value) {
2323
- const usage = value.usage;
2324
- if (usage && typeof usage === "object") {
2325
- const cost = Number(usage.cost ?? usage.total_cost);
2326
- if (Number.isFinite(cost) && cost > 0) {
2327
- return cost;
2328
- }
2329
- }
2330
- return 0;
2331
- }
2332
- function normalizeOpenAIVideoSize(model, size, aspectRatio, resolution) {
2333
- const requested = size ?? openAIVideoSizeForAspect(aspectRatio, resolution);
2334
- if (!requested) {
2335
- return undefined;
2336
- }
2337
- const parsed = parsePixelSize(requested);
2338
- const requestedAspect = parsed
2339
- ? (parsed.width >= parsed.height ? "16:9" : "9:16")
2340
- : aspectRatio;
2341
- const isPortrait = requestedAspect !== "16:9";
2342
- if (model === "sora-2-pro") {
2343
- if (requested === "1024x1792" || requested === "1792x1024" || requested === "720x1280" || requested === "1280x720") {
2344
- return requested;
2345
- }
2346
- return resolution === "1080p"
2347
- ? isPortrait ? "1024x1792" : "1792x1024"
2348
- : isPortrait ? "720x1280" : "1280x720";
2349
- }
2350
- if (model === "sora-2") {
2351
- return isPortrait ? "720x1280" : "1280x720";
2352
- }
2353
- return requested;
2354
- }
2355
- function openAIVideoSizeForAspect(aspectRatio, resolution) {
2356
- if (resolution === "1080p") {
2357
- if (aspectRatio === "9:16")
2358
- return "1024x1792";
2359
- if (aspectRatio === "16:9")
2360
- return "1792x1024";
2361
- }
2362
- if (aspectRatio === "9:16") {
2363
- return "720x1280";
2364
- }
2365
- if (aspectRatio === "16:9") {
2366
- return "1280x720";
2367
- }
2368
- return undefined;
2369
- }
2370
- function openAIVideoSeconds(duration) {
2371
- if (!duration) {
2372
- return undefined;
2373
- }
2374
- if (duration >= 10) {
2375
- return "12";
2376
- }
2377
- if (duration >= 6) {
2378
- return "8";
2379
- }
2380
- return "4";
2381
- }
2382
- function geminiVideoAspectRatio(aspectRatio) {
2383
- return aspectRatio === "9:16" ? "9:16" : aspectRatio === "16:9" ? "16:9" : undefined;
2384
- }
2385
- function geminiVideoResolution(resolution) {
2386
- return resolution === "1080p" ? "1080p" : resolution === "720p" ? "720p" : undefined;
2387
- }
2388
- function geminiVideoDurationSeconds(duration) {
2389
- if (!duration) {
2390
- return undefined;
2391
- }
2392
- if (duration >= 7) {
2393
- return 8;
2394
- }
2395
- if (duration >= 5) {
2396
- return 6;
2397
- }
2398
- return 4;
2399
- }
2400
- function findGeminiVideoUri(value) {
2401
- if (!value || typeof value !== "object") {
2402
- return null;
2403
- }
2404
- if (Array.isArray(value)) {
2405
- for (const item of value) {
2406
- const found = findGeminiVideoUri(item);
2407
- if (found) {
2408
- return found;
2409
- }
2410
- }
2411
- return null;
2412
- }
2413
- const record = value;
2414
- for (const key of ["uri", "videoUri", "fileUri", "downloadUri"]) {
2415
- const raw = record[key];
2416
- if (typeof raw === "string" && /^https?:\/\//i.test(raw)) {
2417
- return raw;
2418
- }
2419
- }
2420
- for (const nested of Object.values(record)) {
2421
- const found = findGeminiVideoUri(nested);
2422
- if (found) {
2423
- return found;
2424
- }
2425
- }
2426
- return null;
2427
- }
2428
- function findGeminiInlineVideo(value) {
2429
- if (!value || typeof value !== "object") {
2430
- return null;
2431
- }
2432
- if (Array.isArray(value)) {
2433
- for (const item of value) {
2434
- const found = findGeminiInlineVideo(item);
2435
- if (found) {
2436
- return found;
2437
- }
2438
- }
2439
- return null;
2440
- }
2441
- const record = value;
2442
- const encoded = typeof record.videoBytes === "string" ? record.videoBytes :
2443
- typeof record.bytesBase64Encoded === "string" ? record.bytesBase64Encoded :
2444
- typeof record.data === "string" ? record.data :
2445
- null;
2446
- if (encoded) {
2447
- return {
2448
- bytes: Buffer.from(encoded, "base64"),
2449
- contentType: typeof record.mimeType === "string" ? record.mimeType : "video/mp4"
2450
- };
2451
- }
2452
- for (const nested of Object.values(record)) {
2453
- const found = findGeminiInlineVideo(nested);
2454
- if (found) {
2455
- return found;
2456
- }
2457
- }
2458
- return null;
2459
- }
2460
- async function downloadAuthenticatedVideo(input) {
2461
- const headers = input.provider === "openai"
2462
- ? { Authorization: `Bearer ${input.apiKey}` }
2463
- : { "x-goog-api-key": input.apiKey };
2464
- const response = await fetch(input.url, { headers });
2465
- if (response.status === 401 || response.status === 403) {
2466
- throw new ProviderAuthError(`${input.provider} authentication failed`);
2467
- }
2468
- if (response.status === 402 || response.status === 429) {
2469
- throw await rateLimitOrQuotaError(input.provider, response);
2470
- }
2471
- if (!response.ok) {
2472
- const details = await response.text();
2473
- throw new Error(`${input.provider} video download returned ${response.status}${details ? `: ${details}` : ""}`);
2474
- }
2475
- return {
2476
- bytes: Buffer.from(await response.arrayBuffer()),
2477
- contentType: response.headers.get("content-type")?.split(";")[0]?.trim() || inferVideoContentType(input.url)
2478
- };
2479
- }
2480
- async function downloadGeminiVideo(url, apiKey) {
2481
- return downloadAuthenticatedVideo({
2482
- url,
2483
- apiKey,
2484
- provider: "gemini"
2485
- });
2486
- }
2487
- async function downloadOpenRouterVideo(url, apiKey) {
2488
- const includeAuth = /^https:\/\/openrouter\.ai\//i.test(url);
2489
- const response = await fetch(url, includeAuth
2490
- ? {
2491
- headers: {
2492
- Authorization: `Bearer ${apiKey}`
2493
- }
2494
- }
2495
- : {});
2496
- if (response.status === 401 || response.status === 403) {
2497
- throw new ProviderAuthError("openrouter authentication failed");
2498
- }
2499
- if (response.status === 402 || response.status === 429) {
2500
- throw await rateLimitOrQuotaError("openrouter", response);
2501
- }
2502
- if (!response.ok) {
2503
- const details = await response.text();
2504
- throw new Error(`openrouter video download returned ${response.status}${details ? `: ${details}` : ""}`);
2505
- }
2506
- const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() || inferVideoContentType(url);
2507
- return {
2508
- bytes: Buffer.from(await response.arrayBuffer()),
2509
- contentType
2510
- };
2511
- }
2512
- function inferVideoContentType(url) {
2513
- if (/\.webm(\?|#|$)/i.test(url)) {
2514
- return "video/webm";
2515
- }
2516
- if (/\.mov(\?|#|$)/i.test(url)) {
2517
- return "video/quicktime";
2518
- }
2519
- return "video/mp4";
2520
- }
2521
- function assertSupportedModel(kind, provider, model, supportedModels) {
2522
- const normalizedModel = normalizeProviderModel(model);
2523
- if (!normalizedModel) {
2524
- throw new Error(`Missing ${kind} model for provider ${provider}.`);
2525
- }
2526
- const allowedModels = supportedModels[provider];
2527
- if (!allowedModels?.has(normalizedModel)) {
2528
- const allowed = allowedModels ? [...allowedModels].join(", ") : "none";
2529
- throw new Error(`Unsupported ${kind} model "${normalizedModel}" for provider ${provider}. ` +
2530
- `Templates must use Vidfarm-approved models only. Allowed models: ${allowed}.`);
2531
- }
2532
- }
2533
- function assertSupportedSpeechModel(kind, provider, model) {
2534
- assertSupportedModel(kind, provider, model, kind === "tts" ? SUPPORTED_TTS_MODELS : SUPPORTED_STT_MODELS);
2535
- }
2536
- function normalizeProviderModel(model) {
2537
- return model.trim().replace(/^models\//, "");
2538
- }
2539
- function extractGeminiText(data) {
2540
- return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
2541
- }
2542
- function supportsGeminiImageSize(model) {
2543
- return model === "gemini-3.1-flash-image-preview" || model === "gemini-3-pro-image-preview";
2544
- }
2545
- async function fetchRemoteBytes(input) {
2546
- const maxAttempts = isVidfarmTemplateMediaUrl(input.url) ? 6 : 4;
2547
- let lastFailure = null;
2548
- for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
2549
- let response;
2550
- try {
2551
- response = await fetch(input.url);
2552
- }
2553
- catch (error) {
2554
- lastFailure = error instanceof Error ? error.message : String(error);
2555
- if (attempt < maxAttempts - 1) {
2556
- await sleep(remoteMediaRetryDelayMs(null, attempt));
2557
- continue;
2558
- }
2559
- throw new ProviderTransientError(`Could not fetch remote media ${input.url} after ${maxAttempts} attempts: ${lastFailure}`, 5);
2560
- }
2561
- if (response.ok) {
2562
- const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || input.fallbackMimeType || "application/octet-stream";
2563
- const bytes = Buffer.from(await response.arrayBuffer());
2564
- return { bytes, mimeType };
2565
- }
2566
- lastFailure = String(response.status);
2567
- if (!isRetryableRemoteMediaStatus(response.status, input.url)) {
2568
- throw new Error(`Could not fetch remote media ${input.url}: ${response.status}`);
2569
- }
2570
- if (attempt < maxAttempts - 1) {
2571
- await sleep(remoteMediaRetryDelayMs(response, attempt));
2572
- continue;
2573
- }
2574
- }
2575
- throw new ProviderTransientError(`Could not fetch remote media ${input.url} after ${maxAttempts} attempts: ${lastFailure ?? "unknown error"}`, 5);
2576
- }
2577
- function isRetryableRemoteMediaStatus(status, url) {
2578
- return isTransientRemoteMediaStatus(status)
2579
- || (isVidfarmTemplateMediaUrl(url) && (status === 403 || status === 404));
2580
- }
2581
- function isVidfarmTemplateMediaUrl(value) {
2582
- try {
2583
- const url = new URL(value);
2584
- return url.pathname === "/template-media" && Boolean(url.searchParams.get("key"));
2585
- }
2586
- catch {
2587
- return false;
2588
- }
2589
- }
2590
- async function remoteImageToBlob(url) {
2591
- const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
2592
- return new Blob([bytes], { type: mimeType });
2593
- }
2594
- async function remoteImageToOpenAIVideoBlob(url, size) {
2595
- const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
2596
- if (!mimeType.toLowerCase().startsWith("image/")) {
2597
- throw new Error(`OpenAI video input references must be direct image URLs. Received ${mimeType || "unknown content"} from ${url}.`);
2598
- }
2599
- const parsedSize = parsePixelSize(size);
2600
- if (!parsedSize) {
2601
- return new Blob([bytes], { type: "image/png" });
2602
- }
2603
- const normalized = await sharp(bytes)
2604
- .resize(parsedSize.width, parsedSize.height, {
2605
- fit: "cover",
2606
- position: "center"
2607
- })
2608
- .png()
2609
- .toBuffer();
2610
- return new Blob([new Uint8Array(normalized)], { type: "image/png" });
2611
- }
2612
- function parsePixelSize(size) {
2613
- const match = /^(\d+)x(\d+)$/i.exec(size ?? "");
2614
- if (!match) {
2615
- return null;
2616
- }
2617
- const width = Number(match[1]);
2618
- const height = Number(match[2]);
2619
- return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
2620
- ? { width, height }
2621
- : null;
2622
- }
2623
- function openAIImageFilename(index, contentType) {
2624
- const extension = contentType === "image/jpeg" ? "jpg" : contentType === "image/webp" ? "webp" : "png";
2625
- return `reference-${index + 1}.${extension}`;
2626
- }
2627
- async function buildGeminiPromptParts(prompt, attachments) {
2628
- const parts = [];
2629
- for (const attachment of attachments) {
2630
- if (!/^https?:\/\//i.test(attachment)) {
2631
- throw new Error(`Gemini prompt attachments must be public URLs: ${attachment}`);
2632
- }
2633
- const { bytes, mimeType } = await fetchRemoteBytes({ url: attachment });
2634
- parts.push({
2635
- inlineData: {
2636
- mimeType,
2637
- data: bytes.toString("base64")
2638
- }
2639
- });
2640
- }
2641
- parts.push({ text: prompt });
2642
- return parts;
2643
- }
2644
- function decodeInlineDataUrl(value) {
2645
- const match = value.match(/^data:([^;,]+);base64,(.+)$/);
2646
- if (!match) {
2647
- return null;
2648
- }
2649
- return {
2650
- contentType: match[1],
2651
- bytes: Buffer.from(match[2], "base64")
2652
- };
2653
- }
2654
- async function parseOpenRouterImageGenerationPayload(data) {
2655
- const imageData = Array.isArray(data.data) ? data.data[0] : undefined;
2656
- const b64Json = typeof imageData?.b64_json === "string" ? imageData.b64_json : null;
2657
- if (b64Json) {
2658
- return {
2659
- bytes: Buffer.from(b64Json, "base64"),
2660
- contentType: "image/png",
2661
- revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readStringField(data, "revised_prompt")
2662
- };
2663
- }
2664
- const url = readOptionalStringField(imageData, "url") ??
2665
- readOptionalStringField(imageData, "image_url") ??
2666
- readLegacyOpenRouterChatImageUrl(data);
2667
- if (!url) {
2668
- return null;
2669
- }
2670
- const inline = decodeInlineDataUrl(url);
2671
- if (inline) {
2672
- return {
2673
- bytes: inline.bytes,
2674
- contentType: inline.contentType,
2675
- revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readLegacyOpenRouterChatContent(data)
2676
- };
2677
- }
2678
- if (/^https?:\/\//i.test(url)) {
2679
- const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
2680
- return {
2681
- bytes,
2682
- contentType: mimeType,
2683
- revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readLegacyOpenRouterChatContent(data)
2684
- };
2685
- }
2686
- return null;
2687
- }
2688
- function readOptionalStringField(value, field) {
2689
- return value ? readStringField(value, field) : null;
2690
- }
2691
- function readLegacyOpenRouterChatImageUrl(data) {
2692
- const choices = Array.isArray(data.choices) ? data.choices : [];
2693
- const choice = choices[0];
2694
- const message = choice?.message && typeof choice.message === "object"
2695
- ? choice.message
2696
- : null;
2697
- const images = Array.isArray(message?.images) ? message.images : [];
2698
- const firstImage = images[0];
2699
- const snake = firstImage?.image_url && typeof firstImage.image_url === "object"
2700
- ? firstImage.image_url
2701
- : null;
2702
- const camel = firstImage?.imageUrl && typeof firstImage.imageUrl === "object"
2703
- ? firstImage.imageUrl
2704
- : null;
2705
- return (snake ? readStringField(snake, "url") : null) ?? (camel ? readStringField(camel, "url") : null);
2706
- }
2707
- function readLegacyOpenRouterChatContent(data) {
2708
- const choices = Array.isArray(data.choices) ? data.choices : [];
2709
- const choice = choices[0];
2710
- const message = choice?.message && typeof choice.message === "object"
2711
- ? choice.message
2712
- : null;
2713
- return message ? readStringField(message, "content") : null;
2714
- }
2715
- function conciseOpenRouterImageResponse(data) {
2716
- const message = readLegacyOpenRouterChatContent(data);
2717
- if (message) {
2718
- return message.replace(/\s+/g, " ").trim().slice(0, 500);
2719
- }
2720
- return JSON.stringify(data).replace(/\s+/g, " ").slice(0, 500);
2721
- }
2722
- function buildMockSlideSvg(prompt) {
2723
- const escaped = escapeXml(prompt.slice(0, 220));
2724
- return `
2725
- <svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1920" viewBox="0 0 1080 1920">
2726
- <defs>
2727
- <linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
2728
- <stop offset="0%" stop-color="#31241c" />
2729
- <stop offset="52%" stop-color="#866245" />
2730
- <stop offset="100%" stop-color="#d7c0a4" />
2731
- </linearGradient>
2732
- </defs>
2733
- <rect width="1080" height="1920" fill="url(#bg)" />
2734
- <rect x="94" y="1180" width="892" height="470" rx="44" fill="rgba(0,0,0,0.18)" stroke="rgba(255,255,255,0.25)" />
2735
- <text x="108" y="170" fill="#f7f0e7" font-size="54" font-family="Times New Roman, serif">Mock AI image</text>
2736
- <text x="108" y="240" fill="#f7f0e7" font-size="34" font-family="Times New Roman, serif">${escaped}</text>
2737
- <text x="108" y="1280" fill="#f7f0e7" font-size="42" font-family="Times New Roman, serif">Reserved text-safe zone</text>
2738
- </svg>`;
2739
- }
2740
- function escapeXml(value) {
2741
- return value
2742
- .replace(/&/g, "&amp;")
2743
- .replace(/</g, "&lt;")
2744
- .replace(/>/g, "&gt;")
2745
- .replace(/"/g, "&quot;")
2746
- .replace(/'/g, "&apos;");
2747
- }
2748
- //# sourceMappingURL=providers.js.map