fix(codex): keep catalog capabilities stable when accounts are unschedulable

Intersect advertised alias capabilities across all active group members
that map the model, not only currently schedulable accounts, so rate-limits
cannot widen image input or context windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Long Li
2026-08-25 21:01:58 +09:00
co-authored by Cursor
parent fc589bce17
commit db01fb98f8
4 changed files with 173 additions and 11 deletions
@@ -68,6 +68,10 @@ func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Cont
return out, nil
}
func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
return s.ListSchedulableByGroupID(ctx, groupID)
}
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
return &GatewayHandler{
gatewayService: service.NewGatewayService(
@@ -48,6 +48,10 @@ func (r codexModelsFailoverAccountRepo) ListSchedulableByGroupID(_ context.Conte
return append([]service.Account(nil), r.accounts...), nil
}
func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64) ([]service.Account, error) {
return append([]service.Account(nil), r.accounts...), nil
}
type codexModelsFailoverHTTPUpstream struct {
service.HTTPUpstream
mu sync.Mutex
@@ -109,11 +109,11 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest(
return nil, false, nil
}
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, group.ID)
visible, catalog, err := loadCodexGroupCatalogAccounts(ctx, s.accountRepo, group.ID)
if err != nil {
return nil, false, fmt.Errorf("load group configured Codex models: %w", err)
}
configuredModels := openAIConfiguredCodexModelIDs(accounts)
configuredModels := openAIConfiguredCodexModelIDs(visible)
if len(configuredModels) == 0 {
return nil, false, nil
}
@@ -121,7 +121,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest(
body, err := buildCodexModelsManifestForAccounts(
PlatformOpenAI,
configuredModels,
accounts,
catalog,
nil,
true,
)
@@ -197,6 +197,29 @@ func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context,
return openAIConfiguredCodexModelIDs(accounts), nil
}
// 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.
func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, groupID int64) (visible []Account, catalog []Account, err error) {
if repo == nil {
return nil, nil, nil
}
visible, err = repo.ListSchedulableByGroupID(ctx, groupID)
if err != nil {
return nil, nil, err
}
catalog = visible
groupAccounts, listErr := repo.ListByGroup(ctx, groupID)
if listErr != nil {
return visible, catalog, nil
}
return visible, groupAccounts, nil
}
func openAIConfiguredCodexModelIDs(accounts []Account) []string {
seen := make(map[string]struct{})
models := make([]string, 0)
@@ -631,9 +654,11 @@ func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) {
}
// BuildCodexModelsManifestForGroup derives input capabilities from the
// concrete Responses route and schedulable accounts behind a group. Unknown or
// mixed capabilities fail closed to the text-only descriptor used by the
// standalone builder.
// concrete Responses route and group accounts behind a group. Unknown or mixed
// capabilities fail closed to the text-only descriptor used by the standalone
// builder. Caller-supplied model IDs still decide which slugs appear; advertised
// capabilities intersect all active group members that map the alias, including
// accounts that are not currently schedulable.
func (s *GatewayService) BuildCodexModelsManifestForGroup(
ctx context.Context,
group *Group,
@@ -651,7 +676,7 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
return BuildCodexModelsManifest(modelIDs)
}
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, group.ID)
_, catalog, err := loadCodexGroupCatalogAccounts(ctx, s.accountRepo, group.ID)
if err != nil {
return BuildCodexModelsManifest(modelIDs)
}
@@ -666,7 +691,7 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
return buildCodexModelsManifestForAccounts(
effectivePlatform,
modelIDs,
accounts,
catalog,
compositeRoutes,
compositeRoutesAvailable,
)
@@ -38,11 +38,17 @@ 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]
return append([]Account(nil), accounts...), nil
}
type countingCodexModelsAccountRepo struct {
AccountRepository
accounts []Account
err error
calls atomic.Int32
accounts []Account
err error
listByGroupErr error
calls atomic.Int32
}
func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) {
@@ -53,6 +59,83 @@ 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
}
return append([]Account(nil), r.accounts...), nil
}
type splitCodexModelsAccountRepo struct {
AccountRepository
schedulable map[int64][]Account
catalog map[int64][]Account
}
func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
return append([]Account(nil), r.schedulable[groupID]...), nil
}
func (r splitCodexModelsAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) {
return append([]Account(nil), r.catalog[groupID]...), nil
}
func newCodexCatalogMappedAccount(
id int64,
target string,
displayName string,
levels []string,
modalities []string,
contextWindow int64,
schedulable bool,
extraMapping map[string]any,
) Account {
reasoning := true
mapping := map[string]any{"my-coder": target}
for key, value := range extraMapping {
mapping[key] = value
}
account := Account{
ID: id,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Status: StatusActive,
Schedulable: schedulable,
Credentials: map[string]any{
"base_url": fmt.Sprintf("https://provider-%d.example/v1", id),
"model_mapping": mapping,
},
}
models := map[string]UpstreamModelMetadata{
target: {
ID: target,
DisplayName: displayName,
Description: displayName + " upstream",
Reasoning: &reasoning,
SupportedReasoningLevels: levels,
InputModalities: modalities,
ContextWindow: contextWindow,
},
}
for _, value := range extraMapping {
exclusive, _ := value.(string)
if exclusive == "" || exclusive == target {
continue
}
models[exclusive] = UpstreamModelMetadata{
ID: exclusive,
DisplayName: "Exclusive Model",
Description: "Only mapped on the unschedulable account",
Reasoning: &reasoning,
SupportedReasoningLevels: []string{"high"},
InputModalities: []string{"text", "image"},
ContextWindow: 1_000_000,
}
}
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: models})
return account
}
func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any {
t.Helper()
@@ -877,6 +960,52 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t
require.Equal(t, manifest.ETag, notModified.ETag)
}
// Scenario: OpenAI 配置目录对暂时不可调度账号取能力交集,且不发布其独有模型。
func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAccounts(t *testing.T) {
t.Parallel()
const groupID int64 = 79
schedulable := newCodexCatalogMappedAccount(
41,
"gpt-5.6-sol",
"GPT-5.6 Sol",
[]string{"low", "medium", "high", "xhigh"},
[]string{"text", "image"},
1_000_000,
true,
nil,
)
unschedulable := newCodexCatalogMappedAccount(
42,
"glm-5.3",
"GLM 5.3",
[]string{"low", "medium", "high"},
[]string{"text"},
272_000,
false,
map[string]any{"exclusive-model": "exclusive-upstream"},
)
svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
schedulable: map[int64][]Account{groupID: {schedulable}},
catalog: map[int64][]Account{groupID: {schedulable, unschedulable}},
}}
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, "my-coder", models[0]["display_name"])
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
require.EqualValues(t, 272_000, models[0]["context_window"])
}
// Scenario: 没有管理员模型配置时保留现有上游发现路径。
func TestBuildGroupConfiguredCodexModelsManifestFallsThroughWithoutConfiguration(t *testing.T) {
t.Parallel()