diff --git a/dist/src/llm-client.js b/dist/src/llm-client.js index 4edbb84af..a2e9eabcd 100644 --- a/dist/src/llm-client.js +++ b/dist/src/llm-client.js @@ -71,8 +71,100 @@ function recoverJsonFromReasoning(reasoningText) { return null; } } +const REASONING_DISABLE_RULES = [ + { + // Qwen3 / QwQ — DashScope chat_template_kwargs (verified). vLLM-based + // aggregators (SiliconFlow, etc.) follow the same convention. + modelPattern: /qwen3|qwq/i, + providers: [ + { baseURL: "default", params: { chat_template_kwargs: { enable_thinking: false } } }, + ], + }, + { + // DeepSeek V4 / R1 family. + // - DashScope (Aliyun) hosts deepseek-v4-flash and uses chat_template_kwargs + // (verified, issue #929 — reasoning drops to 0, completion tokens -82%). + // - DeepSeek's own API (api.deepseek.com) uses thinking.type=disabled for + // v4-pro/v4-flash. deepseek-reasoner (R1) on the official API is a pure + // reasoning model and cannot disable thinking. + modelPattern: /deepseek.*(v\d|r1)/i, + providers: [ + { baseURL: /dashscope|aliyun/i, params: { chat_template_kwargs: { enable_thinking: false } } }, + { baseURL: /deepseek\.com/i, params: { thinking: { type: "disabled" } } }, + { baseURL: "default", params: { chat_template_kwargs: { enable_thinking: false } } }, + ], + }, + { + // Zhipu GLM-4.6 / GLM-Z1 — thinking.type=disabled (docs.bigmodel.cn). + modelPattern: /glm.*(4\.6|z1)|glm.*thinking/i, + providers: [ + { baseURL: "default", params: { thinking: { type: "disabled" } } }, + ], + }, + { + // Volcano Ark doubao thinking/seed models — thinking.type=disabled on the + // OpenAI-compatible /api/v3/chat/completions endpoint (docs.volcengine.com). + // Non-thinking variants (doubao-1.5-pro, etc.) are intentionally excluded. + modelPattern: /doubao.*(thinking|seed)/i, + providers: [ + { baseURL: "default", params: { thinking: { type: "disabled" } } }, + ], + }, + { + // Moonshot Kimi K1.5 / K2 — thinking.type=disabled (platform.kimi.com). + // Note: kimi-k2.7-code cannot disable thinking. + modelPattern: /kimi.*k[12]|kimi.*thinking/i, + providers: [ + { baseURL: "default", params: { thinking: { type: "disabled" } } }, + ], + }, + { + // MiniMax M2 / abab — thinking_budget=0 (api.minimax.chat). + modelPattern: /minimax.*m2|abab/i, + providers: [ + { baseURL: "default", params: { thinking_budget: 0 } }, + ], + }, + { + // Tencent Hunyuan T1 / Turbo-S — enable_thinking=false (cloud.tencent.com). + modelPattern: /hunyuan.*(t1|turbo)/i, + providers: [ + { baseURL: "default", params: { enable_thinking: false } }, + ], + }, + { + // StepFun step-3 / step-2 — thinking=false (developer.stepfun.com). + modelPattern: /step-[32]/i, + providers: [ + { baseURL: "default", params: { thinking: false } }, + ], + }, +]; +/** + * Build the request-body parameters that disable thinking/reasoning for the + * given model on the given provider endpoint, or `null` if the model is not a + * known reasoning model. The provider (baseURL) is consulted so that models + * hosted on different endpoints receive the correct parameter shape. + */ +function buildDisableReasoningParams(model, baseURL) { + for (const rule of REASONING_DISABLE_RULES) { + if (!rule.modelPattern.test(model)) + continue; + let defaultParams = null; + for (const entry of rule.providers) { + if (entry.baseURL === "default") { + defaultParams = entry.params; + } + else if (baseURL && entry.baseURL.test(baseURL)) { + return entry.params; + } + } + return defaultParams; + } + return null; +} function shouldDisableReasoningForJson(model) { - return /qwen3|deepseek.*r1|qwq/i.test(model); + return buildDisableReasoningParams(model) !== null; } function previewText(value, maxLen = 200) { const normalized = value.replace(/\s+/g, " ").trim(); @@ -193,9 +285,7 @@ function createApiKeyClient(config, log, warnLog) { { role: "user", content: prompt }, ], temperature: 0.1, - ...(shouldDisableReasoningForJson(config.model) - ? { chat_template_kwargs: { enable_thinking: false } } - : {}), + ...(buildDisableReasoningParams(config.model, config.baseURL) ?? {}), }; const response = await client.chat.completions.create(request); const message = response.choices?.[0]?.message; @@ -406,4 +496,4 @@ export function createLlmClient(config) { } return createApiKeyClient(config, log, warnLog); } -export { extractJsonFromResponse, repairCommonJson, shouldDisableReasoningForJson, stripReasoningTrace }; +export { buildDisableReasoningParams, extractJsonFromResponse, repairCommonJson, shouldDisableReasoningForJson, stripReasoningTrace }; diff --git a/src/llm-client.ts b/src/llm-client.ts index 2e29c7772..15d0c951b 100644 --- a/src/llm-client.ts +++ b/src/llm-client.ts @@ -99,8 +99,141 @@ function recoverJsonFromReasoning(reasoningText: string | undefined): T | nul } } +/** + * Per-model-family rules for disabling thinking/reasoning in JSON extraction + * requests. Different providers honor different request-body parameter shapes, + * and the same model can require a different parameter depending on which + * endpoint hosts it — e.g. `deepseek-v4-flash` uses + * `chat_template_kwargs.enable_thinking=false` on Aliyun DashScope but + * `thinking.type="disabled"` on DeepSeek's own API. + * + * Each rule maps a model-name pattern to a list of provider entries; the first + * entry whose `baseURL` pattern matches the configured base URL wins, with a + * `default` entry as the fallback for unknown aggregators (most vLLM-based + * aggregators follow the DashScope `chat_template_kwargs` convention). + * + * When no rule matches, no parameter is injected and the + * `recoverJsonFromReasoning` fallback (PR #914) handles models that stream + * their answer only into a reasoning field. + * + * Parameters are sourced from each provider's official API documentation: + * - DashScope (qwen3/qwq/deepseek-r1/deepseek-v4): chat_template_kwargs.enable_thinking — verified + * - DeepSeek official (deepseek-v4-pro/flash): thinking.type=disabled — api-docs.deepseek.com + * - Zhipu GLM-4.6/GLM-Z1: thinking.type=disabled — docs.bigmodel.cn + * - Volcano Ark doubao-thinking/seed: thinking.type=disabled — docs.volcengine.com (OpenAI-compat /api/v3) + * - Moonshot Kimi K1.5/K2: thinking.type=disabled — platform.kimi.com + * - MiniMax M2/abab: thinking_budget=0 — api.minimax.chat + * - Tencent Hunyuan T1/Turbo-S: enable_thinking=false — cloud.tencent.com + * - StepFun step-3/step-2: thinking=false — developer.stepfun.com + */ +interface ReasoningDisableProviderEntry { + /** Matched against the configured baseURL (case-insensitive). "default" = fallback. */ + baseURL: RegExp | "default"; + params: Record; +} + +interface ReasoningDisableRule { + modelPattern: RegExp; + providers: ReasoningDisableProviderEntry[]; +} + +const REASONING_DISABLE_RULES: ReasoningDisableRule[] = [ + { + // Qwen3 / QwQ — DashScope chat_template_kwargs (verified). vLLM-based + // aggregators (SiliconFlow, etc.) follow the same convention. + modelPattern: /qwen3|qwq/i, + providers: [ + { baseURL: "default", params: { chat_template_kwargs: { enable_thinking: false } } }, + ], + }, + { + // DeepSeek V4 / R1 family. + // - DashScope (Aliyun) hosts deepseek-v4-flash and uses chat_template_kwargs + // (verified, issue #929 — reasoning drops to 0, completion tokens -82%). + // - DeepSeek's own API (api.deepseek.com) uses thinking.type=disabled for + // v4-pro/v4-flash. deepseek-reasoner (R1) on the official API is a pure + // reasoning model and cannot disable thinking. + modelPattern: /deepseek.*(v\d|r1)/i, + providers: [ + { baseURL: /dashscope|aliyun/i, params: { chat_template_kwargs: { enable_thinking: false } } }, + { baseURL: /deepseek\.com/i, params: { thinking: { type: "disabled" } } }, + { baseURL: "default", params: { chat_template_kwargs: { enable_thinking: false } } }, + ], + }, + { + // Zhipu GLM-4.6 / GLM-Z1 — thinking.type=disabled (docs.bigmodel.cn). + modelPattern: /glm.*(4\.6|z1)|glm.*thinking/i, + providers: [ + { baseURL: "default", params: { thinking: { type: "disabled" } } }, + ], + }, + { + // Volcano Ark doubao thinking/seed models — thinking.type=disabled on the + // OpenAI-compatible /api/v3/chat/completions endpoint (docs.volcengine.com). + // Non-thinking variants (doubao-1.5-pro, etc.) are intentionally excluded. + modelPattern: /doubao.*(thinking|seed)/i, + providers: [ + { baseURL: "default", params: { thinking: { type: "disabled" } } }, + ], + }, + { + // Moonshot Kimi K1.5 / K2 — thinking.type=disabled (platform.kimi.com). + // Note: kimi-k2.7-code cannot disable thinking. + modelPattern: /kimi.*k[12]|kimi.*thinking/i, + providers: [ + { baseURL: "default", params: { thinking: { type: "disabled" } } }, + ], + }, + { + // MiniMax M2 / abab — thinking_budget=0 (api.minimax.chat). + modelPattern: /minimax.*m2|abab/i, + providers: [ + { baseURL: "default", params: { thinking_budget: 0 } }, + ], + }, + { + // Tencent Hunyuan T1 / Turbo-S — enable_thinking=false (cloud.tencent.com). + modelPattern: /hunyuan.*(t1|turbo)/i, + providers: [ + { baseURL: "default", params: { enable_thinking: false } }, + ], + }, + { + // StepFun step-3 / step-2 — thinking=false (developer.stepfun.com). + modelPattern: /step-[32]/i, + providers: [ + { baseURL: "default", params: { thinking: false } }, + ], + }, +]; + +/** + * Build the request-body parameters that disable thinking/reasoning for the + * given model on the given provider endpoint, or `null` if the model is not a + * known reasoning model. The provider (baseURL) is consulted so that models + * hosted on different endpoints receive the correct parameter shape. + */ +function buildDisableReasoningParams( + model: string, + baseURL?: string, +): Record | null { + for (const rule of REASONING_DISABLE_RULES) { + if (!rule.modelPattern.test(model)) continue; + let defaultParams: Record | null = null; + for (const entry of rule.providers) { + if (entry.baseURL === "default") { + defaultParams = entry.params; + } else if (baseURL && entry.baseURL.test(baseURL)) { + return entry.params; + } + } + return defaultParams; + } + return null; +} + function shouldDisableReasoningForJson(model: string): boolean { - return /qwen3|deepseek.*r1|qwq/i.test(model); + return buildDisableReasoningParams(model) !== null; } function previewText(value: string, maxLen = 200): string { @@ -240,9 +373,7 @@ function createApiKeyClient(config: LlmClientConfig, log: (msg: string) => void, { role: "user", content: prompt }, ], temperature: 0.1, - ...(shouldDisableReasoningForJson(config.model) - ? { chat_template_kwargs: { enable_thinking: false } } - : {}), + ...(buildDisableReasoningParams(config.model, config.baseURL) ?? {}), }; const response = await client.chat.completions.create(request as any); @@ -473,4 +604,4 @@ export function createLlmClient(config: LlmClientConfig): LlmClient { return createApiKeyClient(config, log, warnLog); } -export { extractJsonFromResponse, repairCommonJson, shouldDisableReasoningForJson, stripReasoningTrace }; +export { buildDisableReasoningParams, extractJsonFromResponse, repairCommonJson, shouldDisableReasoningForJson, stripReasoningTrace }; diff --git a/test/llm-api-key-client.test.mjs b/test/llm-api-key-client.test.mjs index 5b8944a23..2ba6d4bee 100644 --- a/test/llm-api-key-client.test.mjs +++ b/test/llm-api-key-client.test.mjs @@ -4,7 +4,7 @@ import { afterEach, describe, it } from "node:test"; import jitiFactory from "jiti"; const jiti = jitiFactory(import.meta.url, { interopDefault: true }); -const { createLlmClient, shouldDisableReasoningForJson, stripReasoningTrace } = jiti("../src/llm-client.ts"); +const { createLlmClient, buildDisableReasoningParams, shouldDisableReasoningForJson, stripReasoningTrace } = jiti("../src/llm-client.ts"); describe("LLM api-key client", () => { let server; @@ -109,6 +109,86 @@ describe("LLM api-key client", () => { assert.equal(stripReasoningTrace("{\"bad\":true}{\"ok\":true}"), "{\"ok\":true}"); }); + it("builds per-provider disable-reasoning params for known Chinese model families", () => { + // Qwen3 / QwQ → chat_template_kwargs (DashScope / vLLM convention) + assert.deepEqual(buildDisableReasoningParams("qwen3.5-27b-fp8"), { + chat_template_kwargs: { enable_thinking: false }, + }); + assert.deepEqual(buildDisableReasoningParams("QwQ-32B"), { + chat_template_kwargs: { enable_thinking: false }, + }); + + // DeepSeek V4 / R1 — provider-aware. + // On DashScope baseURL → chat_template_kwargs. + assert.deepEqual( + buildDisableReasoningParams("deepseek-v4-flash", "https://dashscope.aliyuncs.com/compatible-mode/v1"), + { chat_template_kwargs: { enable_thinking: false } }, + ); + // On DeepSeek official baseURL → thinking.type=disabled. + assert.deepEqual( + buildDisableReasoningParams("deepseek-v4-flash", "https://api.deepseek.com/v1"), + { thinking: { type: "disabled" } }, + ); + // On an unknown aggregator → falls back to chat_template_kwargs (vLLM convention). + assert.deepEqual( + buildDisableReasoningParams("deepseek-v4-flash", "https://api.siliconflow.cn/v1"), + { chat_template_kwargs: { enable_thinking: false } }, + ); + // R1 family still detected. + assert.equal(shouldDisableReasoningForJson("DeepSeek-R1-Distill-Qwen-32B"), true); + + // GLM-4.6 / GLM-Z1 → thinking.type=disabled + assert.deepEqual(buildDisableReasoningParams("glm-4.6"), { thinking: { type: "disabled" } }); + assert.deepEqual(buildDisableReasoningParams("glm-z1-flash"), { thinking: { type: "disabled" } }); + + // Doubao thinking/seed → thinking.type=disabled (non-thinking variants excluded) + assert.deepEqual(buildDisableReasoningParams("doubao-1.5-thinking"), { thinking: { type: "disabled" } }); + assert.deepEqual(buildDisableReasoningParams("doubao-seed-1.6"), { thinking: { type: "disabled" } }); + + // Kimi K1.5 / K2 → thinking.type=disabled + assert.deepEqual(buildDisableReasoningParams("kimi-k2.6"), { thinking: { type: "disabled" } }); + assert.deepEqual(buildDisableReasoningParams("kimi-k1.5"), { thinking: { type: "disabled" } }); + + // MiniMax M2 / abab → thinking_budget=0 + assert.deepEqual(buildDisableReasoningParams("MiniMax-M2"), { thinking_budget: 0 }); + assert.deepEqual(buildDisableReasoningParams("abab7-chat"), { thinking_budget: 0 }); + + // Hunyuan T1 / Turbo-S → enable_thinking=false + assert.deepEqual(buildDisableReasoningParams("hunyuan-t1"), { enable_thinking: false }); + assert.deepEqual(buildDisableReasoningParams("hunyuan-turbo-s"), { enable_thinking: false }); + + // StepFun step-3 / step-2 → thinking=false + assert.deepEqual(buildDisableReasoningParams("step-3"), { thinking: false }); + + // Non-reasoning models → null (no param injected). + assert.equal(buildDisableReasoningParams("gpt-4o-mini"), null); + assert.equal(buildDisableReasoningParams("doubao-1.5-pro"), null); + }); + + it("injects chat_template_kwargs for deepseek-v4-flash on an unknown aggregator baseURL", async () => { + let requestBody; + server = http.createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + requestBody = JSON.parse(body); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ choices: [{ message: { content: "{\"memories\":[]}" } }] })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + + const llm = createLlmClient({ + auth: "api-key", + apiKey: "test-api-key", + model: "deepseek-v4-flash", + baseURL: `http://127.0.0.1:${port}/v1`, + }); + await llm.completeJson("extract", "deepseek-probe"); + // localhost baseURL is an unknown aggregator → falls back to chat_template_kwargs. + assert.deepEqual(requestBody.chat_template_kwargs, { enable_thinking: false }); + assert.equal(requestBody.thinking, undefined); + }); + it("recovers JSON from reasoning_content when message content is empty", async () => { const logs = []; server = http.createServer(async (_req, res) => {