From 752eaccefa200a6284fe51f2b31e188bade5ea3d Mon Sep 17 00:00:00 2001 From: moranzhi Date: Mon, 14 Sep 2026 01:14:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=90=84=E5=8E=82=E5=95=86?= =?UTF-8?q?=E6=80=9D=E8=80=83=E4=B8=8E=E9=87=87=E6=A0=B7=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=98=A0=E5=B0=84=EF=BC=8C=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=8E=A8=E7=90=86=E5=AD=97=E6=AE=B5=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免不同接口字段差异导致思考链丢失或采样参数被忽略。 Co-authored-by: Cursor --- src/config/api-profiles.ts | 8 +- src/llm/capabilities.ts | 3 +- src/llm/client.ts | 20 +++-- src/llm/generation-compat.ts | 137 +++++++++++++++++++++++++++++--- src/llm/preset-wrapper.ts | 8 +- src/llm/stream-complete.ts | 55 ++++++++++--- src/runtime/driver.ts | 3 +- src/runtime/llm-factory.ts | 12 ++- tests/driver.test.ts | 31 ++++++++ tests/generation-compat.test.ts | 98 ++++++++++++++++++++--- tests/preset-wrapper.test.ts | 26 ++++++ tests/stream-complete.test.ts | 40 ++++++++++ 12 files changed, 392 insertions(+), 49 deletions(-) create mode 100644 tests/stream-complete.test.ts diff --git a/src/config/api-profiles.ts b/src/config/api-profiles.ts index 4d86317..f480b0a 100644 --- a/src/config/api-profiles.ts +++ b/src/config/api-profiles.ts @@ -18,10 +18,10 @@ export type ApiProfile = { apiKey: string; model: string; /** - * 该配置默认思考强度(写入请求的 reasoning_effort)。 - * 省略 / auto = 不发送;none = 显式关闭;带 tools 的请求会强制 none。 - * 探测永远用 low,不受此项影响。 - */ + * 该配置默认思考强度(按型号写入 reasoning_effort / thinking)。 + * 省略 / auto = 不发送;none = 关闭或映射为最轻档;带 tools 时非 GLM 强制 none,GLM-5.3 保留合法档。 + * 探测永远用 low,不受此项影响。 + */ reasoningEffort?: string; createdAt: string; updatedAt: string; diff --git a/src/llm/capabilities.ts b/src/llm/capabilities.ts index 3fa9294..9e5b0e0 100644 --- a/src/llm/capabilities.ts +++ b/src/llm/capabilities.ts @@ -5,6 +5,7 @@ import { applyRejectedGenerationField, + forceReasoningEffortNoneWhenTools, isToolsReasoningEffortConflictError, parseRejectedGenerationField, } from "./generation-compat.js"; @@ -186,7 +187,7 @@ async function postChat( const tried = new Set(); if (isToolsReasoningEffortConflictError(first.text, first.status)) { - retryBody.reasoning_effort = "none"; + forceReasoningEffortNoneWhenTools(retryBody, config.model); tried.add("reasoning_effort_none"); return postOnce(config, retryBody); } diff --git a/src/llm/client.ts b/src/llm/client.ts index 49c7ecc..6934924 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -148,8 +148,8 @@ export function buildRequestBody( applyOpenAiGeneration(body, options?.generation ?? {}, config.model, { reasoningEffort: config.reasoningEffort, }); - // 对齐 imyai:带 tools 时强制 none(省略字段网关仍可能注入默认 effort) - forceReasoningEffortNoneWhenTools(body); + // 对齐 imyai:带 tools 时按型号改写思考参数(非 GLM 强制 none) + forceReasoningEffortNoneWhenTools(body, config.model); if (options?.responseFormat === "json_object") { body.response_format = { type: "json_object" }; @@ -231,10 +231,13 @@ function extractMessageParts(message: Record | undefined): { : rawContent == null ? null : ""; - const reasoning = - typeof message.reasoning_content === "string" - ? message.reasoning_content.trim() - : undefined; + const reasoningRaw = + (typeof message.reasoning_content === "string" && + message.reasoning_content.trim()) || + (typeof message.reasoning === "string" && message.reasoning.trim()) || + (typeof message.thinking === "string" && message.thinking.trim()) || + ""; + const reasoning = reasoningRaw || undefined; const toolCalls: ParsedToolCall[] = []; const rawCalls = message.tool_calls; @@ -422,8 +425,11 @@ export class OpenAiCompatibleProvider implements LlmProvider { if (!parts.content && parts.toolCalls.length === 0 && !parts.reasoning) { throw new Error("LLM stream returned empty content and no tool calls"); } + const content = + parts.content ?? + (parts.toolCalls.length === 0 ? parts.reasoning || null : null); return { - content: parts.content, + content, toolCalls: parts.toolCalls, reasoning: parts.reasoning || undefined, usage: parts.usage, diff --git a/src/llm/generation-compat.ts b/src/llm/generation-compat.ts index 2339645..992b251 100644 --- a/src/llm/generation-compat.ts +++ b/src/llm/generation-compat.ts @@ -6,8 +6,9 @@ import type { GenerationParameters } from "../types/preset.js"; * - top_k、min_p、repetition_penalty 不是官方 Chat Completions 字段,默认不发 * - 推理模型去掉 temperature / top_p / penalty * - o 系列与 gpt-5 用 max_completion_tokens - * - 带 tools 时强制 reasoning_effort=none(gpt-5.6-luna 等网关:省略字段仍会注入默认 effort) - * - 仍 400 时按错误信息剥掉不支持字段再试;tools+reasoning 冲突则改 none 再试 + * - GLM:按型号映射 thinking / reasoning_effort(5.3 拒收 none;旧版拒收 effort) + * - 带 tools 时:非 GLM 强制 reasoning_effort=none(gpt-5.6-luna 等);GLM-5.3 保留合法档 + * - 仍 400 时按错误信息剥掉不支持字段再试;tools+reasoning 冲突则按型号改写再试 */ const REASONING_EFFORT_VALUES = [ @@ -29,6 +30,17 @@ const REASONING_EFFORT_ALIASES: Record = { const VERBOSITY_VALUES = ["low", "medium", "high"] as const; type Verbosity = (typeof VERBOSITY_VALUES)[number]; +/** GLM 思考参数形态(智谱 / 中转常见型号) */ +export type GlmThinkingKind = + /** 非 GLM */ + | "none" + /** 仅 thinking.type,不发 reasoning_effort(glm-4.5/4.6/4.7/5/5.1 等) */ + | "thinking_only" + /** thinking + 完整 effort 枚举(glm-5.2) */ + | "effort_full" + /** 强制思考;effort 仅 low/high/max(glm-5.3) */ + | "effort_lhm_always_on"; + export const STRIPPABLE_GENERATION_FIELDS = new Set([ "temperature", "top_p", @@ -40,6 +52,7 @@ export const STRIPPABLE_GENERATION_FIELDS = new Set([ "seed", "n", "reasoning_effort", + "thinking", "verbosity", "max_tokens", "max_completion_tokens", @@ -82,6 +95,75 @@ export function classifyChatModel(model: string): ChatModelCompat { }; } +/** + * 识别 GLM 思考 API 形态。 + * 名称含 glm 即走 GLM 路径;未知子版默认 thinking_only(不发 effort,避免 400)。 + */ +export function classifyGlmThinking(model: string): GlmThinkingKind { + const id = modelLeaf(model); + if (!id.includes("glm")) return "none"; + // 5.3 / 5.3-flash:不可 disabled,effort ∈ {low,high,max} + if (/glm-?5\.3/.test(id)) return "effort_lhm_always_on"; + // 5.2:可 disabled;effort 枚举较全 + if (/glm-?5\.2/.test(id)) return "effort_full"; + return "thinking_only"; +} + +function mapGlm53Effort( + effort: ReasoningEffort, +): "low" | "high" | "max" { + switch (effort) { + case "none": + case "minimal": + case "low": + return "low"; + case "medium": + case "high": + return "high"; + case "xhigh": + case "max": + return "max"; + } +} + +/** 把配置的 effort 落到当前模型可接受的 body 字段。 */ +function applyReasoningToBody( + body: Record, + model: string, + effort: ReasoningEffort | undefined, +): void { + if (effort === undefined) return; + + const glm = classifyGlmThinking(model); + if (glm === "none") { + body.reasoning_effort = effort; + return; + } + + if (glm === "effort_lhm_always_on") { + body.thinking = { type: "enabled" }; + body.reasoning_effort = mapGlm53Effort(effort); + return; + } + + if (glm === "effort_full") { + if (effort === "none" || effort === "minimal") { + body.thinking = { type: "disabled" }; + body.reasoning_effort = "none"; + return; + } + body.thinking = { type: "enabled" }; + body.reasoning_effort = effort; + return; + } + + // thinking_only:旧 GLM 拒收 reasoning_effort + body.thinking = { + type: effort === "none" || effort === "minimal" ? "disabled" : "enabled", + }; + delete body.reasoning_effort; +} + export function sanitizeReasoningEffort( value: unknown, ): ReasoningEffort | undefined { @@ -148,9 +230,7 @@ export function applyOpenAiGeneration( const reasoningEffort = sanitizeReasoningEffort(gen.reasoningEffort) ?? sanitizeReasoningEffort(defaults?.reasoningEffort); - if (reasoningEffort !== undefined) { - body.reasoning_effort = reasoningEffort; - } + applyReasoningToBody(body, model, reasoningEffort); const verbosity = sanitizeVerbosity(gen.verbosity); if (verbosity !== undefined && supportsVerbosity) { @@ -158,12 +238,36 @@ export function applyOpenAiGeneration( } } -/** chat/completions + function tools 时须显式 none,否则网关可能注入默认 effort 并 400。 */ +/** + * 带 function tools 时改写思考参数。 + * - 非 GLM(如 gpt-5.6-luna):须显式 reasoning_effort=none + * - GLM-5.3:不能 none,保留/压到合法档(默认 low) + * - GLM-5.2 / 旧版:thinking disabled,去掉 effort + */ export function forceReasoningEffortNoneWhenTools( body: Record, + model?: string, ): void { const tools = body.tools; if (!Array.isArray(tools) || tools.length === 0) return; + + const modelId = + (typeof model === "string" && model) || + (typeof body.model === "string" ? body.model : ""); + const glm = classifyGlmThinking(modelId); + + if (glm === "effort_lhm_always_on") { + const current = sanitizeReasoningEffort(body.reasoning_effort) ?? "low"; + body.thinking = { type: "enabled" }; + body.reasoning_effort = mapGlm53Effort(current); + return; + } + if (glm === "effort_full" || glm === "thinking_only") { + body.thinking = { type: "disabled" }; + delete body.reasoning_effort; + return; + } + body.reasoning_effort = "none"; } @@ -279,17 +383,18 @@ export function applyRejectedGenerationField( return true; } - // tools + reasoning_effort 冲突:删字段不够,须显式 none + // tools + reasoning_effort 冲突:按型号改写(非 GLM → none;GLM 另处理) if ( field === "reasoning_effort" && Array.isArray(body.tools) && body.tools.length > 0 && - body.reasoning_effort !== "none" && !tried.has("reasoning_effort_none") ) { - body.reasoning_effort = "none"; + const before = snapshotThinkingFields(body); + forceReasoningEffortNoneWhenTools(body); tried.add("reasoning_effort_none"); - return true; + if (snapshotThinkingFields(body) !== before) return true; + // 已是目标态仍报错 → 继续往下删字段 } if (!(field in body)) return false; @@ -298,6 +403,13 @@ export function applyRejectedGenerationField( return true; } +function snapshotThinkingFields(body: Record): string { + return JSON.stringify({ + reasoning_effort: body.reasoning_effort ?? null, + thinking: body.thinking ?? null, + }); +} + function applyToolsReasoningEffortNone( body: Record, tried: Set, @@ -305,12 +417,13 @@ function applyToolsReasoningEffortNone( if ( !Array.isArray(body.tools) || body.tools.length === 0 || - body.reasoning_effort === "none" || tried.has("reasoning_effort_none") ) { return false; } - body.reasoning_effort = "none"; + const before = snapshotThinkingFields(body); + forceReasoningEffortNoneWhenTools(body); + if (snapshotThinkingFields(body) === before) return false; tried.add("reasoning_effort_none"); return true; } diff --git a/src/llm/preset-wrapper.ts b/src/llm/preset-wrapper.ts index fadff57..9cd85d7 100644 --- a/src/llm/preset-wrapper.ts +++ b/src/llm/preset-wrapper.ts @@ -1,4 +1,5 @@ import { loadAppSettings } from "../config/settings.js"; +import type { PersonaDirective } from "../persona/store.js"; import { frameMessagesWithPreset } from "../preset/play-frame.js"; import { resolveActivePreset } from "../preset/store.js"; import type { PresetPackage } from "../types/preset.js"; @@ -30,11 +31,12 @@ export function wrapLlmForSession( inner: LlmProvider, trackingRef?: LlmTrackingRef, getPreset: () => PresetPackage | null = readActivePreset, + getPersona?: () => PersonaDirective | null, ): LlmProvider { const tracked = trackingRef ? new TokenTrackingProvider(inner, () => trackingRef.current) : inner; - return new PresetLlmProvider(tracked, getPreset); + return new PresetLlmProvider(tracked, getPreset, getPersona); } /** 所有 LLM 请求:preset 夹心 + 生成参数。无选用预设则原样转发。 */ @@ -42,6 +44,7 @@ export class PresetLlmProvider implements LlmProvider { constructor( private readonly inner: LlmProvider, private readonly getPreset: () => PresetPackage | null, + private readonly getPersona?: () => PersonaDirective | null, ) {} private prepare( @@ -50,8 +53,9 @@ export class PresetLlmProvider implements LlmProvider { ): { messages: ChatMessage[]; options: CompleteOptions | undefined } { const preset = this.getPreset(); if (!preset) return { messages, options }; + const persona = this.getPersona ? this.getPersona() : undefined; return { - messages: frameMessagesWithPreset(preset, messages), + messages: frameMessagesWithPreset(preset, messages, persona), options: { ...options, generation: options?.generation ?? preset.generation, diff --git a/src/llm/stream-complete.ts b/src/llm/stream-complete.ts index 83eb429..7a5600b 100644 --- a/src/llm/stream-complete.ts +++ b/src/llm/stream-complete.ts @@ -45,6 +45,20 @@ function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] { return out; } +/** DeepSeek `reasoning_content`;部分中转用 `reasoning` / `thinking` */ +function readReasoningDelta(row: Record): string { + for (const key of ["reasoning_content", "reasoning", "thinking"] as const) { + const val = row[key]; + if (typeof val === "string" && val) return val; + } + return ""; +} + +function readContentText(row: Record): string { + const val = row.content; + return typeof val === "string" && val ? val : ""; +} + /** 解析 OpenAI 兼容 SSE 流,累积 reasoning / content / tool_calls */ export async function consumeOpenAiToolStream( body: ReadableStream, @@ -91,19 +105,38 @@ export async function consumeOpenAiToolStream( const choice = (parsed.choices as unknown[])?.[0]; if (!choice || typeof choice !== "object") continue; - const delta = (choice as Record).delta; - if (!delta || typeof delta !== "object") continue; - const d = delta as Record; - - if (typeof d.reasoning_content === "string" && d.reasoning_content) { - reasoning += d.reasoning_content; - callbacks.onReasoningDelta?.(d.reasoning_content); + const row = choice as Record; + const delta = row.delta; + const message = row.message; + if (delta && typeof delta === "object") { + const d = delta as Record; + const think = readReasoningDelta(d); + if (think) { + reasoning += think; + callbacks.onReasoningDelta?.(think); + } + const chunk = readContentText(d); + if (chunk) { + content += chunk; + callbacks.onContentDelta?.(chunk); + } + if (d.tool_calls) applyToolCallDelta(toolAcc, d.tool_calls); } - if (typeof d.content === "string" && d.content) { - content += d.content; - callbacks.onContentDelta?.(d.content); + // 部分中转最后一帧把全文放在 message 而非 delta + if (message && typeof message === "object" && !content) { + const msg = message as Record; + const think = readReasoningDelta(msg); + if (think && !reasoning) { + reasoning = think; + callbacks.onReasoningDelta?.(think); + } + const full = readContentText(msg); + if (full) { + content = full; + callbacks.onContentDelta?.(full); + } + if (msg.tool_calls) applyToolCallDelta(toolAcc, msg.tool_calls); } - if (d.tool_calls) applyToolCallDelta(toolAcc, d.tool_calls); } } diff --git a/src/runtime/driver.ts b/src/runtime/driver.ts index 2172f7e..a1887bc 100644 --- a/src/runtime/driver.ts +++ b/src/runtime/driver.ts @@ -126,7 +126,8 @@ export function createLocalLlmDriver(llm: LlmProvider): AgentDriver { emitThinkingDone(input, result.reasoning); if (result.toolCalls.length === 0) { - const content = result.content?.trim() ?? ""; + const content = + result.content?.trim() || result.reasoning?.trim() || ""; if (content) { return { iterations: iteration, stop: { kind: "text", content } }; } diff --git a/src/runtime/llm-factory.ts b/src/runtime/llm-factory.ts index ea58219..fbc9aa6 100644 --- a/src/runtime/llm-factory.ts +++ b/src/runtime/llm-factory.ts @@ -9,6 +9,7 @@ import { OpenAiCompatibleProvider, type LlmProvider, } from "../llm/client.js"; +import type { PersonaDirective } from "../persona/store.js"; import type { PresetPackage } from "../types/preset.js"; import { wrapLlmForSession, @@ -43,8 +44,14 @@ function buildInnerLlm(): LlmProvider { /** Web/CLI 默认 LLM:本地 profile + 全局 preset + token 统计 */ export function createDefaultMainAgentLlm( trackingRef?: LlmTrackingRef, + getPersona?: () => PersonaDirective | null, ): LlmProvider { - return wrapLlmForSession(buildInnerLlm(), trackingRef); + return wrapLlmForSession( + buildInnerLlm(), + trackingRef, + undefined, + getPersona, + ); } export function hasRealLlmConfig(): boolean { @@ -60,8 +67,9 @@ export function isMockLlm(_llm: LlmProvider): boolean { /** 设置页切换 profile / preset 后调用,返回新 LLM 实例 */ export function reloadDefaultMainAgentLlm( trackingRef?: LlmTrackingRef, + getPersona?: () => PersonaDirective | null, ): LlmProvider { - return createDefaultMainAgentLlm(trackingRef); + return createDefaultMainAgentLlm(trackingRef, getPersona); } /** 试跑指定预设:生成参数跟这条预设走,不依赖当前选用。 */ diff --git a/tests/driver.test.ts b/tests/driver.test.ts index db8fcdb..c1083bd 100644 --- a/tests/driver.test.ts +++ b/tests/driver.test.ts @@ -41,6 +41,37 @@ describe("local LLM driver", () => { expect(run.stop).toEqual({ kind: "text", content: "done" }); }); + it("uses reasoning as the story when tools return no content", async () => { + const llm = { + complete: async () => ({ content: "" }), + completeWithTools: async () => ({ + content: null, + toolCalls: [], + reasoning: "门开了。她把耳机摘下,问:「你在干什么?」", + }), + }; + const driver = createLocalLlmDriver(llm as never); + const run = await driver.run({ + system: "sys", + messages: [{ role: "user", content: "go" }], + tools: [ + { + type: "function", + function: { + name: "chance", + description: "roll", + parameters: { type: "object", properties: {} }, + }, + }, + ], + handleStep: () => ({ kind: "continue", results: [] }), + }); + expect(run.stop).toEqual({ + kind: "text", + content: "门开了。她把耳机摘下,问:「你在干什么?」", + }); + }); + it("stops when handleStep concludes, without another model call", async () => { const llm = new MockLlmProvider([ createMockToolCall("run_worker", { workerId: "x" }, "t1"), diff --git a/tests/generation-compat.test.ts b/tests/generation-compat.test.ts index f5588fe..84831fd 100644 --- a/tests/generation-compat.test.ts +++ b/tests/generation-compat.test.ts @@ -3,6 +3,7 @@ import { applyOpenAiGeneration, applyRejectedGenerationField, classifyChatModel, + classifyGlmThinking, fetchWithGenerationCompat, forceReasoningEffortNoneWhenTools, isToolsReasoningEffortConflictError, @@ -77,6 +78,19 @@ describe("classifyChatModel", () => { }); }); +describe("classifyGlmThinking", () => { + it("classifies glm generations", () => { + expect(classifyGlmThinking("glm-5.3")).toBe("effort_lhm_always_on"); + expect(classifyGlmThinking("ZHIPU/GLM-5.3-Flash")).toBe( + "effort_lhm_always_on", + ); + expect(classifyGlmThinking("glm-5.2")).toBe("effort_full"); + expect(classifyGlmThinking("glm-5.1")).toBe("thinking_only"); + expect(classifyGlmThinking("glm-4.7")).toBe("thinking_only"); + expect(classifyGlmThinking("gpt-5")).toBe("none"); + }); +}); + describe("applyOpenAiGeneration", () => { it("does not send ST extras or auto sentinels on a chat model", () => { const body: Record = { model: "deepseek-v4-pro" }; @@ -102,11 +116,30 @@ describe("applyOpenAiGeneration", () => { expect(body.max_tokens).toBeUndefined(); expect(body.max_completion_tokens).toBe(60000); }); + + it("maps glm-5.3 none/medium to low/high and always enables thinking", () => { + const noneBody: Record = { model: "glm-5.3" }; + applyOpenAiGeneration(noneBody, { reasoningEffort: "none" }, "glm-5.3"); + expect(noneBody.thinking).toEqual({ type: "enabled" }); + expect(noneBody.reasoning_effort).toBe("low"); + + const midBody: Record = { model: "glm-5.3" }; + applyOpenAiGeneration(midBody, { reasoningEffort: "medium" }, "glm-5.3"); + expect(midBody.reasoning_effort).toBe("high"); + }); + + it("uses thinking only for older glm and omits reasoning_effort", () => { + const body: Record = { model: "glm-5.1" }; + applyOpenAiGeneration(body, { reasoningEffort: "high" }, "glm-5.1"); + expect(body.thinking).toEqual({ type: "enabled" }); + expect(body.reasoning_effort).toBeUndefined(); + }); }); describe("forceReasoningEffortNoneWhenTools", () => { - it("forces none when tools are present", () => { + it("forces none when tools are present on non-glm", () => { const body: Record = { + model: "gpt-5.6-luna", tools: [{ type: "function", function: { name: "x" } }], reasoning_effort: "high", }; @@ -114,14 +147,37 @@ describe("forceReasoningEffortNoneWhenTools", () => { expect(body.reasoning_effort).toBe("none"); }); - it("sets none even when effort was omitted", () => { + it("sets none even when effort was omitted on non-glm", () => { const body: Record = { + model: "gpt-4o", tools: [{ type: "function", function: { name: "x" } }], }; forceReasoningEffortNoneWhenTools(body); expect(body.reasoning_effort).toBe("none"); }); + it("keeps a legal glm-5.3 effort instead of none", () => { + const body: Record = { + model: "glm-5.3", + tools: [{ type: "function", function: { name: "x" } }], + reasoning_effort: "high", + }; + forceReasoningEffortNoneWhenTools(body); + expect(body.reasoning_effort).toBe("high"); + expect(body.thinking).toEqual({ type: "enabled" }); + }); + + it("disables thinking for older glm tools calls", () => { + const body: Record = { + model: "glm-5.1", + tools: [{ type: "function", function: { name: "x" } }], + reasoning_effort: "high", + }; + forceReasoningEffortNoneWhenTools(body); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.thinking).toEqual({ type: "disabled" }); + }); + it("leaves body alone without tools", () => { const body: Record = { reasoning_effort: "high" }; forceReasoningEffortNoneWhenTools(body); @@ -162,7 +218,8 @@ describe("parseRejectedGenerationField", () => { parseRejectedGenerationField( JSON.stringify({ error: { - message: "Unsupported parameter: 'temperature' is not supported with this model.", + message: + "Unsupported parameter: 'temperature' is not supported with this model.", param: "temperature", }, }), @@ -196,9 +253,9 @@ describe("applyRejectedGenerationField", () => { it("renames max_completion_tokens to max_tokens once", () => { const body: Record = { max_completion_tokens: 100 }; const tried = new Set(); - expect(applyRejectedGenerationField(body, "max_completion_tokens", tried)).toBe( - true, - ); + expect( + applyRejectedGenerationField(body, "max_completion_tokens", tried), + ).toBe(true); expect(body.max_tokens).toBe(100); expect(body.max_completion_tokens).toBeUndefined(); expect(applyRejectedGenerationField(body, "max_tokens", tried)).toBe(true); @@ -206,13 +263,16 @@ describe("applyRejectedGenerationField", () => { expect(body.max_tokens).toBeUndefined(); }); - it("sets reasoning_effort to none when tools are present", () => { + it("sets reasoning_effort to none when tools are present on non-glm", () => { const body: Record = { + model: "gpt-5.6-luna", tools: [{ type: "function" }], reasoning_effort: "high", }; const tried = new Set(); - expect(applyRejectedGenerationField(body, "reasoning_effort", tried)).toBe(true); + expect( + applyRejectedGenerationField(body, "reasoning_effort", tried), + ).toBe(true); expect(body.reasoning_effort).toBe("none"); }); }); @@ -311,6 +371,7 @@ describe("buildRequestBody", () => { generation: { temperature: 1 }, }); expect(body.reasoning_effort).toBe("high"); + expect(body.thinking).toEqual({ type: "enabled" }); }); it("lets generation reasoningEffort override the profile default", () => { @@ -326,7 +387,7 @@ describe("buildRequestBody", () => { expect(body.reasoning_effort).toBe("low"); }); - it("forces reasoning_effort none when tools are attached", () => { + it("forces reasoning_effort none when tools are attached on non-glm", () => { const config: LlmConfig = { baseUrl: "https://example.test/v1", apiKey: "k", @@ -345,6 +406,25 @@ describe("buildRequestBody", () => { expect(body.tools).toHaveLength(1); }); + it("keeps glm-5.3 effort when tools are attached", () => { + const config: LlmConfig = { + baseUrl: "https://example.test/v1", + apiKey: "k", + model: "glm-5.3", + reasoningEffort: "max", + }; + const body = buildRequestBody(config, messages, { + tools: [ + { + type: "function", + function: { name: "run_worker", parameters: { type: "object" } }, + }, + ], + }); + expect(body.reasoning_effort).toBe("max"); + expect(body.thinking).toEqual({ type: "enabled" }); + }); + it("does not forward top_k from an imported ST preset", () => { const config: LlmConfig = { baseUrl: "https://example.test/v1", diff --git a/tests/preset-wrapper.test.ts b/tests/preset-wrapper.test.ts index 9968070..49e3538 100644 --- a/tests/preset-wrapper.test.ts +++ b/tests/preset-wrapper.test.ts @@ -136,4 +136,30 @@ describe("wrapLlmForSession", () => { ); expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages); }); + + it("uses getPersona so @玩家 stays @玩家 during creation", async () => { + const preset: PresetPackage = { + ...samplePreset, + prompts: [ + { + id: "sys", + name: "系统", + enabled: true, + role: "system", + content: "称呼 @玩家", + marker: false, + sourceIdentifier: "sys", + }, + ], + }; + const { provider, received } = recordingProvider(); + const llm = wrapLlmForSession( + provider, + undefined, + () => preset, + () => ({ name: "@玩家", description: "占位" }), + ); + await llm.complete([{ role: "user", content: "TASK" }]); + expect(received[0].messages[0].content).toBe("称呼 @玩家"); + }); }); diff --git a/tests/stream-complete.test.ts b/tests/stream-complete.test.ts new file mode 100644 index 0000000..412e614 --- /dev/null +++ b/tests/stream-complete.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { consumeOpenAiToolStream } from "../src/llm/stream-complete.js"; + +function sseStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); +} + +describe("consumeOpenAiToolStream", () => { + it("reads reasoning from reasoning_content and content from delta", async () => { + const parts = await consumeOpenAiToolStream( + sseStream([ + 'data: {"choices":[{"delta":{"reasoning_content":"想一下"}}]}\n', + 'data: {"choices":[{"delta":{"content":"门开了"}}]}\n', + "data: [DONE]\n", + ]), + {}, + ); + expect(parts.reasoning).toBe("想一下"); + expect(parts.content).toBe("门开了"); + }); + + it("accepts reasoning / thinking aliases and last-frame message content", async () => { + const parts = await consumeOpenAiToolStream( + sseStream([ + 'data: {"choices":[{"delta":{"reasoning":"先想"}}]}\n', + 'data: {"choices":[{"message":{"content":"她摘下耳机。"}}]}\n', + "data: [DONE]\n", + ]), + {}, + ); + expect(parts.reasoning).toBe("先想"); + expect(parts.content).toBe("她摘下耳机。"); + }); +});