@mevdragon/vidfarm-devcli 0.10.0 → 0.11.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.
@@ -5,35 +5,9 @@ import { createId } from "../lib/ids.js";
5
5
  import { addSeconds } from "../lib/time.js";
6
6
  import { ServerlessProviderKeyService } from "./serverless-provider-keys.js";
7
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
- }
8
+ import { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError, conciseProviderDetails, parseRetryAfterSeconds, rateLimitOrQuotaError } from "./provider-errors.js";
9
+ import { SUPPORTED_STT_MODELS, SUPPORTED_TTS_MODELS, defaultSpeechModelFor, generateSpeechWithKey, inferSpeechContentType, transcribeSpeechWithKey } from "./speech.js";
10
+ export { ProviderAuthError, ProviderKeyUnavailableError, ProviderLeaseTimeoutError, ProviderQuotaExceededError, ProviderRateLimitError, ProviderTransientError, ProviderWaitExceededError } from "./provider-errors.js";
37
11
  const SUPPORTED_TEXT_MODELS = {
38
12
  openai: new Set(["gpt-5.4"]),
39
13
  gemini: new Set(["gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
@@ -96,16 +70,6 @@ const OPENAI_IMAGE_SIZES = new Set([
96
70
  "auto"
97
71
  ]);
98
72
  const GEMINI_IMAGE_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"]);
99
- const SUPPORTED_TTS_MODELS = {
100
- openrouter: new Set(["google/gemini-3.1-flash-tts-preview"]),
101
- gemini: new Set(["gemini-3.1-flash-tts-preview", "gemini-2.5-flash-preview-tts"]),
102
- openai: new Set(["gpt-4o-mini-tts-2025-12-15"])
103
- };
104
- const SUPPORTED_STT_MODELS = {
105
- openrouter: new Set(["openai/gpt-4o-mini-transcribe"]),
106
- gemini: new Set(["gemini-3.1-flash-lite-preview", "gemini-2.5-flash-lite"]),
107
- openai: new Set(["gpt-4o-mini-transcribe-2025-12-15"])
108
- };
109
73
  const PROVIDER_KEY_VALIDATION_MODELS = {
110
74
  openai: "gpt-4.1-mini",
111
75
  gemini: "gemini-3.1-flash-lite-preview",
@@ -134,48 +98,9 @@ function formatDurationSeconds(totalSeconds) {
134
98
  }
135
99
  return `${Math.max(1, Math.round(totalSeconds))} second${Math.round(totalSeconds) === 1 ? "" : "s"}`;
136
100
  }
137
- function parseRetryAfterSeconds(value) {
138
- if (!value) {
139
- return 60;
140
- }
141
- const numeric = Number(value);
142
- if (Number.isFinite(numeric) && numeric > 0) {
143
- return Math.max(1, Math.min(Math.ceil(numeric), 60 * 60));
144
- }
145
- const parsedDate = Date.parse(value);
146
- if (Number.isFinite(parsedDate)) {
147
- return Math.max(1, Math.min(Math.ceil((parsedDate - Date.now()) / 1000), 60 * 60));
148
- }
149
- return 60;
150
- }
151
- function isHardQuotaError(details) {
152
- const normalized = details.toLowerCase();
153
- return [
154
- "insufficient_quota",
155
- "quota_exceeded",
156
- "exceeded your current quota",
157
- "billing",
158
- "insufficient credits",
159
- "not enough credits",
160
- "credit balance",
161
- "prepaid credits",
162
- "resource_exhausted"
163
- ].some((pattern) => normalized.includes(pattern));
164
- }
165
- function conciseProviderDetails(details) {
166
- const compact = details.replace(/\s+/g, " ").trim();
167
- return compact ? `: ${compact.slice(0, 500)}` : "";
168
- }
169
101
  function conciseProviderResponse(operation, response, details) {
170
102
  return `${operation} returned ${response.status}${conciseProviderDetails(details)}`;
171
103
  }
172
- async function rateLimitOrQuotaError(provider, response) {
173
- const details = await response.text();
174
- if (isHardQuotaError(details)) {
175
- return new ProviderQuotaExceededError(`${provider} quota or billing limit was reached${conciseProviderDetails(details)}`);
176
- }
177
- return new ProviderRateLimitError(`${provider} rate limited${conciseProviderDetails(details)}`, parseRetryAfterSeconds(response.headers.get("retry-after")));
178
- }
179
104
  function isTransientProviderStatus(status) {
180
105
  return status === 408 || status === 409 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
181
106
  }
@@ -253,9 +178,6 @@ function defaultModelFor(kind, provider) {
253
178
  function providerSupportsModelFrom(supported, provider, model) {
254
179
  return Boolean(supported[provider]?.has(normalizeProviderModel(model)));
255
180
  }
256
- function defaultSpeechModelFor(kind, provider) {
257
- return kind === "tts" ? SUPPORTED_TTS_MODELS[provider]?.values().next().value : SUPPORTED_STT_MODELS[provider]?.values().next().value;
258
- }
259
181
  function uniqueProviders(requested, supported) {
260
182
  return [
261
183
  requested,
@@ -686,23 +608,15 @@ export class ProviderService {
686
608
  }
687
609
  };
688
610
  }
689
- const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => input.provider === "gemini"
690
- ? this.callGeminiSpeechGeneration({
691
- model: input.model,
692
- text: input.text,
693
- voice: input.voice,
694
- instructions: input.instructions,
695
- apiKey: lease.secret
696
- })
697
- : this.callOpenAICompatibleSpeechGeneration({
698
- provider: input.provider === "openrouter" ? "openrouter" : "openai",
699
- model: input.model,
700
- text: input.text,
701
- voice: input.voice,
702
- instructions: input.instructions,
703
- responseFormat: input.responseFormat,
704
- apiKey: lease.secret
705
- })));
611
+ const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => generateSpeechWithKey({
612
+ provider: input.provider,
613
+ model: input.model,
614
+ text: input.text,
615
+ voice: input.voice,
616
+ instructions: input.instructions,
617
+ responseFormat: input.responseFormat,
618
+ apiKey: lease.secret
619
+ })));
706
620
  await this.serverlessProviderKeys.recordProviderKeyUsage({
707
621
  id: createId("usage"),
708
622
  keyId: lease.keyId,
@@ -1612,6 +1526,14 @@ export class ProviderService {
1612
1526
  await this.serverlessProviderKeys.touchProviderKey(lease.keyId);
1613
1527
  return {
1614
1528
  text: "Mock transcription output.",
1529
+ language: input.language?.trim() || null,
1530
+ segments: input.diarize
1531
+ ? [
1532
+ { speaker: "speaker_1", start_sec: 0, end_sec: 2, text: "Mock transcription output." },
1533
+ { speaker: "speaker_2", start_sec: 2, end_sec: 4, text: "Mock second narrator." }
1534
+ ]
1535
+ : [{ speaker: "speaker_1", start_sec: null, end_sec: null, text: "Mock transcription output." }],
1536
+ diarization: input.diarize ? "diarized" : "none",
1615
1537
  usage: {
1616
1538
  inputTokens: 0,
1617
1539
  outputTokens: 0,
@@ -1619,32 +1541,16 @@ export class ProviderService {
1619
1541
  }
1620
1542
  };
1621
1543
  }
1622
- const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => input.provider === "gemini"
1623
- ? this.callGeminiSpeechTranscription({
1624
- model: input.model,
1625
- audio: input.audio,
1626
- contentType: input.contentType,
1627
- prompt: input.prompt,
1628
- language: input.language,
1629
- apiKey: lease.secret
1630
- })
1631
- : input.provider === "openrouter"
1632
- ? this.callOpenRouterSpeechTranscription({
1633
- model: input.model,
1634
- audio: input.audio,
1635
- contentType: input.contentType,
1636
- prompt: input.prompt,
1637
- language: input.language,
1638
- apiKey: lease.secret
1639
- })
1640
- : this.callOpenAISpeechTranscription({
1641
- model: input.model,
1642
- audio: input.audio,
1643
- contentType: input.contentType,
1644
- prompt: input.prompt,
1645
- language: input.language,
1646
- apiKey: lease.secret
1647
- })));
1544
+ const result = zeroUsageCost(await this.withLeaseRenewal(lease, () => transcribeSpeechWithKey({
1545
+ provider: input.provider,
1546
+ model: input.model,
1547
+ audio: input.audio,
1548
+ contentType: input.contentType,
1549
+ prompt: input.prompt,
1550
+ language: input.language,
1551
+ diarize: input.diarize,
1552
+ apiKey: lease.secret
1553
+ })));
1648
1554
  await this.serverlessProviderKeys.recordProviderKeyUsage({
1649
1555
  id: createId("usage"),
1650
1556
  keyId: lease.keyId,
@@ -1897,118 +1803,6 @@ export class ProviderService {
1897
1803
  }
1898
1804
  return image;
1899
1805
  }
1900
- async callOpenAICompatibleSpeechGeneration(input) {
1901
- const requestedFormat = input.responseFormat ?? "mp3";
1902
- const usePcmBridge = input.provider === "openrouter" &&
1903
- /gemini/i.test(input.model) &&
1904
- requestedFormat !== "mp3";
1905
- const responseFormat = usePcmBridge ? "pcm" : requestedFormat;
1906
- const endpoint = input.provider === "openrouter"
1907
- ? "https://openrouter.ai/api/v1/audio/speech"
1908
- : "https://api.openai.com/v1/audio/speech";
1909
- const response = await fetch(endpoint, {
1910
- method: "POST",
1911
- headers: {
1912
- "Content-Type": "application/json",
1913
- Authorization: `Bearer ${input.apiKey}`
1914
- },
1915
- body: JSON.stringify({
1916
- model: input.model,
1917
- input: input.text,
1918
- voice: input.voice ?? "alloy",
1919
- instructions: input.instructions,
1920
- response_format: responseFormat
1921
- })
1922
- });
1923
- if (response.status === 401 || response.status === 403) {
1924
- throw new ProviderAuthError(`${input.provider} authentication failed`);
1925
- }
1926
- if (response.status === 429) {
1927
- throw await rateLimitOrQuotaError(input.provider, response);
1928
- }
1929
- if (!response.ok) {
1930
- const details = await response.text();
1931
- throw new Error(`${input.provider} speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
1932
- }
1933
- const rawBytes = Buffer.from(await response.arrayBuffer());
1934
- const bytes = usePcmBridge ? pcm16MonoToWav(rawBytes, 24000) : rawBytes;
1935
- const contentType = usePcmBridge
1936
- ? "audio/wav"
1937
- : response.headers.get("content-type")?.split(";")[0]?.trim() || inferSpeechContentType(responseFormat);
1938
- return {
1939
- bytes,
1940
- contentType,
1941
- usage: {
1942
- inputTokens: 0,
1943
- outputTokens: 0,
1944
- costUsd: 0
1945
- }
1946
- };
1947
- }
1948
- async callGeminiSpeechGeneration(input) {
1949
- const prompt = input.instructions?.trim()
1950
- ? `${input.instructions.trim()}\n\nSpeak exactly the following text:\n${input.text}`
1951
- : input.text;
1952
- const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
1953
- method: "POST",
1954
- headers: {
1955
- "Content-Type": "application/json"
1956
- },
1957
- body: JSON.stringify({
1958
- contents: [{ parts: [{ text: prompt }] }],
1959
- generationConfig: {
1960
- responseModalities: ["AUDIO"],
1961
- speechConfig: {
1962
- voiceConfig: {
1963
- prebuiltVoiceConfig: {
1964
- voiceName: input.voice ?? "Kore"
1965
- }
1966
- }
1967
- }
1968
- }
1969
- })
1970
- });
1971
- if (response.status === 401 || response.status === 403) {
1972
- throw new ProviderAuthError("gemini authentication failed");
1973
- }
1974
- if (response.status === 429) {
1975
- throw await rateLimitOrQuotaError("gemini", response);
1976
- }
1977
- if (!response.ok) {
1978
- const details = await response.text();
1979
- throw new Error(`gemini speech generation returned ${response.status}${details ? `: ${details}` : ""}`);
1980
- }
1981
- const data = await response.json();
1982
- const part = data.candidates?.[0]?.content?.parts?.find((item) => item.inlineData?.data || item.inline_data?.data);
1983
- const encoded = part?.inlineData?.data ?? part?.inline_data?.data;
1984
- if (!encoded) {
1985
- throw new Error("gemini speech generation returned no audio payload");
1986
- }
1987
- const rawBytes = Buffer.from(encoded, "base64");
1988
- const rawContentType = String(part?.inlineData?.mimeType ?? part?.inline_data?.mime_type ?? "audio/L16;rate=24000");
1989
- const pcmRateMatch = /audio\/l16;?\s*rate=(\d+)/i.exec(rawContentType);
1990
- const looksLikeWav = isWavBuffer(rawBytes);
1991
- const looksLikeMp3 = isLikelyMp3Buffer(rawBytes);
1992
- const shouldWrapPcm = Boolean(pcmRateMatch ||
1993
- (!looksLikeWav && !looksLikeMp3));
1994
- const bytes = shouldWrapPcm
1995
- ? pcm16MonoToWav(rawBytes, Number(pcmRateMatch?.[1] || "24000"))
1996
- : rawBytes;
1997
- const contentType = shouldWrapPcm
1998
- ? "audio/wav"
1999
- : looksLikeWav
2000
- ? "audio/wav"
2001
- : "audio/mpeg";
2002
- return {
2003
- bytes,
2004
- contentType,
2005
- usage: {
2006
- inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
2007
- outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
2008
- costUsd: 0
2009
- }
2010
- };
2011
- }
2012
1806
  async callOpenAICompatibleLayoutAnalysis(input) {
2013
1807
  const endpoint = input.provider === "openrouter"
2014
1808
  ? "https://openrouter.ai/api/v1/chat/completions"
@@ -2112,128 +1906,6 @@ export class ProviderService {
2112
1906
  const text = data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "";
2113
1907
  return String(text);
2114
1908
  }
2115
- async callOpenAISpeechTranscription(input) {
2116
- const contentType = input.contentType ?? "audio/mpeg";
2117
- const form = new FormData();
2118
- form.set("file", new Blob([Buffer.from(input.audio)], { type: contentType }), `audio.${audioExtensionFromContentType(contentType)}`);
2119
- form.set("model", input.model);
2120
- form.set("response_format", "json");
2121
- if (input.prompt) {
2122
- form.set("prompt", input.prompt);
2123
- }
2124
- if (input.language) {
2125
- form.set("language", input.language);
2126
- }
2127
- const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
2128
- method: "POST",
2129
- headers: {
2130
- Authorization: `Bearer ${input.apiKey}`
2131
- },
2132
- body: form
2133
- });
2134
- if (response.status === 401 || response.status === 403) {
2135
- throw new ProviderAuthError("openai authentication failed");
2136
- }
2137
- if (response.status === 429) {
2138
- throw await rateLimitOrQuotaError("openai", response);
2139
- }
2140
- if (!response.ok) {
2141
- const details = await response.text();
2142
- throw new Error(`openai transcription returned ${response.status}${details ? `: ${details}` : ""}`);
2143
- }
2144
- const data = await response.json();
2145
- return {
2146
- text: String(data.text ?? ""),
2147
- usage: {
2148
- inputTokens: Number(data.usage?.input_tokens ?? 0),
2149
- outputTokens: Number(data.usage?.output_tokens ?? 0),
2150
- costUsd: Number(data.usage?.cost ?? 0)
2151
- }
2152
- };
2153
- }
2154
- async callOpenRouterSpeechTranscription(input) {
2155
- const response = await fetch("https://openrouter.ai/api/v1/audio/transcriptions", {
2156
- method: "POST",
2157
- headers: {
2158
- "Content-Type": "application/json",
2159
- Authorization: `Bearer ${input.apiKey}`
2160
- },
2161
- body: JSON.stringify({
2162
- model: input.model,
2163
- input_audio: {
2164
- data: Buffer.from(input.audio).toString("base64"),
2165
- format: audioFormatFromContentType(input.contentType)
2166
- },
2167
- ...(input.prompt ? { prompt: input.prompt } : {}),
2168
- ...(input.language ? { language: input.language } : {})
2169
- })
2170
- });
2171
- if (response.status === 401 || response.status === 403) {
2172
- throw new ProviderAuthError("openrouter authentication failed");
2173
- }
2174
- if (response.status === 429) {
2175
- throw await rateLimitOrQuotaError("openrouter", response);
2176
- }
2177
- if (!response.ok) {
2178
- const details = await response.text();
2179
- throw new Error(`openrouter transcription returned ${response.status}${details ? `: ${details}` : ""}`);
2180
- }
2181
- const data = await response.json();
2182
- return {
2183
- text: String(data.text ?? ""),
2184
- usage: {
2185
- inputTokens: Number(data.usage?.input_tokens ?? data.usage?.prompt_tokens ?? 0),
2186
- outputTokens: Number(data.usage?.output_tokens ?? data.usage?.completion_tokens ?? 0),
2187
- costUsd: Number(data.usage?.cost ?? 0)
2188
- }
2189
- };
2190
- }
2191
- async callGeminiSpeechTranscription(input) {
2192
- const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${input.model}:generateContent?key=${input.apiKey}`, {
2193
- method: "POST",
2194
- headers: {
2195
- "Content-Type": "application/json"
2196
- },
2197
- body: JSON.stringify({
2198
- contents: [{
2199
- parts: [
2200
- {
2201
- inline_data: {
2202
- mime_type: input.contentType ?? "audio/mpeg",
2203
- data: Buffer.from(input.audio).toString("base64")
2204
- }
2205
- },
2206
- {
2207
- text: buildGeminiTranscriptionPrompt(input.prompt, input.language)
2208
- }
2209
- ]
2210
- }],
2211
- generationConfig: {
2212
- temperature: 0,
2213
- responseMimeType: "text/plain"
2214
- }
2215
- })
2216
- });
2217
- if (response.status === 401 || response.status === 403) {
2218
- throw new ProviderAuthError("gemini authentication failed");
2219
- }
2220
- if (response.status === 429) {
2221
- throw await rateLimitOrQuotaError("gemini", response);
2222
- }
2223
- if (!response.ok) {
2224
- const details = await response.text();
2225
- throw new Error(`gemini transcription returned ${response.status}${details ? `: ${details}` : ""}`);
2226
- }
2227
- const data = await response.json();
2228
- return {
2229
- text: extractGeminiText(data),
2230
- usage: {
2231
- inputTokens: Number(data.usageMetadata?.promptTokenCount ?? 0),
2232
- outputTokens: Number(data.usageMetadata?.candidatesTokenCount ?? 0),
2233
- costUsd: 0
2234
- }
2235
- };
2236
- }
2237
1909
  async callProvider(input) {
2238
1910
  if (input.provider === "gemini") {
2239
1911
  return this.callGemini({
@@ -2643,77 +2315,6 @@ function assertSupportedSpeechModel(kind, provider, model) {
2643
2315
  function normalizeProviderModel(model) {
2644
2316
  return model.trim().replace(/^models\//, "");
2645
2317
  }
2646
- function inferSpeechContentType(format) {
2647
- return format === "wav" ? "audio/wav" : "audio/mpeg";
2648
- }
2649
- function pcm16MonoToWav(pcmBuffer, sampleRate) {
2650
- const header = Buffer.alloc(44);
2651
- header.write("RIFF", 0, 4, "ascii");
2652
- header.writeUInt32LE(36 + pcmBuffer.byteLength, 4);
2653
- header.write("WAVE", 8, 4, "ascii");
2654
- header.write("fmt ", 12, 4, "ascii");
2655
- header.writeUInt32LE(16, 16);
2656
- header.writeUInt16LE(1, 20);
2657
- header.writeUInt16LE(1, 22);
2658
- header.writeUInt32LE(sampleRate, 24);
2659
- header.writeUInt32LE(sampleRate * 2, 28);
2660
- header.writeUInt16LE(2, 32);
2661
- header.writeUInt16LE(16, 34);
2662
- header.write("data", 36, 4, "ascii");
2663
- header.writeUInt32LE(pcmBuffer.byteLength, 40);
2664
- return Buffer.concat([header, pcmBuffer]);
2665
- }
2666
- function isWavBuffer(buffer) {
2667
- return buffer.byteLength >= 12
2668
- && buffer.subarray(0, 4).toString("ascii") === "RIFF"
2669
- && buffer.subarray(8, 12).toString("ascii") === "WAVE";
2670
- }
2671
- function isLikelyMp3Buffer(buffer) {
2672
- if (buffer.byteLength < 3) {
2673
- return false;
2674
- }
2675
- if (buffer.subarray(0, 3).toString("ascii") === "ID3") {
2676
- return true;
2677
- }
2678
- return buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0;
2679
- }
2680
- function audioFormatFromContentType(contentType) {
2681
- const normalized = contentType?.split(";")[0]?.trim().toLowerCase();
2682
- switch (normalized) {
2683
- case "audio/wav":
2684
- case "audio/x-wav":
2685
- return "wav";
2686
- case "audio/mp4":
2687
- case "audio/m4a":
2688
- return "mp4";
2689
- case "audio/webm":
2690
- return "webm";
2691
- case "audio/ogg":
2692
- return "ogg";
2693
- default:
2694
- return "mp3";
2695
- }
2696
- }
2697
- function audioExtensionFromContentType(contentType) {
2698
- switch (audioFormatFromContentType(contentType)) {
2699
- case "wav":
2700
- return "wav";
2701
- case "mp4":
2702
- return "m4a";
2703
- case "webm":
2704
- return "webm";
2705
- case "ogg":
2706
- return "ogg";
2707
- default:
2708
- return "mp3";
2709
- }
2710
- }
2711
- function buildGeminiTranscriptionPrompt(prompt, language) {
2712
- const basePrompt = prompt?.trim() || "Transcribe this audio verbatim. Return plain text only.";
2713
- return language?.trim()
2714
- ? `${basePrompt}\n\nExpected language: ${language.trim()}.`
2715
- : basePrompt;
2716
- }
2717
2318
  function extractGeminiText(data) {
2718
2319
  return String(data.candidates?.[0]?.content?.parts?.map((part) => part.text ?? "").join("\n") ?? "");
2719
2320
  }