@mevdragon/vidfarm-devcli 0.2.13 → 0.3.0
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/README.md +11 -4
- package/SKILL.director.md +97 -0
- package/SKILL.platform.md +66 -6
- package/dist/src/cli.js +137 -1524
- package/dist/src/dev-serve.js +169 -0
- package/package.json +5 -24
- package/.env.example +0 -60
- package/auto-create-hyperframe-templates/AUTO_CREATE_HYPERFRAME_TEMPLATES.md +0 -275
- package/auto-create-hyperframe-templates/extractor-system-prompt.md +0 -128
- package/auto-create-hyperframe-templates/input-manifest.schema.json +0 -56
- package/auto-create-hyperframe-templates/lambda-memory-estimator.md +0 -118
- package/auto-create-hyperframe-templates/production-graph.schema.json +0 -314
- package/auto-create-hyperframe-templates/runbook.md +0 -198
- package/auto-create-hyperframe-templates/scripts/create-hyperframe-template.mjs +0 -368
- package/auto-create-hyperframe-templates/template-plan.schema.json +0 -311
- package/auto-create-hyperframe-templates/template-planner-prompt.md +0 -144
- package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +0 -599
- package/auto-create-templates/extractor-system-prompt.md +0 -119
- package/auto-create-templates/production-graph.schema.json +0 -276
- package/auto-create-templates/runbook.md +0 -140
- package/auto-create-templates/template-plan.schema.json +0 -301
- package/auto-create-templates/template-planner-prompt.md +0 -137
- package/dist/src/account-pages-legacy.js +0 -9336
- package/dist/src/account-pages-legacy.js.map +0 -1
- package/dist/src/account-pages.js +0 -61
- package/dist/src/account-pages.js.map +0 -1
- package/dist/src/app.js +0 -14023
- package/dist/src/app.js.map +0 -1
- package/dist/src/cli.js.map +0 -1
- package/dist/src/composition-runtime.js +0 -581
- package/dist/src/composition-runtime.js.map +0 -1
- package/dist/src/config.js +0 -140
- package/dist/src/config.js.map +0 -1
- package/dist/src/context.js +0 -492
- package/dist/src/context.js.map +0 -1
- package/dist/src/dev-app-legacy.js +0 -694
- package/dist/src/dev-app-legacy.js.map +0 -1
- package/dist/src/dev-app.js +0 -6
- package/dist/src/dev-app.js.map +0 -1
- package/dist/src/domain.js +0 -2
- package/dist/src/domain.js.map +0 -1
- package/dist/src/editor-chat-history.js +0 -82
- package/dist/src/editor-chat-history.js.map +0 -1
- package/dist/src/editor-chat.js +0 -311
- package/dist/src/editor-chat.js.map +0 -1
- package/dist/src/frontend/flockposter-cache-store.js +0 -124
- package/dist/src/frontend/flockposter-cache-store.js.map +0 -1
- package/dist/src/frontend/homepage-client.js +0 -117
- package/dist/src/frontend/homepage-client.js.map +0 -1
- package/dist/src/frontend/homepage-shared.js +0 -4
- package/dist/src/frontend/homepage-shared.js.map +0 -1
- package/dist/src/frontend/homepage-store.js +0 -28
- package/dist/src/frontend/homepage-store.js.map +0 -1
- package/dist/src/frontend/homepage-view.js +0 -260
- package/dist/src/frontend/homepage-view.js.map +0 -1
- package/dist/src/frontend/page-runtime-client.js +0 -103
- package/dist/src/frontend/page-runtime-client.js.map +0 -1
- package/dist/src/frontend/page-runtime-store.js +0 -9
- package/dist/src/frontend/page-runtime-store.js.map +0 -1
- package/dist/src/frontend/sentry.js +0 -42
- package/dist/src/frontend/sentry.js.map +0 -1
- package/dist/src/frontend/template-editor-chat.js +0 -3767
- package/dist/src/frontend/template-editor-chat.js.map +0 -1
- package/dist/src/help-page.js +0 -333
- package/dist/src/help-page.js.map +0 -1
- package/dist/src/homepage.js +0 -919
- package/dist/src/homepage.js.map +0 -1
- package/dist/src/hyperframes/composition.js +0 -180
- package/dist/src/hyperframes/composition.js.map +0 -1
- package/dist/src/index.js +0 -16
- package/dist/src/index.js.map +0 -1
- package/dist/src/instrument.js +0 -30
- package/dist/src/instrument.js.map +0 -1
- package/dist/src/lib/crypto.js +0 -45
- package/dist/src/lib/crypto.js.map +0 -1
- package/dist/src/lib/dev-log.js +0 -54
- package/dist/src/lib/dev-log.js.map +0 -1
- package/dist/src/lib/display-name.js +0 -11
- package/dist/src/lib/display-name.js.map +0 -1
- package/dist/src/lib/ids.js +0 -24
- package/dist/src/lib/ids.js.map +0 -1
- package/dist/src/lib/images.js +0 -19
- package/dist/src/lib/images.js.map +0 -1
- package/dist/src/lib/json.js +0 -15
- package/dist/src/lib/json.js.map +0 -1
- package/dist/src/lib/template-dna.js +0 -543
- package/dist/src/lib/template-dna.js.map +0 -1
- package/dist/src/lib/template-paths.js +0 -28
- package/dist/src/lib/template-paths.js.map +0 -1
- package/dist/src/lib/template-style-options.js +0 -203
- package/dist/src/lib/template-style-options.js.map +0 -1
- package/dist/src/lib/time.js +0 -7
- package/dist/src/lib/time.js.map +0 -1
- package/dist/src/lib/video-quality-harness.js +0 -60
- package/dist/src/lib/video-quality-harness.js.map +0 -1
- package/dist/src/page-runtime.js +0 -2
- package/dist/src/page-runtime.js.map +0 -1
- package/dist/src/page-shell.js +0 -2393
- package/dist/src/page-shell.js.map +0 -1
- package/dist/src/primitive-context.js +0 -299
- package/dist/src/primitive-context.js.map +0 -1
- package/dist/src/primitive-registry.js +0 -2181
- package/dist/src/primitive-registry.js.map +0 -1
- package/dist/src/primitive-sdk.js +0 -4
- package/dist/src/primitive-sdk.js.map +0 -1
- package/dist/src/primitives/remotion/html-image.js +0 -30
- package/dist/src/primitives/remotion/html-image.js.map +0 -1
- package/dist/src/primitives/remotion/media-slideshow.js +0 -60
- package/dist/src/primitives/remotion/media-slideshow.js.map +0 -1
- package/dist/src/react-page-shell.js +0 -35
- package/dist/src/react-page-shell.js.map +0 -1
- package/dist/src/ready-post-schedule-component.js +0 -1515
- package/dist/src/ready-post-schedule-component.js.map +0 -1
- package/dist/src/registry.js +0 -296
- package/dist/src/registry.js.map +0 -1
- package/dist/src/runtime.js +0 -31
- package/dist/src/runtime.js.map +0 -1
- package/dist/src/services/api-call-history.js +0 -249
- package/dist/src/services/api-call-history.js.map +0 -1
- package/dist/src/services/auth.js +0 -152
- package/dist/src/services/auth.js.map +0 -1
- package/dist/src/services/billing.js +0 -231
- package/dist/src/services/billing.js.map +0 -1
- package/dist/src/services/chat-threads.js +0 -92
- package/dist/src/services/chat-threads.js.map +0 -1
- package/dist/src/services/fork-access.js +0 -94
- package/dist/src/services/fork-access.js.map +0 -1
- package/dist/src/services/ghostcut.js +0 -132
- package/dist/src/services/ghostcut.js.map +0 -1
- package/dist/src/services/hyperframes.js +0 -1014
- package/dist/src/services/hyperframes.js.map +0 -1
- package/dist/src/services/job-capacity.js +0 -14
- package/dist/src/services/job-capacity.js.map +0 -1
- package/dist/src/services/job-logs.js +0 -197
- package/dist/src/services/job-logs.js.map +0 -1
- package/dist/src/services/jobs.js +0 -136
- package/dist/src/services/jobs.js.map +0 -1
- package/dist/src/services/media-processing.js +0 -743
- package/dist/src/services/media-processing.js.map +0 -1
- package/dist/src/services/primitive-media-lambda.js +0 -280
- package/dist/src/services/primitive-media-lambda.js.map +0 -1
- package/dist/src/services/providers.js +0 -2870
- package/dist/src/services/providers.js.map +0 -1
- package/dist/src/services/rate-limits.js +0 -237
- package/dist/src/services/rate-limits.js.map +0 -1
- package/dist/src/services/remotion.js +0 -688
- package/dist/src/services/remotion.js.map +0 -1
- package/dist/src/services/serverless-auth.js +0 -374
- package/dist/src/services/serverless-auth.js.map +0 -1
- package/dist/src/services/serverless-jobs.js +0 -1074
- package/dist/src/services/serverless-jobs.js.map +0 -1
- package/dist/src/services/serverless-provider-keys.js +0 -401
- package/dist/src/services/serverless-provider-keys.js.map +0 -1
- package/dist/src/services/serverless-records.js +0 -1086
- package/dist/src/services/serverless-records.js.map +0 -1
- package/dist/src/services/serverless-template-configs.js +0 -67
- package/dist/src/services/serverless-template-configs.js.map +0 -1
- package/dist/src/services/storage.js +0 -382
- package/dist/src/services/storage.js.map +0 -1
- package/dist/src/services/template-certification.js +0 -475
- package/dist/src/services/template-certification.js.map +0 -1
- package/dist/src/services/template-loader.js +0 -99
- package/dist/src/services/template-loader.js.map +0 -1
- package/dist/src/services/template-runtime-bundles.js +0 -217
- package/dist/src/services/template-runtime-bundles.js.map +0 -1
- package/dist/src/services/template-sources.js +0 -1017
- package/dist/src/services/template-sources.js.map +0 -1
- package/dist/src/services/video-normalization.js +0 -2
- package/dist/src/services/video-normalization.js.map +0 -1
- package/dist/src/services/webhooks.js +0 -62
- package/dist/src/services/webhooks.js.map +0 -1
- package/dist/src/template-editor-pages.js +0 -6624
- package/dist/src/template-editor-pages.js.map +0 -1
- package/dist/src/template-editor-shell.js +0 -2713
- package/dist/src/template-editor-shell.js.map +0 -1
- package/dist/src/template-sdk.js +0 -4
- package/dist/src/template-sdk.js.map +0 -1
- package/dist/src/worker.js +0 -17
- package/dist/src/worker.js.map +0 -1
- package/public/assets/homepage-app.js +0 -54
- package/public/assets/homepage-client-app.js +0 -80
- package/public/assets/page-runtime-client-app.js +0 -94
- package/readme.secret.md +0 -89
- package/templates/vidfarm_template_0000/README.md +0 -106
- package/templates/vidfarm_template_0000/SKILL.md +0 -266
- 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 +0 -11
- package/templates/vidfarm_template_0000/src/lib/images.js +0 -221
- package/templates/vidfarm_template_0000/src/remotion/Root.js +0 -33
- package/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
- package/templates/vidfarm_template_0000/src/sdk.js +0 -3
- package/templates/vidfarm_template_0000/src/style-options.js +0 -200
- package/templates/vidfarm_template_0000/src/template-dna.js +0 -9
- package/templates/vidfarm_template_0000/src/template.js +0 -1566
- package/templates/vidfarm_template_0000/template.config.json +0 -21
|
@@ -1,2870 +0,0 @@
|
|
|
1
|
-
import { config } from "../config.js";
|
|
2
|
-
import { parseJson } from "../lib/json.js";
|
|
3
|
-
import { decryptString } from "../lib/crypto.js";
|
|
4
|
-
import { createId } from "../lib/ids.js";
|
|
5
|
-
import { addSeconds } from "../lib/time.js";
|
|
6
|
-
import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
|
|
7
|
-
import sharp from "sharp";
|
|
8
|
-
export class ProviderKeyUnavailableError extends Error {
|
|
9
|
-
}
|
|
10
|
-
export class ProviderAuthError extends Error {
|
|
11
|
-
}
|
|
12
|
-
export class ProviderQuotaExceededError extends Error {
|
|
13
|
-
}
|
|
14
|
-
export class ProviderWaitExceededError extends Error {
|
|
15
|
-
}
|
|
16
|
-
export class ProviderLeaseTimeoutError extends Error {
|
|
17
|
-
retryAfterSeconds;
|
|
18
|
-
constructor(message, retryAfterSeconds = 5) {
|
|
19
|
-
super(message);
|
|
20
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
export class ProviderRateLimitError extends Error {
|
|
24
|
-
retryAfterSeconds;
|
|
25
|
-
constructor(message, retryAfterSeconds = 60) {
|
|
26
|
-
super(message);
|
|
27
|
-
this.retryAfterSeconds = retryAfterSeconds;
|
|
28
|
-
}
|
|
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"]);
|
|
95
|
-
const SUPPORTED_TTS_MODELS = {
|
|
96
|
-
openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
|
|
97
|
-
gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
|
|
98
|
-
openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
|
|
99
|
-
};
|
|
100
|
-
const SUPPORTED_STT_MODELS = {
|
|
101
|
-
openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
|
|
102
|
-
gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
|
|
103
|
-
openai: new Set(["gpt-4o-mini-transcribe-2025-12-15"])
|
|
104
|
-
};
|
|
105
|
-
const PROVIDER_KEY_VALIDATION_MODELS = {
|
|
106
|
-
openai: "gpt-4.1-mini",
|
|
107
|
-
gemini: "gemini-3.1-flash-lite-preview",
|
|
108
|
-
openrouter: "google/gemini-3.1-flash-lite-preview",
|
|
109
|
-
perplexity: "sonar"
|
|
110
|
-
};
|
|
111
|
-
function readStoredProviderSecret(value) {
|
|
112
|
-
try {
|
|
113
|
-
return decryptString(value, config.ENCRYPTION_SECRET);
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return value;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
function sleep(ms) {
|
|
120
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
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
|
-
}
|
|
233
|
-
function zeroUsageCost(result) {
|
|
234
|
-
return {
|
|
235
|
-
...result,
|
|
236
|
-
usage: {
|
|
237
|
-
...result.usage,
|
|
238
|
-
costUsd: 0
|
|
239
|
-
}
|
|
240
|
-
};
|
|
241
|
-
}
|
|
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
|
-
}
|
|
337
|
-
const CHAT_PROVIDER_LEASE_PRIORITY = 0;
|
|
338
|
-
const DEFAULT_PROVIDER_LEASE_TIMEOUT_MS = config.PROVIDER_LEASE_WAIT_TIMEOUT_SECONDS * 1000;
|
|
339
|
-
const CHAT_PROVIDER_LEASE_TIMEOUT_MS = 15_000;
|
|
340
|
-
const DEFAULT_PROVIDER_LEASE_SECONDS = 90;
|
|
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
|
-
}
|
|
348
|
-
export class ProviderService {
|
|
349
|
-
serverlessProviderKeys = new ServerlessProviderKeyService();
|
|
350
|
-
async handleLeasedProviderError(input) {
|
|
351
|
-
if (input.error instanceof ProviderRateLimitError) {
|
|
352
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
353
|
-
id: createId("usage"),
|
|
354
|
-
keyId: input.lease.keyId,
|
|
355
|
-
jobId: input.jobId,
|
|
356
|
-
provider: input.provider,
|
|
357
|
-
model: input.model,
|
|
358
|
-
eventType: "rate_limit",
|
|
359
|
-
metadata: {
|
|
360
|
-
...(input.metadata ?? {}),
|
|
361
|
-
retryAfterSeconds: input.error.retryAfterSeconds
|
|
362
|
-
}
|
|
363
|
-
});
|
|
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));
|
|
377
|
-
}
|
|
378
|
-
else if (input.error instanceof ProviderAuthError) {
|
|
379
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
380
|
-
id: createId("usage"),
|
|
381
|
-
keyId: input.lease.keyId,
|
|
382
|
-
jobId: input.jobId,
|
|
383
|
-
provider: input.provider,
|
|
384
|
-
model: input.model,
|
|
385
|
-
eventType: "auth_error",
|
|
386
|
-
metadata: input.metadata ?? {}
|
|
387
|
-
});
|
|
388
|
-
await this.serverlessProviderKeys.setProviderKeyCooldown(input.lease.keyId, null, "invalid", "auth_error");
|
|
389
|
-
}
|
|
390
|
-
throw input.error;
|
|
391
|
-
}
|
|
392
|
-
listAvailableProviders(_customerId) {
|
|
393
|
-
return PROVIDER_FALLBACK_PRIORITY;
|
|
394
|
-
}
|
|
395
|
-
async listAvailableProvidersAsync(customerId) {
|
|
396
|
-
const now = Date.now();
|
|
397
|
-
const providers = (await this.serverlessProviderKeys.listProviderKeysWithSecrets(customerId))
|
|
398
|
-
.map((entry) => ({
|
|
399
|
-
provider: String(entry.provider),
|
|
400
|
-
status: String(entry.status),
|
|
401
|
-
cooldownUntil: entry.cooldown_until ? String(entry.cooldown_until) : null,
|
|
402
|
-
secret: readStoredProviderSecret(String(entry.secret))
|
|
403
|
-
}))
|
|
404
|
-
.filter((entry) => entry.status === "active")
|
|
405
|
-
.filter((entry) => !entry.cooldownUntil || Date.parse(entry.cooldownUntil) <= now)
|
|
406
|
-
.filter((entry) => entry.secret.trim().length > 0)
|
|
407
|
-
.map((entry) => entry.provider);
|
|
408
|
-
return [...new Set(providers)];
|
|
409
|
-
}
|
|
410
|
-
async validateProviderKey(input) {
|
|
411
|
-
const model = PROVIDER_KEY_VALIDATION_MODELS[input.provider];
|
|
412
|
-
if (!model) {
|
|
413
|
-
throw new Error(`Provider ${input.provider} does not support validation.`);
|
|
414
|
-
}
|
|
415
|
-
if (config.mockProviders) {
|
|
416
|
-
return { provider: input.provider, model, mock: true };
|
|
417
|
-
}
|
|
418
|
-
try {
|
|
419
|
-
await this.callProvider({
|
|
420
|
-
provider: input.provider,
|
|
421
|
-
model,
|
|
422
|
-
prompt: "Reply with OK.",
|
|
423
|
-
temperature: 0,
|
|
424
|
-
apiKey: input.secret
|
|
425
|
-
});
|
|
426
|
-
return { provider: input.provider, model, mock: false };
|
|
427
|
-
}
|
|
428
|
-
catch (error) {
|
|
429
|
-
throw new Error(toProviderValidationMessage(input.provider, error));
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
async leaseCustomerKey(input) {
|
|
433
|
-
const leaseToken = createId("lease");
|
|
434
|
-
const leaseDurationSeconds = input.leaseDurationSeconds ?? DEFAULT_PROVIDER_LEASE_SECONDS;
|
|
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);
|
|
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();
|
|
485
|
-
}
|
|
486
|
-
finally {
|
|
487
|
-
clearInterval(timer);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
async releaseLease(lease) {
|
|
491
|
-
await this.serverlessProviderKeys.releaseProviderKeyLease({ keyId: lease.keyId, leaseToken: lease.leaseToken });
|
|
492
|
-
}
|
|
493
|
-
async leaseEditorChatKey(input) {
|
|
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"),
|
|
515
|
-
provider: input.provider,
|
|
516
|
-
secret: readStoredProviderSecret(key.secret),
|
|
517
|
-
jobId: input.chatId,
|
|
518
|
-
leaseDurationSeconds: CHAT_PROVIDER_LEASE_SECONDS
|
|
519
|
-
};
|
|
520
|
-
}
|
|
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) {
|
|
556
|
-
const lease = await this.leaseCustomerKey({
|
|
557
|
-
customerId: input.customerId,
|
|
558
|
-
provider: input.provider,
|
|
559
|
-
jobId: input.jobId,
|
|
560
|
-
workerId: input.workerId
|
|
561
|
-
});
|
|
562
|
-
try {
|
|
563
|
-
if (config.mockProviders) {
|
|
564
|
-
const text = [
|
|
565
|
-
`Hook: ${input.prompt.slice(0, 80)}`,
|
|
566
|
-
"Beat 1: Identify the pain point.",
|
|
567
|
-
"Beat 2: Show the turning point.",
|
|
568
|
-
"Beat 3: Close with a clear CTA."
|
|
569
|
-
].join("\n");
|
|
570
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
571
|
-
id: createId("usage"),
|
|
572
|
-
keyId: lease.keyId,
|
|
573
|
-
jobId: input.jobId,
|
|
574
|
-
provider: input.provider,
|
|
575
|
-
model: input.model,
|
|
576
|
-
eventType: "success",
|
|
577
|
-
inputTokens: 180,
|
|
578
|
-
outputTokens: 120,
|
|
579
|
-
metadata: { mock: true }
|
|
580
|
-
});
|
|
581
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
582
|
-
return {
|
|
583
|
-
text,
|
|
584
|
-
usage: {
|
|
585
|
-
inputTokens: 180,
|
|
586
|
-
outputTokens: 120,
|
|
587
|
-
costUsd: 0
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => this.callProvider({ ...input, apiKey: lease.secret })));
|
|
592
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
593
|
-
id: createId("usage"),
|
|
594
|
-
keyId: lease.keyId,
|
|
595
|
-
jobId: input.jobId,
|
|
596
|
-
provider: input.provider,
|
|
597
|
-
model: input.model,
|
|
598
|
-
eventType: "success",
|
|
599
|
-
inputTokens: result.usage.inputTokens,
|
|
600
|
-
outputTokens: result.usage.outputTokens
|
|
601
|
-
});
|
|
602
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
603
|
-
return result;
|
|
604
|
-
}
|
|
605
|
-
catch (error) {
|
|
606
|
-
return await this.handleLeasedProviderError({
|
|
607
|
-
error,
|
|
608
|
-
lease,
|
|
609
|
-
jobId: input.jobId,
|
|
610
|
-
provider: input.provider,
|
|
611
|
-
model: input.model
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
finally {
|
|
615
|
-
await this.releaseLease(lease);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
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) {
|
|
653
|
-
assertSupportedSpeechModel("tts", input.provider, input.model);
|
|
654
|
-
const lease = await this.leaseCustomerKey({
|
|
655
|
-
customerId: input.customerId,
|
|
656
|
-
provider: input.provider,
|
|
657
|
-
jobId: input.jobId,
|
|
658
|
-
workerId: input.workerId
|
|
659
|
-
});
|
|
660
|
-
try {
|
|
661
|
-
if (config.mockProviders) {
|
|
662
|
-
const contentType = inferSpeechContentType(input.responseFormat ?? "mp3");
|
|
663
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
664
|
-
id: createId("usage"),
|
|
665
|
-
keyId: lease.keyId,
|
|
666
|
-
jobId: input.jobId,
|
|
667
|
-
provider: input.provider,
|
|
668
|
-
model: input.model,
|
|
669
|
-
eventType: "success",
|
|
670
|
-
inputTokens: 0,
|
|
671
|
-
outputTokens: 0,
|
|
672
|
-
metadata: { mock: true, type: "speech_generation" }
|
|
673
|
-
});
|
|
674
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
675
|
-
return {
|
|
676
|
-
bytes: Buffer.from(`mock speech:${input.text.slice(0, 120)}`, "utf8"),
|
|
677
|
-
contentType,
|
|
678
|
-
usage: {
|
|
679
|
-
inputTokens: 0,
|
|
680
|
-
outputTokens: 0,
|
|
681
|
-
costUsd: 0
|
|
682
|
-
}
|
|
683
|
-
};
|
|
684
|
-
}
|
|
685
|
-
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => input.provider === "gemini"
|
|
686
|
-
? this.callGeminiSpeechGeneration({
|
|
687
|
-
model: input.model,
|
|
688
|
-
text: input.text,
|
|
689
|
-
voice: input.voice,
|
|
690
|
-
instructions: input.instructions,
|
|
691
|
-
apiKey: lease.secret
|
|
692
|
-
})
|
|
693
|
-
: this.callOpenAICompatibleSpeechGeneration({
|
|
694
|
-
provider: input.provider === "openrouter" ? "openrouter" : "openai",
|
|
695
|
-
model: input.model,
|
|
696
|
-
text: input.text,
|
|
697
|
-
voice: input.voice,
|
|
698
|
-
instructions: input.instructions,
|
|
699
|
-
responseFormat: input.responseFormat,
|
|
700
|
-
apiKey: lease.secret
|
|
701
|
-
})));
|
|
702
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
703
|
-
id: createId("usage"),
|
|
704
|
-
keyId: lease.keyId,
|
|
705
|
-
jobId: input.jobId,
|
|
706
|
-
provider: input.provider,
|
|
707
|
-
model: input.model,
|
|
708
|
-
eventType: "success",
|
|
709
|
-
inputTokens: result.usage.inputTokens,
|
|
710
|
-
outputTokens: result.usage.outputTokens,
|
|
711
|
-
metadata: { type: "speech_generation" }
|
|
712
|
-
});
|
|
713
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
714
|
-
return result;
|
|
715
|
-
}
|
|
716
|
-
catch (error) {
|
|
717
|
-
return await this.handleLeasedProviderError({
|
|
718
|
-
error,
|
|
719
|
-
lease,
|
|
720
|
-
jobId: input.jobId,
|
|
721
|
-
provider: input.provider,
|
|
722
|
-
model: input.model,
|
|
723
|
-
metadata: { type: "speech_generation" }
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
finally {
|
|
727
|
-
await this.releaseLease(lease);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
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) {
|
|
766
|
-
const lease = await this.leaseCustomerKey({
|
|
767
|
-
customerId: input.customerId,
|
|
768
|
-
provider: input.provider,
|
|
769
|
-
jobId: input.jobId,
|
|
770
|
-
workerId: input.workerId
|
|
771
|
-
});
|
|
772
|
-
try {
|
|
773
|
-
if (config.mockProviders) {
|
|
774
|
-
const svg = buildMockSlideSvg(input.prompt);
|
|
775
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
776
|
-
id: createId("usage"),
|
|
777
|
-
keyId: lease.keyId,
|
|
778
|
-
jobId: input.jobId,
|
|
779
|
-
provider: input.provider,
|
|
780
|
-
model: input.model,
|
|
781
|
-
eventType: "success",
|
|
782
|
-
inputTokens: 0,
|
|
783
|
-
outputTokens: 0,
|
|
784
|
-
metadata: { mock: true, type: "image_generation" }
|
|
785
|
-
});
|
|
786
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
787
|
-
return {
|
|
788
|
-
bytes: Buffer.from(svg, "utf8"),
|
|
789
|
-
contentType: "image/svg+xml",
|
|
790
|
-
revisedPrompt: input.prompt
|
|
791
|
-
};
|
|
792
|
-
}
|
|
793
|
-
const result = await this.withLeaseRenewal(lease, () => (input.provider === "gemini"
|
|
794
|
-
? this.callGeminiImageGeneration({
|
|
795
|
-
model: input.model,
|
|
796
|
-
prompt: input.prompt,
|
|
797
|
-
promptAttachments: input.promptAttachments,
|
|
798
|
-
aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
|
|
799
|
-
imageSize: input.imageSize,
|
|
800
|
-
apiKey: lease.secret
|
|
801
|
-
})
|
|
802
|
-
: input.provider === "openrouter"
|
|
803
|
-
? this.callOpenRouterImageGeneration({
|
|
804
|
-
model: input.model,
|
|
805
|
-
prompt: input.prompt,
|
|
806
|
-
promptAttachments: input.promptAttachments,
|
|
807
|
-
aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
|
|
808
|
-
imageSize: input.imageSize,
|
|
809
|
-
apiKey: lease.secret
|
|
810
|
-
})
|
|
811
|
-
: this.callOpenAIImageGeneration({
|
|
812
|
-
model: input.model,
|
|
813
|
-
prompt: input.prompt,
|
|
814
|
-
promptAttachments: input.promptAttachments,
|
|
815
|
-
size: normalizeOpenAIImageSize(input.size, input.aspectRatio),
|
|
816
|
-
aspectRatio: input.aspectRatio,
|
|
817
|
-
apiKey: lease.secret
|
|
818
|
-
})));
|
|
819
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
820
|
-
id: createId("usage"),
|
|
821
|
-
keyId: lease.keyId,
|
|
822
|
-
jobId: input.jobId,
|
|
823
|
-
provider: input.provider,
|
|
824
|
-
model: input.model,
|
|
825
|
-
eventType: "success",
|
|
826
|
-
inputTokens: 0,
|
|
827
|
-
outputTokens: 0,
|
|
828
|
-
metadata: { type: "image_generation" }
|
|
829
|
-
});
|
|
830
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
831
|
-
return result;
|
|
832
|
-
}
|
|
833
|
-
catch (error) {
|
|
834
|
-
return await this.handleLeasedProviderError({
|
|
835
|
-
error,
|
|
836
|
-
lease,
|
|
837
|
-
jobId: input.jobId,
|
|
838
|
-
provider: input.provider,
|
|
839
|
-
model: input.model,
|
|
840
|
-
metadata: { type: "image_generation" }
|
|
841
|
-
});
|
|
842
|
-
}
|
|
843
|
-
finally {
|
|
844
|
-
await this.releaseLease(lease);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
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);
|
|
884
|
-
const lease = await this.leaseCustomerKey({
|
|
885
|
-
customerId: input.customerId,
|
|
886
|
-
provider: input.provider,
|
|
887
|
-
jobId: input.jobId,
|
|
888
|
-
workerId: input.workerId
|
|
889
|
-
});
|
|
890
|
-
try {
|
|
891
|
-
if (config.mockProviders) {
|
|
892
|
-
const svg = buildMockSlideSvg(`EDIT ${input.instruction}\n${input.sourceImageUrl}`);
|
|
893
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
894
|
-
id: createId("usage"),
|
|
895
|
-
keyId: lease.keyId,
|
|
896
|
-
jobId: input.jobId,
|
|
897
|
-
provider: input.provider,
|
|
898
|
-
model: input.model,
|
|
899
|
-
eventType: "success",
|
|
900
|
-
inputTokens: 0,
|
|
901
|
-
outputTokens: 0,
|
|
902
|
-
metadata: { mock: true, type: "image_edit" }
|
|
903
|
-
});
|
|
904
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
905
|
-
return {
|
|
906
|
-
bytes: Buffer.from(svg, "utf8"),
|
|
907
|
-
contentType: "image/svg+xml",
|
|
908
|
-
revisedPrompt: input.instruction
|
|
909
|
-
};
|
|
910
|
-
}
|
|
911
|
-
const result = await this.withLeaseRenewal(lease, () => (input.provider === "openai"
|
|
912
|
-
? this.callOpenAIImageEdit({
|
|
913
|
-
model: input.model,
|
|
914
|
-
sourceImageUrl: input.sourceImageUrl,
|
|
915
|
-
instruction: input.instruction,
|
|
916
|
-
referenceAttachments: input.referenceAttachments,
|
|
917
|
-
maskUrl: input.maskUrl,
|
|
918
|
-
size: normalizeOpenAIImageSize(input.size, input.aspectRatio),
|
|
919
|
-
apiKey: lease.secret
|
|
920
|
-
})
|
|
921
|
-
: input.provider === "perplexity"
|
|
922
|
-
? (() => {
|
|
923
|
-
throw new Error("Perplexity image edit is not supported.");
|
|
924
|
-
})()
|
|
925
|
-
: this.callAttachmentDrivenImageEdit({
|
|
926
|
-
provider: input.provider,
|
|
927
|
-
model: input.model,
|
|
928
|
-
sourceImageUrl: input.sourceImageUrl,
|
|
929
|
-
instruction: input.instruction,
|
|
930
|
-
referenceAttachments: input.referenceAttachments,
|
|
931
|
-
maskUrl: input.maskUrl,
|
|
932
|
-
aspectRatio: normalizeGeminiAspectRatio(input.aspectRatio),
|
|
933
|
-
imageSize: input.imageSize,
|
|
934
|
-
apiKey: lease.secret
|
|
935
|
-
})));
|
|
936
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
937
|
-
id: createId("usage"),
|
|
938
|
-
keyId: lease.keyId,
|
|
939
|
-
jobId: input.jobId,
|
|
940
|
-
provider: input.provider,
|
|
941
|
-
model: input.model,
|
|
942
|
-
eventType: "success",
|
|
943
|
-
inputTokens: 0,
|
|
944
|
-
outputTokens: 0,
|
|
945
|
-
metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
|
|
946
|
-
});
|
|
947
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
948
|
-
return result;
|
|
949
|
-
}
|
|
950
|
-
catch (error) {
|
|
951
|
-
return await this.handleLeasedProviderError({
|
|
952
|
-
error,
|
|
953
|
-
lease,
|
|
954
|
-
jobId: input.jobId,
|
|
955
|
-
provider: input.provider,
|
|
956
|
-
model: input.model,
|
|
957
|
-
metadata: { type: input.maskUrl ? "image_inpaint" : "image_edit" }
|
|
958
|
-
});
|
|
959
|
-
}
|
|
960
|
-
finally {
|
|
961
|
-
await this.releaseLease(lease);
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
async inpaintImage(input) {
|
|
965
|
-
return this.editImage({
|
|
966
|
-
...input,
|
|
967
|
-
maskUrl: input.maskUrl
|
|
968
|
-
});
|
|
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
|
-
}
|
|
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);
|
|
1435
|
-
const lease = await this.leaseCustomerKey({
|
|
1436
|
-
customerId: input.customerId,
|
|
1437
|
-
provider: input.provider,
|
|
1438
|
-
jobId: input.jobId,
|
|
1439
|
-
workerId: input.workerId
|
|
1440
|
-
});
|
|
1441
|
-
try {
|
|
1442
|
-
if (config.mockProviders) {
|
|
1443
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
1444
|
-
id: createId("usage"),
|
|
1445
|
-
keyId: lease.keyId,
|
|
1446
|
-
jobId: input.jobId,
|
|
1447
|
-
provider: input.provider,
|
|
1448
|
-
model: input.model,
|
|
1449
|
-
eventType: "success",
|
|
1450
|
-
inputTokens: 0,
|
|
1451
|
-
outputTokens: 0,
|
|
1452
|
-
metadata: { mock: true, type: "layout_analysis" }
|
|
1453
|
-
});
|
|
1454
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
1455
|
-
return {
|
|
1456
|
-
zone: "bottom",
|
|
1457
|
-
align: "center",
|
|
1458
|
-
maxWidthPercent: 82,
|
|
1459
|
-
justification: "Mock mode defaults to bottom-center for readable slideshow captions."
|
|
1460
|
-
};
|
|
1461
|
-
}
|
|
1462
|
-
const raw = await this.withLeaseRenewal(lease, () => (input.provider === "gemini"
|
|
1463
|
-
? this.callGeminiLayoutAnalysis({
|
|
1464
|
-
model: input.model,
|
|
1465
|
-
imageUrl: input.imageUrl,
|
|
1466
|
-
overlayText: input.overlayText,
|
|
1467
|
-
apiKey: lease.secret
|
|
1468
|
-
})
|
|
1469
|
-
: this.callOpenAICompatibleLayoutAnalysis({
|
|
1470
|
-
provider: input.provider === "openrouter" ? "openrouter" : "openai",
|
|
1471
|
-
model: input.model,
|
|
1472
|
-
imageUrl: input.imageUrl,
|
|
1473
|
-
overlayText: input.overlayText,
|
|
1474
|
-
apiKey: lease.secret
|
|
1475
|
-
})));
|
|
1476
|
-
const parsed = parseJson(raw, null);
|
|
1477
|
-
if (!parsed?.zone || !parsed?.align) {
|
|
1478
|
-
throw new Error(`${input.provider} layout analysis returned invalid JSON`);
|
|
1479
|
-
}
|
|
1480
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
1481
|
-
id: createId("usage"),
|
|
1482
|
-
keyId: lease.keyId,
|
|
1483
|
-
jobId: input.jobId,
|
|
1484
|
-
provider: input.provider,
|
|
1485
|
-
model: input.model,
|
|
1486
|
-
eventType: "success",
|
|
1487
|
-
inputTokens: 0,
|
|
1488
|
-
outputTokens: 0,
|
|
1489
|
-
metadata: { type: "layout_analysis" }
|
|
1490
|
-
});
|
|
1491
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
1492
|
-
return {
|
|
1493
|
-
zone: parsed.zone,
|
|
1494
|
-
align: parsed.align,
|
|
1495
|
-
maxWidthPercent: clamp(Number(parsed.maxWidthPercent ?? 76), 52, 88),
|
|
1496
|
-
justification: parsed.justification ?? "Selected to preserve legibility and subject framing."
|
|
1497
|
-
};
|
|
1498
|
-
}
|
|
1499
|
-
catch (error) {
|
|
1500
|
-
return await this.handleLeasedProviderError({
|
|
1501
|
-
error,
|
|
1502
|
-
lease,
|
|
1503
|
-
jobId: input.jobId,
|
|
1504
|
-
provider: input.provider,
|
|
1505
|
-
model: input.model,
|
|
1506
|
-
metadata: { type: "layout_analysis" }
|
|
1507
|
-
});
|
|
1508
|
-
}
|
|
1509
|
-
finally {
|
|
1510
|
-
await this.releaseLease(lease);
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
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) {
|
|
1548
|
-
assertSupportedSpeechModel("stt", input.provider, input.model);
|
|
1549
|
-
const lease = await this.leaseCustomerKey({
|
|
1550
|
-
customerId: input.customerId,
|
|
1551
|
-
provider: input.provider,
|
|
1552
|
-
jobId: input.jobId,
|
|
1553
|
-
workerId: input.workerId
|
|
1554
|
-
});
|
|
1555
|
-
try {
|
|
1556
|
-
if (config.mockProviders) {
|
|
1557
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
1558
|
-
id: createId("usage"),
|
|
1559
|
-
keyId: lease.keyId,
|
|
1560
|
-
jobId: input.jobId,
|
|
1561
|
-
provider: input.provider,
|
|
1562
|
-
model: input.model,
|
|
1563
|
-
eventType: "success",
|
|
1564
|
-
inputTokens: 0,
|
|
1565
|
-
outputTokens: 0,
|
|
1566
|
-
metadata: { mock: true, type: "speech_transcription" }
|
|
1567
|
-
});
|
|
1568
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
1569
|
-
return {
|
|
1570
|
-
text: "Mock transcription output.",
|
|
1571
|
-
usage: {
|
|
1572
|
-
inputTokens: 0,
|
|
1573
|
-
outputTokens: 0,
|
|
1574
|
-
costUsd: 0
|
|
1575
|
-
}
|
|
1576
|
-
};
|
|
1577
|
-
}
|
|
1578
|
-
const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => input.provider === "gemini"
|
|
1579
|
-
? this.callGeminiSpeechTranscription({
|
|
1580
|
-
model: input.model,
|
|
1581
|
-
audio: input.audio,
|
|
1582
|
-
contentType: input.contentType,
|
|
1583
|
-
prompt: input.prompt,
|
|
1584
|
-
language: input.language,
|
|
1585
|
-
apiKey: lease.secret
|
|
1586
|
-
})
|
|
1587
|
-
: input.provider === "openrouter"
|
|
1588
|
-
? this.callOpenRouterSpeechTranscription({
|
|
1589
|
-
model: input.model,
|
|
1590
|
-
audio: input.audio,
|
|
1591
|
-
contentType: input.contentType,
|
|
1592
|
-
prompt: input.prompt,
|
|
1593
|
-
language: input.language,
|
|
1594
|
-
apiKey: lease.secret
|
|
1595
|
-
})
|
|
1596
|
-
: this.callOpenAISpeechTranscription({
|
|
1597
|
-
model: input.model,
|
|
1598
|
-
audio: input.audio,
|
|
1599
|
-
contentType: input.contentType,
|
|
1600
|
-
prompt: input.prompt,
|
|
1601
|
-
language: input.language,
|
|
1602
|
-
apiKey: lease.secret
|
|
1603
|
-
})));
|
|
1604
|
-
await this.serverlessProviderKeys.recordProviderKeyUsage({
|
|
1605
|
-
id: createId("usage"),
|
|
1606
|
-
keyId: lease.keyId,
|
|
1607
|
-
jobId: input.jobId,
|
|
1608
|
-
provider: input.provider,
|
|
1609
|
-
model: input.model,
|
|
1610
|
-
eventType: "success",
|
|
1611
|
-
inputTokens: result.usage.inputTokens,
|
|
1612
|
-
outputTokens: result.usage.outputTokens,
|
|
1613
|
-
metadata: { type: "speech_transcription" }
|
|
1614
|
-
});
|
|
1615
|
-
await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
|
|
1616
|
-
return result;
|
|
1617
|
-
}
|
|
1618
|
-
catch (error) {
|
|
1619
|
-
return await this.handleLeasedProviderError({
|
|
1620
|
-
error,
|
|
1621
|
-
lease,
|
|
1622
|
-
jobId: input.jobId,
|
|
1623
|
-
provider: input.provider,
|
|
1624
|
-
model: input.model,
|
|
1625
|
-
metadata: { type: "speech_transcription" }
|
|
1626
|
-
});
|
|
1627
|
-
}
|
|
1628
|
-
finally {
|
|
1629
|
-
await this.releaseLease(lease);
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
async callOpenAIImageGeneration(input) {
|
|
1633
|
-
const prompt = input.aspectRatio === "9:16"
|
|
1634
|
-
? [
|
|
1635
|
-
input.prompt,
|
|
1636
|
-
"Return a native 9:16 vertical composition with no letterboxing, no inset landscape window, and no empty top or bottom padding."
|
|
1637
|
-
].join("\n")
|
|
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
|
-
}
|
|
1648
|
-
const response = await fetch("https://api.openai.com/v1/images/generations", {
|
|
1649
|
-
method: "POST",
|
|
1650
|
-
headers: {
|
|
1651
|
-
"Content-Type": "application/json",
|
|
1652
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
1653
|
-
},
|
|
1654
|
-
body: JSON.stringify({
|
|
1655
|
-
model: input.model,
|
|
1656
|
-
prompt,
|
|
1657
|
-
size: input.size ?? "1024x1536"
|
|
1658
|
-
})
|
|
1659
|
-
});
|
|
1660
|
-
if (response.status === 401) {
|
|
1661
|
-
throw new ProviderAuthError("openai authentication failed");
|
|
1662
|
-
}
|
|
1663
|
-
if (response.status === 429) {
|
|
1664
|
-
throw await rateLimitOrQuotaError("openai", response);
|
|
1665
|
-
}
|
|
1666
|
-
if (!response.ok) {
|
|
1667
|
-
const details = await response.text();
|
|
1668
|
-
throw new Error(`openai image generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1669
|
-
}
|
|
1670
|
-
const data = await response.json();
|
|
1671
|
-
const encoded = data.data?.[0]?.b64_json;
|
|
1672
|
-
if (!encoded) {
|
|
1673
|
-
throw new Error("openai image generation returned no image payload");
|
|
1674
|
-
}
|
|
1675
|
-
return {
|
|
1676
|
-
bytes: Buffer.from(encoded, "base64"),
|
|
1677
|
-
contentType: "image/png",
|
|
1678
|
-
revisedPrompt: data.data?.[0]?.revised_prompt ?? null
|
|
1679
|
-
};
|
|
1680
|
-
}
|
|
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
|
-
}
|
|
1695
|
-
const form = new FormData();
|
|
1696
|
-
form.set("model", input.model);
|
|
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
|
-
}
|
|
1704
|
-
if (input.maskUrl) {
|
|
1705
|
-
form.set("mask", await remoteImageToBlob(input.maskUrl));
|
|
1706
|
-
}
|
|
1707
|
-
const response = await fetch("https://api.openai.com/v1/images/edits", {
|
|
1708
|
-
method: "POST",
|
|
1709
|
-
headers: {
|
|
1710
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
1711
|
-
},
|
|
1712
|
-
body: form
|
|
1713
|
-
});
|
|
1714
|
-
if (response.status === 401) {
|
|
1715
|
-
throw new ProviderAuthError("openai authentication failed");
|
|
1716
|
-
}
|
|
1717
|
-
if (response.status === 429) {
|
|
1718
|
-
throw await rateLimitOrQuotaError("openai", response);
|
|
1719
|
-
}
|
|
1720
|
-
if (!response.ok) {
|
|
1721
|
-
const details = await response.text();
|
|
1722
|
-
throw new Error(`openai image edit returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1723
|
-
}
|
|
1724
|
-
const data = await response.json();
|
|
1725
|
-
const encoded = data.data?.[0]?.b64_json;
|
|
1726
|
-
if (!encoded) {
|
|
1727
|
-
throw new Error("openai image edit returned no image payload");
|
|
1728
|
-
}
|
|
1729
|
-
return {
|
|
1730
|
-
bytes: Buffer.from(encoded, "base64"),
|
|
1731
|
-
contentType: "image/png",
|
|
1732
|
-
revisedPrompt: data.data?.[0]?.revised_prompt ?? null
|
|
1733
|
-
};
|
|
1734
|
-
}
|
|
1735
|
-
async callAttachmentDrivenImageEdit(input) {
|
|
1736
|
-
const promptAttachments = [
|
|
1737
|
-
input.sourceImageUrl,
|
|
1738
|
-
...(input.maskUrl ? [input.maskUrl] : []),
|
|
1739
|
-
...(input.referenceAttachments ?? [])
|
|
1740
|
-
];
|
|
1741
|
-
const prompt = [
|
|
1742
|
-
input.maskUrl
|
|
1743
|
-
? "Edit this image using the provided source image and mask."
|
|
1744
|
-
: "Edit this image using the provided source image as the base reference.",
|
|
1745
|
-
input.maskUrl
|
|
1746
|
-
? "Only change the masked region. Preserve all unmasked regions exactly."
|
|
1747
|
-
: "Preserve the overall composition unless the instruction explicitly changes it.",
|
|
1748
|
-
`Instruction: ${input.instruction}`,
|
|
1749
|
-
"Return one final edited image only."
|
|
1750
|
-
].join("\n");
|
|
1751
|
-
return input.provider === "gemini"
|
|
1752
|
-
? this.callGeminiImageGeneration({
|
|
1753
|
-
model: input.model,
|
|
1754
|
-
prompt,
|
|
1755
|
-
promptAttachments,
|
|
1756
|
-
aspectRatio: input.aspectRatio,
|
|
1757
|
-
imageSize: input.imageSize,
|
|
1758
|
-
apiKey: input.apiKey
|
|
1759
|
-
})
|
|
1760
|
-
: this.callOpenRouterImageGeneration({
|
|
1761
|
-
model: input.model,
|
|
1762
|
-
prompt,
|
|
1763
|
-
promptAttachments,
|
|
1764
|
-
aspectRatio: input.aspectRatio,
|
|
1765
|
-
imageSize: input.imageSize,
|
|
1766
|
-
apiKey: input.apiKey
|
|
1767
|
-
});
|
|
1768
|
-
}
|
|
1769
|
-
async callGeminiImageGeneration(input) {
|
|
1770
|
-
const imageConfig = {};
|
|
1771
|
-
if (input.aspectRatio) {
|
|
1772
|
-
imageConfig.aspectRatio = input.aspectRatio;
|
|
1773
|
-
}
|
|
1774
|
-
if (input.imageSize && supportsGeminiImageSize(input.model)) {
|
|
1775
|
-
imageConfig.imageSize = input.imageSize;
|
|
1776
|
-
}
|
|
1777
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
1778
|
-
method: "POST",
|
|
1779
|
-
headers: {
|
|
1780
|
-
"Content-Type": "application/json"
|
|
1781
|
-
},
|
|
1782
|
-
body: JSON.stringify({
|
|
1783
|
-
contents: [{ parts: await buildGeminiPromptParts(input.prompt, input.promptAttachments ?? []) }],
|
|
1784
|
-
generationConfig: {
|
|
1785
|
-
responseModalities: ["Image"],
|
|
1786
|
-
...(Object.keys(imageConfig).length
|
|
1787
|
-
? {
|
|
1788
|
-
imageConfig
|
|
1789
|
-
}
|
|
1790
|
-
: {})
|
|
1791
|
-
}
|
|
1792
|
-
})
|
|
1793
|
-
});
|
|
1794
|
-
if (response.status === 401 || response.status === 403) {
|
|
1795
|
-
throw new ProviderAuthError("gemini authentication failed");
|
|
1796
|
-
}
|
|
1797
|
-
if (response.status === 429) {
|
|
1798
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
1799
|
-
}
|
|
1800
|
-
if (!response.ok) {
|
|
1801
|
-
const details = await response.text();
|
|
1802
|
-
throw new Error(`gemini image generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1803
|
-
}
|
|
1804
|
-
const data = await response.json();
|
|
1805
|
-
const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data);
|
|
1806
|
-
const encoded = part?.inlineData?.data;
|
|
1807
|
-
if (!encoded) {
|
|
1808
|
-
throw new Error("gemini image generation returned no image payload");
|
|
1809
|
-
}
|
|
1810
|
-
return {
|
|
1811
|
-
bytes: Buffer.from(encoded, "base64"),
|
|
1812
|
-
contentType: part.inlineData?.mimeType ?? "image/png",
|
|
1813
|
-
revisedPrompt: null
|
|
1814
|
-
};
|
|
1815
|
-
}
|
|
1816
|
-
async callOpenRouterImageGeneration(input) {
|
|
1817
|
-
const requestBody = {
|
|
1818
|
-
model: input.model,
|
|
1819
|
-
prompt: input.prompt
|
|
1820
|
-
};
|
|
1821
|
-
if (input.promptAttachments?.length) {
|
|
1822
|
-
requestBody.input_references = input.promptAttachments;
|
|
1823
|
-
}
|
|
1824
|
-
if (input.aspectRatio) {
|
|
1825
|
-
requestBody.aspect_ratio = input.aspectRatio;
|
|
1826
|
-
}
|
|
1827
|
-
if (input.imageSize) {
|
|
1828
|
-
requestBody.image_size = input.imageSize;
|
|
1829
|
-
}
|
|
1830
|
-
const response = await fetch("https://openrouter.ai/api/v1/images", {
|
|
1831
|
-
method: "POST",
|
|
1832
|
-
headers: {
|
|
1833
|
-
"Content-Type": "application/json",
|
|
1834
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
1835
|
-
},
|
|
1836
|
-
body: JSON.stringify(requestBody)
|
|
1837
|
-
});
|
|
1838
|
-
if (response.status === 401 || response.status === 403) {
|
|
1839
|
-
throw new ProviderAuthError("openrouter authentication failed");
|
|
1840
|
-
}
|
|
1841
|
-
if (response.status === 429) {
|
|
1842
|
-
throw await rateLimitOrQuotaError("openrouter", response);
|
|
1843
|
-
}
|
|
1844
|
-
if (!response.ok) {
|
|
1845
|
-
const details = await response.text();
|
|
1846
|
-
throw new Error(`openrouter image generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1847
|
-
}
|
|
1848
|
-
const data = await response.json();
|
|
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}` : ""}`);
|
|
1853
|
-
}
|
|
1854
|
-
return image;
|
|
1855
|
-
}
|
|
1856
|
-
async callOpenAICompatibleSpeechGeneration(input) {
|
|
1857
|
-
const requestedFormat = input.responseFormat ?? "mp3";
|
|
1858
|
-
const usePcmBridge = input.provider === "openrouter" &&
|
|
1859
|
-
/gemini/i.test(input.model) &&
|
|
1860
|
-
requestedFormat !== "mp3";
|
|
1861
|
-
const responseFormat = usePcmBridge ? "pcm" : requestedFormat;
|
|
1862
|
-
const endpoint = input.provider === "openrouter"
|
|
1863
|
-
? "https://openrouter.ai/api/v1/audio/speech"
|
|
1864
|
-
: "https://api.openai.com/v1/audio/speech";
|
|
1865
|
-
const response = await fetch(endpoint, {
|
|
1866
|
-
method: "POST",
|
|
1867
|
-
headers: {
|
|
1868
|
-
"Content-Type": "application/json",
|
|
1869
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
1870
|
-
},
|
|
1871
|
-
body: JSON.stringify({
|
|
1872
|
-
model: input.model,
|
|
1873
|
-
input: input.text,
|
|
1874
|
-
voice: input.voice ?? "alloy",
|
|
1875
|
-
instructions: input.instructions,
|
|
1876
|
-
response_format: responseFormat
|
|
1877
|
-
})
|
|
1878
|
-
});
|
|
1879
|
-
if (response.status === 401 || response.status === 403) {
|
|
1880
|
-
throw new ProviderAuthError(`${input.provider} authentication failed`);
|
|
1881
|
-
}
|
|
1882
|
-
if (response.status === 429) {
|
|
1883
|
-
throw await rateLimitOrQuotaError(input.provider, response);
|
|
1884
|
-
}
|
|
1885
|
-
if (!response.ok) {
|
|
1886
|
-
const details = await response.text();
|
|
1887
|
-
throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1888
|
-
}
|
|
1889
|
-
const rawBytes = Buffer.from(await response.arrayBuffer());
|
|
1890
|
-
const bytes = usePcmBridge ? pcm16MonoToWav(rawBytes, 24000) : rawBytes;
|
|
1891
|
-
const contentType = usePcmBridge
|
|
1892
|
-
? "audio/wav"
|
|
1893
|
-
: response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat);
|
|
1894
|
-
return {
|
|
1895
|
-
bytes,
|
|
1896
|
-
contentType,
|
|
1897
|
-
usage: {
|
|
1898
|
-
inputTokens: 0,
|
|
1899
|
-
outputTokens: 0,
|
|
1900
|
-
costUsd: 0
|
|
1901
|
-
}
|
|
1902
|
-
};
|
|
1903
|
-
}
|
|
1904
|
-
async callGeminiSpeechGeneration(input) {
|
|
1905
|
-
const prompt = input.instructions?.trim()
|
|
1906
|
-
? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
|
|
1907
|
-
: input.text;
|
|
1908
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
1909
|
-
method: "POST",
|
|
1910
|
-
headers: {
|
|
1911
|
-
"Content-Type": "application/json"
|
|
1912
|
-
},
|
|
1913
|
-
body: JSON.stringify({
|
|
1914
|
-
contents: [{ parts: [{ text: prompt }] }],
|
|
1915
|
-
generationConfig: {
|
|
1916
|
-
responseModalities: ["AUDIO"],
|
|
1917
|
-
speechConfig: {
|
|
1918
|
-
voiceConfig: {
|
|
1919
|
-
prebuiltVoiceConfig: {
|
|
1920
|
-
voiceName: input.voice ?? "Kore"
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
})
|
|
1926
|
-
});
|
|
1927
|
-
if (response.status === 401 || response.status === 403) {
|
|
1928
|
-
throw new ProviderAuthError("gemini authentication failed");
|
|
1929
|
-
}
|
|
1930
|
-
if (response.status === 429) {
|
|
1931
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
1932
|
-
}
|
|
1933
|
-
if (!response.ok) {
|
|
1934
|
-
const details = await response.text();
|
|
1935
|
-
throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
1936
|
-
}
|
|
1937
|
-
const data = await response.json();
|
|
1938
|
-
const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
|
|
1939
|
-
const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
|
|
1940
|
-
if (!encoded) {
|
|
1941
|
-
throw new Error("gemini speech generation returned no audio payload");
|
|
1942
|
-
}
|
|
1943
|
-
const rawBytes = Buffer.from(encoded, "base64");
|
|
1944
|
-
const rawContentType = String(part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000");
|
|
1945
|
-
const pcmRateMatch = /audio\/l16;?\s*rate=(\d+)/i.exec(rawContentType);
|
|
1946
|
-
const looksLikeWav = isWavBuffer(rawBytes);
|
|
1947
|
-
const looksLikeMp3 = isLikelyMp3Buffer(rawBytes);
|
|
1948
|
-
const shouldWrapPcm = Boolean(pcmRateMatch ||
|
|
1949
|
-
(!looksLikeWav && !looksLikeMp3));
|
|
1950
|
-
const bytes = shouldWrapPcm
|
|
1951
|
-
? pcm16MonoToWav(rawBytes, Number(pcmRateMatch?.[1] || "24000"))
|
|
1952
|
-
: rawBytes;
|
|
1953
|
-
const contentType = shouldWrapPcm
|
|
1954
|
-
? "audio/wav"
|
|
1955
|
-
: looksLikeWav
|
|
1956
|
-
? "audio/wav"
|
|
1957
|
-
: "audio/mpeg";
|
|
1958
|
-
return {
|
|
1959
|
-
bytes,
|
|
1960
|
-
contentType,
|
|
1961
|
-
usage: {
|
|
1962
|
-
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
1963
|
-
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
1964
|
-
costUsd: 0
|
|
1965
|
-
}
|
|
1966
|
-
};
|
|
1967
|
-
}
|
|
1968
|
-
async callOpenAICompatibleLayoutAnalysis(input) {
|
|
1969
|
-
const endpoint = input.provider === "openrouter"
|
|
1970
|
-
? "https://openrouter.ai/api/v1/chat/completions"
|
|
1971
|
-
: "https://api.openai.com/v1/chat/completions";
|
|
1972
|
-
const response = await fetch(endpoint, {
|
|
1973
|
-
method: "POST",
|
|
1974
|
-
headers: {
|
|
1975
|
-
"Content-Type": "application/json",
|
|
1976
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
1977
|
-
},
|
|
1978
|
-
body: JSON.stringify({
|
|
1979
|
-
model: input.model,
|
|
1980
|
-
temperature: 0.1,
|
|
1981
|
-
response_format: { type: "json_object" },
|
|
1982
|
-
messages: [
|
|
1983
|
-
{
|
|
1984
|
-
role: "system",
|
|
1985
|
-
content: "Analyze a 9:16 slideshow image and return JSON with zone, align, maxWidthPercent, justification. Prefer centered text placement in the middle third of the frame unless that would cover the subject. Avoid covering the subject, avoid fake caption panels, and keep the caption inside a TikTok-safe region that avoids the top tabs, right-side action rail, and bottom caption/audio UI."
|
|
1986
|
-
},
|
|
1987
|
-
{
|
|
1988
|
-
role: "user",
|
|
1989
|
-
content: [
|
|
1990
|
-
{
|
|
1991
|
-
type: "text",
|
|
1992
|
-
text: `The exact overlay text is: ${input.overlayText}\nChoose zone from top, center, bottom. Choose align from left, center, right. Prefer center when viable. maxWidthPercent must be between 46 and 62.\nTreat the top 12 percent, right 22 percent, and bottom 20 percent as reserved TikTok UI chrome.\nThe visual goal is native TikTok caption placement, not poster typography.`
|
|
1993
|
-
},
|
|
1994
|
-
{
|
|
1995
|
-
type: "image_url",
|
|
1996
|
-
image_url: {
|
|
1997
|
-
url: input.imageUrl
|
|
1998
|
-
}
|
|
1999
|
-
}
|
|
2000
|
-
]
|
|
2001
|
-
}
|
|
2002
|
-
]
|
|
2003
|
-
})
|
|
2004
|
-
});
|
|
2005
|
-
if (response.status === 401) {
|
|
2006
|
-
throw new ProviderAuthError(`${input.provider} authentication failed`);
|
|
2007
|
-
}
|
|
2008
|
-
if (response.status === 429) {
|
|
2009
|
-
throw await rateLimitOrQuotaError(input.provider, response);
|
|
2010
|
-
}
|
|
2011
|
-
if (!response.ok) {
|
|
2012
|
-
throw new Error(`${input.provider} layout analysis returned ${response.status}`);
|
|
2013
|
-
}
|
|
2014
|
-
const data = await response.json();
|
|
2015
|
-
return String(data.choices?.[0]?.message?.content ?? "");
|
|
2016
|
-
}
|
|
2017
|
-
async callGeminiLayoutAnalysis(input) {
|
|
2018
|
-
const image = await fetch(input.imageUrl);
|
|
2019
|
-
if (!image.ok) {
|
|
2020
|
-
throw new Error(`could not fetch image for gemini layout analysis: ${image.status}`);
|
|
2021
|
-
}
|
|
2022
|
-
const bytes = Buffer.from(await image.arrayBuffer());
|
|
2023
|
-
const mimeType = image.headers.get("content-type") || "image/png";
|
|
2024
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
2025
|
-
method: "POST",
|
|
2026
|
-
headers: {
|
|
2027
|
-
"Content-Type": "application/json"
|
|
2028
|
-
},
|
|
2029
|
-
body: JSON.stringify({
|
|
2030
|
-
contents: [{
|
|
2031
|
-
parts: [
|
|
2032
|
-
{
|
|
2033
|
-
text: [
|
|
2034
|
-
"Analyze this 9:16 slideshow image and return JSON with keys zone, align, maxWidthPercent, justification.",
|
|
2035
|
-
"zone must be one of top, center, bottom.",
|
|
2036
|
-
"align must be one of left, center, right.",
|
|
2037
|
-
"Prefer centered text placement in the middle third unless that would cover the subject.",
|
|
2038
|
-
"maxWidthPercent must be between 46 and 62.",
|
|
2039
|
-
"Prefer negative space and avoid covering the subject.",
|
|
2040
|
-
"Treat the top 12 percent, right 22 percent, and bottom 20 percent as reserved TikTok UI chrome.",
|
|
2041
|
-
"The visual goal is native TikTok caption placement, not poster typography or large title cards.",
|
|
2042
|
-
`Exact overlay text: ${input.overlayText}`
|
|
2043
|
-
].join("\n")
|
|
2044
|
-
},
|
|
2045
|
-
{
|
|
2046
|
-
inline_data: {
|
|
2047
|
-
mime_type: mimeType,
|
|
2048
|
-
data: bytes.toString("base64")
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2051
|
-
]
|
|
2052
|
-
}],
|
|
2053
|
-
generationConfig: {
|
|
2054
|
-
responseMimeType: "application/json"
|
|
2055
|
-
}
|
|
2056
|
-
})
|
|
2057
|
-
});
|
|
2058
|
-
if (response.status === 401 || response.status === 403) {
|
|
2059
|
-
throw new ProviderAuthError("gemini authentication failed");
|
|
2060
|
-
}
|
|
2061
|
-
if (response.status === 429) {
|
|
2062
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
2063
|
-
}
|
|
2064
|
-
if (!response.ok) {
|
|
2065
|
-
throw new Error(`gemini layout analysis returned ${response.status}`);
|
|
2066
|
-
}
|
|
2067
|
-
const data = await response.json();
|
|
2068
|
-
const text = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "";
|
|
2069
|
-
return String(text);
|
|
2070
|
-
}
|
|
2071
|
-
async callOpenAISpeechTranscription(input) {
|
|
2072
|
-
const contentType = input.contentType ?? "audio/mpeg";
|
|
2073
|
-
const form = new FormData();
|
|
2074
|
-
form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
|
|
2075
|
-
form.set("model", input.model);
|
|
2076
|
-
form.set("response_format", "json");
|
|
2077
|
-
if (input.prompt) {
|
|
2078
|
-
form.set("prompt", input.prompt);
|
|
2079
|
-
}
|
|
2080
|
-
if (input.language) {
|
|
2081
|
-
form.set("language", input.language);
|
|
2082
|
-
}
|
|
2083
|
-
const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
|
|
2084
|
-
method: "POST",
|
|
2085
|
-
headers: {
|
|
2086
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
2087
|
-
},
|
|
2088
|
-
body: form
|
|
2089
|
-
});
|
|
2090
|
-
if (response.status === 401 || response.status === 403) {
|
|
2091
|
-
throw new ProviderAuthError("openai authentication failed");
|
|
2092
|
-
}
|
|
2093
|
-
if (response.status === 429) {
|
|
2094
|
-
throw await rateLimitOrQuotaError("openai", response);
|
|
2095
|
-
}
|
|
2096
|
-
if (!response.ok) {
|
|
2097
|
-
const details = await response.text();
|
|
2098
|
-
throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2099
|
-
}
|
|
2100
|
-
const data = await response.json();
|
|
2101
|
-
return {
|
|
2102
|
-
text: String(data.text ?? ""),
|
|
2103
|
-
usage: {
|
|
2104
|
-
inputTokens: Number(data.usage?.input_tokens ?? 0),
|
|
2105
|
-
outputTokens: Number(data.usage?.output_tokens ?? 0),
|
|
2106
|
-
costUsd: Number(data.usage?.cost ?? 0)
|
|
2107
|
-
}
|
|
2108
|
-
};
|
|
2109
|
-
}
|
|
2110
|
-
async callOpenRouterSpeechTranscription(input) {
|
|
2111
|
-
const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
|
|
2112
|
-
method: "POST",
|
|
2113
|
-
headers: {
|
|
2114
|
-
"Content-Type": "application/json",
|
|
2115
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
2116
|
-
},
|
|
2117
|
-
body: JSON.stringify({
|
|
2118
|
-
model: input.model,
|
|
2119
|
-
input_audio: {
|
|
2120
|
-
data: Buffer.from(input.audio).toString("base64"),
|
|
2121
|
-
format: audioFormatFromContentType(input.contentType)
|
|
2122
|
-
},
|
|
2123
|
-
...(input.prompt ? { prompt: input.prompt } : {}),
|
|
2124
|
-
...(input.language ? { language: input.language } : {})
|
|
2125
|
-
})
|
|
2126
|
-
});
|
|
2127
|
-
if (response.status === 401 || response.status === 403) {
|
|
2128
|
-
throw new ProviderAuthError("openrouter authentication failed");
|
|
2129
|
-
}
|
|
2130
|
-
if (response.status === 429) {
|
|
2131
|
-
throw await rateLimitOrQuotaError("openrouter", response);
|
|
2132
|
-
}
|
|
2133
|
-
if (!response.ok) {
|
|
2134
|
-
const details = await response.text();
|
|
2135
|
-
throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2136
|
-
}
|
|
2137
|
-
const data = await response.json();
|
|
2138
|
-
return {
|
|
2139
|
-
text: String(data.text ?? ""),
|
|
2140
|
-
usage: {
|
|
2141
|
-
inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
|
|
2142
|
-
outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
|
|
2143
|
-
costUsd: Number(data.usage?.cost ?? 0)
|
|
2144
|
-
}
|
|
2145
|
-
};
|
|
2146
|
-
}
|
|
2147
|
-
async callGeminiSpeechTranscription(input) {
|
|
2148
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
2149
|
-
method: "POST",
|
|
2150
|
-
headers: {
|
|
2151
|
-
"Content-Type": "application/json"
|
|
2152
|
-
},
|
|
2153
|
-
body: JSON.stringify({
|
|
2154
|
-
contents: [{
|
|
2155
|
-
parts: [
|
|
2156
|
-
{
|
|
2157
|
-
inline_data: {
|
|
2158
|
-
mime_type: input.contentType ?? "audio/mpeg",
|
|
2159
|
-
data: Buffer.from(input.audio).toString("base64")
|
|
2160
|
-
}
|
|
2161
|
-
},
|
|
2162
|
-
{
|
|
2163
|
-
text: buildGeminiTranscriptionPrompt(input.prompt, input.language)
|
|
2164
|
-
}
|
|
2165
|
-
]
|
|
2166
|
-
}],
|
|
2167
|
-
generationConfig: {
|
|
2168
|
-
temperature: 0,
|
|
2169
|
-
responseMimeType: "text/plain"
|
|
2170
|
-
}
|
|
2171
|
-
})
|
|
2172
|
-
});
|
|
2173
|
-
if (response.status === 401 || response.status === 403) {
|
|
2174
|
-
throw new ProviderAuthError("gemini authentication failed");
|
|
2175
|
-
}
|
|
2176
|
-
if (response.status === 429) {
|
|
2177
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
2178
|
-
}
|
|
2179
|
-
if (!response.ok) {
|
|
2180
|
-
const details = await response.text();
|
|
2181
|
-
throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
|
|
2182
|
-
}
|
|
2183
|
-
const data = await response.json();
|
|
2184
|
-
return {
|
|
2185
|
-
text: extractGeminiText(data),
|
|
2186
|
-
usage: {
|
|
2187
|
-
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
2188
|
-
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
2189
|
-
costUsd: 0
|
|
2190
|
-
}
|
|
2191
|
-
};
|
|
2192
|
-
}
|
|
2193
|
-
async callProvider(input) {
|
|
2194
|
-
if (input.provider === "gemini") {
|
|
2195
|
-
return this.callGemini({
|
|
2196
|
-
provider: "gemini",
|
|
2197
|
-
model: input.model,
|
|
2198
|
-
prompt: input.prompt,
|
|
2199
|
-
temperature: input.temperature,
|
|
2200
|
-
responseFormat: input.responseFormat,
|
|
2201
|
-
apiKey: input.apiKey
|
|
2202
|
-
});
|
|
2203
|
-
}
|
|
2204
|
-
return this.callOpenAICompatible(input);
|
|
2205
|
-
}
|
|
2206
|
-
async callOpenAICompatible(input) {
|
|
2207
|
-
const endpoint = input.provider === "openrouter"
|
|
2208
|
-
? "https://openrouter.ai/api/v1/chat/completions"
|
|
2209
|
-
: input.provider === "perplexity"
|
|
2210
|
-
? "https://api.perplexity.ai/chat/completions"
|
|
2211
|
-
: "https://api.openai.com/v1/chat/completions";
|
|
2212
|
-
const response = await fetch(endpoint, {
|
|
2213
|
-
method: "POST",
|
|
2214
|
-
headers: {
|
|
2215
|
-
"Content-Type": "application/json",
|
|
2216
|
-
Authorization: `Bearer ${input.apiKey}`
|
|
2217
|
-
},
|
|
2218
|
-
body: JSON.stringify({
|
|
2219
|
-
model: input.model,
|
|
2220
|
-
temperature: input.temperature ?? 0.7,
|
|
2221
|
-
...(input.responseFormat === "json" ? { response_format: { type: "json_object" } } : {}),
|
|
2222
|
-
messages: [{ role: "user", content: input.prompt }]
|
|
2223
|
-
})
|
|
2224
|
-
});
|
|
2225
|
-
if (response.status === 401) {
|
|
2226
|
-
throw new ProviderAuthError(`${input.provider} authentication failed`);
|
|
2227
|
-
}
|
|
2228
|
-
if (response.status === 429) {
|
|
2229
|
-
throw await rateLimitOrQuotaError(input.provider, response);
|
|
2230
|
-
}
|
|
2231
|
-
if (!response.ok) {
|
|
2232
|
-
throw new Error(`${input.provider} returned ${response.status}`);
|
|
2233
|
-
}
|
|
2234
|
-
const data = await response.json();
|
|
2235
|
-
return {
|
|
2236
|
-
text: data.choices?.[0]?.message?.content ?? "",
|
|
2237
|
-
usage: {
|
|
2238
|
-
inputTokens: Number(data.usage?.prompt_tokens ?? 0),
|
|
2239
|
-
outputTokens: Number(data.usage?.completion_tokens ?? 0),
|
|
2240
|
-
costUsd: 0
|
|
2241
|
-
}
|
|
2242
|
-
};
|
|
2243
|
-
}
|
|
2244
|
-
async callGemini(input) {
|
|
2245
|
-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
|
|
2246
|
-
method: "POST",
|
|
2247
|
-
headers: {
|
|
2248
|
-
"Content-Type": "application/json"
|
|
2249
|
-
},
|
|
2250
|
-
body: JSON.stringify({
|
|
2251
|
-
contents: [{ parts: [{ text: input.prompt }] }],
|
|
2252
|
-
generationConfig: {
|
|
2253
|
-
temperature: input.temperature ?? 0.7,
|
|
2254
|
-
...(input.responseFormat === "json" ? { responseMimeType: "application/json" } : {})
|
|
2255
|
-
}
|
|
2256
|
-
})
|
|
2257
|
-
});
|
|
2258
|
-
if (response.status === 401 || response.status === 403) {
|
|
2259
|
-
throw new ProviderAuthError("Gemini authentication failed");
|
|
2260
|
-
}
|
|
2261
|
-
if (response.status === 429) {
|
|
2262
|
-
throw await rateLimitOrQuotaError("gemini", response);
|
|
2263
|
-
}
|
|
2264
|
-
if (!response.ok) {
|
|
2265
|
-
throw new Error(`Gemini returned ${response.status}`);
|
|
2266
|
-
}
|
|
2267
|
-
const data = await response.json();
|
|
2268
|
-
return {
|
|
2269
|
-
text: data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "",
|
|
2270
|
-
usage: {
|
|
2271
|
-
inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
|
|
2272
|
-
outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
|
|
2273
|
-
costUsd: 0
|
|
2274
|
-
}
|
|
2275
|
-
};
|
|
2276
|
-
}
|
|
2277
|
-
}
|
|
2278
|
-
function clamp(value, min, max) {
|
|
2279
|
-
return Math.max(min, Math.min(max, value));
|
|
2280
|
-
}
|
|
2281
|
-
function summarizeProviderValidationError(error) {
|
|
2282
|
-
const rawMessage = error instanceof Error ? error.message : "Unknown provider error";
|
|
2283
|
-
return rawMessage
|
|
2284
|
-
.replace(/\s+/g, " ")
|
|
2285
|
-
.trim()
|
|
2286
|
-
.slice(0, 280);
|
|
2287
|
-
}
|
|
2288
|
-
function toProviderValidationMessage(provider, error) {
|
|
2289
|
-
const providerLabel = formatProviderLabel(provider);
|
|
2290
|
-
const summary = summarizeProviderValidationError(error);
|
|
2291
|
-
if (!summary) {
|
|
2292
|
-
return `Unable to verify ${providerLabel} API key.`;
|
|
2293
|
-
}
|
|
2294
|
-
if (/401|403|invalid api key|api key not valid|incorrect api key|unauthorized|permission denied|authentication failed/i.test(summary)) {
|
|
2295
|
-
return `${providerLabel} API key was rejected by the provider. ${summary}`;
|
|
2296
|
-
}
|
|
2297
|
-
return `Unable to verify ${providerLabel} API key. ${summary}`;
|
|
2298
|
-
}
|
|
2299
|
-
function formatProviderLabel(provider) {
|
|
2300
|
-
switch (provider) {
|
|
2301
|
-
case "openai":
|
|
2302
|
-
return "OpenAI";
|
|
2303
|
-
case "gemini":
|
|
2304
|
-
return "Gemini";
|
|
2305
|
-
case "openrouter":
|
|
2306
|
-
return "OpenRouter";
|
|
2307
|
-
case "perplexity":
|
|
2308
|
-
return "Perplexity";
|
|
2309
|
-
}
|
|
2310
|
-
}
|
|
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);
|
|
2574
|
-
if (!normalizedModel) {
|
|
2575
|
-
throw new Error(`Missing ${kind} model for provider ${provider}.`);
|
|
2576
|
-
}
|
|
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}.`);
|
|
2582
|
-
}
|
|
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
|
-
}
|
|
2590
|
-
function inferSpeechContentType(format) {
|
|
2591
|
-
return format === "wav" ? "audio/wav" : "audio/mpeg";
|
|
2592
|
-
}
|
|
2593
|
-
function pcm16MonoToWav(pcmBuffer, sampleRate) {
|
|
2594
|
-
const header = Buffer.alloc(44);
|
|
2595
|
-
header.write("RIFF", 0, 4, "ascii");
|
|
2596
|
-
header.writeUInt32LE(36 + pcmBuffer.byteLength, 4);
|
|
2597
|
-
header.write("WAVE", 8, 4, "ascii");
|
|
2598
|
-
header.write("fmt ", 12, 4, "ascii");
|
|
2599
|
-
header.writeUInt32LE(16, 16);
|
|
2600
|
-
header.writeUInt16LE(1, 20);
|
|
2601
|
-
header.writeUInt16LE(1, 22);
|
|
2602
|
-
header.writeUInt32LE(sampleRate, 24);
|
|
2603
|
-
header.writeUInt32LE(sampleRate * 2, 28);
|
|
2604
|
-
header.writeUInt16LE(2, 32);
|
|
2605
|
-
header.writeUInt16LE(16, 34);
|
|
2606
|
-
header.write("data", 36, 4, "ascii");
|
|
2607
|
-
header.writeUInt32LE(pcmBuffer.byteLength, 40);
|
|
2608
|
-
return Buffer.concat([header, pcmBuffer]);
|
|
2609
|
-
}
|
|
2610
|
-
function isWavBuffer(buffer) {
|
|
2611
|
-
return buffer.byteLength >= 12
|
|
2612
|
-
&& buffer.subarray(0, 4).toString("ascii") === "RIFF"
|
|
2613
|
-
&& buffer.subarray(8, 12).toString("ascii") === "WAVE";
|
|
2614
|
-
}
|
|
2615
|
-
function isLikelyMp3Buffer(buffer) {
|
|
2616
|
-
if (buffer.byteLength < 3) {
|
|
2617
|
-
return false;
|
|
2618
|
-
}
|
|
2619
|
-
if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
|
|
2620
|
-
return true;
|
|
2621
|
-
}
|
|
2622
|
-
return buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
|
|
2623
|
-
}
|
|
2624
|
-
function audioFormatFromContentType(contentType) {
|
|
2625
|
-
const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
|
|
2626
|
-
switch (normalized) {
|
|
2627
|
-
case "audio/wav":
|
|
2628
|
-
case "audio/x-wav":
|
|
2629
|
-
return "wav";
|
|
2630
|
-
case "audio/mp4":
|
|
2631
|
-
case "audio/m4a":
|
|
2632
|
-
return "mp4";
|
|
2633
|
-
case "audio/webm":
|
|
2634
|
-
return "webm";
|
|
2635
|
-
case "audio/ogg":
|
|
2636
|
-
return "ogg";
|
|
2637
|
-
default:
|
|
2638
|
-
return "mp3";
|
|
2639
|
-
}
|
|
2640
|
-
}
|
|
2641
|
-
function audioExtensionFromContentType(contentType) {
|
|
2642
|
-
switch (audioFormatFromContentType(contentType)) {
|
|
2643
|
-
case "wav":
|
|
2644
|
-
return "wav";
|
|
2645
|
-
case "mp4":
|
|
2646
|
-
return "m4a";
|
|
2647
|
-
case "webm":
|
|
2648
|
-
return "webm";
|
|
2649
|
-
case "ogg":
|
|
2650
|
-
return "ogg";
|
|
2651
|
-
default:
|
|
2652
|
-
return "mp3";
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
function buildGeminiTranscriptionPrompt(prompt, language) {
|
|
2656
|
-
const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
|
|
2657
|
-
return language?.trim()
|
|
2658
|
-
? `${basePrompt}\n\nExpected language: ${language.trim()}.`
|
|
2659
|
-
: basePrompt;
|
|
2660
|
-
}
|
|
2661
|
-
function extractGeminiText(data) {
|
|
2662
|
-
return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
|
|
2663
|
-
}
|
|
2664
|
-
function supportsGeminiImageSize(model) {
|
|
2665
|
-
return model === "gemini-3.1-flash-image-preview" || model === "gemini-3-pro-image-preview";
|
|
2666
|
-
}
|
|
2667
|
-
async function fetchRemoteBytes(input) {
|
|
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;
|
|
2710
|
-
}
|
|
2711
|
-
}
|
|
2712
|
-
async function remoteImageToBlob(url) {
|
|
2713
|
-
const { bytes, mimeType } = await fetchRemoteBytes({ url, fallbackMimeType: "image/png" });
|
|
2714
|
-
return new Blob([bytes], { type: mimeType });
|
|
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
|
-
}
|
|
2749
|
-
async function buildGeminiPromptParts(prompt, attachments) {
|
|
2750
|
-
const parts = [];
|
|
2751
|
-
for (const attachment of attachments) {
|
|
2752
|
-
if (!/^https?:\/\//i.test(attachment)) {
|
|
2753
|
-
throw new Error(`Gemini prompt attachments must be public URLs: ${attachment}`);
|
|
2754
|
-
}
|
|
2755
|
-
const { bytes, mimeType } = await fetchRemoteBytes({ url: attachment });
|
|
2756
|
-
parts.push({
|
|
2757
|
-
inlineData: {
|
|
2758
|
-
mimeType,
|
|
2759
|
-
data: bytes.toString("base64")
|
|
2760
|
-
}
|
|
2761
|
-
});
|
|
2762
|
-
}
|
|
2763
|
-
parts.push({ text: prompt });
|
|
2764
|
-
return parts;
|
|
2765
|
-
}
|
|
2766
|
-
function decodeInlineDataUrl(value) {
|
|
2767
|
-
const match = value.match(/^data:([^;,]+);base64,(.+)$/);
|
|
2768
|
-
if (!match) {
|
|
2769
|
-
return null;
|
|
2770
|
-
}
|
|
2771
|
-
return {
|
|
2772
|
-
contentType: match[1],
|
|
2773
|
-
bytes: Buffer.from(match[2], "base64")
|
|
2774
|
-
};
|
|
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
|
-
}
|
|
2844
|
-
function buildMockSlideSvg(prompt) {
|
|
2845
|
-
const escaped = escapeXml(prompt.slice(0, 220));
|
|
2846
|
-
return `
|
|
2847
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1920" viewBox="0 0 1080 1920">
|
|
2848
|
-
<defs>
|
|
2849
|
-
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
|
2850
|
-
<stop offset="0%" stop-color="#31241c" />
|
|
2851
|
-
<stop offset="52%" stop-color="#866245" />
|
|
2852
|
-
<stop offset="100%" stop-color="#d7c0a4" />
|
|
2853
|
-
</linearGradient>
|
|
2854
|
-
</defs>
|
|
2855
|
-
<rect width="1080" height="1920" fill="url(#bg)" />
|
|
2856
|
-
<rect x="94" y="1180" width="892" height="470" rx="44" fill="rgba(0,0,0,0.18)" stroke="rgba(255,255,255,0.25)" />
|
|
2857
|
-
<text x="108" y="170" fill="#f7f0e7" font-size="54" font-family="Times New Roman, serif">Mock AI image</text>
|
|
2858
|
-
<text x="108" y="240" fill="#f7f0e7" font-size="34" font-family="Times New Roman, serif">${escaped}</text>
|
|
2859
|
-
<text x="108" y="1280" fill="#f7f0e7" font-size="42" font-family="Times New Roman, serif">Reserved text-safe zone</text>
|
|
2860
|
-
</svg>`;
|
|
2861
|
-
}
|
|
2862
|
-
function escapeXml(value) {
|
|
2863
|
-
return value
|
|
2864
|
-
.replace(/&/g, "&")
|
|
2865
|
-
.replace(/</g, "<")
|
|
2866
|
-
.replace(/>/g, ">")
|
|
2867
|
-
.replace(/"/g, """)
|
|
2868
|
-
.replace(/'/g, "'");
|
|
2869
|
-
}
|
|
2870
|
-
//# sourceMappingURL=providers.js.map
|