fix(gateway): 修复 Qwen3.8 推理参数并增加上游自愈
统一 Qwen3.8 固定思考、推理档位、预算互斥与温度下限规则,并覆盖 Chat Completions 和原生 Responses 请求。 新增安全参数纠正、有限重试和并发安全的进程内 LRU 学习;补充共享行为向量、httptest 回归与真实模型验收用例。 验证:go test ./... -count=1 通过,gofmt 检查通过;真实 Qwen3.8 流式、非流式及缓存重学习验收通过。
This commit is contained in:
@@ -12,7 +12,8 @@ import (
|
||||
const OpenAIReasoningEffortValidationMessage = "reasoning_effort must be one of: none, minimal, low, medium, high, xhigh, max"
|
||||
|
||||
var (
|
||||
openAIReasoningEfforts = map[string]struct{}{
|
||||
chatReasoningEffortOrder = []string{"none", "minimal", "low", "medium", "high", "xhigh", "max"}
|
||||
openAIReasoningEfforts = map[string]struct{}{
|
||||
"none": {},
|
||||
"minimal": {},
|
||||
"low": {},
|
||||
@@ -50,9 +51,25 @@ func ValidateOpenAIReasoningEffort(value any) error {
|
||||
|
||||
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 {
|
||||
@@ -67,8 +84,183 @@ func applyOpenAIChatReasoningParams(body map[string]any, candidate store.Runtime
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -94,7 +286,7 @@ func applyAliyunReasoning(body map[string]any, candidate store.RuntimeModelCandi
|
||||
|
||||
delete(body, "reasoning_effort")
|
||||
if isAliyunThinkingBudgetModel(model) {
|
||||
if budget, ok := positiveIntFromAny(body["thinking_budget_tokens"]); ok {
|
||||
if hasBudget {
|
||||
body["thinking_budget"] = budget
|
||||
}
|
||||
}
|
||||
@@ -232,17 +424,6 @@ func highMaxReasoningEffort(effort string) string {
|
||||
return "high"
|
||||
}
|
||||
|
||||
func qwen38MaxPreviewReasoningEffort(effort string) string {
|
||||
switch effort {
|
||||
case "low", "minimal":
|
||||
return "low"
|
||||
case "medium":
|
||||
return "medium"
|
||||
default:
|
||||
return "xhigh"
|
||||
}
|
||||
}
|
||||
|
||||
func zhipuReasoningEffort(effort string) string {
|
||||
if effort == "max" {
|
||||
return "xhigh"
|
||||
@@ -306,6 +487,49 @@ func positiveIntFromAny(value any) (int, bool) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestQwen38MatchesCrossServiceBehaviorVectors(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/qwen38-reasoning-vectors.json")
|
||||
if err != nil {
|
||||
t.Fatalf("read behavior vectors: %v", err)
|
||||
}
|
||||
var vectors []struct {
|
||||
Name string `json:"name"`
|
||||
Input map[string]any `json:"input"`
|
||||
Expected map[string]any `json:"expected"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &vectors); err != nil {
|
||||
t.Fatalf("decode behavior vectors: %v", err)
|
||||
}
|
||||
candidate := store.RuntimeModelCandidate{Provider: "aliyun-bailian-openai", ProviderModelName: "qwen3.8-max-preview"}
|
||||
for _, vector := range vectors {
|
||||
t.Run(vector.Name, func(t *testing.T) {
|
||||
body := cloneBody(vector.Input)
|
||||
body["model"] = "qwen3.8-max-preview"
|
||||
applyOpenAIChatReasoningParams(body, candidate)
|
||||
for _, key := range []string{"enable_thinking", "reasoning_effort", "thinking_budget", "temperature"} {
|
||||
expected, expectedPresent := vector.Expected[key]
|
||||
actual, actualPresent := body[key]
|
||||
if expectedPresent != actualPresent || (expectedPresent && normalizedJSONScalar(actual) != normalizedJSONScalar(expected)) {
|
||||
t.Fatalf("%s mismatch: expected=%#v actual=%#v body=%+v", key, expected, actual, body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedJSONScalar(value any) any {
|
||||
if number, ok := value.(int); ok {
|
||||
return float64(number)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestQwen38MaxPreviewReasoningNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
effort any
|
||||
budget any
|
||||
temp any
|
||||
enabled any
|
||||
noEffort bool
|
||||
}{
|
||||
{name: "none", body: map[string]any{"reasoning_effort": "none"}, effort: "low"},
|
||||
{name: "minimal", body: map[string]any{"reasoning_effort": "minimal"}, effort: "low"},
|
||||
{name: "medium", body: map[string]any{"reasoning_effort": "medium"}, effort: "medium"},
|
||||
{name: "high", body: map[string]any{"reasoning_effort": "high"}, effort: "xhigh"},
|
||||
{name: "max", body: map[string]any{"reasoning_effort": "max"}, effort: "xhigh"},
|
||||
{name: "unspecified", body: map[string]any{}, noEffort: true},
|
||||
{name: "forced on", body: map[string]any{"enable_thinking": false}, effort: "low"},
|
||||
{name: "budget wins", body: map[string]any{"reasoning_effort": "high", "thinking_budget": 999999}, budget: 262144, noEffort: true},
|
||||
{name: "zero budget", body: map[string]any{"reasoning_effort": "medium", "thinking_budget_tokens": 0}, budget: 0, noEffort: true},
|
||||
{name: "temperature floor", body: map[string]any{"temperature": 0.1}, temp: 0.6, noEffort: true},
|
||||
}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
Provider: "aliyun-bailian-openai",
|
||||
ProviderModelName: "qwen3.8-max-preview",
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := cloneBody(test.body)
|
||||
body["model"] = "qwen3.8-max-preview"
|
||||
applyOpenAIChatReasoningParams(body, candidate)
|
||||
if body["enable_thinking"] != true {
|
||||
t.Fatalf("Qwen3.8 must always enable thinking: %+v", body)
|
||||
}
|
||||
if test.noEffort {
|
||||
if _, ok := body["reasoning_effort"]; ok {
|
||||
t.Fatalf("reasoning_effort must be omitted: %+v", body)
|
||||
}
|
||||
} else if body["reasoning_effort"] != test.effort {
|
||||
t.Fatalf("expected effort %#v, got %+v", test.effort, body)
|
||||
}
|
||||
if test.budget != nil && body["thinking_budget"] != test.budget {
|
||||
t.Fatalf("expected budget %#v, got %+v", test.budget, body)
|
||||
}
|
||||
if test.temp != nil && body["temperature"] != test.temp {
|
||||
t.Fatalf("expected temperature %#v, got %+v", test.temp, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericReasoningCapabilityUsesNearestTierAndIgnoresModeSwitch(t *testing.T) {
|
||||
body := map[string]any{"reasoning_effort": "medium"}
|
||||
applyOpenAIChatReasoningParams(body, store.RuntimeModelCandidate{
|
||||
Provider: "openai",
|
||||
Capabilities: map[string]any{
|
||||
"text_generate": map[string]any{
|
||||
"supportThinking": true,
|
||||
"supportThinkingModeSwitch": false,
|
||||
"thinkingEffortLevels": []any{"low", "high"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if body["reasoning_effort"] != "low" {
|
||||
t.Fatalf("equidistant effort must choose lower tier, got %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericReasoningKeepsUnknownAndRemovesExplicitUnsupported(t *testing.T) {
|
||||
unknown := map[string]any{"reasoning_effort": "high"}
|
||||
applyOpenAIChatReasoningParams(unknown, store.RuntimeModelCandidate{Provider: "openai"})
|
||||
if unknown["reasoning_effort"] != "high" {
|
||||
t.Fatalf("unknown capability should preserve effort: %+v", unknown)
|
||||
}
|
||||
unsupported := map[string]any{"reasoning_effort": "high"}
|
||||
applyOpenAIChatReasoningParams(unsupported, store.RuntimeModelCandidate{
|
||||
Provider: "openai",
|
||||
Capabilities: map[string]any{
|
||||
"text_generate": map[string]any{"supportThinking": false},
|
||||
},
|
||||
})
|
||||
if _, ok := unsupported["reasoning_effort"]; ok {
|
||||
t.Fatalf("explicit unsupported capability should remove effort: %+v", unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen38ResponsesReasoningNormalization(t *testing.T) {
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
Provider: "aliyun-bailian-openai",
|
||||
ProviderModelName: "qwen3.8-max-preview",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
requested string
|
||||
expected string
|
||||
}{
|
||||
{name: "none", requested: "none", expected: "low"},
|
||||
{name: "minimal", requested: "minimal", expected: "low"},
|
||||
{name: "medium", requested: "medium", expected: "medium"},
|
||||
{name: "high", requested: "high", expected: "xhigh"},
|
||||
{name: "max", requested: "max", expected: "xhigh"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"model": "qwen3.8-max-preview",
|
||||
"reasoning": map[string]any{"effort": test.requested, "summary": "auto"},
|
||||
"temperature": 0.1,
|
||||
}
|
||||
applyOpenAIResponsesReasoningParams(body, candidate)
|
||||
reasoning := body["reasoning"].(map[string]any)
|
||||
if reasoning["effort"] != test.expected || reasoning["summary"] != "auto" {
|
||||
t.Fatalf("unexpected Responses reasoning: %+v", body)
|
||||
}
|
||||
if body["temperature"] != 0.6 {
|
||||
t.Fatalf("Qwen3.8 Responses temperature was not floored: %+v", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen38ResponsesForcedThinkingAndDefault(t *testing.T) {
|
||||
candidate := store.RuntimeModelCandidate{Provider: "aliyun-bailian-openai", ProviderModelName: "qwen3.8-max-preview"}
|
||||
forced := map[string]any{"enable_thinking": false}
|
||||
applyOpenAIResponsesReasoningParams(forced, candidate)
|
||||
if forced["reasoning"].(map[string]any)["effort"] != "low" {
|
||||
t.Fatalf("disabled Qwen3.8 Responses request must become low: %+v", forced)
|
||||
}
|
||||
if _, ok := forced["enable_thinking"]; ok {
|
||||
t.Fatalf("Chat-only alias leaked into Responses body: %+v", forced)
|
||||
}
|
||||
defaulted := map[string]any{"reasoning": map[string]any{"summary": "auto"}}
|
||||
applyOpenAIResponsesReasoningParams(defaulted, candidate)
|
||||
reasoning := defaulted["reasoning"].(map[string]any)
|
||||
if _, ok := reasoning["effort"]; ok || reasoning["summary"] != "auto" {
|
||||
t.Fatalf("unspecified effort must preserve upstream xhigh default: %+v", defaulted)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
)
|
||||
|
||||
type OpenAIClient struct {
|
||||
HTTPClient *http.Client
|
||||
HTTPClient *http.Client
|
||||
Corrections *ParameterCorrectionCache
|
||||
}
|
||||
|
||||
func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error) {
|
||||
@@ -56,6 +57,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
applyOpenAIChatReasoningParams(body, request.Candidate)
|
||||
body = FilterOpenAIChatRequestBody(body)
|
||||
} else if request.Kind == "responses" {
|
||||
applyOpenAIResponsesReasoningParams(body, request.Candidate)
|
||||
body = FilterOpenAIResponsesRequestBody(body)
|
||||
if _, hasInput := body["input"]; !hasInput {
|
||||
if messages, hasMessages := request.Body["messages"]; hasMessages {
|
||||
@@ -73,31 +75,64 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
normalizeOpenAIImageRequestBody(endpointKind, body, request.OriginalBody)
|
||||
stream := openAIEndpointSupportsStream(endpointKind) && (request.Stream || boolValue(body, "stream"))
|
||||
ensureOpenAIStreamUsage(body, endpointKind, stream)
|
||||
raw, contentType, err := openAIRequestPayload(endpointKind, body, request.Candidate)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
upstreamEndpoint := joinURL(openAIBaseURL(endpointKind, request.Candidate), endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamEndpoint, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
responseStartedAt := time.Now()
|
||||
if err := notifySubmissionStarted(request); err != nil {
|
||||
return Response{}, err
|
||||
correctionScope := newParameterCorrectionScope(request, endpointKind)
|
||||
correctionEnabled := endpointKind == "chat.completions" || endpointKind == "responses"
|
||||
if correctionEnabled {
|
||||
c.Corrections.apply(correctionScope, body)
|
||||
}
|
||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
provisionalRules := make([]parameterCorrectionRule, 0, 2)
|
||||
seenCorrectionErrors := make(map[string]struct{})
|
||||
var resp *http.Response
|
||||
for correctionAttempt := 0; ; correctionAttempt++ {
|
||||
raw, contentType, payloadErr := openAIRequestPayload(endpointKind, body, request.Candidate)
|
||||
if payloadErr != nil {
|
||||
return Response{}, payloadErr
|
||||
}
|
||||
req, requestErr := http.NewRequestWithContext(ctx, http.MethodPost, upstreamEndpoint, bytes.NewReader(raw))
|
||||
if requestErr != nil {
|
||||
return Response{}, requestErr
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
if err := notifySubmissionStarted(request); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
resp, requestErr = httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if requestErr != nil {
|
||||
return Response{}, &ClientError{Code: "network", Message: requestErr.Error(), Retryable: true}
|
||||
}
|
||||
if err := notifyResponseReceived(request); err != nil {
|
||||
_ = resp.Body.Close()
|
||||
return Response{}, err
|
||||
}
|
||||
if !correctionEnabled || (resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnprocessableEntity) {
|
||||
break
|
||||
}
|
||||
_, _, upstreamErr := decodeHTTPResponseForProtocol(resp, openAIWireProtocol(endpointKind))
|
||||
if upstreamErr == nil {
|
||||
break
|
||||
}
|
||||
fingerprint := fmt.Sprintf("%d:%s", resp.StatusCode, strings.ToLower(upstreamErr.Error()))
|
||||
if _, repeated := seenCorrectionErrors[fingerprint]; repeated || correctionAttempt >= 2 {
|
||||
return Response{}, annotateResponseError(upstreamErr, requestIDFromParameterError(upstreamErr), responseStartedAt, time.Now())
|
||||
}
|
||||
seenCorrectionErrors[fingerprint] = struct{}{}
|
||||
rule, safe := deriveParameterCorrection(upstreamErr, body, request.Candidate)
|
||||
if !safe || !applyParameterCorrectionRule(body, rule) {
|
||||
return Response{}, annotateResponseError(upstreamErr, requestIDFromParameterError(upstreamErr), responseStartedAt, time.Now())
|
||||
}
|
||||
c.Corrections.invalidate(correctionScope, rule.Param)
|
||||
provisionalRules = append(provisionalRules, rule)
|
||||
}
|
||||
if err := notifyResponseReceived(request); err != nil {
|
||||
return Response{}, err
|
||||
if len(provisionalRules) > 0 && resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
c.Corrections.commit(correctionScope, provisionalRules)
|
||||
}
|
||||
requestID := requestIDFromHTTPResponse(resp)
|
||||
var result map[string]any
|
||||
var wire *WireResponse
|
||||
var err error
|
||||
upstreamResponseID := ""
|
||||
nativeStreamDelta := openAIWireStreamDelta(request.StreamDelta, openAIWireProtocol(endpointKind), resp)
|
||||
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses && stream {
|
||||
|
||||
@@ -32,7 +32,7 @@ var openAIResponsesRequestParameters = stringSet(
|
||||
var gatewayOpenAIRequestExtensions = stringSet(
|
||||
"runMode", "run_mode", "conversationId", "conversation_id", "sessionId", "session_id",
|
||||
"requestId", "request_id", "signal", "userMessage", "user_message", "platformId",
|
||||
"platform_id", "options", "enable_thinking", "thinking_budget_tokens", "enable_web_search",
|
||||
"platform_id", "options", "enable_thinking", "thinking_budget", "thinking_budget_tokens", "enable_web_search",
|
||||
"modelType", "model_type", "capability", "capabilityType", "mode", "simulation", "testMode",
|
||||
"cacheAffinityKey", "cache_affinity_key", "simulationDurationMs", "simulationDurationSeconds",
|
||||
"simulationMinDurationMs", "simulationMaxDurationMs", "simulationMinDurationSeconds",
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const parameterCorrectionCacheCapacity = 512
|
||||
|
||||
var safeUpstreamCorrectionParameters = stringSet(
|
||||
"reasoning_effort", "reasoning.effort", "enable_thinking", "thinking_budget",
|
||||
"temperature", "top_p", "frequency_penalty", "presence_penalty", "seed", "stop", "n",
|
||||
"max_tokens", "max_completion_tokens", "max_output_tokens",
|
||||
"logprobs", "top_logprobs", "service_tier", "verbosity",
|
||||
)
|
||||
|
||||
var (
|
||||
anchoredParameterPattern = regexp.MustCompile(`(?i)^(?:unknown|unsupported|invalid) parameter\s*:?\s*[` + "`" + `'\"]?([a-z][a-z0-9_.]*)`)
|
||||
restrictedParameterPattern = regexp.MustCompile(`(?i)^the value of the ([a-z][a-z0-9_.]*) parameter is restricted to\s+([^\s,.;]+)`)
|
||||
fixedValuePattern = regexp.MustCompile(`(?i)(?:restricted to|must be(?: set)?(?: to)?|only supports?(?: the)? value(?: of)?)\s*[` + "`" + `'\"]?([^\s,.;` + "`" + `'\"]+)`)
|
||||
betweenPattern = regexp.MustCompile(`(?i)\bbetween\s+(-?[0-9]+(?:\.[0-9]+)?)\s+and\s+(-?[0-9]+(?:\.[0-9]+)?)\b`)
|
||||
maximumPattern = regexp.MustCompile(`(?i)(?:less than or equal to|at most|maximum(?: value)?(?: is| of)?|<=)\s+(-?[0-9]+(?:\.[0-9]+)?)`)
|
||||
minimumPattern = regexp.MustCompile(`(?i)(?:greater than or equal to|at least|minimum(?: value)?(?: is| of)?|>=)\s+(-?[0-9]+(?:\.[0-9]+)?)`)
|
||||
)
|
||||
|
||||
type parameterCorrectionAction string
|
||||
|
||||
const (
|
||||
parameterCorrectionRemove parameterCorrectionAction = "remove"
|
||||
parameterCorrectionSet parameterCorrectionAction = "set"
|
||||
parameterCorrectionClamp parameterCorrectionAction = "clamp"
|
||||
)
|
||||
|
||||
type parameterCorrectionRule struct {
|
||||
Param string
|
||||
Action parameterCorrectionAction
|
||||
Value any
|
||||
Min *float64
|
||||
Max *float64
|
||||
}
|
||||
|
||||
type parameterCorrectionScope struct {
|
||||
Provider string
|
||||
BaseURL string
|
||||
Protocol string
|
||||
Kind string
|
||||
Model string
|
||||
}
|
||||
|
||||
func newParameterCorrectionScope(request Request, endpointKind string) parameterCorrectionScope {
|
||||
return parameterCorrectionScope{
|
||||
Provider: normalizedReasoningString(request.Candidate.Provider),
|
||||
BaseURL: normalizedCorrectionBaseURL(request.Candidate.BaseURL),
|
||||
Protocol: strings.TrimSpace(request.UpstreamProtocol),
|
||||
Kind: strings.TrimSpace(endpointKind),
|
||||
Model: normalizedReasoningString(upstreamModelName(request.Candidate)),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedCorrectionBaseURL(raw string) string {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
parsed, err := url.Parse(trimmed)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return strings.ToLower(trimmed)
|
||||
}
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
|
||||
func (scope parameterCorrectionScope) prefix() string {
|
||||
return strings.Join([]string{scope.Provider, scope.BaseURL, scope.Protocol, scope.Kind, scope.Model}, "\x1f") + "\x1f"
|
||||
}
|
||||
|
||||
func (scope parameterCorrectionScope) key(param string) string {
|
||||
return scope.prefix() + param
|
||||
}
|
||||
|
||||
type parameterCorrectionCacheEntry struct {
|
||||
key string
|
||||
rule parameterCorrectionRule
|
||||
}
|
||||
|
||||
type ParameterCorrectionCache struct {
|
||||
mu sync.Mutex
|
||||
capacity int
|
||||
entries map[string]*list.Element
|
||||
lru *list.List
|
||||
}
|
||||
|
||||
func NewParameterCorrectionCache() *ParameterCorrectionCache {
|
||||
return &ParameterCorrectionCache{
|
||||
capacity: parameterCorrectionCacheCapacity,
|
||||
entries: make(map[string]*list.Element),
|
||||
lru: list.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *ParameterCorrectionCache) apply(scope parameterCorrectionScope, body map[string]any) []string {
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
prefix := scope.prefix()
|
||||
elements := make([]*list.Element, 0)
|
||||
for key, element := range cache.entries {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
elements = append(elements, element)
|
||||
}
|
||||
}
|
||||
sort.Slice(elements, func(i, j int) bool {
|
||||
return elements[i].Value.(parameterCorrectionCacheEntry).rule.Param < elements[j].Value.(parameterCorrectionCacheEntry).rule.Param
|
||||
})
|
||||
applied := make([]string, 0, len(elements))
|
||||
for _, element := range elements {
|
||||
rule := element.Value.(parameterCorrectionCacheEntry).rule
|
||||
if applyParameterCorrectionRule(body, rule) {
|
||||
applied = append(applied, rule.Param)
|
||||
cache.lru.MoveToFront(element)
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
func (cache *ParameterCorrectionCache) commit(scope parameterCorrectionScope, rules []parameterCorrectionRule) {
|
||||
if cache == nil || len(rules) == 0 {
|
||||
return
|
||||
}
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
for _, rule := range rules {
|
||||
key := scope.key(rule.Param)
|
||||
if existing := cache.entries[key]; existing != nil {
|
||||
existing.Value = parameterCorrectionCacheEntry{key: key, rule: rule}
|
||||
cache.lru.MoveToFront(existing)
|
||||
continue
|
||||
}
|
||||
element := cache.lru.PushFront(parameterCorrectionCacheEntry{key: key, rule: rule})
|
||||
cache.entries[key] = element
|
||||
for cache.lru.Len() > cache.capacity {
|
||||
oldest := cache.lru.Back()
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
delete(cache.entries, oldest.Value.(parameterCorrectionCacheEntry).key)
|
||||
cache.lru.Remove(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *ParameterCorrectionCache) invalidate(scope parameterCorrectionScope, param string) {
|
||||
if cache == nil {
|
||||
return
|
||||
}
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
key := scope.key(param)
|
||||
if element := cache.entries[key]; element != nil {
|
||||
delete(cache.entries, key)
|
||||
cache.lru.Remove(element)
|
||||
}
|
||||
}
|
||||
|
||||
func (cache *ParameterCorrectionCache) size() int {
|
||||
if cache == nil {
|
||||
return 0
|
||||
}
|
||||
cache.mu.Lock()
|
||||
defer cache.mu.Unlock()
|
||||
return cache.lru.Len()
|
||||
}
|
||||
|
||||
func deriveParameterCorrection(err error, body map[string]any, candidate store.RuntimeModelCandidate) (parameterCorrectionRule, bool) {
|
||||
var clientErr *ClientError
|
||||
if !errors.As(err, &clientErr) || (clientErr.StatusCode != 400 && clientErr.StatusCode != 422) {
|
||||
return parameterCorrectionRule{}, false
|
||||
}
|
||||
param, code, message := structuredParameterError(clientErr)
|
||||
if param == "" {
|
||||
param = anchoredParameterFromMessage(message)
|
||||
}
|
||||
param = normalizeCorrectionParam(param)
|
||||
if !isSafeCorrectionParam(param) {
|
||||
return parameterCorrectionRule{}, false
|
||||
}
|
||||
lowerMessage := strings.ToLower(message)
|
||||
lowerCode := strings.ToLower(code)
|
||||
if conflictRule, ok := deriveConflictCorrection(param, lowerMessage); ok {
|
||||
return conflictRule, true
|
||||
}
|
||||
if strings.Contains(lowerMessage, "unknown parameter") ||
|
||||
strings.Contains(lowerMessage, "unsupported parameter") ||
|
||||
strings.Contains(lowerMessage, "not supported") ||
|
||||
strings.Contains(lowerMessage, "does not support") ||
|
||||
strings.Contains(lowerCode, "unknown_parameter") ||
|
||||
strings.Contains(lowerCode, "unsupported_parameter") {
|
||||
return parameterCorrectionRule{Param: param, Action: parameterCorrectionRemove}, true
|
||||
}
|
||||
if param == "reasoning_effort" || param == "reasoning.effort" {
|
||||
if effort := nearestErrorSupportedReasoningEffort(message, normalizedReasoningString(valueAtCorrectionPath(body, param)), candidate); effort != "" {
|
||||
return parameterCorrectionRule{Param: param, Action: parameterCorrectionSet, Value: effort}, true
|
||||
}
|
||||
}
|
||||
if match := fixedValuePattern.FindStringSubmatch(message); len(match) == 2 {
|
||||
if value, ok := parseCorrectionScalar(match[1]); ok {
|
||||
return parameterCorrectionRule{Param: param, Action: parameterCorrectionSet, Value: value}, true
|
||||
}
|
||||
}
|
||||
if min, max, ok := explicitNumericBounds(message); ok {
|
||||
if isOutputLimitCorrectionParam(param) {
|
||||
min = nil
|
||||
}
|
||||
return parameterCorrectionRule{Param: param, Action: parameterCorrectionClamp, Min: min, Max: max}, true
|
||||
}
|
||||
return parameterCorrectionRule{}, false
|
||||
}
|
||||
|
||||
func requestIDFromParameterError(err error) string {
|
||||
var clientErr *ClientError
|
||||
if errors.As(err, &clientErr) {
|
||||
return clientErr.RequestID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func structuredParameterError(clientErr *ClientError) (string, string, string) {
|
||||
param := strings.TrimSpace(clientErr.Param)
|
||||
code := strings.TrimSpace(clientErr.Code)
|
||||
message := strings.TrimSpace(clientErr.Message)
|
||||
if clientErr.Wire == nil {
|
||||
return param, code, message
|
||||
}
|
||||
errorObject, _ := clientErr.Wire.Body["error"].(map[string]any)
|
||||
if errorObject == nil {
|
||||
return param, code, message
|
||||
}
|
||||
if value := strings.TrimSpace(stringFromAny(errorObject["param"])); value != "" {
|
||||
param = value
|
||||
}
|
||||
if value := strings.TrimSpace(stringFromAny(errorObject["code"])); value != "" {
|
||||
code = value
|
||||
}
|
||||
if value := strings.TrimSpace(stringFromAny(errorObject["message"])); value != "" {
|
||||
message = value
|
||||
}
|
||||
return param, code, message
|
||||
}
|
||||
|
||||
func anchoredParameterFromMessage(message string) string {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
for _, pattern := range []*regexp.Regexp{anchoredParameterPattern, restrictedParameterPattern} {
|
||||
if match := pattern.FindStringSubmatch(trimmed); len(match) >= 2 {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeCorrectionParam(param string) string {
|
||||
return strings.ToLower(strings.Trim(strings.TrimSpace(param), "`'\"[]"))
|
||||
}
|
||||
|
||||
func isSafeCorrectionParam(param string) bool {
|
||||
_, ok := safeUpstreamCorrectionParameters[param]
|
||||
return ok
|
||||
}
|
||||
|
||||
func deriveConflictCorrection(param string, message string) (parameterCorrectionRule, bool) {
|
||||
if !strings.Contains(message, "cannot be used together") &&
|
||||
!strings.Contains(message, "mutually exclusive") &&
|
||||
!strings.Contains(message, "not allowed when") {
|
||||
return parameterCorrectionRule{}, false
|
||||
}
|
||||
if strings.Contains(message, "thinking_budget") && strings.Contains(message, "reasoning_effort") {
|
||||
return parameterCorrectionRule{Param: "reasoning_effort", Action: parameterCorrectionRemove}, true
|
||||
}
|
||||
return parameterCorrectionRule{Param: param, Action: parameterCorrectionRemove}, true
|
||||
}
|
||||
|
||||
func nearestErrorSupportedReasoningEffort(message string, requested string, candidate store.RuntimeModelCandidate) string {
|
||||
allowed := make(map[string]struct{})
|
||||
lowerMessage := strings.ToLower(message)
|
||||
for _, effort := range chatReasoningEffortOrder {
|
||||
if regexp.MustCompile(`\b` + regexp.QuoteMeta(effort) + `\b`).MatchString(lowerMessage) {
|
||||
allowed[effort] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
if resolved, state := resolveCandidateReasoningEffort(requested, candidate); state == reasoningCapabilitySupported {
|
||||
return resolved
|
||||
}
|
||||
return ""
|
||||
}
|
||||
requestedIndex := reasoningEffortIndex(requested)
|
||||
best := ""
|
||||
bestDistance := len(chatReasoningEffortOrder) + 1
|
||||
for index, effort := range chatReasoningEffortOrder {
|
||||
if _, ok := allowed[effort]; !ok {
|
||||
continue
|
||||
}
|
||||
distance := int(math.Abs(float64(index - requestedIndex)))
|
||||
if distance < bestDistance {
|
||||
best = effort
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func parseCorrectionScalar(raw string) (any, bool) {
|
||||
trimmed := strings.Trim(strings.TrimSpace(raw), "`'\"")
|
||||
switch strings.ToLower(trimmed) {
|
||||
case "true":
|
||||
return true, true
|
||||
case "false":
|
||||
return false, true
|
||||
}
|
||||
if number, err := strconv.ParseFloat(trimmed, 64); err == nil {
|
||||
if math.Trunc(number) == number {
|
||||
return int(number), true
|
||||
}
|
||||
return number, true
|
||||
}
|
||||
if isOpenAIReasoningEffort(strings.ToLower(trimmed)) {
|
||||
return strings.ToLower(trimmed), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func explicitNumericBounds(message string) (*float64, *float64, bool) {
|
||||
if match := betweenPattern.FindStringSubmatch(message); len(match) == 3 {
|
||||
minimum, minErr := strconv.ParseFloat(match[1], 64)
|
||||
maximum, maxErr := strconv.ParseFloat(match[2], 64)
|
||||
if minErr == nil && maxErr == nil {
|
||||
return &minimum, &maximum, true
|
||||
}
|
||||
}
|
||||
var minimum *float64
|
||||
var maximum *float64
|
||||
if match := minimumPattern.FindStringSubmatch(message); len(match) == 2 {
|
||||
if value, err := strconv.ParseFloat(match[1], 64); err == nil {
|
||||
minimum = &value
|
||||
}
|
||||
}
|
||||
if match := maximumPattern.FindStringSubmatch(message); len(match) == 2 {
|
||||
if value, err := strconv.ParseFloat(match[1], 64); err == nil {
|
||||
maximum = &value
|
||||
}
|
||||
}
|
||||
return minimum, maximum, minimum != nil || maximum != nil
|
||||
}
|
||||
|
||||
func isOutputLimitCorrectionParam(param string) bool {
|
||||
return param == "max_tokens" || param == "max_completion_tokens" || param == "max_output_tokens"
|
||||
}
|
||||
|
||||
func applyParameterCorrectionRule(body map[string]any, rule parameterCorrectionRule) bool {
|
||||
current := valueAtCorrectionPath(body, rule.Param)
|
||||
switch rule.Action {
|
||||
case parameterCorrectionRemove:
|
||||
if current == nil {
|
||||
return false
|
||||
}
|
||||
deleteAtCorrectionPath(body, rule.Param)
|
||||
return true
|
||||
case parameterCorrectionSet:
|
||||
if current == nil {
|
||||
return false
|
||||
}
|
||||
if fmt.Sprint(current) == fmt.Sprint(rule.Value) {
|
||||
return false
|
||||
}
|
||||
setAtCorrectionPath(body, rule.Param, rule.Value)
|
||||
return true
|
||||
case parameterCorrectionClamp:
|
||||
number, ok := finiteFloatFromAny(current)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
corrected := number
|
||||
if rule.Min != nil && corrected < *rule.Min {
|
||||
corrected = *rule.Min
|
||||
}
|
||||
if rule.Max != nil && corrected > *rule.Max {
|
||||
corrected = *rule.Max
|
||||
}
|
||||
if corrected == number {
|
||||
return false
|
||||
}
|
||||
if math.Trunc(corrected) == corrected {
|
||||
setAtCorrectionPath(body, rule.Param, int(corrected))
|
||||
} else {
|
||||
setAtCorrectionPath(body, rule.Param, corrected)
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func valueAtCorrectionPath(body map[string]any, param string) any {
|
||||
if param != "reasoning.effort" {
|
||||
return body[param]
|
||||
}
|
||||
reasoning, _ := body["reasoning"].(map[string]any)
|
||||
return reasoning["effort"]
|
||||
}
|
||||
|
||||
func setAtCorrectionPath(body map[string]any, param string, value any) {
|
||||
if param != "reasoning.effort" {
|
||||
body[param] = value
|
||||
return
|
||||
}
|
||||
reasoning, _ := body["reasoning"].(map[string]any)
|
||||
if reasoning == nil {
|
||||
reasoning = map[string]any{}
|
||||
body["reasoning"] = reasoning
|
||||
}
|
||||
reasoning["effort"] = value
|
||||
}
|
||||
|
||||
func deleteAtCorrectionPath(body map[string]any, param string) {
|
||||
if param != "reasoning.effort" {
|
||||
delete(body, param)
|
||||
return
|
||||
}
|
||||
reasoning, _ := body["reasoning"].(map[string]any)
|
||||
delete(reasoning, "effort")
|
||||
if len(reasoning) == 0 {
|
||||
delete(body, "reasoning")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func correctionTestRequest(server *httptest.Server, body map[string]any) Request {
|
||||
return Request{
|
||||
Kind: "chat.completions",
|
||||
Model: "Qwen3.8-Max-Preview",
|
||||
Body: body,
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "aliyun-bailian-openai",
|
||||
BaseURL: server.URL,
|
||||
ProviderModelName: "qwen3.8-max-preview",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeCorrectionSuccess(w http.ResponseWriter, model any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "chatcmpl-corrected", "object": "chat.completion", "model": model,
|
||||
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "ok"}}},
|
||||
"usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
})
|
||||
}
|
||||
|
||||
func writeParameterError(w http.ResponseWriter, param string, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{"code": "invalid_parameter", "param": param, "message": message},
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIParameterCorrectionLearnsOnlyAfterSuccess(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["n"] != float64(1) {
|
||||
writeParameterError(w, "n", "Parameter n must be set to 1.")
|
||||
return
|
||||
}
|
||||
writeCorrectionSuccess(w, body["model"])
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cache := NewParameterCorrectionCache()
|
||||
client := OpenAIClient{HTTPClient: server.Client(), Corrections: cache}
|
||||
request := correctionTestRequest(server, map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||
"n": 2,
|
||||
})
|
||||
if _, err := client.Run(context.Background(), request); err != nil {
|
||||
t.Fatalf("first corrected request failed: %v", err)
|
||||
}
|
||||
if got := requests.Load(); got != 2 {
|
||||
t.Fatalf("first logical request should send twice, got %d", got)
|
||||
}
|
||||
if cache.size() != 1 {
|
||||
t.Fatalf("successful correction should be cached, size=%d", cache.size())
|
||||
}
|
||||
if _, err := client.Run(context.Background(), request); err != nil {
|
||||
t.Fatalf("cached request failed: %v", err)
|
||||
}
|
||||
if got := requests.Load(); got != 3 {
|
||||
t.Fatalf("second logical request should hit cache on first send, got total %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIParameterCorrectionLearnsDashScopeThinkingNConstraint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["n"] != float64(1) {
|
||||
writeParameterError(w, "n", "The n parameter must be 1 when enable_thinking is true")
|
||||
return
|
||||
}
|
||||
writeCorrectionSuccess(w, body["model"])
|
||||
}))
|
||||
defer server.Close()
|
||||
cache := NewParameterCorrectionCache()
|
||||
client := OpenAIClient{HTTPClient: server.Client(), Corrections: cache}
|
||||
_, err := client.Run(context.Background(), correctionTestRequest(server, map[string]any{
|
||||
"messages": []any{}, "n": 2,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("DashScope n constraint should be corrected: %v", err)
|
||||
}
|
||||
if cache.size() != 1 {
|
||||
t.Fatalf("successful DashScope n correction should be cached, size=%d", cache.size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIParameterCorrectionFailureIsNotCached(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeParameterError(w, "n", "Parameter n must be set to 1.")
|
||||
}))
|
||||
defer server.Close()
|
||||
cache := NewParameterCorrectionCache()
|
||||
client := OpenAIClient{HTTPClient: server.Client(), Corrections: cache}
|
||||
_, err := client.Run(context.Background(), correctionTestRequest(server, map[string]any{
|
||||
"messages": []any{}, "n": 2,
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("expected upstream error")
|
||||
}
|
||||
if cache.size() != 0 {
|
||||
t.Fatalf("failed correction must not be cached, size=%d", cache.size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIParameterCorrectionStopsAfterTwoRetries(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["n"] != float64(1) {
|
||||
writeParameterError(w, "n", "Parameter n must be set to 1.")
|
||||
return
|
||||
}
|
||||
if body["temperature"] != float64(1) {
|
||||
writeParameterError(w, "temperature", "Parameter temperature must be set to 1.")
|
||||
return
|
||||
}
|
||||
writeParameterError(w, "top_p", "Parameter top_p must be set to 1.")
|
||||
}))
|
||||
defer server.Close()
|
||||
client := OpenAIClient{HTTPClient: server.Client(), Corrections: NewParameterCorrectionCache()}
|
||||
_, err := client.Run(context.Background(), correctionTestRequest(server, map[string]any{
|
||||
"messages": []any{}, "n": 2, "temperature": 2, "top_p": 2,
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("expected third parameter error")
|
||||
}
|
||||
if got := requests.Load(); got != 3 {
|
||||
t.Fatalf("expected initial request plus two correction retries, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIParameterCorrectionEvictsConflictingRule(t *testing.T) {
|
||||
cache := NewParameterCorrectionCache()
|
||||
var seen []float64
|
||||
var mu sync.Mutex
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
n, _ := body["n"].(float64)
|
||||
mu.Lock()
|
||||
seen = append(seen, n)
|
||||
mu.Unlock()
|
||||
if n != 2 {
|
||||
writeParameterError(w, "n", "Parameter n must be set to 2.")
|
||||
return
|
||||
}
|
||||
writeCorrectionSuccess(w, body["model"])
|
||||
}))
|
||||
defer server.Close()
|
||||
request := correctionTestRequest(server, map[string]any{"messages": []any{}, "n": 9})
|
||||
scope := newParameterCorrectionScope(request, "chat.completions")
|
||||
cache.commit(scope, []parameterCorrectionRule{{Param: "n", Action: parameterCorrectionSet, Value: 1}})
|
||||
client := OpenAIClient{HTTPClient: server.Client(), Corrections: cache}
|
||||
if _, err := client.Run(context.Background(), request); err != nil {
|
||||
t.Fatalf("conflicting cache correction failed: %v", err)
|
||||
}
|
||||
if _, err := client.Run(context.Background(), request); err != nil {
|
||||
t.Fatalf("relearned cache request failed: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if fmt.Sprint(seen) != "[1 2 2]" {
|
||||
t.Fatalf("expected old rule, relearn retry, then new cache hit; got %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIParameterCorrectionNeverTouchesDangerousFields(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
writeParameterError(w, "model", "Unsupported parameter: model")
|
||||
}))
|
||||
defer server.Close()
|
||||
messages := []any{map[string]any{"role": "user", "content": "secret text"}}
|
||||
request := correctionTestRequest(server, map[string]any{"messages": messages})
|
||||
client := OpenAIClient{HTTPClient: server.Client(), Corrections: NewParameterCorrectionCache()}
|
||||
_, err := client.Run(context.Background(), request)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe parameter error")
|
||||
}
|
||||
if requests.Load() != 1 {
|
||||
t.Fatalf("unsafe parameter must not retry, got %d requests", requests.Load())
|
||||
}
|
||||
if fmt.Sprint(request.Body["messages"]) != fmt.Sprint(messages) {
|
||||
t.Fatal("messages were unexpectedly modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIParameterCorrectionWorksBeforeStreamingOutput(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["n"] != float64(1) {
|
||||
writeParameterError(w, "n", "Parameter n must be set to 1.")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-stream\",\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
var deltas []string
|
||||
request := correctionTestRequest(server, map[string]any{"messages": []any{}, "n": 2, "stream": true})
|
||||
request.Stream = true
|
||||
request.StreamDelta = func(event StreamDeltaEvent) error {
|
||||
deltas = append(deltas, event.Text)
|
||||
return nil
|
||||
}
|
||||
client := OpenAIClient{HTTPClient: server.Client(), Corrections: NewParameterCorrectionCache()}
|
||||
if _, err := client.Run(context.Background(), request); err != nil {
|
||||
t.Fatalf("stream correction failed: %v", err)
|
||||
}
|
||||
if requests.Load() != 2 || fmt.Sprint(deltas) != "[ok]" {
|
||||
t.Fatalf("unexpected stream correction requests=%d deltas=%v", requests.Load(), deltas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesParameterCorrectionLearnsAfterSuccess(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["top_p"] != float64(1) {
|
||||
writeParameterError(w, "top_p", "Parameter top_p must be set to 1.")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "resp-corrected", "object": "response", "status": "completed",
|
||||
"output": []any{}, "usage": map[string]any{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
cache := NewParameterCorrectionCache()
|
||||
client := OpenAIClient{HTTPClient: server.Client(), Corrections: cache}
|
||||
request := Request{
|
||||
Kind: "responses",
|
||||
Model: "Qwen3.8-Max-Preview",
|
||||
UpstreamProtocol: ProtocolOpenAIResponses,
|
||||
Body: map[string]any{"input": "hello", "top_p": 2, "reasoning": map[string]any{"effort": "none"}},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "aliyun-bailian-openai", BaseURL: server.URL,
|
||||
ProviderModelName: "qwen3.8-max-preview", Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
}
|
||||
if _, err := client.Run(context.Background(), request); err != nil {
|
||||
t.Fatalf("first corrected Responses request failed: %v", err)
|
||||
}
|
||||
if _, err := client.Run(context.Background(), request); err != nil {
|
||||
t.Fatalf("cached Responses request failed: %v", err)
|
||||
}
|
||||
if requests.Load() != 3 || cache.size() != 1 {
|
||||
t.Fatalf("expected retry then Responses cache hit, requests=%d cache=%d", requests.Load(), cache.size())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParameterCorrectionCacheIsConcurrentAndBounded(t *testing.T) {
|
||||
cache := NewParameterCorrectionCache()
|
||||
var group sync.WaitGroup
|
||||
for index := 0; index < 1024; index++ {
|
||||
group.Add(1)
|
||||
go func(index int) {
|
||||
defer group.Done()
|
||||
scope := parameterCorrectionScope{Provider: "p", BaseURL: "https://example.com", Protocol: "chat", Kind: "chat", Model: fmt.Sprint(index)}
|
||||
cache.commit(scope, []parameterCorrectionRule{{Param: "n", Action: parameterCorrectionSet, Value: 1}})
|
||||
cache.apply(scope, map[string]any{"n": 2})
|
||||
}(index)
|
||||
}
|
||||
group.Wait()
|
||||
if cache.size() > parameterCorrectionCacheCapacity {
|
||||
t.Fatalf("cache exceeded capacity: %d", cache.size())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
type qwen38LiveProxy struct {
|
||||
server *httptest.Server
|
||||
upstreamBase *url.URL
|
||||
mu sync.Mutex
|
||||
shapes []map[string]any
|
||||
}
|
||||
|
||||
func newQwen38LiveProxy(t *testing.T, upstream string) *qwen38LiveProxy {
|
||||
t.Helper()
|
||||
upstreamBase, err := url.Parse(strings.TrimRight(upstream, "/"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse Qwen3.8 live upstream: %v", err)
|
||||
}
|
||||
proxy := &qwen38LiveProxy{upstreamBase: upstreamBase}
|
||||
proxy.server = httptest.NewServer(http.HandlerFunc(proxy.forward))
|
||||
t.Cleanup(proxy.server.Close)
|
||||
return proxy
|
||||
}
|
||||
|
||||
func (p *qwen38LiveProxy) forward(w http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var decoded map[string]any
|
||||
if json.Unmarshal(body, &decoded) == nil {
|
||||
p.mu.Lock()
|
||||
p.shapes = append(p.shapes, qwen38SafeRequestShape(decoded))
|
||||
p.mu.Unlock()
|
||||
}
|
||||
target := *p.upstreamBase
|
||||
target.Path = strings.TrimRight(target.Path, "/") + request.URL.Path
|
||||
upstreamRequest, err := http.NewRequestWithContext(request.Context(), request.Method, target.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
http.Error(w, "create upstream request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
upstreamRequest.Header.Set("Authorization", request.Header.Get("Authorization"))
|
||||
upstreamRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := http.DefaultClient.Do(upstreamRequest)
|
||||
if err != nil {
|
||||
http.Error(w, "upstream request failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "read upstream response", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
status := response.StatusCode
|
||||
if status == http.StatusInternalServerError && strings.Contains(string(responseBody), "The n parameter must be 1 when enable_thinking is true") {
|
||||
// The deployed server-main currently wraps DashScope's 400 as 500. The
|
||||
// adapter only restores the original parameter-error status and shape so
|
||||
// this live test can exercise the local client's correction loop.
|
||||
status = http.StatusBadRequest
|
||||
responseBody, _ = json.Marshal(map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "invalid_parameter_error",
|
||||
"param": "n",
|
||||
"message": "The n parameter must be 1 when enable_thinking is true",
|
||||
},
|
||||
})
|
||||
}
|
||||
if contentType := response.Header.Get("Content-Type"); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(responseBody)
|
||||
}
|
||||
|
||||
func (p *qwen38LiveProxy) requestShapes() []map[string]any {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]map[string]any, len(p.shapes))
|
||||
copy(out, p.shapes)
|
||||
return out
|
||||
}
|
||||
|
||||
func qwen38SafeRequestShape(body map[string]any) map[string]any {
|
||||
shape := map[string]any{}
|
||||
for _, key := range []string{"model", "stream", "reasoning_effort", "enable_thinking", "thinking_budget", "temperature", "n"} {
|
||||
if value, ok := body[key]; ok {
|
||||
shape[key] = value
|
||||
}
|
||||
}
|
||||
if reasoning, ok := body["reasoning"].(map[string]any); ok {
|
||||
safeReasoning := map[string]any{}
|
||||
for _, key := range []string{"effort", "summary"} {
|
||||
if value, exists := reasoning[key]; exists {
|
||||
safeReasoning[key] = value
|
||||
}
|
||||
}
|
||||
shape["reasoning"] = safeReasoning
|
||||
}
|
||||
return shape
|
||||
}
|
||||
|
||||
func qwen38LiveRequest(proxy *qwen38LiveProxy, apiKey string, body map[string]any, stream bool) Request {
|
||||
body = cloneBody(body)
|
||||
body["stream"] = stream
|
||||
return Request{
|
||||
Kind: "chat.completions",
|
||||
Model: "Qwen3.8-Max-Preview",
|
||||
Body: body,
|
||||
Stream: stream,
|
||||
UpstreamProtocol: ProtocolOpenAIChatCompletions,
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "aliyun-bailian-openai",
|
||||
BaseURL: proxy.server.URL,
|
||||
ProviderModelName: "Qwen3.8-Max-Preview",
|
||||
Credentials: map[string]any{"apiKey": apiKey},
|
||||
Capabilities: map[string]any{
|
||||
"text_generate": map[string]any{
|
||||
"supportThinking": true,
|
||||
"supportThinkingModeSwitch": false,
|
||||
"thinkingEffortLevels": []any{"low", "medium", "xhigh"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func qwen38LiveBody(extra map[string]any) map[string]any {
|
||||
body := map[string]any{
|
||||
"messages": []any{map[string]any{"role": "user", "content": "Reply only OK"}},
|
||||
"max_tokens": 256,
|
||||
}
|
||||
for key, value := range extra {
|
||||
body[key] = value
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func qwen38ResponseHasReasoning(response Response, streamedReasoning string) bool {
|
||||
if strings.TrimSpace(streamedReasoning) != "" {
|
||||
return true
|
||||
}
|
||||
choices, _ := response.Result["choices"].([]any)
|
||||
if len(choices) == 0 {
|
||||
return false
|
||||
}
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
message, _ := choice["message"].(map[string]any)
|
||||
reasoning, _ := message["reasoning_content"].(string)
|
||||
return strings.TrimSpace(reasoning) != ""
|
||||
}
|
||||
|
||||
func qwen38LiveConfig(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
baseURL := strings.TrimSpace(os.Getenv("QWEN38_LIVE_BASE_URL"))
|
||||
apiKey := strings.TrimSpace(os.Getenv("QWEN38_LIVE_API_KEY"))
|
||||
if baseURL == "" || apiKey == "" {
|
||||
t.Skip("set QWEN38_LIVE_BASE_URL and QWEN38_LIVE_API_KEY to run real-model acceptance")
|
||||
}
|
||||
return baseURL, apiKey
|
||||
}
|
||||
|
||||
func TestQwen38LiveStaticNormalizationAndReasoning(t *testing.T) {
|
||||
baseURL, apiKey := qwen38LiveConfig(t)
|
||||
proxy := newQwen38LiveProxy(t, baseURL)
|
||||
client := OpenAIClient{Corrections: NewParameterCorrectionCache()}
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]any
|
||||
stream bool
|
||||
expected map[string]any
|
||||
absent []string
|
||||
allowCompatibilityRetry bool
|
||||
}{
|
||||
{name: "none", input: map[string]any{"reasoning_effort": "none"}, expected: map[string]any{"reasoning_effort": "low", "enable_thinking": true}},
|
||||
{name: "minimal stream", input: map[string]any{"reasoning_effort": "minimal"}, stream: true, expected: map[string]any{"reasoning_effort": "low", "enable_thinking": true}},
|
||||
{name: "medium", input: map[string]any{"reasoning_effort": "medium"}, expected: map[string]any{"reasoning_effort": "medium", "enable_thinking": true}},
|
||||
{name: "high stream", input: map[string]any{"reasoning_effort": "high"}, stream: true, expected: map[string]any{"reasoning_effort": "xhigh", "enable_thinking": true}},
|
||||
{name: "max", input: map[string]any{"reasoning_effort": "max"}, expected: map[string]any{"reasoning_effort": "xhigh", "enable_thinking": true}},
|
||||
{name: "forced thinking stream", input: map[string]any{"enable_thinking": false}, stream: true, expected: map[string]any{"reasoning_effort": "low", "enable_thinking": true}},
|
||||
{name: "budget wins", input: map[string]any{"reasoning_effort": "high", "thinking_budget": 128}, expected: map[string]any{"thinking_budget": float64(128), "enable_thinking": true}, absent: []string{"reasoning_effort"}, allowCompatibilityRetry: true},
|
||||
{name: "temperature floor stream", input: map[string]any{"temperature": 0.1}, stream: true, expected: map[string]any{"temperature": 0.6, "enable_thinking": true}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
before := len(proxy.requestShapes())
|
||||
streamedReasoning := ""
|
||||
request := qwen38LiveRequest(proxy, apiKey, qwen38LiveBody(test.input), test.stream)
|
||||
if test.stream {
|
||||
request.StreamDelta = func(event StreamDeltaEvent) error {
|
||||
streamedReasoning += event.ReasoningContent
|
||||
return nil
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
response, err := client.Run(ctx, request)
|
||||
if err != nil {
|
||||
shapes := proxy.requestShapes()
|
||||
t.Fatalf("real Qwen3.8 request failed: %v; safe request shapes=%+v", err, shapes[before:])
|
||||
}
|
||||
if !qwen38ResponseHasReasoning(response, streamedReasoning) {
|
||||
t.Fatal("real Qwen3.8 response did not contain reasoning_content")
|
||||
}
|
||||
shapes := proxy.requestShapes()
|
||||
submissions := len(shapes) - before
|
||||
if submissions != 1 && !(test.allowCompatibilityRetry && submissions == 2) {
|
||||
t.Fatalf("unexpected submission count, before=%d after=%d safe request shapes=%+v", before, len(shapes), shapes[before:])
|
||||
}
|
||||
shape := shapes[before]
|
||||
for key, expected := range test.expected {
|
||||
if normalizedJSONScalar(shape[key]) != normalizedJSONScalar(expected) {
|
||||
t.Fatalf("%s expected %#v, got %#v in shape %+v", key, expected, shape[key], shape)
|
||||
}
|
||||
}
|
||||
for _, key := range test.absent {
|
||||
if _, ok := shape[key]; ok {
|
||||
t.Fatalf("%s must be absent in shape %+v", key, shape)
|
||||
}
|
||||
}
|
||||
t.Logf("case=%s response_id=%s correction=%t cache_hit=false", test.name, response.RequestID, submissions > 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen38LiveCorrectionCacheAndRestart(t *testing.T) {
|
||||
baseURL, apiKey := qwen38LiveConfig(t)
|
||||
proxy := newQwen38LiveProxy(t, baseURL)
|
||||
request := qwen38LiveRequest(proxy, apiKey, qwen38LiveBody(map[string]any{"reasoning_effort": "none", "n": 2}), false)
|
||||
cache := NewParameterCorrectionCache()
|
||||
client := OpenAIClient{Corrections: cache}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
first, err := client.Run(ctx, request)
|
||||
if err != nil || !qwen38ResponseHasReasoning(first, "") {
|
||||
t.Fatalf("cold corrected request failed or lacked reasoning: %v", err)
|
||||
}
|
||||
second, err := client.Run(ctx, request)
|
||||
if err != nil || !qwen38ResponseHasReasoning(second, "") {
|
||||
t.Fatalf("cached corrected request failed or lacked reasoning: %v", err)
|
||||
}
|
||||
shapes := proxy.requestShapes()
|
||||
if len(shapes) != 3 || normalizedJSONScalar(shapes[0]["n"]) != float64(2) || normalizedJSONScalar(shapes[1]["n"]) != float64(1) || normalizedJSONScalar(shapes[2]["n"]) != float64(1) {
|
||||
t.Fatalf("expected cold n=2 -> corrected n=1 -> cached n=1, got %+v", shapes)
|
||||
}
|
||||
|
||||
restarted := OpenAIClient{Corrections: NewParameterCorrectionCache()}
|
||||
third, err := restarted.Run(ctx, request)
|
||||
if err != nil || !qwen38ResponseHasReasoning(third, "") {
|
||||
t.Fatalf("post-restart corrected request failed or lacked reasoning: %v", err)
|
||||
}
|
||||
shapes = proxy.requestShapes()
|
||||
if len(shapes) != 5 || normalizedJSONScalar(shapes[3]["n"]) != float64(2) || normalizedJSONScalar(shapes[4]["n"]) != float64(1) {
|
||||
t.Fatalf("new process cache should relearn n rule, got %+v", shapes)
|
||||
}
|
||||
t.Logf("response_ids=%s,%s,%s correction=n:set:1 cache_hit_sequence=false,true,false", first.RequestID, second.RequestID, third.RequestID)
|
||||
}
|
||||
|
||||
func TestQwen38LiveNativeResponsesNormalization(t *testing.T) {
|
||||
baseURL, apiKey := qwen38LiveConfig(t)
|
||||
proxy := newQwen38LiveProxy(t, baseURL)
|
||||
client := OpenAIClient{Corrections: NewParameterCorrectionCache()}
|
||||
request := Request{
|
||||
Kind: "responses",
|
||||
Model: "Qwen3.8-Max-Preview",
|
||||
UpstreamProtocol: ProtocolOpenAIResponses,
|
||||
Body: map[string]any{
|
||||
"input": "Reply only OK", "max_output_tokens": 256,
|
||||
"reasoning": map[string]any{"effort": "none"},
|
||||
},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "aliyun-bailian-openai", BaseURL: proxy.server.URL,
|
||||
ProviderModelName: "Qwen3.8-Max-Preview", Credentials: map[string]any{"apiKey": apiKey},
|
||||
Capabilities: map[string]any{"text_generate": map[string]any{
|
||||
"supportThinking": true, "supportThinkingModeSwitch": false,
|
||||
"thinkingEffortLevels": []any{"low", "medium", "xhigh"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
response, err := client.Run(ctx, request)
|
||||
if err != nil {
|
||||
t.Fatalf("real Qwen3.8 Responses request failed: %v; safe shapes=%+v", err, proxy.requestShapes())
|
||||
}
|
||||
shapes := proxy.requestShapes()
|
||||
if len(shapes) != 1 {
|
||||
t.Fatalf("expected one Responses submission, got %+v", shapes)
|
||||
}
|
||||
reasoning, _ := shapes[0]["reasoning"].(map[string]any)
|
||||
if reasoning["effort"] != "low" {
|
||||
t.Fatalf("Responses none was not normalized to low: %+v", shapes)
|
||||
}
|
||||
t.Logf("case=responses-none response_id=%s correction=false cache_hit=false", response.RequestID)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{ "name": "none-to-low", "input": { "reasoning_effort": "none" }, "expected": { "enable_thinking": true, "reasoning_effort": "low" } },
|
||||
{ "name": "minimal-to-low", "input": { "reasoning_effort": "minimal" }, "expected": { "enable_thinking": true, "reasoning_effort": "low" } },
|
||||
{ "name": "medium", "input": { "reasoning_effort": "medium" }, "expected": { "enable_thinking": true, "reasoning_effort": "medium" } },
|
||||
{ "name": "high-to-xhigh", "input": { "reasoning_effort": "high" }, "expected": { "enable_thinking": true, "reasoning_effort": "xhigh" } },
|
||||
{ "name": "max-to-xhigh", "input": { "reasoning_effort": "max" }, "expected": { "enable_thinking": true, "reasoning_effort": "xhigh" } },
|
||||
{ "name": "force-thinking", "input": { "enable_thinking": false }, "expected": { "enable_thinking": true, "reasoning_effort": "low" } },
|
||||
{ "name": "budget-wins", "input": { "reasoning_effort": "high", "thinking_budget": 300000 }, "expected": { "enable_thinking": true, "thinking_budget": 262144 } },
|
||||
{ "name": "temperature-floor", "input": { "temperature": 0.1 }, "expected": { "enable_thinking": true, "temperature": 0.6 } }
|
||||
]
|
||||
Reference in New Issue
Block a user