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