fix(relay): preserve literal models with effort suffixes

This commit is contained in:
chengqian committed 2026-08-07 14:08:34 +08:00
1 parent 5a75c2ef98
commit 908dce79f7
2 files changed
+125 -17

No files matched your search

+29 -17
View File
@@ -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)
+96
View File
@@ -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
}
}
})
}
}