@ryanfw/prompt-orchestration-pipeline 1.0.5 → 1.1.0
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/config/__tests__/models.test.ts +5 -5
- package/src/config/models.ts +54 -1
- package/src/core/logger.ts +15 -6
- package/src/llm/__tests__/index.test.ts +12 -0
- package/src/llm/index.ts +9 -0
- package/src/providers/__tests__/alibaba.test.ts +186 -0
- package/src/providers/alibaba.ts +193 -0
- package/src/providers/types.ts +9 -0
- package/src/ui/server/endpoints/job-control-endpoints.ts +109 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryanfw/prompt-orchestration-pipeline",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from "
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
ModelAlias,
|
|
4
4
|
MODEL_CONFIG,
|
|
@@ -15,8 +15,8 @@ import {
|
|
|
15
15
|
} from "../models";
|
|
16
16
|
import type { ModelConfigEntry } from "../models";
|
|
17
17
|
|
|
18
|
-
const MODEL_COUNT =
|
|
19
|
-
const PROVIDER_COUNT =
|
|
18
|
+
const MODEL_COUNT = 42;
|
|
19
|
+
const PROVIDER_COUNT = 8;
|
|
20
20
|
|
|
21
21
|
describe("ModelAlias", () => {
|
|
22
22
|
it(`has exactly ${MODEL_COUNT} entries`, () => {
|
|
@@ -91,7 +91,7 @@ describe("VALID_MODEL_ALIASES", () => {
|
|
|
91
91
|
|
|
92
92
|
describe("DEFAULT_MODEL_BY_PROVIDER", () => {
|
|
93
93
|
it(`has entries for all ${PROVIDER_COUNT} providers`, () => {
|
|
94
|
-
const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai"];
|
|
94
|
+
const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba"];
|
|
95
95
|
expect(Object.keys(DEFAULT_MODEL_BY_PROVIDER).length).toBe(PROVIDER_COUNT);
|
|
96
96
|
for (const provider of providers) {
|
|
97
97
|
expect(provider in DEFAULT_MODEL_BY_PROVIDER).toBe(true);
|
|
@@ -203,7 +203,7 @@ describe("FUNCTION_NAME_BY_ALIAS", () => {
|
|
|
203
203
|
|
|
204
204
|
describe("PROVIDER_FUNCTIONS", () => {
|
|
205
205
|
it(`has entries for all ${PROVIDER_COUNT} providers`, () => {
|
|
206
|
-
const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai"];
|
|
206
|
+
const providers = ["openai", "anthropic", "gemini", "deepseek", "moonshot", "claude-code", "zai", "alibaba"];
|
|
207
207
|
for (const provider of providers) {
|
|
208
208
|
expect(provider in PROVIDER_FUNCTIONS).toBe(true);
|
|
209
209
|
}
|
package/src/config/models.ts
CHANGED
|
@@ -5,7 +5,8 @@ export type ProviderName =
|
|
|
5
5
|
| "deepseek"
|
|
6
6
|
| "moonshot"
|
|
7
7
|
| "claude-code"
|
|
8
|
-
| "zai"
|
|
8
|
+
| "zai"
|
|
9
|
+
| "alibaba";
|
|
9
10
|
|
|
10
11
|
export interface ModelConfigEntry {
|
|
11
12
|
readonly provider: ProviderName;
|
|
@@ -79,6 +80,14 @@ export const ModelAlias = Object.freeze({
|
|
|
79
80
|
ZAI_GLM_4_LONG: "zai:glm-4-long",
|
|
80
81
|
ZAI_GLM_Z1_FLASH: "zai:glm-z1-flash",
|
|
81
82
|
ZAI_GLM_Z1_AIR: "zai:glm-z1-air",
|
|
83
|
+
// Alibaba (Qwen via DashScope)
|
|
84
|
+
ALIBABA_QWEN3_MAX: "alibaba:qwen3-max",
|
|
85
|
+
ALIBABA_QWEN3_5_PLUS: "alibaba:qwen3.5-plus",
|
|
86
|
+
ALIBABA_QWEN_PLUS: "alibaba:qwen-plus",
|
|
87
|
+
ALIBABA_QWEN_FLASH: "alibaba:qwen-flash",
|
|
88
|
+
ALIBABA_QWQ_PLUS: "alibaba:qwq-plus",
|
|
89
|
+
ALIBABA_QWEN3_CODER_PLUS: "alibaba:qwen3-coder-plus",
|
|
90
|
+
ALIBABA_QWEN3_CODER_FLASH: "alibaba:qwen3-coder-flash",
|
|
82
91
|
} as const);
|
|
83
92
|
|
|
84
93
|
export type ModelAliasKey = (typeof ModelAlias)[keyof typeof ModelAlias];
|
|
@@ -304,6 +313,49 @@ const MODEL_CONFIG_RAW: Record<ModelAliasKey, ModelConfigEntry> = {
|
|
|
304
313
|
tokenCostInPerMillion: 0.1,
|
|
305
314
|
tokenCostOutPerMillion: 0.4,
|
|
306
315
|
},
|
|
316
|
+
// Alibaba (Qwen via DashScope)
|
|
317
|
+
"alibaba:qwen3-max": {
|
|
318
|
+
provider: "alibaba",
|
|
319
|
+
model: "qwen3-max",
|
|
320
|
+
tokenCostInPerMillion: 0.359,
|
|
321
|
+
tokenCostOutPerMillion: 1.434,
|
|
322
|
+
},
|
|
323
|
+
"alibaba:qwen3.5-plus": {
|
|
324
|
+
provider: "alibaba",
|
|
325
|
+
model: "qwen3.5-plus",
|
|
326
|
+
tokenCostInPerMillion: 0.115,
|
|
327
|
+
tokenCostOutPerMillion: 0.688,
|
|
328
|
+
},
|
|
329
|
+
"alibaba:qwen-plus": {
|
|
330
|
+
provider: "alibaba",
|
|
331
|
+
model: "qwen-plus",
|
|
332
|
+
tokenCostInPerMillion: 0.115,
|
|
333
|
+
tokenCostOutPerMillion: 0.287,
|
|
334
|
+
},
|
|
335
|
+
"alibaba:qwen-flash": {
|
|
336
|
+
provider: "alibaba",
|
|
337
|
+
model: "qwen-flash",
|
|
338
|
+
tokenCostInPerMillion: 0.022,
|
|
339
|
+
tokenCostOutPerMillion: 0.216,
|
|
340
|
+
},
|
|
341
|
+
"alibaba:qwq-plus": {
|
|
342
|
+
provider: "alibaba",
|
|
343
|
+
model: "qwq-plus",
|
|
344
|
+
tokenCostInPerMillion: 0.8,
|
|
345
|
+
tokenCostOutPerMillion: 2.4,
|
|
346
|
+
},
|
|
347
|
+
"alibaba:qwen3-coder-plus": {
|
|
348
|
+
provider: "alibaba",
|
|
349
|
+
model: "qwen3-coder-plus",
|
|
350
|
+
tokenCostInPerMillion: 0.115, // nearest-family qwen-plus
|
|
351
|
+
tokenCostOutPerMillion: 0.287, // nearest-family qwen-plus
|
|
352
|
+
},
|
|
353
|
+
"alibaba:qwen3-coder-flash": {
|
|
354
|
+
provider: "alibaba",
|
|
355
|
+
model: "qwen3-coder-flash",
|
|
356
|
+
tokenCostInPerMillion: 0.022, // nearest-family qwen-flash
|
|
357
|
+
tokenCostOutPerMillion: 0.216, // nearest-family qwen-flash
|
|
358
|
+
},
|
|
307
359
|
};
|
|
308
360
|
|
|
309
361
|
export const MODEL_CONFIG: Readonly<Record<ModelAliasKey, ModelConfigEntry>> =
|
|
@@ -361,6 +413,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Readonly<Record<ProviderName, ModelAlias
|
|
|
361
413
|
moonshot: "moonshot:kimi-k2.5",
|
|
362
414
|
"claude-code": "claude-code:sonnet",
|
|
363
415
|
zai: "zai:glm-4-plus",
|
|
416
|
+
alibaba: "alibaba:qwen3-max",
|
|
364
417
|
} as const);
|
|
365
418
|
|
|
366
419
|
// ─── Function Name Derived Index ─────────────────────────────────────────────
|
package/src/core/logger.ts
CHANGED
|
@@ -66,11 +66,20 @@ export function createLogger(componentName: string, context?: LogContext): Logge
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function stringify(data: unknown): string {
|
|
70
|
+
if (typeof data === "string") return data;
|
|
71
|
+
try {
|
|
72
|
+
return JSON.stringify(data);
|
|
73
|
+
} catch {
|
|
74
|
+
return String(data);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
return {
|
|
70
79
|
debug(message, data) {
|
|
71
80
|
if (process.env["NODE_ENV"] === "production" && !process.env["DEBUG"]) return;
|
|
72
81
|
if (data !== undefined) {
|
|
73
|
-
console.debug(prefix, message, data);
|
|
82
|
+
console.debug(prefix, message, stringify(data));
|
|
74
83
|
} else {
|
|
75
84
|
console.debug(prefix, message);
|
|
76
85
|
}
|
|
@@ -78,7 +87,7 @@ export function createLogger(componentName: string, context?: LogContext): Logge
|
|
|
78
87
|
|
|
79
88
|
log(message, data) {
|
|
80
89
|
if (data !== undefined) {
|
|
81
|
-
console.log(prefix, message, data);
|
|
90
|
+
console.log(prefix, message, stringify(data));
|
|
82
91
|
} else {
|
|
83
92
|
console.log(prefix, message);
|
|
84
93
|
}
|
|
@@ -86,7 +95,7 @@ export function createLogger(componentName: string, context?: LogContext): Logge
|
|
|
86
95
|
|
|
87
96
|
warn(message, data) {
|
|
88
97
|
if (data !== undefined) {
|
|
89
|
-
console.warn(prefix, message, data);
|
|
98
|
+
console.warn(prefix, message, stringify(data));
|
|
90
99
|
} else {
|
|
91
100
|
console.warn(prefix, message);
|
|
92
101
|
}
|
|
@@ -95,7 +104,7 @@ export function createLogger(componentName: string, context?: LogContext): Logge
|
|
|
95
104
|
error(message, data) {
|
|
96
105
|
const enriched = data !== undefined ? formatData(data) : undefined;
|
|
97
106
|
if (enriched !== undefined) {
|
|
98
|
-
console.error(prefix, message, enriched);
|
|
107
|
+
console.error(prefix, message, stringify(enriched));
|
|
99
108
|
} else {
|
|
100
109
|
console.error(prefix, message);
|
|
101
110
|
}
|
|
@@ -103,7 +112,7 @@ export function createLogger(componentName: string, context?: LogContext): Logge
|
|
|
103
112
|
|
|
104
113
|
group(label, data) {
|
|
105
114
|
if (data !== undefined) {
|
|
106
|
-
console.group(prefix, label, data);
|
|
115
|
+
console.group(prefix, label, stringify(data));
|
|
107
116
|
} else {
|
|
108
117
|
console.group(prefix, label);
|
|
109
118
|
}
|
|
@@ -114,7 +123,7 @@ export function createLogger(componentName: string, context?: LogContext): Logge
|
|
|
114
123
|
},
|
|
115
124
|
|
|
116
125
|
sse(eventType, eventData) {
|
|
117
|
-
console.log(prefix, `[SSE:${eventType}]`, eventData);
|
|
126
|
+
console.log(prefix, `[SSE:${eventType}]`, stringify(eventData));
|
|
118
127
|
void getSSERegistry().then((registry) => {
|
|
119
128
|
if (!registry) return;
|
|
120
129
|
try {
|
|
@@ -477,6 +477,7 @@ describe("LLM Gateway", () => {
|
|
|
477
477
|
delete process.env["ZAI_API_KEY"];
|
|
478
478
|
delete process.env["ZHIPU_API_KEY"];
|
|
479
479
|
delete process.env["MOONSHOT_API_KEY"];
|
|
480
|
+
delete process.env["ALIBABA_API_KEY"];
|
|
480
481
|
|
|
481
482
|
const availability = getAvailableProviders();
|
|
482
483
|
|
|
@@ -487,12 +488,14 @@ describe("LLM Gateway", () => {
|
|
|
487
488
|
expect(availability.zai).toBe(false);
|
|
488
489
|
expect(availability.zhipu).toBe(false);
|
|
489
490
|
expect(availability.moonshot).toBe(false);
|
|
491
|
+
expect(availability.alibaba).toBe(false);
|
|
490
492
|
});
|
|
491
493
|
|
|
492
494
|
it("returns true for providers with API keys set", () => {
|
|
493
495
|
process.env["OPENAI_API_KEY"] = "test";
|
|
494
496
|
process.env["ANTHROPIC_API_KEY"] = "test";
|
|
495
497
|
process.env["ZAI_API_KEY"] = "test";
|
|
498
|
+
process.env["ALIBABA_API_KEY"] = "test";
|
|
496
499
|
|
|
497
500
|
const availability = getAvailableProviders();
|
|
498
501
|
|
|
@@ -500,6 +503,7 @@ describe("LLM Gateway", () => {
|
|
|
500
503
|
expect(availability.anthropic).toBe(true);
|
|
501
504
|
expect(availability.zai).toBe(true);
|
|
502
505
|
expect(availability.zhipu).toBe(true);
|
|
506
|
+
expect(availability.alibaba).toBe(true);
|
|
503
507
|
});
|
|
504
508
|
|
|
505
509
|
it("reports mock as available when provider is registered", () => {
|
|
@@ -507,6 +511,14 @@ describe("LLM Gateway", () => {
|
|
|
507
511
|
const availability = getAvailableProviders();
|
|
508
512
|
expect(availability.mock).toBe(true);
|
|
509
513
|
});
|
|
514
|
+
|
|
515
|
+
it("dispatches to alibaba adapter without unknown-provider error", async () => {
|
|
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);
|
|
521
|
+
});
|
|
510
522
|
});
|
|
511
523
|
|
|
512
524
|
// ── JSON Format Inference ──────────────────────────────────────────────
|
package/src/llm/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { anthropicChat } from "../providers/anthropic.ts";
|
|
|
8
8
|
import { openaiChat } from "../providers/openai.ts";
|
|
9
9
|
import { geminiChat } from "../providers/gemini.ts";
|
|
10
10
|
import { deepseekChat } from "../providers/deepseek.ts";
|
|
11
|
+
import { alibabaChat } from "../providers/alibaba.ts";
|
|
11
12
|
import { moonshotChat } from "../providers/moonshot.ts";
|
|
12
13
|
import { zaiChat } from "../providers/zhipu.ts";
|
|
13
14
|
import { claudeCodeChat, isClaudeCodeAvailable } from "../providers/claude-code.ts";
|
|
@@ -68,6 +69,7 @@ const JSON_INFER_PROVIDERS = new Set<ProviderName>([
|
|
|
68
69
|
"deepseek",
|
|
69
70
|
"gemini",
|
|
70
71
|
"moonshot",
|
|
72
|
+
"alibaba",
|
|
71
73
|
]);
|
|
72
74
|
|
|
73
75
|
function inferJsonFormat(options: ChatOptions): ChatOptions {
|
|
@@ -93,6 +95,12 @@ async function callAdapter(
|
|
|
93
95
|
const { provider, messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries } = options;
|
|
94
96
|
|
|
95
97
|
switch (provider) {
|
|
98
|
+
case "alibaba":
|
|
99
|
+
return alibabaChat({
|
|
100
|
+
messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries,
|
|
101
|
+
frequencyPenalty: options.frequencyPenalty,
|
|
102
|
+
presencePenalty: options.presencePenalty,
|
|
103
|
+
});
|
|
96
104
|
case "anthropic":
|
|
97
105
|
return anthropicChat({ messages, model, temperature, maxTokens, responseFormat, topP, stop, maxRetries });
|
|
98
106
|
case "openai":
|
|
@@ -435,6 +443,7 @@ export function registerMockProvider(provider: MockProvider): void {
|
|
|
435
443
|
|
|
436
444
|
export function getAvailableProviders(): ProviderAvailability {
|
|
437
445
|
return {
|
|
446
|
+
alibaba: !!process.env["ALIBABA_API_KEY"],
|
|
438
447
|
openai: !!process.env["OPENAI_API_KEY"],
|
|
439
448
|
anthropic: !!process.env["ANTHROPIC_API_KEY"],
|
|
440
449
|
gemini: !!process.env["GEMINI_API_KEY"],
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { alibabaChat } from "../alibaba.ts";
|
|
3
|
+
import { ProviderJsonParseError } from "../types.ts";
|
|
4
|
+
import type { AlibabaOptions } from "../types.ts";
|
|
5
|
+
import type { Mock } from "vitest";
|
|
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,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function mockFetchResponse(body: unknown, status = 200) {
|
|
23
|
+
return {
|
|
24
|
+
ok: status >= 200 && status < 300,
|
|
25
|
+
status,
|
|
26
|
+
json: vi.fn().mockResolvedValue(body),
|
|
27
|
+
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
|
28
|
+
} as unknown as Response;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const baseOptions: AlibabaOptions = {
|
|
32
|
+
messages: [
|
|
33
|
+
{ role: "system", content: "You are helpful." },
|
|
34
|
+
{ role: "user", content: "Return JSON." },
|
|
35
|
+
],
|
|
36
|
+
responseFormat: "json_object",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe("alibabaChat", () => {
|
|
40
|
+
let originalFetch: typeof globalThis.fetch;
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
+
let fetchMock: Mock<(...args: any[]) => any>;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
originalFetch = globalThis.fetch;
|
|
46
|
+
fetchMock = vi.fn();
|
|
47
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
48
|
+
process.env["ALIBABA_API_KEY"] = "test-key";
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
globalThis.fetch = originalFetch;
|
|
53
|
+
delete process.env["ALIBABA_API_KEY"];
|
|
54
|
+
delete process.env["ALIBABA_BASE_URL"];
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns parsed JSON content with usage on success", async () => {
|
|
58
|
+
const jsonPayload = { result: "success", count: 42 };
|
|
59
|
+
fetchMock.mockResolvedValue(
|
|
60
|
+
mockFetchResponse(
|
|
61
|
+
makeAlibabaResponse(JSON.stringify(jsonPayload), 15, 25),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const result = await alibabaChat(baseOptions);
|
|
66
|
+
|
|
67
|
+
expect(result.content).toEqual(jsonPayload);
|
|
68
|
+
expect(result.usage).toEqual({
|
|
69
|
+
prompt_tokens: 15,
|
|
70
|
+
completion_tokens: 25,
|
|
71
|
+
total_tokens: 40,
|
|
72
|
+
});
|
|
73
|
+
expect(result.raw).toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("throws ProviderJsonParseError on invalid JSON when responseFormat is json_object", async () => {
|
|
77
|
+
const nonJsonText = "This is plain text, not JSON at all.";
|
|
78
|
+
fetchMock.mockResolvedValue(
|
|
79
|
+
mockFetchResponse(makeAlibabaResponse(nonJsonText)),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await alibabaChat(baseOptions);
|
|
84
|
+
expect.unreachable("should have thrown");
|
|
85
|
+
} catch (err) {
|
|
86
|
+
expect(err).toBeInstanceOf(ProviderJsonParseError);
|
|
87
|
+
const parseErr = err as ProviderJsonParseError;
|
|
88
|
+
expect(parseErr.provider).toBe("alibaba");
|
|
89
|
+
expect(parseErr.model).toBe("qwen-plus");
|
|
90
|
+
expect(parseErr.sample).toBeTruthy();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("retries on HTTP 500 with exponential backoff", async () => {
|
|
95
|
+
const jsonPayload = { retried: true };
|
|
96
|
+
fetchMock
|
|
97
|
+
.mockResolvedValueOnce(
|
|
98
|
+
mockFetchResponse({ error: { message: "Server error" } }, 500),
|
|
99
|
+
)
|
|
100
|
+
.mockResolvedValueOnce(
|
|
101
|
+
mockFetchResponse({ error: { message: "Server error" } }, 500),
|
|
102
|
+
)
|
|
103
|
+
.mockResolvedValueOnce(
|
|
104
|
+
mockFetchResponse(
|
|
105
|
+
makeAlibabaResponse(JSON.stringify(jsonPayload)),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const result = await alibabaChat({ ...baseOptions, maxRetries: 3 });
|
|
110
|
+
|
|
111
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
112
|
+
expect(result.content).toEqual(jsonPayload);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does NOT retry on HTTP 401", async () => {
|
|
116
|
+
fetchMock.mockResolvedValue(
|
|
117
|
+
mockFetchResponse({ error: { message: "Unauthorized" } }, 401),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await expect(
|
|
121
|
+
alibabaChat({ ...baseOptions, maxRetries: 3 }),
|
|
122
|
+
).rejects.toMatchObject({ status: 401, message: "Unauthorized" });
|
|
123
|
+
|
|
124
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("uses ALIBABA_BASE_URL env var when set", async () => {
|
|
128
|
+
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
|
+
);
|
|
133
|
+
|
|
134
|
+
await alibabaChat(baseOptions);
|
|
135
|
+
|
|
136
|
+
const [url] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
137
|
+
expect(url).toBe(
|
|
138
|
+
"https://custom.api.example.com/chat/completions",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("passes frequencyPenalty and presencePenalty in request body", async () => {
|
|
143
|
+
const jsonPayload = { ok: true };
|
|
144
|
+
fetchMock.mockResolvedValue(
|
|
145
|
+
mockFetchResponse(makeAlibabaResponse(JSON.stringify(jsonPayload))),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await alibabaChat({
|
|
149
|
+
...baseOptions,
|
|
150
|
+
frequencyPenalty: 0.5,
|
|
151
|
+
presencePenalty: 0.2,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const body = JSON.parse(
|
|
155
|
+
(fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
|
|
156
|
+
);
|
|
157
|
+
expect(body.frequency_penalty).toBe(0.5);
|
|
158
|
+
expect(body.presence_penalty).toBe(0.2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("sends enable_thinking true by default", async () => {
|
|
162
|
+
fetchMock.mockResolvedValue(
|
|
163
|
+
mockFetchResponse(makeAlibabaResponse(JSON.stringify({ ok: true }))),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await alibabaChat(baseOptions);
|
|
167
|
+
|
|
168
|
+
const body = JSON.parse(
|
|
169
|
+
(fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
|
|
170
|
+
);
|
|
171
|
+
expect(body.enable_thinking).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("sends enable_thinking false when thinking is disabled", async () => {
|
|
175
|
+
fetchMock.mockResolvedValue(
|
|
176
|
+
mockFetchResponse(makeAlibabaResponse(JSON.stringify({ ok: true }))),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await alibabaChat({ ...baseOptions, thinking: "disabled" });
|
|
180
|
+
|
|
181
|
+
const body = JSON.parse(
|
|
182
|
+
(fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string,
|
|
183
|
+
);
|
|
184
|
+
expect(body.enable_thinking).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// ── src/providers/alibaba.ts ──
|
|
2
|
+
// Alibaba (DashScope) adapter using OpenAI-compatible chat completions API.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
extractMessages,
|
|
6
|
+
ensureMessagesPresent,
|
|
7
|
+
ensureJsonResponseFormat,
|
|
8
|
+
isRetryableError,
|
|
9
|
+
sleep,
|
|
10
|
+
stripMarkdownFences,
|
|
11
|
+
tryParseJSON,
|
|
12
|
+
createProviderError,
|
|
13
|
+
} from "./base.ts";
|
|
14
|
+
import { ProviderJsonParseError } from "./types.ts";
|
|
15
|
+
import type {
|
|
16
|
+
AlibabaOptions,
|
|
17
|
+
AdapterResponse,
|
|
18
|
+
ResponseFormatObject,
|
|
19
|
+
} from "./types.ts";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MODEL = "qwen-plus";
|
|
22
|
+
const DEFAULT_TEMPERATURE = 0.7;
|
|
23
|
+
const DEFAULT_THINKING: "enabled" | "disabled" = "enabled";
|
|
24
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Determines whether the response format indicates JSON mode.
|
|
28
|
+
* Returns true for "json", "json_object", { type: "json_object" }, { json_schema: ... }.
|
|
29
|
+
*/
|
|
30
|
+
function isJsonMode(
|
|
31
|
+
responseFormat: string | ResponseFormatObject | undefined,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!responseFormat) return false;
|
|
34
|
+
if (typeof responseFormat === "string") {
|
|
35
|
+
return responseFormat === "json" || responseFormat === "json_object";
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
responseFormat.type === "json_object" ||
|
|
39
|
+
responseFormat.json_schema != null
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function alibabaChat(
|
|
44
|
+
options: AlibabaOptions,
|
|
45
|
+
): Promise<AdapterResponse> {
|
|
46
|
+
const {
|
|
47
|
+
messages,
|
|
48
|
+
model = DEFAULT_MODEL,
|
|
49
|
+
temperature = DEFAULT_TEMPERATURE,
|
|
50
|
+
maxTokens,
|
|
51
|
+
responseFormat,
|
|
52
|
+
topP,
|
|
53
|
+
stop,
|
|
54
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
55
|
+
frequencyPenalty,
|
|
56
|
+
presencePenalty,
|
|
57
|
+
thinking = DEFAULT_THINKING,
|
|
58
|
+
} = options;
|
|
59
|
+
|
|
60
|
+
ensureMessagesPresent(messages, "alibaba");
|
|
61
|
+
|
|
62
|
+
const jsonMode = isJsonMode(responseFormat);
|
|
63
|
+
|
|
64
|
+
const { systemMsg, userMessages, assistantMessages } =
|
|
65
|
+
extractMessages(messages);
|
|
66
|
+
|
|
67
|
+
// Build the messages array in conversation order
|
|
68
|
+
const apiMessages: Array<{ role: string; content: string }> = [];
|
|
69
|
+
if (systemMsg) {
|
|
70
|
+
apiMessages.push({ role: "system", content: systemMsg });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const nonSystemMessages = [...userMessages, ...assistantMessages].sort(
|
|
74
|
+
(a, b) => {
|
|
75
|
+
const aIdx = messages.indexOf(a);
|
|
76
|
+
const bIdx = messages.indexOf(b);
|
|
77
|
+
return aIdx - bIdx;
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
for (const m of nonSystemMessages) {
|
|
81
|
+
apiMessages.push({ role: m.role, content: m.content });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const apiKey = process.env["ALIBABA_API_KEY"];
|
|
85
|
+
|
|
86
|
+
const endpoint = `${process.env["ALIBABA_BASE_URL"] ?? "https://dashscope-us.aliyuncs.com/compatible-mode/v1"}/chat/completions`;
|
|
87
|
+
|
|
88
|
+
const body: Record<string, unknown> = {
|
|
89
|
+
model,
|
|
90
|
+
messages: apiMessages,
|
|
91
|
+
temperature,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (maxTokens != null) body["max_tokens"] = maxTokens;
|
|
95
|
+
if (topP != null) body["top_p"] = topP;
|
|
96
|
+
if (stop != null) body["stop"] = stop;
|
|
97
|
+
if (frequencyPenalty != null) body["frequency_penalty"] = frequencyPenalty;
|
|
98
|
+
if (presencePenalty != null) body["presence_penalty"] = presencePenalty;
|
|
99
|
+
body["enable_thinking"] = thinking === "enabled";
|
|
100
|
+
|
|
101
|
+
if (jsonMode) {
|
|
102
|
+
ensureJsonResponseFormat(responseFormat, "alibaba");
|
|
103
|
+
body["response_format"] = { type: "json_object" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let lastError: unknown;
|
|
107
|
+
|
|
108
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
109
|
+
try {
|
|
110
|
+
const response = await fetch(endpoint, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
Authorization: `Bearer ${apiKey ?? ""}`,
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify(body),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
let errorBody: unknown;
|
|
121
|
+
try {
|
|
122
|
+
errorBody = await response.json();
|
|
123
|
+
} catch {
|
|
124
|
+
errorBody = await response.text();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const err = createProviderError(
|
|
128
|
+
response.status,
|
|
129
|
+
errorBody,
|
|
130
|
+
`Alibaba API error: ${response.status}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// 401 is never retried
|
|
134
|
+
if (response.status === 401) {
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const data = (await response.json()) as {
|
|
142
|
+
choices: Array<{
|
|
143
|
+
message: { content: string };
|
|
144
|
+
}>;
|
|
145
|
+
usage?: {
|
|
146
|
+
prompt_tokens: number;
|
|
147
|
+
completion_tokens: number;
|
|
148
|
+
total_tokens: number;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const rawText = data.choices?.[0]?.message?.content ?? "";
|
|
153
|
+
const stripped = stripMarkdownFences(rawText);
|
|
154
|
+
const parsed = tryParseJSON(stripped);
|
|
155
|
+
|
|
156
|
+
// In JSON mode, if tryParseJSON returns a string, the response is unparseable
|
|
157
|
+
if (jsonMode && typeof parsed === "string") {
|
|
158
|
+
throw new ProviderJsonParseError(
|
|
159
|
+
"alibaba",
|
|
160
|
+
model,
|
|
161
|
+
parsed.slice(0, 200),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const usage = data.usage ?? {
|
|
166
|
+
prompt_tokens: 0,
|
|
167
|
+
completion_tokens: 0,
|
|
168
|
+
total_tokens: 0,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
content:
|
|
173
|
+
typeof parsed === "string"
|
|
174
|
+
? parsed
|
|
175
|
+
: (parsed as Record<string, unknown>),
|
|
176
|
+
text: rawText,
|
|
177
|
+
usage,
|
|
178
|
+
raw: data,
|
|
179
|
+
};
|
|
180
|
+
} catch (err) {
|
|
181
|
+
lastError = err;
|
|
182
|
+
|
|
183
|
+
if (!isRetryableError(err) || attempt >= maxRetries) {
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Exponential backoff: 2^attempt * 1000ms
|
|
188
|
+
await sleep(Math.pow(2, attempt) * 1000);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
throw lastError;
|
|
193
|
+
}
|
package/src/providers/types.ts
CHANGED
|
@@ -69,6 +69,7 @@ export type ProviderName =
|
|
|
69
69
|
| "zhipu"
|
|
70
70
|
| "claudecode"
|
|
71
71
|
| "moonshot"
|
|
72
|
+
| "alibaba"
|
|
72
73
|
| "mock";
|
|
73
74
|
|
|
74
75
|
/** Options for the chat() gateway function. */
|
|
@@ -106,6 +107,13 @@ export interface DeepSeekOptions extends ProviderOptions {
|
|
|
106
107
|
stream?: boolean;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/** Alibaba-specific options. */
|
|
111
|
+
export interface AlibabaOptions extends ProviderOptions {
|
|
112
|
+
frequencyPenalty?: number;
|
|
113
|
+
presencePenalty?: number;
|
|
114
|
+
thinking?: "enabled" | "disabled";
|
|
115
|
+
}
|
|
116
|
+
|
|
109
117
|
/** Moonshot-specific options. */
|
|
110
118
|
export interface MoonshotOptions {
|
|
111
119
|
messages: ChatMessage[];
|
|
@@ -160,6 +168,7 @@ export interface ProviderAvailability {
|
|
|
160
168
|
zhipu: boolean;
|
|
161
169
|
claudecode: boolean;
|
|
162
170
|
moonshot: boolean;
|
|
171
|
+
alibaba: boolean;
|
|
163
172
|
mock: boolean;
|
|
164
173
|
}
|
|
165
174
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, rename } from "node:fs/promises";
|
|
1
|
+
import { mkdir, rename, unlink } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { createErrorResponse } from "../config-bridge";
|
|
@@ -6,7 +6,7 @@ import { Constants } from "../config-bridge-node";
|
|
|
6
6
|
import { readJob } from "../job-reader";
|
|
7
7
|
import { sendJson } from "../utils/http-utils";
|
|
8
8
|
import { getJobDirectoryPath } from "../../../config/paths";
|
|
9
|
-
import { readJobStatus, resetJobToCleanSlate, resetSingleTask } from "../../../core/status-writer";
|
|
9
|
+
import { readJobStatus, resetJobToCleanSlate, resetSingleTask, writeJobStatus } from "../../../core/status-writer";
|
|
10
10
|
|
|
11
11
|
const RUNNER_PATH = path.resolve(import.meta.dir, "../../../core/pipeline-runner.ts");
|
|
12
12
|
|
|
@@ -36,6 +36,55 @@ async function spawnDetached(args: string[], env?: Record<string, string | undef
|
|
|
36
36
|
proc.unref();
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
async function readRunnerPid(jobDir: string): Promise<number | null> {
|
|
40
|
+
try {
|
|
41
|
+
const content = await Bun.file(path.join(jobDir, "runner.pid")).text();
|
|
42
|
+
const pid = parseInt(content.trim(), 10);
|
|
43
|
+
return Number.isNaN(pid) ? null : pid;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function killProcess(pid: number): Promise<{ killed: boolean; signal: string | null }> {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, 15); // SIGTERM
|
|
52
|
+
} catch (err: unknown) {
|
|
53
|
+
if ((err as NodeJS.ErrnoException).code === "ESRCH") {
|
|
54
|
+
return { killed: false, signal: null };
|
|
55
|
+
}
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Do not hold the HTTP request open while waiting for shutdown.
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
process.kill(pid, 9); // SIGKILL
|
|
69
|
+
} catch (err: unknown) {
|
|
70
|
+
if ((err as NodeJS.ErrnoException).code !== "ESRCH") {
|
|
71
|
+
console.error(`[handleJobStop] Failed to SIGKILL pid ${pid}:`, err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, 1500);
|
|
75
|
+
timer.unref();
|
|
76
|
+
|
|
77
|
+
return { killed: true, signal: "SIGTERM" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function cleanupRunnerPid(jobDir: string): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
await unlink(path.join(jobDir, "runner.pid"));
|
|
83
|
+
} catch (err: unknown) {
|
|
84
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
39
88
|
export async function resolveJobLifecycle(dataDir: string, jobId: string): Promise<string | null> {
|
|
40
89
|
const result = await readJob(jobId);
|
|
41
90
|
if (!result.ok) return null;
|
|
@@ -168,7 +217,64 @@ export async function handleJobStop(
|
|
|
168
217
|
if (!lifecycle) {
|
|
169
218
|
return sendJson(404, createErrorResponse(Constants.ERROR_CODES.JOB_NOT_FOUND, `job "${jobId}" was not found`));
|
|
170
219
|
}
|
|
171
|
-
|
|
220
|
+
|
|
221
|
+
const jobDir = getJobDirectoryPath(dataDir, jobId, lifecycle as "current" | "complete");
|
|
222
|
+
|
|
223
|
+
// Kill the runner process via PID file
|
|
224
|
+
let pidFound = false;
|
|
225
|
+
let usedSignal: string | null = null;
|
|
226
|
+
|
|
227
|
+
const pid = await readRunnerPid(jobDir);
|
|
228
|
+
if (pid !== null) {
|
|
229
|
+
pidFound = true;
|
|
230
|
+
try {
|
|
231
|
+
const result = await killProcess(pid);
|
|
232
|
+
usedSignal = result.signal;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.error(`[handleJobStop] Error killing pid ${pid} for job ${jobId}:`, err);
|
|
235
|
+
}
|
|
236
|
+
await cleanupRunnerPid(jobDir);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let resetTask: string | null = null;
|
|
240
|
+
|
|
241
|
+
// Reset running task and clear root-level fields in a single atomic write.
|
|
242
|
+
await writeJobStatus(jobDir, (snapshot) => {
|
|
243
|
+
if (snapshot.current && snapshot.tasks[snapshot.current]?.state === "running") {
|
|
244
|
+
resetTask = snapshot.current;
|
|
245
|
+
} else {
|
|
246
|
+
for (const taskId of Object.keys(snapshot.tasks)) {
|
|
247
|
+
if (snapshot.tasks[taskId]!.state === "running") {
|
|
248
|
+
resetTask = taskId;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (resetTask) {
|
|
255
|
+
const task = snapshot.tasks[resetTask];
|
|
256
|
+
if (task) {
|
|
257
|
+
task.state = "pending";
|
|
258
|
+
task.currentStage = null;
|
|
259
|
+
delete task.failedStage;
|
|
260
|
+
delete task.error;
|
|
261
|
+
task.attempts = 0;
|
|
262
|
+
task.refinementAttempts = 0;
|
|
263
|
+
task.tokenUsage = [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
snapshot.current = null;
|
|
268
|
+
snapshot.currentStage = null;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return sendJson(202, {
|
|
272
|
+
ok: true,
|
|
273
|
+
jobId,
|
|
274
|
+
stopped: pidFound,
|
|
275
|
+
resetTask,
|
|
276
|
+
signal: usedSignal,
|
|
277
|
+
});
|
|
172
278
|
} finally {
|
|
173
279
|
endStop(jobId);
|
|
174
280
|
}
|