增强各厂商思考与采样参数映射,并统一流式推理字段解析。
避免不同接口字段差异导致思考链丢失或采样参数被忽略。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = { 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<string, unknown> = { max_completion_tokens: 100 };
|
||||
const tried = new Set<string>();
|
||||
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<string, unknown> = {
|
||||
model: "gpt-5.6-luna",
|
||||
tools: [{ type: "function" }],
|
||||
reasoning_effort: "high",
|
||||
};
|
||||
const tried = new Set<string>();
|
||||
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",
|
||||
|
||||
@@ -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("称呼 @玩家");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consumeOpenAiToolStream } from "../src/llm/stream-complete.js";
|
||||
|
||||
function sseStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
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("她摘下耳机。");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user