@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.2.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",
@@ -94,7 +94,7 @@ export const defaultConfig = {
94
94
  taskRunner: {
95
95
  maxRefinementAttempts: 3,
96
96
  stageTimeout: 300000,
97
- llmRequestTimeout: 120000,
97
+ llmRequestTimeout: 3600000,
98
98
  },
99
99
  llm: {
100
100
  defaultProvider: "openai",
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 opts = inferJsonFormat(options);
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
- function makeAlibabaResponse(
8
- content: string,
9
- promptTokens = 10,
10
- completionTokens = 20,
11
- ) {
12
- return {
13
- choices: [{ message: { content } }],
14
- usage: {
15
- prompt_tokens: promptTokens,
16
- completion_tokens: completionTokens,
17
- total_tokens: promptTokens + completionTokens,
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 mockFetchResponse(body: unknown, status = 200) {
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
- fetchMock.mockResolvedValue(
60
- mockFetchResponse(
61
- makeAlibabaResponse(JSON.stringify(jsonPayload), 15, 25),
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
- expect(result.raw).toBeDefined();
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
- fetchMock.mockResolvedValue(
79
- mockFetchResponse(makeAlibabaResponse(nonJsonText)),
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
- mockFetchResponse({ error: { message: "Server error" } }, 500),
151
+ mockErrorResponse({ error: { message: "Server error" } }, 500),
99
152
  )
100
153
  .mockResolvedValueOnce(
101
- mockFetchResponse({ error: { message: "Server error" } }, 500),
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
- mockFetchResponse({ error: { message: "Unauthorized" } }, 401),
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 jsonPayload = { ok: true };
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 jsonPayload = { ok: true };
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
- fetchMock.mockResolvedValue(
163
- mockFetchResponse(makeAlibabaResponse(JSON.stringify({ ok: true }))),
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
- fetchMock.mockResolvedValue(
186
- mockFetchResponse(makeAlibabaResponse(JSON.stringify({ ok: true }))),
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
- fetchMock.mockResolvedValue(
199
- mockFetchResponse(makeAlibabaResponse(JSON.stringify({ ok: true }))),
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
- function makeAnthropicResponse(
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
- content: [{ type: "text", text }],
14
- usage: { input_tokens: inputTokens, output_tokens: outputTokens },
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 mockFetchResponse(body: unknown, status = 200) {
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
- fetchMock.mockResolvedValue(
55
- mockFetchResponse(makeAnthropicResponse(JSON.stringify(jsonPayload), 15, 25)),
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
- mockFetchResponse({ error: { message: "Unauthorized" } }, 401),
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
- mockFetchResponse({ error: { message: "Rate limited" } }, 429),
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
- fetchMock.mockResolvedValue(
114
- mockFetchResponse(makeAnthropicResponse(JSON.stringify(jsonPayload))),
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
- fetchMock.mockResolvedValue(
127
- mockFetchResponse(makeAnthropicResponse(nonJsonText)),
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 jsonPayload = { ok: true };
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("constructs the request body with system and messages in conversation order", async () => {
159
- const jsonPayload = { ok: true };
160
- fetchMock.mockResolvedValue(
161
- mockFetchResponse(makeAnthropicResponse(JSON.stringify(jsonPayload))),
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 jsonPayload = { custom: true };
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
- fetchMock.mockResolvedValue(
211
- mockFetchResponse(makeAnthropicResponse(JSON.stringify({ ok: true }))),
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
- fetchMock.mockResolvedValue(
229
- mockFetchResponse(makeAnthropicResponse(JSON.stringify({ ok: true }))),
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
- fetchMock.mockResolvedValue(
253
- mockFetchResponse(makeAnthropicResponse(fencedJson)),
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
  });