@tyvm/knowhow 0.0.108 → 0.0.109-dev.86123ed

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 (236) hide show
  1. package/README.md +45 -0
  2. package/package.json +9 -4
  3. package/scripts/build-for-node.sh +10 -24
  4. package/scripts/publish.sh +86 -0
  5. package/src/agents/base/base.ts +10 -0
  6. package/src/agents/tools/execCommand.ts +49 -6
  7. package/src/agents/tools/index.ts +0 -1
  8. package/src/agents/tools/list.ts +2 -4
  9. package/src/chat/CliChatService.ts +11 -2
  10. package/src/chat/modules/AgentModule.ts +61 -31
  11. package/src/chat/modules/SessionsModule.ts +47 -3
  12. package/src/chat/modules/SystemModule.ts +2 -2
  13. package/src/chat/renderer/CompactRenderer.ts +20 -0
  14. package/src/chat/renderer/ConsoleRenderer.ts +19 -0
  15. package/src/chat/renderer/FancyRenderer.ts +19 -0
  16. package/src/chat/renderer/types.ts +11 -0
  17. package/src/cli.ts +91 -659
  18. package/src/clients/anthropic.ts +18 -17
  19. package/src/clients/index.ts +31 -11
  20. package/src/clients/openai.ts +8 -5
  21. package/src/clients/types.ts +48 -10
  22. package/src/clients/withRetry.ts +89 -0
  23. package/src/cloudWorker.ts +175 -113
  24. package/src/commands/agent.ts +246 -0
  25. package/src/commands/misc.ts +174 -0
  26. package/src/commands/modules.ts +217 -0
  27. package/src/commands/services.ts +77 -0
  28. package/src/commands/workers.ts +168 -0
  29. package/src/config.ts +38 -1
  30. package/src/fileSync.ts +70 -29
  31. package/src/hashes.ts +35 -13
  32. package/src/index.ts +18 -0
  33. package/src/logger.ts +197 -0
  34. package/src/plugins/embedding.ts +11 -6
  35. package/src/plugins/plugins.ts +0 -21
  36. package/src/plugins/vim.ts +5 -16
  37. package/src/processors/JsonCompressor.ts +6 -6
  38. package/src/services/EventService.ts +61 -1
  39. package/src/services/KnowhowClient.ts +34 -4
  40. package/src/services/MediaProcessorService.ts +3 -2
  41. package/src/services/modules/index.ts +95 -51
  42. package/src/services/modules/types.ts +6 -0
  43. package/src/tunnel.ts +216 -0
  44. package/src/types.ts +0 -1
  45. package/src/worker.ts +105 -312
  46. package/src/workers/auth/WsMiddleware.ts +99 -0
  47. package/src/workers/auth/authMiddleware.ts +104 -0
  48. package/src/workers/auth/types.ts +14 -2
  49. package/src/workers/tools/index.ts +2 -0
  50. package/src/workers/tools/reloadConfig.ts +84 -0
  51. package/tests/services/WorkerReloadConfig.test.ts +141 -0
  52. package/tests/unit/clients/AIClient.test.ts +446 -0
  53. package/tests/unit/clients/withRetry.test.ts +319 -0
  54. package/tests/unit/commands/github-credentials.test.ts +210 -0
  55. package/tests/unit/modules/moduleLoading.test.ts +39 -37
  56. package/tests/unit/plugins/pluginLoading.test.ts +0 -85
  57. package/ts_build/package.json +9 -4
  58. package/ts_build/src/agents/base/base.js +11 -0
  59. package/ts_build/src/agents/base/base.js.map +1 -1
  60. package/ts_build/src/agents/tools/execCommand.d.ts +1 -1
  61. package/ts_build/src/agents/tools/execCommand.js +39 -5
  62. package/ts_build/src/agents/tools/execCommand.js.map +1 -1
  63. package/ts_build/src/agents/tools/index.d.ts +0 -1
  64. package/ts_build/src/agents/tools/index.js +0 -1
  65. package/ts_build/src/agents/tools/index.js.map +1 -1
  66. package/ts_build/src/agents/tools/list.js +2 -4
  67. package/ts_build/src/agents/tools/list.js.map +1 -1
  68. package/ts_build/src/chat/CliChatService.js +14 -2
  69. package/ts_build/src/chat/CliChatService.js.map +1 -1
  70. package/ts_build/src/chat/modules/AgentModule.d.ts +1 -1
  71. package/ts_build/src/chat/modules/AgentModule.js +43 -20
  72. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  73. package/ts_build/src/chat/modules/SessionsModule.js +37 -3
  74. package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
  75. package/ts_build/src/chat/modules/SystemModule.js +2 -2
  76. package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
  77. package/ts_build/src/chat/renderer/CompactRenderer.d.ts +4 -0
  78. package/ts_build/src/chat/renderer/CompactRenderer.js +16 -0
  79. package/ts_build/src/chat/renderer/CompactRenderer.js.map +1 -1
  80. package/ts_build/src/chat/renderer/ConsoleRenderer.d.ts +4 -0
  81. package/ts_build/src/chat/renderer/ConsoleRenderer.js +16 -0
  82. package/ts_build/src/chat/renderer/ConsoleRenderer.js.map +1 -1
  83. package/ts_build/src/chat/renderer/FancyRenderer.d.ts +4 -0
  84. package/ts_build/src/chat/renderer/FancyRenderer.js +16 -0
  85. package/ts_build/src/chat/renderer/FancyRenderer.js.map +1 -1
  86. package/ts_build/src/chat/renderer/types.d.ts +2 -0
  87. package/ts_build/src/cli.js +47 -519
  88. package/ts_build/src/cli.js.map +1 -1
  89. package/ts_build/src/clients/anthropic.d.ts +5 -5
  90. package/ts_build/src/clients/anthropic.js +18 -17
  91. package/ts_build/src/clients/anthropic.js.map +1 -1
  92. package/ts_build/src/clients/index.js +9 -10
  93. package/ts_build/src/clients/index.js.map +1 -1
  94. package/ts_build/src/clients/openai.js +4 -4
  95. package/ts_build/src/clients/openai.js.map +1 -1
  96. package/ts_build/src/clients/types.d.ts +15 -8
  97. package/ts_build/src/clients/withRetry.d.ts +2 -0
  98. package/ts_build/src/clients/withRetry.js +60 -0
  99. package/ts_build/src/clients/withRetry.js.map +1 -0
  100. package/ts_build/src/cloudWorker.d.ts +14 -0
  101. package/ts_build/src/cloudWorker.js +105 -66
  102. package/ts_build/src/cloudWorker.js.map +1 -1
  103. package/ts_build/src/commands/agent.d.ts +6 -0
  104. package/ts_build/src/commands/agent.js +229 -0
  105. package/ts_build/src/commands/agent.js.map +1 -0
  106. package/ts_build/src/commands/misc.d.ts +10 -0
  107. package/ts_build/src/commands/misc.js +197 -0
  108. package/ts_build/src/commands/misc.js.map +1 -0
  109. package/ts_build/src/commands/modules.d.ts +3 -0
  110. package/ts_build/src/commands/modules.js +207 -0
  111. package/ts_build/src/commands/modules.js.map +1 -0
  112. package/ts_build/src/commands/services.d.ts +5 -0
  113. package/ts_build/src/commands/services.js +87 -0
  114. package/ts_build/src/commands/services.js.map +1 -0
  115. package/ts_build/src/commands/workers.d.ts +6 -0
  116. package/ts_build/src/commands/workers.js +168 -0
  117. package/ts_build/src/commands/workers.js.map +1 -0
  118. package/ts_build/src/config.d.ts +1 -0
  119. package/ts_build/src/config.js +33 -1
  120. package/ts_build/src/config.js.map +1 -1
  121. package/ts_build/src/fileSync.d.ts +6 -0
  122. package/ts_build/src/fileSync.js +50 -23
  123. package/ts_build/src/fileSync.js.map +1 -1
  124. package/ts_build/src/hashes.d.ts +2 -2
  125. package/ts_build/src/hashes.js +35 -9
  126. package/ts_build/src/hashes.js.map +1 -1
  127. package/ts_build/src/index.d.ts +1 -0
  128. package/ts_build/src/index.js +17 -1
  129. package/ts_build/src/index.js.map +1 -1
  130. package/ts_build/src/logger.d.ts +21 -0
  131. package/ts_build/src/logger.js +106 -0
  132. package/ts_build/src/logger.js.map +1 -0
  133. package/ts_build/src/plugins/embedding.js +4 -3
  134. package/ts_build/src/plugins/embedding.js.map +1 -1
  135. package/ts_build/src/plugins/plugins.d.ts +0 -2
  136. package/ts_build/src/plugins/plugins.js +0 -11
  137. package/ts_build/src/plugins/plugins.js.map +1 -1
  138. package/ts_build/src/plugins/vim.js +3 -9
  139. package/ts_build/src/plugins/vim.js.map +1 -1
  140. package/ts_build/src/processors/JsonCompressor.js +4 -4
  141. package/ts_build/src/processors/JsonCompressor.js.map +1 -1
  142. package/ts_build/src/services/EventService.d.ts +6 -1
  143. package/ts_build/src/services/EventService.js +29 -0
  144. package/ts_build/src/services/EventService.js.map +1 -1
  145. package/ts_build/src/services/KnowhowClient.d.ts +13 -1
  146. package/ts_build/src/services/KnowhowClient.js +19 -2
  147. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  148. package/ts_build/src/services/MediaProcessorService.d.ts +2 -1
  149. package/ts_build/src/services/MediaProcessorService.js +2 -1
  150. package/ts_build/src/services/MediaProcessorService.js.map +1 -1
  151. package/ts_build/src/services/modules/index.d.ts +33 -0
  152. package/ts_build/src/services/modules/index.js +67 -47
  153. package/ts_build/src/services/modules/index.js.map +1 -1
  154. package/ts_build/src/services/modules/types.d.ts +6 -0
  155. package/ts_build/src/tunnel.d.ts +27 -0
  156. package/ts_build/src/tunnel.js +112 -0
  157. package/ts_build/src/tunnel.js.map +1 -0
  158. package/ts_build/src/types.d.ts +0 -1
  159. package/ts_build/src/types.js.map +1 -1
  160. package/ts_build/src/worker.d.ts +1 -4
  161. package/ts_build/src/worker.js +59 -227
  162. package/ts_build/src/worker.js.map +1 -1
  163. package/ts_build/src/workers/auth/WsMiddleware.d.ts +8 -0
  164. package/ts_build/src/workers/auth/WsMiddleware.js +65 -0
  165. package/ts_build/src/workers/auth/WsMiddleware.js.map +1 -0
  166. package/ts_build/src/workers/auth/authMiddleware.d.ts +3 -0
  167. package/ts_build/src/workers/auth/authMiddleware.js +60 -0
  168. package/ts_build/src/workers/auth/authMiddleware.js.map +1 -0
  169. package/ts_build/src/workers/auth/types.d.ts +8 -1
  170. package/ts_build/src/workers/tools/index.d.ts +2 -0
  171. package/ts_build/src/workers/tools/index.js +4 -1
  172. package/ts_build/src/workers/tools/index.js.map +1 -1
  173. package/ts_build/src/workers/tools/reloadConfig.d.ts +14 -0
  174. package/ts_build/src/workers/tools/reloadConfig.js +48 -0
  175. package/ts_build/src/workers/tools/reloadConfig.js.map +1 -0
  176. package/ts_build/tests/services/WorkerReloadConfig.test.d.ts +1 -0
  177. package/ts_build/tests/services/WorkerReloadConfig.test.js +86 -0
  178. package/ts_build/tests/services/WorkerReloadConfig.test.js.map +1 -0
  179. package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
  180. package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
  181. package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
  182. package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
  183. package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
  184. package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
  185. package/ts_build/tests/unit/commands/github-credentials.test.d.ts +1 -0
  186. package/ts_build/tests/unit/commands/github-credentials.test.js +145 -0
  187. package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -0
  188. package/ts_build/tests/unit/modules/moduleLoading.test.js +20 -26
  189. package/ts_build/tests/unit/modules/moduleLoading.test.js.map +1 -1
  190. package/ts_build/tests/unit/plugins/pluginLoading.test.js +0 -65
  191. package/ts_build/tests/unit/plugins/pluginLoading.test.js.map +1 -1
  192. package/src/agents/tools/executeScript/README.md +0 -94
  193. package/src/agents/tools/executeScript/definition.ts +0 -79
  194. package/src/agents/tools/executeScript/examples/dependency-injection-validation.ts +0 -272
  195. package/src/agents/tools/executeScript/examples/quick-test.ts +0 -74
  196. package/src/agents/tools/executeScript/examples/serialization-test.ts +0 -321
  197. package/src/agents/tools/executeScript/examples/test-runner.ts +0 -197
  198. package/src/agents/tools/executeScript/index.ts +0 -98
  199. package/src/services/script-execution/SandboxContext.ts +0 -282
  200. package/src/services/script-execution/ScriptExecutor.ts +0 -441
  201. package/src/services/script-execution/ScriptPolicy.ts +0 -194
  202. package/src/services/script-execution/ScriptTracer.ts +0 -249
  203. package/src/services/script-execution/types.ts +0 -134
  204. package/ts_build/src/agents/tools/executeScript/definition.d.ts +0 -2
  205. package/ts_build/src/agents/tools/executeScript/definition.js +0 -76
  206. package/ts_build/src/agents/tools/executeScript/definition.js.map +0 -1
  207. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.d.ts +0 -18
  208. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.js +0 -192
  209. package/ts_build/src/agents/tools/executeScript/examples/dependency-injection-validation.js.map +0 -1
  210. package/ts_build/src/agents/tools/executeScript/examples/quick-test.d.ts +0 -3
  211. package/ts_build/src/agents/tools/executeScript/examples/quick-test.js +0 -64
  212. package/ts_build/src/agents/tools/executeScript/examples/quick-test.js.map +0 -1
  213. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.d.ts +0 -15
  214. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.js +0 -266
  215. package/ts_build/src/agents/tools/executeScript/examples/serialization-test.js.map +0 -1
  216. package/ts_build/src/agents/tools/executeScript/examples/test-runner.d.ts +0 -4
  217. package/ts_build/src/agents/tools/executeScript/examples/test-runner.js +0 -208
  218. package/ts_build/src/agents/tools/executeScript/examples/test-runner.js.map +0 -1
  219. package/ts_build/src/agents/tools/executeScript/index.d.ts +0 -28
  220. package/ts_build/src/agents/tools/executeScript/index.js +0 -72
  221. package/ts_build/src/agents/tools/executeScript/index.js.map +0 -1
  222. package/ts_build/src/services/script-execution/SandboxContext.d.ts +0 -34
  223. package/ts_build/src/services/script-execution/SandboxContext.js +0 -189
  224. package/ts_build/src/services/script-execution/SandboxContext.js.map +0 -1
  225. package/ts_build/src/services/script-execution/ScriptExecutor.d.ts +0 -19
  226. package/ts_build/src/services/script-execution/ScriptExecutor.js +0 -269
  227. package/ts_build/src/services/script-execution/ScriptExecutor.js.map +0 -1
  228. package/ts_build/src/services/script-execution/ScriptPolicy.d.ts +0 -28
  229. package/ts_build/src/services/script-execution/ScriptPolicy.js +0 -115
  230. package/ts_build/src/services/script-execution/ScriptPolicy.js.map +0 -1
  231. package/ts_build/src/services/script-execution/ScriptTracer.d.ts +0 -19
  232. package/ts_build/src/services/script-execution/ScriptTracer.js +0 -186
  233. package/ts_build/src/services/script-execution/ScriptTracer.js.map +0 -1
  234. package/ts_build/src/services/script-execution/types.d.ts +0 -108
  235. package/ts_build/src/services/script-execution/types.js +0 -3
  236. package/ts_build/src/services/script-execution/types.js.map +0 -1
@@ -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
+ });