feat(runtime): 适配推理模式开关
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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"
|
||||
|
||||
var (
|
||||
openAIReasoningEfforts = map[string]struct{}{
|
||||
"none": {},
|
||||
"minimal": {},
|
||||
"low": {},
|
||||
"medium": {},
|
||||
"high": {},
|
||||
"xhigh": {},
|
||||
}
|
||||
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"])
|
||||
if effort == "" || !isOpenAIReasoningEffort(effort) {
|
||||
return
|
||||
}
|
||||
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 applyAliyunReasoning(body map[string]any, candidate store.RuntimeModelCandidate, effort string) {
|
||||
defer delete(body, "thinking_budget_tokens")
|
||||
|
||||
if effort == "none" {
|
||||
body["enable_thinking"] = false
|
||||
delete(body, "reasoning_effort")
|
||||
return
|
||||
}
|
||||
|
||||
body["enable_thinking"] = true
|
||||
model := chatReasoningModelName(body, candidate)
|
||||
if isAliyunHighMaxReasoningModel(model) {
|
||||
body["reasoning_effort"] = highMaxReasoningEffort(effort)
|
||||
return
|
||||
}
|
||||
|
||||
delete(body, "reasoning_effort")
|
||||
if isAliyunThinkingBudgetModel(model) {
|
||||
if budget, ok := positiveIntFromAny(body["thinking_budget_tokens"]); ok {
|
||||
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 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" {
|
||||
return "max"
|
||||
}
|
||||
return "high"
|
||||
}
|
||||
|
||||
func zhipuReasoningEffort(effort string) string {
|
||||
if _, ok := zhipuReasoningEfforts[effort]; ok {
|
||||
return effort
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func volcesChatReasoningEffort(effort string) string {
|
||||
switch effort {
|
||||
case "none":
|
||||
return "minimal"
|
||||
case "xhigh":
|
||||
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
|
||||
}
|
||||
|
||||
type jsonNumber interface {
|
||||
Float64() (float64, error)
|
||||
}
|
||||
@@ -343,6 +343,190 @@ func TestOpenAIClientChatContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIClientChatReasoningParamsByProvider(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
model string
|
||||
baseURL string
|
||||
body map[string]any
|
||||
assertion func(t *testing.T, body map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "generic openai keeps openai reasoning effort",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
if body["reasoning_effort"] != "xhigh" {
|
||||
t.Fatalf("generic openai should keep xhigh, got %+v", body)
|
||||
}
|
||||
if _, ok := body["thinking"]; ok {
|
||||
t.Fatalf("generic openai should not receive thinking object: %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aliyun qwen enables thinking and maps budget",
|
||||
provider: "aliyun-bailian-openai",
|
||||
model: "qwen3.7-plus",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "high",
|
||||
"thinking_budget_tokens": 4096,
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
if body["enable_thinking"] != true || body["thinking_budget"] != float64(4096) {
|
||||
t.Fatalf("aliyun qwen should use enable_thinking/thinking_budget, got %+v", body)
|
||||
}
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("aliyun qwen should not receive reasoning_effort: %+v", body)
|
||||
}
|
||||
if _, ok := body["thinking_budget_tokens"]; ok {
|
||||
t.Fatalf("internal thinking_budget_tokens should be removed: %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "aliyun qwen disables thinking for none",
|
||||
provider: "aliyun-bailian-openai",
|
||||
model: "qwen3.7-plus",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "none",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
if body["enable_thinking"] != false {
|
||||
t.Fatalf("aliyun qwen none should disable thinking, got %+v", body)
|
||||
}
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("aliyun qwen none should remove reasoning_effort: %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same deepseek model on aliyun uses aliyun protocol",
|
||||
provider: "aliyun-bailian-openai",
|
||||
model: "deepseek-v4",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
if body["enable_thinking"] != true || body["reasoning_effort"] != "max" {
|
||||
t.Fatalf("aliyun deepseek should use enable_thinking and max, got %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deepseek official uses thinking object and high max effort",
|
||||
provider: "deepseek-openai",
|
||||
model: "deepseek-v4",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
thinking, _ := body["thinking"].(map[string]any)
|
||||
if thinking["type"] != "enabled" || body["reasoning_effort"] != "max" {
|
||||
t.Fatalf("deepseek official should use thinking enabled and max, got %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "volces seed none disables thinking",
|
||||
provider: "volces-openai",
|
||||
model: "doubao-seed-2-0-pro-260215",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "none",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
thinking, _ := body["thinking"].(map[string]any)
|
||||
if thinking["type"] != "disabled" {
|
||||
t.Fatalf("volces seed none should disable thinking, got %+v", body)
|
||||
}
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("volces seed none should remove reasoning_effort: %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "volces seed enables thinking and maps xhigh",
|
||||
provider: "volces-openai",
|
||||
model: "doubao-seed-2-0-pro-260215",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
thinking, _ := body["thinking"].(map[string]any)
|
||||
if thinking["type"] != "enabled" || body["reasoning_effort"] != "high" {
|
||||
t.Fatalf("volces seed xhigh should enable thinking and map to high, got %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zhipu preserves xhigh for glm 5.2",
|
||||
provider: "zhipu-openai",
|
||||
model: "glm-5.2",
|
||||
body: map[string]any{
|
||||
"reasoning_effort": "xhigh",
|
||||
},
|
||||
assertion: func(t *testing.T, body map[string]any) {
|
||||
thinking, _ := body["thinking"].(map[string]any)
|
||||
if thinking["type"] != "enabled" || body["reasoning_effort"] != "xhigh" {
|
||||
t.Fatalf("zhipu glm should use thinking enabled and preserve xhigh, got %+v", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var captured map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-reasoning",
|
||||
"object": "chat.completion",
|
||||
"model": captured["model"],
|
||||
"choices": []any{map[string]any{
|
||||
"message": map[string]any{"role": "assistant", "content": "ok"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
body := map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||
}
|
||||
for key, value := range tc.body {
|
||||
body[key] = value
|
||||
}
|
||||
baseURL := tc.baseURL
|
||||
if baseURL == "" {
|
||||
baseURL = server.URL
|
||||
}
|
||||
|
||||
_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
|
||||
Kind: "chat.completions",
|
||||
Model: tc.model,
|
||||
Body: body,
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: tc.provider,
|
||||
BaseURL: baseURL,
|
||||
ProviderModelName: tc.model,
|
||||
ModelName: tc.model,
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run openai client: %v", err)
|
||||
}
|
||||
tc.assertion(t, captured)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageFromOpenAIUsageTracksKnownCachedInputZero(t *testing.T) {
|
||||
knownZero := usageFromOpenAIUsage(map[string]any{
|
||||
"prompt_tokens": 100,
|
||||
|
||||
@@ -27,6 +27,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
body := cloneBody(request.Body)
|
||||
if request.Kind == "chat.completions" {
|
||||
body = NormalizeChatCompletionRequestBody(body)
|
||||
applyOpenAIChatReasoningParams(body, request.Candidate)
|
||||
}
|
||||
body["model"] = upstreamModelName(request.Candidate)
|
||||
stream := openAIEndpointSupportsStream(request.Kind) && (request.Stream || boolValue(body, "stream"))
|
||||
|
||||
Reference in New Issue
Block a user