diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index c12f281ff9..beb9400d11 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1166,7 +1166,7 @@ func (h *GatewayHandler) Models(c *gin.Context) { } if platform == service.PlatformComposite { - availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, true) + availableModels := h.compositeAvailableModels(c.Request.Context(), groupID, "", true) if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { source := availableModels if len(source) == 0 { @@ -1265,19 +1265,19 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi platform = group.Platform } if platform == service.PlatformComposite { - availableModels := h.compositeAvailableModels(ctx, groupID, false) + availableModels := h.compositeAvailableModels(ctx, groupID, service.CompositeRouteEndpointResponses, false) fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite) + models := availableModels + if len(models) == 0 { + models = fallbackModels + } if group.ModelAllowlistEnabled() { - source := availableModels - if len(source) == 0 { - source = fallbackModels - } - return group.ModelAllowlist.FilterForListing(source) + models = group.ModelAllowlist.FilterForListing(models) } - if len(availableModels) > 0 { - return availableModels + if filtered, err := h.gatewayService.FilterCompositeCodexModels(ctx, group.ID, models); err == nil { + return filtered } - return fallbackModels + return models } availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) @@ -1294,7 +1294,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi // compositeAvailableModels lists the models the composite group can serve. // includeSystemOne adds TypeSafe models, which only work through /v1/systemone; // LLM client catalogs (Codex) must exclude them. -func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, includeSystemOne bool) []string { +func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64, endpoint string, includeSystemOne bool) []string { if h == nil || h.gatewayService == nil { return nil } @@ -1325,6 +1325,16 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * models = append(models, model) } } + // A route can expose a public ID that no account model mapping contains. + // On lookup failure, retain the existing account-derived catalog only. + if routeModels, err := h.gatewayService.GetCompositeRouteModels(ctx, groupID, endpoint, includeSystemOne); err == nil { + for _, model := range routeModels { + if _, ok := seen[model]; !ok { + seen[model] = struct{}{} + models = append(models, model) + } + } + } return models } diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index d2fdb5cfa0..e3bb7be616 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -3,6 +3,7 @@ package handler import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -22,6 +23,16 @@ type gatewayModelsAccountRepoStub struct { byGroup map[int64][]service.Account } +type gatewayModelsRouteRepoStub struct { + service.CompositeModelRouteRepository + routes []service.CompositeModelRoute + err error +} + +func (s *gatewayModelsRouteRepoStub) ListByGroup(_ context.Context, _ int64, _ bool) ([]service.CompositeModelRoute, error) { + return s.routes, s.err +} + type gatewayModelsResponseForTest struct { Object string `json:"object"` Data []gatewayModelItemForTest `json:"data"` @@ -81,16 +92,80 @@ func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx conte return s.ListSchedulableByGroupID(ctx, *groupID) } -func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler { +func newGatewayModelsHandlerForTest(repo service.AccountRepository, routeRepo ...service.CompositeModelRouteRepository) *GatewayHandler { + var resolver *service.CompositeRouteResolver + if len(routeRepo) > 0 { + resolver = service.NewCompositeRouteResolver(routeRepo[0]) + } return &GatewayHandler{ gatewayService: service.NewGatewayService( repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, resolver, nil, nil, ), } } +func TestGatewayCompositeRouteModelsAppearInBothCatalogs(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 7784 + accounts := &gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformOpenAI, Credentials: map[string]any{ + "model_mapping": map[string]any{"mapped-model": "upstream"}, + }}}, + }} + routes := &gatewayModelsRouteRepoStub{routes: []service.CompositeModelRoute{ + {PublicModel: "route-only", MatchType: service.CompositeRouteMatchExact, Enabled: true}, + {PublicModel: "route-only", MatchType: service.CompositeRouteMatchExact, Enabled: true}, + {PublicModel: "mapped-model", MatchType: service.CompositeRouteMatchExact, Enabled: true}, + {PublicModel: "image-only", MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointImages, Enabled: true}, + {PublicModel: "prefix-", MatchType: service.CompositeRouteMatchPrefix, Enabled: true}, + {PublicModel: "disabled", MatchType: service.CompositeRouteMatchExact, Enabled: false}, + }} + for _, tc := range []struct { + name string + repo service.CompositeModelRouteRepository + allowlist []string + want []string + }{ + {"routes", routes, nil, []string{"mapped-model", "route-only", "image-only"}}, + {"allowlist", routes, []string{"route-only", "disabled", "unknown"}, []string{"route-only"}}, + {"lookup failure", &gatewayModelsRouteRepoStub{err: errors.New("unavailable")}, nil, []string{"mapped-model"}}, + {"allowlisted lookup failure", &gatewayModelsRouteRepoStub{err: errors.New("unavailable")}, []string{"mapped-model", "unknown"}, []string{"mapped-model"}}, + {"no route repo", nil, nil, []string{"mapped-model"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newGatewayModelsHandlerForTest(accounts, tc.repo) + group := &service.Group{ID: groupID, Platform: service.PlatformComposite} + if tc.allowlist != nil { + group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: tc.allowlist} + } + for _, endpoint := range []string{"models", "codex"} { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + if endpoint == "models" { + h.Models(c) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, tc.want, modelIDsForTest(got.Data)) + } else { + h.CodexModels(c) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + want := tc.want + if tc.name == "routes" { + want = []string{"mapped-model", "route-only"} + } + require.ElementsMatch(t, want, codexModelSlugsForTest(got.Models)) + } + require.Equal(t, http.StatusOK, rec.Code) + } + }) + } +} + func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) { antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity) require.NotEmpty(t, antigravityIDs) @@ -1570,3 +1645,188 @@ func TestDefaultModelIDsForPlatform_CompositeFallbackExcludesTypeSafe(t *testing require.NotContains(t, defaultModelIDsForPlatform(service.PlatformComposite), "jev-latest") require.NotContains(t, defaultCodexModelIDsForPlatform(service.PlatformComposite), "jev-latest") } + +func TestGatewayModels_CompositeExactRoutesRespectTypeSafeIsolation(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 7784 + routes := &gatewayModelsRouteRepoStub{routes: []service.CompositeModelRoute{ + {PublicModel: "systemone-any-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointAny, Enabled: true}, + {PublicModel: "systemone-responses-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true}, + {PublicModel: "systemone-legacy-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Enabled: true}, + {PublicModel: "jev-latest", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Enabled: true}, + {PublicModel: "responses-alias", TargetPlatform: service.PlatformOpenAI, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true}, + {PublicModel: "image-alias", TargetPlatform: service.PlatformOpenAI, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointImages, Enabled: true}, + }} + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{}, routes) + wantModels := make([]string, 0, len(routes.routes)) + for _, route := range routes.routes { + wantModels = append(wantModels, route.PublicModel) + } + for _, allowlisted := range []bool{false, true} { + name := "unrestricted" + if allowlisted { + name = "allowlisted" + } + t.Run(name, func(t *testing.T) { + group := &service.Group{ID: groupID, Platform: service.PlatformComposite} + if allowlisted { + group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{ + "systemone-any-alias", "systemone-responses-alias", "systemone-legacy-alias", "jev-latest", "responses-alias", "image-alias", + }} + } + for _, codex := range []bool{false, true} { + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + ctx.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + if codex { + h.CodexModels(ctx) + var manifest codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &manifest)) + require.Equal(t, []string{"responses-alias"}, codexModelSlugsForTest(manifest.Models)) + } else { + h.Models(ctx) + var models gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &models)) + require.ElementsMatch(t, wantModels, modelIDsForTest(models.Data)) + } + require.Equal(t, http.StatusOK, rec.Code) + } + }) + } +} + +func TestGatewayModels_CompositeCodexFiltersEffectiveResponsesRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 7793 + defaultModel := defaultModelIDsForPlatform(service.PlatformOpenAI)[0] + for _, scenario := range []struct { + name string + model string + accounts []service.Account + routes []service.CompositeModelRoute + }{ + { + name: "colliding exact routes", + model: "shared-alias", + routes: []service.CompositeModelRoute{ + {PublicModel: "shared-alias", TargetPlatform: service.PlatformOpenAI, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointAny, Priority: 1, Enabled: true}, + {PublicModel: "shared-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Priority: 100, Enabled: true}, + }, + }, + { + name: "account alias exact override", + model: "account-alias", + accounts: []service.Account{{ID: 1, Platform: service.PlatformOpenAI, Credentials: map[string]any{ + "model_mapping": map[string]any{"account-alias": "gpt-5"}, + }}}, + routes: []service.CompositeModelRoute{ + {PublicModel: "account-alias", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true}, + }, + }, + { + name: "account alias prefix override", + model: "account-alias", + accounts: []service.Account{{ID: 1, Platform: service.PlatformOpenAI, Credentials: map[string]any{ + "model_mapping": map[string]any{"account-alias": "gpt-5"}, + }}}, + routes: []service.CompositeModelRoute{ + {PublicModel: "account-", TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchPrefix, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true}, + }, + }, + { + name: "account default override", + model: defaultModel, + accounts: []service.Account{{ID: 1, Platform: service.PlatformOpenAI}}, + routes: []service.CompositeModelRoute{ + {PublicModel: defaultModel, TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true}, + }, + }, + { + name: "fallback default override", + model: defaultModel, + routes: []service.CompositeModelRoute{ + {PublicModel: defaultModel, TargetPlatform: service.PlatformTypeSafe, MatchType: service.CompositeRouteMatchPrefix, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true}, + }, + }, + } { + t.Run(scenario.name, func(t *testing.T) { + for _, allowlisted := range []bool{false, true} { + name := "unrestricted" + if allowlisted { + name = "allowlisted" + } + t.Run(name, func(t *testing.T) { + routes := append([]service.CompositeModelRoute(nil), scenario.routes...) + if scenario.name != "fallback default override" { + routes = append(routes, service.CompositeModelRoute{ + PublicModel: "responses-control", TargetPlatform: service.PlatformOpenAI, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointResponses, Enabled: true, + }) + } + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{groupID: scenario.accounts}, + }, &gatewayModelsRouteRepoStub{routes: routes}) + group := &service.Group{ID: groupID, Platform: service.PlatformComposite} + if allowlisted { + group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{scenario.model, "responses-control"}} + } + for _, codex := range []bool{false, true} { + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + ctx.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + if codex { + h.CodexModels(ctx) + var manifest codexModelsResponseForTest + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &manifest)) + slugs := codexModelSlugsForTest(manifest.Models) + require.NotContains(t, slugs, scenario.model) + if scenario.name != "fallback default override" { + require.Contains(t, slugs, "responses-control") + } else if allowlisted { + require.Empty(t, slugs) + } + } else { + h.Models(ctx) + var models gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &models)) + require.Contains(t, modelIDsForTest(models.Data), scenario.model) + } + require.Equal(t, http.StatusOK, recorder.Code) + } + }) + } + }) + } +} + +func TestGatewayModels_CompositeCodexRouteLookupErrorKeepsFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 7793 + model := defaultCodexModelIDsForPlatform(service.PlatformComposite)[0] + for _, allowlisted := range []bool{false, true} { + name := "unrestricted" + if allowlisted { + name = "allowlisted" + } + t.Run(name, func(t *testing.T) { + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{}, &gatewayModelsRouteRepoStub{err: errors.New("unavailable")}) + group := &service.Group{ID: groupID, Platform: service.PlatformComposite} + if allowlisted { + group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{model}} + } + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + ctx.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + h.CodexModels(ctx) + require.Equal(t, http.StatusOK, recorder.Code) + var manifest codexModelsResponseForTest + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &manifest)) + require.Contains(t, codexModelSlugsForTest(manifest.Models), model) + if allowlisted { + require.Len(t, manifest.Models, 1) + } + }) + } +} diff --git a/backend/internal/service/composite_route_resolver.go b/backend/internal/service/composite_route_resolver.go index f2e1928ace..5d73de7de0 100644 --- a/backend/internal/service/composite_route_resolver.go +++ b/backend/internal/service/composite_route_resolver.go @@ -22,6 +22,52 @@ func (r *CompositeRouteResolver) SetModelOwnershipResolver(resolver CompositeMod } } +// ListExactPublicModels returns enabled, concrete route IDs suitable for a model catalog. +func (r *CompositeRouteResolver) ListExactPublicModels(ctx context.Context, groupID int64, endpoint string, includeSystemOne bool) ([]string, error) { + if r == nil || r.repo == nil || groupID <= 0 { + return nil, nil + } + routes, err := r.repo.ListByGroup(ctx, groupID, false) + if err != nil { + return nil, err + } + models := make([]string, 0, len(routes)) + for _, route := range routes { + if route.Enabled && route.MatchType == CompositeRouteMatchExact && + (endpoint == "" || normalizeCompositeRouteEndpoint(route.Endpoint) == CompositeRouteEndpointAny || normalizeCompositeRouteEndpoint(route.Endpoint) == endpoint) { + if model := strings.TrimSpace(route.PublicModel); model != "" { + models = append(models, model) + } + } + } + if !includeSystemOne { + models = filterSystemOneRouteModels(routes, models, endpoint) + } + return models, nil +} + +func (r *CompositeRouteResolver) FilterCodexModels(ctx context.Context, groupID int64, models []string) ([]string, error) { + if r == nil || r.repo == nil || groupID <= 0 { + return models, nil + } + routes, err := r.repo.ListByGroup(ctx, groupID, false) + if err != nil { + return nil, err + } + return filterSystemOneRouteModels(routes, models, CompositeRouteEndpointResponses), nil +} + +func filterSystemOneRouteModels(routes []CompositeModelRoute, models []string, endpoint string) []string { + filtered := make([]string, 0, len(models)) + for _, model := range models { + route, matched := matchCompositeRoute(routes, model, normalizeCompositeRouteEndpoint(endpoint)) + if !matched || route.TargetPlatform != PlatformTypeSafe { + filtered = append(filtered, model) + } + } + return filtered +} + func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, model, endpoint string) (CompositeRouteDecision, error) { model = strings.TrimSpace(model) endpoint = normalizeCompositeRouteEndpoint(endpoint) diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index 474b29387b..92827ab659 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -12,6 +12,85 @@ type compositeRouteRepoStub struct { routes []CompositeModelRoute } +func TestCompositeRouteResolverCodexFilteringMatchesProductionPriority(t *testing.T) { + for _, scenario := range []struct { + name string + routes []CompositeModelRoute + want []string + }{ + { + name: "endpoint beats priority", + routes: []CompositeModelRoute{ + {ID: 1, PublicModel: "alias", MatchType: CompositeRouteMatchExact, Endpoint: CompositeRouteEndpointAny, TargetPlatform: PlatformOpenAI, Priority: 1}, + {ID: 2, PublicModel: "alias", MatchType: CompositeRouteMatchExact, Endpoint: CompositeRouteEndpointResponses, TargetPlatform: PlatformTypeSafe, Priority: 100}, + }, + want: []string{"unrouted"}, + }, + { + name: "Responses OpenAI overrides any TypeSafe", + routes: []CompositeModelRoute{ + {ID: 1, PublicModel: "alias", MatchType: CompositeRouteMatchExact, Endpoint: CompositeRouteEndpointAny, TargetPlatform: PlatformTypeSafe, Priority: 1}, + {ID: 2, PublicModel: "alias", MatchType: CompositeRouteMatchExact, Endpoint: CompositeRouteEndpointResponses, TargetPlatform: PlatformOpenAI, Priority: 100}, + }, + want: []string{"alias", "unrouted"}, + }, + { + name: "longer prefix beats priority", + routes: []CompositeModelRoute{ + {ID: 1, PublicModel: "a", MatchType: CompositeRouteMatchPrefix, TargetPlatform: PlatformOpenAI, Priority: 1}, + {ID: 2, PublicModel: "ali", MatchType: CompositeRouteMatchPrefix, TargetPlatform: PlatformTypeSafe, Priority: 100}, + }, + want: []string{"unrouted"}, + }, + { + name: "exact beats endpoint prefix", + routes: []CompositeModelRoute{ + {ID: 1, PublicModel: "alias", MatchType: CompositeRouteMatchPrefix, Endpoint: CompositeRouteEndpointResponses, TargetPlatform: PlatformTypeSafe}, + {ID: 2, PublicModel: "alias", MatchType: CompositeRouteMatchExact, Endpoint: CompositeRouteEndpointAny, TargetPlatform: PlatformOpenAI}, + }, + want: []string{"alias", "unrouted"}, + }, + { + name: "lower priority wins", + routes: []CompositeModelRoute{ + {ID: 1, PublicModel: "alias", MatchType: CompositeRouteMatchExact, TargetPlatform: PlatformTypeSafe, Priority: 100}, + {ID: 2, PublicModel: "alias", MatchType: CompositeRouteMatchExact, TargetPlatform: PlatformOpenAI, Priority: 1}, + }, + want: []string{"alias", "unrouted"}, + }, + { + name: "lower ID breaks tie", + routes: []CompositeModelRoute{ + {ID: 2, PublicModel: "alias", MatchType: CompositeRouteMatchExact, TargetPlatform: PlatformOpenAI}, + {ID: 1, PublicModel: "alias", MatchType: CompositeRouteMatchExact, TargetPlatform: PlatformTypeSafe}, + }, + want: []string{"unrouted"}, + }, + } { + t.Run(scenario.name, func(t *testing.T) { + for index := range scenario.routes { + scenario.routes[index].Enabled = true + scenario.routes[index].GroupID = 7 + } + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{routes: scenario.routes}) + models, err := resolver.FilterCodexModels(context.Background(), 7, []string{"alias", "unrouted"}) + require.NoError(t, err) + require.Equal(t, scenario.want, models) + decision, err := resolver.Resolve(context.Background(), 7, "alias", CompositeRouteEndpointResponses) + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, decision.TargetPlatform != PlatformTypeSafe, len(models) == 2) + exactModels, err := resolver.ListExactPublicModels(context.Background(), 7, CompositeRouteEndpointResponses, false) + require.NoError(t, err) + if decision.TargetPlatform == PlatformTypeSafe { + require.Empty(t, exactModels) + } else { + require.Contains(t, exactModels, "alias") + } + }) + } +} + func (s compositeRouteRepoStub) ListByGroup(ctx context.Context, groupID int64, includeDisabled bool) ([]CompositeModelRoute, error) { routes := make([]CompositeModelRoute, 0, len(s.routes)) for _, route := range s.routes { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index eb782d9a1e..c22097ede1 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1535,6 +1535,21 @@ func explicitModelMappingClaims(account Account, model string) bool { return ok && strings.TrimSpace(mapped) != "" } +// GetCompositeRouteModels returns public IDs from enabled exact composite routes. +func (s *GatewayService) GetCompositeRouteModels(ctx context.Context, groupID *int64, endpoint string, includeSystemOne bool) ([]string, error) { + if s == nil || s.compositeResolver == nil || groupID == nil { + return nil, nil + } + return s.compositeResolver.ListExactPublicModels(ctx, *groupID, endpoint, includeSystemOne) +} + +func (s *GatewayService) FilterCompositeCodexModels(ctx context.Context, groupID int64, models []string) ([]string, error) { + if s == nil || s.compositeResolver == nil { + return models, nil + } + return s.compositeResolver.FilterCodexModels(ctx, groupID, models) +} + // GetSchedulablePlatforms returns the concrete platforms that currently have // schedulable accounts in the target group. func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} {