From ba345f105277f612d55ffb93cb3cdba05582ab40 Mon Sep 17 00:00:00 2001 From: Jarl Date: Mon, 31 Aug 2026 20:26:29 +0800 Subject: [PATCH] fix(codex): ignore persistently disabled catalog accounts --- .../internal/handler/gateway_models_test.go | 7 ++ .../openai_codex_models_handler_test.go | 4 + .../openai_codex_model_metadata_test.go | 30 +++-- .../service/openai_codex_models_service.go | 27 ++++- .../openai_codex_models_service_test.go | 108 +++++++++++++++--- 5 files changed, 146 insertions(+), 30 deletions(-) diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 8059611faa..386954a17e 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -72,6 +72,13 @@ func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID return s.ListSchedulableByGroupID(ctx, groupID) } +func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]service.Account, error) { + if groupID == nil { + return nil, nil + } + return s.ListSchedulableByGroupID(ctx, *groupID) +} + func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler { return &GatewayHandler{ gatewayService: service.NewGatewayService( diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 9eabd3e5b2..fb66f8ee13 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -53,6 +53,10 @@ func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64) return append([]service.Account(nil), r.accounts...), nil } +func (r codexModelsFailoverAccountRepo) ListModelAvailabilityCandidates(_ context.Context, _ *int64, _ []string, _ bool) ([]service.Account, error) { + return append([]service.Account(nil), r.accounts...), nil +} + type codexModelsFailoverHTTPUpstream struct { service.HTTPUpstream mu sync.Mutex diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index 5161617d7b..b058fdc0df 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -198,8 +198,9 @@ func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithout } } -// Scenario: temporarily unschedulable mapped accounts still participate in capability intersection. -func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t *testing.T) { +// Scenario: accounts that leave the current scheduling pool only because of +// transient state still participate in capability intersection. +func TestBuildCodexModelsManifestForGroupIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) { t.Parallel() const groupID int64 = 741 @@ -213,19 +214,19 @@ func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t true, nil, ) - unschedulable := newCodexCatalogMappedAccount( + transientlyUnschedulable := newCodexCatalogMappedAccount( 42, "glm-5.3", "GLM 5.3", []string{"low", "medium", "high"}, []string{"text"}, 272_000, - false, + true, map[string]any{"exclusive-model": "exclusive-upstream"}, ) svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{ schedulable: map[int64][]Account{groupID: {schedulable}}, - catalog: map[int64][]Account{groupID: {schedulable, unschedulable}}, + catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}}, }} body, err := svc.BuildCodexModelsManifestForGroup( @@ -241,8 +242,8 @@ func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t require.EqualValues(t, 272_000, models[0]["context_window"]) } -// Scenario: deleting an account can widen the advertised contract. -func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemoved(t *testing.T) { +// Scenario: a persistently disabled account cannot narrow the advertised contract. +func TestBuildCodexModelsManifestForGroupIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) { t.Parallel() const groupID int64 = 742 @@ -256,9 +257,20 @@ func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemove true, nil, ) + disabled := newCodexCatalogMappedAccount( + 42, + "glm-5.3", + "GLM 5.3", + []string{"low", "medium", "high"}, + []string{"text"}, + 272_000, + false, + nil, + ) svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{ schedulable: map[int64][]Account{groupID: {remaining}}, catalog: map[int64][]Account{groupID: {remaining}}, + all: map[int64][]Account{groupID: {remaining, disabled}}, }} body, err := svc.BuildCodexModelsManifestForGroup( @@ -271,7 +283,7 @@ func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemove require.EqualValues(t, 1_000_000, models[0]["context_window"]) } -func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFails(t *testing.T) { +func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenAvailabilityLookupFails(t *testing.T) { t.Parallel() const groupID int64 = 743 @@ -286,7 +298,7 @@ func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFa true, nil, )}, - listByGroupErr: errors.New("group listing unavailable"), + availabilityErr: errors.New("group listing unavailable"), } svc := &GatewayService{accountRepo: repo} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index b55549e753..82489b639a 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -208,11 +208,12 @@ func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, // loadCodexGroupCatalogAccounts separates picker membership from capability // intersection. visible accounts are currently schedulable and decide which -// public aliases appear. catalog accounts are all non-deleted active group -// members; their snapshots keep advertised capabilities from widening when a -// mapped account is only temporarily unschedulable. If ListByGroup fails, the -// catalog falls back to the schedulable set so a listing error does not fail -// the client request. +// public aliases appear. catalog accounts are persistently enabled group +// members; the availability query ignores transient rate-limit, overload, and +// temporary-unschedulable state so those conditions cannot widen advertised +// capabilities. Persistently disabled accounts are excluded because routing +// cannot select them. If the availability query fails, the catalog falls back +// to the schedulable set so a listing error does not fail the client request. func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, groupID int64) (visible []Account, catalog []Account, err error) { if repo == nil { return nil, nil, nil @@ -222,7 +223,21 @@ func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, return nil, nil, err } catalog = visible - groupAccounts, listErr := repo.ListByGroup(ctx, groupID) + groupAccounts, listErr := repo.ListModelAvailabilityCandidates( + ctx, + &groupID, + []string{ + PlatformAnthropic, + PlatformOpenAI, + PlatformGemini, + PlatformAntigravity, + PlatformGrok, + PlatformKimi, + PlatformZhipu, + PlatformDeepseek, + }, + false, + ) if listErr != nil { return visible, catalog, nil } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 77a65cfb38..62d2924258 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -39,17 +39,23 @@ func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Con return append([]Account(nil), accounts...), nil } -func (r codexModelsVisibilityAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) { - accounts := r.byGroup[groupID] +func (r codexModelsVisibilityAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) { + if groupID == nil { + return nil, nil + } + accounts := r.byGroup[*groupID] return append([]Account(nil), accounts...), nil } type countingCodexModelsAccountRepo struct { AccountRepository - accounts []Account - err error - listByGroupErr error - calls atomic.Int32 + accounts []Account + err error + availabilityErr error + groupID *int64 + platforms []string + includeGrouped bool + calls atomic.Int32 } func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) { @@ -60,9 +66,15 @@ func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Cont return append([]Account(nil), r.accounts...), nil } -func (r *countingCodexModelsAccountRepo) ListByGroup(_ context.Context, _ int64) ([]Account, error) { - if r.listByGroupErr != nil { - return nil, r.listByGroupErr +func (r *countingCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, platforms []string, includeGrouped bool) ([]Account, error) { + if groupID != nil { + value := *groupID + r.groupID = &value + } + r.platforms = append([]string(nil), platforms...) + r.includeGrouped = includeGrouped + if r.availabilityErr != nil { + return nil, r.availabilityErr } return append([]Account(nil), r.accounts...), nil } @@ -71,6 +83,7 @@ type splitCodexModelsAccountRepo struct { AccountRepository schedulable map[int64][]Account catalog map[int64][]Account + all map[int64][]Account } func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) { @@ -78,7 +91,18 @@ func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, } func (r splitCodexModelsAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) { - return append([]Account(nil), r.catalog[groupID]...), nil + accounts := r.all[groupID] + if accounts == nil { + accounts = r.catalog[groupID] + } + return append([]Account(nil), accounts...), nil +} + +func (r splitCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) { + if groupID == nil { + return nil, nil + } + return append([]Account(nil), r.catalog[*groupID]...), nil } func newCodexCatalogMappedAccount( @@ -814,6 +838,13 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { ) require.NoError(t, err) require.Equal(t, int32(1), repo.calls.Load()) + require.NotNil(t, repo.groupID) + require.Equal(t, groupID, *repo.groupID) + require.False(t, repo.includeGrouped) + require.Contains(t, repo.platforms, PlatformOpenAI) + require.Contains(t, repo.platforms, PlatformGrok) + require.Contains(t, repo.platforms, PlatformDeepseek) + require.NotContains(t, repo.platforms, PlatformComposite) } func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) { @@ -1003,8 +1034,9 @@ func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWil require.NotContains(t, string(manifest.Body), "gpt-*") } -// Scenario: OpenAI 配置目录对暂时不可调度账号取能力交集,且不发布其独有模型。 -func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAccounts(t *testing.T) { +// Scenario: OpenAI 配置目录对仅因瞬态状态退出当前调度池的账号取能力交集, +// 且不发布其独有模型。持久 schedulable 仍为 true。 +func TestBuildGroupConfiguredCodexModelsManifestIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) { t.Parallel() const groupID int64 = 79 @@ -1018,19 +1050,19 @@ func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAcc true, nil, ) - unschedulable := newCodexCatalogMappedAccount( + transientlyUnschedulable := newCodexCatalogMappedAccount( 42, "glm-5.3", "GLM 5.3", []string{"low", "medium", "high"}, []string{"text"}, 272_000, - false, + true, map[string]any{"exclusive-model": "exclusive-upstream"}, ) svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{ schedulable: map[int64][]Account{groupID: {schedulable}}, - catalog: map[int64][]Account{groupID: {schedulable, unschedulable}}, + catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}}, }} manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( @@ -1049,6 +1081,52 @@ func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAcc require.EqualValues(t, 272_000, models[0]["context_window"]) } +// Scenario: 管理员持久禁用的账号不能继续收窄 Codex 能力目录。 +func TestBuildGroupConfiguredCodexModelsManifestIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) { + t.Parallel() + + const groupID int64 = 80 + enabled := newCodexCatalogMappedAccount( + 51, + "gpt-5.6-sol", + "GPT-5.6 Sol", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 1_000_000, + true, + nil, + ) + disabled := newCodexCatalogMappedAccount( + 52, + "glm-5.3", + "GLM 5.3", + []string{"low", "medium", "high"}, + []string{"text"}, + 272_000, + false, + nil, + ) + svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{ + schedulable: map[int64][]Account{groupID: {enabled}}, + catalog: map[int64][]Account{groupID: {enabled}}, + all: map[int64][]Account{groupID: {enabled, disabled}}, + }} + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + ) + require.NoError(t, err) + require.True(t, configured) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "my-coder", models[0]["slug"]) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) +} + // Scenario: 没有管理员模型配置时保留现有上游发现路径。 func TestBuildGroupConfiguredCodexModelsManifestFallsThroughWithoutConfiguration(t *testing.T) { t.Parallel()