@wopr-network/platform-core 1.6.0 → 1.7.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/dist/monetization/adapters/rate-table.js +13 -1
- package/dist/monetization/adapters/rate-table.test.js +8 -1
- package/dist/monetization/adapters/transcription-factory.d.ts +48 -0
- package/dist/monetization/adapters/transcription-factory.js +52 -0
- package/dist/monetization/adapters/transcription-factory.test.d.ts +1 -0
- package/dist/monetization/adapters/transcription-factory.test.js +84 -0
- package/dist/monetization/adapters/tts-factory.d.ts +50 -0
- package/dist/monetization/adapters/tts-factory.js +62 -0
- package/dist/monetization/adapters/tts-factory.test.d.ts +1 -0
- package/dist/monetization/adapters/tts-factory.test.js +129 -0
- package/dist/monetization/index.d.ts +2 -0
- package/dist/monetization/index.js +4 -0
- package/package.json +1 -1
- package/src/monetization/adapters/rate-table.test.ts +9 -1
- package/src/monetization/adapters/rate-table.ts +14 -1
- package/src/monetization/adapters/transcription-factory.test.ts +110 -0
- package/src/monetization/adapters/transcription-factory.ts +79 -0
- package/src/monetization/adapters/tts-factory.test.ts +163 -0
- package/src/monetization/adapters/tts-factory.ts +93 -0
- package/src/monetization/index.ts +14 -0
|
@@ -61,8 +61,20 @@ export const RATE_TABLE = [
|
|
|
61
61
|
margin: 1.3, // 30% — dashboard default; runtime uses getMargin()
|
|
62
62
|
effectivePrice: 0.0000013, // = costPerUnit * margin ($1.30 per 1M tokens)
|
|
63
63
|
},
|
|
64
|
+
// Transcription - Speech-to-Text
|
|
65
|
+
// NOTE: No self-hosted whisper adapter yet — only premium (deepgram) available.
|
|
66
|
+
// When self-hosted-whisper lands, add a standard tier entry here.
|
|
67
|
+
{
|
|
68
|
+
capability: "transcription",
|
|
69
|
+
tier: "premium",
|
|
70
|
+
provider: "deepgram",
|
|
71
|
+
costPerUnit: 0.0000717, // $0.0043/min = $0.0000717/sec
|
|
72
|
+
billingUnit: "per-second",
|
|
73
|
+
margin: 1.3, // 30% — dashboard default; runtime uses getMargin()
|
|
74
|
+
effectivePrice: 0.00009321, // = costPerUnit * margin ($93.21 per 1M seconds ≈ $5.59 per 1M minutes)
|
|
75
|
+
},
|
|
64
76
|
// Future self-hosted adapters will add more entries here:
|
|
65
|
-
// - transcription: self-hosted-whisper (standard)
|
|
77
|
+
// - transcription: self-hosted-whisper (standard) — when GPU adapter exists
|
|
66
78
|
// - embeddings: self-hosted-embeddings (standard) vs openrouter (premium)
|
|
67
79
|
// - image-generation: self-hosted-sdxl (standard) vs replicate (premium)
|
|
68
80
|
];
|
|
@@ -104,6 +104,12 @@ describe("getRatesForCapability", () => {
|
|
|
104
104
|
expect(rates.map((r) => r.tier)).toContain("standard");
|
|
105
105
|
expect(rates.map((r) => r.tier)).toContain("premium");
|
|
106
106
|
});
|
|
107
|
+
it("returns premium-only for transcription (no standard tier yet)", () => {
|
|
108
|
+
const rates = getRatesForCapability("transcription");
|
|
109
|
+
expect(rates).toHaveLength(1);
|
|
110
|
+
expect(rates[0].tier).toBe("premium");
|
|
111
|
+
expect(rates[0].provider).toBe("deepgram");
|
|
112
|
+
});
|
|
107
113
|
it("returns empty array for non-existent capability", () => {
|
|
108
114
|
const rates = getRatesForCapability("image-generation");
|
|
109
115
|
expect(rates).toHaveLength(0);
|
|
@@ -136,7 +142,8 @@ describe("calculateSavings", () => {
|
|
|
136
142
|
expect(savings).toBeCloseTo(2.01, 2);
|
|
137
143
|
});
|
|
138
144
|
it("returns zero when capability has no standard tier", () => {
|
|
139
|
-
|
|
145
|
+
// Transcription only has premium (deepgram) — no self-hosted whisper yet
|
|
146
|
+
const savings = calculateSavings("transcription", 1000);
|
|
140
147
|
expect(savings).toBe(0);
|
|
141
148
|
});
|
|
142
149
|
it("returns zero when capability has no premium tier", () => {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcription adapter factory — instantiates all available STT adapters
|
|
3
|
+
* from environment config and returns them ready to register.
|
|
4
|
+
*
|
|
5
|
+
* Only adapters with valid config are created. The factory never touches
|
|
6
|
+
* the database — it returns plain ProviderAdapter instances that the caller
|
|
7
|
+
* registers with an ArbitrageRouter or AdapterSocket.
|
|
8
|
+
*
|
|
9
|
+
* Priority order (cheapest first, when all adapters available):
|
|
10
|
+
* self-hosted-whisper (GPU, cheapest) → Deepgram ($0.0043/min, third-party)
|
|
11
|
+
*
|
|
12
|
+
* NOTE: Self-hosted Whisper adapter doesn't exist yet. When it does, it
|
|
13
|
+
* will slot in as the GPU tier (cheapest) ahead of Deepgram.
|
|
14
|
+
*/
|
|
15
|
+
import { type DeepgramAdapterConfig } from "./deepgram.js";
|
|
16
|
+
import type { ProviderAdapter } from "./types.js";
|
|
17
|
+
/** Top-level factory config. Only providers with an API key are instantiated. */
|
|
18
|
+
export interface TranscriptionFactoryConfig {
|
|
19
|
+
/** Deepgram API key. Omit or empty string to skip. */
|
|
20
|
+
deepgramApiKey?: string;
|
|
21
|
+
/** Per-adapter config overrides */
|
|
22
|
+
deepgram?: Omit<Partial<DeepgramAdapterConfig>, "apiKey">;
|
|
23
|
+
}
|
|
24
|
+
/** Result of the factory — adapters + metadata for observability. */
|
|
25
|
+
export interface TranscriptionFactoryResult {
|
|
26
|
+
/** All instantiated adapters, ordered by cost priority (cheapest first). */
|
|
27
|
+
adapters: ProviderAdapter[];
|
|
28
|
+
/** Map of adapter name → ProviderAdapter for direct registration. */
|
|
29
|
+
adapterMap: Map<string, ProviderAdapter>;
|
|
30
|
+
/** Names of providers that were skipped (no API key). */
|
|
31
|
+
skipped: string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Create transcription adapters from the provided config.
|
|
35
|
+
*
|
|
36
|
+
* Returns only adapters whose API key is present and non-empty.
|
|
37
|
+
* Order matches arbitrage priority: cheapest first.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createTranscriptionAdapters(config: TranscriptionFactoryConfig): TranscriptionFactoryResult;
|
|
40
|
+
/**
|
|
41
|
+
* Create transcription adapters from environment variables.
|
|
42
|
+
*
|
|
43
|
+
* Reads API keys from:
|
|
44
|
+
* - DEEPGRAM_API_KEY
|
|
45
|
+
*
|
|
46
|
+
* Accepts optional per-adapter overrides.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createTranscriptionAdaptersFromEnv(overrides?: Omit<TranscriptionFactoryConfig, "deepgramApiKey">): TranscriptionFactoryResult;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcription adapter factory — instantiates all available STT adapters
|
|
3
|
+
* from environment config and returns them ready to register.
|
|
4
|
+
*
|
|
5
|
+
* Only adapters with valid config are created. The factory never touches
|
|
6
|
+
* the database — it returns plain ProviderAdapter instances that the caller
|
|
7
|
+
* registers with an ArbitrageRouter or AdapterSocket.
|
|
8
|
+
*
|
|
9
|
+
* Priority order (cheapest first, when all adapters available):
|
|
10
|
+
* self-hosted-whisper (GPU, cheapest) → Deepgram ($0.0043/min, third-party)
|
|
11
|
+
*
|
|
12
|
+
* NOTE: Self-hosted Whisper adapter doesn't exist yet. When it does, it
|
|
13
|
+
* will slot in as the GPU tier (cheapest) ahead of Deepgram.
|
|
14
|
+
*/
|
|
15
|
+
import { createDeepgramAdapter } from "./deepgram.js";
|
|
16
|
+
/**
|
|
17
|
+
* Create transcription adapters from the provided config.
|
|
18
|
+
*
|
|
19
|
+
* Returns only adapters whose API key is present and non-empty.
|
|
20
|
+
* Order matches arbitrage priority: cheapest first.
|
|
21
|
+
*/
|
|
22
|
+
export function createTranscriptionAdapters(config) {
|
|
23
|
+
const adapters = [];
|
|
24
|
+
const skipped = [];
|
|
25
|
+
// Deepgram — $0.0043/min (Nova-2 wholesale)
|
|
26
|
+
if (config.deepgramApiKey) {
|
|
27
|
+
adapters.push(createDeepgramAdapter({ apiKey: config.deepgramApiKey, ...config.deepgram }));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
skipped.push("deepgram");
|
|
31
|
+
}
|
|
32
|
+
// Future: self-hosted-whisper will go BEFORE deepgram (GPU tier, cheapest)
|
|
33
|
+
const adapterMap = new Map();
|
|
34
|
+
for (const adapter of adapters) {
|
|
35
|
+
adapterMap.set(adapter.name, adapter);
|
|
36
|
+
}
|
|
37
|
+
return { adapters, adapterMap, skipped };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create transcription adapters from environment variables.
|
|
41
|
+
*
|
|
42
|
+
* Reads API keys from:
|
|
43
|
+
* - DEEPGRAM_API_KEY
|
|
44
|
+
*
|
|
45
|
+
* Accepts optional per-adapter overrides.
|
|
46
|
+
*/
|
|
47
|
+
export function createTranscriptionAdaptersFromEnv(overrides) {
|
|
48
|
+
return createTranscriptionAdapters({
|
|
49
|
+
deepgramApiKey: process.env.DEEPGRAM_API_KEY,
|
|
50
|
+
...overrides,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTranscriptionAdapters, createTranscriptionAdaptersFromEnv } from "./transcription-factory.js";
|
|
3
|
+
describe("createTranscriptionAdapters", () => {
|
|
4
|
+
it("creates deepgram adapter when API key provided", () => {
|
|
5
|
+
const result = createTranscriptionAdapters({
|
|
6
|
+
deepgramApiKey: "sk-dg",
|
|
7
|
+
});
|
|
8
|
+
expect(result.adapters).toHaveLength(1);
|
|
9
|
+
expect(result.adapterMap.size).toBe(1);
|
|
10
|
+
expect(result.skipped).toHaveLength(0);
|
|
11
|
+
});
|
|
12
|
+
it("returns deepgram as only adapter", () => {
|
|
13
|
+
const result = createTranscriptionAdapters({
|
|
14
|
+
deepgramApiKey: "sk-dg",
|
|
15
|
+
});
|
|
16
|
+
expect(result.adapters[0].name).toBe("deepgram");
|
|
17
|
+
});
|
|
18
|
+
it("skips deepgram when no API key", () => {
|
|
19
|
+
const result = createTranscriptionAdapters({});
|
|
20
|
+
expect(result.adapters).toHaveLength(0);
|
|
21
|
+
expect(result.skipped).toEqual(["deepgram"]);
|
|
22
|
+
});
|
|
23
|
+
it("skips deepgram with empty string API key", () => {
|
|
24
|
+
const result = createTranscriptionAdapters({
|
|
25
|
+
deepgramApiKey: "",
|
|
26
|
+
});
|
|
27
|
+
expect(result.adapters).toHaveLength(0);
|
|
28
|
+
expect(result.skipped).toContain("deepgram");
|
|
29
|
+
});
|
|
30
|
+
it("adapter supports transcription capability", () => {
|
|
31
|
+
const result = createTranscriptionAdapters({
|
|
32
|
+
deepgramApiKey: "sk-dg",
|
|
33
|
+
});
|
|
34
|
+
expect(result.adapters[0].capabilities).toContain("transcription");
|
|
35
|
+
});
|
|
36
|
+
it("adapter implements transcribe", () => {
|
|
37
|
+
const result = createTranscriptionAdapters({
|
|
38
|
+
deepgramApiKey: "sk-dg",
|
|
39
|
+
});
|
|
40
|
+
expect(typeof result.adapters[0].transcribe).toBe("function");
|
|
41
|
+
});
|
|
42
|
+
it("adapterMap keys match adapter names", () => {
|
|
43
|
+
const result = createTranscriptionAdapters({
|
|
44
|
+
deepgramApiKey: "sk-dg",
|
|
45
|
+
});
|
|
46
|
+
for (const [key, adapter] of result.adapterMap) {
|
|
47
|
+
expect(key).toBe(adapter.name);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it("passes per-adapter config overrides", () => {
|
|
51
|
+
const result = createTranscriptionAdapters({
|
|
52
|
+
deepgramApiKey: "sk-dg",
|
|
53
|
+
deepgram: { costPerMinute: 0.005, defaultModel: "nova-2-general" },
|
|
54
|
+
});
|
|
55
|
+
expect(result.adapters).toHaveLength(1);
|
|
56
|
+
expect(result.adapters[0].name).toBe("deepgram");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe("createTranscriptionAdaptersFromEnv", () => {
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
vi.unstubAllEnvs();
|
|
62
|
+
});
|
|
63
|
+
it("reads API keys from environment variables", () => {
|
|
64
|
+
vi.stubEnv("DEEPGRAM_API_KEY", "env-dg");
|
|
65
|
+
const result = createTranscriptionAdaptersFromEnv();
|
|
66
|
+
expect(result.adapters).toHaveLength(1);
|
|
67
|
+
expect(result.adapters[0].name).toBe("deepgram");
|
|
68
|
+
expect(result.skipped).toHaveLength(0);
|
|
69
|
+
});
|
|
70
|
+
it("returns empty when no env vars set", () => {
|
|
71
|
+
vi.stubEnv("DEEPGRAM_API_KEY", "");
|
|
72
|
+
const result = createTranscriptionAdaptersFromEnv();
|
|
73
|
+
expect(result.adapters).toHaveLength(0);
|
|
74
|
+
expect(result.skipped).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
it("accepts per-adapter overrides alongside env keys", () => {
|
|
77
|
+
vi.stubEnv("DEEPGRAM_API_KEY", "env-dg");
|
|
78
|
+
const result = createTranscriptionAdaptersFromEnv({
|
|
79
|
+
deepgram: { marginMultiplier: 1.5 },
|
|
80
|
+
});
|
|
81
|
+
expect(result.adapters).toHaveLength(1);
|
|
82
|
+
expect(result.adapters[0].name).toBe("deepgram");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS adapter factory — instantiates all available TTS adapters from
|
|
3
|
+
* environment config and returns them ready to register.
|
|
4
|
+
*
|
|
5
|
+
* Only adapters with valid config are created. The factory never touches
|
|
6
|
+
* the database — it returns plain ProviderAdapter instances that the caller
|
|
7
|
+
* registers with an ArbitrageRouter or AdapterSocket.
|
|
8
|
+
*
|
|
9
|
+
* Priority order (cheapest first):
|
|
10
|
+
* GPU (chatterbox-tts, self-hosted) → ElevenLabs (third-party)
|
|
11
|
+
*/
|
|
12
|
+
import { type ChatterboxTTSAdapterConfig } from "./chatterbox-tts.js";
|
|
13
|
+
import { type ElevenLabsAdapterConfig } from "./elevenlabs.js";
|
|
14
|
+
import type { ProviderAdapter } from "./types.js";
|
|
15
|
+
/** Top-level factory config. Chatterbox needs a base URL; ElevenLabs needs an API key. */
|
|
16
|
+
export interface TTSFactoryConfig {
|
|
17
|
+
/** Chatterbox GPU container base URL (e.g., "http://chatterbox:8000"). Omit to skip. */
|
|
18
|
+
chatterboxBaseUrl?: string;
|
|
19
|
+
/** ElevenLabs API key. Omit or empty string to skip. */
|
|
20
|
+
elevenlabsApiKey?: string;
|
|
21
|
+
/** Per-adapter config overrides */
|
|
22
|
+
chatterbox?: Omit<Partial<ChatterboxTTSAdapterConfig>, "baseUrl">;
|
|
23
|
+
elevenlabs?: Omit<Partial<ElevenLabsAdapterConfig>, "apiKey">;
|
|
24
|
+
}
|
|
25
|
+
/** Result of the factory — adapters + metadata for observability. */
|
|
26
|
+
export interface TTSFactoryResult {
|
|
27
|
+
/** All instantiated adapters, ordered by cost priority (cheapest first). */
|
|
28
|
+
adapters: ProviderAdapter[];
|
|
29
|
+
/** Map of adapter name → ProviderAdapter for direct registration. */
|
|
30
|
+
adapterMap: Map<string, ProviderAdapter>;
|
|
31
|
+
/** Names of providers that were skipped (missing config). */
|
|
32
|
+
skipped: string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create TTS adapters from the provided config.
|
|
36
|
+
*
|
|
37
|
+
* Returns only adapters whose required config is present.
|
|
38
|
+
* Order matches arbitrage priority: GPU (cheapest) first.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createTTSAdapters(config: TTSFactoryConfig): TTSFactoryResult;
|
|
41
|
+
/**
|
|
42
|
+
* Create TTS adapters from environment variables.
|
|
43
|
+
*
|
|
44
|
+
* Reads config from:
|
|
45
|
+
* - CHATTERBOX_BASE_URL
|
|
46
|
+
* - ELEVENLABS_API_KEY
|
|
47
|
+
*
|
|
48
|
+
* Accepts optional per-adapter overrides.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createTTSAdaptersFromEnv(overrides?: Omit<TTSFactoryConfig, "chatterboxBaseUrl" | "elevenlabsApiKey">): TTSFactoryResult;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS adapter factory — instantiates all available TTS adapters from
|
|
3
|
+
* environment config and returns them ready to register.
|
|
4
|
+
*
|
|
5
|
+
* Only adapters with valid config are created. The factory never touches
|
|
6
|
+
* the database — it returns plain ProviderAdapter instances that the caller
|
|
7
|
+
* registers with an ArbitrageRouter or AdapterSocket.
|
|
8
|
+
*
|
|
9
|
+
* Priority order (cheapest first):
|
|
10
|
+
* GPU (chatterbox-tts, self-hosted) → ElevenLabs (third-party)
|
|
11
|
+
*/
|
|
12
|
+
import { createChatterboxTTSAdapter } from "./chatterbox-tts.js";
|
|
13
|
+
import { createElevenLabsAdapter } from "./elevenlabs.js";
|
|
14
|
+
/**
|
|
15
|
+
* Create TTS adapters from the provided config.
|
|
16
|
+
*
|
|
17
|
+
* Returns only adapters whose required config is present.
|
|
18
|
+
* Order matches arbitrage priority: GPU (cheapest) first.
|
|
19
|
+
*/
|
|
20
|
+
export function createTTSAdapters(config) {
|
|
21
|
+
const adapters = [];
|
|
22
|
+
const skipped = [];
|
|
23
|
+
// Chatterbox — self-hosted GPU, $2.00/1M chars wholesale, $2.40/1M effective (cheapest)
|
|
24
|
+
if (config.chatterboxBaseUrl) {
|
|
25
|
+
adapters.push(createChatterboxTTSAdapter({
|
|
26
|
+
baseUrl: config.chatterboxBaseUrl,
|
|
27
|
+
costPerUnit: 0.000002,
|
|
28
|
+
...config.chatterbox,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
skipped.push("chatterbox-tts");
|
|
33
|
+
}
|
|
34
|
+
// ElevenLabs — third-party, ~$15/1M chars (premium)
|
|
35
|
+
if (config.elevenlabsApiKey) {
|
|
36
|
+
adapters.push(createElevenLabsAdapter({ apiKey: config.elevenlabsApiKey, ...config.elevenlabs }));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
skipped.push("elevenlabs");
|
|
40
|
+
}
|
|
41
|
+
const adapterMap = new Map();
|
|
42
|
+
for (const adapter of adapters) {
|
|
43
|
+
adapterMap.set(adapter.name, adapter);
|
|
44
|
+
}
|
|
45
|
+
return { adapters, adapterMap, skipped };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Create TTS adapters from environment variables.
|
|
49
|
+
*
|
|
50
|
+
* Reads config from:
|
|
51
|
+
* - CHATTERBOX_BASE_URL
|
|
52
|
+
* - ELEVENLABS_API_KEY
|
|
53
|
+
*
|
|
54
|
+
* Accepts optional per-adapter overrides.
|
|
55
|
+
*/
|
|
56
|
+
export function createTTSAdaptersFromEnv(overrides) {
|
|
57
|
+
return createTTSAdapters({
|
|
58
|
+
chatterboxBaseUrl: process.env.CHATTERBOX_BASE_URL,
|
|
59
|
+
elevenlabsApiKey: process.env.ELEVENLABS_API_KEY,
|
|
60
|
+
...overrides,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTTSAdapters, createTTSAdaptersFromEnv } from "./tts-factory.js";
|
|
3
|
+
describe("createTTSAdapters", () => {
|
|
4
|
+
it("creates both adapters when all config provided", () => {
|
|
5
|
+
const result = createTTSAdapters({
|
|
6
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
7
|
+
elevenlabsApiKey: "sk-el",
|
|
8
|
+
});
|
|
9
|
+
expect(result.adapters).toHaveLength(2);
|
|
10
|
+
expect(result.adapterMap.size).toBe(2);
|
|
11
|
+
expect(result.skipped).toHaveLength(0);
|
|
12
|
+
});
|
|
13
|
+
it("returns adapters in cost-priority order (GPU first)", () => {
|
|
14
|
+
const result = createTTSAdapters({
|
|
15
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
16
|
+
elevenlabsApiKey: "sk-el",
|
|
17
|
+
});
|
|
18
|
+
const names = result.adapters.map((a) => a.name);
|
|
19
|
+
expect(names).toEqual(["chatterbox-tts", "elevenlabs"]);
|
|
20
|
+
});
|
|
21
|
+
it("skips chatterbox when no base URL", () => {
|
|
22
|
+
const result = createTTSAdapters({
|
|
23
|
+
elevenlabsApiKey: "sk-el",
|
|
24
|
+
});
|
|
25
|
+
expect(result.adapters).toHaveLength(1);
|
|
26
|
+
expect(result.adapters[0].name).toBe("elevenlabs");
|
|
27
|
+
expect(result.skipped).toEqual(["chatterbox-tts"]);
|
|
28
|
+
});
|
|
29
|
+
it("skips elevenlabs when no API key", () => {
|
|
30
|
+
const result = createTTSAdapters({
|
|
31
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
32
|
+
});
|
|
33
|
+
expect(result.adapters).toHaveLength(1);
|
|
34
|
+
expect(result.adapters[0].name).toBe("chatterbox-tts");
|
|
35
|
+
expect(result.skipped).toEqual(["elevenlabs"]);
|
|
36
|
+
});
|
|
37
|
+
it("skips elevenlabs with empty string API key", () => {
|
|
38
|
+
const result = createTTSAdapters({
|
|
39
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
40
|
+
elevenlabsApiKey: "",
|
|
41
|
+
});
|
|
42
|
+
expect(result.adapters).toHaveLength(1);
|
|
43
|
+
expect(result.skipped).toContain("elevenlabs");
|
|
44
|
+
});
|
|
45
|
+
it("returns empty result when no config provided", () => {
|
|
46
|
+
const result = createTTSAdapters({});
|
|
47
|
+
expect(result.adapters).toHaveLength(0);
|
|
48
|
+
expect(result.adapterMap.size).toBe(0);
|
|
49
|
+
expect(result.skipped).toEqual(["chatterbox-tts", "elevenlabs"]);
|
|
50
|
+
});
|
|
51
|
+
it("all adapters support tts capability", () => {
|
|
52
|
+
const result = createTTSAdapters({
|
|
53
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
54
|
+
elevenlabsApiKey: "sk-el",
|
|
55
|
+
});
|
|
56
|
+
for (const adapter of result.adapters) {
|
|
57
|
+
expect(adapter.capabilities).toContain("tts");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
it("all adapters implement synthesizeSpeech", () => {
|
|
61
|
+
const result = createTTSAdapters({
|
|
62
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
63
|
+
elevenlabsApiKey: "sk-el",
|
|
64
|
+
});
|
|
65
|
+
for (const adapter of result.adapters) {
|
|
66
|
+
expect(typeof adapter.synthesizeSpeech).toBe("function");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it("adapterMap keys match adapter names", () => {
|
|
70
|
+
const result = createTTSAdapters({
|
|
71
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
72
|
+
elevenlabsApiKey: "sk-el",
|
|
73
|
+
});
|
|
74
|
+
for (const [key, adapter] of result.adapterMap) {
|
|
75
|
+
expect(key).toBe(adapter.name);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
it("chatterbox adapter is marked self-hosted", () => {
|
|
79
|
+
const result = createTTSAdapters({
|
|
80
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
81
|
+
});
|
|
82
|
+
expect(result.adapters[0].selfHosted).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
it("elevenlabs adapter is not marked self-hosted", () => {
|
|
85
|
+
const result = createTTSAdapters({
|
|
86
|
+
elevenlabsApiKey: "sk-el",
|
|
87
|
+
});
|
|
88
|
+
expect(result.adapters[0].selfHosted).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
it("passes per-adapter config overrides", () => {
|
|
91
|
+
const result = createTTSAdapters({
|
|
92
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
93
|
+
chatterbox: { costPerChar: 0.000001 },
|
|
94
|
+
elevenlabsApiKey: "sk-el",
|
|
95
|
+
elevenlabs: { defaultVoice: "custom-voice" },
|
|
96
|
+
});
|
|
97
|
+
expect(result.adapters).toHaveLength(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("createTTSAdaptersFromEnv", () => {
|
|
101
|
+
afterAll(() => {
|
|
102
|
+
vi.unstubAllEnvs();
|
|
103
|
+
});
|
|
104
|
+
it("reads config from environment variables", () => {
|
|
105
|
+
vi.stubEnv("CHATTERBOX_BASE_URL", "http://chatterbox:8000");
|
|
106
|
+
vi.stubEnv("ELEVENLABS_API_KEY", "env-el");
|
|
107
|
+
const result = createTTSAdaptersFromEnv();
|
|
108
|
+
expect(result.adapters).toHaveLength(2);
|
|
109
|
+
const names = result.adapters.map((a) => a.name);
|
|
110
|
+
expect(names).toEqual(["chatterbox-tts", "elevenlabs"]);
|
|
111
|
+
expect(result.skipped).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
it("returns empty when no env vars set", () => {
|
|
114
|
+
vi.stubEnv("CHATTERBOX_BASE_URL", "");
|
|
115
|
+
vi.stubEnv("ELEVENLABS_API_KEY", "");
|
|
116
|
+
const result = createTTSAdaptersFromEnv();
|
|
117
|
+
expect(result.adapters).toHaveLength(0);
|
|
118
|
+
expect(result.skipped).toHaveLength(2);
|
|
119
|
+
});
|
|
120
|
+
it("accepts per-adapter overrides alongside env config", () => {
|
|
121
|
+
vi.stubEnv("CHATTERBOX_BASE_URL", "http://chatterbox:8000");
|
|
122
|
+
vi.stubEnv("ELEVENLABS_API_KEY", "");
|
|
123
|
+
const result = createTTSAdaptersFromEnv({
|
|
124
|
+
chatterbox: { costPerChar: 0.000001 },
|
|
125
|
+
});
|
|
126
|
+
expect(result.adapters).toHaveLength(1);
|
|
127
|
+
expect(result.adapters[0].name).toBe("chatterbox-tts");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -30,6 +30,8 @@ export { calculateSavings, getRatesForCapability, lookupRate, RATE_TABLE, type R
|
|
|
30
30
|
export { createReplicateAdapter, type ReplicateAdapterConfig } from "./adapters/replicate.js";
|
|
31
31
|
export { checkHealth, type FetchFn, type SelfHostedAdapterConfig } from "./adapters/self-hosted-base.js";
|
|
32
32
|
export { createTextGenAdapters, createTextGenAdaptersFromEnv, type TextGenFactoryConfig, type TextGenFactoryResult, } from "./adapters/text-gen-factory.js";
|
|
33
|
+
export { createTranscriptionAdapters, createTranscriptionAdaptersFromEnv, type TranscriptionFactoryConfig, type TranscriptionFactoryResult, } from "./adapters/transcription-factory.js";
|
|
34
|
+
export { createTTSAdapters, createTTSAdaptersFromEnv, type TTSFactoryConfig, type TTSFactoryResult, } from "./adapters/tts-factory.js";
|
|
33
35
|
export { type AdapterCapability, type AdapterResult, type EmbeddingsInput, type EmbeddingsOutput, type ImageGenerationInput, type ImageGenerationOutput, type MeterEvent, type ProviderAdapter, type TextGenerationInput, type TextGenerationOutput, type TranscriptionInput, type TranscriptionOutput, type TTSInput, type TTSOutput, withMargin, } from "./adapters/types.js";
|
|
34
36
|
export { type ArbitrageRequest, ArbitrageRouter, type ArbitrageRouterConfig, type MarginRecord, type ModelProviderEntry, NoProviderAvailableError, ProviderRegistry, type ProviderRegistryConfig, type RoutingDecision, } from "./arbitrage/index.js";
|
|
35
37
|
export type { BudgetCheckerConfig, BudgetCheckResult, SpendLimits } from "./budget/index.js";
|
|
@@ -35,6 +35,10 @@ export { createReplicateAdapter } from "./adapters/replicate.js";
|
|
|
35
35
|
export { checkHealth } from "./adapters/self-hosted-base.js";
|
|
36
36
|
// Text-generation adapter factory (WOP-463)
|
|
37
37
|
export { createTextGenAdapters, createTextGenAdaptersFromEnv, } from "./adapters/text-gen-factory.js";
|
|
38
|
+
// Transcription adapter factory
|
|
39
|
+
export { createTranscriptionAdapters, createTranscriptionAdaptersFromEnv, } from "./adapters/transcription-factory.js";
|
|
40
|
+
// TTS adapter factory
|
|
41
|
+
export { createTTSAdapters, createTTSAdaptersFromEnv, } from "./adapters/tts-factory.js";
|
|
38
42
|
export { withMargin, } from "./adapters/types.js";
|
|
39
43
|
// Arbitrage router — multi-provider routing for maximum margin (WOP-463)
|
|
40
44
|
export { ArbitrageRouter, NoProviderAvailableError, ProviderRegistry, } from "./arbitrage/index.js";
|
package/package.json
CHANGED
|
@@ -138,6 +138,13 @@ describe("getRatesForCapability", () => {
|
|
|
138
138
|
expect(rates.map((r) => r.tier)).toContain("premium");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
it("returns premium-only for transcription (no standard tier yet)", () => {
|
|
142
|
+
const rates = getRatesForCapability("transcription");
|
|
143
|
+
expect(rates).toHaveLength(1);
|
|
144
|
+
expect(rates[0].tier).toBe("premium");
|
|
145
|
+
expect(rates[0].provider).toBe("deepgram");
|
|
146
|
+
});
|
|
147
|
+
|
|
141
148
|
it("returns empty array for non-existent capability", () => {
|
|
142
149
|
const rates = getRatesForCapability("image-generation" as unknown as AdapterCapability);
|
|
143
150
|
expect(rates).toHaveLength(0);
|
|
@@ -178,7 +185,8 @@ describe("calculateSavings", () => {
|
|
|
178
185
|
});
|
|
179
186
|
|
|
180
187
|
it("returns zero when capability has no standard tier", () => {
|
|
181
|
-
|
|
188
|
+
// Transcription only has premium (deepgram) — no self-hosted whisper yet
|
|
189
|
+
const savings = calculateSavings("transcription", 1000);
|
|
182
190
|
expect(savings).toBe(0);
|
|
183
191
|
});
|
|
184
192
|
|
|
@@ -83,8 +83,21 @@ export const RATE_TABLE: RateEntry[] = [
|
|
|
83
83
|
effectivePrice: 0.0000013, // = costPerUnit * margin ($1.30 per 1M tokens)
|
|
84
84
|
},
|
|
85
85
|
|
|
86
|
+
// Transcription - Speech-to-Text
|
|
87
|
+
// NOTE: No self-hosted whisper adapter yet — only premium (deepgram) available.
|
|
88
|
+
// When self-hosted-whisper lands, add a standard tier entry here.
|
|
89
|
+
{
|
|
90
|
+
capability: "transcription",
|
|
91
|
+
tier: "premium",
|
|
92
|
+
provider: "deepgram",
|
|
93
|
+
costPerUnit: 0.0000717, // $0.0043/min = $0.0000717/sec
|
|
94
|
+
billingUnit: "per-second",
|
|
95
|
+
margin: 1.3, // 30% — dashboard default; runtime uses getMargin()
|
|
96
|
+
effectivePrice: 0.00009321, // = costPerUnit * margin ($93.21 per 1M seconds ≈ $5.59 per 1M minutes)
|
|
97
|
+
},
|
|
98
|
+
|
|
86
99
|
// Future self-hosted adapters will add more entries here:
|
|
87
|
-
// - transcription: self-hosted-whisper (standard)
|
|
100
|
+
// - transcription: self-hosted-whisper (standard) — when GPU adapter exists
|
|
88
101
|
// - embeddings: self-hosted-embeddings (standard) vs openrouter (premium)
|
|
89
102
|
// - image-generation: self-hosted-sdxl (standard) vs replicate (premium)
|
|
90
103
|
];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTranscriptionAdapters, createTranscriptionAdaptersFromEnv } from "./transcription-factory.js";
|
|
3
|
+
|
|
4
|
+
describe("createTranscriptionAdapters", () => {
|
|
5
|
+
it("creates deepgram adapter when API key provided", () => {
|
|
6
|
+
const result = createTranscriptionAdapters({
|
|
7
|
+
deepgramApiKey: "sk-dg",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
expect(result.adapters).toHaveLength(1);
|
|
11
|
+
expect(result.adapterMap.size).toBe(1);
|
|
12
|
+
expect(result.skipped).toHaveLength(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns deepgram as only adapter", () => {
|
|
16
|
+
const result = createTranscriptionAdapters({
|
|
17
|
+
deepgramApiKey: "sk-dg",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(result.adapters[0].name).toBe("deepgram");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("skips deepgram when no API key", () => {
|
|
24
|
+
const result = createTranscriptionAdapters({});
|
|
25
|
+
|
|
26
|
+
expect(result.adapters).toHaveLength(0);
|
|
27
|
+
expect(result.skipped).toEqual(["deepgram"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("skips deepgram with empty string API key", () => {
|
|
31
|
+
const result = createTranscriptionAdapters({
|
|
32
|
+
deepgramApiKey: "",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.adapters).toHaveLength(0);
|
|
36
|
+
expect(result.skipped).toContain("deepgram");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("adapter supports transcription capability", () => {
|
|
40
|
+
const result = createTranscriptionAdapters({
|
|
41
|
+
deepgramApiKey: "sk-dg",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.adapters[0].capabilities).toContain("transcription");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("adapter implements transcribe", () => {
|
|
48
|
+
const result = createTranscriptionAdapters({
|
|
49
|
+
deepgramApiKey: "sk-dg",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(typeof result.adapters[0].transcribe).toBe("function");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("adapterMap keys match adapter names", () => {
|
|
56
|
+
const result = createTranscriptionAdapters({
|
|
57
|
+
deepgramApiKey: "sk-dg",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
for (const [key, adapter] of result.adapterMap) {
|
|
61
|
+
expect(key).toBe(adapter.name);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("passes per-adapter config overrides", () => {
|
|
66
|
+
const result = createTranscriptionAdapters({
|
|
67
|
+
deepgramApiKey: "sk-dg",
|
|
68
|
+
deepgram: { costPerMinute: 0.005, defaultModel: "nova-2-general" },
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.adapters).toHaveLength(1);
|
|
72
|
+
expect(result.adapters[0].name).toBe("deepgram");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("createTranscriptionAdaptersFromEnv", () => {
|
|
77
|
+
afterAll(() => {
|
|
78
|
+
vi.unstubAllEnvs();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reads API keys from environment variables", () => {
|
|
82
|
+
vi.stubEnv("DEEPGRAM_API_KEY", "env-dg");
|
|
83
|
+
|
|
84
|
+
const result = createTranscriptionAdaptersFromEnv();
|
|
85
|
+
|
|
86
|
+
expect(result.adapters).toHaveLength(1);
|
|
87
|
+
expect(result.adapters[0].name).toBe("deepgram");
|
|
88
|
+
expect(result.skipped).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns empty when no env vars set", () => {
|
|
92
|
+
vi.stubEnv("DEEPGRAM_API_KEY", "");
|
|
93
|
+
|
|
94
|
+
const result = createTranscriptionAdaptersFromEnv();
|
|
95
|
+
|
|
96
|
+
expect(result.adapters).toHaveLength(0);
|
|
97
|
+
expect(result.skipped).toHaveLength(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("accepts per-adapter overrides alongside env keys", () => {
|
|
101
|
+
vi.stubEnv("DEEPGRAM_API_KEY", "env-dg");
|
|
102
|
+
|
|
103
|
+
const result = createTranscriptionAdaptersFromEnv({
|
|
104
|
+
deepgram: { marginMultiplier: 1.5 },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(result.adapters).toHaveLength(1);
|
|
108
|
+
expect(result.adapters[0].name).toBe("deepgram");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcription adapter factory — instantiates all available STT adapters
|
|
3
|
+
* from environment config and returns them ready to register.
|
|
4
|
+
*
|
|
5
|
+
* Only adapters with valid config are created. The factory never touches
|
|
6
|
+
* the database — it returns plain ProviderAdapter instances that the caller
|
|
7
|
+
* registers with an ArbitrageRouter or AdapterSocket.
|
|
8
|
+
*
|
|
9
|
+
* Priority order (cheapest first, when all adapters available):
|
|
10
|
+
* self-hosted-whisper (GPU, cheapest) → Deepgram ($0.0043/min, third-party)
|
|
11
|
+
*
|
|
12
|
+
* NOTE: Self-hosted Whisper adapter doesn't exist yet. When it does, it
|
|
13
|
+
* will slot in as the GPU tier (cheapest) ahead of Deepgram.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createDeepgramAdapter, type DeepgramAdapterConfig } from "./deepgram.js";
|
|
17
|
+
import type { ProviderAdapter } from "./types.js";
|
|
18
|
+
|
|
19
|
+
/** Top-level factory config. Only providers with an API key are instantiated. */
|
|
20
|
+
export interface TranscriptionFactoryConfig {
|
|
21
|
+
/** Deepgram API key. Omit or empty string to skip. */
|
|
22
|
+
deepgramApiKey?: string;
|
|
23
|
+
/** Per-adapter config overrides */
|
|
24
|
+
deepgram?: Omit<Partial<DeepgramAdapterConfig>, "apiKey">;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Result of the factory — adapters + metadata for observability. */
|
|
28
|
+
export interface TranscriptionFactoryResult {
|
|
29
|
+
/** All instantiated adapters, ordered by cost priority (cheapest first). */
|
|
30
|
+
adapters: ProviderAdapter[];
|
|
31
|
+
/** Map of adapter name → ProviderAdapter for direct registration. */
|
|
32
|
+
adapterMap: Map<string, ProviderAdapter>;
|
|
33
|
+
/** Names of providers that were skipped (no API key). */
|
|
34
|
+
skipped: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Create transcription adapters from the provided config.
|
|
39
|
+
*
|
|
40
|
+
* Returns only adapters whose API key is present and non-empty.
|
|
41
|
+
* Order matches arbitrage priority: cheapest first.
|
|
42
|
+
*/
|
|
43
|
+
export function createTranscriptionAdapters(config: TranscriptionFactoryConfig): TranscriptionFactoryResult {
|
|
44
|
+
const adapters: ProviderAdapter[] = [];
|
|
45
|
+
const skipped: string[] = [];
|
|
46
|
+
|
|
47
|
+
// Deepgram — $0.0043/min (Nova-2 wholesale)
|
|
48
|
+
if (config.deepgramApiKey) {
|
|
49
|
+
adapters.push(createDeepgramAdapter({ apiKey: config.deepgramApiKey, ...config.deepgram }));
|
|
50
|
+
} else {
|
|
51
|
+
skipped.push("deepgram");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Future: self-hosted-whisper will go BEFORE deepgram (GPU tier, cheapest)
|
|
55
|
+
|
|
56
|
+
const adapterMap = new Map<string, ProviderAdapter>();
|
|
57
|
+
for (const adapter of adapters) {
|
|
58
|
+
adapterMap.set(adapter.name, adapter);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { adapters, adapterMap, skipped };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create transcription adapters from environment variables.
|
|
66
|
+
*
|
|
67
|
+
* Reads API keys from:
|
|
68
|
+
* - DEEPGRAM_API_KEY
|
|
69
|
+
*
|
|
70
|
+
* Accepts optional per-adapter overrides.
|
|
71
|
+
*/
|
|
72
|
+
export function createTranscriptionAdaptersFromEnv(
|
|
73
|
+
overrides?: Omit<TranscriptionFactoryConfig, "deepgramApiKey">,
|
|
74
|
+
): TranscriptionFactoryResult {
|
|
75
|
+
return createTranscriptionAdapters({
|
|
76
|
+
deepgramApiKey: process.env.DEEPGRAM_API_KEY,
|
|
77
|
+
...overrides,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTTSAdapters, createTTSAdaptersFromEnv } from "./tts-factory.js";
|
|
3
|
+
|
|
4
|
+
describe("createTTSAdapters", () => {
|
|
5
|
+
it("creates both adapters when all config provided", () => {
|
|
6
|
+
const result = createTTSAdapters({
|
|
7
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
8
|
+
elevenlabsApiKey: "sk-el",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(result.adapters).toHaveLength(2);
|
|
12
|
+
expect(result.adapterMap.size).toBe(2);
|
|
13
|
+
expect(result.skipped).toHaveLength(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns adapters in cost-priority order (GPU first)", () => {
|
|
17
|
+
const result = createTTSAdapters({
|
|
18
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
19
|
+
elevenlabsApiKey: "sk-el",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const names = result.adapters.map((a) => a.name);
|
|
23
|
+
expect(names).toEqual(["chatterbox-tts", "elevenlabs"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("skips chatterbox when no base URL", () => {
|
|
27
|
+
const result = createTTSAdapters({
|
|
28
|
+
elevenlabsApiKey: "sk-el",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result.adapters).toHaveLength(1);
|
|
32
|
+
expect(result.adapters[0].name).toBe("elevenlabs");
|
|
33
|
+
expect(result.skipped).toEqual(["chatterbox-tts"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("skips elevenlabs when no API key", () => {
|
|
37
|
+
const result = createTTSAdapters({
|
|
38
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.adapters).toHaveLength(1);
|
|
42
|
+
expect(result.adapters[0].name).toBe("chatterbox-tts");
|
|
43
|
+
expect(result.skipped).toEqual(["elevenlabs"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("skips elevenlabs with empty string API key", () => {
|
|
47
|
+
const result = createTTSAdapters({
|
|
48
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
49
|
+
elevenlabsApiKey: "",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result.adapters).toHaveLength(1);
|
|
53
|
+
expect(result.skipped).toContain("elevenlabs");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns empty result when no config provided", () => {
|
|
57
|
+
const result = createTTSAdapters({});
|
|
58
|
+
|
|
59
|
+
expect(result.adapters).toHaveLength(0);
|
|
60
|
+
expect(result.adapterMap.size).toBe(0);
|
|
61
|
+
expect(result.skipped).toEqual(["chatterbox-tts", "elevenlabs"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("all adapters support tts capability", () => {
|
|
65
|
+
const result = createTTSAdapters({
|
|
66
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
67
|
+
elevenlabsApiKey: "sk-el",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const adapter of result.adapters) {
|
|
71
|
+
expect(adapter.capabilities).toContain("tts");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("all adapters implement synthesizeSpeech", () => {
|
|
76
|
+
const result = createTTSAdapters({
|
|
77
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
78
|
+
elevenlabsApiKey: "sk-el",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const adapter of result.adapters) {
|
|
82
|
+
expect(typeof adapter.synthesizeSpeech).toBe("function");
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("adapterMap keys match adapter names", () => {
|
|
87
|
+
const result = createTTSAdapters({
|
|
88
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
89
|
+
elevenlabsApiKey: "sk-el",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
for (const [key, adapter] of result.adapterMap) {
|
|
93
|
+
expect(key).toBe(adapter.name);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("chatterbox adapter is marked self-hosted", () => {
|
|
98
|
+
const result = createTTSAdapters({
|
|
99
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.adapters[0].selfHosted).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("elevenlabs adapter is not marked self-hosted", () => {
|
|
106
|
+
const result = createTTSAdapters({
|
|
107
|
+
elevenlabsApiKey: "sk-el",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(result.adapters[0].selfHosted).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("passes per-adapter config overrides", () => {
|
|
114
|
+
const result = createTTSAdapters({
|
|
115
|
+
chatterboxBaseUrl: "http://chatterbox:8000",
|
|
116
|
+
chatterbox: { costPerChar: 0.000001 },
|
|
117
|
+
elevenlabsApiKey: "sk-el",
|
|
118
|
+
elevenlabs: { defaultVoice: "custom-voice" },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result.adapters).toHaveLength(2);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("createTTSAdaptersFromEnv", () => {
|
|
126
|
+
afterAll(() => {
|
|
127
|
+
vi.unstubAllEnvs();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("reads config from environment variables", () => {
|
|
131
|
+
vi.stubEnv("CHATTERBOX_BASE_URL", "http://chatterbox:8000");
|
|
132
|
+
vi.stubEnv("ELEVENLABS_API_KEY", "env-el");
|
|
133
|
+
|
|
134
|
+
const result = createTTSAdaptersFromEnv();
|
|
135
|
+
|
|
136
|
+
expect(result.adapters).toHaveLength(2);
|
|
137
|
+
const names = result.adapters.map((a) => a.name);
|
|
138
|
+
expect(names).toEqual(["chatterbox-tts", "elevenlabs"]);
|
|
139
|
+
expect(result.skipped).toHaveLength(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns empty when no env vars set", () => {
|
|
143
|
+
vi.stubEnv("CHATTERBOX_BASE_URL", "");
|
|
144
|
+
vi.stubEnv("ELEVENLABS_API_KEY", "");
|
|
145
|
+
|
|
146
|
+
const result = createTTSAdaptersFromEnv();
|
|
147
|
+
|
|
148
|
+
expect(result.adapters).toHaveLength(0);
|
|
149
|
+
expect(result.skipped).toHaveLength(2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("accepts per-adapter overrides alongside env config", () => {
|
|
153
|
+
vi.stubEnv("CHATTERBOX_BASE_URL", "http://chatterbox:8000");
|
|
154
|
+
vi.stubEnv("ELEVENLABS_API_KEY", "");
|
|
155
|
+
|
|
156
|
+
const result = createTTSAdaptersFromEnv({
|
|
157
|
+
chatterbox: { costPerChar: 0.000001 },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(result.adapters).toHaveLength(1);
|
|
161
|
+
expect(result.adapters[0].name).toBe("chatterbox-tts");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS adapter factory — instantiates all available TTS adapters from
|
|
3
|
+
* environment config and returns them ready to register.
|
|
4
|
+
*
|
|
5
|
+
* Only adapters with valid config are created. The factory never touches
|
|
6
|
+
* the database — it returns plain ProviderAdapter instances that the caller
|
|
7
|
+
* registers with an ArbitrageRouter or AdapterSocket.
|
|
8
|
+
*
|
|
9
|
+
* Priority order (cheapest first):
|
|
10
|
+
* GPU (chatterbox-tts, self-hosted) → ElevenLabs (third-party)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type ChatterboxTTSAdapterConfig, createChatterboxTTSAdapter } from "./chatterbox-tts.js";
|
|
14
|
+
import { createElevenLabsAdapter, type ElevenLabsAdapterConfig } from "./elevenlabs.js";
|
|
15
|
+
import type { ProviderAdapter } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/** Top-level factory config. Chatterbox needs a base URL; ElevenLabs needs an API key. */
|
|
18
|
+
export interface TTSFactoryConfig {
|
|
19
|
+
/** Chatterbox GPU container base URL (e.g., "http://chatterbox:8000"). Omit to skip. */
|
|
20
|
+
chatterboxBaseUrl?: string;
|
|
21
|
+
/** ElevenLabs API key. Omit or empty string to skip. */
|
|
22
|
+
elevenlabsApiKey?: string;
|
|
23
|
+
/** Per-adapter config overrides */
|
|
24
|
+
chatterbox?: Omit<Partial<ChatterboxTTSAdapterConfig>, "baseUrl">;
|
|
25
|
+
elevenlabs?: Omit<Partial<ElevenLabsAdapterConfig>, "apiKey">;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Result of the factory — adapters + metadata for observability. */
|
|
29
|
+
export interface TTSFactoryResult {
|
|
30
|
+
/** All instantiated adapters, ordered by cost priority (cheapest first). */
|
|
31
|
+
adapters: ProviderAdapter[];
|
|
32
|
+
/** Map of adapter name → ProviderAdapter for direct registration. */
|
|
33
|
+
adapterMap: Map<string, ProviderAdapter>;
|
|
34
|
+
/** Names of providers that were skipped (missing config). */
|
|
35
|
+
skipped: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create TTS adapters from the provided config.
|
|
40
|
+
*
|
|
41
|
+
* Returns only adapters whose required config is present.
|
|
42
|
+
* Order matches arbitrage priority: GPU (cheapest) first.
|
|
43
|
+
*/
|
|
44
|
+
export function createTTSAdapters(config: TTSFactoryConfig): TTSFactoryResult {
|
|
45
|
+
const adapters: ProviderAdapter[] = [];
|
|
46
|
+
const skipped: string[] = [];
|
|
47
|
+
|
|
48
|
+
// Chatterbox — self-hosted GPU, $2.00/1M chars wholesale, $2.40/1M effective (cheapest)
|
|
49
|
+
if (config.chatterboxBaseUrl) {
|
|
50
|
+
adapters.push(
|
|
51
|
+
createChatterboxTTSAdapter({
|
|
52
|
+
baseUrl: config.chatterboxBaseUrl,
|
|
53
|
+
costPerUnit: 0.000002,
|
|
54
|
+
...config.chatterbox,
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
skipped.push("chatterbox-tts");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ElevenLabs — third-party, ~$15/1M chars (premium)
|
|
62
|
+
if (config.elevenlabsApiKey) {
|
|
63
|
+
adapters.push(createElevenLabsAdapter({ apiKey: config.elevenlabsApiKey, ...config.elevenlabs }));
|
|
64
|
+
} else {
|
|
65
|
+
skipped.push("elevenlabs");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const adapterMap = new Map<string, ProviderAdapter>();
|
|
69
|
+
for (const adapter of adapters) {
|
|
70
|
+
adapterMap.set(adapter.name, adapter);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { adapters, adapterMap, skipped };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create TTS adapters from environment variables.
|
|
78
|
+
*
|
|
79
|
+
* Reads config from:
|
|
80
|
+
* - CHATTERBOX_BASE_URL
|
|
81
|
+
* - ELEVENLABS_API_KEY
|
|
82
|
+
*
|
|
83
|
+
* Accepts optional per-adapter overrides.
|
|
84
|
+
*/
|
|
85
|
+
export function createTTSAdaptersFromEnv(
|
|
86
|
+
overrides?: Omit<TTSFactoryConfig, "chatterboxBaseUrl" | "elevenlabsApiKey">,
|
|
87
|
+
): TTSFactoryResult {
|
|
88
|
+
return createTTSAdapters({
|
|
89
|
+
chatterboxBaseUrl: process.env.CHATTERBOX_BASE_URL,
|
|
90
|
+
elevenlabsApiKey: process.env.ELEVENLABS_API_KEY,
|
|
91
|
+
...overrides,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -65,6 +65,20 @@ export {
|
|
|
65
65
|
type TextGenFactoryConfig,
|
|
66
66
|
type TextGenFactoryResult,
|
|
67
67
|
} from "./adapters/text-gen-factory.js";
|
|
68
|
+
// Transcription adapter factory
|
|
69
|
+
export {
|
|
70
|
+
createTranscriptionAdapters,
|
|
71
|
+
createTranscriptionAdaptersFromEnv,
|
|
72
|
+
type TranscriptionFactoryConfig,
|
|
73
|
+
type TranscriptionFactoryResult,
|
|
74
|
+
} from "./adapters/transcription-factory.js";
|
|
75
|
+
// TTS adapter factory
|
|
76
|
+
export {
|
|
77
|
+
createTTSAdapters,
|
|
78
|
+
createTTSAdaptersFromEnv,
|
|
79
|
+
type TTSFactoryConfig,
|
|
80
|
+
type TTSFactoryResult,
|
|
81
|
+
} from "./adapters/tts-factory.js";
|
|
68
82
|
export {
|
|
69
83
|
type AdapterCapability,
|
|
70
84
|
type AdapterResult,
|