package model import ( "io" "log/slog" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // discardLogger 返回一个丢弃所有记录的日志器,但仍带有 // 适配器的模块来源,因此未注入日志器而构建的适配器 // 绝不会产生无来源记录。 func discardLogger(source string) *slog.Logger { return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), source) } // OpenAI 兼容与 Anthropic 适配器共用的推理线取值 // (design/04 §3.2,已于 2026-10-03 对照各提供商文档核对)。 const ( // thinkingTypeEnabled 与 thinkingTypeDisabled 是 // {"thinking":{"type":...}} 开关的取值。DeepSeek 默认开启,所以省略 // 该字段会让思考保持开启——「不干预」不是关闭它的办法; // 只有显式 disabled(或 reasoning_effort "none")才是。 thinkingTypeEnabled = "enabled" thinkingTypeDisabled = "disabled" // effortNone 是关闭思考的 reasoning_effort 取值。它是 // 开关而非强度档位,所以绝不能被替换成某个档位:把它 // 映射到 "low" 会颠倒调用方的意图(关闭思考 → 开启思考)。 effortNone = "none" ) // reasoningVendor 标识 OpenAI 兼容端点背后的厂商。 // 它们的线协议相同,但可接受的 // reasoning_effort 取值不同,所以必须按正确的 // 白名单校验,而不是盲目转发。 type reasoningVendor string const ( vendorOpenAI reasoningVendor = "openai" vendorDeepSeek reasoningVendor = "deepseek" vendorQwen reasoningVendor = "qwen" vendorUnknown reasoningVendor = "unknown" ) // reasoningEffortAliases 是 DeepSeek 记录的兼容映射: // 服务接受这些别名并采用映射后的档位。我们在自己这一侧 // 也做同一映射,因此请求体总是携带提供商 // 记录为有效值的那一档(design/04 §3.2 事实 1)。 var reasoningEffortAliases = map[string]string{ "minimal": "low", "medium": "high", "xhigh": "high", "ultra": "max", } // reasoningEffortAllowed 是按厂商划分的可接受 reasoning_effort // 取值集合。不在本表里的厂商完全没有 reasoning_effort 支持, // 这一点会被上报,而不是靠猜。 // // 这里刻意没有「向下取到最近档位」的降级:不支持的取值 // 会被丢弃并上报,因为静默改写它可能会颠倒调用方的 // 意图——早先的 bug 正是这样把 DeepSeek 的 "none"(关闭思考) // 映射到了 "low"(开启思考)。 var reasoningEffortAllowed = map[reasoningVendor]map[string]bool{ vendorOpenAI: tierSet("none", "minimal", "low", "medium", "high", "xhigh", "max"), vendorDeepSeek: tierSet("none", "low", "high", "max"), } // tierSet 由给定档位构建一个成员集合。 func tierSet(tiers ...string) map[string]bool { set := make(map[string]bool, len(tiers)) for _, t := range tiers { set[t] = true } return set } // reasoningVendorFor 按模型名分类 OpenAI 兼容请求的厂商, // 模型名无法识别出厂商时退回到 base URL。 // 无法识别的组合得到 vendorUnknown,好让调用方上报「不 // 支持」而不是假定 OpenAI 语义。Anthropic 适配器用 // 同一套分类来区分 DeepSeek 的 /anthropic 端点(它忽略 // budget_tokens)与 Anthropic 端点。 func reasoningVendorFor(model, baseURL string) reasoningVendor { m := strings.ToLower(strings.TrimSpace(model)) switch { case strings.Contains(m, "deepseek"): return vendorDeepSeek case strings.Contains(m, "qwen"): return vendorQwen case strings.Contains(m, "gpt-"), strings.Contains(m, "chatgpt"), strings.HasPrefix(m, "o1"), strings.HasPrefix(m, "o3"), strings.HasPrefix(m, "o4"): return vendorOpenAI } u := strings.ToLower(strings.TrimSpace(baseURL)) switch { case strings.Contains(u, "deepseek"): return vendorDeepSeek case strings.Contains(u, "dashscope"), strings.Contains(u, "aliyuncs"): return vendorQwen case strings.Contains(u, "openai"): return vendorOpenAI } return vendorUnknown } // thinkingSwitchValue 把显式开/关开关映射到它的线取值。 func thinkingSwitchValue(enable bool) string { if enable { return thinkingTypeEnabled } return thinkingTypeDisabled } // normalizeDeepSeekEffort 把 effort 映射到 DeepSeek 实际采用的档位, // 并报告该值是否为服务记录的取值:记录在案的集合 // (none/low/high/max)原样通过,兼容别名映射到其 // 官方档位。 func normalizeDeepSeekEffort(effort string) (string, bool) { if tier, ok := reasoningEffortAliases[effort]; ok { return tier, true } if reasoningEffortAllowed[vendorDeepSeek][effort] { return effort, true } return "", false } // resolveReasoningEffort 针对厂商白名单校验 effort 并 // 返回要发送的值;"" 表示丢弃该字段。无法识别的值绝不会 // 被换成别的档位——它会被丢弃,并把 provider、field、 // value 与 model 一并上报,因为改动它会颠倒调用方的意图。DeepSeek // 的兼容别名会被归一化为服务采用的档位并上报。 // // vendor 必须是带 reasoning_effort 白名单的厂商(DeepSeek / OpenAI): // 不带该字段的形状(vLLM / DashScope)以及形状未知的 // 端点会自行丢弃 effort 并上报,因此它们的调用方 // 永远不会走到本函数(见 reasoning_shape.go,裁决 03.4.2)。 func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string { effort = strings.TrimSpace(effort) if effort == "" { return "" } allowed := reasoningEffortAllowed[vendor] if allowed[effort] { return effort } if vendor == vendorDeepSeek { if tier, ok := normalizeDeepSeekEffort(effort); ok { log.Warn("reasoning_effort alias normalized to the tier DeepSeek applies", "provider", "deepseek", "field", "reasoning_effort", "value", effort, "sent", tier, "model", model) return tier } } log.Warn("reasoning_effort dropped: unrecognized value", "provider", string(vendor), "field", "reasoning_effort", "value", effort, "model", model) return "" } // toolChoiceForces 报告 tool_choice 指令是否强制模型 // 调用工具("required"、Anthropic 的 "any",或具名工具)。auto/none 不会。 func toolChoiceForces(toolChoice any) bool { switch v := toolChoice.(type) { case nil: return false case string: return strings.EqualFold(strings.TrimSpace(v), "required") case map[string]any: if forcedToolChoiceType(v["type"]) { return true } _, named := v["function"] return named case map[string]string: if forcedToolChoiceType(v["type"]) { return true } _, named := v["function"] return named } return false } // forcedToolChoiceType 报告 tool_choice 的 "type" 取值是否强制调用。 func forcedToolChoiceType(value any) bool { t, _ := value.(string) switch strings.ToLower(strings.TrimSpace(t)) { case "required", "any", "tool": return true } return false } // reportForcedToolChoiceWithThinking 就 DeepSeek 红线报警: // 强制型工具调用——tool_choice "required"/"any"/具名工具——与思考 // 不兼容:思考开启(其默认)时 API 会用 400 拒绝该强制 // 指令,所以调用方必须先把思考显式关掉 // (design/04 §3.2 事实 5)。它只上报:解算过程绝不能自行 // 关掉思考。在 OpenAI 兼容适配器上该指令尚未上线 // (M2),所以那里只是前瞻性警告——它仍然记录了 // 所请求的组合永远无法被满足。 func reportForcedToolChoiceWithThinking(log *slog.Logger, provider, model string, toolChoice any, thinkingOn bool) { if !thinkingOn || !toolChoiceForces(toolChoice) { return } log.Warn("tool_choice forcing cannot be combined with thinking (forcing is rejected with a 400): disable thinking explicitly before forcing a tool call", "provider", provider, "field", "tool_choice", "model", model) }