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) {
|
func TestUsageFromOpenAIUsageTracksKnownCachedInputZero(t *testing.T) {
|
||||||
knownZero := usageFromOpenAIUsage(map[string]any{
|
knownZero := usageFromOpenAIUsage(map[string]any{
|
||||||
"prompt_tokens": 100,
|
"prompt_tokens": 100,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
|||||||
body := cloneBody(request.Body)
|
body := cloneBody(request.Body)
|
||||||
if request.Kind == "chat.completions" {
|
if request.Kind == "chat.completions" {
|
||||||
body = NormalizeChatCompletionRequestBody(body)
|
body = NormalizeChatCompletionRequestBody(body)
|
||||||
|
applyOpenAIChatReasoningParams(body, request.Candidate)
|
||||||
}
|
}
|
||||||
body["model"] = upstreamModelName(request.Candidate)
|
body["model"] = upstreamModelName(request.Candidate)
|
||||||
stream := openAIEndpointSupportsStream(request.Kind) && (request.Stream || boolValue(body, "stream"))
|
stream := openAIEndpointSupportsStream(request.Kind) && (request.Stream || boolValue(body, "stream"))
|
||||||
|
|||||||
@@ -138,6 +138,27 @@ func TestWriteCompatibleTaskResponseReturnsJSONWhenStreamIsFalse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteCompatibleTaskResponseMapsInvalidParameterToBadRequest(t *testing.T) {
|
||||||
|
executor := &fakeTaskExecutor{
|
||||||
|
runErr: &clients.ClientError{
|
||||||
|
Code: "invalid_parameter",
|
||||||
|
Message: "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh",
|
||||||
|
Retryable: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
writeCompatibleTaskResponse(context.Background(), recorder, req, executor, "chat.completions", "gpt-test", store.GatewayTask{ID: "task-test"}, &auth.User{}, false, false)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d want=%d body=%s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "invalid_parameter") {
|
||||||
|
t.Fatalf("response should include invalid_parameter code: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) {
|
func TestWriteCompatibleTaskResponseReturnsSSEWhenStreamIsTrue(t *testing.T) {
|
||||||
executor := &fakeTaskExecutor{
|
executor := &fakeTaskExecutor{
|
||||||
deltas: []clients.StreamDeltaEvent{{Text: "hel"}, {Text: "lo"}},
|
deltas: []clients.StreamDeltaEvent{{Text: "hel"}, {Text: "lo"}},
|
||||||
@@ -284,15 +305,22 @@ type fakeTaskExecutor struct {
|
|||||||
streamCalls int
|
streamCalls int
|
||||||
deltas []clients.StreamDeltaEvent
|
deltas []clients.StreamDeltaEvent
|
||||||
output map[string]any
|
output map[string]any
|
||||||
|
runErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeTaskExecutor) Execute(context.Context, store.GatewayTask, *auth.User) (runner.Result, error) {
|
func (f *fakeTaskExecutor) Execute(context.Context, store.GatewayTask, *auth.User) (runner.Result, error) {
|
||||||
f.executeCalls++
|
f.executeCalls++
|
||||||
|
if f.runErr != nil {
|
||||||
|
return runner.Result{}, f.runErr
|
||||||
|
}
|
||||||
return runner.Result{Output: f.output}, nil
|
return runner.Result{Output: f.output}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeTaskExecutor) ExecuteStream(_ context.Context, _ store.GatewayTask, _ *auth.User, onDelta clients.StreamDelta) (runner.Result, error) {
|
func (f *fakeTaskExecutor) ExecuteStream(_ context.Context, _ store.GatewayTask, _ *auth.User, onDelta clients.StreamDelta) (runner.Result, error) {
|
||||||
f.streamCalls++
|
f.streamCalls++
|
||||||
|
if f.runErr != nil {
|
||||||
|
return runner.Result{}, f.runErr
|
||||||
|
}
|
||||||
for _, delta := range f.deltas {
|
for _, delta := range f.deltas {
|
||||||
if err := onDelta(delta); err != nil {
|
if err := onDelta(delta); err != nil {
|
||||||
return runner.Result{}, err
|
return runner.Result{}, err
|
||||||
|
|||||||
@@ -1306,7 +1306,7 @@ func scopeForTaskKind(kind string) string {
|
|||||||
|
|
||||||
func statusFromRunError(err error) int {
|
func statusFromRunError(err error) int {
|
||||||
switch {
|
switch {
|
||||||
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
|
case clients.ErrorCode(err) == "bad_request" || clients.ErrorCode(err) == "invalid_parameter" || clients.ErrorCode(err) == "cloned_voice_expired" || clients.ErrorCode(err) == "cloned_voice_unavailable" || clients.ErrorCode(err) == "cloned_voice_platform_unavailable" || clients.ErrorCode(err) == "unsupported_operation" || clients.ErrorCode(err) == "invalid_proxy":
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
case clients.ErrorCode(err) == "cloned_voice_not_found":
|
case clients.ErrorCode(err) == "cloned_voice_not_found":
|
||||||
return http.StatusNotFound
|
return http.StatusNotFound
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ type TaskRequest struct {
|
|||||||
Stream bool `json:"stream,omitempty" example:"false"`
|
Stream bool `json:"stream,omitempty" example:"false"`
|
||||||
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
|
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
|
||||||
// ReasoningEffort 推理深度,OpenAI-compatible 请求字段;开放字符串,取值随 provider 和模型能力而定,常见值为 none、minimal、low、medium、high、xhigh,也可配置 max 等供应商自定义值。
|
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
|
||||||
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
|
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
|
||||||
Size string `json:"size,omitempty" example:"1024x1024"`
|
Size string `json:"size,omitempty" example:"1024x1024"`
|
||||||
Duration int `json:"duration,omitempty" example:"5"`
|
Duration int `json:"duration,omitempty" example:"5"`
|
||||||
@@ -216,7 +216,7 @@ type ChatCompletionRequest struct {
|
|||||||
Messages []ChatMessage `json:"messages"`
|
Messages []ChatMessage `json:"messages"`
|
||||||
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
|
Temperature float64 `json:"temperature,omitempty" example:"0.7"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
|
MaxTokens int `json:"max_tokens,omitempty" example:"512"`
|
||||||
// ReasoningEffort 推理深度,OpenAI-compatible 请求字段;开放字符串,取值随 provider 和模型能力而定,常见值为 none、minimal、low、medium、high、xhigh,也可配置 max 等供应商自定义值。
|
// ReasoningEffort 推理强度,OpenAI-compatible 请求字段;仅支持 none、minimal、low、medium、high、xhigh。供应商自定义取值由网关按平台适配。
|
||||||
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
|
ReasoningEffort string `json:"reasoning_effort,omitempty" example:"medium"`
|
||||||
Stream bool `json:"stream,omitempty" example:"false"`
|
Stream bool `json:"stream,omitempty" example:"false"`
|
||||||
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
RunMode string `json:"runMode,omitempty" example:"simulation"`
|
||||||
|
|||||||
@@ -1295,6 +1295,9 @@ func validateRequest(kind string, body map[string]any) error {
|
|||||||
if body["messages"] == nil {
|
if body["messages"] == nil {
|
||||||
return errors.New("messages is required")
|
return errors.New("messages is required")
|
||||||
}
|
}
|
||||||
|
if err := clients.ValidateOpenAIReasoningEffort(body["reasoning_effort"]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
case "responses":
|
case "responses":
|
||||||
if body["input"] == nil && body["messages"] == nil {
|
if body["input"] == nil && body["messages"] == nil {
|
||||||
return errors.New("input or messages is required")
|
return errors.New("input or messages is required")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||||
@@ -14,6 +15,37 @@ func (namedClient) Run(context.Context, clients.Request) (clients.Response, erro
|
|||||||
return clients.Response{}, nil
|
return clients.Response{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRequestAcceptsOpenAIReasoningEffort(t *testing.T) {
|
||||||
|
for _, effort := range []string{"none", "minimal", "low", "medium", "high", "xhigh"} {
|
||||||
|
t.Run(effort, func(t *testing.T) {
|
||||||
|
err := validateRequest("chat.completions", map[string]any{
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||||
|
"reasoning_effort": effort,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected %s to be accepted: %v", effort, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRequestRejectsNonOpenAIReasoningEffort(t *testing.T) {
|
||||||
|
for _, effort := range []string{"max", "auto"} {
|
||||||
|
t.Run(effort, func(t *testing.T) {
|
||||||
|
err := validateRequest("chat.completions", map[string]any{
|
||||||
|
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
|
||||||
|
"reasoning_effort": effort,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected %s to be rejected", effort)
|
||||||
|
}
|
||||||
|
if clients.ErrorCode(err) != "invalid_parameter" || !strings.Contains(err.Error(), clients.OpenAIReasoningEffortValidationMessage) {
|
||||||
|
t.Fatalf("unexpected validation error for %s: %T %v", effort, err, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClientForMapsGoogleGeminiSpecToGeminiClient(t *testing.T) {
|
func TestClientForMapsGoogleGeminiSpecToGeminiClient(t *testing.T) {
|
||||||
service := &Service{clients: map[string]clients.Client{
|
service := &Service{clients: map[string]clients.Client{
|
||||||
"gemini": namedClient("gemini"),
|
"gemini": namedClient("gemini"),
|
||||||
|
|||||||
@@ -160,18 +160,17 @@ WHERE p.status = 'enabled'
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
OR (
|
OR (
|
||||||
COALESCE(m.model_alias, '') = ''
|
m.model_name = $1::text
|
||||||
AND (
|
OR COALESCE(NULLIF(m.provider_model_name, ''), m.model_name) = $1::text
|
||||||
m.model_name = $1::text
|
OR b.canonical_model_key = $1::text
|
||||||
OR b.canonical_model_key = $1::text
|
OR b.provider_model_name = $1::text
|
||||||
OR b.provider_model_name = $1::text
|
OR (
|
||||||
OR (
|
NULLIF($3::text, '') IS NOT NULL
|
||||||
NULLIF($3::text, '') IS NOT NULL
|
AND (
|
||||||
AND (
|
regexp_replace(COALESCE(m.model_name, ''), '[[:space:]]+', '', 'g') = $3::text
|
||||||
regexp_replace(COALESCE(m.model_name, ''), '[[:space:]]+', '', 'g') = $3::text
|
OR regexp_replace(COALESCE(NULLIF(m.provider_model_name, ''), m.model_name), '[[:space:]]+', '', 'g') = $3::text
|
||||||
OR regexp_replace(COALESCE(b.canonical_model_key, ''), '[[:space:]]+', '', 'g') = $3::text
|
OR regexp_replace(COALESCE(b.canonical_model_key, ''), '[[:space:]]+', '', 'g') = $3::text
|
||||||
OR regexp_replace(COALESCE(b.provider_model_name, ''), '[[:space:]]+', '', 'g') = $3::text
|
OR regexp_replace(COALESCE(b.provider_model_name, ''), '[[:space:]]+', '', 'g') = $3::text
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const textFields: FieldDefinition[] = [
|
|||||||
{ key: 'max_input_tokens', label: '最大输入 Token', placeholder: '64000', type: 'number' },
|
{ key: 'max_input_tokens', label: '最大输入 Token', placeholder: '64000', type: 'number' },
|
||||||
{ key: 'max_output_tokens', label: '最大输出 Token', placeholder: '8192', type: 'number' },
|
{ key: 'max_output_tokens', label: '最大输出 Token', placeholder: '8192', type: 'number' },
|
||||||
{ key: 'max_thinking_tokens', label: '最大思考 Token', placeholder: '32768', type: 'number' },
|
{ key: 'max_thinking_tokens', label: '最大思考 Token', placeholder: '32768', type: 'number' },
|
||||||
{ key: 'thinkingEffortLevels', label: '推理深度', hint: '声明模型支持的 reasoning_effort 取值,可填写 max 等供应商自定义值', placeholder: 'none, minimal, low, medium, high, xhigh, max', type: 'list' },
|
{ key: 'thinkingEffortLevels', label: '推理深度', hint: '声明模型支持的 OpenAI reasoning_effort 取值,平台差异由网关适配', placeholder: 'none, minimal, low, medium, high, xhigh', type: 'list' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const embeddingFields: FieldDefinition[] = [
|
const embeddingFields: FieldDefinition[] = [
|
||||||
@@ -536,7 +536,7 @@ const imageAspectRatioOptions = [
|
|||||||
'7:4',
|
'7:4',
|
||||||
'4:7',
|
'4:7',
|
||||||
];
|
];
|
||||||
const thinkingEffortOptions = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
const thinkingEffortOptions = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'];
|
||||||
const omniVideoModeOptions = ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'];
|
const omniVideoModeOptions = ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'];
|
||||||
const durationOptionValues = ['1', '2', '3', '4', '5', '6', '8', '10', '15', '20', '25', '30'];
|
const durationOptionValues = ['1', '2', '3', '4', '5', '6', '8', '10', '15', '20', '25', '30'];
|
||||||
const exclusiveCapabilityFields: Record<string, string> = {
|
const exclusiveCapabilityFields: Record<string, string> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user