增强各厂商思考与采样参数映射,并统一流式推理字段解析。
避免不同接口字段差异导致思考链丢失或采样参数被忽略。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import {
|
||||
applyRejectedGenerationField,
|
||||
forceReasoningEffortNoneWhenTools,
|
||||
isToolsReasoningEffortConflictError,
|
||||
parseRejectedGenerationField,
|
||||
} from "./generation-compat.js";
|
||||
@@ -186,7 +187,7 @@ async function postChat(
|
||||
const tried = new Set<string>();
|
||||
|
||||
if (isToolsReasoningEffortConflictError(first.text, first.status)) {
|
||||
retryBody.reasoning_effort = "none";
|
||||
forceReasoningEffortNoneWhenTools(retryBody, config.model);
|
||||
tried.add("reasoning_effort_none");
|
||||
return postOnce(config, retryBody);
|
||||
}
|
||||
|
||||
+13
-7
@@ -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<string, unknown> | 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,
|
||||
|
||||
+125
-12
@@ -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<string, ReasoningEffort> = {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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, unknown>): string {
|
||||
return JSON.stringify({
|
||||
reasoning_effort: body.reasoning_effort ?? null,
|
||||
thinking: body.thinking ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function applyToolsReasoningEffortNone(
|
||||
body: Record<string, unknown>,
|
||||
tried: Set<string>,
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+44
-11
@@ -45,6 +45,20 @@ function toolCallsFromAccumulator(acc: ToolCallAccumulator): ParsedToolCall[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** DeepSeek `reasoning_content`;部分中转用 `reasoning` / `thinking` */
|
||||
function readReasoningDelta(row: Record<string, unknown>): 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, unknown>): string {
|
||||
const val = row.content;
|
||||
return typeof val === "string" && val ? val : "";
|
||||
}
|
||||
|
||||
/** 解析 OpenAI 兼容 SSE 流,累积 reasoning / content / tool_calls */
|
||||
export async function consumeOpenAiToolStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
@@ -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<string, unknown>).delta;
|
||||
if (!delta || typeof delta !== "object") continue;
|
||||
const d = delta as Record<string, unknown>;
|
||||
|
||||
if (typeof d.reasoning_content === "string" && d.reasoning_content) {
|
||||
reasoning += d.reasoning_content;
|
||||
callbacks.onReasoningDelta?.(d.reasoning_content);
|
||||
const row = choice as Record<string, unknown>;
|
||||
const delta = row.delta;
|
||||
const message = row.message;
|
||||
if (delta && typeof delta === "object") {
|
||||
const d = delta as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** 试跑指定预设:生成参数跟这条预设走,不依赖当前选用。 */
|
||||
|
||||
@@ -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