@startsimpli/llm 0.4.29 → 0.4.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -5
- package/bin/startsimpli-speech.js +22 -0
- package/package.json +10 -3
- package/src/__tests__/fixtures/captions-golden.json +1881 -0
- package/src/__tests__/fixtures/eleven-v3-tagged-alignment.json +1 -0
- package/src/__tests__/fixtures/voices-golden.json +614 -0
- package/src/__tests__/refine.test.ts +193 -0
- package/src/__tests__/speech-captions-cli.test.ts +37 -0
- package/src/__tests__/speech-captions.test.ts +136 -0
- package/src/__tests__/speech-cli.test.ts +67 -0
- package/src/__tests__/speech-synthesize.test.ts +277 -0
- package/src/__tests__/speech-voices-cli.test.ts +45 -0
- package/src/__tests__/speech-voices.test.ts +109 -0
- package/src/index.ts +3 -0
- package/src/refine/errors.ts +23 -0
- package/src/refine/gates.ts +55 -0
- package/src/refine/index.ts +11 -0
- package/src/refine/llm-step.ts +35 -0
- package/src/refine/refine.ts +81 -0
- package/src/refine/types.ts +54 -0
- package/src/speech/alignment.ts +48 -0
- package/src/speech/bin.ts +12 -0
- package/src/speech/captions/cues.ts +108 -0
- package/src/speech/captions/formats.ts +33 -0
- package/src/speech/captions/index.ts +6 -0
- package/src/speech/captions/types.ts +18 -0
- package/src/speech/captions/words.ts +91 -0
- package/src/speech/captions-cli.ts +50 -0
- package/src/speech/cli.ts +140 -0
- package/src/speech/http.ts +11 -4
- package/src/speech/index.ts +8 -2
- package/src/speech/interface.ts +7 -5
- package/src/speech/providers/elevenlabs-models.ts +34 -0
- package/src/speech/providers/elevenlabs-tts.ts +88 -0
- package/src/speech/providers/elevenlabs.ts +23 -5
- package/src/speech/providers/mock.ts +23 -3
- package/src/speech/providers/openai.ts +34 -3
- package/src/speech/repr.ts +14 -0
- package/src/speech/service.ts +25 -14
- package/src/speech/types.ts +55 -1
- package/src/speech/voices/cast-voices.json +127 -0
- package/src/speech/voices/cast-voices.ts +169 -0
- package/src/speech/voices/emotion.ts +51 -0
- package/src/speech/voices/errors.ts +14 -0
- package/src/speech/voices/index.ts +12 -0
- package/src/speech/voices/schema.ts +53 -0
- package/src/speech/voices/tags.ts +11 -0
- package/src/speech/voices-cli.ts +43 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { LLMProviderError } from '../errors';
|
|
6
|
+
import {
|
|
7
|
+
ElevenLabsSpeechProvider, MockSpeechProvider, OpenAISpeechProvider, SpeechService,
|
|
8
|
+
createElevenLabsSpeechProvider, resetMockSpeechBehavior, setMockSpeechBehavior, wordsFromCharacters,
|
|
9
|
+
} from '../speech';
|
|
10
|
+
import type { ISpeechProvider, SynthesizeRequest } from '../speech';
|
|
11
|
+
|
|
12
|
+
afterEach(() => { resetMockSpeechBehavior(); vi.unstubAllEnvs(); });
|
|
13
|
+
|
|
14
|
+
const req: SynthesizeRequest = { text: 'hi you', voiceId: 'v1' };
|
|
15
|
+
const AUDIO = new Uint8Array([9, 8, 7, 6]);
|
|
16
|
+
const b64 = Buffer.from(AUDIO).toString('base64');
|
|
17
|
+
|
|
18
|
+
/** ElevenLabs with-timestamps body for `hi you`. */
|
|
19
|
+
const elevenBody = (text = 'hi you') => {
|
|
20
|
+
const chars = [...text];
|
|
21
|
+
return {
|
|
22
|
+
audio_base64: b64,
|
|
23
|
+
alignment: {
|
|
24
|
+
characters: chars,
|
|
25
|
+
character_start_times_seconds: chars.map((_, i) => i * 0.1),
|
|
26
|
+
character_end_times_seconds: chars.map((_, i) => (i + 1) * 0.1),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const respond = (body: unknown | Uint8Array, status = 200) =>
|
|
32
|
+
vi.fn(async (_u: unknown, _i?: RequestInit) =>
|
|
33
|
+
new Response(body instanceof Uint8Array ? body : typeof body === 'string' ? body : JSON.stringify(body), { status }));
|
|
34
|
+
|
|
35
|
+
const wires: Array<{ name: string; make: (f: typeof fetch) => ISpeechProvider; ok: unknown; aligned: boolean; mime: string }> = [
|
|
36
|
+
{ name: 'elevenlabs', make: (fetchImpl) => new ElevenLabsSpeechProvider({ apiKey: 'k', fetchImpl }), ok: elevenBody(), aligned: true, mime: 'audio/mpeg' },
|
|
37
|
+
{ name: 'openai', make: (fetchImpl) => new OpenAISpeechProvider({ apiKey: 'k', fetchImpl }), ok: AUDIO, aligned: false, mime: 'audio/mpeg' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
describe.each(wires)('synthesize contract: $name', ({ name, make, ok, aligned, mime }) => {
|
|
41
|
+
it('advertises the capability', () => {
|
|
42
|
+
expect(make(fetch).capabilities.synthesize).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns audio bytes, mime, provider, model and character usage', async () => {
|
|
46
|
+
const out = await make(respond(ok) as never).synthesize!(req);
|
|
47
|
+
expect([...out.audio]).toEqual([...AUDIO]);
|
|
48
|
+
expect(out.mimeType).toBe(mime);
|
|
49
|
+
expect(out).toMatchObject({ provider: name });
|
|
50
|
+
expect(out.model).toBeTruthy();
|
|
51
|
+
expect(out.usage.characters).toBe(req.text.length);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it(aligned ? 'returns ordered, non-overlapping word timings' : 'has no alignment (the vendor gives none)', async () => {
|
|
55
|
+
const out = await make(respond(ok) as never).synthesize!(req);
|
|
56
|
+
if (!aligned) { expect(out.words).toBeUndefined(); return; }
|
|
57
|
+
expect(out.words?.map((w) => w.text)).toEqual(['hi', 'you']);
|
|
58
|
+
const [a, b] = out.words!;
|
|
59
|
+
expect(a.end).toBeLessThanOrEqual(b.start);
|
|
60
|
+
expect(out.durationSec).toBeGreaterThan(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each([[401, 'AUTHENTICATION_ERROR', false], [429, 'RATE_LIMITED', true], [503, 'SERVICE_UNAVAILABLE', true], [400, 'INVALID_REQUEST', false]])(
|
|
64
|
+
'classifies HTTP %i as %s (retryable=%s)', async (status, code, retryable) => {
|
|
65
|
+
const err = await make(respond('boom', status as number) as never).synthesize!(req).catch((e) => e);
|
|
66
|
+
expect(err).toBeInstanceOf(LLMProviderError);
|
|
67
|
+
expect(err).toMatchObject({ code, retryable, provider: name });
|
|
68
|
+
expect(err.message).not.toMatch(/transcription/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('refuses empty text without calling the vendor', async () => {
|
|
72
|
+
const f = respond(ok);
|
|
73
|
+
await expect(make(f as never).synthesize!({ ...req, text: ' ' })).rejects.toMatchObject({ code: 'INVALID_REQUEST' });
|
|
74
|
+
expect(f).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('turns a network failure into a retryable error', async () => {
|
|
78
|
+
const f = vi.fn(async () => { throw new TypeError('fetch failed'); });
|
|
79
|
+
await expect(make(f as never).synthesize!(req)).rejects.toMatchObject({ retryable: true, code: 'SERVICE_UNAVAILABLE' });
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('elevenlabs synthesize specifics', () => {
|
|
84
|
+
const run = async (patch: Partial<SynthesizeRequest> = {}, body: unknown = elevenBody()) => {
|
|
85
|
+
const f = respond(body);
|
|
86
|
+
const out = await new ElevenLabsSpeechProvider({ apiKey: 'sekret', fetchImpl: f as never }).synthesize({ ...req, ...patch });
|
|
87
|
+
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
|
|
88
|
+
return { out, url, init, sent: JSON.parse(init.body as string) };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
it('posts to with-timestamps with the key header and voice in the path', async () => {
|
|
92
|
+
const { url, init } = await run();
|
|
93
|
+
expect(url).toBe('https://api.elevenlabs.io/v1/text-to-speech/v1/with-timestamps?output_format=mp3_44100_128');
|
|
94
|
+
expect((init.headers as Record<string, string>)['xi-api-key']).toBe('sekret');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('takes the voice from the call, else ELEVENLABS_VOICE_ID, else refuses', async () => {
|
|
98
|
+
const p = createElevenLabsSpeechProvider({ ELEVENLABS_API_KEY: 'k', ELEVENLABS_VOICE_ID: 'envvoice' })!;
|
|
99
|
+
const f = respond(elevenBody());
|
|
100
|
+
(p as any).fetchImpl = f;
|
|
101
|
+
await p.synthesize({ text: 'hi you' });
|
|
102
|
+
expect((f.mock.calls[0] as unknown as [string])[0]).toContain('/text-to-speech/envvoice/with-timestamps');
|
|
103
|
+
const bare = new ElevenLabsSpeechProvider({ apiKey: 'k', fetchImpl: respond(elevenBody()) as never });
|
|
104
|
+
await expect(bare.synthesize({ text: 'hi' })).rejects.toMatchObject({ code: 'INVALID_REQUEST' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('maps outputFormat to the mime type', async () => {
|
|
108
|
+
expect((await run({ outputFormat: 'pcm_22050' })).out.mimeType).toBe('audio/pcm');
|
|
109
|
+
expect((await run({ outputFormat: 'pcm_22050' })).url).toContain('output_format=pcm_22050');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('passes model, language and voiceSettings through', async () => {
|
|
113
|
+
const { sent } = await run({ model: 'eleven_v3', language: 'en', voiceSettings: { stability: 0.5, similarity_boost: 0.7 } });
|
|
114
|
+
expect(sent).toMatchObject({ text: 'hi you', model_id: 'eleven_v3', language_code: 'en', voice_settings: { stability: 0.5, similarity_boost: 0.7 } });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('typed style speed/stability become voice_settings; explicit voiceSettings win', async () => {
|
|
118
|
+
const { sent } = await run({ style: { speed: 1.1, stability: 0.3 }, voiceSettings: { stability: 0.9 } });
|
|
119
|
+
expect(sent.voice_settings).toEqual({ speed: 1.1, stability: 0.9 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('style tags are prefixed on models that read them', async () => {
|
|
123
|
+
const { sent, out } = await run({ model: 'eleven_v3', style: { tags: ['excited', 'whispers'] } },
|
|
124
|
+
elevenBody('[excited][whispers] hi you'));
|
|
125
|
+
expect(sent.text).toBe('[excited][whispers] hi you');
|
|
126
|
+
expect(out.usage.characters).toBe('[excited][whispers] hi you'.length);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('style tags are dropped on models that would speak them aloud', async () => {
|
|
130
|
+
const { sent } = await run({ model: 'eleven_multilingual_v2', style: { tags: ['excited'] } });
|
|
131
|
+
expect(sent.text).toBe('hi you');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('audio tags are not words: stripped from the words, offsets kept, characters untouched', async () => {
|
|
135
|
+
const text = '[excited] hi you';
|
|
136
|
+
const { out } = await run({ model: 'eleven_v3', text }, elevenBody(text));
|
|
137
|
+
expect(out.words?.map((w) => w.text)).toEqual(['hi', 'you']);
|
|
138
|
+
expect(out.words![0].start).toBeCloseTo(1.0); // "[excited] " is 10 chars at 0.1s
|
|
139
|
+
expect(out.characters?.length).toBe(text.length);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('keepTags:true leaves tags in the words', async () => {
|
|
143
|
+
const text = '[excited] hi you';
|
|
144
|
+
const { out } = await run({ model: 'eleven_v3', text, keepTags: true }, elevenBody(text));
|
|
145
|
+
expect(out.words?.map((w) => w.text)).toEqual(['[excited]', 'hi', 'you']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('cost follows the model rate table (USD per 1k characters)', async () => {
|
|
149
|
+
const text = 'x'.repeat(1000);
|
|
150
|
+
expect((await run({ model: 'eleven_v3', text }, elevenBody(text))).out.usage.costUsd).toBeCloseTo(0.08);
|
|
151
|
+
expect((await run({ model: 'eleven_multilingual_v2', text }, elevenBody(text))).out.usage.costUsd).toBeCloseTo(0.08);
|
|
152
|
+
expect((await run({ model: 'eleven_flash_v2_5', text }, elevenBody(text))).out.usage.costUsd).toBeCloseTo(0.04);
|
|
153
|
+
expect((await run({ model: 'some_future_model', text }, elevenBody(text))).out.usage.costUsd).toBeCloseTo(0.10);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('a response with no alignment still returns audio', async () => {
|
|
157
|
+
const { out } = await run({}, { audio_base64: b64 });
|
|
158
|
+
expect(out.words).toBeUndefined();
|
|
159
|
+
expect([...out.audio]).toEqual([...AUDIO]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('openai: posts the voice and format to /v1/audio/speech', async () => {
|
|
163
|
+
const f = respond(AUDIO);
|
|
164
|
+
await new OpenAISpeechProvider({ apiKey: 'k', fetchImpl: f as never }).synthesize({ text: 'hi' });
|
|
165
|
+
const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit];
|
|
166
|
+
expect(url).toBe('https://api.openai.com/v1/audio/speech');
|
|
167
|
+
expect(JSON.parse(init.body as string)).toMatchObject({ input: 'hi', response_format: 'mp3' });
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('wordsFromCharacters', () => {
|
|
172
|
+
it('splits on whitespace and skips bracket tags by default', () => {
|
|
173
|
+
const chars = [...'[calm] a b'].map((text, i) => ({ text, start: i, end: i + 1 }));
|
|
174
|
+
expect(wordsFromCharacters(chars).map((w) => [w.text, w.start])).toEqual([['a', 7], ['b', 9]]);
|
|
175
|
+
expect(wordsFromCharacters(chars, { keepTags: true }).map((w) => w.text)).toEqual(['[calm]', 'a', 'b']);
|
|
176
|
+
});
|
|
177
|
+
it('leaves an unclosed bracket as ordinary text', () => {
|
|
178
|
+
const chars = [...'a [b'].map((text, i) => ({ text, start: i, end: i + 1 }));
|
|
179
|
+
expect(wordsFromCharacters(chars).map((w) => w.text)).toEqual(['a', '[b']);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('real v3 alignment fixture', () => {
|
|
184
|
+
it('drops the [excited] tag from the words but keeps the true offsets', () => {
|
|
185
|
+
const fx = JSON.parse(readFileSync(join(__dirname, 'fixtures', 'eleven-v3-tagged-alignment.json'), 'utf8'));
|
|
186
|
+
const chars = fx.alignment.characters.map((text: string, i: number) => ({
|
|
187
|
+
text, start: fx.alignment.character_start_times_seconds[i], end: fx.alignment.character_end_times_seconds[i] }));
|
|
188
|
+
const words = wordsFromCharacters(chars);
|
|
189
|
+
expect(words[0].text).toBe('Most');
|
|
190
|
+
expect(words[0].start).toBeCloseTo(0.202, 2); // the tag took the first ~0.2s
|
|
191
|
+
expect(wordsFromCharacters(chars, { keepTags: true })[0].text).toBe('[excited]');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('mock synthesize', () => {
|
|
196
|
+
it('is deterministic with aligned words and usage', async () => {
|
|
197
|
+
const out = await new MockSpeechProvider().synthesize(req);
|
|
198
|
+
expect(out.audio.byteLength).toBeGreaterThan(0);
|
|
199
|
+
expect(out.words?.map((w) => w.text)).toEqual(['hi', 'you']);
|
|
200
|
+
expect(out.usage.characters).toBe(6);
|
|
201
|
+
expect(out.provider).toBe('mock');
|
|
202
|
+
});
|
|
203
|
+
it('refuses empty text and simulates auth and outage failures', async () => {
|
|
204
|
+
const mock = new MockSpeechProvider();
|
|
205
|
+
await expect(mock.synthesize({ text: '' })).rejects.toMatchObject({ code: 'INVALID_REQUEST' });
|
|
206
|
+
setMockSpeechBehavior({ authError: true });
|
|
207
|
+
await expect(mock.synthesize(req)).rejects.toMatchObject({ code: 'AUTHENTICATION_ERROR', retryable: false });
|
|
208
|
+
setMockSpeechBehavior({ failTimes: 1 });
|
|
209
|
+
await expect(mock.synthesize(req)).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', retryable: true });
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('SpeechService.synthesize', () => {
|
|
214
|
+
const fake = (name: string, impl: () => Promise<any>, synth = true): ISpeechProvider => ({
|
|
215
|
+
name, capabilities: { transcribe: true, synthesize: synth }, defaultModel: 'm', isAvailable: () => true,
|
|
216
|
+
transcribe: vi.fn() as never, synthesize: vi.fn(impl) as never,
|
|
217
|
+
});
|
|
218
|
+
const withProviders = (service: SpeechService, ...ps: ISpeechProvider[]) => {
|
|
219
|
+
(service as any).providers = new Map(ps.map((p) => [p.name, p]));
|
|
220
|
+
return service;
|
|
221
|
+
};
|
|
222
|
+
const ok = (provider: string) => async () => ({ audio: AUDIO, mimeType: 'audio/mpeg', model: 'm', provider, usage: { characters: 6 } });
|
|
223
|
+
const outage = async () => { throw new LLMProviderError('down', 'SERVICE_UNAVAILABLE', true, 503); };
|
|
224
|
+
|
|
225
|
+
it('only considers providers that can synthesize', async () => {
|
|
226
|
+
const s = withProviders(new SpeechService(), fake('elevenlabs', ok('elevenlabs'), false), fake('openai', ok('openai')));
|
|
227
|
+
expect((await s.synthesize(req)).provider).toBe('openai');
|
|
228
|
+
expect(s.getAvailableProviders('synthesize')).toEqual(['openai']);
|
|
229
|
+
});
|
|
230
|
+
it('picks the cheapest vendor that states a price, then falls back to the dearer one', async () => {
|
|
231
|
+
const priced = (name: string, price: number, impl: () => Promise<any>) => ({ ...fake(name, impl), costPer1kCharsUsd: () => price });
|
|
232
|
+
const s = withProviders(new SpeechService({ retryDelayMs: 1, maxRetries: 0 }),
|
|
233
|
+
priced('elevenlabs', 0.08, ok('elevenlabs')), priced('openai', 0.015, outage));
|
|
234
|
+
expect((await s.synthesize(req)).provider).toBe('elevenlabs'); // openai (cheapest) failed, fell through
|
|
235
|
+
const cheap = withProviders(new SpeechService(), priced('elevenlabs', 0.08, ok('elevenlabs')), priced('openai', 0.015, ok('openai')));
|
|
236
|
+
expect((await cheap.synthesize(req)).provider).toBe('openai');
|
|
237
|
+
const pinned = withProviders(new SpeechService({ preferredProvider: 'elevenlabs' }), priced('elevenlabs', 0.08, ok('elevenlabs')), priced('openai', 0.015, ok('openai')));
|
|
238
|
+
expect((await pinned.synthesize(req)).provider).toBe('elevenlabs');
|
|
239
|
+
});
|
|
240
|
+
it('a provider without a key is unavailable, not an error', () => {
|
|
241
|
+
const none = new OpenAISpeechProvider({ apiKey: '' });
|
|
242
|
+
expect(none.isAvailable()).toBe(false);
|
|
243
|
+
expect(withProviders(new SpeechService(), none).getAvailableProviders('synthesize')).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
it('retries a retryable failure, then falls through', async () => {
|
|
246
|
+
const primary = fake('elevenlabs', outage);
|
|
247
|
+
const s = withProviders(new SpeechService({ retryDelayMs: 1, maxRetries: 2 }), primary, fake('openai', ok('openai')));
|
|
248
|
+
expect((await s.synthesize(req)).provider).toBe('openai');
|
|
249
|
+
expect(primary.synthesize).toHaveBeenCalledTimes(3);
|
|
250
|
+
});
|
|
251
|
+
it('never retries an auth error', async () => {
|
|
252
|
+
const bad = fake('elevenlabs', async () => { throw new LLMProviderError('no', 'AUTHENTICATION_ERROR', false, 401); });
|
|
253
|
+
const s = withProviders(new SpeechService({ retryDelayMs: 1 }), bad, fake('openai', ok('openai')));
|
|
254
|
+
await s.synthesize(req);
|
|
255
|
+
expect(bad.synthesize).toHaveBeenCalledTimes(1);
|
|
256
|
+
});
|
|
257
|
+
it('does not fall through on the caller\'s bad input', async () => {
|
|
258
|
+
const bad = fake('elevenlabs', async () => { throw new LLMProviderError('bad', 'INVALID_REQUEST', false, 400); });
|
|
259
|
+
const next = fake('openai', ok('openai'));
|
|
260
|
+
await expect(withProviders(new SpeechService(), bad, next).synthesize(req)).rejects.toMatchObject({ code: 'INVALID_REQUEST' });
|
|
261
|
+
expect(next.synthesize).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
it('says what to set when nothing can synthesize', async () => {
|
|
264
|
+
await expect(withProviders(new SpeechService()).synthesize(req)).rejects.toThrow(/ELEVENLABS_API_KEY/);
|
|
265
|
+
});
|
|
266
|
+
it('SPEECH_PROVIDER=mock synthesizes, and survives a cleared outage', async () => {
|
|
267
|
+
vi.stubEnv('SPEECH_PROVIDER', 'mock');
|
|
268
|
+
setMockSpeechBehavior({ failTimes: 2 });
|
|
269
|
+
const out = await new SpeechService({ retryDelayMs: 1 }).synthesize(req);
|
|
270
|
+
expect(out.provider).toBe('mock');
|
|
271
|
+
});
|
|
272
|
+
it('transcribe is untouched by synthesize-only providers', async () => {
|
|
273
|
+
const synthOnly = { ...fake('elevenlabs', ok('elevenlabs')), capabilities: { transcribe: false, synthesize: true } } as ISpeechProvider;
|
|
274
|
+
const s = withProviders(new SpeechService(), synthOnly);
|
|
275
|
+
await expect(s.transcribe({ audio: AUDIO, mimeType: 'audio/mp4' })).rejects.toThrow(/No speech providers/);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { defaultCastLibrary } from '../speech';
|
|
6
|
+
import { runCli } from '../speech/cli';
|
|
7
|
+
|
|
8
|
+
/** Produced by the Python resolver (blender-spike anim/voice/export_golden.py): the CLI must agree with it. */
|
|
9
|
+
const golden: Array<{ name: string; request: any; expected: any }> = JSON.parse(
|
|
10
|
+
readFileSync(join(__dirname, 'fixtures', 'voices-golden.json'), 'utf8'),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const run = (argv: string[], stdin: string) => runCli(argv, { readStdin: async () => stdin });
|
|
14
|
+
|
|
15
|
+
describe('startsimpli-speech voices resolve', () => {
|
|
16
|
+
it('has golden cases from the Python resolver', () => expect(golden.length).toBeGreaterThanOrEqual(15));
|
|
17
|
+
|
|
18
|
+
it.each(golden.map((g) => [g.name, g] as const))('golden: %s', async (_name, g) => {
|
|
19
|
+
const { libraryPatch, ...request } = g.request;
|
|
20
|
+
const body = { ...request, ...(libraryPatch ? { library: { ...defaultCastLibrary, ...libraryPatch } } : {}) };
|
|
21
|
+
const res = await run(['voices', 'resolve'], JSON.stringify(body));
|
|
22
|
+
if (g.expected.error) {
|
|
23
|
+
expect(res.exitCode).toBe(1);
|
|
24
|
+
// Python knows two kinds; TypeScript names more, all of them "not_resolvable" to Python.
|
|
25
|
+
const error = (res.output as any).error;
|
|
26
|
+
expect(error.message).toBe(g.expected.error.message);
|
|
27
|
+
if (g.expected.error.kind === 'unfilled') expect(error.kind).toBe('unfilled');
|
|
28
|
+
expect((res.output as any).warnings).toEqual(g.expected.warnings);
|
|
29
|
+
} else {
|
|
30
|
+
expect(res.exitCode).toBe(0);
|
|
31
|
+
expect(res.output).toEqual({ voices: g.expected.voices, warnings: g.expected.warnings });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('flags override the request, and --in reads a file', async () => {
|
|
36
|
+
const res = await run(['voices', 'resolve', '--model', 'eleven_multilingual_v2', '--expiring', 'allow', '--today', '2027-01-01'], '{"cast":[{"id":"a","voice":"presenter"}]}');
|
|
37
|
+
expect((res.output as any).voices.a.model).toBe('eleven_multilingual_v2');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('usage errors exit 2', async () => {
|
|
41
|
+
expect((await run(['voices'], '{}')).exitCode).toBe(2);
|
|
42
|
+
expect((await run(['voices', 'resolve'], '{}')).exitCode).toBe(2);
|
|
43
|
+
expect((await run(['voices', 'resolve', '--expiring', 'sometimes'], '{"cast":[]}')).exitCode).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DuplicateVoiceError, ExpiringVoiceError, RetiredVoiceError, UnfilledVoiceSlotError, UnknownLanguageError,
|
|
5
|
+
UnknownModelError, VoiceNotResolvableError, createCastVoices, defaultCastLibrary, directEmotion, stripTags,
|
|
6
|
+
validateCastLibrary,
|
|
7
|
+
} from '../speech';
|
|
8
|
+
|
|
9
|
+
const lib = () => structuredClone(defaultCastLibrary) as typeof defaultCastLibrary;
|
|
10
|
+
const cast = (o: Record<string, unknown> = {}) => createCastVoices(lib(), { expiring: 'allow', ...o } as any);
|
|
11
|
+
const ES = 'A'.repeat(20);
|
|
12
|
+
const EXPIRING = 'cgSgspJ2msm6clMCkdW9';
|
|
13
|
+
|
|
14
|
+
describe('voice cast library: resolution', () => {
|
|
15
|
+
it('a library id resolves to that entry; a real-looking voice id passes through; an unknown alias is ignored', () => {
|
|
16
|
+
const cv = cast();
|
|
17
|
+
expect(cv.resolve({ id: 'x', voice: 'manager' }).voiceId).toBe(lib().roles.manager.voice_id);
|
|
18
|
+
const passthrough = cv.resolve({ id: 'x', voice: 'AbCdEfGhIjKlMnOpQrSt' });
|
|
19
|
+
expect(passthrough).toMatchObject({ voiceId: 'AbCdEfGhIjKlMnOpQrSt', role: null, settings: {} });
|
|
20
|
+
expect(() => cv.resolve({ id: 'maya', voice: 'maya' })).toThrow(VoiceNotResolvableError);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('members without a voice take default_pool in order, counting only voiceless members', () => {
|
|
24
|
+
const got = cast().resolveCast([{ id: 'a', voice: 'clone_1' }, { id: 'maya', voice: 'maya' }, { id: 'b' }]);
|
|
25
|
+
expect(got.a.role).toBe('clone_1');
|
|
26
|
+
expect(got.maya.role).toBe('presenter');
|
|
27
|
+
expect(got.b.role).toBe('engineer');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('unfilled slot, retired id, unknown model and a shared voice each throw a named error', () => {
|
|
31
|
+
const cv = cast();
|
|
32
|
+
expect(() => cv.resolve({ id: 's', voice: 'open_slot_1' })).toThrow(UnfilledVoiceSlotError);
|
|
33
|
+
expect(() => cv.resolve({ id: 's', voice: 'open_slot_1' })).toThrow(/open_slot_1.*bspike-448/);
|
|
34
|
+
expect(() => cv.resolve({ id: 'x', voice: '21m00Tcm4TlvDq8ikWAM' })).toThrow(RetiredVoiceError);
|
|
35
|
+
expect(() => cv.resolve({ id: 'x', voice: 'presenter' }, { model: 'nope' })).toThrow(UnknownModelError);
|
|
36
|
+
expect(() => cv.resolveCast([{ id: 'a', voice: 'presenter' }, { id: 'b', voice: 'presenter' }])).toThrow(DuplicateVoiceError);
|
|
37
|
+
expect(() => cv.resolve({ id: 'z', voice: 'zz' }, {})).toThrow(/no voice reference/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('a per-language entry selects model and voice ids; no language is the unchanged path', () => {
|
|
41
|
+
const data = lib() as any;
|
|
42
|
+
data.languages.es = { model: 'eleven_multilingual_v2', rhubarb_recognizer: 'phonetic', roles: { presenter: { voice_id: ES } } };
|
|
43
|
+
const cv = createCastVoices(data, { expiring: 'allow' });
|
|
44
|
+
const base = cv.resolve({ id: 'm', voice: 'presenter' });
|
|
45
|
+
expect(cv.resolve({ id: 'm', voice: 'presenter' }, { language: 'en-GB' })).toEqual(base);
|
|
46
|
+
expect(base.language).toBeNull();
|
|
47
|
+
const es = cv.resolve({ id: 'm', voice: 'presenter' }, { language: 'es-MX' });
|
|
48
|
+
expect(es).toMatchObject({ voiceId: ES, model: 'eleven_multilingual_v2', language: 'es' });
|
|
49
|
+
expect(cv.resolve({ id: 'm', voice: 'engineer' }, { language: 'es' }).voiceId).toBe(lib().roles.engineer.voice_id);
|
|
50
|
+
expect(cv.resolve({ id: 'm', voice: 'presenter' }, { language: 'es', model: 'eleven_v3' }).model).toBe('eleven_v3');
|
|
51
|
+
expect(() => cv.resolve({ id: 'm', voice: 'presenter' }, { language: 'xx' })).toThrow(UnknownLanguageError);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('voice cast library: expiring Default voices (clock injected)', () => {
|
|
56
|
+
const member = { id: 'x', voice: EXPIRING };
|
|
57
|
+
it('warns before 2026-12-31 (once per voice) and errors from the deadline', () => {
|
|
58
|
+
const onWarn = vi.fn();
|
|
59
|
+
const cv = createCastVoices(lib(), { today: '2026-11-02', onWarn });
|
|
60
|
+
cv.resolve(member); cv.resolve(member);
|
|
61
|
+
expect(onWarn).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(onWarn.mock.calls[0][0]).toMatch(/Jessica.*expires 2026-12-31.*59 days left/);
|
|
63
|
+
expect(cv.warnings).toHaveLength(1);
|
|
64
|
+
expect(() => createCastVoices(lib(), { today: '2026-12-31' }).resolve(member)).toThrow(ExpiringVoiceError);
|
|
65
|
+
});
|
|
66
|
+
it('allow never complains; error always does; anything else is rejected', () => {
|
|
67
|
+
const onWarn = vi.fn();
|
|
68
|
+
createCastVoices(lib(), { today: '2027-06-01', expiring: 'allow', onWarn }).resolve(member);
|
|
69
|
+
expect(onWarn).not.toHaveBeenCalled();
|
|
70
|
+
expect(() => createCastVoices(lib(), { today: '2026-01-01', expiring: 'error' }).resolve(member)).toThrow(ExpiringVoiceError);
|
|
71
|
+
expect(() => createCastVoices(lib(), { expiring: 'sometimes' as any })).toThrow(/expiring must be/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('voice cast library: emotion and tags', () => {
|
|
76
|
+
const spec = (model: string, role = 'presenter') => cast().resolve({ id: 'm', voice: role }, { model });
|
|
77
|
+
it('tag models get a prepended tag and untouched settings; the tag is not spoken text', () => {
|
|
78
|
+
const s = spec('eleven_v3');
|
|
79
|
+
const d = directEmotion(cast(), s, 'Hello there.', 'excited');
|
|
80
|
+
expect(d).toMatchObject({ textSent: '[excited] Hello there.', spokenText: 'Hello there.', settings: s.settings });
|
|
81
|
+
});
|
|
82
|
+
it('non-tag models get a clamped settings delta and no tag', () => {
|
|
83
|
+
const s = spec('eleven_multilingual_v2');
|
|
84
|
+
const d = directEmotion(cast(), s, 'Hello there.', 'excited');
|
|
85
|
+
expect(d.textSent).toBe('Hello there.');
|
|
86
|
+
expect(d.settings).toEqual({ stability: 0.25, similarity_boost: 0.75, style: 0.7, speed: 1.04 });
|
|
87
|
+
});
|
|
88
|
+
it('neutral, null and unknown emotions change nothing; a role can opt out; mode off/cli', () => {
|
|
89
|
+
const s = spec('eleven_v3');
|
|
90
|
+
for (const e of ['neutral', null, 'grumpy']) expect(directEmotion(cast(), s, 'Hi.', e)).toMatchObject({ textSent: 'Hi.', settings: s.settings });
|
|
91
|
+
expect(directEmotion(cast(), spec('eleven_v3', 'ai_assistant'), 'Hi.', 'excited').textSent).toBe('Hi.');
|
|
92
|
+
expect(directEmotion(cast(), s, 'Hi.', 'excited', 'off')).toMatchObject({ textSent: 'Hi.', cliEmotion: null });
|
|
93
|
+
expect(directEmotion(cast(), s, 'Hi.', 'excited', 'cli')).toMatchObject({ textSent: 'Hi.', cliEmotion: 'excited' });
|
|
94
|
+
expect(() => directEmotion(cast(), s, 'Hi.', 'excited', 'bogus' as any)).toThrow(/emotion mode/);
|
|
95
|
+
});
|
|
96
|
+
it('already-tagged text is not double tagged; stripTags collapses whitespace', () => {
|
|
97
|
+
const d = directEmotion(cast(), spec('eleven_v3'), '[laughs] Hello.', 'excited');
|
|
98
|
+
expect(d).toMatchObject({ textSent: '[laughs] Hello.', spokenText: 'Hello.' });
|
|
99
|
+
expect(stripTags('[a] hi [b] you ')).toBe('hi you');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('voice cast library: schema', () => {
|
|
104
|
+
it('the shipped library validates, holds no secret, and rejects a broken one', () => {
|
|
105
|
+
expect(() => validateCastLibrary(defaultCastLibrary)).not.toThrow();
|
|
106
|
+
expect(JSON.stringify(defaultCastLibrary)).not.toMatch(/sk_[0-9a-f]{20,}|xi-api-key/i);
|
|
107
|
+
expect(() => validateCastLibrary({ ...defaultCastLibrary, roles: undefined })).toThrow();
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -75,3 +75,6 @@ export type {
|
|
|
75
75
|
export { registerLLMProvider, llmProviders } from './service';
|
|
76
76
|
export { ProviderRegistry, pickProvider } from './providers/registry';
|
|
77
77
|
export type { Selectable, ProviderFactory } from './providers/registry';
|
|
78
|
+
|
|
79
|
+
// Refine-with-gates loop (also at `@startsimpli/llm/refine`)
|
|
80
|
+
export * from './refine';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RefineAttempt } from './types';
|
|
2
|
+
|
|
3
|
+
export class InvalidTriesError extends RangeError {
|
|
4
|
+
constructor(tries: unknown) {
|
|
5
|
+
super(`tries must be an integer >= 1, or null to loop until the gates pass; got ${String(tries)}`);
|
|
6
|
+
this.name = 'InvalidTriesError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class UnknownGateError extends Error {
|
|
11
|
+
constructor(readonly missing: string[], registered: string[]) {
|
|
12
|
+
super(`unknown gate(s) ${JSON.stringify(missing)}; registered: ${JSON.stringify([...registered].sort())}`);
|
|
13
|
+
this.name = 'UnknownGateError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The step threw. Not retried: `attempts` holds the tries completed before it, `n` the try that failed. */
|
|
18
|
+
export class RefineStepError<T = unknown> extends Error {
|
|
19
|
+
constructor(readonly n: number, readonly attempts: RefineAttempt<T>[], readonly cause: unknown) {
|
|
20
|
+
super(`refine step failed on try ${n}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
21
|
+
this.name = 'RefineStepError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { UnknownGateError } from './errors';
|
|
2
|
+
import type { RefineGate, GateFailure, GateNote } from './types';
|
|
3
|
+
|
|
4
|
+
export interface RegisterOptions {
|
|
5
|
+
/** Default true: a second registration of a name replaces the first. false throws instead. */
|
|
6
|
+
replace?: boolean;
|
|
7
|
+
/** Registered, but run only when named (`only: [...]`). */
|
|
8
|
+
optIn?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Gates by name. A module adds one by registering it; the loop runs them in registration order. */
|
|
12
|
+
export interface GateRegistry<T, C = unknown> {
|
|
13
|
+
register(name: string, gate: RefineGate<T, C>, options?: RegisterOptions): void;
|
|
14
|
+
names(): string[];
|
|
15
|
+
/** Registered gates that run when none are named (opt-in ones left out). */
|
|
16
|
+
defaultNames(): string[];
|
|
17
|
+
/** Throws `UnknownGateError` for a name that is not registered; returns the names to run. */
|
|
18
|
+
select(only?: string[]): string[];
|
|
19
|
+
/** The gates that failed, plus the informational lines of all of them. */
|
|
20
|
+
run(result: T, context: C, only?: string[]): Promise<{ report: GateFailure[]; notes: GateNote[] }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createGateRegistry<T, C = unknown>(): GateRegistry<T, C> {
|
|
24
|
+
const gates = new Map<string, RefineGate<T, C>>();
|
|
25
|
+
const optIn = new Set<string>();
|
|
26
|
+
const registry: GateRegistry<T, C> = {
|
|
27
|
+
register(name, gate, options = {}) {
|
|
28
|
+
if (gates.has(name) && options.replace === false) throw new Error(`gate ${JSON.stringify(name)} is already registered`);
|
|
29
|
+
gates.set(name, gate);
|
|
30
|
+
if (options.optIn) optIn.add(name); else optIn.delete(name);
|
|
31
|
+
},
|
|
32
|
+
names: () => [...gates.keys()],
|
|
33
|
+
defaultNames: () => [...gates.keys()].filter((n) => !optIn.has(n)),
|
|
34
|
+
select(only) {
|
|
35
|
+
if (!only) return registry.defaultNames();
|
|
36
|
+
const missing = only.filter((n) => !gates.has(n));
|
|
37
|
+
if (missing.length) throw new UnknownGateError(missing, registry.names());
|
|
38
|
+
return [...only];
|
|
39
|
+
},
|
|
40
|
+
async run(result, context, only) {
|
|
41
|
+
const report: GateFailure[] = [];
|
|
42
|
+
const notes: GateNote[] = [];
|
|
43
|
+
for (const name of registry.select(only)) {
|
|
44
|
+
const failures: string[] = [];
|
|
45
|
+
for (const finding of await gates.get(name)!(result, context)) {
|
|
46
|
+
const { text, level } = typeof finding === 'string' ? { text: finding, level: 'defect' as const } : { text: finding.text, level: finding.level ?? 'defect' };
|
|
47
|
+
if (level === 'info') notes.push({ gate: name, text: String(text) }); else failures.push(String(text));
|
|
48
|
+
}
|
|
49
|
+
if (failures.length) report.push({ gate: name, failures });
|
|
50
|
+
}
|
|
51
|
+
return { report, notes };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
return registry;
|
|
55
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Refine-with-gates: a result is checked by named gates and the failures go back to a model step until they pass. */
|
|
2
|
+
export { refineWithGates } from './refine';
|
|
3
|
+
export type { RefineOptions, RefineOutcome } from './refine';
|
|
4
|
+
export { createGateRegistry } from './gates';
|
|
5
|
+
export type { GateRegistry, RegisterOptions } from './gates';
|
|
6
|
+
export { llmRefineStep } from './llm-step';
|
|
7
|
+
export type { LLMRefineStepOptions } from './llm-step';
|
|
8
|
+
export { InvalidTriesError, RefineStepError, UnknownGateError } from './errors';
|
|
9
|
+
export type {
|
|
10
|
+
RefineAttempt, RefineCost, RefineGate, GateFailure, GateFinding, GateNote, RefineStep, StepInput, StepOutput,
|
|
11
|
+
} from './types';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ILLMProvider } from '../providers/interface';
|
|
2
|
+
import type { GenerateOptions } from '../types';
|
|
3
|
+
import { extractJson } from '../utils/json-extraction';
|
|
4
|
+
import type { ExtractValidation } from '../utils/extract-validate-correct';
|
|
5
|
+
import type { RefineStep } from './types';
|
|
6
|
+
|
|
7
|
+
export interface LLMRefineStepOptions<T> {
|
|
8
|
+
/** Any `ILLMProvider` (or a fake with `generate`): the injectable client. */
|
|
9
|
+
provider: Pick<ILLMProvider, 'generate'>;
|
|
10
|
+
systemPrompt: string;
|
|
11
|
+
/** The brief, sent on every try. */
|
|
12
|
+
brief: string;
|
|
13
|
+
/** Validates the parsed JSON; its errors become the step's rejections and go back to the model. */
|
|
14
|
+
validate: (json: unknown) => ExtractValidation<T>;
|
|
15
|
+
options?: GenerateOptions;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A `RefineStep` over one provider call: the user message is JSON `{brief, previous_result?, gate_failures?}`, so
|
|
20
|
+
* the model sees its previous result and what the gates said about it. An unreadable or invalid reply is returned as
|
|
21
|
+
* `errors` (a failure the loop feeds back); a provider error throws and ends the loop.
|
|
22
|
+
*/
|
|
23
|
+
export function llmRefineStep<T>(config: LLMRefineStepOptions<T>): RefineStep<T> {
|
|
24
|
+
return async ({ previous, failures }) => {
|
|
25
|
+
const payload: Record<string, unknown> = { brief: config.brief };
|
|
26
|
+
if (previous !== null) payload.previous_result = previous;
|
|
27
|
+
if (failures.length) payload.gate_failures = failures;
|
|
28
|
+
const response = await config.provider.generate(JSON.stringify(payload), config.systemPrompt, config.options);
|
|
29
|
+
const meta = { model: response.model, usage: response.usage };
|
|
30
|
+
const extracted = extractJson(response.content);
|
|
31
|
+
if (!extracted.success) return { result: null, errors: [extracted.error ?? 'No valid JSON object found in response'], ...meta };
|
|
32
|
+
const checked = config.validate(extracted.json);
|
|
33
|
+
return checked.valid ? { result: checked.data, ...meta } : { result: null, errors: checked.errors, ...meta };
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { addUsage, newUsageTally, type UsageTally } from '../utils/usage-tally';
|
|
2
|
+
import { InvalidTriesError, RefineStepError } from './errors';
|
|
3
|
+
import type { GateRegistry } from './gates';
|
|
4
|
+
import type { RefineAttempt, RefineCost, GateFailure, RefineStep } from './types';
|
|
5
|
+
|
|
6
|
+
export interface RefineOptions<T, C = unknown> {
|
|
7
|
+
/** One model call per try (see `llmRefineStep`). Hand it the client you want; tests hand it a fake. */
|
|
8
|
+
step: RefineStep<T>;
|
|
9
|
+
gates: GateRegistry<T, C>;
|
|
10
|
+
/** An integer >= 1 is the number of step calls at most. `null` loops until every gate passes: nothing else caps it. */
|
|
11
|
+
tries: number | null;
|
|
12
|
+
/** Passed to every gate. */
|
|
13
|
+
context?: C;
|
|
14
|
+
/** Run only these gates (default: every registered gate except opt-in ones). Unknown names throw before any call. */
|
|
15
|
+
only?: string[];
|
|
16
|
+
/** RefineGate name under which the step's own rejections (`errors`, or no result) are reported. Default `step`. */
|
|
17
|
+
rejectedGate?: string;
|
|
18
|
+
/** Clock for `seconds`, injectable. Default `Date.now`. */
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RefineOutcome<T> {
|
|
23
|
+
/** The last result (the passing one when `passed`). */
|
|
24
|
+
result: T | null;
|
|
25
|
+
passed: boolean;
|
|
26
|
+
attempts: RefineAttempt<T>[];
|
|
27
|
+
/** Summed over every attempt. */
|
|
28
|
+
cost: RefineCost;
|
|
29
|
+
/** Per-model token usage over every attempt (the shared `UsageTally`). */
|
|
30
|
+
tally: UsageTally;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const zeroCost = (): RefineCost => ({ usd: 0, promptTokens: 0, completionTokens: 0, cachedTokens: 0 });
|
|
34
|
+
const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Prompt refinement: brief -> result -> gates -> the failures go back to the step -> a revised result.
|
|
38
|
+
* Each try is exactly one step call; the step gets the previous result and the gates' failures as data, so the
|
|
39
|
+
* model revises its own work instead of starting over. A step that throws ends the loop with `RefineStepError`.
|
|
40
|
+
* Every attempt's result, report and cost is returned.
|
|
41
|
+
*/
|
|
42
|
+
export async function refineWithGates<T, C = unknown>(options: RefineOptions<T, C>): Promise<RefineOutcome<T>> {
|
|
43
|
+
const { step, gates, tries, context = undefined as C, only, rejectedGate = 'step', now = Date.now } = options;
|
|
44
|
+
if (tries !== null && !(typeof tries === 'number' && Number.isInteger(tries) && tries >= 1)) throw new InvalidTriesError(tries);
|
|
45
|
+
const names = gates.select(only);
|
|
46
|
+
|
|
47
|
+
const attempts: RefineAttempt<T>[] = [];
|
|
48
|
+
const tally = newUsageTally();
|
|
49
|
+
const total = zeroCost();
|
|
50
|
+
let previous: T | null = null;
|
|
51
|
+
let failures: GateFailure[] = [];
|
|
52
|
+
|
|
53
|
+
for (let n = 1; tries === null || n <= tries; n += 1) {
|
|
54
|
+
const t0 = now();
|
|
55
|
+
let out;
|
|
56
|
+
try {
|
|
57
|
+
out = await step({ n, previous, failures });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new RefineStepError(n, attempts, error);
|
|
60
|
+
}
|
|
61
|
+
const errors = out.errors ?? [];
|
|
62
|
+
let report: GateFailure[];
|
|
63
|
+
let notes: RefineAttempt<T>['notes'] = [];
|
|
64
|
+
if (errors.length || out.result === null) {
|
|
65
|
+
report = [{ gate: rejectedGate, failures: errors.length ? errors : ['the reply could not be read as a result'] }];
|
|
66
|
+
} else {
|
|
67
|
+
({ report, notes } = await gates.run(out.result, context, names));
|
|
68
|
+
}
|
|
69
|
+
const cost: RefineCost = {
|
|
70
|
+
usd: out.costUsd ?? 0, promptTokens: out.usage?.promptTokens ?? 0, completionTokens: out.usage?.completionTokens ?? 0, cachedTokens: out.usage?.cachedTokens ?? 0,
|
|
71
|
+
};
|
|
72
|
+
if (out.usage) addUsage(tally, { model: out.model, usage: out.usage });
|
|
73
|
+
total.usd = round6(total.usd + cost.usd);
|
|
74
|
+
total.promptTokens += cost.promptTokens; total.completionTokens += cost.completionTokens; total.cachedTokens += cost.cachedTokens;
|
|
75
|
+
attempts.push({ n, result: out.result, report, notes, cost, passed: report.length === 0, seconds: Math.round(((now() - t0) / 1000) * 1000) / 1000, calls: out.calls ?? 1 });
|
|
76
|
+
if (report.length === 0) return { result: out.result, passed: true, attempts, cost: total, tally };
|
|
77
|
+
previous = out.result;
|
|
78
|
+
failures = report;
|
|
79
|
+
}
|
|
80
|
+
return { result: attempts[attempts.length - 1]?.result ?? null, passed: false, attempts, cost: total, tally };
|
|
81
|
+
}
|