@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 +1 -1
- package/src/llm/__tests__/index.test.ts +6 -4
- package/src/providers/__tests__/alibaba.test.ts +23 -0
- package/src/providers/__tests__/anthropic.test.ts +23 -0
- package/src/providers/__tests__/base.test.ts +13 -0
- package/src/providers/__tests__/deepseek.test.ts +74 -1
- package/src/providers/__tests__/gemini.test.ts +23 -0
- package/src/providers/__tests__/moonshot.test.ts +23 -0
- package/src/providers/__tests__/openai.test.ts +33 -0
- package/src/providers/__tests__/zhipu.test.ts +23 -0
- package/src/providers/alibaba.ts +4 -0
- package/src/providers/anthropic.ts +4 -0
- package/src/providers/base.ts +5 -0
- package/src/providers/deepseek.ts +61 -27
- package/src/providers/gemini.ts +4 -0
- package/src/providers/moonshot.ts +4 -0
- package/src/providers/openai.ts +48 -13
- package/src/providers/types.ts +2 -0
- package/src/providers/zhipu.ts +4 -0
- package/src/ui/client/__tests__/job-adapter.test.ts +20 -0
- package/src/ui/client/adapters/job-adapter.ts +5 -1
- package/src/ui/components/ui/RestartJobModal.tsx +6 -6
- package/src/ui/components/ui/__tests__/RestartJobModal.test.tsx +49 -0
- package/src/ui/dist/assets/{index-CkBEIVbA.js → index-CItKJVeE.js} +9 -7
- package/src/ui/dist/assets/{index-CkBEIVbA.js.map → index-CItKJVeE.js.map} +1 -1
- package/src/ui/dist/index.html +1 -1
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/endpoints/job-endpoints.ts +21 -4
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.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
|
|
518
|
-
|
|
519
|
-
chat({ provider: "alibaba", messages: baseMessages })
|
|
520
|
-
|
|
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
|
});
|
package/src/providers/alibaba.ts
CHANGED
|
@@ -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) {
|
package/src/providers/base.ts
CHANGED
|
@@ -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:
|
|
177
|
+
// Streaming mode: retry loop around the initial HTTP request
|
|
173
178
|
if (stream) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 <=
|
|
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 >=
|
|
315
|
+
if (!isRetryableError(err) || attempt >= retryLimit) {
|
|
282
316
|
throw err;
|
|
283
317
|
}
|
|
284
318
|
|
package/src/providers/gemini.ts
CHANGED
|
@@ -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) {
|