@ryanfw/prompt-orchestration-pipeline 1.2.2 → 1.2.3
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/package.json +1 -1
- package/src/core/config.ts +1 -1
- package/src/llm/index.ts +14 -10
- package/src/providers/__tests__/alibaba.test.ts +152 -59
- package/src/providers/__tests__/anthropic.test.ts +145 -55
- package/src/providers/__tests__/claude-code.test.ts +68 -34
- package/src/providers/__tests__/deepseek.test.ts +229 -269
- package/src/providers/__tests__/gemini.test.ts +145 -100
- package/src/providers/__tests__/moonshot.test.ts +140 -121
- package/src/providers/__tests__/openai.test.ts +162 -55
- package/src/providers/__tests__/stream-accumulator.test.ts +512 -0
- package/src/providers/__tests__/zhipu.test.ts +153 -95
- package/src/providers/alibaba.ts +138 -18
- package/src/providers/anthropic.ts +75 -69
- package/src/providers/base.ts +1 -1
- package/src/providers/claude-code.ts +21 -12
- package/src/providers/deepseek.ts +22 -156
- package/src/providers/gemini.ts +10 -24
- package/src/providers/moonshot.ts +19 -18
- package/src/providers/openai.ts +180 -5
- package/src/providers/stream-accumulator.ts +435 -0
- package/src/providers/types.ts +1 -0
- package/src/providers/zhipu.ts +19 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "A Prompt-orchestration pipeline (POP) is a framework for building, running, and experimenting with complex chains of LLM tasks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/ui/server/index.ts",
|
package/src/core/config.ts
CHANGED
package/src/llm/index.ts
CHANGED
|
@@ -92,43 +92,43 @@ function inferJsonFormat(options: ChatOptions): ChatOptions {
|
|
|
92
92
|
async function callAdapter(
|
|
93
93
|
options: ChatOptions,
|
|
94
94
|
): Promise<AdapterResponse> {
|
|
95
|
-
const { provider, messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries } = options;
|
|
95
|
+
const { provider, messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs } = options;
|
|
96
96
|
|
|
97
97
|
switch (provider) {
|
|
98
98
|
case "alibaba":
|
|
99
99
|
return alibabaChat({
|
|
100
|
-
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries,
|
|
100
|
+
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs,
|
|
101
101
|
frequencyPenalty: options.frequencyPenalty,
|
|
102
102
|
presencePenalty: options.presencePenalty,
|
|
103
103
|
});
|
|
104
104
|
case "anthropic":
|
|
105
|
-
return anthropicChat({ messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries });
|
|
105
|
+
return anthropicChat({ messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs });
|
|
106
106
|
case "openai":
|
|
107
107
|
return openaiChat({
|
|
108
|
-
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries,
|
|
108
|
+
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs,
|
|
109
109
|
seed: undefined,
|
|
110
110
|
frequencyPenalty: options.frequencyPenalty,
|
|
111
111
|
presencePenalty: options.presencePenalty,
|
|
112
112
|
});
|
|
113
113
|
case "gemini":
|
|
114
114
|
return geminiChat({
|
|
115
|
-
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries,
|
|
115
|
+
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs,
|
|
116
116
|
frequencyPenalty: options.frequencyPenalty,
|
|
117
117
|
presencePenalty: options.presencePenalty,
|
|
118
118
|
});
|
|
119
119
|
case "deepseek":
|
|
120
120
|
return deepseekChat({
|
|
121
|
-
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries,
|
|
121
|
+
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs,
|
|
122
122
|
frequencyPenalty: options.frequencyPenalty,
|
|
123
123
|
presencePenalty: options.presencePenalty,
|
|
124
124
|
});
|
|
125
125
|
case "moonshot":
|
|
126
|
-
return moonshotChat({ messages, model, maxTokens, responseFormat, maxRetries });
|
|
126
|
+
return moonshotChat({ messages, model, maxTokens, responseFormat, maxRetries, requestTimeoutMs });
|
|
127
127
|
case "zai":
|
|
128
128
|
case "zhipu":
|
|
129
|
-
return zaiChat({ messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries });
|
|
129
|
+
return zaiChat({ messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries, requestTimeoutMs });
|
|
130
130
|
case "claudecode":
|
|
131
|
-
return claudeCodeChat({ messages, model, maxTokens, responseFormat, maxRetries });
|
|
131
|
+
return claudeCodeChat({ messages, model, maxTokens, responseFormat, maxRetries, requestTimeoutMs });
|
|
132
132
|
case "mock": {
|
|
133
133
|
if (!mockProvider) {
|
|
134
134
|
throw new Error("No mock provider registered. Call registerMockProvider() first.");
|
|
@@ -180,7 +180,11 @@ async function writeDebugLog(options: ChatOptions, response: ChatResponse): Prom
|
|
|
180
180
|
|
|
181
181
|
export async function chat(options: ChatOptions): Promise<ChatResponse> {
|
|
182
182
|
ensureMessagesPresent(options.messages, options.provider);
|
|
183
|
-
const
|
|
183
|
+
const configTimeout = getConfig().taskRunner.llmRequestTimeout;
|
|
184
|
+
const opts = inferJsonFormat({
|
|
185
|
+
...options,
|
|
186
|
+
requestTimeoutMs: options.requestTimeoutMs ?? configTimeout,
|
|
187
|
+
});
|
|
184
188
|
const id = `llm-${++requestCounter}-${Date.now()}`;
|
|
185
189
|
const model = opts.model ?? "";
|
|
186
190
|
const startTime = Date.now();
|
|
@@ -4,25 +4,66 @@ import { ProviderJsonParseError } from "../types.ts";
|
|
|
4
4
|
import type { AlibabaOptions } from "../types.ts";
|
|
5
5
|
import type { Mock } from "vitest";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Creates a mock ReadableStream that yields SSE-formatted data.
|
|
9
|
+
*/
|
|
10
|
+
function makeSSEStream(events: string[]): ReadableStream<Uint8Array> {
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const chunks = events.map((e) => encoder.encode(e));
|
|
13
|
+
let index = 0;
|
|
14
|
+
|
|
15
|
+
return new ReadableStream({
|
|
16
|
+
pull(controller) {
|
|
17
|
+
if (index < chunks.length) {
|
|
18
|
+
controller.enqueue(chunks[index]!);
|
|
19
|
+
index++;
|
|
20
|
+
} else {
|
|
21
|
+
controller.close();
|
|
22
|
+
}
|
|
18
23
|
},
|
|
19
|
-
};
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds SSE events for an OpenAI-compatible streaming response.
|
|
29
|
+
*/
|
|
30
|
+
function makeOpenAiSseEvents(
|
|
31
|
+
textChunks: string[],
|
|
32
|
+
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number },
|
|
33
|
+
): string[] {
|
|
34
|
+
const events: string[] = [];
|
|
35
|
+
for (const chunk of textChunks) {
|
|
36
|
+
events.push(
|
|
37
|
+
`data: ${JSON.stringify({ choices: [{ delta: { content: chunk } }] })}\n\n`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (usage) {
|
|
41
|
+
events.push(
|
|
42
|
+
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }], usage })}\n\n`,
|
|
43
|
+
);
|
|
44
|
+
} else {
|
|
45
|
+
events.push(
|
|
46
|
+
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}\n\n`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
events.push("data: [DONE]\n\n");
|
|
50
|
+
return events;
|
|
20
51
|
}
|
|
21
52
|
|
|
22
|
-
function
|
|
53
|
+
function mockStreamingResponse(events: string[], status = 200) {
|
|
23
54
|
return {
|
|
24
55
|
ok: status >= 200 && status < 300,
|
|
25
56
|
status,
|
|
57
|
+
body: makeSSEStream(events),
|
|
58
|
+
json: vi.fn(),
|
|
59
|
+
text: vi.fn(),
|
|
60
|
+
} as unknown as Response;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mockErrorResponse(body: unknown, status: number) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
status,
|
|
26
67
|
json: vi.fn().mockResolvedValue(body),
|
|
27
68
|
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
|
28
69
|
} as unknown as Response;
|
|
@@ -56,11 +97,11 @@ describe("alibabaChat", () => {
|
|
|
56
97
|
|
|
57
98
|
it("returns parsed JSON content with usage on success", async () => {
|
|
58
99
|
const jsonPayload = { result: "success", count: 42 };
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
),
|
|
100
|
+
const events = makeOpenAiSseEvents(
|
|
101
|
+
[JSON.stringify(jsonPayload)],
|
|
102
|
+
{ prompt_tokens: 15, completion_tokens: 25, total_tokens: 40 },
|
|
63
103
|
);
|
|
104
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
64
105
|
|
|
65
106
|
const result = await alibabaChat(baseOptions);
|
|
66
107
|
|
|
@@ -70,14 +111,25 @@ describe("alibabaChat", () => {
|
|
|
70
111
|
completion_tokens: 25,
|
|
71
112
|
total_tokens: 40,
|
|
72
113
|
});
|
|
73
|
-
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("sends stream: true and stream_options in request body", async () => {
|
|
117
|
+
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
118
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
119
|
+
|
|
120
|
+
await alibabaChat(baseOptions);
|
|
121
|
+
|
|
122
|
+
const body = JSON.parse(
|
|
123
|
+
(fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
|
|
124
|
+
);
|
|
125
|
+
expect(body.stream).toBe(true);
|
|
126
|
+
expect(body.stream_options).toEqual({ include_usage: true });
|
|
74
127
|
});
|
|
75
128
|
|
|
76
129
|
it("throws ProviderJsonParseError on invalid JSON when responseFormat is json_object", async () => {
|
|
77
130
|
const nonJsonText = "This is plain text, not JSON at all.";
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
);
|
|
131
|
+
const events = makeOpenAiSseEvents([nonJsonText]);
|
|
132
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
81
133
|
|
|
82
134
|
try {
|
|
83
135
|
await alibabaChat(baseOptions);
|
|
@@ -93,18 +145,15 @@ describe("alibabaChat", () => {
|
|
|
93
145
|
|
|
94
146
|
it("retries on HTTP 500 with exponential backoff", async () => {
|
|
95
147
|
const jsonPayload = { retried: true };
|
|
148
|
+
const events = makeOpenAiSseEvents([JSON.stringify(jsonPayload)]);
|
|
96
149
|
fetchMock
|
|
97
150
|
.mockResolvedValueOnce(
|
|
98
|
-
|
|
151
|
+
mockErrorResponse({ error: { message: "Server error" } }, 500),
|
|
99
152
|
)
|
|
100
153
|
.mockResolvedValueOnce(
|
|
101
|
-
|
|
154
|
+
mockErrorResponse({ error: { message: "Server error" } }, 500),
|
|
102
155
|
)
|
|
103
|
-
.mockResolvedValueOnce(
|
|
104
|
-
mockFetchResponse(
|
|
105
|
-
makeAlibabaResponse(JSON.stringify(jsonPayload)),
|
|
106
|
-
),
|
|
107
|
-
);
|
|
156
|
+
.mockResolvedValueOnce(mockStreamingResponse(events));
|
|
108
157
|
|
|
109
158
|
const result = await alibabaChat({ ...baseOptions, maxRetries: 3 });
|
|
110
159
|
|
|
@@ -114,7 +163,7 @@ describe("alibabaChat", () => {
|
|
|
114
163
|
|
|
115
164
|
it("does NOT retry on HTTP 401", async () => {
|
|
116
165
|
fetchMock.mockResolvedValue(
|
|
117
|
-
|
|
166
|
+
mockErrorResponse({ error: { message: "Unauthorized" } }, 401),
|
|
118
167
|
);
|
|
119
168
|
|
|
120
169
|
await expect(
|
|
@@ -126,10 +175,8 @@ describe("alibabaChat", () => {
|
|
|
126
175
|
|
|
127
176
|
it("uses ALIBABA_BASE_URL env var when set", async () => {
|
|
128
177
|
process.env["ALIBABA_BASE_URL"] = "https://custom.api.example.com";
|
|
129
|
-
const
|
|
130
|
-
fetchMock.mockResolvedValue(
|
|
131
|
-
mockFetchResponse(makeAlibabaResponse(JSON.stringify(jsonPayload))),
|
|
132
|
-
);
|
|
178
|
+
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
179
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
133
180
|
|
|
134
181
|
await alibabaChat(baseOptions);
|
|
135
182
|
|
|
@@ -140,10 +187,8 @@ describe("alibabaChat", () => {
|
|
|
140
187
|
});
|
|
141
188
|
|
|
142
189
|
it("passes frequencyPenalty and presencePenalty in request body", async () => {
|
|
143
|
-
const
|
|
144
|
-
fetchMock.mockResolvedValue(
|
|
145
|
-
mockFetchResponse(makeAlibabaResponse(JSON.stringify(jsonPayload))),
|
|
146
|
-
);
|
|
190
|
+
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
191
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
147
192
|
|
|
148
193
|
await alibabaChat({
|
|
149
194
|
...baseOptions,
|
|
@@ -158,10 +203,9 @@ describe("alibabaChat", () => {
|
|
|
158
203
|
expect(body.presence_penalty).toBe(0.2);
|
|
159
204
|
});
|
|
160
205
|
|
|
161
|
-
it("passes an AbortSignal to fetch", async () => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
);
|
|
206
|
+
it("passes an AbortSignal to fetch (IdleTimeoutController)", async () => {
|
|
207
|
+
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
208
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
165
209
|
|
|
166
210
|
await alibabaChat(baseOptions);
|
|
167
211
|
|
|
@@ -169,22 +213,9 @@ describe("alibabaChat", () => {
|
|
|
169
213
|
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
170
214
|
});
|
|
171
215
|
|
|
172
|
-
it("uses custom requestTimeoutMs for the abort signal", async () => {
|
|
173
|
-
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
|
|
174
|
-
fetchMock.mockResolvedValue(
|
|
175
|
-
mockFetchResponse(makeAlibabaResponse(JSON.stringify({ ok: true }))),
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
await alibabaChat({ ...baseOptions, requestTimeoutMs: 5000 });
|
|
179
|
-
|
|
180
|
-
expect(timeoutSpy).toHaveBeenCalledWith(5000);
|
|
181
|
-
timeoutSpy.mockRestore();
|
|
182
|
-
});
|
|
183
|
-
|
|
184
216
|
it("sends enable_thinking true by default", async () => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
);
|
|
217
|
+
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
218
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
188
219
|
|
|
189
220
|
await alibabaChat(baseOptions);
|
|
190
221
|
|
|
@@ -195,9 +226,8 @@ describe("alibabaChat", () => {
|
|
|
195
226
|
});
|
|
196
227
|
|
|
197
228
|
it("sends enable_thinking false when thinking is disabled", async () => {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
);
|
|
229
|
+
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
230
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
201
231
|
|
|
202
232
|
await alibabaChat({ ...baseOptions, thinking: "disabled" });
|
|
203
233
|
|
|
@@ -206,4 +236,67 @@ describe("alibabaChat", () => {
|
|
|
206
236
|
);
|
|
207
237
|
expect(body.enable_thinking).toBe(false);
|
|
208
238
|
});
|
|
239
|
+
|
|
240
|
+
describe("streaming accumulation", () => {
|
|
241
|
+
it("accumulates text across multiple SSE chunks", async () => {
|
|
242
|
+
const events = [
|
|
243
|
+
'data: {"choices":[{"delta":{"content":"{\\"he"}}]}\n\n',
|
|
244
|
+
'data: {"choices":[{"delta":{"content":"llo\\":\\"world\\"}"}}]}\n\n',
|
|
245
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
|
246
|
+
"data: [DONE]\n\n",
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
250
|
+
|
|
251
|
+
const result = await alibabaChat(baseOptions);
|
|
252
|
+
expect(result.content).toEqual({ hello: "world" });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("captures usage from the final streaming chunk", async () => {
|
|
256
|
+
const events = makeOpenAiSseEvents(
|
|
257
|
+
[JSON.stringify({ ok: true })],
|
|
258
|
+
{ prompt_tokens: 50, completion_tokens: 30, total_tokens: 80 },
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
262
|
+
|
|
263
|
+
const result = await alibabaChat(baseOptions);
|
|
264
|
+
expect(result.usage).toEqual({
|
|
265
|
+
prompt_tokens: 50,
|
|
266
|
+
completion_tokens: 30,
|
|
267
|
+
total_tokens: 80,
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("defaults usage to zeros when stream provides no usage", async () => {
|
|
272
|
+
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
273
|
+
|
|
274
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
275
|
+
|
|
276
|
+
const result = await alibabaChat(baseOptions);
|
|
277
|
+
expect(result.usage).toEqual({
|
|
278
|
+
prompt_tokens: 0,
|
|
279
|
+
completion_tokens: 0,
|
|
280
|
+
total_tokens: 0,
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("retries on timeout then succeeds on second attempt", async () => {
|
|
285
|
+
const events = makeOpenAiSseEvents([JSON.stringify({ ok: true })]);
|
|
286
|
+
|
|
287
|
+
fetchMock
|
|
288
|
+
.mockRejectedValueOnce(
|
|
289
|
+
new DOMException("signal timed out", "TimeoutError"),
|
|
290
|
+
)
|
|
291
|
+
.mockResolvedValueOnce(mockStreamingResponse(events));
|
|
292
|
+
|
|
293
|
+
const result = await alibabaChat({
|
|
294
|
+
...baseOptions,
|
|
295
|
+
maxRetries: 1,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
299
|
+
expect(result.content).toEqual({ ok: true });
|
|
300
|
+
});
|
|
301
|
+
});
|
|
209
302
|
});
|
|
@@ -4,21 +4,67 @@ import { ProviderJsonModeError, ProviderJsonParseError } from "../types.ts";
|
|
|
4
4
|
import type { AnthropicOptions } from "../types.ts";
|
|
5
5
|
import type { Mock } from "vitest";
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Creates a mock ReadableStream that yields SSE-formatted data.
|
|
9
|
+
*/
|
|
10
|
+
function makeSSEStream(events: string[]): ReadableStream<Uint8Array> {
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const chunks = events.map((e) => encoder.encode(e));
|
|
13
|
+
let index = 0;
|
|
14
|
+
|
|
15
|
+
return new ReadableStream({
|
|
16
|
+
pull(controller) {
|
|
17
|
+
if (index < chunks.length) {
|
|
18
|
+
controller.enqueue(chunks[index]!);
|
|
19
|
+
index++;
|
|
20
|
+
} else {
|
|
21
|
+
controller.close();
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds SSE events for an Anthropic streaming response.
|
|
29
|
+
*/
|
|
30
|
+
function makeAnthropicSseEvents(
|
|
8
31
|
text: string,
|
|
9
32
|
inputTokens = 10,
|
|
10
33
|
outputTokens = 20,
|
|
11
|
-
) {
|
|
12
|
-
return
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
34
|
+
): string[] {
|
|
35
|
+
return [
|
|
36
|
+
`event: message_start\ndata: ${JSON.stringify({
|
|
37
|
+
type: "message_start",
|
|
38
|
+
message: { usage: { input_tokens: inputTokens } },
|
|
39
|
+
})}\n\n`,
|
|
40
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
41
|
+
type: "content_block_delta",
|
|
42
|
+
delta: { text },
|
|
43
|
+
})}\n\n`,
|
|
44
|
+
`event: message_delta\ndata: ${JSON.stringify({
|
|
45
|
+
type: "message_delta",
|
|
46
|
+
usage: { output_tokens: outputTokens },
|
|
47
|
+
})}\n\n`,
|
|
48
|
+
`event: message_stop\ndata: ${JSON.stringify({
|
|
49
|
+
type: "message_stop",
|
|
50
|
+
})}\n\n`,
|
|
51
|
+
];
|
|
16
52
|
}
|
|
17
53
|
|
|
18
|
-
function
|
|
54
|
+
function mockStreamingResponse(events: string[], status = 200) {
|
|
19
55
|
return {
|
|
20
56
|
ok: status >= 200 && status < 300,
|
|
21
57
|
status,
|
|
58
|
+
body: makeSSEStream(events),
|
|
59
|
+
json: vi.fn(),
|
|
60
|
+
text: vi.fn(),
|
|
61
|
+
} as unknown as Response;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mockErrorResponse(body: unknown, status: number) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
status,
|
|
22
68
|
json: vi.fn().mockResolvedValue(body),
|
|
23
69
|
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
|
24
70
|
} as unknown as Response;
|
|
@@ -51,9 +97,12 @@ describe("anthropicChat", () => {
|
|
|
51
97
|
|
|
52
98
|
it("returns parsed JSON content, correct usage, and text for a valid response", async () => {
|
|
53
99
|
const jsonPayload = { result: "success", count: 42 };
|
|
54
|
-
|
|
55
|
-
|
|
100
|
+
const events = makeAnthropicSseEvents(
|
|
101
|
+
JSON.stringify(jsonPayload),
|
|
102
|
+
15,
|
|
103
|
+
25,
|
|
56
104
|
);
|
|
105
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
57
106
|
|
|
58
107
|
const result = await anthropicChat(baseOptions);
|
|
59
108
|
|
|
@@ -64,12 +113,11 @@ describe("anthropicChat", () => {
|
|
|
64
113
|
completion_tokens: 25,
|
|
65
114
|
total_tokens: 40,
|
|
66
115
|
});
|
|
67
|
-
expect(result.raw).toBeDefined();
|
|
68
116
|
});
|
|
69
117
|
|
|
70
118
|
it("throws immediately on 401 without retrying", async () => {
|
|
71
119
|
fetchMock.mockResolvedValue(
|
|
72
|
-
|
|
120
|
+
mockErrorResponse({ error: { message: "Unauthorized" } }, 401),
|
|
73
121
|
);
|
|
74
122
|
|
|
75
123
|
await expect(
|
|
@@ -82,13 +130,12 @@ describe("anthropicChat", () => {
|
|
|
82
130
|
|
|
83
131
|
it("retries on 429 then succeeds on 200", async () => {
|
|
84
132
|
const jsonPayload = { retried: true };
|
|
133
|
+
const events = makeAnthropicSseEvents(JSON.stringify(jsonPayload));
|
|
85
134
|
fetchMock
|
|
86
135
|
.mockResolvedValueOnce(
|
|
87
|
-
|
|
136
|
+
mockErrorResponse({ error: { message: "Rate limited" } }, 429),
|
|
88
137
|
)
|
|
89
|
-
.mockResolvedValueOnce(
|
|
90
|
-
mockFetchResponse(makeAnthropicResponse(JSON.stringify(jsonPayload))),
|
|
91
|
-
);
|
|
138
|
+
.mockResolvedValueOnce(mockStreamingResponse(events));
|
|
92
139
|
|
|
93
140
|
const result = await anthropicChat({
|
|
94
141
|
...baseOptions,
|
|
@@ -110,9 +157,8 @@ describe("anthropicChat", () => {
|
|
|
110
157
|
|
|
111
158
|
it("defaults to json responseFormat when responseFormat is omitted", async () => {
|
|
112
159
|
const jsonPayload = { defaultFormat: true };
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
);
|
|
160
|
+
const events = makeAnthropicSseEvents(JSON.stringify(jsonPayload));
|
|
161
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
116
162
|
|
|
117
163
|
// responseFormat defaults to "json" — should not throw
|
|
118
164
|
const result = await anthropicChat({
|
|
@@ -123,9 +169,8 @@ describe("anthropicChat", () => {
|
|
|
123
169
|
|
|
124
170
|
it("throws ProviderJsonParseError for non-JSON text in JSON mode", async () => {
|
|
125
171
|
const nonJsonText = "This is plain text, not JSON at all.";
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
);
|
|
172
|
+
const events = makeAnthropicSseEvents(nonJsonText);
|
|
173
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
129
174
|
|
|
130
175
|
try {
|
|
131
176
|
await anthropicChat(baseOptions);
|
|
@@ -140,10 +185,8 @@ describe("anthropicChat", () => {
|
|
|
140
185
|
});
|
|
141
186
|
|
|
142
187
|
it("sends correct headers including anthropic-version and x-api-key", async () => {
|
|
143
|
-
const
|
|
144
|
-
fetchMock.mockResolvedValue(
|
|
145
|
-
mockFetchResponse(makeAnthropicResponse(JSON.stringify(jsonPayload))),
|
|
146
|
-
);
|
|
188
|
+
const events = makeAnthropicSseEvents(JSON.stringify({ ok: true }));
|
|
189
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
147
190
|
|
|
148
191
|
await anthropicChat(baseOptions);
|
|
149
192
|
|
|
@@ -155,11 +198,21 @@ describe("anthropicChat", () => {
|
|
|
155
198
|
expect(headers["Content-Type"]).toBe("application/json");
|
|
156
199
|
});
|
|
157
200
|
|
|
158
|
-
it("
|
|
159
|
-
const
|
|
160
|
-
fetchMock.mockResolvedValue(
|
|
161
|
-
|
|
201
|
+
it("sends stream: true in the request body", async () => {
|
|
202
|
+
const events = makeAnthropicSseEvents(JSON.stringify({ ok: true }));
|
|
203
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
204
|
+
|
|
205
|
+
await anthropicChat(baseOptions);
|
|
206
|
+
|
|
207
|
+
const body = JSON.parse(
|
|
208
|
+
(fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
|
|
162
209
|
);
|
|
210
|
+
expect(body.stream).toBe(true);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("constructs the request body with system and messages in conversation order", async () => {
|
|
214
|
+
const events = makeAnthropicSseEvents(JSON.stringify({ ok: true }));
|
|
215
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
163
216
|
|
|
164
217
|
await anthropicChat({
|
|
165
218
|
messages: [
|
|
@@ -186,10 +239,8 @@ describe("anthropicChat", () => {
|
|
|
186
239
|
});
|
|
187
240
|
|
|
188
241
|
it("uses custom model, temperature, and maxTokens when provided", async () => {
|
|
189
|
-
const
|
|
190
|
-
fetchMock.mockResolvedValue(
|
|
191
|
-
mockFetchResponse(makeAnthropicResponse(JSON.stringify(jsonPayload))),
|
|
192
|
-
);
|
|
242
|
+
const events = makeAnthropicSseEvents(JSON.stringify({ custom: true }));
|
|
243
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
193
244
|
|
|
194
245
|
await anthropicChat({
|
|
195
246
|
...baseOptions,
|
|
@@ -207,9 +258,8 @@ describe("anthropicChat", () => {
|
|
|
207
258
|
});
|
|
208
259
|
|
|
209
260
|
it("passes topP and stop sequences when provided", async () => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
);
|
|
261
|
+
const events = makeAnthropicSseEvents(JSON.stringify({ ok: true }));
|
|
262
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
213
263
|
|
|
214
264
|
await anthropicChat({
|
|
215
265
|
...baseOptions,
|
|
@@ -224,10 +274,9 @@ describe("anthropicChat", () => {
|
|
|
224
274
|
expect(body.stop_sequences).toEqual(["END", "STOP"]);
|
|
225
275
|
});
|
|
226
276
|
|
|
227
|
-
it("passes an AbortSignal to fetch", async () => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
);
|
|
277
|
+
it("passes an AbortSignal to fetch (IdleTimeoutController)", async () => {
|
|
278
|
+
const events = makeAnthropicSseEvents(JSON.stringify({ ok: true }));
|
|
279
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
231
280
|
|
|
232
281
|
await anthropicChat(baseOptions);
|
|
233
282
|
|
|
@@ -235,25 +284,66 @@ describe("anthropicChat", () => {
|
|
|
235
284
|
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
236
285
|
});
|
|
237
286
|
|
|
238
|
-
it("uses custom requestTimeoutMs for the abort signal", async () => {
|
|
239
|
-
const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
|
|
240
|
-
fetchMock.mockResolvedValue(
|
|
241
|
-
mockFetchResponse(makeAnthropicResponse(JSON.stringify({ ok: true }))),
|
|
242
|
-
);
|
|
243
|
-
|
|
244
|
-
await anthropicChat({ ...baseOptions, requestTimeoutMs: 5000 });
|
|
245
|
-
|
|
246
|
-
expect(timeoutSpy).toHaveBeenCalledWith(5000);
|
|
247
|
-
timeoutSpy.mockRestore();
|
|
248
|
-
});
|
|
249
|
-
|
|
250
287
|
it("handles markdown-fenced JSON responses", async () => {
|
|
251
288
|
const fencedJson = '```json\n{"fenced": true}\n```';
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
);
|
|
289
|
+
const events = makeAnthropicSseEvents(fencedJson);
|
|
290
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
255
291
|
|
|
256
292
|
const result = await anthropicChat(baseOptions);
|
|
257
293
|
expect(result.content).toEqual({ fenced: true });
|
|
258
294
|
});
|
|
295
|
+
|
|
296
|
+
describe("streaming accumulation", () => {
|
|
297
|
+
it("accumulates text across multiple content_block_delta events", async () => {
|
|
298
|
+
const events = [
|
|
299
|
+
`event: message_start\ndata: ${JSON.stringify({
|
|
300
|
+
type: "message_start",
|
|
301
|
+
message: { usage: { input_tokens: 10 } },
|
|
302
|
+
})}\n\n`,
|
|
303
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
304
|
+
type: "content_block_delta",
|
|
305
|
+
delta: { text: '{"he' },
|
|
306
|
+
})}\n\n`,
|
|
307
|
+
`event: content_block_delta\ndata: ${JSON.stringify({
|
|
308
|
+
type: "content_block_delta",
|
|
309
|
+
delta: { text: 'llo":"world"}' },
|
|
310
|
+
})}\n\n`,
|
|
311
|
+
`event: message_delta\ndata: ${JSON.stringify({
|
|
312
|
+
type: "message_delta",
|
|
313
|
+
usage: { output_tokens: 5 },
|
|
314
|
+
})}\n\n`,
|
|
315
|
+
`event: message_stop\ndata: ${JSON.stringify({
|
|
316
|
+
type: "message_stop",
|
|
317
|
+
})}\n\n`,
|
|
318
|
+
];
|
|
319
|
+
|
|
320
|
+
fetchMock.mockResolvedValue(mockStreamingResponse(events));
|
|
321
|
+
|
|
322
|
+
const result = await anthropicChat(baseOptions);
|
|
323
|
+
expect(result.content).toEqual({ hello: "world" });
|
|
324
|
+
expect(result.usage).toEqual({
|
|
325
|
+
prompt_tokens: 10,
|
|
326
|
+
completion_tokens: 5,
|
|
327
|
+
total_tokens: 15,
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("retries on timeout then succeeds on second attempt", async () => {
|
|
332
|
+
const events = makeAnthropicSseEvents(JSON.stringify({ ok: true }));
|
|
333
|
+
|
|
334
|
+
fetchMock
|
|
335
|
+
.mockRejectedValueOnce(
|
|
336
|
+
new DOMException("signal timed out", "TimeoutError"),
|
|
337
|
+
)
|
|
338
|
+
.mockResolvedValueOnce(mockStreamingResponse(events));
|
|
339
|
+
|
|
340
|
+
const result = await anthropicChat({
|
|
341
|
+
...baseOptions,
|
|
342
|
+
maxRetries: 1,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
346
|
+
expect(result.content).toEqual({ ok: true });
|
|
347
|
+
});
|
|
348
|
+
});
|
|
259
349
|
});
|