统一 Qwen3.8 固定思考、推理档位、预算互斥与温度下限规则,并覆盖 Chat Completions 和原生 Responses 请求。 新增安全参数纠正、有限重试和并发安全的进程内 LRU 学习;补充共享行为向量、httptest 回归与真实模型验收用例。 验证:go test ./... -count=1 通过,gofmt 检查通过;真实 Qwen3.8 流式、非流式及缓存重学习验收通过。
313 lines
12 KiB
Go
313 lines
12 KiB
Go
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)
|
|
}
|