@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.
- package/.env.example +37 -1
- package/README.md +27 -183
- package/SKILL.director.md +293 -0
- package/SKILL.platform.md +312 -0
- package/auto-create-hyperframe-templates/AUTO_CREATE_HYPERFRAME_TEMPLATES.md +275 -0
- package/auto-create-hyperframe-templates/extractor-system-prompt.md +128 -0
- package/auto-create-hyperframe-templates/input-manifest.schema.json +56 -0
- package/auto-create-hyperframe-templates/lambda-memory-estimator.md +118 -0
- package/auto-create-hyperframe-templates/production-graph.schema.json +314 -0
- package/auto-create-hyperframe-templates/runbook.md +198 -0
- package/auto-create-hyperframe-templates/scripts/create-hyperframe-template.mjs +368 -0
- package/auto-create-hyperframe-templates/template-plan.schema.json +311 -0
- package/auto-create-hyperframe-templates/template-planner-prompt.md +144 -0
- package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +116 -0
- package/auto-create-templates/extractor-system-prompt.md +6 -1
- package/auto-create-templates/template-plan.schema.json +73 -2
- package/auto-create-templates/template-planner-prompt.md +62 -1
- package/dist/src/account-pages-legacy.js +361 -89
- package/dist/src/account-pages-legacy.js.map +1 -0
- package/dist/src/account-pages.js +13 -3
- package/dist/src/account-pages.js.map +1 -0
- package/dist/src/app.js +7060 -946
- package/dist/src/app.js.map +1 -0
- package/dist/src/cli.js +110 -29
- package/dist/src/cli.js.map +1 -0
- package/dist/src/composition-runtime.js +581 -0
- package/dist/src/composition-runtime.js.map +1 -0
- package/dist/src/config.js +69 -11
- package/dist/src/config.js.map +1 -0
- package/dist/src/context.js +280 -21
- package/dist/src/context.js.map +1 -0
- package/dist/src/dev-app-legacy.js +18 -17
- package/dist/src/dev-app-legacy.js.map +1 -0
- package/dist/src/dev-app.js +1 -0
- package/dist/src/dev-app.js.map +1 -0
- package/dist/src/domain.js +2 -1
- package/dist/src/domain.js.map +1 -0
- package/dist/src/editor-chat-history.js +10 -0
- package/dist/src/editor-chat-history.js.map +1 -0
- package/dist/src/editor-chat.js +133 -24
- package/dist/src/editor-chat.js.map +1 -0
- package/dist/src/frontend/flockposter-cache-store.js +10 -2
- package/dist/src/frontend/flockposter-cache-store.js.map +1 -0
- package/dist/src/frontend/homepage-client.js +6 -3
- package/dist/src/frontend/homepage-client.js.map +1 -0
- package/dist/src/frontend/homepage-shared.js +1 -0
- package/dist/src/frontend/homepage-shared.js.map +1 -0
- package/dist/src/frontend/homepage-store.js +1 -0
- package/dist/src/frontend/homepage-store.js.map +1 -0
- package/dist/src/frontend/homepage-view.js +131 -18
- package/dist/src/frontend/homepage-view.js.map +1 -0
- package/dist/src/frontend/page-runtime-client.js +5 -2
- package/dist/src/frontend/page-runtime-client.js.map +1 -0
- package/dist/src/frontend/page-runtime-store.js +1 -0
- package/dist/src/frontend/page-runtime-store.js.map +1 -0
- package/dist/src/frontend/sentry.js +42 -0
- package/dist/src/frontend/sentry.js.map +1 -0
- package/dist/src/frontend/template-editor-chat.js +1719 -213
- package/dist/src/frontend/template-editor-chat.js.map +1 -0
- package/dist/src/help-page.js +333 -0
- package/dist/src/help-page.js.map +1 -0
- package/dist/src/homepage.js +169 -47
- package/dist/src/homepage.js.map +1 -0
- package/dist/src/hyperframes/composition.js +180 -0
- package/dist/src/hyperframes/composition.js.map +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/instrument.js +30 -0
- package/dist/src/instrument.js.map +1 -0
- package/dist/src/lib/crypto.js +1 -0
- package/dist/src/lib/crypto.js.map +1 -0
- package/dist/src/lib/dev-log.js +54 -0
- package/dist/src/lib/dev-log.js.map +1 -0
- package/dist/src/lib/display-name.js +11 -0
- package/dist/src/lib/display-name.js.map +1 -0
- package/dist/src/lib/ids.js +21 -1
- package/dist/src/lib/ids.js.map +1 -0
- package/dist/src/lib/images.js +1 -0
- package/dist/src/lib/images.js.map +1 -0
- package/dist/src/lib/json.js +1 -0
- package/dist/src/lib/json.js.map +1 -0
- package/dist/src/lib/template-dna.js +1 -0
- package/dist/src/lib/template-dna.js.map +1 -0
- package/dist/src/lib/template-paths.js +1 -0
- package/dist/src/lib/template-paths.js.map +1 -0
- package/dist/src/lib/template-style-options.js +29 -3
- package/dist/src/lib/template-style-options.js.map +1 -0
- package/dist/src/lib/time.js +1 -0
- package/dist/src/lib/time.js.map +1 -0
- package/dist/src/lib/video-quality-harness.js +60 -0
- package/dist/src/lib/video-quality-harness.js.map +1 -0
- package/dist/src/page-runtime.js +1 -0
- package/dist/src/page-runtime.js.map +1 -0
- package/dist/src/page-shell.js +1501 -49
- package/dist/src/page-shell.js.map +1 -0
- package/dist/src/primitive-context.js +144 -8
- package/dist/src/primitive-context.js.map +1 -0
- package/dist/src/primitive-registry.js +1919 -78
- package/dist/src/primitive-registry.js.map +1 -0
- package/dist/src/primitive-sdk.js +1 -0
- package/dist/src/primitive-sdk.js.map +1 -0
- package/dist/src/primitives/remotion/html-image.js +3 -1
- package/dist/src/primitives/remotion/html-image.js.map +1 -0
- package/dist/src/primitives/remotion/media-slideshow.js +60 -0
- package/dist/src/primitives/remotion/media-slideshow.js.map +1 -0
- package/dist/src/react-page-shell.js +1 -0
- package/dist/src/react-page-shell.js.map +1 -0
- package/dist/src/ready-post-schedule-component.js +1 -0
- package/dist/src/ready-post-schedule-component.js.map +1 -0
- package/dist/src/registry.js +237 -24
- package/dist/src/registry.js.map +1 -0
- package/dist/src/runtime.js +3 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/services/api-call-history.js +4 -0
- package/dist/src/services/api-call-history.js.map +1 -0
- package/dist/src/services/auth.js +40 -50
- package/dist/src/services/auth.js.map +1 -0
- package/dist/src/services/billing.js +20 -44
- package/dist/src/services/billing.js.map +1 -0
- package/dist/src/services/chat-threads.js +10 -6
- package/dist/src/services/chat-threads.js.map +1 -0
- package/dist/src/services/fork-access.js +94 -0
- package/dist/src/services/fork-access.js.map +1 -0
- package/dist/src/services/ghostcut.js +132 -0
- package/dist/src/services/ghostcut.js.map +1 -0
- package/dist/src/services/hyperframes.js +1014 -0
- package/dist/src/services/hyperframes.js.map +1 -0
- package/dist/src/services/job-capacity.js +14 -0
- package/dist/src/services/job-capacity.js.map +1 -0
- package/dist/src/services/job-logs.js +4 -0
- package/dist/src/services/job-logs.js.map +1 -0
- package/dist/src/services/jobs.js +99 -91
- package/dist/src/services/jobs.js.map +1 -0
- package/dist/src/services/media-processing.js +743 -0
- package/dist/src/services/media-processing.js.map +1 -0
- package/dist/src/services/primitive-media-lambda.js +280 -0
- package/dist/src/services/primitive-media-lambda.js.map +1 -0
- package/dist/src/services/providers.js +1560 -178
- package/dist/src/services/providers.js.map +1 -0
- package/dist/src/services/rate-limits.js +3 -2
- package/dist/src/services/rate-limits.js.map +1 -0
- package/dist/src/services/remotion.js +495 -92
- package/dist/src/services/remotion.js.map +1 -0
- package/dist/src/services/serverless-auth.js +374 -0
- package/dist/src/services/serverless-auth.js.map +1 -0
- package/dist/src/services/serverless-jobs.js +1074 -0
- package/dist/src/services/serverless-jobs.js.map +1 -0
- package/dist/src/services/serverless-provider-keys.js +401 -0
- package/dist/src/services/serverless-provider-keys.js.map +1 -0
- package/dist/src/services/serverless-records.js +1086 -0
- package/dist/src/services/serverless-records.js.map +1 -0
- package/dist/src/services/serverless-template-configs.js +67 -0
- package/dist/src/services/serverless-template-configs.js.map +1 -0
- package/dist/src/services/storage.js +170 -35
- package/dist/src/services/storage.js.map +1 -0
- package/dist/src/services/template-certification.js +295 -3
- package/dist/src/services/template-certification.js.map +1 -0
- package/dist/src/services/template-loader.js +45 -1
- package/dist/src/services/template-loader.js.map +1 -0
- package/dist/src/services/template-runtime-bundles.js +217 -0
- package/dist/src/services/template-runtime-bundles.js.map +1 -0
- package/dist/src/services/template-sources.js +452 -87
- package/dist/src/services/template-sources.js.map +1 -0
- package/dist/src/services/video-normalization.js +2 -0
- package/dist/src/services/video-normalization.js.map +1 -0
- package/dist/src/services/webhooks.js +7 -6
- package/dist/src/services/webhooks.js.map +1 -0
- package/dist/src/template-editor-pages.js +5637 -1322
- package/dist/src/template-editor-pages.js.map +1 -0
- package/dist/src/template-editor-shell.js +1376 -170
- package/dist/src/template-editor-shell.js.map +1 -0
- package/dist/src/template-sdk.js +1 -0
- package/dist/src/template-sdk.js.map +1 -0
- package/dist/src/worker.js +11 -226
- package/dist/src/worker.js.map +1 -0
- package/package.json +47 -12
- package/public/assets/homepage-client-app.js +34 -8
- package/public/assets/page-runtime-client-app.js +44 -18
- package/readme.secret.md +89 -0
- package/templates/vidfarm_template_0000/README.md +106 -0
- package/templates/vidfarm_template_0000/SKILL.md +266 -0
- package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
- package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
- package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
- package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
- package/templates/vidfarm_template_0000/composition.json +11 -0
- package/templates/vidfarm_template_0000/src/lib/images.js +221 -0
- package/templates/vidfarm_template_0000/src/remotion/Root.js +33 -0
- package/templates/vidfarm_template_0000/src/remotion/index.js +3 -0
- package/templates/vidfarm_template_0000/src/sdk.js +3 -0
- package/templates/vidfarm_template_0000/src/style-options.js +200 -0
- package/templates/vidfarm_template_0000/src/template-dna.js +9 -0
- package/templates/vidfarm_template_0000/src/template.js +1566 -0
- package/templates/vidfarm_template_0000/template.config.json +21 -0
- package/GETTING_STARTED.developers.md +0 -87
- package/SKILL.developer.md +0 -1186
- 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
|
-
|
|
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 =
|
|
64
|
-
const CHAT_PROVIDER_LEASE_TIMEOUT_MS =
|
|
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 =
|
|
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
|
-
|
|
349
|
+
serverlessProviderKeys = new ServerlessProviderKeyService();
|
|
350
|
+
async handleLeasedProviderError(input) {
|
|
70
351
|
if (input.error instanceof ProviderRateLimitError) {
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
+
await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, null, "invalid", "auth_error");
|
|
96
389
|
}
|
|
97
390
|
throw input.error;
|
|
98
391
|
}
|
|
99
|
-
listAvailableProviders(
|
|
392
|
+
listAvailableProviders(_customerId) {
|
|
393
|
+
return PROVIDER_FALLBACK_PRIORITY;
|
|
394
|
+
}
|
|
395
|
+
async listAvailableProvidersAsync(customerId) {
|
|
100
396
|
const now = Date.now();
|
|
101
|
-
const providers =
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
+
await this.serverlessProviderKeys.releaseProviderKeyLease({ keyId: lease.keyId, leaseToken: lease.leaseToken });
|
|
174
492
|
}
|
|
175
493
|
async leaseEditorChatKey(input) {
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
706
|
-
form.set("size", input.size
|
|
707
|
-
|
|
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
|
|
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
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
1825
|
+
requestBody.aspect_ratio = input.aspectRatio;
|
|
831
1826
|
}
|
|
832
1827
|
if (input.imageSize) {
|
|
833
|
-
|
|
1828
|
+
requestBody.image_size = input.imageSize;
|
|
834
1829
|
}
|
|
835
|
-
const response = await fetch("https://openrouter.ai/api/v1/
|
|
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
|
|
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
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
throw new Error(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1337
|
-
const
|
|
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
|
|
2575
|
+
throw new Error(`Missing ${kind} model for provider ${provider}.`);
|
|
1340
2576
|
}
|
|
1341
|
-
const
|
|
1342
|
-
if (!
|
|
1343
|
-
|
|
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
|
|
1425
|
-
|
|
1426
|
-
|
|
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, """)
|
|
1487
2868
|
.replace(/'/g, "'");
|
|
1488
2869
|
}
|
|
2870
|
+
//# sourceMappingURL=providers.js.map
|