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) }