fix(relay): preserve literal models with effort suffixes
This commit is contained in:
1 parent
5a75c2ef98
commit
908dce79f7
2 files changed
+125
-17
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user