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:
visa2
2026-07-25 10:20:50 +08:00
parent cb24522dd5
commit 386b57bb77
6 changed files with 93 additions and 2 deletions
@@ -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: '预览',
+3
View File
@@ -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>