mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user