mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
fix(composite): pass the requested model through when a prefix route leaves upstream_model empty
Composite model routes support a match_type: prefix mode with an optional upstream_model. Leaving upstream_model empty is meant to pass the concrete requested model through to the upstream, but every request matching the prefix collapsed to the route's public_model instead. A route with public_model=deepseek-v4, match_type=prefix and an empty upstream_model forwarded both deepseek-v4-flash and deepseek-v4-pro upstream as deepseek-v4. Root cause: normalizeCompositeRouteInput backfilled an empty upstream_model with public_model at save time, so CompositeRouteResolver.Resolve never reached its "upstreamModel == '' -> concrete requested model" fallback. - Skip the backfill for prefix routes only; an empty upstream_model stays empty and Resolve passes the concrete requested model through. - exact routes keep the backfill (model == public_model, so persisted/display contract is unchanged). - Explicit upstream_model still forwards that fixed model in every mode. - Frontend: add an upstream_model hint (zh/en) documenting empty = passthrough.
This commit is contained in:
@@ -1584,7 +1584,8 @@ func TestAdminService_CreateCompositeRoute_NormalizesAndPersists(t *testing.T) {
|
||||
require.Equal(t, "router/gpt-", route.PublicModel)
|
||||
require.Equal(t, CompositeRouteMatchPrefix, route.MatchType)
|
||||
require.Equal(t, PlatformOpenAI, route.TargetPlatform)
|
||||
require.Equal(t, "router/gpt-", route.UpstreamModel)
|
||||
// prefix 路由留空 upstream_model 不再回填 public_model:留空表示透传原始请求模型。
|
||||
require.Equal(t, "", route.UpstreamModel)
|
||||
require.Equal(t, CompositeRouteEndpointResponses, route.Endpoint)
|
||||
require.Equal(t, 100, route.Priority)
|
||||
require.True(t, route.Enabled)
|
||||
@@ -1592,6 +1593,29 @@ func TestAdminService_CreateCompositeRoute_NormalizesAndPersists(t *testing.T) {
|
||||
require.Equal(t, route, routeRepo.created)
|
||||
}
|
||||
|
||||
// TestAdminService_CreateCompositeRoute_ExactEmptyUpstreamBackfillsPublicModel 锁定
|
||||
// 保守行为:exact 路由留空 upstream_model 仍回填 public_model(持久化/展示契约不变)。
|
||||
func TestAdminService_CreateCompositeRoute_ExactEmptyUpstreamBackfillsPublicModel(t *testing.T) {
|
||||
groupRepo := &groupRepoStubForAdmin{
|
||||
getByID: &Group{ID: 7, Platform: PlatformComposite},
|
||||
}
|
||||
routeRepo := &compositeRouteRepoStubForAdmin{nextID: 99}
|
||||
svc := &adminServiceImpl{groupRepo: groupRepo, compositeRouteRepo: routeRepo}
|
||||
|
||||
route, err := svc.CreateCompositeRoute(context.Background(), 7, CompositeRouteInput{
|
||||
PublicModel: "openrouter/gpt-5",
|
||||
MatchType: CompositeRouteMatchExact,
|
||||
TargetPlatform: PlatformOpenAI,
|
||||
Endpoint: CompositeRouteEndpointResponses,
|
||||
Enabled: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, route)
|
||||
require.Equal(t, CompositeRouteMatchExact, route.MatchType)
|
||||
require.Equal(t, "openrouter/gpt-5", route.UpstreamModel)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateAndDeleteCompositeRouteRequireRouteOwnership(t *testing.T) {
|
||||
groupRepo := &groupRepoStubForAdmin{
|
||||
getByID: &Group{ID: 7, Platform: PlatformComposite},
|
||||
|
||||
@@ -118,7 +118,12 @@ func normalizeCompositeRouteInput(input CompositeRouteInput) CompositeRouteInput
|
||||
input.TargetPlatform = strings.TrimSpace(input.TargetPlatform)
|
||||
input.UpstreamModel = strings.TrimSpace(input.UpstreamModel)
|
||||
input.Endpoint = normalizeCompositeRouteEndpoint(input.Endpoint)
|
||||
if input.UpstreamModel == "" {
|
||||
// 仅对 exact 路由把空 upstream_model 回填成 public_model:exact 命中时请求模型
|
||||
// 恒等于 public_model,回填只影响持久化/后台展示,保留原有契约不变。
|
||||
// prefix 路由留空则不回填——Resolve 会回退到具体请求模型,从而透传原始模型
|
||||
// (否则 public=deepseek-v4 的前缀路由会把 deepseek-v4-flash / deepseek-v4-pro
|
||||
// 都塌缩成固定的 deepseek-v4)。显式填写 upstream_model 时任何模式都原样固定转发。
|
||||
if input.UpstreamModel == "" && input.MatchType == CompositeRouteMatchExact {
|
||||
input.UpstreamModel = input.PublicModel
|
||||
}
|
||||
input.Notes = strings.TrimSpace(input.Notes)
|
||||
|
||||
@@ -107,6 +107,63 @@ func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T
|
||||
require.Equal(t, int64(2), decision.Route.ID)
|
||||
}
|
||||
|
||||
// TestCompositeRouteResolverPrefixEmptyUpstreamPassesThroughRequestedModel 验证:
|
||||
// 前缀匹配路由留空 upstream_model 时,转发的是具体请求模型(各自原样),而不是
|
||||
// 塌缩成 public_model。这是「留空 = 透传原始模型」语义的核心场景。
|
||||
func TestCompositeRouteResolverPrefixEmptyUpstreamPassesThroughRequestedModel(t *testing.T) {
|
||||
resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
|
||||
routes: []CompositeModelRoute{
|
||||
{
|
||||
ID: 1,
|
||||
GroupID: 7,
|
||||
PublicModel: "deepseek-v4",
|
||||
MatchType: CompositeRouteMatchPrefix,
|
||||
TargetPlatform: PlatformOpenAI,
|
||||
UpstreamModel: "", // 留空 = 透传
|
||||
Endpoint: CompositeRouteEndpointAny,
|
||||
Priority: 100,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4"} {
|
||||
decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointChatCompletions)
|
||||
require.NoError(t, err)
|
||||
require.True(t, decision.Matched, "model %q should match prefix route", model)
|
||||
require.Equal(t, CompositeRouteSourceExplicit, decision.Source)
|
||||
require.Equal(t, PlatformOpenAI, decision.TargetPlatform)
|
||||
require.Equal(t, model, decision.UpstreamModel, "model %q should pass through verbatim", model)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompositeRouteResolverPrefixExplicitUpstreamStillFixed 验证:前缀匹配路由显式
|
||||
// 填写 upstream_model 时,所有命中请求仍转发同一个固定上游模型(行为不变)。
|
||||
func TestCompositeRouteResolverPrefixExplicitUpstreamStillFixed(t *testing.T) {
|
||||
resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
|
||||
routes: []CompositeModelRoute{
|
||||
{
|
||||
ID: 1,
|
||||
GroupID: 7,
|
||||
PublicModel: "deepseek-v4",
|
||||
MatchType: CompositeRouteMatchPrefix,
|
||||
TargetPlatform: PlatformOpenAI,
|
||||
UpstreamModel: "deepseek-chat",
|
||||
Endpoint: CompositeRouteEndpointAny,
|
||||
Priority: 100,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for _, model := range []string{"deepseek-v4-flash", "deepseek-v4-pro"} {
|
||||
decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointChatCompletions)
|
||||
require.NoError(t, err)
|
||||
require.True(t, decision.Matched)
|
||||
require.Equal(t, "deepseek-chat", decision.UpstreamModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeRouteResolverIgnoresDisabledRoutesAndFallsBackToDetector(t *testing.T) {
|
||||
resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
|
||||
routes: []CompositeModelRoute{
|
||||
|
||||
@@ -1031,6 +1031,7 @@ export default {
|
||||
endpoint: 'Endpoint',
|
||||
targetPlatform: 'Target Platform',
|
||||
upstreamModel: 'Upstream Model',
|
||||
upstreamModelHint: 'Leave empty to pass the original requested model through: under prefix match each matched model forwards verbatim (e.g. deepseek-v4-flash and deepseek-v4-pro each forwarded as-is); set a value to forward every matched request to that fixed model.',
|
||||
notes: 'Notes',
|
||||
enabled: 'Enabled',
|
||||
preview: 'Preview',
|
||||
|
||||
@@ -1028,6 +1028,7 @@ export default {
|
||||
endpoint: '端点',
|
||||
targetPlatform: '目标平台',
|
||||
upstreamModel: '上游模型',
|
||||
upstreamModelHint: '留空表示透传原始请求模型:前缀匹配下每个命中模型各自原样转发(如 deepseek-v4-flash、deepseek-v4-pro 分别转发);填写则所有命中请求都固定转发该模型。',
|
||||
notes: '备注',
|
||||
enabled: '启用',
|
||||
preview: '预览',
|
||||
|
||||
@@ -3809,6 +3809,9 @@
|
||||
class="input"
|
||||
placeholder="gpt-5"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.groups.compositeRoutes.upstreamModelHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user