Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions backend/internal/application/channel/model_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,103 @@ func TestReasoningContentPassbackRequiredForDeepSeekChatCompletions(t *testing.T
}
}

// Moonshot、智谱、小米 MiMo 的思考模型同样要求历史 assistant 消息原样携带 reasoning_content,
// 覆盖显式 vendor 与仅凭模型名推断两种入口,并反向断言未纳入白名单的厂商保持关闭。
func TestReasoningContentPassbackRequiredForAdditionalChatCompletionsVendors(t *testing.T) {
required := []struct {
name string
candidates []string
}{
{"moonshot vendor", []string{"moonshot", "kimi-k3"}},
{"kimi alias", []string{"kimi", "kimi-k2.7-code"}},
{"kimi by model name", []string{"", "kimi-k3"}},
{"moonshot by model name", []string{"", "moonshot-v1-128k"}},
{"zhipu vendor", []string{"zhipu", "glm-4.6"}},
{"glm alias", []string{"glm", "glm-4.6"}},
{"glm by model name", []string{"", "glm-4.6"}},
{"chatglm by model name", []string{"", "chatglm-6b"}},
{"xiaomi vendor", []string{"xiaomi", "mimo-v2.5-pro"}},
{"mimo by model name", []string{"", "mimo-v2.5-pro"}},
{"mimo omni by model name", []string{"", "mimo-v2-omni"}},
{"mimo namespaced model name", []string{"", "xiaomi/mimo-v2.5-pro"}},
{"alibaba vendor", []string{"alibaba", "qwen3.6-plus"}},
{"qwen alias", []string{"qwen", "qwen-max"}},
{"qwq by model name", []string{"", "qwq-32b"}},
{"qvq by model name", []string{"", "qvq-max"}},
{"tongyi by model name", []string{"", "tongyi-deepresearch"}},
{"minimax vendor", []string{"minimax", "minimax-m2"}},
{"abab by model name", []string{"", "abab-6.5s"}},
{"hailuo by model name", []string{"", "hailuo-02"}},
}
for _, item := range required {
if !reasoningContentPassbackRequired(llm.AdapterOpenAIChatCompletions, item.candidates...) {
t.Fatalf("%s: expected Chat Completions route to require reasoning_content passback", item.name)
}
}

// 非 Chat Completions 协议不受影响。
if reasoningContentPassbackRequired(llm.AdapterOpenAIResponses, "moonshot", "kimi-k3") {
t.Fatal("expected Responses route to skip reasoning_content passback for moonshot")
}
if reasoningContentPassbackRequired(llm.AdapterOpenAIResponses, "zhipu", "glm-4.6") {
t.Fatal("expected Responses route to skip reasoning_content passback for zhipu")
}

// 未列入白名单的厂商保持关闭,避免向不接受该字段的上游发送多余入参。
for _, item := range []struct {
name string
candidates []string
}{
{"bytedance", []string{"bytedance", "doubao-seed-1-6"}},
{"doubao by model name", []string{"", "doubao-1-5-thinking-pro"}},
{"tencent", []string{"tencent", "hunyuan-turbos"}},
{"anthropic", []string{"anthropic", "claude-opus-4-8"}},
} {
if reasoningContentPassbackRequired(llm.AdapterOpenAIChatCompletions, item.candidates...) {
t.Fatalf("%s: expected vendor outside the passback allowlist to skip reasoning_content", item.name)
}
}
}

// 阿里 Qwen 只回传字段无效,必须同时下发 preserve_thinking;其余厂商不应收到该私有入参,
// OpenRouter 更要拦住——它有自己的 reasoning 字段与参数校验。
func TestReasoningPassbackRequestOptionsOnlyForAlibabaChatCompletions(t *testing.T) {
got := reasoningPassbackRequestOptions(llm.AdapterOpenAIChatCompletions, "alibaba", "qwen3.6-plus")
if len(got) != 1 || got["preserve_thinking"] != true {
t.Fatalf("expected preserve_thinking for alibaba chat completions, got %#v", got)
}
if byName := reasoningPassbackRequestOptions(llm.AdapterOpenAIChatCompletions, "", "qwq-32b"); byName["preserve_thinking"] != true {
t.Fatalf("expected model-name inference to require preserve_thinking, got %#v", byName)
}

// 返回值必须是副本,调用方改动不能污染包级变量。
got["preserve_thinking"] = false
got["injected"] = true
again := reasoningPassbackRequestOptions(llm.AdapterOpenAIChatCompletions, "alibaba", "qwen3.6-plus")
if len(again) != 1 || again["preserve_thinking"] != true {
t.Fatalf("package-level options were mutated by caller: %#v", again)
}

for _, item := range []struct {
name string
protocol string
candidates []string
}{
{"openrouter alibaba", llm.AdapterOpenRouterChat, []string{"alibaba", "qwen/qwen3-max"}},
{"responses alibaba", llm.AdapterOpenAIResponses, []string{"alibaba", "qwen3.6-plus"}},
{"minimax", llm.AdapterOpenAIChatCompletions, []string{"minimax", "minimax-m2"}},
{"deepseek", llm.AdapterOpenAIChatCompletions, []string{"deepseek", "deepseek-v4-flash-free"}},
{"moonshot", llm.AdapterOpenAIChatCompletions, []string{"moonshot", "kimi-k3"}},
{"zhipu", llm.AdapterOpenAIChatCompletions, []string{"zhipu", "glm-4.6"}},
{"xiaomi", llm.AdapterOpenAIChatCompletions, []string{"xiaomi", "mimo-v2.5-pro"}},
{"vendor outside allowlist", llm.AdapterOpenAIChatCompletions, []string{"anthropic", "claude-opus-4-8"}},
} {
if options := reasoningPassbackRequestOptions(item.protocol, item.candidates...); options != nil {
t.Fatalf("%s: expected no vendor request options, got %#v", item.name, options)
}
}
}

func TestNormalizeModelIconSeparatesVendorAndModelFamily(t *testing.T) {
tests := map[string]struct {
vendor string
Expand Down
1 change: 1 addition & 0 deletions backend/internal/application/channel/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ type ResolvedRoute struct {
ModelSystemPrompt string
UpstreamModel string
ReasoningContentPassback bool
ReasoningPassbackRequestOptions map[string]interface{}
UpstreamCbFailureThreshold int
UpstreamCbModelThreshold int
UpstreamCbThresholdLogic string
Expand Down
1 change: 1 addition & 0 deletions backend/internal/application/channel/service_routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ func buildResolvedRoute(row repository.ChannelUpstreamRouteRow, apiKey string) *
ModelSystemPrompt: strings.TrimSpace(row.ModelSystemPrompt),
UpstreamModel: strings.TrimSpace(row.UpstreamModelName),
ReasoningContentPassback: reasoningContentPassbackRequired(row.Protocol, row.ModelVendor, row.PlatformModelName, row.UpstreamModelName, row.UpstreamName),
ReasoningPassbackRequestOptions: reasoningPassbackRequestOptions(row.Protocol, row.ModelVendor, row.PlatformModelName, row.UpstreamModelName, row.UpstreamName),
UpstreamCbFailureThreshold: row.UpstreamCbFailureThreshold,
UpstreamCbModelThreshold: row.UpstreamCbModelThreshold,
UpstreamCbThresholdLogic: row.UpstreamCbThresholdLogic,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,17 +403,65 @@ func normalizeUpstreamModelVendor(raw string, candidates ...string) string {
return "unknown"
}

// reasoningContentPassbackVendors 列出 Chat Completions 协议下要求回传 reasoning_content 的厂商。
// 这些厂商的思考模型都要求历史 assistant 消息原样携带 reasoning_content:
// DeepSeek 与小米 MiMo 在历史含工具调用时缺失该字段会直接返回 400;Moonshot 同样返回 400;
// 智谱默认 clear_thinking=false 即保留式思考,官方明确裁剪或改写历史推理比完全不传更糟;
// 阿里 Qwen 需配合 preserve_thinking 入参(见 reasoningPassbackVendorRequestOptions)才会读取历史推理;
// MiniMax 自 M2 起 chat template 同时接受 content 内联 <think> 与 reasoning_content,
// 而应用层已把 <think> 统一收敛进 reasoning_content,无需额外解析。
//
// 仍未纳入:bytedance(Doubao) 由服务端自行判断历史思维链是否参与推理,缺乏一手证据表明回传必需。
var reasoningContentPassbackVendors = map[string]bool{
"deepseek": true,
"moonshot": true,
"zhipu": true,
"xiaomi": true,
"alibaba": true,
"minimax": true,
}

// reasoningPassbackVendorRequestOptions 列出「仅回传字段无效、必须同时下发」的厂商私有请求入参。
// 阿里百炼默认忽略 messages 里的历史 reasoning_content,须显式传 preserve_thinking=true
// 才会把历史推理拼接进下一轮输入;不传则只是白白付出推理 token 且不报错。
var reasoningPassbackVendorRequestOptions = map[string]map[string]interface{}{
"alibaba": {"preserve_thinking": true},
}

func reasoningContentPassbackRequired(protocol string, candidates ...string) bool {
switch llm.NormalizeAdapter(protocol) {
case llm.AdapterOpenRouterChat:
return true
case llm.AdapterOpenAIChatCompletions:
return detectModelVendor(candidates...) == "deepseek"
return reasoningContentPassbackVendors[detectModelVendor(candidates...)]
default:
return false
}
}

// reasoningPassbackRequestOptions 返回本路由回传生效时需附加的厂商私有请求入参副本。
// 仅在原生 Chat Completions 协议下生效:OpenRouter 有自己的 reasoning 字段与参数校验,
// 转发厂商私有顶层入参会被判为未知参数。返回副本避免调用方污染包级变量。
func reasoningPassbackRequestOptions(protocol string, candidates ...string) map[string]interface{} {
if llm.NormalizeAdapter(protocol) != llm.AdapterOpenAIChatCompletions {
return nil
}
vendor := detectModelVendor(candidates...)
// 与回传白名单联动,避免两张表漂移:回传都没开启时不应下发配套入参。
if !reasoningContentPassbackVendors[vendor] {
return nil
}
required := reasoningPassbackVendorRequestOptions[vendor]
if len(required) == 0 {
return nil
}
options := make(map[string]interface{}, len(required))
for key, value := range required {
options[key] = value
}
return options
}

func detectModelVendor(candidates ...string) string {
fallback := ""
for _, candidate := range candidates {
Expand Down
62 changes: 62 additions & 0 deletions backend/internal/application/conversation/model_option_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -773,3 +773,65 @@ func cloneModelOptionValue(value interface{}) interface{} {
return typed
}
}

// shouldApplyReasoningPassbackRequestOptions 判断本轮是否需要下发厂商私有的回传配套入参。
//
// 三个条件缺一不可:
// - 回传实际生效(路由能力 AND 用户设置),否则等于付费读历史推理却没有历史推理可读;
// - 该路由确有配套入参要求;
// - 本轮真实发送的历史里已存在非空推理。这些入参只影响「历史」思维链的处理方式,
// 首轮或非思考模型下发它没有收益,且能规避自建后端把未知顶层字段判为非法入参。
func shouldApplyReasoningPassbackRequestOptions(
passbackEnabled bool,
required map[string]interface{},
messages []llm.Message,
) bool {
if !passbackEnabled || len(required) == 0 {
return false
}
return promptCarriesAssistantReasoning(messages)
}

// promptCarriesAssistantReasoning 判断本轮真实发送的历史里是否已有非空 assistant 推理内容。
func promptCarriesAssistantReasoning(messages []llm.Message) bool {
for _, item := range messages {
if item.Role == "assistant" && strings.TrimSpace(item.ReasoningContent) != "" {
return true
}
}
return false
}

// withReasoningPassbackRequestOptions 补齐厂商要求的回传配套入参。
//
// 该入参属于协议正确性而非用户偏好,因此绕过管理员选项白名单——白名单收窄时不应让回传
// 静默退化成「传了字段但模型不读」。但用户或管理员显式声明过的值一律不覆盖:除了已过滤
// 结果,还需回看 rawOptions 与模型能力 defaultOptions,因为白名单模式会把未放行的键丢掉,
// 只看 options 会把管理员刻意设的 false 覆盖成 true。
func withReasoningPassbackRequestOptions(
options map[string]interface{},
required map[string]interface{},
rawOptions map[string]interface{},
capabilitiesJSON string,
) map[string]interface{} {
if len(required) == 0 {
return options
}
defaults := modelCapabilityDefaultOptions(capabilitiesJSON)
for key, value := range required {
if _, ok := options[key]; ok {
continue
}
if _, ok := rawOptions[key]; ok {
continue
}
if _, ok := defaults[key]; ok {
continue
}
if options == nil {
options = make(map[string]interface{}, len(required))
}
options[key] = value
}
return options
}
Loading
Loading