- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
`chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
`rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
211 lines
7.8 KiB
Go
211 lines
7.8 KiB
Go
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)
|
||
}
|