@mevdragon/vidfarm-devcli 0.2.12 → 0.2.13

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 (199) hide show
  1. package/.env.example +37 -1
  2. package/README.md +27 -183
  3. package/SKILL.director.md +293 -0
  4. package/SKILL.platform.md +312 -0
  5. package/auto-create-hyperframe-templates/AUTO_CREATE_HYPERFRAME_TEMPLATES.md +275 -0
  6. package/auto-create-hyperframe-templates/extractor-system-prompt.md +128 -0
  7. package/auto-create-hyperframe-templates/input-manifest.schema.json +56 -0
  8. package/auto-create-hyperframe-templates/lambda-memory-estimator.md +118 -0
  9. package/auto-create-hyperframe-templates/production-graph.schema.json +314 -0
  10. package/auto-create-hyperframe-templates/runbook.md +198 -0
  11. package/auto-create-hyperframe-templates/scripts/create-hyperframe-template.mjs +368 -0
  12. package/auto-create-hyperframe-templates/template-plan.schema.json +311 -0
  13. package/auto-create-hyperframe-templates/template-planner-prompt.md +144 -0
  14. package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +116 -0
  15. package/auto-create-templates/extractor-system-prompt.md +6 -1
  16. package/auto-create-templates/template-plan.schema.json +73 -2
  17. package/auto-create-templates/template-planner-prompt.md +62 -1
  18. package/dist/src/account-pages-legacy.js +361 -89
  19. package/dist/src/account-pages-legacy.js.map +1 -0
  20. package/dist/src/account-pages.js +13 -3
  21. package/dist/src/account-pages.js.map +1 -0
  22. package/dist/src/app.js +7060 -946
  23. package/dist/src/app.js.map +1 -0
  24. package/dist/src/cli.js +110 -29
  25. package/dist/src/cli.js.map +1 -0
  26. package/dist/src/composition-runtime.js +581 -0
  27. package/dist/src/composition-runtime.js.map +1 -0
  28. package/dist/src/config.js +69 -11
  29. package/dist/src/config.js.map +1 -0
  30. package/dist/src/context.js +280 -21
  31. package/dist/src/context.js.map +1 -0
  32. package/dist/src/dev-app-legacy.js +18 -17
  33. package/dist/src/dev-app-legacy.js.map +1 -0
  34. package/dist/src/dev-app.js +1 -0
  35. package/dist/src/dev-app.js.map +1 -0
  36. package/dist/src/domain.js +2 -1
  37. package/dist/src/domain.js.map +1 -0
  38. package/dist/src/editor-chat-history.js +10 -0
  39. package/dist/src/editor-chat-history.js.map +1 -0
  40. package/dist/src/editor-chat.js +133 -24
  41. package/dist/src/editor-chat.js.map +1 -0
  42. package/dist/src/frontend/flockposter-cache-store.js +10 -2
  43. package/dist/src/frontend/flockposter-cache-store.js.map +1 -0
  44. package/dist/src/frontend/homepage-client.js +6 -3
  45. package/dist/src/frontend/homepage-client.js.map +1 -0
  46. package/dist/src/frontend/homepage-shared.js +1 -0
  47. package/dist/src/frontend/homepage-shared.js.map +1 -0
  48. package/dist/src/frontend/homepage-store.js +1 -0
  49. package/dist/src/frontend/homepage-store.js.map +1 -0
  50. package/dist/src/frontend/homepage-view.js +131 -18
  51. package/dist/src/frontend/homepage-view.js.map +1 -0
  52. package/dist/src/frontend/page-runtime-client.js +5 -2
  53. package/dist/src/frontend/page-runtime-client.js.map +1 -0
  54. package/dist/src/frontend/page-runtime-store.js +1 -0
  55. package/dist/src/frontend/page-runtime-store.js.map +1 -0
  56. package/dist/src/frontend/sentry.js +42 -0
  57. package/dist/src/frontend/sentry.js.map +1 -0
  58. package/dist/src/frontend/template-editor-chat.js +1719 -213
  59. package/dist/src/frontend/template-editor-chat.js.map +1 -0
  60. package/dist/src/help-page.js +333 -0
  61. package/dist/src/help-page.js.map +1 -0
  62. package/dist/src/homepage.js +169 -47
  63. package/dist/src/homepage.js.map +1 -0
  64. package/dist/src/hyperframes/composition.js +180 -0
  65. package/dist/src/hyperframes/composition.js.map +1 -0
  66. package/dist/src/index.js +1 -0
  67. package/dist/src/index.js.map +1 -0
  68. package/dist/src/instrument.js +30 -0
  69. package/dist/src/instrument.js.map +1 -0
  70. package/dist/src/lib/crypto.js +1 -0
  71. package/dist/src/lib/crypto.js.map +1 -0
  72. package/dist/src/lib/dev-log.js +54 -0
  73. package/dist/src/lib/dev-log.js.map +1 -0
  74. package/dist/src/lib/display-name.js +11 -0
  75. package/dist/src/lib/display-name.js.map +1 -0
  76. package/dist/src/lib/ids.js +21 -1
  77. package/dist/src/lib/ids.js.map +1 -0
  78. package/dist/src/lib/images.js +1 -0
  79. package/dist/src/lib/images.js.map +1 -0
  80. package/dist/src/lib/json.js +1 -0
  81. package/dist/src/lib/json.js.map +1 -0
  82. package/dist/src/lib/template-dna.js +1 -0
  83. package/dist/src/lib/template-dna.js.map +1 -0
  84. package/dist/src/lib/template-paths.js +1 -0
  85. package/dist/src/lib/template-paths.js.map +1 -0
  86. package/dist/src/lib/template-style-options.js +29 -3
  87. package/dist/src/lib/template-style-options.js.map +1 -0
  88. package/dist/src/lib/time.js +1 -0
  89. package/dist/src/lib/time.js.map +1 -0
  90. package/dist/src/lib/video-quality-harness.js +60 -0
  91. package/dist/src/lib/video-quality-harness.js.map +1 -0
  92. package/dist/src/page-runtime.js +1 -0
  93. package/dist/src/page-runtime.js.map +1 -0
  94. package/dist/src/page-shell.js +1501 -49
  95. package/dist/src/page-shell.js.map +1 -0
  96. package/dist/src/primitive-context.js +144 -8
  97. package/dist/src/primitive-context.js.map +1 -0
  98. package/dist/src/primitive-registry.js +1919 -78
  99. package/dist/src/primitive-registry.js.map +1 -0
  100. package/dist/src/primitive-sdk.js +1 -0
  101. package/dist/src/primitive-sdk.js.map +1 -0
  102. package/dist/src/primitives/remotion/html-image.js +3 -1
  103. package/dist/src/primitives/remotion/html-image.js.map +1 -0
  104. package/dist/src/primitives/remotion/media-slideshow.js +60 -0
  105. package/dist/src/primitives/remotion/media-slideshow.js.map +1 -0
  106. package/dist/src/react-page-shell.js +1 -0
  107. package/dist/src/react-page-shell.js.map +1 -0
  108. package/dist/src/ready-post-schedule-component.js +1 -0
  109. package/dist/src/ready-post-schedule-component.js.map +1 -0
  110. package/dist/src/registry.js +237 -24
  111. package/dist/src/registry.js.map +1 -0
  112. package/dist/src/runtime.js +3 -0
  113. package/dist/src/runtime.js.map +1 -0
  114. package/dist/src/services/api-call-history.js +4 -0
  115. package/dist/src/services/api-call-history.js.map +1 -0
  116. package/dist/src/services/auth.js +40 -50
  117. package/dist/src/services/auth.js.map +1 -0
  118. package/dist/src/services/billing.js +20 -44
  119. package/dist/src/services/billing.js.map +1 -0
  120. package/dist/src/services/chat-threads.js +10 -6
  121. package/dist/src/services/chat-threads.js.map +1 -0
  122. package/dist/src/services/fork-access.js +94 -0
  123. package/dist/src/services/fork-access.js.map +1 -0
  124. package/dist/src/services/ghostcut.js +132 -0
  125. package/dist/src/services/ghostcut.js.map +1 -0
  126. package/dist/src/services/hyperframes.js +1014 -0
  127. package/dist/src/services/hyperframes.js.map +1 -0
  128. package/dist/src/services/job-capacity.js +14 -0
  129. package/dist/src/services/job-capacity.js.map +1 -0
  130. package/dist/src/services/job-logs.js +4 -0
  131. package/dist/src/services/job-logs.js.map +1 -0
  132. package/dist/src/services/jobs.js +99 -91
  133. package/dist/src/services/jobs.js.map +1 -0
  134. package/dist/src/services/media-processing.js +743 -0
  135. package/dist/src/services/media-processing.js.map +1 -0
  136. package/dist/src/services/primitive-media-lambda.js +280 -0
  137. package/dist/src/services/primitive-media-lambda.js.map +1 -0
  138. package/dist/src/services/providers.js +1560 -178
  139. package/dist/src/services/providers.js.map +1 -0
  140. package/dist/src/services/rate-limits.js +3 -2
  141. package/dist/src/services/rate-limits.js.map +1 -0
  142. package/dist/src/services/remotion.js +495 -92
  143. package/dist/src/services/remotion.js.map +1 -0
  144. package/dist/src/services/serverless-auth.js +374 -0
  145. package/dist/src/services/serverless-auth.js.map +1 -0
  146. package/dist/src/services/serverless-jobs.js +1074 -0
  147. package/dist/src/services/serverless-jobs.js.map +1 -0
  148. package/dist/src/services/serverless-provider-keys.js +401 -0
  149. package/dist/src/services/serverless-provider-keys.js.map +1 -0
  150. package/dist/src/services/serverless-records.js +1086 -0
  151. package/dist/src/services/serverless-records.js.map +1 -0
  152. package/dist/src/services/serverless-template-configs.js +67 -0
  153. package/dist/src/services/serverless-template-configs.js.map +1 -0
  154. package/dist/src/services/storage.js +170 -35
  155. package/dist/src/services/storage.js.map +1 -0
  156. package/dist/src/services/template-certification.js +295 -3
  157. package/dist/src/services/template-certification.js.map +1 -0
  158. package/dist/src/services/template-loader.js +45 -1
  159. package/dist/src/services/template-loader.js.map +1 -0
  160. package/dist/src/services/template-runtime-bundles.js +217 -0
  161. package/dist/src/services/template-runtime-bundles.js.map +1 -0
  162. package/dist/src/services/template-sources.js +452 -87
  163. package/dist/src/services/template-sources.js.map +1 -0
  164. package/dist/src/services/video-normalization.js +2 -0
  165. package/dist/src/services/video-normalization.js.map +1 -0
  166. package/dist/src/services/webhooks.js +7 -6
  167. package/dist/src/services/webhooks.js.map +1 -0
  168. package/dist/src/template-editor-pages.js +5637 -1322
  169. package/dist/src/template-editor-pages.js.map +1 -0
  170. package/dist/src/template-editor-shell.js +1376 -170
  171. package/dist/src/template-editor-shell.js.map +1 -0
  172. package/dist/src/template-sdk.js +1 -0
  173. package/dist/src/template-sdk.js.map +1 -0
  174. package/dist/src/worker.js +11 -226
  175. package/dist/src/worker.js.map +1 -0
  176. package/package.json +47 -12
  177. package/public/assets/homepage-client-app.js +34 -8
  178. package/public/assets/page-runtime-client-app.js +44 -18
  179. package/readme.secret.md +89 -0
  180. package/templates/vidfarm_template_0000/README.md +106 -0
  181. package/templates/vidfarm_template_0000/SKILL.md +266 -0
  182. package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
  183. package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  184. package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
  185. package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
  186. package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  187. package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
  188. package/templates/vidfarm_template_0000/composition.json +11 -0
  189. package/templates/vidfarm_template_0000/src/lib/images.js +221 -0
  190. package/templates/vidfarm_template_0000/src/remotion/Root.js +33 -0
  191. package/templates/vidfarm_template_0000/src/remotion/index.js +3 -0
  192. package/templates/vidfarm_template_0000/src/sdk.js +3 -0
  193. package/templates/vidfarm_template_0000/src/style-options.js +200 -0
  194. package/templates/vidfarm_template_0000/src/template-dna.js +9 -0
  195. package/templates/vidfarm_template_0000/src/template.js +1566 -0
  196. package/templates/vidfarm_template_0000/template.config.json +21 -0
  197. package/GETTING_STARTED.developers.md +0 -87
  198. package/SKILL.developer.md +0 -1186
  199. package/dist/src/db.js +0 -2007
@@ -1,13 +1,18 @@
1
1
  import { config } from "../config.js";
2
- import { database } from "../db.js";
3
2
  import { parseJson } from "../lib/json.js";
4
3
  import { decryptString } from "../lib/crypto.js";
5
4
  import { createId } from "../lib/ids.js";
6
5
  import { addSeconds } from "../lib/time.js";
6
+ import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
7
+ import sharp from "sharp";
7
8
  export class ProviderKeyUnavailableError extends Error {
8
9
  }
9
10
  export class ProviderAuthError extends Error {
10
11
  }
12
+ export class ProviderQuotaExceededError extends Error {
13
+ }
14
+ export class ProviderWaitExceededError extends Error {
15
+ }
11
16
  export class ProviderLeaseTimeoutError extends Error {
12
17
  retryAfterSeconds;
13
18
  constructor(message, retryAfterSeconds = 5) {
@@ -22,6 +27,71 @@ export class ProviderRateLimitError extends Error {
22
27
  this.retryAfterSeconds = retryAfterSeconds;
23
28
  }
24
29
  }
30
+ export class ProviderTransientError extends Error {
31
+ retryAfterSeconds;
32
+ constructor(message, retryAfterSeconds = 5) {
33
+ super(message);
34
+ this.retryAfterSeconds = retryAfterSeconds;
35
+ }
36
+ }
37
+ const SUPPORTED_TEXT_MODELS = {
38
+ openai: new Set(["gpt-5.4"]),
39
+ gemini: new Set(["gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
40
+ openrouter: new Set(["qwen/qwen3.6-plus"]),
41
+ perplexity: new Set(["sonar"])
42
+ };
43
+ const SUPPORTED_IMAGE_MODELS = {
44
+ openai: new Set(["gpt-image-2", "gpt-image-1"]),
45
+ gemini: new Set(["gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview", "gemini-2.5-flash-image"]),
46
+ openrouter: new Set([
47
+ "google/gemini-3.1-flash-image-preview",
48
+ "google/gemini-2.5-flash-image-preview",
49
+ "bytedance/seedance-2.0",
50
+ "bytedance-seed/seedream-4.5"
51
+ ])
52
+ };
53
+ const SUPPORTED_VIDEO_MODELS = {
54
+ openai: new Set(["sora-2", "sora-2-pro"]),
55
+ gemini: new Set(["veo-3.0-generate-001", "veo-3.0-fast-generate-001"]),
56
+ openrouter: new Set([
57
+ "bytedance/seedance-2.0-fast",
58
+ "bytedance/seedance-2.0",
59
+ "google/veo-3.1-lite",
60
+ "google/veo-3.1-fast",
61
+ "google/veo-3.1",
62
+ "kwaivgi/kling-v3.0-std",
63
+ "kwaivgi/kling-v3.0-pro",
64
+ "openai/sora-2-pro"
65
+ ])
66
+ };
67
+ const DEFAULT_TEXT_MODELS = {
68
+ openai: "gpt-5.4",
69
+ gemini: "gemini-3.1-flash-lite",
70
+ openrouter: "qwen/qwen3.6-plus",
71
+ perplexity: "sonar"
72
+ };
73
+ const DEFAULT_IMAGE_MODELS = {
74
+ openai: "gpt-image-2",
75
+ gemini: "gemini-3-pro-image-preview",
76
+ openrouter: "google/gemini-3.1-flash-image-preview"
77
+ };
78
+ const DEFAULT_VIDEO_MODELS = {
79
+ openai: "sora-2",
80
+ gemini: "veo-3.0-generate-001",
81
+ openrouter: "bytedance/seedance-2.0"
82
+ };
83
+ const PROVIDER_FALLBACK_PRIORITY = ["openai", "gemini", "openrouter", "perplexity"];
84
+ const OPENAI_IMAGE_SIZES = new Set([
85
+ "1024x1024",
86
+ "1536x1024",
87
+ "1024x1536",
88
+ "2048x2048",
89
+ "2048x1152",
90
+ "3840x2160",
91
+ "2160x3840",
92
+ "auto"
93
+ ]);
94
+ const GEMINI_IMAGE_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]);
25
95
  const SUPPORTED_TTS_MODELS = {
26
96
  openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
27
97
  gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
@@ -49,6 +119,117 @@ function readStoredProviderSecret(value) {
49
119
  function sleep(ms) {
50
120
  return new Promise((resolve) => setTimeout(resolve, ms));
51
121
  }
122
+ function formatDurationSeconds(totalSeconds) {
123
+ if (totalSeconds >= 3600) {
124
+ const hours = Math.round(totalSeconds / 3600);
125
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
126
+ }
127
+ if (totalSeconds >= 60) {
128
+ const minutes = Math.round(totalSeconds / 60);
129
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
130
+ }
131
+ return `${Math.max(1, Math.round(totalSeconds))} second${Math.round(totalSeconds) === 1 ? "" : "s"}`;
132
+ }
133
+ function parseRetryAfterSeconds(value) {
134
+ if (!value) {
135
+ return 60;
136
+ }
137
+ const numeric = Number(value);
138
+ if (Number.isFinite(numeric) && numeric > 0) {
139
+ return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
140
+ }
141
+ const parsedDate = Date.parse(value);
142
+ if (Number.isFinite(parsedDate)) {
143
+ return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
144
+ }
145
+ return 60;
146
+ }
147
+ function isHardQuotaError(details) {
148
+ const normalized = details.toLowerCase();
149
+ return [
150
+ "insufficient_quota",
151
+ "quota_exceeded",
152
+ "exceeded your current quota",
153
+ "billing",
154
+ "insufficient credits",
155
+ "not enough credits",
156
+ "credit balance",
157
+ "prepaid credits",
158
+ "resource_exhausted"
159
+ ].some((pattern) => normalized.includes(pattern));
160
+ }
161
+ function conciseProviderDetails(details) {
162
+ const compact = details.replace(/\s+/g, " ").trim();
163
+ return compact ? `: ${compact.slice(0, 500)}` : "";
164
+ }
165
+ function conciseProviderResponse(operation, response, details) {
166
+ return `${operation} returned ${response.status}${conciseProviderDetails(details)}`;
167
+ }
168
+ async function rateLimitOrQuotaError(provider, response) {
169
+ const details = await response.text();
170
+ if (isHardQuotaError(details)) {
171
+ return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
172
+ }
173
+ return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
174
+ }
175
+ function isTransientProviderStatus(status) {
176
+ return status === 408 || status === 409 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
177
+ }
178
+ function isTransientRemoteMediaStatus(status) {
179
+ return status === 408 || status === 425 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
180
+ }
181
+ function remoteMediaRetryDelayMs(response, attempt) {
182
+ const retryAfter = response?.headers.get("retry-after") ?? null;
183
+ if (retryAfter) {
184
+ return Math.min(parseRetryAfterSeconds(retryAfter) * 1000, 5_000);
185
+ }
186
+ return Math.min(250 * (2 ** attempt), 2_000);
187
+ }
188
+ async function transientProviderError(provider, operation, response) {
189
+ const details = await response.text().catch(() => "");
190
+ return new ProviderTransientError(`${provider} ${operation} temporarily failed with ${response.status}${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
191
+ }
192
+ async function waitForRetryableProviderRateLimit(error, deadlineMs) {
193
+ const waitMs = error.retryAfterSeconds * 1000;
194
+ if (Date.now() + waitMs > deadlineMs) {
195
+ throw new ProviderWaitExceededError(`Provider remained rate limited for ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
196
+ }
197
+ await sleep(waitMs);
198
+ }
199
+ async function waitForRetryableProviderError(error, deadlineMs) {
200
+ if (error instanceof ProviderQuotaExceededError || error instanceof ProviderAuthError) {
201
+ return false;
202
+ }
203
+ if (error instanceof ProviderRateLimitError) {
204
+ await waitForRetryableProviderRateLimit(error, deadlineMs);
205
+ return true;
206
+ }
207
+ if (error instanceof ProviderLeaseTimeoutError) {
208
+ const waitMs = error.retryAfterSeconds * 1000;
209
+ if (Date.now() + waitMs > deadlineMs) {
210
+ throw new ProviderWaitExceededError(`No eligible provider key became available within ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
211
+ }
212
+ await sleep(waitMs);
213
+ return true;
214
+ }
215
+ if (error instanceof ProviderTransientError) {
216
+ const waitMs = error.retryAfterSeconds * 1000;
217
+ if (Date.now() + waitMs > deadlineMs) {
218
+ throw new ProviderWaitExceededError(`Provider remained temporarily unavailable within ${formatDurationSeconds(config.PROVIDER_LEASE_INLINE_WAIT_SECONDS)}. Last error: ${error.message}`);
219
+ }
220
+ await sleep(waitMs);
221
+ return true;
222
+ }
223
+ return false;
224
+ }
225
+ function isRecoverableProviderAttemptError(error) {
226
+ return error instanceof ProviderLeaseTimeoutError ||
227
+ error instanceof ProviderKeyUnavailableError ||
228
+ error instanceof ProviderRateLimitError ||
229
+ error instanceof ProviderQuotaExceededError ||
230
+ error instanceof ProviderAuthError ||
231
+ error instanceof ProviderTransientError;
232
+ }
52
233
  function zeroUsageCost(result) {
53
234
  return {
54
235
  ...result,
@@ -58,17 +239,117 @@ function zeroUsageCost(result) {
58
239
  }
59
240
  };
60
241
  }
61
- const DEFAULT_PROVIDER_LEASE_PRIORITY = 100;
242
+ function providerSupportsModel(kind, provider, model) {
243
+ const supported = kind === "text" ? SUPPORTED_TEXT_MODELS : kind === "image" ? SUPPORTED_IMAGE_MODELS : SUPPORTED_VIDEO_MODELS;
244
+ return providerSupportsModelFrom(supported, provider, model);
245
+ }
246
+ function defaultModelFor(kind, provider) {
247
+ return kind === "text" ? DEFAULT_TEXT_MODELS[provider] : kind === "image" ? DEFAULT_IMAGE_MODELS[provider] : DEFAULT_VIDEO_MODELS[provider];
248
+ }
249
+ function providerSupportsModelFrom(supported, provider, model) {
250
+ return Boolean(supported[provider]?.has(normalizeProviderModel(model)));
251
+ }
252
+ function defaultSpeechModelFor(kind, provider) {
253
+ return kind === "tts" ? SUPPORTED_TTS_MODELS[provider]?.values().next().value : SUPPORTED_STT_MODELS[provider]?.values().next().value;
254
+ }
255
+ function uniqueProviders(requested, supported) {
256
+ return [
257
+ requested,
258
+ ...PROVIDER_FALLBACK_PRIORITY.filter((provider) => provider !== requested)
259
+ ].filter((provider) => supported[provider]?.size);
260
+ }
261
+ async function availableProviderCounts(providerKeyService, customerId) {
262
+ return new Map((await providerKeyService.listAvailableProviderKeyCounts(customerId))
263
+ .map((entry) => [entry.provider, entry.availableCount]));
264
+ }
265
+ async function configuredProviderCounts(providerKeyService, customerId) {
266
+ const counts = new Map();
267
+ for (const entry of await providerKeyService.listProviderKeysWithSecrets(customerId)) {
268
+ const provider = String(entry.provider);
269
+ const status = String(entry.status);
270
+ const secret = readStoredProviderSecret(String(entry.secret ?? ""));
271
+ if (status !== "active" || !secret.trim()) {
272
+ continue;
273
+ }
274
+ counts.set(provider, (counts.get(provider) ?? 0) + 1);
275
+ }
276
+ return counts;
277
+ }
278
+ async function providerAttempts(input) {
279
+ const keyCounts = await configuredProviderCounts(input.providerKeyService, input.customerId);
280
+ const availableCounts = await availableProviderCounts(input.providerKeyService, input.customerId);
281
+ const attempts = uniqueProviders(input.requestedProvider, input.supportedModels)
282
+ .map((provider, index) => ({ provider, index }))
283
+ .filter(({ provider }) => (keyCounts.get(provider) ?? 0) > 0)
284
+ .filter(({ provider }) => input.allowProvider?.(provider) ?? true)
285
+ .sort((a, b) => {
286
+ const aAvailable = (availableCounts.get(a.provider) ?? 0) > 0 ? 0 : 1;
287
+ const bAvailable = (availableCounts.get(b.provider) ?? 0) > 0 ? 0 : 1;
288
+ return aAvailable - bAvailable || a.index - b.index;
289
+ })
290
+ .map(({ provider }) => ({
291
+ provider,
292
+ model: provider === input.requestedProvider && providerSupportsModelFrom(input.supportedModels, provider, input.requestedModel)
293
+ ? normalizeProviderModel(input.requestedModel)
294
+ : input.defaultModelForProvider(provider)
295
+ }))
296
+ .filter((attempt) => Boolean(attempt.model));
297
+ if (!attempts.length) {
298
+ const supportedProviders = Object.entries(input.supportedModels)
299
+ .filter(([, models]) => models?.size)
300
+ .map(([provider]) => provider)
301
+ .join(", ");
302
+ const configuredProviders = [...keyCounts.entries()]
303
+ .filter(([, count]) => count > 0)
304
+ .map(([provider]) => provider)
305
+ .join(", ") || "none";
306
+ throw new ProviderKeyUnavailableError(`No active ${input.capability} provider key is configured for this customer. ` +
307
+ `Configured providers: ${configuredProviders}. Supported providers for this capability: ${supportedProviders || "none"}.`);
308
+ }
309
+ return attempts;
310
+ }
311
+ function normalizeOpenAIImageSize(size, aspectRatio) {
312
+ if (size && OPENAI_IMAGE_SIZES.has(size)) {
313
+ return size;
314
+ }
315
+ if (aspectRatio === "16:9") {
316
+ return "1536x1024";
317
+ }
318
+ if (aspectRatio === "9:16") {
319
+ return "1024x1536";
320
+ }
321
+ if (size && /^(\d+)x(\d+)$/i.test(size)) {
322
+ const [, widthRaw, heightRaw] = /^(\d+)x(\d+)$/i.exec(size);
323
+ const width = Number(widthRaw);
324
+ const height = Number(heightRaw);
325
+ if (width > height) {
326
+ return "1536x1024";
327
+ }
328
+ if (height > width) {
329
+ return "1024x1536";
330
+ }
331
+ }
332
+ return "auto";
333
+ }
334
+ function normalizeGeminiAspectRatio(aspectRatio) {
335
+ return aspectRatio && GEMINI_IMAGE_ASPECT_RATIOS.has(aspectRatio) ? aspectRatio : undefined;
336
+ }
62
337
  const CHAT_PROVIDER_LEASE_PRIORITY = 0;
63
- const DEFAULT_PROVIDER_LEASE_TIMEOUT_MS = 120_000;
64
- const CHAT_PROVIDER_LEASE_TIMEOUT_MS = 45_000;
65
- const PROVIDER_LEASE_POLL_MS = 250;
338
+ const DEFAULT_PROVIDER_LEASE_TIMEOUT_MS = config.PROVIDER_LEASE_WAIT_TIMEOUT_SECONDS * 1000;
339
+ const CHAT_PROVIDER_LEASE_TIMEOUT_MS = 15_000;
66
340
  const DEFAULT_PROVIDER_LEASE_SECONDS = 90;
67
- const CHAT_PROVIDER_LEASE_SECONDS = 300;
341
+ const CHAT_PROVIDER_LEASE_SECONDS = 120;
342
+ const CHAT_PROVIDER_LEASE_CONCURRENCY = 2;
343
+ const PROVIDER_LEASE_RENEWAL_MIN_INTERVAL_MS = 5_000;
344
+ const PROVIDER_QUOTA_COOLDOWN_SECONDS = 60 * 60;
345
+ function providerLeaseRetryDeadlineMs() {
346
+ return Date.now() + Math.max(0, config.PROVIDER_LEASE_INLINE_WAIT_SECONDS) * 1000;
347
+ }
68
348
  export class ProviderService {
69
- handleLeasedProviderError(input) {
349
+ serverlessProviderKeys = new ServerlessProviderKeyService();
350
+ async handleLeasedProviderError(input) {
70
351
  if (input.error instanceof ProviderRateLimitError) {
71
- database.recordProviderKeyUsage({
352
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
72
353
  id: createId("usage"),
73
354
  keyId: input.lease.keyId,
74
355
  jobId: input.jobId,
@@ -80,10 +361,22 @@ export class ProviderService {
80
361
  retryAfterSeconds: input.error.retryAfterSeconds
81
362
  }
82
363
  });
83
- database.setProviderKeyCooldown(input.lease.keyId, addSeconds(new Date(), input.error.retryAfterSeconds));
364
+ await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, addSeconds(new Date(), input.error.retryAfterSeconds));
365
+ }
366
+ else if (input.error instanceof ProviderQuotaExceededError) {
367
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
368
+ id: createId("usage"),
369
+ keyId: input.lease.keyId,
370
+ jobId: input.jobId,
371
+ provider: input.provider,
372
+ model: input.model,
373
+ eventType: "quota_exceeded",
374
+ metadata: input.metadata ?? {}
375
+ });
376
+ await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, addSeconds(new Date(), PROVIDER_QUOTA_COOLDOWN_SECONDS));
84
377
  }
85
378
  else if (input.error instanceof ProviderAuthError) {
86
- database.recordProviderKeyUsage({
379
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
87
380
  id: createId("usage"),
88
381
  keyId: input.lease.keyId,
89
382
  jobId: input.jobId,
@@ -92,13 +385,16 @@ export class ProviderService {
92
385
  eventType: "auth_error",
93
386
  metadata: input.metadata ?? {}
94
387
  });
95
- database.setProviderKeyCooldown(input.lease.keyId, null, "invalid", "auth_error");
388
+ await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, null, "invalid", "auth_error");
96
389
  }
97
390
  throw input.error;
98
391
  }
99
- listAvailableProviders(customerId) {
392
+ listAvailableProviders(_customerId) {
393
+ return PROVIDER_FALLBACK_PRIORITY;
394
+ }
395
+ async listAvailableProvidersAsync(customerId) {
100
396
  const now = Date.now();
101
- const providers = database.listProviderKeysWithSecrets(customerId)
397
+ const providers = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(customerId))
102
398
  .map((entry) => ({
103
399
  provider: String(entry.provider),
104
400
  status: String(entry.status),
@@ -135,55 +431,128 @@ export class ProviderService {
135
431
  }
136
432
  async leaseCustomerKey(input) {
137
433
  const leaseToken = createId("lease");
138
- const waiterId = createId("wait");
139
- const priority = input.priority ?? DEFAULT_PROVIDER_LEASE_PRIORITY;
140
- const waitTimeoutMs = input.waitTimeoutMs ?? DEFAULT_PROVIDER_LEASE_TIMEOUT_MS;
141
434
  const leaseDurationSeconds = input.leaseDurationSeconds ?? DEFAULT_PROVIDER_LEASE_SECONDS;
142
- const deadline = Date.now() + waitTimeoutMs;
143
- try {
144
- while (Date.now() < deadline) {
145
- const row = database.acquireProviderKeyLease({
146
- customerId: input.customerId,
147
- provider: input.provider,
148
- waiterId,
149
- workerId: input.workerId,
150
- jobId: input.jobId,
151
- priority,
152
- leaseToken,
153
- expiresAt: addSeconds(new Date(), leaseDurationSeconds),
154
- waiterExpiresAt: addSeconds(new Date(), 10)
155
- });
156
- if (row) {
157
- return {
158
- keyId: row.keyId,
159
- leaseToken,
160
- provider: input.provider,
161
- secret: readStoredProviderSecret(row.encryptedSecret)
162
- };
163
- }
164
- await sleep(PROVIDER_LEASE_POLL_MS);
435
+ const row = await this.serverlessProviderKeys.acquireProviderKeyLease({
436
+ customerId: input.customerId,
437
+ provider: input.provider,
438
+ leaseToken,
439
+ workerId: input.workerId,
440
+ jobId: input.jobId,
441
+ expiresAt: addSeconds(new Date(), leaseDurationSeconds)
442
+ });
443
+ if (!row) {
444
+ const message = `No eligible ${input.provider} key is immediately available.`;
445
+ if ((input.timeoutFailure ?? "retry") === "retry") {
446
+ throw new ProviderLeaseTimeoutError(message);
165
447
  }
448
+ throw new ProviderWaitExceededError(message);
449
+ }
450
+ return {
451
+ keyId: row.keyId,
452
+ leaseToken,
453
+ provider: input.provider,
454
+ secret: readStoredProviderSecret(row.encryptedSecret),
455
+ jobId: input.jobId,
456
+ leaseDurationSeconds
457
+ };
458
+ }
459
+ providerLeaseTimeoutMessage(waitTimeoutMs, provider) {
460
+ if (waitTimeoutMs === null) {
461
+ return `No eligible ${provider} key became available.`;
462
+ }
463
+ return `No eligible ${provider} key became available within ${formatDurationSeconds(Math.max(1, Math.floor((waitTimeoutMs ?? DEFAULT_PROVIDER_LEASE_TIMEOUT_MS) / 1000)))}.`;
464
+ }
465
+ throwProviderLeaseTimeout(input) {
466
+ const message = this.providerLeaseTimeoutMessage(input.waitTimeoutMs, input.provider);
467
+ if ((input.timeoutFailure ?? "retry") === "retry") {
468
+ throw new ProviderLeaseTimeoutError(message);
469
+ }
470
+ throw new ProviderWaitExceededError(message);
471
+ }
472
+ async withLeaseRenewal(lease, run) {
473
+ const renewEveryMs = Math.max(PROVIDER_LEASE_RENEWAL_MIN_INTERVAL_MS, Math.floor((lease.leaseDurationSeconds * 1000) / 2));
474
+ const timer = setInterval(() => {
475
+ const expiresAt = addSeconds(new Date(), lease.leaseDurationSeconds);
476
+ void this.serverlessProviderKeys.extendProviderKeyLease({
477
+ keyId: lease.keyId,
478
+ leaseToken: lease.leaseToken,
479
+ expiresAt
480
+ });
481
+ }, renewEveryMs);
482
+ timer.unref();
483
+ try {
484
+ return await run();
166
485
  }
167
486
  finally {
168
- database.deleteProviderKeyWaiter(waiterId);
487
+ clearInterval(timer);
169
488
  }
170
- throw new ProviderLeaseTimeoutError(`No eligible ${input.provider} key became available in time.`);
171
489
  }
172
490
  async releaseLease(lease) {
173
- database.releaseProviderKeyLease({ keyId: lease.keyId, leaseToken: lease.leaseToken });
491
+ await this.serverlessProviderKeys.releaseProviderKeyLease({ keyId: lease.keyId, leaseToken: lease.leaseToken });
174
492
  }
175
493
  async leaseEditorChatKey(input) {
176
- return this.leaseCustomerKey({
177
- customerId: input.customerId,
494
+ const now = new Date().toISOString();
495
+ const key = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(input.customerId))
496
+ .filter((entry) => entry.provider === input.provider)
497
+ .filter((entry) => entry.status === "active")
498
+ .filter((entry) => !entry.cooldown_until || entry.cooldown_until <= now)
499
+ .filter((entry) => readStoredProviderSecret(entry.secret).trim().length > 0)
500
+ .sort((a, b) => {
501
+ const aLast = a.last_used_at ?? "";
502
+ const bLast = b.last_used_at ?? "";
503
+ if (!aLast && bLast)
504
+ return -1;
505
+ if (aLast && !bLast)
506
+ return 1;
507
+ return aLast.localeCompare(bLast) || a.created_at.localeCompare(b.created_at);
508
+ })[0];
509
+ if (!key) {
510
+ throw new ProviderLeaseTimeoutError(`No eligible ${input.provider} key is currently available for editor chat.`);
511
+ }
512
+ return {
513
+ keyId: key.id,
514
+ leaseToken: createId("chatlease"),
178
515
  provider: input.provider,
516
+ secret: readStoredProviderSecret(key.secret),
179
517
  jobId: input.chatId,
180
- workerId: `editor-chat:${input.chatId}`,
181
- priority: CHAT_PROVIDER_LEASE_PRIORITY,
182
- waitTimeoutMs: CHAT_PROVIDER_LEASE_TIMEOUT_MS,
183
518
  leaseDurationSeconds: CHAT_PROVIDER_LEASE_SECONDS
184
- });
519
+ };
185
520
  }
186
521
  async generateText(input) {
522
+ const attempts = await providerAttempts({
523
+ providerKeyService: this.serverlessProviderKeys,
524
+ customerId: input.customerId,
525
+ capability: "text",
526
+ requestedProvider: input.provider,
527
+ requestedModel: input.model,
528
+ supportedModels: SUPPORTED_TEXT_MODELS,
529
+ defaultModelForProvider: (provider) => defaultModelFor("text", provider)
530
+ });
531
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
532
+ for (;;) {
533
+ let lastError = null;
534
+ for (const attempt of attempts) {
535
+ try {
536
+ return await this.generateTextWithProvider({
537
+ ...input,
538
+ provider: attempt.provider,
539
+ model: attempt.model
540
+ });
541
+ }
542
+ catch (error) {
543
+ lastError = error;
544
+ if (!isRecoverableProviderAttemptError(error)) {
545
+ throw error;
546
+ }
547
+ }
548
+ }
549
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
550
+ continue;
551
+ }
552
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI provider key became available in time.");
553
+ }
554
+ }
555
+ async generateTextWithProvider(input) {
187
556
  const lease = await this.leaseCustomerKey({
188
557
  customerId: input.customerId,
189
558
  provider: input.provider,
@@ -198,7 +567,7 @@ export class ProviderService {
198
567
  "Beat 2: Show the turning point.",
199
568
  "Beat 3: Close with a clear CTA."
200
569
  ].join("\n");
201
- database.recordProviderKeyUsage({
570
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
202
571
  id: createId("usage"),
203
572
  keyId: lease.keyId,
204
573
  jobId: input.jobId,
@@ -209,7 +578,7 @@ export class ProviderService {
209
578
  outputTokens: 120,
210
579
  metadata: { mock: true }
211
580
  });
212
- database.touchProviderKey(lease.keyId);
581
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
213
582
  return {
214
583
  text,
215
584
  usage: {
@@ -219,8 +588,8 @@ export class ProviderService {
219
588
  }
220
589
  };
221
590
  }
222
- const result = zeroUsageCost(await this.callProvider({ ...input, apiKey: lease.secret }));
223
- database.recordProviderKeyUsage({
591
+ const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => this.callProvider({ ...input, apiKey: lease.secret })));
592
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
224
593
  id: createId("usage"),
225
594
  keyId: lease.keyId,
226
595
  jobId: input.jobId,
@@ -230,11 +599,11 @@ export class ProviderService {
230
599
  inputTokens: result.usage.inputTokens,
231
600
  outputTokens: result.usage.outputTokens
232
601
  });
233
- database.touchProviderKey(lease.keyId);
602
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
234
603
  return result;
235
604
  }
236
605
  catch (error) {
237
- this.handleLeasedProviderError({
606
+ return await this.handleLeasedProviderError({
238
607
  error,
239
608
  lease,
240
609
  jobId: input.jobId,
@@ -247,6 +616,40 @@ export class ProviderService {
247
616
  }
248
617
  }
249
618
  async generateSpeech(input) {
619
+ const attempts = await providerAttempts({
620
+ providerKeyService: this.serverlessProviderKeys,
621
+ customerId: input.customerId,
622
+ capability: "tts",
623
+ requestedProvider: input.provider,
624
+ requestedModel: input.model,
625
+ supportedModels: SUPPORTED_TTS_MODELS,
626
+ defaultModelForProvider: (provider) => defaultSpeechModelFor("tts", provider)
627
+ });
628
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
629
+ for (;;) {
630
+ let lastError = null;
631
+ for (const attempt of attempts) {
632
+ try {
633
+ return await this.generateSpeechWithProvider({
634
+ ...input,
635
+ provider: attempt.provider,
636
+ model: attempt.model
637
+ });
638
+ }
639
+ catch (error) {
640
+ lastError = error;
641
+ if (!isRecoverableProviderAttemptError(error)) {
642
+ throw error;
643
+ }
644
+ }
645
+ }
646
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
647
+ continue;
648
+ }
649
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI speech provider key became available in time.");
650
+ }
651
+ }
652
+ async generateSpeechWithProvider(input) {
250
653
  assertSupportedSpeechModel("tts", input.provider, input.model);
251
654
  const lease = await this.leaseCustomerKey({
252
655
  customerId: input.customerId,
@@ -257,7 +660,7 @@ export class ProviderService {
257
660
  try {
258
661
  if (config.mockProviders) {
259
662
  const contentType = inferSpeechContentType(input.responseFormat ?? "mp3");
260
- database.recordProviderKeyUsage({
663
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
261
664
  id: createId("usage"),
262
665
  keyId: lease.keyId,
263
666
  jobId: input.jobId,
@@ -268,7 +671,7 @@ export class ProviderService {
268
671
  outputTokens: 0,
269
672
  metadata: { mock: true, type: "speech_generation" }
270
673
  });
271
- database.touchProviderKey(lease.keyId);
674
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
272
675
  return {
273
676
  bytes: Buffer.from(`mock speech:${input.text.slice(0, 120)}`, "utf8"),
274
677
  contentType,
@@ -279,15 +682,15 @@ export class ProviderService {
279
682
  }
280
683
  };
281
684
  }
282
- const result = zeroUsageCost(input.provider === "gemini"
283
- ? await this.callGeminiSpeechGeneration({
685
+ const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => input.provider === "gemini"
686
+ ? this.callGeminiSpeechGeneration({
284
687
  model: input.model,
285
688
  text: input.text,
286
689
  voice: input.voice,
287
690
  instructions: input.instructions,
288
691
  apiKey: lease.secret
289
692
  })
290
- : await this.callOpenAICompatibleSpeechGeneration({
693
+ : this.callOpenAICompatibleSpeechGeneration({
291
694
  provider: input.provider === "openrouter" ? "openrouter" : "openai",
292
695
  model: input.model,
293
696
  text: input.text,
@@ -295,8 +698,8 @@ export class ProviderService {
295
698
  instructions: input.instructions,
296
699
  responseFormat: input.responseFormat,
297
700
  apiKey: lease.secret
298
- }));
299
- database.recordProviderKeyUsage({
701
+ })));
702
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
300
703
  id: createId("usage"),
301
704
  keyId: lease.keyId,
302
705
  jobId: input.jobId,
@@ -307,11 +710,11 @@ export class ProviderService {
307
710
  outputTokens: result.usage.outputTokens,
308
711
  metadata: { type: "speech_generation" }
309
712
  });
310
- database.touchProviderKey(lease.keyId);
713
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
311
714
  return result;
312
715
  }
313
716
  catch (error) {
314
- this.handleLeasedProviderError({
717
+ return await this.handleLeasedProviderError({
315
718
  error,
316
719
  lease,
317
720
  jobId: input.jobId,
@@ -325,6 +728,41 @@ export class ProviderService {
325
728
  }
326
729
  }
327
730
  async generateImage(input) {
731
+ const attempts = await providerAttempts({
732
+ providerKeyService: this.serverlessProviderKeys,
733
+ customerId: input.customerId,
734
+ capability: "image",
735
+ requestedProvider: input.provider,
736
+ requestedModel: input.model,
737
+ supportedModels: SUPPORTED_IMAGE_MODELS,
738
+ defaultModelForProvider: (provider) => defaultModelFor("image", provider),
739
+ allowProvider: (provider) => provider !== "perplexity"
740
+ });
741
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
742
+ for (;;) {
743
+ let lastError = null;
744
+ for (const attempt of attempts) {
745
+ try {
746
+ return await this.generateImageWithProvider({
747
+ ...input,
748
+ provider: attempt.provider,
749
+ model: attempt.model
750
+ });
751
+ }
752
+ catch (error) {
753
+ lastError = error;
754
+ if (!isRecoverableProviderAttemptError(error)) {
755
+ throw error;
756
+ }
757
+ }
758
+ }
759
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
760
+ continue;
761
+ }
762
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI image provider key became available in time.");
763
+ }
764
+ }
765
+ async generateImageWithProvider(input) {
328
766
  const lease = await this.leaseCustomerKey({
329
767
  customerId: input.customerId,
330
768
  provider: input.provider,
@@ -334,7 +772,7 @@ export class ProviderService {
334
772
  try {
335
773
  if (config.mockProviders) {
336
774
  const svg = buildMockSlideSvg(input.prompt);
337
- database.recordProviderKeyUsage({
775
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
338
776
  id: createId("usage"),
339
777
  keyId: lease.keyId,
340
778
  jobId: input.jobId,
@@ -345,40 +783,40 @@ export class ProviderService {
345
783
  outputTokens: 0,
346
784
  metadata: { mock: true, type: "image_generation" }
347
785
  });
348
- database.touchProviderKey(lease.keyId);
786
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
349
787
  return {
350
788
  bytes: Buffer.from(svg, "utf8"),
351
789
  contentType: "image/svg+xml",
352
790
  revisedPrompt: input.prompt
353
791
  };
354
792
  }
355
- const result = input.provider === "gemini"
356
- ? await this.callGeminiImageGeneration({
793
+ const result = await this.withLeaseRenewal(lease, () => (input.provider === "gemini"
794
+ ? this.callGeminiImageGeneration({
357
795
  model: input.model,
358
796
  prompt: input.prompt,
359
797
  promptAttachments: input.promptAttachments,
360
- aspectRatio: input.aspectRatio,
798
+ aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
361
799
  imageSize: input.imageSize,
362
800
  apiKey: lease.secret
363
801
  })
364
802
  : input.provider === "openrouter"
365
- ? await this.callOpenRouterImageGeneration({
803
+ ? this.callOpenRouterImageGeneration({
366
804
  model: input.model,
367
805
  prompt: input.prompt,
368
806
  promptAttachments: input.promptAttachments,
369
- aspectRatio: input.aspectRatio,
807
+ aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
370
808
  imageSize: input.imageSize,
371
809
  apiKey: lease.secret
372
810
  })
373
- : await this.callOpenAIImageGeneration({
811
+ : this.callOpenAIImageGeneration({
374
812
  model: input.model,
375
813
  prompt: input.prompt,
376
814
  promptAttachments: input.promptAttachments,
377
- size: input.size,
815
+ size: normalizeOpenAIImageSize(input.size, input.aspectRatio),
378
816
  aspectRatio: input.aspectRatio,
379
817
  apiKey: lease.secret
380
- });
381
- database.recordProviderKeyUsage({
818
+ })));
819
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
382
820
  id: createId("usage"),
383
821
  keyId: lease.keyId,
384
822
  jobId: input.jobId,
@@ -389,11 +827,11 @@ export class ProviderService {
389
827
  outputTokens: 0,
390
828
  metadata: { type: "image_generation" }
391
829
  });
392
- database.touchProviderKey(lease.keyId);
830
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
393
831
  return result;
394
832
  }
395
833
  catch (error) {
396
- this.handleLeasedProviderError({
834
+ return await this.handleLeasedProviderError({
397
835
  error,
398
836
  lease,
399
837
  jobId: input.jobId,
@@ -407,6 +845,42 @@ export class ProviderService {
407
845
  }
408
846
  }
409
847
  async editImage(input) {
848
+ const attempts = await providerAttempts({
849
+ providerKeyService: this.serverlessProviderKeys,
850
+ customerId: input.customerId,
851
+ capability: "image",
852
+ requestedProvider: input.provider,
853
+ requestedModel: input.model,
854
+ supportedModels: SUPPORTED_IMAGE_MODELS,
855
+ defaultModelForProvider: (provider) => defaultModelFor("image", provider),
856
+ allowProvider: (provider) => provider !== "perplexity"
857
+ });
858
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
859
+ for (;;) {
860
+ let lastError = null;
861
+ for (const attempt of attempts) {
862
+ try {
863
+ return await this.editImageWithProvider({
864
+ ...input,
865
+ provider: attempt.provider,
866
+ model: attempt.model
867
+ });
868
+ }
869
+ catch (error) {
870
+ lastError = error;
871
+ if (!isRecoverableProviderAttemptError(error)) {
872
+ throw error;
873
+ }
874
+ }
875
+ }
876
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
877
+ continue;
878
+ }
879
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI image edit provider key became available in time.");
880
+ }
881
+ }
882
+ async editImageWithProvider(input) {
883
+ assertSupportedModel("image", input.provider, input.model, SUPPORTED_IMAGE_MODELS);
410
884
  const lease = await this.leaseCustomerKey({
411
885
  customerId: input.customerId,
412
886
  provider: input.provider,
@@ -416,7 +890,7 @@ export class ProviderService {
416
890
  try {
417
891
  if (config.mockProviders) {
418
892
  const svg = buildMockSlideSvg(`EDIT ${input.instruction}\n${input.sourceImageUrl}`);
419
- database.recordProviderKeyUsage({
893
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
420
894
  id: createId("usage"),
421
895
  keyId: lease.keyId,
422
896
  jobId: input.jobId,
@@ -427,38 +901,39 @@ export class ProviderService {
427
901
  outputTokens: 0,
428
902
  metadata: { mock: true, type: "image_edit" }
429
903
  });
430
- database.touchProviderKey(lease.keyId);
904
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
431
905
  return {
432
906
  bytes: Buffer.from(svg, "utf8"),
433
907
  contentType: "image/svg+xml",
434
908
  revisedPrompt: input.instruction
435
909
  };
436
910
  }
437
- const result = input.provider === "openai"
438
- ? await this.callOpenAIImageEdit({
911
+ const result = await this.withLeaseRenewal(lease, () => (input.provider === "openai"
912
+ ? this.callOpenAIImageEdit({
439
913
  model: input.model,
440
914
  sourceImageUrl: input.sourceImageUrl,
441
915
  instruction: input.instruction,
916
+ referenceAttachments: input.referenceAttachments,
442
917
  maskUrl: input.maskUrl,
443
- size: input.size,
918
+ size: normalizeOpenAIImageSize(input.size, input.aspectRatio),
444
919
  apiKey: lease.secret
445
920
  })
446
921
  : input.provider === "perplexity"
447
922
  ? (() => {
448
923
  throw new Error("Perplexity image edit is not supported.");
449
924
  })()
450
- : await this.callAttachmentDrivenImageEdit({
925
+ : this.callAttachmentDrivenImageEdit({
451
926
  provider: input.provider,
452
927
  model: input.model,
453
928
  sourceImageUrl: input.sourceImageUrl,
454
929
  instruction: input.instruction,
455
930
  referenceAttachments: input.referenceAttachments,
456
931
  maskUrl: input.maskUrl,
457
- aspectRatio: input.aspectRatio,
932
+ aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
458
933
  imageSize: input.imageSize,
459
934
  apiKey: lease.secret
460
- });
461
- database.recordProviderKeyUsage({
935
+ })));
936
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
462
937
  id: createId("usage"),
463
938
  keyId: lease.keyId,
464
939
  jobId: input.jobId,
@@ -469,11 +944,11 @@ export class ProviderService {
469
944
  outputTokens: 0,
470
945
  metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
471
946
  });
472
- database.touchProviderKey(lease.keyId);
947
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
473
948
  return result;
474
949
  }
475
950
  catch (error) {
476
- this.handleLeasedProviderError({
951
+ return await this.handleLeasedProviderError({
477
952
  error,
478
953
  lease,
479
954
  jobId: input.jobId,
@@ -492,7 +967,471 @@ export class ProviderService {
492
967
  maskUrl: input.maskUrl
493
968
  });
494
969
  }
970
+ async generateVideo(input) {
971
+ const attempts = await providerAttempts({
972
+ providerKeyService: this.serverlessProviderKeys,
973
+ customerId: input.customerId,
974
+ capability: "video",
975
+ requestedProvider: input.provider,
976
+ requestedModel: input.model,
977
+ supportedModels: SUPPORTED_VIDEO_MODELS,
978
+ defaultModelForProvider: (provider) => defaultModelFor("video", provider),
979
+ allowProvider: (provider) => provider !== "perplexity"
980
+ });
981
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
982
+ for (;;) {
983
+ let lastError = null;
984
+ for (const attempt of attempts) {
985
+ try {
986
+ return await this.generateVideoWithProvider({
987
+ ...input,
988
+ provider: attempt.provider,
989
+ model: attempt.model
990
+ });
991
+ }
992
+ catch (error) {
993
+ lastError = error;
994
+ if (!isRecoverableProviderAttemptError(error)) {
995
+ throw error;
996
+ }
997
+ }
998
+ }
999
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1000
+ continue;
1001
+ }
1002
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI video provider key became available in time.");
1003
+ }
1004
+ }
1005
+ async generateVideoWithProvider(input) {
1006
+ assertSupportedModel("video", input.provider, input.model, SUPPORTED_VIDEO_MODELS);
1007
+ const lease = await this.leaseCustomerKey({
1008
+ customerId: input.customerId,
1009
+ provider: input.provider,
1010
+ jobId: input.jobId,
1011
+ workerId: input.workerId,
1012
+ leaseDurationSeconds: Math.max(900, DEFAULT_PROVIDER_LEASE_SECONDS)
1013
+ });
1014
+ try {
1015
+ if (config.mockProviders) {
1016
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
1017
+ id: createId("usage"),
1018
+ keyId: lease.keyId,
1019
+ jobId: input.jobId,
1020
+ provider: input.provider,
1021
+ model: input.model,
1022
+ eventType: "success",
1023
+ inputTokens: 0,
1024
+ outputTokens: 0,
1025
+ metadata: { mock: true, type: "video_generation" }
1026
+ });
1027
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1028
+ return {
1029
+ bytes: Buffer.from(`mock video:${input.prompt.slice(0, 120)}`, "utf8"),
1030
+ contentType: "video/mp4",
1031
+ providerJob: { mock: true, model: input.model },
1032
+ usageCostUsd: 0
1033
+ };
1034
+ }
1035
+ const result = await this.withLeaseRenewal(lease, () => {
1036
+ const request = {
1037
+ model: input.model,
1038
+ prompt: input.prompt,
1039
+ duration: input.duration,
1040
+ resolution: input.resolution,
1041
+ aspectRatio: input.aspectRatio,
1042
+ size: input.size,
1043
+ generateAudio: input.generateAudio,
1044
+ frameImages: input.frameImages,
1045
+ inputReferences: input.inputReferences,
1046
+ seed: input.seed,
1047
+ providerOptions: input.providerOptions,
1048
+ apiKey: lease.secret
1049
+ };
1050
+ if (input.provider === "openai") {
1051
+ return this.callOpenAIVideoGeneration(request);
1052
+ }
1053
+ if (input.provider === "gemini") {
1054
+ return this.callGeminiVideoGeneration(request);
1055
+ }
1056
+ if (input.provider === "openrouter") {
1057
+ return this.callOpenRouterVideoGeneration(request);
1058
+ }
1059
+ throw new ProviderKeyUnavailableError(`AI video generation does not support ${input.provider} provider keys.`);
1060
+ });
1061
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
1062
+ id: createId("usage"),
1063
+ keyId: lease.keyId,
1064
+ jobId: input.jobId,
1065
+ provider: input.provider,
1066
+ model: input.model,
1067
+ eventType: "success",
1068
+ inputTokens: 0,
1069
+ outputTokens: 0,
1070
+ metadata: { type: "video_generation", usageCostUsd: result.usageCostUsd, providerJobId: result.providerJob.id ?? null }
1071
+ });
1072
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1073
+ return result;
1074
+ }
1075
+ catch (error) {
1076
+ return await this.handleLeasedProviderError({
1077
+ error,
1078
+ lease,
1079
+ jobId: input.jobId,
1080
+ provider: input.provider,
1081
+ model: input.model,
1082
+ metadata: { type: "video_generation" }
1083
+ });
1084
+ }
1085
+ finally {
1086
+ await this.releaseLease(lease);
1087
+ }
1088
+ }
1089
+ async callOpenAIVideoGeneration(input) {
1090
+ const form = new FormData();
1091
+ form.set("model", input.model);
1092
+ form.set("prompt", input.prompt);
1093
+ const size = normalizeOpenAIVideoSize(input.model, input.size, input.aspectRatio, input.resolution);
1094
+ if (size) {
1095
+ form.set("size", size);
1096
+ }
1097
+ const seconds = openAIVideoSeconds(input.duration);
1098
+ if (seconds) {
1099
+ form.set("seconds", seconds);
1100
+ }
1101
+ const referenceUrls = [
1102
+ ...(input.frameImages ?? []).map((frame) => frame.imageUrl),
1103
+ ...(input.inputReferences ?? [])
1104
+ ].slice(0, 1);
1105
+ for (const [index, url] of referenceUrls.entries()) {
1106
+ const image = await remoteImageToOpenAIVideoBlob(url, size);
1107
+ form.append("input_reference", image, openAIImageFilename(index, image.type));
1108
+ }
1109
+ const response = await fetch("https://api.openai.com/v1/videos", {
1110
+ method: "POST",
1111
+ headers: {
1112
+ Authorization: `Bearer ${input.apiKey}`
1113
+ },
1114
+ body: form
1115
+ });
1116
+ if (response.status === 401 || response.status === 403) {
1117
+ throw new ProviderAuthError("openai authentication failed");
1118
+ }
1119
+ if (response.status === 402 || response.status === 429) {
1120
+ throw await rateLimitOrQuotaError("openai", response);
1121
+ }
1122
+ if (isTransientProviderStatus(response.status)) {
1123
+ throw await transientProviderError("openai", "video generation", response);
1124
+ }
1125
+ if (!response.ok) {
1126
+ const details = await response.text();
1127
+ throw new Error(conciseProviderResponse("openai video generation", response, details));
1128
+ }
1129
+ const submitted = await response.json();
1130
+ const videoId = readStringField(submitted, "id");
1131
+ if (!videoId) {
1132
+ throw new Error("openai video generation returned no video id");
1133
+ }
1134
+ let latestStatus = submitted;
1135
+ for (let attempt = 0; attempt < 120; attempt += 1) {
1136
+ if (attempt > 0) {
1137
+ await sleep(30_000);
1138
+ }
1139
+ const statusResponse = await fetch(`https://api.openai.com/v1/videos/${encodeURIComponent(videoId)}`, {
1140
+ headers: {
1141
+ Authorization: `Bearer ${input.apiKey}`
1142
+ }
1143
+ });
1144
+ if (statusResponse.status === 401 || statusResponse.status === 403) {
1145
+ throw new ProviderAuthError("openai authentication failed");
1146
+ }
1147
+ if (statusResponse.status === 402 || statusResponse.status === 429) {
1148
+ throw await rateLimitOrQuotaError("openai", statusResponse);
1149
+ }
1150
+ if (isTransientProviderStatus(statusResponse.status)) {
1151
+ const transient = await transientProviderError("openai", "video polling", statusResponse);
1152
+ await sleep(transient.retryAfterSeconds * 1000);
1153
+ continue;
1154
+ }
1155
+ if (!statusResponse.ok) {
1156
+ const details = await statusResponse.text();
1157
+ throw new Error(conciseProviderResponse("openai video polling", statusResponse, details));
1158
+ }
1159
+ latestStatus = await statusResponse.json();
1160
+ const status = String(latestStatus.status ?? "").toLowerCase();
1161
+ if (status === "failed" || status === "cancelled" || status === "canceled" || status === "expired") {
1162
+ const details = typeof latestStatus.error === "string" ? `: ${latestStatus.error}` : "";
1163
+ throw new Error(`openai video generation ${status}${details}`);
1164
+ }
1165
+ if (status === "completed") {
1166
+ const video = await downloadAuthenticatedVideo({
1167
+ url: `https://api.openai.com/v1/videos/${encodeURIComponent(videoId)}/content`,
1168
+ apiKey: input.apiKey,
1169
+ provider: "openai"
1170
+ });
1171
+ return {
1172
+ ...video,
1173
+ providerJob: latestStatus,
1174
+ usageCostUsd: readOpenAIUsageCost(latestStatus)
1175
+ };
1176
+ }
1177
+ }
1178
+ throw new ProviderWaitExceededError("OpenAI video generation did not complete within 60 minutes.");
1179
+ }
1180
+ async callGeminiVideoGeneration(input) {
1181
+ const instance = {
1182
+ prompt: input.prompt
1183
+ };
1184
+ const firstFrameUrl = input.frameImages?.find((frame) => frame.frameType === "first_frame")?.imageUrl ??
1185
+ input.inputReferences?.[0];
1186
+ if (firstFrameUrl) {
1187
+ const { bytes, mimeType } = await fetchRemoteBytes({ url: firstFrameUrl, fallbackMimeType: "image/png" });
1188
+ instance.image = {
1189
+ bytesBase64Encoded: bytes.toString("base64"),
1190
+ mimeType
1191
+ };
1192
+ }
1193
+ const parameters = {
1194
+ sampleCount: 1
1195
+ };
1196
+ const aspectRatio = geminiVideoAspectRatio(input.aspectRatio);
1197
+ if (aspectRatio) {
1198
+ parameters.aspectRatio = aspectRatio;
1199
+ }
1200
+ const resolution = geminiVideoResolution(input.resolution);
1201
+ if (resolution) {
1202
+ parameters.resolution = resolution;
1203
+ }
1204
+ const durationSeconds = geminiVideoDurationSeconds(input.duration);
1205
+ if (durationSeconds) {
1206
+ parameters.durationSeconds = durationSeconds;
1207
+ }
1208
+ if (input.generateAudio !== undefined) {
1209
+ parameters.generateAudio = input.generateAudio;
1210
+ }
1211
+ if (input.seed !== undefined) {
1212
+ parameters.seed = input.seed;
1213
+ }
1214
+ if (input.providerOptions) {
1215
+ Object.assign(parameters, input.providerOptions);
1216
+ }
1217
+ const baseUrl = "https://generativelanguage.googleapis.com/v1beta";
1218
+ const response = await fetch(`${baseUrl}/models/${encodeURIComponent(input.model)}:predictLongRunning`, {
1219
+ method: "POST",
1220
+ headers: {
1221
+ "Content-Type": "application/json",
1222
+ "x-goog-api-key": input.apiKey
1223
+ },
1224
+ body: JSON.stringify({
1225
+ instances: [instance],
1226
+ parameters
1227
+ })
1228
+ });
1229
+ if (response.status === 401 || response.status === 403) {
1230
+ throw new ProviderAuthError("gemini authentication failed");
1231
+ }
1232
+ if (response.status === 402 || response.status === 429) {
1233
+ throw await rateLimitOrQuotaError("gemini", response);
1234
+ }
1235
+ if (!response.ok) {
1236
+ const details = await response.text();
1237
+ throw new Error(`gemini video generation returned ${response.status}${details ? `: ${details}` : ""}`);
1238
+ }
1239
+ const submitted = await response.json();
1240
+ const operationName = readStringField(submitted, "name");
1241
+ if (!operationName) {
1242
+ throw new Error("gemini video generation returned no operation name");
1243
+ }
1244
+ let latestStatus = submitted;
1245
+ for (let attempt = 0; attempt < 120; attempt += 1) {
1246
+ if (attempt > 0) {
1247
+ await sleep(30_000);
1248
+ }
1249
+ const statusResponse = await fetch(`${baseUrl}/${operationName}`, {
1250
+ headers: {
1251
+ "x-goog-api-key": input.apiKey
1252
+ }
1253
+ });
1254
+ if (statusResponse.status === 401 || statusResponse.status === 403) {
1255
+ throw new ProviderAuthError("gemini authentication failed");
1256
+ }
1257
+ if (statusResponse.status === 402 || statusResponse.status === 429) {
1258
+ throw await rateLimitOrQuotaError("gemini", statusResponse);
1259
+ }
1260
+ if (!statusResponse.ok) {
1261
+ const details = await statusResponse.text();
1262
+ throw new Error(`gemini video polling returned ${statusResponse.status}${details ? `: ${details}` : ""}`);
1263
+ }
1264
+ latestStatus = await statusResponse.json();
1265
+ if (latestStatus.error) {
1266
+ throw new Error(`gemini video generation failed: ${JSON.stringify(latestStatus.error).slice(0, 500)}`);
1267
+ }
1268
+ if (latestStatus.done === true) {
1269
+ const videoUrl = findGeminiVideoUri(latestStatus);
1270
+ if (videoUrl) {
1271
+ const video = await downloadGeminiVideo(videoUrl, input.apiKey);
1272
+ return {
1273
+ ...video,
1274
+ providerJob: latestStatus,
1275
+ usageCostUsd: 0
1276
+ };
1277
+ }
1278
+ const inline = findGeminiInlineVideo(latestStatus);
1279
+ if (inline) {
1280
+ return {
1281
+ bytes: inline.bytes,
1282
+ contentType: inline.contentType,
1283
+ providerJob: latestStatus,
1284
+ usageCostUsd: 0
1285
+ };
1286
+ }
1287
+ throw new Error("gemini video generation completed without a video URI or inline video bytes");
1288
+ }
1289
+ }
1290
+ throw new ProviderWaitExceededError("Gemini video generation did not complete within 60 minutes.");
1291
+ }
1292
+ async callOpenRouterVideoGeneration(input) {
1293
+ const requestBody = {
1294
+ model: input.model,
1295
+ prompt: input.prompt
1296
+ };
1297
+ if (input.aspectRatio) {
1298
+ requestBody.aspect_ratio = input.aspectRatio;
1299
+ }
1300
+ if (input.duration !== undefined) {
1301
+ requestBody.duration = input.duration;
1302
+ }
1303
+ if (input.resolution) {
1304
+ requestBody.resolution = input.resolution;
1305
+ }
1306
+ if (input.size) {
1307
+ requestBody.size = input.size;
1308
+ }
1309
+ if (input.generateAudio !== undefined) {
1310
+ requestBody.generate_audio = input.generateAudio;
1311
+ }
1312
+ if (input.frameImages?.length) {
1313
+ requestBody.frame_images = input.frameImages.map((frame) => ({
1314
+ image_url: frame.imageUrl,
1315
+ frame_type: frame.frameType
1316
+ }));
1317
+ }
1318
+ if (input.inputReferences?.length) {
1319
+ requestBody.input_references = input.inputReferences;
1320
+ }
1321
+ if (input.seed !== undefined) {
1322
+ requestBody.seed = input.seed;
1323
+ }
1324
+ if (input.providerOptions && Object.keys(input.providerOptions).length > 0) {
1325
+ requestBody.provider = input.providerOptions;
1326
+ }
1327
+ const response = await fetch("https://openrouter.ai/api/v1/videos", {
1328
+ method: "POST",
1329
+ headers: {
1330
+ "Content-Type": "application/json",
1331
+ Authorization: `Bearer ${input.apiKey}`
1332
+ },
1333
+ body: JSON.stringify(requestBody)
1334
+ });
1335
+ if (response.status === 401 || response.status === 403) {
1336
+ throw new ProviderAuthError("openrouter authentication failed");
1337
+ }
1338
+ if (response.status === 402 || response.status === 429) {
1339
+ throw await rateLimitOrQuotaError("openrouter", response);
1340
+ }
1341
+ if (!response.ok) {
1342
+ const details = await response.text();
1343
+ throw new Error(`openrouter video generation returned ${response.status}${details ? `: ${details}` : ""}`);
1344
+ }
1345
+ const submitted = await response.json();
1346
+ const immediateVideoUrl = findOpenRouterVideoUrl(submitted);
1347
+ if (immediateVideoUrl) {
1348
+ const video = await downloadOpenRouterVideo(immediateVideoUrl, input.apiKey);
1349
+ return {
1350
+ ...video,
1351
+ providerJob: submitted,
1352
+ usageCostUsd: readOpenRouterUsageCost(submitted)
1353
+ };
1354
+ }
1355
+ const statusUrl = normalizeOpenRouterUrl(readStringField(submitted, "polling_url") ?? readStringField(submitted, "status_url")) ??
1356
+ openRouterVideoStatusUrl(readStringField(submitted, "id"));
1357
+ if (!statusUrl) {
1358
+ throw new Error("openrouter video generation returned no polling URL or job id");
1359
+ }
1360
+ let latestStatus = submitted;
1361
+ for (let attempt = 0; attempt < 120; attempt += 1) {
1362
+ if (attempt > 0) {
1363
+ await sleep(30_000);
1364
+ }
1365
+ const statusResponse = await fetch(statusUrl, {
1366
+ headers: {
1367
+ Authorization: `Bearer ${input.apiKey}`
1368
+ }
1369
+ });
1370
+ if (statusResponse.status === 401 || statusResponse.status === 403) {
1371
+ throw new ProviderAuthError("openrouter authentication failed");
1372
+ }
1373
+ if (statusResponse.status === 402 || statusResponse.status === 429) {
1374
+ throw await rateLimitOrQuotaError("openrouter", statusResponse);
1375
+ }
1376
+ if (!statusResponse.ok) {
1377
+ const details = await statusResponse.text();
1378
+ throw new Error(`openrouter video polling returned ${statusResponse.status}${details ? `: ${details}` : ""}`);
1379
+ }
1380
+ latestStatus = await statusResponse.json();
1381
+ const status = String(latestStatus.status ?? "").toLowerCase();
1382
+ if (status === "failed" || status === "cancelled" || status === "canceled") {
1383
+ const details = typeof latestStatus.error === "string" ? `: ${latestStatus.error}` : "";
1384
+ throw new Error(`openrouter video generation ${status}${details}`);
1385
+ }
1386
+ const videoUrl = findOpenRouterVideoUrl(latestStatus);
1387
+ if (status === "completed" && videoUrl) {
1388
+ const video = await downloadOpenRouterVideo(videoUrl, input.apiKey);
1389
+ return {
1390
+ ...video,
1391
+ providerJob: latestStatus,
1392
+ usageCostUsd: readOpenRouterUsageCost(latestStatus)
1393
+ };
1394
+ }
1395
+ }
1396
+ throw new ProviderWaitExceededError("OpenRouter video generation did not complete within 60 minutes.");
1397
+ }
495
1398
  async analyzeImageLayout(input) {
1399
+ const attempts = await providerAttempts({
1400
+ providerKeyService: this.serverlessProviderKeys,
1401
+ customerId: input.customerId,
1402
+ capability: "layout_analysis",
1403
+ requestedProvider: input.provider,
1404
+ requestedModel: input.model,
1405
+ supportedModels: SUPPORTED_TEXT_MODELS,
1406
+ defaultModelForProvider: (provider) => defaultModelFor("text", provider),
1407
+ allowProvider: (provider) => provider !== "perplexity"
1408
+ });
1409
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
1410
+ for (;;) {
1411
+ let lastError = null;
1412
+ for (const attempt of attempts) {
1413
+ try {
1414
+ return await this.analyzeImageLayoutWithProvider({
1415
+ ...input,
1416
+ provider: attempt.provider,
1417
+ model: attempt.model
1418
+ });
1419
+ }
1420
+ catch (error) {
1421
+ lastError = error;
1422
+ if (!isRecoverableProviderAttemptError(error)) {
1423
+ throw error;
1424
+ }
1425
+ }
1426
+ }
1427
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1428
+ continue;
1429
+ }
1430
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI layout-analysis provider key became available in time.");
1431
+ }
1432
+ }
1433
+ async analyzeImageLayoutWithProvider(input) {
1434
+ assertSupportedModel("layout_analysis", input.provider, input.model, SUPPORTED_TEXT_MODELS);
496
1435
  const lease = await this.leaseCustomerKey({
497
1436
  customerId: input.customerId,
498
1437
  provider: input.provider,
@@ -501,7 +1440,7 @@ export class ProviderService {
501
1440
  });
502
1441
  try {
503
1442
  if (config.mockProviders) {
504
- database.recordProviderKeyUsage({
1443
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
505
1444
  id: createId("usage"),
506
1445
  keyId: lease.keyId,
507
1446
  jobId: input.jobId,
@@ -512,7 +1451,7 @@ export class ProviderService {
512
1451
  outputTokens: 0,
513
1452
  metadata: { mock: true, type: "layout_analysis" }
514
1453
  });
515
- database.touchProviderKey(lease.keyId);
1454
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
516
1455
  return {
517
1456
  zone: "bottom",
518
1457
  align: "center",
@@ -520,25 +1459,25 @@ export class ProviderService {
520
1459
  justification: "Mock mode defaults to bottom-center for readable slideshow captions."
521
1460
  };
522
1461
  }
523
- const raw = input.provider === "gemini"
524
- ? await this.callGeminiLayoutAnalysis({
1462
+ const raw = await this.withLeaseRenewal(lease, () => (input.provider === "gemini"
1463
+ ? this.callGeminiLayoutAnalysis({
525
1464
  model: input.model,
526
1465
  imageUrl: input.imageUrl,
527
1466
  overlayText: input.overlayText,
528
1467
  apiKey: lease.secret
529
1468
  })
530
- : await this.callOpenAICompatibleLayoutAnalysis({
1469
+ : this.callOpenAICompatibleLayoutAnalysis({
531
1470
  provider: input.provider === "openrouter" ? "openrouter" : "openai",
532
1471
  model: input.model,
533
1472
  imageUrl: input.imageUrl,
534
1473
  overlayText: input.overlayText,
535
1474
  apiKey: lease.secret
536
- });
1475
+ })));
537
1476
  const parsed = parseJson(raw, null);
538
1477
  if (!parsed?.zone || !parsed?.align) {
539
1478
  throw new Error(`${input.provider} layout analysis returned invalid JSON`);
540
1479
  }
541
- database.recordProviderKeyUsage({
1480
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
542
1481
  id: createId("usage"),
543
1482
  keyId: lease.keyId,
544
1483
  jobId: input.jobId,
@@ -549,7 +1488,7 @@ export class ProviderService {
549
1488
  outputTokens: 0,
550
1489
  metadata: { type: "layout_analysis" }
551
1490
  });
552
- database.touchProviderKey(lease.keyId);
1491
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
553
1492
  return {
554
1493
  zone: parsed.zone,
555
1494
  align: parsed.align,
@@ -558,7 +1497,7 @@ export class ProviderService {
558
1497
  };
559
1498
  }
560
1499
  catch (error) {
561
- this.handleLeasedProviderError({
1500
+ return await this.handleLeasedProviderError({
562
1501
  error,
563
1502
  lease,
564
1503
  jobId: input.jobId,
@@ -572,6 +1511,40 @@ export class ProviderService {
572
1511
  }
573
1512
  }
574
1513
  async transcribeSpeech(input) {
1514
+ const attempts = await providerAttempts({
1515
+ providerKeyService: this.serverlessProviderKeys,
1516
+ customerId: input.customerId,
1517
+ capability: "stt",
1518
+ requestedProvider: input.provider,
1519
+ requestedModel: input.model,
1520
+ supportedModels: SUPPORTED_STT_MODELS,
1521
+ defaultModelForProvider: (provider) => defaultSpeechModelFor("stt", provider)
1522
+ });
1523
+ const retryDeadlineMs = providerLeaseRetryDeadlineMs();
1524
+ for (;;) {
1525
+ let lastError = null;
1526
+ for (const attempt of attempts) {
1527
+ try {
1528
+ return await this.transcribeSpeechWithProvider({
1529
+ ...input,
1530
+ provider: attempt.provider,
1531
+ model: attempt.model
1532
+ });
1533
+ }
1534
+ catch (error) {
1535
+ lastError = error;
1536
+ if (!isRecoverableProviderAttemptError(error)) {
1537
+ throw error;
1538
+ }
1539
+ }
1540
+ }
1541
+ if (await waitForRetryableProviderError(lastError, retryDeadlineMs)) {
1542
+ continue;
1543
+ }
1544
+ throw lastError instanceof Error ? lastError : new ProviderLeaseTimeoutError("No eligible AI speech-transcription provider key became available in time.");
1545
+ }
1546
+ }
1547
+ async transcribeSpeechWithProvider(input) {
575
1548
  assertSupportedSpeechModel("stt", input.provider, input.model);
576
1549
  const lease = await this.leaseCustomerKey({
577
1550
  customerId: input.customerId,
@@ -581,7 +1554,7 @@ export class ProviderService {
581
1554
  });
582
1555
  try {
583
1556
  if (config.mockProviders) {
584
- database.recordProviderKeyUsage({
1557
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
585
1558
  id: createId("usage"),
586
1559
  keyId: lease.keyId,
587
1560
  jobId: input.jobId,
@@ -592,7 +1565,7 @@ export class ProviderService {
592
1565
  outputTokens: 0,
593
1566
  metadata: { mock: true, type: "speech_transcription" }
594
1567
  });
595
- database.touchProviderKey(lease.keyId);
1568
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
596
1569
  return {
597
1570
  text: "Mock transcription output.",
598
1571
  usage: {
@@ -602,8 +1575,8 @@ export class ProviderService {
602
1575
  }
603
1576
  };
604
1577
  }
605
- const result = zeroUsageCost(input.provider === "gemini"
606
- ? await this.callGeminiSpeechTranscription({
1578
+ const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => input.provider === "gemini"
1579
+ ? this.callGeminiSpeechTranscription({
607
1580
  model: input.model,
608
1581
  audio: input.audio,
609
1582
  contentType: input.contentType,
@@ -612,7 +1585,7 @@ export class ProviderService {
612
1585
  apiKey: lease.secret
613
1586
  })
614
1587
  : input.provider === "openrouter"
615
- ? await this.callOpenRouterSpeechTranscription({
1588
+ ? this.callOpenRouterSpeechTranscription({
616
1589
  model: input.model,
617
1590
  audio: input.audio,
618
1591
  contentType: input.contentType,
@@ -620,15 +1593,15 @@ export class ProviderService {
620
1593
  language: input.language,
621
1594
  apiKey: lease.secret
622
1595
  })
623
- : await this.callOpenAISpeechTranscription({
1596
+ : this.callOpenAISpeechTranscription({
624
1597
  model: input.model,
625
1598
  audio: input.audio,
626
1599
  contentType: input.contentType,
627
1600
  prompt: input.prompt,
628
1601
  language: input.language,
629
1602
  apiKey: lease.secret
630
- }));
631
- database.recordProviderKeyUsage({
1603
+ })));
1604
+ await this.serverlessProviderKeys.recordProviderKeyUsage({
632
1605
  id: createId("usage"),
633
1606
  keyId: lease.keyId,
634
1607
  jobId: input.jobId,
@@ -639,11 +1612,11 @@ export class ProviderService {
639
1612
  outputTokens: result.usage.outputTokens,
640
1613
  metadata: { type: "speech_transcription" }
641
1614
  });
642
- database.touchProviderKey(lease.keyId);
1615
+ await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
643
1616
  return result;
644
1617
  }
645
1618
  catch (error) {
646
- this.handleLeasedProviderError({
1619
+ return await this.handleLeasedProviderError({
647
1620
  error,
648
1621
  lease,
649
1622
  jobId: input.jobId,
@@ -657,15 +1630,21 @@ export class ProviderService {
657
1630
  }
658
1631
  }
659
1632
  async callOpenAIImageGeneration(input) {
660
- if (input.promptAttachments?.length) {
661
- throw new Error("OpenAI image generation attachments are not implemented in this provider path. Use Gemini for reference attachments.");
662
- }
663
1633
  const prompt = input.aspectRatio === "9:16"
664
1634
  ? [
665
1635
  input.prompt,
666
1636
  "Return a native 9:16 vertical composition with no letterboxing, no inset landscape window, and no empty top or bottom padding."
667
1637
  ].join("\n")
668
1638
  : input.prompt;
1639
+ if (input.promptAttachments?.length) {
1640
+ return this.callOpenAIImageEditRequest({
1641
+ model: input.model,
1642
+ prompt,
1643
+ imageUrls: input.promptAttachments,
1644
+ size: input.size ?? "1024x1536",
1645
+ apiKey: input.apiKey
1646
+ });
1647
+ }
669
1648
  const response = await fetch("https://api.openai.com/v1/images/generations", {
670
1649
  method: "POST",
671
1650
  headers: {
@@ -682,7 +1661,7 @@ export class ProviderService {
682
1661
  throw new ProviderAuthError("openai authentication failed");
683
1662
  }
684
1663
  if (response.status === 429) {
685
- throw new ProviderRateLimitError("openai rate limited");
1664
+ throw await rateLimitOrQuotaError("openai", response);
686
1665
  }
687
1666
  if (!response.ok) {
688
1667
  const details = await response.text();
@@ -700,11 +1679,28 @@ export class ProviderService {
700
1679
  };
701
1680
  }
702
1681
  async callOpenAIImageEdit(input) {
1682
+ return this.callOpenAIImageEditRequest({
1683
+ model: input.model,
1684
+ prompt: input.instruction,
1685
+ imageUrls: [input.sourceImageUrl, ...(input.referenceAttachments ?? [])],
1686
+ maskUrl: input.maskUrl,
1687
+ size: input.size ?? "auto",
1688
+ apiKey: input.apiKey
1689
+ });
1690
+ }
1691
+ async callOpenAIImageEditRequest(input) {
1692
+ if (input.imageUrls.length > 16) {
1693
+ throw new Error("OpenAI image edits support at most 16 input images.");
1694
+ }
703
1695
  const form = new FormData();
704
1696
  form.set("model", input.model);
705
- form.set("prompt", input.instruction);
706
- form.set("size", input.size ?? "1024x1024");
707
- form.set("image", await remoteImageToBlob(input.sourceImageUrl));
1697
+ form.set("prompt", input.prompt);
1698
+ form.set("size", input.size);
1699
+ const imageFieldName = input.imageUrls.length > 1 ? "image[]" : "image";
1700
+ for (let index = 0; index < input.imageUrls.length; index += 1) {
1701
+ const image = await remoteImageToBlob(input.imageUrls[index]);
1702
+ form.append(imageFieldName, image, openAIImageFilename(index, image.type));
1703
+ }
708
1704
  if (input.maskUrl) {
709
1705
  form.set("mask", await remoteImageToBlob(input.maskUrl));
710
1706
  }
@@ -719,7 +1715,7 @@ export class ProviderService {
719
1715
  throw new ProviderAuthError("openai authentication failed");
720
1716
  }
721
1717
  if (response.status === 429) {
722
- throw new ProviderRateLimitError("openai rate limited");
1718
+ throw await rateLimitOrQuotaError("openai", response);
723
1719
  }
724
1720
  if (!response.ok) {
725
1721
  const details = await response.text();
@@ -799,7 +1795,7 @@ export class ProviderService {
799
1795
  throw new ProviderAuthError("gemini authentication failed");
800
1796
  }
801
1797
  if (response.status === 429) {
802
- throw new ProviderRateLimitError("gemini rate limited");
1798
+ throw await rateLimitOrQuotaError("gemini", response);
803
1799
  }
804
1800
  if (!response.ok) {
805
1801
  const details = await response.text();
@@ -818,68 +1814,44 @@ export class ProviderService {
818
1814
  };
819
1815
  }
820
1816
  async callOpenRouterImageGeneration(input) {
821
- const content = [
822
- { type: "text", text: input.prompt },
823
- ...(input.promptAttachments ?? []).map((url) => ({
824
- type: "image_url",
825
- image_url: { url }
826
- }))
827
- ];
828
- const imageConfig = {};
1817
+ const requestBody = {
1818
+ model: input.model,
1819
+ prompt: input.prompt
1820
+ };
1821
+ if (input.promptAttachments?.length) {
1822
+ requestBody.input_references = input.promptAttachments;
1823
+ }
829
1824
  if (input.aspectRatio) {
830
- imageConfig.aspect_ratio = input.aspectRatio;
1825
+ requestBody.aspect_ratio = input.aspectRatio;
831
1826
  }
832
1827
  if (input.imageSize) {
833
- imageConfig.image_size = input.imageSize;
1828
+ requestBody.image_size = input.imageSize;
834
1829
  }
835
- const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
1830
+ const response = await fetch("https://openrouter.ai/api/v1/images", {
836
1831
  method: "POST",
837
1832
  headers: {
838
1833
  "Content-Type": "application/json",
839
1834
  Authorization: `Bearer ${input.apiKey}`
840
1835
  },
841
- body: JSON.stringify({
842
- model: input.model,
843
- messages: [
844
- {
845
- role: "user",
846
- content
847
- }
848
- ],
849
- modalities: ["image", "text"],
850
- stream: false,
851
- ...(Object.keys(imageConfig).length
852
- ? {
853
- image_config: imageConfig
854
- }
855
- : {})
856
- })
1836
+ body: JSON.stringify(requestBody)
857
1837
  });
858
1838
  if (response.status === 401 || response.status === 403) {
859
1839
  throw new ProviderAuthError("openrouter authentication failed");
860
1840
  }
861
1841
  if (response.status === 429) {
862
- throw new ProviderRateLimitError("openrouter rate limited");
1842
+ throw await rateLimitOrQuotaError("openrouter", response);
863
1843
  }
864
1844
  if (!response.ok) {
865
1845
  const details = await response.text();
866
1846
  throw new Error(`openrouter image generation returned ${response.status}${details ? `: ${details}` : ""}`);
867
1847
  }
868
1848
  const data = await response.json();
869
- const imageUrl = data.choices?.[0]?.message?.images?.[0]?.image_url?.url ??
870
- data.choices?.[0]?.message?.images?.[0]?.imageUrl?.url;
871
- if (!imageUrl) {
872
- throw new Error("openrouter image generation returned no image payload");
873
- }
874
- const parsed = decodeInlineDataUrl(imageUrl);
875
- if (!parsed) {
876
- throw new Error("openrouter image generation returned an unsupported image payload");
1849
+ const image = await parseOpenRouterImageGenerationPayload(data);
1850
+ if (!image) {
1851
+ const details = conciseOpenRouterImageResponse(data);
1852
+ throw new Error(`openrouter image generation returned no image payload${details ? `: ${details}` : ""}`);
877
1853
  }
878
- return {
879
- bytes: parsed.bytes,
880
- contentType: parsed.contentType,
881
- revisedPrompt: data.choices?.[0]?.message?.content ?? null
882
- };
1854
+ return image;
883
1855
  }
884
1856
  async callOpenAICompatibleSpeechGeneration(input) {
885
1857
  const requestedFormat = input.responseFormat ?? "mp3";
@@ -908,7 +1880,7 @@ export class ProviderService {
908
1880
  throw new ProviderAuthError(`${input.provider} authentication failed`);
909
1881
  }
910
1882
  if (response.status === 429) {
911
- throw new ProviderRateLimitError(`${input.provider} rate limited`);
1883
+ throw await rateLimitOrQuotaError(input.provider, response);
912
1884
  }
913
1885
  if (!response.ok) {
914
1886
  const details = await response.text();
@@ -956,7 +1928,7 @@ export class ProviderService {
956
1928
  throw new ProviderAuthError("gemini authentication failed");
957
1929
  }
958
1930
  if (response.status === 429) {
959
- throw new ProviderRateLimitError("gemini rate limited");
1931
+ throw await rateLimitOrQuotaError("gemini", response);
960
1932
  }
961
1933
  if (!response.ok) {
962
1934
  const details = await response.text();
@@ -1034,7 +2006,7 @@ export class ProviderService {
1034
2006
  throw new ProviderAuthError(`${input.provider} authentication failed`);
1035
2007
  }
1036
2008
  if (response.status === 429) {
1037
- throw new ProviderRateLimitError(`${input.provider} rate limited`);
2009
+ throw await rateLimitOrQuotaError(input.provider, response);
1038
2010
  }
1039
2011
  if (!response.ok) {
1040
2012
  throw new Error(`${input.provider} layout analysis returned ${response.status}`);
@@ -1087,7 +2059,7 @@ export class ProviderService {
1087
2059
  throw new ProviderAuthError("gemini authentication failed");
1088
2060
  }
1089
2061
  if (response.status === 429) {
1090
- throw new ProviderRateLimitError("gemini rate limited");
2062
+ throw await rateLimitOrQuotaError("gemini", response);
1091
2063
  }
1092
2064
  if (!response.ok) {
1093
2065
  throw new Error(`gemini layout analysis returned ${response.status}`);
@@ -1119,7 +2091,7 @@ export class ProviderService {
1119
2091
  throw new ProviderAuthError("openai authentication failed");
1120
2092
  }
1121
2093
  if (response.status === 429) {
1122
- throw new ProviderRateLimitError("openai rate limited");
2094
+ throw await rateLimitOrQuotaError("openai", response);
1123
2095
  }
1124
2096
  if (!response.ok) {
1125
2097
  const details = await response.text();
@@ -1156,7 +2128,7 @@ export class ProviderService {
1156
2128
  throw new ProviderAuthError("openrouter authentication failed");
1157
2129
  }
1158
2130
  if (response.status === 429) {
1159
- throw new ProviderRateLimitError("openrouter rate limited");
2131
+ throw await rateLimitOrQuotaError("openrouter", response);
1160
2132
  }
1161
2133
  if (!response.ok) {
1162
2134
  const details = await response.text();
@@ -1202,7 +2174,7 @@ export class ProviderService {
1202
2174
  throw new ProviderAuthError("gemini authentication failed");
1203
2175
  }
1204
2176
  if (response.status === 429) {
1205
- throw new ProviderRateLimitError("gemini rate limited");
2177
+ throw await rateLimitOrQuotaError("gemini", response);
1206
2178
  }
1207
2179
  if (!response.ok) {
1208
2180
  const details = await response.text();
@@ -1225,6 +2197,7 @@ export class ProviderService {
1225
2197
  model: input.model,
1226
2198
  prompt: input.prompt,
1227
2199
  temperature: input.temperature,
2200
+ responseFormat: input.responseFormat,
1228
2201
  apiKey: input.apiKey
1229
2202
  });
1230
2203
  }
@@ -1245,6 +2218,7 @@ export class ProviderService {
1245
2218
  body: JSON.stringify({
1246
2219
  model: input.model,
1247
2220
  temperature: input.temperature ?? 0.7,
2221
+ ...(input.responseFormat === "json" ? { response_format: { type: "json_object" } } : {}),
1248
2222
  messages: [{ role: "user", content: input.prompt }]
1249
2223
  })
1250
2224
  });
@@ -1252,7 +2226,7 @@ export class ProviderService {
1252
2226
  throw new ProviderAuthError(`${input.provider} authentication failed`);
1253
2227
  }
1254
2228
  if (response.status === 429) {
1255
- throw new ProviderRateLimitError(`${input.provider} rate limited`);
2229
+ throw await rateLimitOrQuotaError(input.provider, response);
1256
2230
  }
1257
2231
  if (!response.ok) {
1258
2232
  throw new Error(`${input.provider} returned ${response.status}`);
@@ -1276,7 +2250,8 @@ export class ProviderService {
1276
2250
  body: JSON.stringify({
1277
2251
  contents: [{ parts: [{ text: input.prompt }] }],
1278
2252
  generationConfig: {
1279
- temperature: input.temperature ?? 0.7
2253
+ temperature: input.temperature ?? 0.7,
2254
+ ...(input.responseFormat === "json" ? { responseMimeType: "application/json" } : {})
1280
2255
  }
1281
2256
  })
1282
2257
  });
@@ -1284,7 +2259,7 @@ export class ProviderService {
1284
2259
  throw new ProviderAuthError("Gemini authentication failed");
1285
2260
  }
1286
2261
  if (response.status === 429) {
1287
- throw new ProviderRateLimitError("Gemini rate limited");
2262
+ throw await rateLimitOrQuotaError("gemini", response);
1288
2263
  }
1289
2264
  if (!response.ok) {
1290
2265
  throw new Error(`Gemini returned ${response.status}`);
@@ -1333,16 +2308,285 @@ function formatProviderLabel(provider) {
1333
2308
  return "Perplexity";
1334
2309
  }
1335
2310
  }
1336
- function assertSupportedSpeechModel(kind, provider, model) {
1337
- const normalizedModel = model.trim();
2311
+ function readStringField(value, field) {
2312
+ const raw = value[field];
2313
+ return typeof raw === "string" && raw.trim() ? raw.trim() : null;
2314
+ }
2315
+ function normalizeOpenRouterUrl(value) {
2316
+ if (!value) {
2317
+ return null;
2318
+ }
2319
+ if (/^https?:\/\//i.test(value)) {
2320
+ return value;
2321
+ }
2322
+ if (value.startsWith("/")) {
2323
+ return `https://openrouter.ai${value}`;
2324
+ }
2325
+ return `https://openrouter.ai/${value}`;
2326
+ }
2327
+ function openRouterVideoStatusUrl(jobId) {
2328
+ return jobId ? `https://openrouter.ai/api/v1/videos/${encodeURIComponent(jobId)}` : null;
2329
+ }
2330
+ function findOpenRouterVideoUrl(value) {
2331
+ if (!value || typeof value !== "object") {
2332
+ return null;
2333
+ }
2334
+ if (Array.isArray(value)) {
2335
+ for (const item of value) {
2336
+ const found = typeof item === "string" ? item : findOpenRouterVideoUrl(item);
2337
+ if (found && isOpenRouterVideoContentUrl(found)) {
2338
+ return normalizeOpenRouterUrl(found);
2339
+ }
2340
+ }
2341
+ return null;
2342
+ }
2343
+ const record = value;
2344
+ const unsignedUrls = record.unsigned_urls;
2345
+ if (Array.isArray(unsignedUrls)) {
2346
+ const found = unsignedUrls.find((item) => typeof item === "string" && item.trim().length > 0);
2347
+ if (found) {
2348
+ return normalizeOpenRouterUrl(found);
2349
+ }
2350
+ }
2351
+ for (const key of ["video_url", "output_url", "download_url", "file_url", "content_url"]) {
2352
+ const raw = record[key];
2353
+ if (typeof raw === "string" && raw.trim()) {
2354
+ return normalizeOpenRouterUrl(raw.trim());
2355
+ }
2356
+ }
2357
+ return null;
2358
+ }
2359
+ function isOpenRouterVideoContentUrl(value) {
2360
+ return /^https?:\/\//i.test(value) || value.startsWith("/");
2361
+ }
2362
+ function readOpenRouterUsageCost(value) {
2363
+ const usage = value.usage;
2364
+ if (usage && typeof usage === "object") {
2365
+ const cost = Number(usage.cost ?? usage.total_cost);
2366
+ if (Number.isFinite(cost) && cost > 0) {
2367
+ return cost;
2368
+ }
2369
+ }
2370
+ const cost = Number(value.cost ?? value.total_cost);
2371
+ return Number.isFinite(cost) && cost > 0 ? cost : 0;
2372
+ }
2373
+ function readOpenAIUsageCost(value) {
2374
+ const usage = value.usage;
2375
+ if (usage && typeof usage === "object") {
2376
+ const cost = Number(usage.cost ?? usage.total_cost);
2377
+ if (Number.isFinite(cost) && cost > 0) {
2378
+ return cost;
2379
+ }
2380
+ }
2381
+ return 0;
2382
+ }
2383
+ function normalizeOpenAIVideoSize(model, size, aspectRatio, resolution) {
2384
+ const requested = size ?? openAIVideoSizeForAspect(aspectRatio, resolution);
2385
+ if (!requested) {
2386
+ return undefined;
2387
+ }
2388
+ const parsed = parsePixelSize(requested);
2389
+ const requestedAspect = parsed
2390
+ ? (parsed.width >= parsed.height ? "16:9" : "9:16")
2391
+ : aspectRatio;
2392
+ const isPortrait = requestedAspect !== "16:9";
2393
+ if (model === "sora-2-pro") {
2394
+ if (requested === "1024x1792" || requested === "1792x1024" || requested === "720x1280" || requested === "1280x720") {
2395
+ return requested;
2396
+ }
2397
+ return resolution === "1080p"
2398
+ ? isPortrait ? "1024x1792" : "1792x1024"
2399
+ : isPortrait ? "720x1280" : "1280x720";
2400
+ }
2401
+ if (model === "sora-2") {
2402
+ return isPortrait ? "720x1280" : "1280x720";
2403
+ }
2404
+ return requested;
2405
+ }
2406
+ function openAIVideoSizeForAspect(aspectRatio, resolution) {
2407
+ if (resolution === "1080p") {
2408
+ if (aspectRatio === "9:16")
2409
+ return "1024x1792";
2410
+ if (aspectRatio === "16:9")
2411
+ return "1792x1024";
2412
+ }
2413
+ if (aspectRatio === "9:16") {
2414
+ return "720x1280";
2415
+ }
2416
+ if (aspectRatio === "16:9") {
2417
+ return "1280x720";
2418
+ }
2419
+ return undefined;
2420
+ }
2421
+ function openAIVideoSeconds(duration) {
2422
+ if (!duration) {
2423
+ return undefined;
2424
+ }
2425
+ if (duration >= 10) {
2426
+ return "12";
2427
+ }
2428
+ if (duration >= 6) {
2429
+ return "8";
2430
+ }
2431
+ return "4";
2432
+ }
2433
+ function geminiVideoAspectRatio(aspectRatio) {
2434
+ return aspectRatio === "9:16" ? "9:16" : aspectRatio === "16:9" ? "16:9" : undefined;
2435
+ }
2436
+ function geminiVideoResolution(resolution) {
2437
+ return resolution === "1080p" ? "1080p" : resolution === "720p" ? "720p" : undefined;
2438
+ }
2439
+ function geminiVideoDurationSeconds(duration) {
2440
+ if (!duration) {
2441
+ return undefined;
2442
+ }
2443
+ if (duration >= 7) {
2444
+ return 8;
2445
+ }
2446
+ if (duration >= 5) {
2447
+ return 6;
2448
+ }
2449
+ return 4;
2450
+ }
2451
+ function findGeminiVideoUri(value) {
2452
+ if (!value || typeof value !== "object") {
2453
+ return null;
2454
+ }
2455
+ if (Array.isArray(value)) {
2456
+ for (const item of value) {
2457
+ const found = findGeminiVideoUri(item);
2458
+ if (found) {
2459
+ return found;
2460
+ }
2461
+ }
2462
+ return null;
2463
+ }
2464
+ const record = value;
2465
+ for (const key of ["uri", "videoUri", "fileUri", "downloadUri"]) {
2466
+ const raw = record[key];
2467
+ if (typeof raw === "string" && /^https?:\/\//i.test(raw)) {
2468
+ return raw;
2469
+ }
2470
+ }
2471
+ for (const nested of Object.values(record)) {
2472
+ const found = findGeminiVideoUri(nested);
2473
+ if (found) {
2474
+ return found;
2475
+ }
2476
+ }
2477
+ return null;
2478
+ }
2479
+ function findGeminiInlineVideo(value) {
2480
+ if (!value || typeof value !== "object") {
2481
+ return null;
2482
+ }
2483
+ if (Array.isArray(value)) {
2484
+ for (const item of value) {
2485
+ const found = findGeminiInlineVideo(item);
2486
+ if (found) {
2487
+ return found;
2488
+ }
2489
+ }
2490
+ return null;
2491
+ }
2492
+ const record = value;
2493
+ const encoded = typeof record.videoBytes === "string" ? record.videoBytes :
2494
+ typeof record.bytesBase64Encoded === "string" ? record.bytesBase64Encoded :
2495
+ typeof record.data === "string" ? record.data :
2496
+ null;
2497
+ if (encoded) {
2498
+ return {
2499
+ bytes: Buffer.from(encoded, "base64"),
2500
+ contentType: typeof record.mimeType === "string" ? record.mimeType : "video/mp4"
2501
+ };
2502
+ }
2503
+ for (const nested of Object.values(record)) {
2504
+ const found = findGeminiInlineVideo(nested);
2505
+ if (found) {
2506
+ return found;
2507
+ }
2508
+ }
2509
+ return null;
2510
+ }
2511
+ async function downloadAuthenticatedVideo(input) {
2512
+ const headers = input.provider === "openai"
2513
+ ? { Authorization: `Bearer ${input.apiKey}` }
2514
+ : { "x-goog-api-key": input.apiKey };
2515
+ const response = await fetch(input.url, { headers });
2516
+ if (response.status === 401 || response.status === 403) {
2517
+ throw new ProviderAuthError(`${input.provider} authentication failed`);
2518
+ }
2519
+ if (response.status === 402 || response.status === 429) {
2520
+ throw await rateLimitOrQuotaError(input.provider, response);
2521
+ }
2522
+ if (!response.ok) {
2523
+ const details = await response.text();
2524
+ throw new Error(`${input.provider} video download returned ${response.status}${details ? `: ${details}` : ""}`);
2525
+ }
2526
+ return {
2527
+ bytes: Buffer.from(await response.arrayBuffer()),
2528
+ contentType: response.headers.get("content-type")?.split(";")[0]?.trim() || inferVideoContentType(input.url)
2529
+ };
2530
+ }
2531
+ async function downloadGeminiVideo(url, apiKey) {
2532
+ return downloadAuthenticatedVideo({
2533
+ url,
2534
+ apiKey,
2535
+ provider: "gemini"
2536
+ });
2537
+ }
2538
+ async function downloadOpenRouterVideo(url, apiKey) {
2539
+ const includeAuth = /^https:\/\/openrouter\.ai\//i.test(url);
2540
+ const response = await fetch(url, includeAuth
2541
+ ? {
2542
+ headers: {
2543
+ Authorization: `Bearer ${apiKey}`
2544
+ }
2545
+ }
2546
+ : {});
2547
+ if (response.status === 401 || response.status === 403) {
2548
+ throw new ProviderAuthError("openrouter authentication failed");
2549
+ }
2550
+ if (response.status === 402 || response.status === 429) {
2551
+ throw await rateLimitOrQuotaError("openrouter", response);
2552
+ }
2553
+ if (!response.ok) {
2554
+ const details = await response.text();
2555
+ throw new Error(`openrouter video download returned ${response.status}${details ? `: ${details}` : ""}`);
2556
+ }
2557
+ const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() || inferVideoContentType(url);
2558
+ return {
2559
+ bytes: Buffer.from(await response.arrayBuffer()),
2560
+ contentType
2561
+ };
2562
+ }
2563
+ function inferVideoContentType(url) {
2564
+ if (/\.webm(\?|#|$)/i.test(url)) {
2565
+ return "video/webm";
2566
+ }
2567
+ if (/\.mov(\?|#|$)/i.test(url)) {
2568
+ return "video/quicktime";
2569
+ }
2570
+ return "video/mp4";
2571
+ }
2572
+ function assertSupportedModel(kind, provider, model, supportedModels) {
2573
+ const normalizedModel = normalizeProviderModel(model);
1338
2574
  if (!normalizedModel) {
1339
- throw new Error(`Missing ${kind.toUpperCase()} model for provider ${provider}.`);
2575
+ throw new Error(`Missing ${kind} model for provider ${provider}.`);
1340
2576
  }
1341
- const speechCapableProviders = ["openai", "gemini", "openrouter"];
1342
- if (!speechCapableProviders.includes(provider)) {
1343
- throw new Error(`Provider ${provider} does not support ${kind.toUpperCase()} requests.`);
2577
+ const allowedModels = supportedModels[provider];
2578
+ if (!allowedModels?.has(normalizedModel)) {
2579
+ const allowed = allowedModels ? [...allowedModels].join(", ") : "none";
2580
+ throw new Error(`Unsupported ${kind} model "${normalizedModel}" for provider ${provider}. ` +
2581
+ `Templates must use Vidfarm-approved models only. Allowed models: ${allowed}.`);
1344
2582
  }
1345
2583
  }
2584
+ function assertSupportedSpeechModel(kind, provider, model) {
2585
+ assertSupportedModel(kind, provider, model, kind === "tts" ? SUPPORTED_TTS_MODELS : SUPPORTED_STT_MODELS);
2586
+ }
2587
+ function normalizeProviderModel(model) {
2588
+ return model.trim().replace(/^models\//, "");
2589
+ }
1346
2590
  function inferSpeechContentType(format) {
1347
2591
  return format === "wav" ? "audio/wav" : "audio/mpeg";
1348
2592
  }
@@ -1421,18 +2665,87 @@ function supportsGeminiImageSize(model) {
1421
2665
  return model === "gemini-3.1-flash-image-preview" || model === "gemini-3-pro-image-preview";
1422
2666
  }
1423
2667
  async function fetchRemoteBytes(input) {
1424
- const response = await fetch(input.url);
1425
- if (!response.ok) {
1426
- throw new Error(`Could not fetch remote media ${input.url}: ${response.status}`);
2668
+ const maxAttempts = isVidfarmTemplateMediaUrl(input.url) ? 6 : 4;
2669
+ let lastFailure = null;
2670
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
2671
+ let response;
2672
+ try {
2673
+ response = await fetch(input.url);
2674
+ }
2675
+ catch (error) {
2676
+ lastFailure = error instanceof Error ? error.message : String(error);
2677
+ if (attempt < maxAttempts - 1) {
2678
+ await sleep(remoteMediaRetryDelayMs(null, attempt));
2679
+ continue;
2680
+ }
2681
+ throw new ProviderTransientError(`Could not fetch remote media ${input.url} after ${maxAttempts} attempts: ${lastFailure}`, 5);
2682
+ }
2683
+ if (response.ok) {
2684
+ const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || input.fallbackMimeType || "application/octet-stream";
2685
+ const bytes = Buffer.from(await response.arrayBuffer());
2686
+ return { bytes, mimeType };
2687
+ }
2688
+ lastFailure = String(response.status);
2689
+ if (!isRetryableRemoteMediaStatus(response.status, input.url)) {
2690
+ throw new Error(`Could not fetch remote media ${input.url}: ${response.status}`);
2691
+ }
2692
+ if (attempt < maxAttempts - 1) {
2693
+ await sleep(remoteMediaRetryDelayMs(response, attempt));
2694
+ continue;
2695
+ }
2696
+ }
2697
+ throw new ProviderTransientError(`Could not fetch remote media ${input.url} after ${maxAttempts} attempts: ${lastFailure ?? "unknown error"}`, 5);
2698
+ }
2699
+ function isRetryableRemoteMediaStatus(status, url) {
2700
+ return isTransientRemoteMediaStatus(status)
2701
+ || (isVidfarmTemplateMediaUrl(url) && (status === 403 || status === 404));
2702
+ }
2703
+ function isVidfarmTemplateMediaUrl(value) {
2704
+ try {
2705
+ const url = new URL(value);
2706
+ return url.pathname === "/template-media" && Boolean(url.searchParams.get("key"));
2707
+ }
2708
+ catch {
2709
+ return false;
1427
2710
  }
1428
- const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || input.fallbackMimeType || "application/octet-stream";
1429
- const bytes = Buffer.from(await response.arrayBuffer());
1430
- return { bytes, mimeType };
1431
2711
  }
1432
2712
  async function remoteImageToBlob(url) {
1433
2713
  const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
1434
2714
  return new Blob([bytes], { type: mimeType });
1435
2715
  }
2716
+ async function remoteImageToOpenAIVideoBlob(url, size) {
2717
+ const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
2718
+ if (!mimeType.toLowerCase().startsWith("image/")) {
2719
+ throw new Error(`OpenAI video input references must be direct image URLs. Received ${mimeType || "unknown content"} from ${url}.`);
2720
+ }
2721
+ const parsedSize = parsePixelSize(size);
2722
+ if (!parsedSize) {
2723
+ return new Blob([bytes], { type: "image/png" });
2724
+ }
2725
+ const normalized = await sharp(bytes)
2726
+ .resize(parsedSize.width, parsedSize.height, {
2727
+ fit: "cover",
2728
+ position: "center"
2729
+ })
2730
+ .png()
2731
+ .toBuffer();
2732
+ return new Blob([new Uint8Array(normalized)], { type: "image/png" });
2733
+ }
2734
+ function parsePixelSize(size) {
2735
+ const match = /^(\d+)x(\d+)$/i.exec(size ?? "");
2736
+ if (!match) {
2737
+ return null;
2738
+ }
2739
+ const width = Number(match[1]);
2740
+ const height = Number(match[2]);
2741
+ return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
2742
+ ? { width, height }
2743
+ : null;
2744
+ }
2745
+ function openAIImageFilename(index, contentType) {
2746
+ const extension = contentType === "image/jpeg" ? "jpg" : contentType === "image/webp" ? "webp" : "png";
2747
+ return `reference-${index + 1}.${extension}`;
2748
+ }
1436
2749
  async function buildGeminiPromptParts(prompt, attachments) {
1437
2750
  const parts = [];
1438
2751
  for (const attachment of attachments) {
@@ -1460,6 +2773,74 @@ function decodeInlineDataUrl(value) {
1460
2773
  bytes: Buffer.from(match[2], "base64")
1461
2774
  };
1462
2775
  }
2776
+ async function parseOpenRouterImageGenerationPayload(data) {
2777
+ const imageData = Array.isArray(data.data) ? data.data[0] : undefined;
2778
+ const b64Json = typeof imageData?.b64_json === "string" ? imageData.b64_json : null;
2779
+ if (b64Json) {
2780
+ return {
2781
+ bytes: Buffer.from(b64Json, "base64"),
2782
+ contentType: "image/png",
2783
+ revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readStringField(data, "revised_prompt")
2784
+ };
2785
+ }
2786
+ const url = readOptionalStringField(imageData, "url") ??
2787
+ readOptionalStringField(imageData, "image_url") ??
2788
+ readLegacyOpenRouterChatImageUrl(data);
2789
+ if (!url) {
2790
+ return null;
2791
+ }
2792
+ const inline = decodeInlineDataUrl(url);
2793
+ if (inline) {
2794
+ return {
2795
+ bytes: inline.bytes,
2796
+ contentType: inline.contentType,
2797
+ revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readLegacyOpenRouterChatContent(data)
2798
+ };
2799
+ }
2800
+ if (/^https?:\/\//i.test(url)) {
2801
+ const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
2802
+ return {
2803
+ bytes,
2804
+ contentType: mimeType,
2805
+ revisedPrompt: readOptionalStringField(imageData, "revised_prompt") ?? readLegacyOpenRouterChatContent(data)
2806
+ };
2807
+ }
2808
+ return null;
2809
+ }
2810
+ function readOptionalStringField(value, field) {
2811
+ return value ? readStringField(value, field) : null;
2812
+ }
2813
+ function readLegacyOpenRouterChatImageUrl(data) {
2814
+ const choices = Array.isArray(data.choices) ? data.choices : [];
2815
+ const choice = choices[0];
2816
+ const message = choice?.message && typeof choice.message === "object"
2817
+ ? choice.message
2818
+ : null;
2819
+ const images = Array.isArray(message?.images) ? message.images : [];
2820
+ const firstImage = images[0];
2821
+ const snake = firstImage?.image_url && typeof firstImage.image_url === "object"
2822
+ ? firstImage.image_url
2823
+ : null;
2824
+ const camel = firstImage?.imageUrl && typeof firstImage.imageUrl === "object"
2825
+ ? firstImage.imageUrl
2826
+ : null;
2827
+ return (snake ? readStringField(snake, "url") : null) ?? (camel ? readStringField(camel, "url") : null);
2828
+ }
2829
+ function readLegacyOpenRouterChatContent(data) {
2830
+ const choices = Array.isArray(data.choices) ? data.choices : [];
2831
+ const choice = choices[0];
2832
+ const message = choice?.message && typeof choice.message === "object"
2833
+ ? choice.message
2834
+ : null;
2835
+ return message ? readStringField(message, "content") : null;
2836
+ }
2837
+ function conciseOpenRouterImageResponse(data) {
2838
+ const message = readLegacyOpenRouterChatContent(data);
2839
+ if (message) {
2840
+ return message.replace(/\s+/g, " ").trim().slice(0, 500);
2841
+ }
2842
+ return JSON.stringify(data).replace(/\s+/g, " ").slice(0, 500);
2843
+ }
1463
2844
  function buildMockSlideSvg(prompt) {
1464
2845
  const escaped = escapeXml(prompt.slice(0, 220));
1465
2846
  return `
@@ -1486,3 +2867,4 @@ function escapeXml(value) {
1486
2867
  .replace(/"/g, "&quot;")
1487
2868
  .replace(/'/g, "&apos;");
1488
2869
  }
2870
+ //# sourceMappingURL=providers.js.map