From 908dce79f7c429237e80acaf451d9d2c69873735 Mon Sep 17 00:00:00 2001 From: chengqian Date: Fri, 7 Aug 2026 14:08:34 +0800 Subject: [PATCH] fix(relay): preserve literal models with effort suffixes --- relay/handler/relay_handler.go | 46 +++++++++----- relay/handler/relay_handler_test.go | 96 +++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 relay/handler/relay_handler_test.go diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index e7f2ecd..5ff6f4a 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -67,6 +67,32 @@ type relayValidation struct { sessionSignals dispatch.SessionSignals // 请求体中的会话信号(影子模式/新调度用) } +type modelMappingProvider interface { + GetModelMapping(ctx context.Context, modelName string) (standardName string, category string, err error) +} + +// resolveRelayModel gives a literal catalog model precedence over the optional +// thinking/effort suffix syntax. This avoids treating names such as +// "qwen3.8-max" as the virtual model "qwen3.8" with effort=max. +func resolveRelayModel(ctx context.Context, provider modelMappingProvider, modelName string) (string, *helper.ThinkingInfo, error) { + parsed := helper.ParseThinkingSuffix(modelName) + literal := &helper.ThinkingInfo{BaseModel: modelName} + + _, _, err := provider.GetModelMapping(ctx, modelName) + if err == nil { + return modelName, literal, nil + } + if parsed.BaseModel == modelName { + return "", nil, err + } + + _, _, err = provider.GetModelMapping(ctx, parsed.BaseModel) + if err != nil { + return "", nil, err + } + return parsed.BaseModel, &parsed, nil +} + // validateRelayRequest 校验请求合法性:relay mode、QPS 限流(前置)、模型存在性、弃用状态、成员/API Key 模型范围。 // 纯校验逻辑,无 defer 副作用。 func validateRelayRequest( @@ -134,23 +160,9 @@ func validateRelayRequest( return nil, constant.NewRequestError("model is required", nil) } - // 2.5 解析 thinking/effort 后缀 - parsed := helper.ParseThinkingSuffix(modelName) - thinkingInfo := &parsed - lookupModel := modelName - if thinkingInfo.BaseModel != modelName { - lookupModel = thinkingInfo.BaseModel - } - - // 3. 验证模型存在且活跃 - _, _, modelErr := provider.GetModelMapping(ctx, modelName) - if modelErr != nil && thinkingInfo.BaseModel != modelName { - // 完整模型名查找失败,尝试基础模型名 - _, _, modelErr = provider.GetModelMapping(ctx, thinkingInfo.BaseModel) - if modelErr == nil { - lookupModel = thinkingInfo.BaseModel - } - } + // 2.5 解析 thinking/effort 后缀并验证模型。完整模型名存在时按字面模型处理; + // 仅在完整名称不存在时,才尝试将后缀解析为虚拟 thinking/effort 参数。 + lookupModel, thinkingInfo, modelErr := resolveRelayModel(ctx, provider, modelName) if modelErr != nil { if modelErr == common.ErrModelNotFound { return nil, constant.NewRequestError("model not found: "+modelName, modelErr) diff --git a/relay/handler/relay_handler_test.go b/relay/handler/relay_handler_test.go new file mode 100644 index 0000000..5d427c4 --- /dev/null +++ b/relay/handler/relay_handler_test.go @@ -0,0 +1,96 @@ +package handler + +import ( + "context" + "testing" + + "github.com/qianfree/team-api/relay/common" +) + +type modelMappingStub struct { + models map[string]bool + calls []string +} + +func (s *modelMappingStub) GetModelMapping(_ context.Context, modelName string) (string, string, error) { + s.calls = append(s.calls, modelName) + if !s.models[modelName] { + return "", "", common.ErrModelNotFound + } + return modelName, "chat", nil +} + +func TestResolveRelayModel(t *testing.T) { + tests := []struct { + name string + model string + models map[string]bool + wantLookup string + wantEffort string + wantCalls []string + wantErr bool + }{ + { + name: "literal model ending in effort suffix wins", + model: "qwen3.8-max", + models: map[string]bool{"qwen3.8-max": true, "qwen3.8": true}, + wantLookup: "qwen3.8-max", + wantCalls: []string{"qwen3.8-max"}, + }, + { + name: "virtual effort suffix falls back to base model", + model: "o3-max", + models: map[string]bool{"o3": true}, + wantLookup: "o3", + wantEffort: "max", + wantCalls: []string{"o3-max", "o3"}, + }, + { + name: "model without suffix resolves literally", + model: "gpt-4o", + models: map[string]bool{"gpt-4o": true}, + wantLookup: "gpt-4o", + wantCalls: []string{"gpt-4o"}, + }, + { + name: "missing literal and base model returns error", + model: "missing-high", + models: map[string]bool{}, + wantCalls: []string{"missing-high", "missing"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &modelMappingStub{models: tt.models} + lookup, thinking, err := resolveRelayModel(t.Context(), provider, tt.model) + if (err != nil) != tt.wantErr { + t.Fatalf("resolveRelayModel() error = %v, wantErr %v", err, tt.wantErr) + } + if lookup != tt.wantLookup { + t.Errorf("lookup model = %q, want %q", lookup, tt.wantLookup) + } + if !tt.wantErr { + if thinking == nil { + t.Fatal("thinking info is nil") + } + if thinking.BaseModel != tt.wantLookup { + t.Errorf("thinking base model = %q, want %q", thinking.BaseModel, tt.wantLookup) + } + if thinking.EffortLevel != tt.wantEffort { + t.Errorf("effort level = %q, want %q", thinking.EffortLevel, tt.wantEffort) + } + } + if len(provider.calls) != len(tt.wantCalls) { + t.Fatalf("lookup calls = %v, want %v", provider.calls, tt.wantCalls) + } + for i := range tt.wantCalls { + if provider.calls[i] != tt.wantCalls[i] { + t.Errorf("lookup calls = %v, want %v", provider.calls, tt.wantCalls) + break + } + } + }) + } +}