增强各厂商思考与采样参数映射,并统一流式推理字段解析。

避免不同接口字段差异导致思考链丢失或采样参数被忽略。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 01:14:09 +08:00
co-authored by Cursor
parent e6d79d6a0c
commit 752eaccefa
12 changed files with 392 additions and 49 deletions
+4 -4
View File
@@ -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;
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+6 -2
View File
@@ -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
View File
@@ -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);
}
}
+2 -1
View File
@@ -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 } };
}
+10 -2
View File
@@ -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);
}
/** 试跑指定预设:生成参数跟这条预设走,不依赖当前选用。 */