@ryanfw/prompt-orchestration-pipeline 1.2.0 → 1.2.2

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.0",
3
+ "version": "1.2.2",
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",
@@ -514,10 +514,12 @@ describe("LLM Gateway", () => {
514
514
 
515
515
  it("dispatches to alibaba adapter without unknown-provider error", async () => {
516
516
  // Verify the callAdapter dispatch path resolves for provider "alibaba".
517
- // The call will fail (no real API key), but NOT with "Unknown provider".
518
- await expect(
519
- chat({ provider: "alibaba", messages: baseMessages }),
520
- ).rejects.not.toThrow(/unknown provider/i);
517
+ // The call may resolve or reject (no real API key), but NOT with "Unknown provider".
518
+ try {
519
+ await chat({ provider: "alibaba", messages: baseMessages });
520
+ } catch (err: unknown) {
521
+ expect(String(err)).not.toMatch(/unknown provider/i);
522
+ }
521
523
  });
522
524
  });
523
525
 
@@ -158,6 +158,29 @@ describe("alibabaChat", () => {
158
158
  expect(body.presence_penalty).toBe(0.2);
159
159
  });
160
160
 
161
+ it("passes an AbortSignal to fetch", async () => {
162
+ fetchMock.mockResolvedValue(
163
+ mockFetchResponse(makeAlibabaResponse(JSON.stringify({ ok: true }))),
164
+ );
165
+
166
+ await alibabaChat(baseOptions);
167
+
168
+ const init = (fetchMock.mock.calls[0] as [string, RequestInit])[1];
169
+ expect(init.signal).toBeInstanceOf(AbortSignal);
170
+ });
171
+
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
+
161
184
  it("sends enable_thinking true by default", async () => {
162
185
  fetchMock.mockResolvedValue(
163
186
  mockFetchResponse(makeAlibabaResponse(JSON.stringify({ ok: true }))),
@@ -224,6 +224,29 @@ describe("anthropicChat", () => {
224
224
  expect(body.stop_sequences).toEqual(["END", "STOP"]);
225
225
  });
226
226
 
227
+ it("passes an AbortSignal to fetch", async () => {
228
+ fetchMock.mockResolvedValue(
229
+ mockFetchResponse(makeAnthropicResponse(JSON.stringify({ ok: true }))),
230
+ );
231
+
232
+ await anthropicChat(baseOptions);
233
+
234
+ const init = (fetchMock.mock.calls[0] as [string, RequestInit])[1];
235
+ expect(init.signal).toBeInstanceOf(AbortSignal);
236
+ });
237
+
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
+
227
250
  it("handles markdown-fenced JSON responses", async () => {
228
251
  const fencedJson = '```json\n{"fenced": true}\n```';
229
252
  fetchMock.mockResolvedValue(
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  createProviderError,
4
+ DEFAULT_REQUEST_TIMEOUT_MS,
4
5
  ensureJsonResponseFormat,
5
6
  extractMessages,
6
7
  isRetryableError,
@@ -155,6 +156,18 @@ describe("isRetryableError", () => {
155
156
  });
156
157
  expect(isRetryableError(err)).toBe(false);
157
158
  });
159
+
160
+ it("returns true for TimeoutError (AbortSignal/fetch timeout)", () => {
161
+ const err = new DOMException("signal timed out", "TimeoutError");
162
+ expect(err.name).toBe("TimeoutError");
163
+ expect(isRetryableError(err)).toBe(true);
164
+ });
165
+ });
166
+
167
+ describe("DEFAULT_REQUEST_TIMEOUT_MS", () => {
168
+ it("is 120 000 ms", () => {
169
+ expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(120_000);
170
+ });
158
171
  });
159
172
 
160
173
  describe("sleep", () => {
@@ -234,6 +234,29 @@ describe("deepseekChat", () => {
234
234
  expect(body.frequency_penalty).toBe(0.5);
235
235
  expect(body.presence_penalty).toBe(0.2);
236
236
  });
237
+
238
+ it("passes an AbortSignal to fetch", async () => {
239
+ fetchMock.mockResolvedValue(
240
+ mockFetchResponse(makeDeepSeekResponse(JSON.stringify({ ok: true }))),
241
+ );
242
+
243
+ await deepseekChat(baseOptions);
244
+
245
+ const init = (fetchMock.mock.calls[0] as [string, RequestInit])[1];
246
+ expect(init.signal).toBeInstanceOf(AbortSignal);
247
+ });
248
+
249
+ it("uses custom requestTimeoutMs for the abort signal", async () => {
250
+ const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
251
+ fetchMock.mockResolvedValue(
252
+ mockFetchResponse(makeDeepSeekResponse(JSON.stringify({ ok: true }))),
253
+ );
254
+
255
+ await deepseekChat({ ...baseOptions, requestTimeoutMs: 5000 });
256
+
257
+ expect(timeoutSpy).toHaveBeenCalledWith(5000);
258
+ timeoutSpy.mockRestore();
259
+ });
237
260
  });
238
261
 
239
262
  describe("streaming", () => {
@@ -286,10 +309,21 @@ describe("deepseekChat", () => {
286
309
  );
287
310
 
288
311
  await expect(
289
- deepseekChat({ ...baseOptions, stream: true }),
312
+ deepseekChat({ ...baseOptions, stream: true, maxRetries: 0 }),
290
313
  ).rejects.toMatchObject({ status: 500, message: "Server error" });
291
314
  });
292
315
 
316
+ it("normalizes negative maxRetries to zero in streaming mode", async () => {
317
+ fetchMock.mockResolvedValue(
318
+ mockFetchResponse({ error: { message: "Server error" } }, 500),
319
+ );
320
+
321
+ await expect(
322
+ deepseekChat({ ...baseOptions, stream: true, maxRetries: -2 }),
323
+ ).rejects.toMatchObject({ status: 500, message: "Server error" });
324
+ expect(fetchMock).toHaveBeenCalledTimes(1);
325
+ });
326
+
293
327
  it("skips SSE lines that are comments or empty", async () => {
294
328
  const sseEvents = [
295
329
  ": this is a comment\n\n",
@@ -313,6 +347,45 @@ describe("deepseekChat", () => {
313
347
  expect(chunks).toEqual(["only"]);
314
348
  });
315
349
 
350
+ it("retries on timeout then succeeds on second attempt", async () => {
351
+ const sseEvents = [
352
+ 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n',
353
+ "data: [DONE]\n\n",
354
+ ];
355
+
356
+ fetchMock
357
+ .mockRejectedValueOnce(
358
+ new DOMException("signal timed out", "TimeoutError"),
359
+ )
360
+ .mockResolvedValueOnce(mockStreamingResponse(sseEvents));
361
+
362
+ const generator = await deepseekChat({
363
+ ...baseOptions,
364
+ stream: true,
365
+ maxRetries: 1,
366
+ });
367
+
368
+ const chunks: string[] = [];
369
+ for await (const chunk of generator) {
370
+ chunks.push(chunk.content);
371
+ }
372
+
373
+ expect(fetchMock).toHaveBeenCalledTimes(2);
374
+ expect(chunks).toEqual(["ok"]);
375
+ });
376
+
377
+ it("throws immediately on 401 without retrying", async () => {
378
+ fetchMock.mockResolvedValue(
379
+ mockFetchResponse({ error: { message: "Unauthorized" } }, 401),
380
+ );
381
+
382
+ await expect(
383
+ deepseekChat({ ...baseOptions, stream: true, maxRetries: 3 }),
384
+ ).rejects.toMatchObject({ status: 401, message: "Unauthorized" });
385
+
386
+ expect(fetchMock).toHaveBeenCalledTimes(1);
387
+ });
388
+
316
389
  it("skips chunks with no content in delta", async () => {
317
390
  const sseEvents = [
318
391
  'data: {"choices":[{"delta":{}}]}\n\n',
@@ -345,6 +345,29 @@ describe("geminiChat", () => {
345
345
  expect(body.generationConfig.presencePenalty).toBeUndefined();
346
346
  });
347
347
 
348
+ it("passes an AbortSignal to fetch", async () => {
349
+ fetchMock.mockResolvedValue(
350
+ mockFetchResponse(makeGeminiResponse(JSON.stringify({ ok: true }))),
351
+ );
352
+
353
+ await geminiChat(baseOptions);
354
+
355
+ const init = (fetchMock.mock.calls[0] as [string, RequestInit])[1];
356
+ expect(init.signal).toBeInstanceOf(AbortSignal);
357
+ });
358
+
359
+ it("uses custom requestTimeoutMs for the abort signal", async () => {
360
+ const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
361
+ fetchMock.mockResolvedValue(
362
+ mockFetchResponse(makeGeminiResponse(JSON.stringify({ ok: true }))),
363
+ );
364
+
365
+ await geminiChat({ ...baseOptions, requestTimeoutMs: 5000 });
366
+
367
+ expect(timeoutSpy).toHaveBeenCalledWith(5000);
368
+ timeoutSpy.mockRestore();
369
+ });
370
+
348
371
  it("handles missing usageMetadata by defaulting to zeros", async () => {
349
372
  const jsonPayload = { ok: true };
350
373
  const responseWithoutUsage = {
@@ -360,4 +360,27 @@ describe("moonshotChat", () => {
360
360
  const result = await moonshotChat(baseOptions);
361
361
  expect(result.content).toEqual({ fenced: true });
362
362
  });
363
+
364
+ it("passes an AbortSignal to fetch", async () => {
365
+ fetchMock.mockResolvedValue(
366
+ mockFetchResponse(makeMoonshotResponse(JSON.stringify({ ok: true }))),
367
+ );
368
+
369
+ await moonshotChat(baseOptions);
370
+
371
+ const init = (fetchMock.mock.calls[0] as [string, RequestInit])[1];
372
+ expect(init.signal).toBeInstanceOf(AbortSignal);
373
+ });
374
+
375
+ it("uses custom requestTimeoutMs for the abort signal", async () => {
376
+ const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
377
+ fetchMock.mockResolvedValue(
378
+ mockFetchResponse(makeMoonshotResponse(JSON.stringify({ ok: true }))),
379
+ );
380
+
381
+ await moonshotChat({ ...baseOptions, requestTimeoutMs: 5000 });
382
+
383
+ expect(timeoutSpy).toHaveBeenCalledWith(5000);
384
+ timeoutSpy.mockRestore();
385
+ });
363
386
  });
@@ -297,9 +297,42 @@ describe("openaiChat", () => {
297
297
  organization: "org_test",
298
298
  baseURL: "https://example.test/v1",
299
299
  maxRetries: 0,
300
+ timeout: 120_000,
300
301
  });
301
302
  });
302
303
 
304
+ it("constructs the client with custom requestTimeoutMs", async () => {
305
+ mockChatCompletionsCreate.mockResolvedValue(
306
+ makeChatCompletion(JSON.stringify({ ok: true })),
307
+ );
308
+
309
+ await openaiChat({
310
+ ...baseOptions,
311
+ model: "gpt-4o",
312
+ requestTimeoutMs: 30_000,
313
+ });
314
+
315
+ expect(MockOpenAI).toHaveBeenCalledWith(
316
+ expect.objectContaining({ timeout: 30_000 }),
317
+ );
318
+ });
319
+
320
+ it("creates separate client instances for different timeout values", async () => {
321
+ mockChatCompletionsCreate.mockResolvedValue(
322
+ makeChatCompletion(JSON.stringify({ ok: true })),
323
+ );
324
+
325
+ await openaiChat({ ...baseOptions, model: "gpt-4o", requestTimeoutMs: 10_000 });
326
+ await openaiChat({ ...baseOptions, model: "gpt-4o", requestTimeoutMs: 60_000 });
327
+
328
+ // Two distinct timeout values should produce two client constructions
329
+ const timeouts = MockOpenAI.mock.calls.map(
330
+ (call: unknown[]) => (call[0] as { timeout: number }).timeout,
331
+ );
332
+ expect(timeouts).toContain(10_000);
333
+ expect(timeouts).toContain(60_000);
334
+ });
335
+
303
336
  it("passes json_schema to the Responses API when provided", async () => {
304
337
  mockResponsesCreate.mockResolvedValue(
305
338
  makeResponsesResult(JSON.stringify({ ok: true })),
@@ -365,6 +365,29 @@ describe("zhipuChat", () => {
365
365
  expect(body.stop).toBe("DONE");
366
366
  });
367
367
 
368
+ it("passes an AbortSignal to fetch", async () => {
369
+ fetchMock.mockResolvedValue(
370
+ mockFetchResponse(makeZhipuResponse(JSON.stringify({ ok: true }))),
371
+ );
372
+
373
+ await zhipuChat(baseOptions);
374
+
375
+ const init = (fetchMock.mock.calls[0] as [string, RequestInit])[1];
376
+ expect(init.signal).toBeInstanceOf(AbortSignal);
377
+ });
378
+
379
+ it("uses custom requestTimeoutMs for the abort signal", async () => {
380
+ const timeoutSpy = vi.spyOn(AbortSignal, "timeout");
381
+ fetchMock.mockResolvedValue(
382
+ mockFetchResponse(makeZhipuResponse(JSON.stringify({ ok: true }))),
383
+ );
384
+
385
+ await zhipuChat({ ...baseOptions, requestTimeoutMs: 5000 });
386
+
387
+ expect(timeoutSpy).toHaveBeenCalledWith(5000);
388
+ timeoutSpy.mockRestore();
389
+ });
390
+
368
391
  it("exports zaiChat as the canonical adapter", () => {
369
392
  expect(zaiChat).toBe(zhipuChat);
370
393
  });
@@ -2,6 +2,7 @@
2
2
  // Alibaba (DashScope) adapter using OpenAI-compatible chat completions API.
3
3
 
4
4
  import {
5
+ DEFAULT_REQUEST_TIMEOUT_MS,
5
6
  extractMessages,
6
7
  ensureMessagesPresent,
7
8
  ensureJsonResponseFormat,
@@ -52,6 +53,7 @@ export async function alibabaChat(
52
53
  topP,
53
54
  stop,
54
55
  maxRetries = DEFAULT_MAX_RETRIES,
56
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
55
57
  frequencyPenalty,
56
58
  presencePenalty,
57
59
  thinking = DEFAULT_THINKING,
@@ -107,6 +109,7 @@ export async function alibabaChat(
107
109
 
108
110
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
109
111
  try {
112
+ const signal = AbortSignal.timeout(requestTimeoutMs);
110
113
  const response = await fetch(endpoint, {
111
114
  method: "POST",
112
115
  headers: {
@@ -114,6 +117,7 @@ export async function alibabaChat(
114
117
  Authorization: `Bearer ${apiKey ?? ""}`,
115
118
  },
116
119
  body: JSON.stringify(body),
120
+ signal,
117
121
  });
118
122
 
119
123
  if (!response.ok) {
@@ -2,6 +2,7 @@
2
2
  // Anthropic Messages API adapter.
3
3
 
4
4
  import {
5
+ DEFAULT_REQUEST_TIMEOUT_MS,
5
6
  extractMessages,
6
7
  isRetryableError,
7
8
  sleep,
@@ -33,6 +34,7 @@ export async function anthropicChat(
33
34
  topP,
34
35
  stop,
35
36
  maxRetries = DEFAULT_MAX_RETRIES,
37
+ requestTimeoutMs,
36
38
  } = options;
37
39
 
38
40
  // Validate JSON response format — if this throws, we are not in JSON mode
@@ -73,6 +75,7 @@ export async function anthropicChat(
73
75
 
74
76
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
75
77
  try {
78
+ const signal = AbortSignal.timeout(requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
76
79
  const response = await fetch(ANTHROPIC_API_URL, {
77
80
  method: "POST",
78
81
  headers: {
@@ -81,6 +84,7 @@ export async function anthropicChat(
81
84
  "x-api-key": apiKey ?? "",
82
85
  },
83
86
  body: JSON.stringify(body),
87
+ signal,
84
88
  });
85
89
 
86
90
  if (!response.ok) {
@@ -4,6 +4,8 @@
4
4
  import { ProviderJsonModeError, ProviderMessagesError } from "./types.ts";
5
5
  import type { ChatMessage, ExtractedMessages, ProviderError } from "./types.ts";
6
6
 
7
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
8
+
7
9
  const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
8
10
  const RETRYABLE_ERROR_CODES = new Set([
9
11
  "ECONNRESET",
@@ -66,6 +68,9 @@ export function isRetryableError(err: unknown): boolean {
66
68
  // ProviderJsonParseError is never retryable
67
69
  if (err.name === "ProviderJsonParseError") return false;
68
70
 
71
+ // Fetch/AbortSignal timeout errors are always retryable
72
+ if (err.name === "TimeoutError") return true;
73
+
69
74
  // Check HTTP status codes
70
75
  const status = (err as { status?: number }).status;
71
76
  if (typeof status === "number") {
@@ -2,6 +2,7 @@
2
2
  // DeepSeek adapter with streaming support via async generator.
3
3
 
4
4
  import {
5
+ DEFAULT_REQUEST_TIMEOUT_MS,
5
6
  extractMessages,
6
7
  isRetryableError,
7
8
  sleep,
@@ -122,10 +123,14 @@ export async function deepseekChat(
122
123
  topP,
123
124
  stop,
124
125
  maxRetries = DEFAULT_MAX_RETRIES,
126
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
125
127
  frequencyPenalty,
126
128
  presencePenalty,
127
129
  stream = false,
128
130
  } = options;
131
+ const retryLimit = Number.isFinite(maxRetries)
132
+ ? Math.max(0, Math.trunc(maxRetries))
133
+ : DEFAULT_MAX_RETRIES;
129
134
 
130
135
  const jsonMode = !stream && isJsonMode(responseFormat);
131
136
 
@@ -169,43 +174,71 @@ export async function deepseekChat(
169
174
  body["response_format"] = responseFormatPayload;
170
175
  }
171
176
 
172
- // Streaming mode: single fetch, return async generator
177
+ // Streaming mode: retry loop around the initial HTTP request
173
178
  if (stream) {
174
- const response = await fetch(DEEPSEEK_API_URL, {
175
- method: "POST",
176
- headers: {
177
- "Content-Type": "application/json",
178
- Authorization: `Bearer ${apiKey ?? ""}`,
179
- },
180
- body: JSON.stringify(body),
181
- });
182
-
183
- if (!response.ok) {
184
- let errorBody: unknown;
179
+ let lastStreamError: unknown;
180
+
181
+ for (let attempt = 0; attempt <= retryLimit; attempt++) {
185
182
  try {
186
- errorBody = await response.json();
187
- } catch {
188
- errorBody = await response.text();
189
- }
190
- throw createProviderError(
191
- response.status,
192
- errorBody,
193
- `DeepSeek API error: ${response.status}`,
194
- );
195
- }
183
+ const signal = AbortSignal.timeout(requestTimeoutMs);
184
+ const response = await fetch(DEEPSEEK_API_URL, {
185
+ method: "POST",
186
+ headers: {
187
+ "Content-Type": "application/json",
188
+ Authorization: `Bearer ${apiKey ?? ""}`,
189
+ },
190
+ body: JSON.stringify(body),
191
+ signal,
192
+ });
193
+
194
+ if (!response.ok) {
195
+ let errorBody: unknown;
196
+ try {
197
+ errorBody = await response.json();
198
+ } catch {
199
+ errorBody = await response.text();
200
+ }
201
+
202
+ const err = createProviderError(
203
+ response.status,
204
+ errorBody,
205
+ `DeepSeek API error: ${response.status}`,
206
+ );
196
207
 
197
- if (!response.body) {
198
- throw new Error("DeepSeek streaming response has no body");
208
+ // 401 is never retried
209
+ if (response.status === 401) {
210
+ throw err;
211
+ }
212
+
213
+ throw err;
214
+ }
215
+
216
+ if (!response.body) {
217
+ throw new Error("DeepSeek streaming response has no body");
218
+ }
219
+
220
+ return parseSSEStream(response.body);
221
+ } catch (err) {
222
+ lastStreamError = err;
223
+
224
+ if (!isRetryableError(err) || attempt >= retryLimit) {
225
+ throw err;
226
+ }
227
+
228
+ // Exponential backoff: 2^attempt * 1000ms
229
+ await sleep(Math.pow(2, attempt) * 1000);
230
+ }
199
231
  }
200
232
 
201
- return parseSSEStream(response.body);
233
+ throw lastStreamError;
202
234
  }
203
235
 
204
236
  // Non-streaming mode: retry loop
205
237
  let lastError: unknown;
206
238
 
207
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
239
+ for (let attempt = 0; attempt <= retryLimit; attempt++) {
208
240
  try {
241
+ const signal = AbortSignal.timeout(requestTimeoutMs);
209
242
  const response = await fetch(DEEPSEEK_API_URL, {
210
243
  method: "POST",
211
244
  headers: {
@@ -213,6 +246,7 @@ export async function deepseekChat(
213
246
  Authorization: `Bearer ${apiKey ?? ""}`,
214
247
  },
215
248
  body: JSON.stringify(body),
249
+ signal,
216
250
  });
217
251
 
218
252
  if (!response.ok) {
@@ -278,7 +312,7 @@ export async function deepseekChat(
278
312
  } catch (err) {
279
313
  lastError = err;
280
314
 
281
- if (!isRetryableError(err) || attempt >= maxRetries) {
315
+ if (!isRetryableError(err) || attempt >= retryLimit) {
282
316
  throw err;
283
317
  }
284
318
 
@@ -2,6 +2,7 @@
2
2
  // Google Gemini GenerateContent adapter.
3
3
 
4
4
  import {
5
+ DEFAULT_REQUEST_TIMEOUT_MS,
5
6
  extractMessages,
6
7
  isRetryableError,
7
8
  sleep,
@@ -76,6 +77,7 @@ export async function geminiChat(
76
77
  topP,
77
78
  stop,
78
79
  maxRetries = DEFAULT_MAX_RETRIES,
80
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
79
81
  // Gemini does not support these — destructure and discard
80
82
  frequencyPenalty: _frequencyPenalty,
81
83
  presencePenalty: _presencePenalty,
@@ -144,10 +146,12 @@ export async function geminiChat(
144
146
 
145
147
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
146
148
  try {
149
+ const signal = AbortSignal.timeout(requestTimeoutMs);
147
150
  const response = await fetch(url, {
148
151
  method: "POST",
149
152
  headers: { "Content-Type": "application/json" },
150
153
  body: JSON.stringify(body),
154
+ signal,
151
155
  });
152
156
 
153
157
  if (!response.ok) {
@@ -2,6 +2,7 @@
2
2
  // Moonshot adapter with content-filter fallback to DeepSeek.
3
3
 
4
4
  import {
5
+ DEFAULT_REQUEST_TIMEOUT_MS,
5
6
  extractMessages,
6
7
  ensureMessagesPresent,
7
8
  isRetryableError,
@@ -40,6 +41,7 @@ export async function moonshotChat(
40
41
  thinking = DEFAULT_THINKING,
41
42
  maxRetries = DEFAULT_MAX_RETRIES,
42
43
  responseFormat,
44
+ requestTimeoutMs,
43
45
  } = options;
44
46
 
45
47
  ensureMessagesPresent(messages, "moonshot");
@@ -79,6 +81,7 @@ export async function moonshotChat(
79
81
 
80
82
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
81
83
  try {
84
+ const signal = AbortSignal.timeout(requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
82
85
  const response = await fetch(MOONSHOT_API_URL, {
83
86
  method: "POST",
84
87
  headers: {
@@ -86,6 +89,7 @@ export async function moonshotChat(
86
89
  Authorization: `Bearer ${apiKey ?? ""}`,
87
90
  },
88
91
  body: JSON.stringify(body),
92
+ signal,
89
93
  });
90
94
 
91
95
  if (!response.ok) {