Files
easyai-ai-gateway/apps/api/internal/clients/chat_reasoning.go
T
easyai 9c093974a1 fix(gateway): 修复 Qwen3.8 推理参数并增加上游自愈
统一 Qwen3.8 固定思考、推理档位、预算互斥与温度下限规则,并覆盖 Chat Completions 和原生 Responses 请求。

新增安全参数纠正、有限重试和并发安全的进程内 LRU 学习;补充共享行为向量、httptest 回归与真实模型验收用例。

验证:go test ./... -count=1 通过,gofmt 检查通过;真实 Qwen3.8 流式、非流式及缓存重学习验收通过。
2026-08-01 22:50:45 +08:00

536 lines
14 KiB
Go

package clients
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max"
var (
chatReasoningEffortOrder = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}
openAIReasoningEfforts = map[string]struct{}{
"none": {},
"minimal": {},
"low": {},
"medium": {},
"high": {},
"xhigh": {},
"max": {},
}
volcesChatReasoningEfforts = map[string]struct{}{
"minimal": {},
"low": {},
"medium": {},
"high": {},
}
zhipuReasoningEfforts = map[string]struct{}{
"none": {},
"minimal": {},
"low": {},
"medium": {},
"high": {},
"xhigh": {},
}
)
func ValidateOpenAIReasoningEffort(value any) error {
effort := normalizedReasoningString(value)
if effort == "" {
return nil
}
if isOpenAIReasoningEffort(effort) {
return nil
}
return &ClientError{Code: "invalid_parameter", Message: OpenAIReasoningEffortValidationMessage, Retryable: false}
}
func applyOpenAIChatReasoningParams(body map[string]any, candidate store.RuntimeModelCandidate) {
effort := normalizedReasoningString(body["reasoning_effort"])
model := chatReasoningModelName(body, candidate)
if isAliyunBailianOpenAI(candidate) && isAliyunQwen38MaxPreview(model) {
applyAliyunQwen38Reasoning(body, effort)
if temperature, ok := finiteFloatFromAny(body["temperature"]); ok && temperature < 0.6 {
body["temperature"] = 0.6
}
return
}
if effort == "" || !isOpenAIReasoningEffort(effort) {
return
}
resolved, state := resolveCandidateReasoningEffort(effort, candidate)
if state == reasoningCapabilityUnsupported {
delete(body, "reasoning_effort")
return
}
if resolved != "" {
effort = resolved
}
body["reasoning_effort"] = effort
switch {
case isAliyunBailianOpenAI(candidate):
applyAliyunReasoning(body, candidate, effort)
case isDeepSeekOpenAI(candidate):
applyHighMaxThinkingReasoning(body, effort)
case isZhipuOpenAI(candidate):
applyZhipuReasoning(body, candidate, effort)
case isVolcesOpenAI(candidate):
applyVolcesReasoning(body, candidate, effort)
}
}
func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.RuntimeModelCandidate) {
reasoning, _ := body["reasoning"].(map[string]any)
if reasoning != nil {
reasoning = cloneBody(reasoning)
}
effort := ""
if reasoning != nil {
effort = normalizedReasoningString(reasoning["effort"])
}
model := chatReasoningModelName(body, candidate)
qwen38 := isAliyunBailianOpenAI(candidate) && isAliyunQwen38MaxPreview(model)
if qwen38 {
if effort == "" {
if enabled, ok := body["enable_thinking"].(bool); ok && !enabled {
effort = "low"
}
} else {
effort = qwen38MaxPreviewReasoningEffort(effort)
}
} else if effort != "" && isOpenAIReasoningEffort(effort) {
resolved, state := resolveCandidateReasoningEffort(effort, candidate)
if state == reasoningCapabilityUnsupported {
effort = ""
} else if resolved != "" {
effort = resolved
}
}
if effort != "" {
if reasoning == nil {
reasoning = map[string]any{}
}
reasoning["effort"] = effort
} else if reasoning != nil {
delete(reasoning, "effort")
}
if len(reasoning) > 0 {
body["reasoning"] = reasoning
} else {
delete(body, "reasoning")
}
delete(body, "reasoning_effort")
delete(body, "enable_thinking")
if qwen38 {
if temperature, ok := finiteFloatFromAny(body["temperature"]); ok && temperature < 0.6 {
body["temperature"] = 0.6
}
}
}
func applyAliyunQwen38Reasoning(body map[string]any, effort string) {
defer delete(body, "thinking_budget_tokens")
requestedDisable := false
if enabled, ok := body["enable_thinking"].(bool); ok && !enabled {
requestedDisable = true
}
body["enable_thinking"] = true
budget, hasBudget := nonNegativeIntFromAny(body["thinking_budget"])
if !hasBudget {
budget, hasBudget = nonNegativeIntFromAny(body["thinking_budget_tokens"])
}
if hasBudget {
if budget > 262144 {
budget = 262144
}
body["thinking_budget"] = budget
delete(body, "reasoning_effort")
return
}
delete(body, "thinking_budget")
if effort == "" {
if requestedDisable {
body["reasoning_effort"] = "low"
}
return
}
body["reasoning_effort"] = qwen38MaxPreviewReasoningEffort(effort)
}
func qwen38MaxPreviewReasoningEffort(effort string) string {
switch effort {
case "none", "minimal", "low":
return "low"
case "medium":
return "medium"
default:
return "xhigh"
}
}
type reasoningCapabilityState int
const (
reasoningCapabilityUnknown reasoningCapabilityState = iota
reasoningCapabilityUnsupported
reasoningCapabilitySupported
)
func resolveCandidateReasoningEffort(effort string, candidate store.RuntimeModelCandidate) (string, reasoningCapabilityState) {
supported := map[string]struct{}{}
declaredLevels := false
explicitlyUnsupported := false
explicitlySupported := false
for _, key := range []string{"text_generate", "tools_call", "image_analysis", "video_understanding", "audio_understanding", "omni"} {
capability, ok := candidate.Capabilities[key].(map[string]any)
if !ok {
continue
}
if value, ok := capability["supportThinking"].(bool); ok && !value {
explicitlyUnsupported = true
continue
}
if value, ok := capability["supportThinking"].(bool); ok && value {
explicitlySupported = true
}
rawLevels, ok := capability["thinkingEffortLevels"].([]any)
if !ok {
if stringsValue, stringsOK := capability["thinkingEffortLevels"].([]string); stringsOK {
rawLevels = make([]any, len(stringsValue))
for index, value := range stringsValue {
rawLevels[index] = value
}
ok = true
}
}
if !ok {
continue
}
declaredLevels = true
for _, raw := range rawLevels {
level := normalizedReasoningString(raw)
if isOpenAIReasoningEffort(level) {
supported[level] = struct{}{}
}
}
}
if len(supported) == 0 {
if declaredLevels || (explicitlyUnsupported && !explicitlySupported) {
return "", reasoningCapabilityUnsupported
}
return effort, reasoningCapabilityUnknown
}
requestedIndex := reasoningEffortIndex(effort)
best := ""
bestDistance := len(chatReasoningEffortOrder) + 1
for index, candidateEffort := range chatReasoningEffortOrder {
if _, ok := supported[candidateEffort]; !ok {
continue
}
distance := index - requestedIndex
if distance < 0 {
distance = -distance
}
if distance < bestDistance {
best = candidateEffort
bestDistance = distance
}
}
return best, reasoningCapabilitySupported
}
func reasoningEffortIndex(effort string) int {
for index, value := range chatReasoningEffortOrder {
if value == effort {
return index
}
}
return 0
}
func applyAliyunReasoning(body map[string]any, candidate store.RuntimeModelCandidate, effort string) {
defer delete(body, "thinking_budget_tokens")
budget, hasBudget := positiveIntFromAny(body["thinking_budget"])
if !hasBudget {
budget, hasBudget = positiveIntFromAny(body["thinking_budget_tokens"])
}
delete(body, "thinking_budget")
if effort == "none" {
body["enable_thinking"] = false
delete(body, "reasoning_effort")
return
}
body["enable_thinking"] = true
model := chatReasoningModelName(body, candidate)
if isAliyunQwen38MaxPreview(model) {
if budget, ok := positiveIntFromAny(body["thinking_budget_tokens"]); ok {
body["thinking_budget"] = budget
delete(body, "reasoning_effort")
return
}
body["reasoning_effort"] = qwen38MaxPreviewReasoningEffort(effort)
return
}
if isAliyunHighMaxReasoningModel(model) {
body["reasoning_effort"] = highMaxReasoningEffort(effort)
return
}
delete(body, "reasoning_effort")
if isAliyunThinkingBudgetModel(model) {
if hasBudget {
body["thinking_budget"] = budget
}
}
}
func applyHighMaxThinkingReasoning(body map[string]any, effort string) {
if effort == "none" {
body["thinking"] = map[string]any{"type": "disabled"}
delete(body, "reasoning_effort")
return
}
body["thinking"] = map[string]any{"type": "enabled"}
body["reasoning_effort"] = highMaxReasoningEffort(effort)
}
func applyZhipuReasoning(body map[string]any, candidate store.RuntimeModelCandidate, effort string) {
if effort == "none" {
body["thinking"] = map[string]any{"type": "disabled"}
delete(body, "reasoning_effort")
return
}
body["thinking"] = map[string]any{"type": "enabled"}
if !isZhipuReasoningEffortModel(chatReasoningModelName(body, candidate)) {
delete(body, "reasoning_effort")
return
}
if mapped := zhipuReasoningEffort(effort); mapped != "" {
body["reasoning_effort"] = mapped
return
}
delete(body, "reasoning_effort")
}
func applyVolcesReasoning(body map[string]any, _ store.RuntimeModelCandidate, effort string) {
if effort == "none" {
body["thinking"] = map[string]any{"type": "disabled"}
delete(body, "reasoning_effort")
return
}
body["thinking"] = map[string]any{"type": "enabled"}
if mapped := volcesChatReasoningEffort(effort); mapped != "" {
body["reasoning_effort"] = mapped
return
}
delete(body, "reasoning_effort")
}
func normalizedReasoningString(value any) string {
if value == nil {
return ""
}
text, ok := value.(string)
if !ok {
text = fmt.Sprint(value)
}
return strings.ToLower(strings.TrimSpace(text))
}
func isOpenAIReasoningEffort(effort string) bool {
_, ok := openAIReasoningEfforts[effort]
return ok
}
func chatReasoningModelName(body map[string]any, candidate store.RuntimeModelCandidate) string {
for _, value := range []any{
body["model"],
candidate.ProviderModelName,
candidate.ModelName,
candidate.ModelAlias,
} {
if text := normalizedReasoningString(value); text != "" {
return text
}
}
return ""
}
func providerCode(candidate store.RuntimeModelCandidate) string {
return normalizedReasoningString(candidate.Provider)
}
func baseURLCode(candidate store.RuntimeModelCandidate) string {
return normalizedReasoningString(candidate.BaseURL)
}
func isAliyunBailianOpenAI(candidate store.RuntimeModelCandidate) bool {
provider := providerCode(candidate)
baseURL := baseURLCode(candidate)
return provider == "aliyun-bailian-openai" || strings.Contains(baseURL, "dashscope.")
}
func isVolcesOpenAI(candidate store.RuntimeModelCandidate) bool {
provider := providerCode(candidate)
baseURL := baseURLCode(candidate)
return provider == "volces-openai" || strings.Contains(baseURL, "volces.com") || strings.Contains(baseURL, "byteplus.com")
}
func isDeepSeekOpenAI(candidate store.RuntimeModelCandidate) bool {
provider := providerCode(candidate)
baseURL := baseURLCode(candidate)
return provider == "deepseek-openai" || strings.Contains(baseURL, "api.deepseek.com")
}
func isZhipuOpenAI(candidate store.RuntimeModelCandidate) bool {
provider := providerCode(candidate)
baseURL := baseURLCode(candidate)
return provider == "zhipu-openai" || strings.Contains(baseURL, "bigmodel.cn") || strings.Contains(baseURL, "api.z.ai")
}
func isAliyunHighMaxReasoningModel(model string) bool {
return strings.Contains(model, "deepseek-v4") || strings.HasPrefix(model, "glm-")
}
func isAliyunQwen38MaxPreview(model string) bool {
return strings.Contains(model, "qwen3.8-max-preview")
}
func isAliyunThinkingBudgetModel(model string) bool {
return strings.Contains(model, "qwen") || strings.Contains(model, "qwq") || strings.Contains(model, "qvq") || strings.Contains(model, "kimi")
}
func isVolcesReasoningEffortModel(model string) bool {
return strings.HasPrefix(model, "doubao-seed-2-")
}
func isZhipuReasoningEffortModel(model string) bool {
return model == "" || strings.HasPrefix(model, "glm-5.2") || strings.HasPrefix(model, "glm-5-2")
}
func highMaxReasoningEffort(effort string) string {
if effort == "xhigh" || effort == "max" {
return "max"
}
return "high"
}
func zhipuReasoningEffort(effort string) string {
if effort == "max" {
return "xhigh"
}
if _, ok := zhipuReasoningEfforts[effort]; ok {
return effort
}
return ""
}
func volcesChatReasoningEffort(effort string) string {
switch effort {
case "none":
return "minimal"
case "xhigh", "max":
return "high"
default:
if _, ok := volcesChatReasoningEfforts[effort]; ok {
return effort
}
return ""
}
}
func positiveIntFromAny(value any) (int, bool) {
if value == nil {
return 0, false
}
var number float64
switch typed := value.(type) {
case int:
number = float64(typed)
case int64:
number = float64(typed)
case float64:
number = typed
case float32:
number = float64(typed)
case jsonNumber:
parsed, err := typed.Float64()
if err != nil {
return 0, false
}
number = parsed
case string:
parsed := normalizedReasoningString(typed)
if parsed == "" {
return 0, false
}
value, err := strconv.ParseFloat(parsed, 64)
if err != nil {
return 0, false
}
number = value
default:
return 0, false
}
if math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
return 0, false
}
return int(math.Floor(number)), true
}
func nonNegativeIntFromAny(value any) (int, bool) {
number, ok := finiteFloatFromAny(value)
if !ok || number < 0 {
return 0, false
}
return int(math.Floor(number)), true
}
func finiteFloatFromAny(value any) (float64, bool) {
if value == nil {
return 0, false
}
var number float64
switch typed := value.(type) {
case int:
number = float64(typed)
case int64:
number = float64(typed)
case float64:
number = typed
case float32:
number = float64(typed)
case jsonNumber:
parsed, err := typed.Float64()
if err != nil {
return 0, false
}
number = parsed
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
if err != nil {
return 0, false
}
number = parsed
default:
return 0, false
}
if math.IsNaN(number) || math.IsInf(number, 0) {
return 0, false
}
return number, true
}
type jsonNumber interface {
Float64() (float64, error)
}