@tyvm/knowhow 0.0.109-dev.05fe5a0 → 0.0.109-dev.2b94ba2

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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/agents/tools/list.ts +2 -2
  3. package/src/chat/CliChatService.ts +1 -1
  4. package/src/chat/modules/SystemModule.ts +2 -2
  5. package/src/clients/anthropic.ts +1 -1
  6. package/src/clients/index.ts +25 -6
  7. package/src/clients/openai.ts +8 -5
  8. package/src/clients/types.ts +29 -6
  9. package/src/clients/withRetry.ts +89 -0
  10. package/src/commands/modules.ts +365 -30
  11. package/src/config.ts +1 -1
  12. package/src/fileSync.ts +20 -12
  13. package/src/hashes.ts +35 -13
  14. package/src/services/MediaProcessorService.ts +79 -10
  15. package/src/services/modules/index.ts +24 -19
  16. package/tests/unit/clients/AIClient.test.ts +446 -0
  17. package/tests/unit/clients/withRetry.test.ts +319 -0
  18. package/tests/unit/commands/github-credentials.test.ts +1 -2
  19. package/ts_build/package.json +1 -1
  20. package/ts_build/src/agents/tools/list.js +2 -2
  21. package/ts_build/src/agents/tools/list.js.map +1 -1
  22. package/ts_build/src/chat/CliChatService.js +1 -1
  23. package/ts_build/src/chat/CliChatService.js.map +1 -1
  24. package/ts_build/src/chat/modules/SystemModule.js +2 -2
  25. package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
  26. package/ts_build/src/clients/anthropic.js +1 -1
  27. package/ts_build/src/clients/anthropic.js.map +1 -1
  28. package/ts_build/src/clients/index.js +7 -6
  29. package/ts_build/src/clients/index.js.map +1 -1
  30. package/ts_build/src/clients/openai.js +4 -4
  31. package/ts_build/src/clients/openai.js.map +1 -1
  32. package/ts_build/src/clients/types.d.ts +12 -6
  33. package/ts_build/src/clients/withRetry.d.ts +2 -0
  34. package/ts_build/src/clients/withRetry.js +60 -0
  35. package/ts_build/src/clients/withRetry.js.map +1 -0
  36. package/ts_build/src/commands/modules.js +297 -17
  37. package/ts_build/src/commands/modules.js.map +1 -1
  38. package/ts_build/src/config.js +1 -1
  39. package/ts_build/src/config.js.map +1 -1
  40. package/ts_build/src/fileSync.d.ts +2 -2
  41. package/ts_build/src/fileSync.js +13 -11
  42. package/ts_build/src/fileSync.js.map +1 -1
  43. package/ts_build/src/hashes.d.ts +2 -2
  44. package/ts_build/src/hashes.js +35 -9
  45. package/ts_build/src/hashes.js.map +1 -1
  46. package/ts_build/src/services/MediaProcessorService.d.ts +5 -4
  47. package/ts_build/src/services/MediaProcessorService.js +53 -8
  48. package/ts_build/src/services/MediaProcessorService.js.map +1 -1
  49. package/ts_build/src/services/modules/index.js +17 -13
  50. package/ts_build/src/services/modules/index.js.map +1 -1
  51. package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
  52. package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
  53. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
  54. package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
  55. package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
  56. package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
  57. package/ts_build/tests/unit/commands/github-credentials.test.js +1 -2
  58. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -1
@@ -1,9 +1,10 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { exec } from "child_process";
3
+ import { exec, spawn } from "child_process";
4
4
  import { promisify } from "util";
5
5
  import { fileExists, readFile, mkdir } from "../utils";
6
6
  import { AIClient } from "../clients";
7
+ import { Models } from "../types";
7
8
 
8
9
  const execPromise = promisify(exec);
9
10
 
@@ -36,7 +37,7 @@ export interface KeyframeInfo {
36
37
  * audio/video processing steps after downloading with ytdl.
37
38
  */
38
39
  export class MediaProcessorService {
39
- constructor(private clients: any) {}
40
+ constructor(private clients: AIClient) {}
40
41
 
41
42
  /**
42
43
  * Split an audio/video file into fixed-length mp3 chunks using ffmpeg.
@@ -45,7 +46,8 @@ export class MediaProcessorService {
45
46
  filePath: string,
46
47
  outputDir: string,
47
48
  CHUNK_LENGTH_SECONDS = 30,
48
- reuseExistingChunks = true
49
+ reuseExistingChunks = true,
50
+ onProgress?: (progressFraction: number) => void
49
51
  ): Promise<string[]> {
50
52
  const parsed = path.parse(filePath);
51
53
  const fileName = parsed.name;
@@ -72,8 +74,70 @@ export class MediaProcessorService {
72
74
  }
73
75
  }
74
76
 
75
- const command = `ffmpeg -i "${filePath}" -f segment -segment_time ${CHUNK_LENGTH_SECONDS} -map 0:a:0 -acodec mp3 -vn "${outputDirPath}/chunk%04d.mp3"`;
76
- await execAsync(command);
77
+ // Use faster encoding settings:
78
+ // - mono audio (-ac 1): halves encoding work, Whisper handles mono fine
79
+ // - low bitrate (-b:a 32k): sufficient for speech, much faster encode + smaller files
80
+ // - fast preset not available for mp3 encoder, but limiting bitrate helps
81
+ // - -threads 0: use all available CPU threads for faster processing
82
+ // If the input is already an mp3, copy the audio stream to avoid re-encoding
83
+ const inputExt = path.extname(filePath).toLowerCase().replace('.', '');
84
+ const isAlreadyMp3 = inputExt === 'mp3';
85
+ const audioCodecArgs = isAlreadyMp3
86
+ ? '-acodec copy'
87
+ : '-acodec libmp3lame -ac 1 -b:a 32k -threads 0';
88
+
89
+ // Use -progress pipe:1 to get real-time progress from ffmpeg
90
+ // We need the total duration first to calculate fraction
91
+ await new Promise<void>((resolve, reject) => {
92
+ // Get total duration via ffprobe first
93
+ let totalDurationSeconds = 0;
94
+ exec(
95
+ `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
96
+ (err, stdout) => {
97
+ if (!err && stdout.trim()) {
98
+ totalDurationSeconds = parseFloat(stdout.trim()) || 0;
99
+ }
100
+
101
+ // Now run ffmpeg with progress reporting
102
+ const args = [
103
+ '-i', filePath,
104
+ '-f', 'segment',
105
+ '-segment_time', String(CHUNK_LENGTH_SECONDS),
106
+ '-map', '0:a:0',
107
+ ...audioCodecArgs.split(' '),
108
+ '-vn',
109
+ ...(onProgress ? ['-progress', 'pipe:1'] : []),
110
+ `${outputDirPath}/chunk%04d.mp3`,
111
+ ];
112
+
113
+ const proc = spawn('ffmpeg', args);
114
+
115
+ let stdoutBuf = '';
116
+ proc.stdout?.on('data', (data: Buffer) => {
117
+ stdoutBuf += data.toString();
118
+ if (onProgress && totalDurationSeconds > 0) {
119
+ // ffmpeg -progress outputs key=value lines; look for out_time_ms
120
+ const match = stdoutBuf.match(/out_time_ms=(\d+)/g);
121
+ if (match) {
122
+ const last = match[match.length - 1];
123
+ const ms = parseInt(last.split('=')[1], 10);
124
+ const fraction = Math.min(ms / 1000 / totalDurationSeconds, 1);
125
+ onProgress(fraction);
126
+ // Keep only tail to avoid unbounded buffer growth
127
+ stdoutBuf = stdoutBuf.slice(-500);
128
+ }
129
+ }
130
+ });
131
+
132
+ proc.on('close', (code) => {
133
+ if (code === 0) resolve();
134
+ else reject(new Error(`ffmpeg exited with code ${code}`));
135
+ });
136
+ proc.on('error', reject);
137
+ }
138
+ );
139
+ });
140
+
77
141
  await fs.promises.writeFile(doneFilePath, "done");
78
142
 
79
143
  const folderFiles = await fs.promises.readdir(outputDirPath);
@@ -298,8 +362,9 @@ export class MediaProcessorService {
298
362
  });
299
363
  const image = `data:image/jpeg;base64,${base64}`;
300
364
  return this.clients.createCompletion("openai", {
301
- model: "gpt-4o",
365
+ model: Models.openai.GPT_4o,
302
366
  max_tokens: 2500,
367
+ timeout: 20000,
303
368
  messages: [
304
369
  {
305
370
  role: "user",
@@ -315,7 +380,8 @@ export class MediaProcessorService {
315
380
  async *streamProcessVideo(
316
381
  filePath: string,
317
382
  reusePreviousTranscript = true,
318
- chunkTime = 30
383
+ chunkTime = 30,
384
+ onChunkingProgress?: (fraction: number) => void
319
385
  ) {
320
386
  const parsed = path.parse(filePath);
321
387
  const videoJson = `${parsed.dir}/${parsed.name}/video.json`;
@@ -324,7 +390,8 @@ export class MediaProcessorService {
324
390
  const transcriptions = this.streamProcessAudio(
325
391
  filePath,
326
392
  reusePreviousTranscript,
327
- chunkTime
393
+ chunkTime,
394
+ onChunkingProgress
328
395
  );
329
396
 
330
397
  console.log("Extracting keyframes...");
@@ -352,7 +419,8 @@ export class MediaProcessorService {
352
419
  async *streamProcessAudio(
353
420
  filePath: string,
354
421
  reusePreviousTranscript = true,
355
- chunkTime = 30
422
+ chunkTime = 30,
423
+ onChunkingProgress?: (fraction: number) => void
356
424
  ): AsyncGenerator<TranscriptChunk> {
357
425
  const parsed = path.parse(filePath);
358
426
  const outputPath = `${parsed.dir}/${parsed.name}/transcript.json`;
@@ -382,7 +450,8 @@ export class MediaProcessorService {
382
450
  filePath,
383
451
  parsed.dir,
384
452
  chunkTime,
385
- reusePreviousTranscript
453
+ reusePreviousTranscript,
454
+ onChunkingProgress
386
455
  );
387
456
 
388
457
  for await (const chunk of this.streamTranscription(
@@ -31,14 +31,10 @@ export class ModulesService {
31
31
  // puts packages), then cwd node_modules, then global node_modules.
32
32
  // This allows modules installed via `knowhow modules install` to be found
33
33
  // even when knowhow itself is installed globally.
34
- const cwdPaths = (require as any).resolve
35
- ? require.resolve.paths?.("") || []
36
- : [];
37
34
  const resolvePaths = [
38
35
  path.join(process.cwd(), ".knowhow", "node_modules"),
39
36
  path.join(os.homedir(), ".knowhow", "node_modules"),
40
37
  path.join(process.cwd(), "node_modules"),
41
- ...cwdPaths,
42
38
  ];
43
39
 
44
40
  for (const modulePath of allModulePaths) {
@@ -57,22 +53,31 @@ export class ModulesService {
57
53
  resolvedPath = modulePath; // fall back to normal require resolution
58
54
  }
59
55
  }
60
- const rawModule = require(resolvedPath);
61
- const importedModule = (rawModule.default || rawModule) as KnowhowModule;
62
- context.Events?.log(
63
- "ModulesService",
64
- `🔌 Loading module: ${modulePath} (resolved: ${resolvedPath})`
65
- );
66
- await importedModule.init({
67
- config,
68
- cwd: process.cwd(),
69
- context: context as ModuleContext,
70
- });
71
- context.Events?.log(
72
- "ModulesService",
73
- `✅ Module initialized: ${modulePath} (tools: ${importedModule.tools.length}, agents: ${importedModule.agents.length}, plugins: ${importedModule.plugins.length}, clients: ${importedModule.clients.length})`
74
- );
75
56
 
57
+ let importedModule: KnowhowModule;
58
+ try {
59
+ const rawModule = require(resolvedPath);
60
+ importedModule = (rawModule.default || rawModule) as KnowhowModule;
61
+ context.Events?.log(
62
+ "ModulesService",
63
+ `🔌 Loading module: ${modulePath} (resolved: ${resolvedPath})`
64
+ );
65
+ await importedModule.init({
66
+ config,
67
+ cwd: process.cwd(),
68
+ context: context as ModuleContext,
69
+ });
70
+ context.Events?.log(
71
+ "ModulesService",
72
+ `✅ Module initialized: ${modulePath} (tools: ${importedModule.tools.length}, agents: ${importedModule.agents.length}, plugins: ${importedModule.plugins.length}, clients: ${importedModule.clients.length})`
73
+ );
74
+ } catch (err: any) {
75
+ process.stderr.write(
76
+ `\n⚠️ Failed to load module "${modulePath}": ${err.message}\n` +
77
+ ` Run "knowhow modules setup --global" or "knowhow modules install ${modulePath} --global" to fix this.\n\n`
78
+ );
79
+ continue;
80
+ }
76
81
  // Only register tools/agents/plugins/clients if the relevant services
77
82
  // are available in context (they may not be during early CLI command registration)
78
83
  if (context.Agents) {
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Integration tests for AIClient — verifies that retry, timeout, and
3
+ * AbortSignal options flow correctly through AIClient into the underlying
4
+ * GenericClient mock.
5
+ *
6
+ * We bypass all real provider initialisation by calling:
7
+ * aiClient.registerClient(provider, mockClient)
8
+ * aiClient.registerModels(provider, [model])
9
+ */
10
+
11
+ // Prevent real _initDefaultProviders from firing (it reads env vars / files)
12
+ jest.mock("../../../src/config", () => ({
13
+ getConfig: jest.fn().mockResolvedValue({ modules: [] }),
14
+ getGlobalConfig: jest.fn().mockResolvedValue({ modules: [] }),
15
+ getConfigSync: jest.fn().mockReturnValue({}),
16
+ }));
17
+ jest.mock("../../../src/services/KnowhowClient", () => ({
18
+ loadKnowhowJwt: jest.fn().mockReturnValue(null),
19
+ KNOWHOW_API_URL: "https://mock.local",
20
+ }));
21
+
22
+ import { AIClient } from "../../../src/clients/index";
23
+ import type { GenericClient } from "../../../src/clients/types";
24
+
25
+ // ─── Helpers ────────────────────────────────────────────────────────────────
26
+
27
+ /** Build a minimal mock CompletionResponse */
28
+ const mockCompletion = () => ({
29
+ choices: [{ message: { role: "assistant" as const, content: "hello" } }],
30
+ model: "mock-model",
31
+ usage: { prompt_tokens: 10, completion_tokens: 5 },
32
+ });
33
+
34
+ /** Build a minimal mock ImageGenerationResponse */
35
+ const mockImage = () => ({
36
+ created: Date.now(),
37
+ data: [{ url: "https://mock.local/image.png" }],
38
+ });
39
+
40
+ /** Build a minimal mock EmbeddingResponse */
41
+ const mockEmbedding = () => ({
42
+ data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }],
43
+ model: "mock-embed",
44
+ usage: { prompt_tokens: 5, total_tokens: 5 },
45
+ });
46
+
47
+ /** Build a minimal mock AudioGenerationResponse */
48
+ const mockAudio = () => ({
49
+ audio: Buffer.from("fake-audio"),
50
+ format: "mp3",
51
+ });
52
+
53
+ /**
54
+ * Create an AIClient with a registered mock provider.
55
+ * Returns the AIClient and the mocked GenericClient.
56
+ */
57
+ function setupClient(overrides: Partial<GenericClient> = {}) {
58
+ const mockGenericClient: GenericClient = {
59
+ setKey: jest.fn(),
60
+ createChatCompletion: jest
61
+ .fn()
62
+ .mockResolvedValue(mockCompletion()),
63
+ createEmbedding: jest.fn().mockResolvedValue(mockEmbedding()),
64
+ createImageGeneration: jest.fn().mockResolvedValue(mockImage()),
65
+ createAudioGeneration: jest.fn().mockResolvedValue(mockAudio()),
66
+ createAudioTranscription: jest
67
+ .fn()
68
+ .mockResolvedValue({ text: "transcribed" }),
69
+ createVideoGeneration: jest.fn().mockResolvedValue({
70
+ created: Date.now(),
71
+ data: [{ url: "https://mock.local/video.mp4" }],
72
+ }),
73
+ getModels: jest.fn().mockResolvedValue([]),
74
+ ...overrides,
75
+ };
76
+
77
+ const aiClient = new AIClient();
78
+ // Register our mock bypassing all env/network checks
79
+ aiClient.registerClient("mock", mockGenericClient);
80
+ aiClient.registerModels("mock", ["mock-model", "mock-embed"]);
81
+
82
+ return { aiClient, mockGenericClient };
83
+ }
84
+
85
+ // ─── Tests ──────────────────────────────────────────────────────────────────
86
+
87
+ describe("AIClient — retry / timeout / AbortSignal", () => {
88
+ afterEach(() => {
89
+ jest.useRealTimers();
90
+ });
91
+
92
+ // ── createCompletion ──────────────────────────────────────────────────────
93
+
94
+ describe("createCompletion", () => {
95
+ it("returns a completion on success", async () => {
96
+ const { aiClient } = setupClient();
97
+ const result = await aiClient.createCompletion("mock", {
98
+ model: "mock-model",
99
+ messages: [{ role: "user", content: "hi" }],
100
+ });
101
+ expect(result.choices[0].message.content).toBe("hello");
102
+ });
103
+
104
+ it("forwards the AbortSignal to createChatCompletion", async () => {
105
+ const { aiClient, mockGenericClient } = setupClient();
106
+ const controller = new AbortController();
107
+
108
+ await aiClient.createCompletion("mock", {
109
+ model: "mock-model",
110
+ messages: [],
111
+ signal: controller.signal,
112
+ });
113
+
114
+ const callArgs = (mockGenericClient.createChatCompletion as jest.Mock)
115
+ .mock.calls[0][0];
116
+ expect(callArgs.signal).toBeInstanceOf(AbortSignal);
117
+ });
118
+
119
+ it("retries on 429 and succeeds", async () => {
120
+ jest.useFakeTimers();
121
+ const { aiClient, mockGenericClient } = setupClient({
122
+ createChatCompletion: jest
123
+ .fn()
124
+ .mockRejectedValueOnce(new Error("429 rate limited"))
125
+ .mockResolvedValueOnce(mockCompletion()),
126
+ });
127
+
128
+ const promise = aiClient.createCompletion("mock", {
129
+ model: "mock-model",
130
+ messages: [],
131
+ maxRetries: 2,
132
+ backoffMs: 50,
133
+ });
134
+ await jest.runAllTimersAsync();
135
+ const result = await promise;
136
+
137
+ expect(result.choices[0].message.content).toBe("hello");
138
+ expect(mockGenericClient.createChatCompletion).toHaveBeenCalledTimes(2);
139
+ });
140
+
141
+ it("aborts immediately when external signal is pre-aborted", async () => {
142
+ const controller = new AbortController();
143
+ controller.abort();
144
+
145
+ const { aiClient, mockGenericClient } = setupClient();
146
+ await expect(
147
+ aiClient.createCompletion("mock", {
148
+ model: "mock-model",
149
+ messages: [],
150
+ signal: controller.signal,
151
+ })
152
+ ).rejects.toMatchObject({ name: "AbortError" });
153
+
154
+ expect(mockGenericClient.createChatCompletion).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("cancels in-flight request when external signal is aborted", async () => {
158
+ const controller = new AbortController();
159
+ const { aiClient, mockGenericClient } = setupClient({
160
+ createChatCompletion: jest.fn().mockImplementation((opts: any) => {
161
+ return new Promise((_, reject) => {
162
+ opts.signal?.addEventListener("abort", () =>
163
+ reject(opts.signal.reason)
164
+ );
165
+ });
166
+ }),
167
+ });
168
+
169
+ const promise = aiClient.createCompletion("mock", {
170
+ model: "mock-model",
171
+ messages: [],
172
+ signal: controller.signal,
173
+ });
174
+
175
+ setImmediate(() =>
176
+ controller.abort(new DOMException("User cancelled", "AbortError"))
177
+ );
178
+
179
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
180
+ expect(mockGenericClient.createChatCompletion).toHaveBeenCalledTimes(1);
181
+ });
182
+
183
+ it("times out per-attempt and retries", async () => {
184
+ jest.useFakeTimers();
185
+ const { aiClient, mockGenericClient } = setupClient({
186
+ createChatCompletion: jest
187
+ .fn()
188
+ .mockImplementationOnce((opts: any) => {
189
+ return new Promise((_, reject) => {
190
+ opts.signal?.addEventListener("abort", () =>
191
+ reject(opts.signal.reason)
192
+ );
193
+ });
194
+ })
195
+ .mockResolvedValueOnce(mockCompletion()),
196
+ });
197
+
198
+ const promise = aiClient.createCompletion("mock", {
199
+ model: "mock-model",
200
+ messages: [],
201
+ timeout: 1000,
202
+ maxRetries: 2,
203
+ backoffMs: 10,
204
+ });
205
+ await jest.runAllTimersAsync();
206
+ const result = await promise;
207
+
208
+ expect(result.choices[0].message.content).toBe("hello");
209
+ expect(mockGenericClient.createChatCompletion).toHaveBeenCalledTimes(2);
210
+ });
211
+ });
212
+
213
+ // ── createEmbedding ───────────────────────────────────────────────────────
214
+
215
+ describe("createEmbedding", () => {
216
+ it("forwards the AbortSignal to createEmbedding on the client", async () => {
217
+ const { aiClient, mockGenericClient } = setupClient();
218
+ const controller = new AbortController();
219
+
220
+ await aiClient.createEmbedding("mock", {
221
+ input: "test text",
222
+ model: "mock-embed",
223
+ signal: controller.signal,
224
+ });
225
+
226
+ const callArgs = (mockGenericClient.createEmbedding as jest.Mock).mock
227
+ .calls[0][0];
228
+ expect(callArgs.signal).toBeInstanceOf(AbortSignal);
229
+ });
230
+
231
+ it("retries on 500 and succeeds", async () => {
232
+ jest.useFakeTimers();
233
+ const { aiClient, mockGenericClient } = setupClient({
234
+ createEmbedding: jest
235
+ .fn()
236
+ .mockRejectedValueOnce(new Error("500 Internal Server Error"))
237
+ .mockResolvedValueOnce(mockEmbedding()),
238
+ });
239
+
240
+ const promise = aiClient.createEmbedding("mock", {
241
+ input: "test",
242
+ model: "mock-embed",
243
+ maxRetries: 2,
244
+ backoffMs: 50,
245
+ });
246
+ await jest.runAllTimersAsync();
247
+ const result = await promise;
248
+
249
+ expect(result.data[0].embedding).toEqual([0.1, 0.2]);
250
+ expect(mockGenericClient.createEmbedding).toHaveBeenCalledTimes(2);
251
+ });
252
+ });
253
+
254
+ // ── createImageGeneration ─────────────────────────────────────────────────
255
+
256
+ describe("createImageGeneration", () => {
257
+ it("forwards the AbortSignal to createImageGeneration on the client", async () => {
258
+ const { aiClient, mockGenericClient } = setupClient();
259
+ const controller = new AbortController();
260
+
261
+ await aiClient.createImageGeneration("mock", {
262
+ model: "mock-model",
263
+ prompt: "a cat",
264
+ signal: controller.signal,
265
+ });
266
+
267
+ const callArgs = (mockGenericClient.createImageGeneration as jest.Mock)
268
+ .mock.calls[0][0];
269
+ expect(callArgs.signal).toBeInstanceOf(AbortSignal);
270
+ });
271
+
272
+ it("retries on 429 and succeeds", async () => {
273
+ jest.useFakeTimers();
274
+ const { aiClient, mockGenericClient } = setupClient({
275
+ createImageGeneration: jest
276
+ .fn()
277
+ .mockRejectedValueOnce(new Error("429 Too Many Requests"))
278
+ .mockResolvedValueOnce(mockImage()),
279
+ });
280
+
281
+ const promise = aiClient.createImageGeneration("mock", {
282
+ model: "mock-model",
283
+ prompt: "a cat",
284
+ maxRetries: 2,
285
+ backoffMs: 50,
286
+ });
287
+ await jest.runAllTimersAsync();
288
+ const result = await promise;
289
+
290
+ expect(result.data[0].url).toBe("https://mock.local/image.png");
291
+ expect(mockGenericClient.createImageGeneration).toHaveBeenCalledTimes(2);
292
+ });
293
+
294
+ it("aborts when external signal fires mid-request", async () => {
295
+ const controller = new AbortController();
296
+ const { aiClient, mockGenericClient } = setupClient({
297
+ createImageGeneration: jest.fn().mockImplementation((opts: any) => {
298
+ return new Promise((_, reject) => {
299
+ opts.signal?.addEventListener("abort", () =>
300
+ reject(opts.signal.reason)
301
+ );
302
+ });
303
+ }),
304
+ });
305
+
306
+ const promise = aiClient.createImageGeneration("mock", {
307
+ model: "mock-model",
308
+ prompt: "a cat",
309
+ signal: controller.signal,
310
+ });
311
+ setImmediate(() =>
312
+ controller.abort(new DOMException("User cancelled", "AbortError"))
313
+ );
314
+
315
+ await expect(promise).rejects.toMatchObject({ name: "AbortError" });
316
+ expect(mockGenericClient.createImageGeneration).toHaveBeenCalledTimes(1);
317
+ });
318
+ });
319
+
320
+ // ── createAudioGeneration ─────────────────────────────────────────────────
321
+
322
+ describe("createAudioGeneration", () => {
323
+ it("forwards the AbortSignal to createAudioGeneration on the client", async () => {
324
+ const { aiClient, mockGenericClient } = setupClient();
325
+ const controller = new AbortController();
326
+
327
+ await aiClient.createAudioGeneration("mock", {
328
+ model: "mock-model",
329
+ input: "Hello world",
330
+ voice: "alloy",
331
+ signal: controller.signal,
332
+ });
333
+
334
+ const callArgs = (mockGenericClient.createAudioGeneration as jest.Mock)
335
+ .mock.calls[0][0];
336
+ expect(callArgs.signal).toBeInstanceOf(AbortSignal);
337
+ });
338
+
339
+ it("retries on ECONNRESET and succeeds", async () => {
340
+ jest.useFakeTimers();
341
+ const { aiClient, mockGenericClient } = setupClient({
342
+ createAudioGeneration: jest
343
+ .fn()
344
+ .mockRejectedValueOnce(new Error("ECONNRESET"))
345
+ .mockResolvedValueOnce(mockAudio()),
346
+ });
347
+
348
+ const promise = aiClient.createAudioGeneration("mock", {
349
+ model: "mock-model",
350
+ input: "Hello",
351
+ voice: "alloy",
352
+ maxRetries: 2,
353
+ backoffMs: 50,
354
+ });
355
+ await jest.runAllTimersAsync();
356
+ const result = await promise;
357
+
358
+ expect(result.format).toBe("mp3");
359
+ expect(mockGenericClient.createAudioGeneration).toHaveBeenCalledTimes(2);
360
+ });
361
+ });
362
+
363
+ // ── createVideoGeneration ─────────────────────────────────────────────────
364
+
365
+ describe("createVideoGeneration", () => {
366
+ it("forwards the AbortSignal to createVideoGeneration on the client", async () => {
367
+ const { aiClient, mockGenericClient } = setupClient();
368
+ const controller = new AbortController();
369
+
370
+ await aiClient.createVideoGeneration("mock", {
371
+ model: "mock-model",
372
+ prompt: "a sunset",
373
+ signal: controller.signal,
374
+ });
375
+
376
+ const callArgs = (mockGenericClient.createVideoGeneration as jest.Mock)
377
+ .mock.calls[0][0];
378
+ expect(callArgs.signal).toBeInstanceOf(AbortSignal);
379
+ });
380
+
381
+ it("retries on 503 and succeeds", async () => {
382
+ jest.useFakeTimers();
383
+ const { aiClient, mockGenericClient } = setupClient({
384
+ createVideoGeneration: jest
385
+ .fn()
386
+ .mockRejectedValueOnce(new Error("503 Service Unavailable"))
387
+ .mockResolvedValueOnce({
388
+ created: Date.now(),
389
+ data: [{ url: "https://mock.local/video.mp4" }],
390
+ }),
391
+ });
392
+
393
+ const promise = aiClient.createVideoGeneration("mock", {
394
+ model: "mock-model",
395
+ prompt: "a sunset",
396
+ maxRetries: 2,
397
+ backoffMs: 50,
398
+ });
399
+ await jest.runAllTimersAsync();
400
+ const result = await promise;
401
+
402
+ expect(result.data[0].url).toBe("https://mock.local/video.mp4");
403
+ expect(mockGenericClient.createVideoGeneration).toHaveBeenCalledTimes(2);
404
+ });
405
+ });
406
+
407
+ // ── createAudioTranscription ──────────────────────────────────────────────
408
+
409
+ describe("createAudioTranscription", () => {
410
+ it("forwards the AbortSignal to createAudioTranscription on the client", async () => {
411
+ const { aiClient, mockGenericClient } = setupClient();
412
+ const controller = new AbortController();
413
+
414
+ await aiClient.createAudioTranscription("mock", {
415
+ file: Buffer.from("fake-audio"),
416
+ signal: controller.signal,
417
+ });
418
+
419
+ const callArgs = (
420
+ mockGenericClient.createAudioTranscription as jest.Mock
421
+ ).mock.calls[0][0];
422
+ expect(callArgs.signal).toBeInstanceOf(AbortSignal);
423
+ });
424
+
425
+ it("retries on timeout error and succeeds", async () => {
426
+ jest.useFakeTimers();
427
+ const { aiClient, mockGenericClient } = setupClient({
428
+ createAudioTranscription: jest
429
+ .fn()
430
+ .mockRejectedValueOnce(new Error("timeout"))
431
+ .mockResolvedValueOnce({ text: "hello world" }),
432
+ });
433
+
434
+ const promise = aiClient.createAudioTranscription("mock", {
435
+ file: Buffer.from("fake-audio"),
436
+ maxRetries: 2,
437
+ backoffMs: 50,
438
+ });
439
+ await jest.runAllTimersAsync();
440
+ const result = await promise;
441
+
442
+ expect(result.text).toBe("hello world");
443
+ expect(mockGenericClient.createAudioTranscription).toHaveBeenCalledTimes(2);
444
+ });
445
+ });
446
+ });