Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 95 additions & 5 deletions dist/src/llm-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -406,4 +496,4 @@ export function createLlmClient(config) {
}
return createApiKeyClient(config, log, warnLog);
}
export { extractJsonFromResponse, repairCommonJson, shouldDisableReasoningForJson, stripReasoningTrace };
export { buildDisableReasoningParams, extractJsonFromResponse, repairCommonJson, shouldDisableReasoningForJson, stripReasoningTrace };
141 changes: 136 additions & 5 deletions src/llm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,141 @@ function recoverJsonFromReasoning<T>(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<string, unknown>;
}

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<string, unknown> | null {
for (const rule of REASONING_DISABLE_RULES) {
if (!rule.modelPattern.test(model)) continue;
let defaultParams: Record<string, unknown> | 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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
82 changes: 81 additions & 1 deletion test/llm-api-key-client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,6 +109,86 @@ describe("LLM api-key client", () => {
assert.equal(stripReasoningTrace("<think>{\"bad\":true}</think>{\"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) => {
Expand Down