@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryanfw/prompt-orchestration-pipeline",
3
- "version": "1.0.5",
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 "bun:test";
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 = 35;
19
- const PROVIDER_COUNT = 7;
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
  }
@@ -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 ─────────────────────────────────────────────
@@ -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
+ }
@@ -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
- return sendJson(202, { ok: true, jobId, action: "stop", lifecycle });
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
  }