diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 98e63899b6..c0b4faef52 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -1588,7 +1588,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) @@ -1596,6 +1597,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 1dfcec2988..67887a9f45 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 ac4838105d..112577b894 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 260628fd05..54375581b9 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -3886,6 +3886,9 @@ class="input" placeholder="gpt-5" /> +

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