From 386b57bb77b7d3ebf1af73c3003c70b9b5712ecf Mon Sep 17 00:00:00 2001 From: visa2 <1927401+visa2@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:41:52 +0800 Subject: [PATCH] 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. --- .../service/admin_service_group_test.go | 26 ++++++++- .../internal/service/composite_model_route.go | 7 ++- .../service/composite_route_resolver_test.go | 57 +++++++++++++++++++ .../src/i18n/locales/en/admin/overview.ts | 1 + .../src/i18n/locales/zh/admin/overview.ts | 1 + frontend/src/views/admin/GroupsView.vue | 3 + 6 files changed, 93 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index cf13417879..5b28928f79 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -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}, diff --git a/backend/internal/service/composite_model_route.go b/backend/internal/service/composite_model_route.go index 2bae5c62df..a008a3e9fa 100644 --- a/backend/internal/service/composite_model_route.go +++ b/backend/internal/service/composite_model_route.go @@ -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) diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index ed6f6f0850..35028934dd 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -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{ diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 7782f55583..6495c0c719 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 39cbe8a264..c6125b7ddd 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1028,6 +1028,7 @@ export default { endpoint: '端点', targetPlatform: '目标平台', upstreamModel: '上游模型', + upstreamModelHint: '留空表示透传原始请求模型:前缀匹配下每个命中模型各自原样转发(如 deepseek-v4-flash、deepseek-v4-pro 分别转发);填写则所有命中请求都固定转发该模型。', notes: '备注', enabled: '启用', preview: '预览', diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 6ebe04285f..c9996f9f92 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -3809,6 +3809,9 @@ class="input" placeholder="gpt-5" /> +

+ {{ t("admin.groups.compositeRoutes.upstreamModelHint") }} +