fix(codex): ignore persistently disabled catalog accounts

This commit is contained in:
Jarl
2026-08-31 20:26:29 +08:00
parent 52374af940
commit ba345f1052
5 changed files with 146 additions and 30 deletions
@@ -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(
@@ -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
@@ -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}
@@ -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
}
@@ -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()