原生 Chat/Responses 改为透明转发,保留标准工具结构并保护调用方显式参数。补齐 Responses 到 Chat 的兼容转换、协议路由边界、完整响应和流式事件,并同步更新 Swagger、回归测试与真实验收脚本。 验证: - cd apps/api && env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1 - pnpm openapi - pnpm lint - pnpm test - pnpm build - gofmt -l 无输出 - git diff --check 通过 风险: - Chat 回退无法等价表达的 Responses 原生能力现在会返回 unsupported_response_parameter - 真实供应商 E2E 因本地没有已启用的平台模型候选而未完成
613 lines
17 KiB
Go
613 lines
17 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) {
|
|
_ = applyOpenAIChatReasoningParamsWithSource(body, candidate, nil)
|
|
}
|
|
|
|
func applyOpenAIChatReasoningParamsWithSource(body map[string]any, candidate store.RuntimeModelCandidate, original map[string]any) error {
|
|
effort := normalizedReasoningString(body["reasoning_effort"])
|
|
_, explicitEffort := original["reasoning_effort"]
|
|
_, explicitTemperature := original["temperature"]
|
|
model := chatReasoningModelName(body, candidate)
|
|
if isAliyunBailianOpenAI(candidate) && isAliyunQwen38MaxPreview(model) {
|
|
if explicitEffort && qwen38MaxPreviewReasoningEffort(effort) != effort {
|
|
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream only supports low, medium, or xhigh without changing the requested reasoning semantics")
|
|
}
|
|
if temperature, ok := finiteFloatFromAny(body["temperature"]); explicitTemperature && ok && temperature < 0.6 {
|
|
return explicitParameterAdaptationError("temperature", "the selected upstream requires temperature >= 0.6")
|
|
}
|
|
applyAliyunQwen38Reasoning(body, effort)
|
|
if temperature, ok := finiteFloatFromAny(body["temperature"]); ok && temperature < 0.6 {
|
|
body["temperature"] = 0.6
|
|
}
|
|
return nil
|
|
}
|
|
if effort == "" || !isOpenAIReasoningEffort(effort) {
|
|
return nil
|
|
}
|
|
resolved, state := resolveCandidateReasoningEffort(effort, candidate)
|
|
if state == reasoningCapabilityUnsupported {
|
|
if explicitEffort {
|
|
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream does not support reasoning_effort")
|
|
}
|
|
delete(body, "reasoning_effort")
|
|
return nil
|
|
}
|
|
if resolved != "" {
|
|
if explicitEffort && resolved != effort {
|
|
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream does not support the requested reasoning effort exactly")
|
|
}
|
|
effort = resolved
|
|
}
|
|
if explicitEffort {
|
|
if err := validateExplicitProviderReasoningEffort(effort, candidate, model); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateExplicitProviderReasoningEffort(effort string, candidate store.RuntimeModelCandidate, model string) error {
|
|
if effort == "none" {
|
|
return nil
|
|
}
|
|
unsupported := func() error {
|
|
return explicitParameterAdaptationError("reasoning_effort", "the selected upstream cannot preserve the requested reasoning effort exactly")
|
|
}
|
|
switch {
|
|
case isAliyunBailianOpenAI(candidate):
|
|
if !isAliyunHighMaxReasoningModel(model) || highMaxReasoningEffort(effort) != effort {
|
|
return unsupported()
|
|
}
|
|
case isDeepSeekOpenAI(candidate):
|
|
if highMaxReasoningEffort(effort) != effort {
|
|
return unsupported()
|
|
}
|
|
case isZhipuOpenAI(candidate):
|
|
if !isZhipuReasoningEffortModel(model) || zhipuReasoningEffort(effort) != effort {
|
|
return unsupported()
|
|
}
|
|
case isVolcesOpenAI(candidate):
|
|
if !isVolcesReasoningEffortModel(model) || volcesChatReasoningEffort(effort) != effort {
|
|
return unsupported()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyOpenAIResponsesReasoningParams(body map[string]any, candidate store.RuntimeModelCandidate) {
|
|
_ = applyOpenAIResponsesReasoningParamsWithSource(body, candidate, nil)
|
|
}
|
|
|
|
func applyOpenAIResponsesReasoningParamsWithSource(body map[string]any, candidate store.RuntimeModelCandidate, original map[string]any) error {
|
|
reasoning, _ := body["reasoning"].(map[string]any)
|
|
if reasoning != nil {
|
|
reasoning = cloneBody(reasoning)
|
|
}
|
|
effort := ""
|
|
if reasoning != nil {
|
|
effort = normalizedReasoningString(reasoning["effort"])
|
|
}
|
|
originalReasoning, _ := original["reasoning"].(map[string]any)
|
|
_, explicitEffort := originalReasoning["effort"]
|
|
_, explicitTemperature := original["temperature"]
|
|
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 {
|
|
mapped := qwen38MaxPreviewReasoningEffort(effort)
|
|
if explicitEffort && mapped != effort {
|
|
return explicitParameterAdaptationError("reasoning.effort", "the selected upstream only supports low, medium, or xhigh without changing the requested reasoning semantics")
|
|
}
|
|
effort = mapped
|
|
}
|
|
} else if effort != "" && isOpenAIReasoningEffort(effort) {
|
|
resolved, state := resolveCandidateReasoningEffort(effort, candidate)
|
|
if state == reasoningCapabilityUnsupported {
|
|
if explicitEffort {
|
|
return explicitParameterAdaptationError("reasoning.effort", "the selected upstream does not support reasoning.effort")
|
|
}
|
|
effort = ""
|
|
} else if resolved != "" {
|
|
if explicitEffort && resolved != effort {
|
|
return explicitParameterAdaptationError("reasoning.effort", "the selected upstream does not support the requested reasoning effort exactly")
|
|
}
|
|
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 {
|
|
if explicitTemperature {
|
|
return explicitParameterAdaptationError("temperature", "the selected upstream requires temperature >= 0.6")
|
|
}
|
|
body["temperature"] = 0.6
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func explicitParameterAdaptationError(param string, message string) error {
|
|
return &ClientError{Code: "invalid_parameter", Message: message, Param: param, StatusCode: 400, Retryable: false}
|
|
}
|
|
|
|
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)
|
|
}
|