@tyvm/knowhow 0.0.109 → 0.0.110
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/autodoc/README.md +324 -0
- package/autodoc/chat-guide.md +268 -365
- package/autodoc/cli-reference.md +399 -473
- package/autodoc/config-reference.md +431 -330
- package/autodoc/embeddings-guide.md +223 -322
- package/autodoc/generate-guide.md +261 -301
- package/autodoc/language-plugin-guide.md +221 -247
- package/autodoc/modules-guide.md +242 -215
- package/autodoc/plugins-guide.md +470 -469
- package/autodoc/quickstart-guide.md +67 -70
- package/autodoc/skills-guide.md +455 -339
- package/autodoc/worker-guide.md +301 -308
- package/package.json +1 -1
- package/scripts/build-for-node.sh +10 -24
- package/src/agents/tools/list.ts +2 -2
- package/src/ai.ts +81 -37
- package/src/chat/CliChatService.ts +1 -1
- package/src/chat/modules/AgentModule.ts +7 -2
- package/src/chat/modules/SessionsModule.ts +40 -1
- package/src/chat/modules/SystemModule.ts +2 -2
- package/src/clients/anthropic.ts +1 -1
- package/src/clients/index.ts +25 -6
- package/src/clients/openai.ts +8 -5
- package/src/clients/types.ts +29 -6
- package/src/clients/withRetry.ts +89 -0
- package/src/commands/agent.ts +30 -0
- package/src/commands/modules.ts +417 -47
- package/src/config.ts +1 -1
- package/src/fileSync.ts +20 -12
- package/src/hashes.ts +43 -22
- package/src/index.ts +4 -2
- package/src/processors/Base64ImageDetector.ts +73 -0
- package/src/services/MediaProcessorService.ts +79 -10
- package/src/services/modules/index.ts +47 -18
- package/tests/processors/Base64ImageDetector.test.ts +160 -0
- package/tests/unit/clients/AIClient.test.ts +446 -0
- package/tests/unit/clients/withRetry.test.ts +319 -0
- package/tests/unit/commands/github-credentials.test.ts +1 -2
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/tools/list.js +2 -2
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/ai.d.ts +3 -3
- package/ts_build/src/ai.js +51 -23
- package/ts_build/src/ai.js.map +1 -1
- package/ts_build/src/chat/CliChatService.js +1 -1
- package/ts_build/src/chat/CliChatService.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.js +5 -2
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/SessionsModule.js +30 -1
- package/ts_build/src/chat/modules/SessionsModule.js.map +1 -1
- package/ts_build/src/chat/modules/SystemModule.js +2 -2
- package/ts_build/src/chat/modules/SystemModule.js.map +1 -1
- package/ts_build/src/clients/anthropic.js +1 -1
- package/ts_build/src/clients/anthropic.js.map +1 -1
- package/ts_build/src/clients/index.js +7 -6
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.js +4 -4
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/clients/types.d.ts +12 -6
- package/ts_build/src/clients/withRetry.d.ts +2 -0
- package/ts_build/src/clients/withRetry.js +60 -0
- package/ts_build/src/clients/withRetry.js.map +1 -0
- package/ts_build/src/commands/agent.js +25 -0
- package/ts_build/src/commands/agent.js.map +1 -1
- package/ts_build/src/commands/modules.js +359 -32
- package/ts_build/src/commands/modules.js.map +1 -1
- package/ts_build/src/config.js +1 -1
- package/ts_build/src/config.js.map +1 -1
- package/ts_build/src/fileSync.d.ts +2 -2
- package/ts_build/src/fileSync.js +13 -11
- package/ts_build/src/fileSync.js.map +1 -1
- package/ts_build/src/hashes.d.ts +2 -2
- package/ts_build/src/hashes.js +40 -16
- package/ts_build/src/hashes.js.map +1 -1
- package/ts_build/src/index.js +1 -1
- package/ts_build/src/index.js.map +1 -1
- package/ts_build/src/processors/Base64ImageDetector.d.ts +3 -0
- package/ts_build/src/processors/Base64ImageDetector.js +42 -0
- package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
- package/ts_build/src/services/MediaProcessorService.d.ts +5 -4
- package/ts_build/src/services/MediaProcessorService.js +53 -8
- package/ts_build/src/services/MediaProcessorService.js.map +1 -1
- package/ts_build/src/services/modules/index.js +35 -12
- package/ts_build/src/services/modules/index.js.map +1 -1
- package/ts_build/tests/processors/Base64ImageDetector.test.js +111 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
- package/ts_build/tests/unit/clients/AIClient.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/AIClient.test.js +339 -0
- package/ts_build/tests/unit/clients/AIClient.test.js.map +1 -0
- package/ts_build/tests/unit/clients/withRetry.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/withRetry.test.js +225 -0
- package/ts_build/tests/unit/clients/withRetry.test.js.map +1 -0
- package/ts_build/tests/unit/commands/github-credentials.test.js +1 -2
- package/ts_build/tests/unit/commands/github-credentials.test.js.map +1 -1
|
@@ -594,3 +594,163 @@ describe("Base64ImageDetector", () => {
|
|
|
594
594
|
});
|
|
595
595
|
});
|
|
596
596
|
});
|
|
597
|
+
|
|
598
|
+
describe("Base64ImageDetector - image path hint detection", () => {
|
|
599
|
+
let detector: Base64ImageProcessor;
|
|
600
|
+
let processor: ReturnType<Base64ImageProcessor["createProcessor"]>;
|
|
601
|
+
|
|
602
|
+
beforeEach(() => {
|
|
603
|
+
detector = new Base64ImageProcessor();
|
|
604
|
+
processor = detector.createProcessor();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Simulates the actual output from the Playwright MCP browser_take_screenshot tool.
|
|
609
|
+
* When an agent calls browser_take_screenshot, the tool message content looks like:
|
|
610
|
+
*
|
|
611
|
+
* ### Result
|
|
612
|
+
* - [Screenshot of viewport](./hackernews-screenshot.png)
|
|
613
|
+
* ### Ran Playwright code
|
|
614
|
+
* ...
|
|
615
|
+
*
|
|
616
|
+
* The Base64ImageDetector should detect the .png path in this text and add a hint
|
|
617
|
+
* telling the model it can call loadImageAsBase64 to actually view the image.
|
|
618
|
+
*/
|
|
619
|
+
describe("browser screenshot tool response", () => {
|
|
620
|
+
it("should detect image path from a browser screenshot tool message and add hint", () => {
|
|
621
|
+
// This is the exact format returned by the browser MCP take_screenshot tool
|
|
622
|
+
const screenshotToolResponse =
|
|
623
|
+
"### Result\n- [Screenshot of viewport](./hackernews-screenshot.png)\n### Ran Playwright code\n```js\nawait page.screenshot({ path: './hackernews-screenshot.png', scale: 'css', type: 'png' });\n```";
|
|
624
|
+
|
|
625
|
+
const originalMessages: Message[] = [];
|
|
626
|
+
const modifiedMessages: Message[] = [
|
|
627
|
+
{
|
|
628
|
+
role: "tool",
|
|
629
|
+
content: screenshotToolResponse,
|
|
630
|
+
tool_call_id: "call_abc123",
|
|
631
|
+
},
|
|
632
|
+
];
|
|
633
|
+
|
|
634
|
+
processor(originalMessages, modifiedMessages);
|
|
635
|
+
|
|
636
|
+
const content = modifiedMessages[0].content as string;
|
|
637
|
+
expect(typeof content).toBe("string");
|
|
638
|
+
// Should still contain the original text
|
|
639
|
+
expect(content).toContain("Screenshot of viewport");
|
|
640
|
+
expect(content).toContain("./hackernews-screenshot.png");
|
|
641
|
+
// Should contain the hint
|
|
642
|
+
expect(content).toContain("[TIP:");
|
|
643
|
+
expect(content).toContain("loadImageAsBase64");
|
|
644
|
+
expect(content).toContain("./hackernews-screenshot.png");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("should include the exact file path in the hint", () => {
|
|
648
|
+
const screenshotPath = "./hackernews-screenshot.png";
|
|
649
|
+
const toolResponse = `### Result\n- [Screenshot of viewport](${screenshotPath})\n`;
|
|
650
|
+
|
|
651
|
+
const originalMessages: Message[] = [];
|
|
652
|
+
const modifiedMessages: Message[] = [
|
|
653
|
+
{
|
|
654
|
+
role: "tool",
|
|
655
|
+
content: toolResponse,
|
|
656
|
+
tool_call_id: "call_xyz789",
|
|
657
|
+
},
|
|
658
|
+
];
|
|
659
|
+
|
|
660
|
+
processor(originalMessages, modifiedMessages);
|
|
661
|
+
|
|
662
|
+
const content = modifiedMessages[0].content as string;
|
|
663
|
+
// The hint should reference the exact path
|
|
664
|
+
expect(content).toContain(`loadImageAsBase64("${screenshotPath}")`);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("should not add hint when message contains no image paths", () => {
|
|
668
|
+
const toolResponse = "The page has been loaded successfully.";
|
|
669
|
+
|
|
670
|
+
const originalMessages: Message[] = [];
|
|
671
|
+
const modifiedMessages: Message[] = [
|
|
672
|
+
{
|
|
673
|
+
role: "tool",
|
|
674
|
+
content: toolResponse,
|
|
675
|
+
tool_call_id: "call_noimages",
|
|
676
|
+
},
|
|
677
|
+
];
|
|
678
|
+
|
|
679
|
+
processor(originalMessages, modifiedMessages);
|
|
680
|
+
|
|
681
|
+
const content = modifiedMessages[0].content as string;
|
|
682
|
+
expect(content).toBe(toolResponse);
|
|
683
|
+
expect(content).not.toContain("[TIP:");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("should detect absolute paths like /tmp/page-123.png", () => {
|
|
687
|
+
const toolResponse =
|
|
688
|
+
"Screenshot saved to /tmp/page-2026-01-01T12-00-00.png for review.";
|
|
689
|
+
|
|
690
|
+
const originalMessages: Message[] = [];
|
|
691
|
+
const modifiedMessages: Message[] = [
|
|
692
|
+
{
|
|
693
|
+
role: "tool",
|
|
694
|
+
content: toolResponse,
|
|
695
|
+
tool_call_id: "call_abs",
|
|
696
|
+
},
|
|
697
|
+
];
|
|
698
|
+
|
|
699
|
+
processor(originalMessages, modifiedMessages);
|
|
700
|
+
|
|
701
|
+
const content = modifiedMessages[0].content as string;
|
|
702
|
+
expect(content).toContain("[TIP:");
|
|
703
|
+
expect(content).toContain("loadImageAsBase64");
|
|
704
|
+
expect(content).toContain("/tmp/page-2026-01-01T12-00-00.png");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("should detect multiple image paths and hint about all of them", () => {
|
|
708
|
+
const toolResponse =
|
|
709
|
+
"Before: ./before.png\nAfter: ./after.jpg\nDiff: ./diff.png";
|
|
710
|
+
|
|
711
|
+
const originalMessages: Message[] = [];
|
|
712
|
+
const modifiedMessages: Message[] = [
|
|
713
|
+
{
|
|
714
|
+
role: "tool",
|
|
715
|
+
content: toolResponse,
|
|
716
|
+
tool_call_id: "call_multi",
|
|
717
|
+
},
|
|
718
|
+
];
|
|
719
|
+
|
|
720
|
+
processor(originalMessages, modifiedMessages);
|
|
721
|
+
|
|
722
|
+
const content = modifiedMessages[0].content as string;
|
|
723
|
+
expect(content).toContain("[TIP:");
|
|
724
|
+
expect(content).toContain("./before.png");
|
|
725
|
+
expect(content).toContain("./after.jpg");
|
|
726
|
+
expect(content).toContain("./diff.png");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("should not add hint to messages that are actual base64 image data (already converted)", () => {
|
|
730
|
+
const validPngBase64 =
|
|
731
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
732
|
+
|
|
733
|
+
const originalMessages: Message[] = [];
|
|
734
|
+
const modifiedMessages: Message[] = [
|
|
735
|
+
{
|
|
736
|
+
role: "tool",
|
|
737
|
+
content: validPngBase64,
|
|
738
|
+
tool_call_id: "call_base64",
|
|
739
|
+
},
|
|
740
|
+
];
|
|
741
|
+
|
|
742
|
+
processor(originalMessages, modifiedMessages);
|
|
743
|
+
|
|
744
|
+
// Should be converted to image array, not get a text hint
|
|
745
|
+
const content = modifiedMessages[0].content;
|
|
746
|
+
if (Array.isArray(content)) {
|
|
747
|
+
// Good - was converted to image content, no hint needed
|
|
748
|
+
expect(content[0]).toHaveProperty("type", "image_url");
|
|
749
|
+
} else {
|
|
750
|
+
// If kept as string, the hint should NOT be about a file path
|
|
751
|
+
// because base64 data URLs don't contain file paths
|
|
752
|
+
expect(content as string).not.toMatch(/loadImageAsBase64\("data:/);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
});
|
|
@@ -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
|
+
});
|