mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-12 14:41:42 +00:00
fix(codex): align routed catalogs with actual routes
Anthropic fallbacks previously reused Antigravity defaults, leaking Gemini models into Claude-only groups. Composite aliases were also emitted with generic metadata and could be scheduled to accounts that did not own the exact mapping, while generated configs could default to a model absent from the downloaded catalog. Keep provider defaults separated, derive alias capabilities from unique upstream targets with conservative fallback, require exact mapping ownership during scheduling, filter media targets, and choose generated config defaults from the fetched catalog.
This commit is contained in:
8 files changed
+445
-23
No files matched your search
@@ -1413,6 +1413,14 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
|
||||
if normalizedClaudeModel != model {
|
||||
for _, pattern := range availablePatterns {
|
||||
if pattern == normalizedClaudeModel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1434,14 +1442,7 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
}
|
||||
return ids
|
||||
case service.PlatformAnthropic:
|
||||
ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels()))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
for _, model := range antigravity.DefaultModels() {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return mergeModelIDs(ids, nil)
|
||||
return claude.DefaultModelIDs()
|
||||
case service.PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
case service.PlatformComposite:
|
||||
|
||||
@@ -86,6 +86,16 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
|
||||
require.Contains(t, compositeIDs, antigravityIDs[0])
|
||||
}
|
||||
|
||||
// Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models.
|
||||
func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) {
|
||||
anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic)
|
||||
require.Contains(t, anthropicIDs, "claude-opus-4-6")
|
||||
require.NotContains(t, anthropicIDs, "gemini-2.5-flash")
|
||||
|
||||
antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
|
||||
require.Contains(t, antigravityIDs, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
|
||||
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -433,6 +443,62 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
||||
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
// Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults.
|
||||
func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(64)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAnthropic}},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := codexModelSlugsForTest(got.Models)
|
||||
require.Contains(t, slugs, "claude-opus-4-6")
|
||||
require.NotContains(t, slugs, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups.
|
||||
func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(65)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAntigravity}},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
ids := modelIDsForTest(got.Data)
|
||||
require.Contains(t, ids, "claude-opus-4-6")
|
||||
require.Contains(t, ids, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -395,6 +395,61 @@ func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T
|
||||
require.Equal(t, PlatformAnthropic, acc.Platform, "应只返回 anthropic 平台账户")
|
||||
}
|
||||
|
||||
// Scenario: account-owned Composite aliases are scheduled only to accounts that declare the exact mapping.
|
||||
func TestGatewayService_SelectAccountForModelWithExclusions_CompositeAliasRequiresOwningAccount(t *testing.T) {
|
||||
groupID := int64(77)
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: []Account{
|
||||
{
|
||||
ID: 1,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Priority: 1,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
AccountGroups: []AccountGroup{{GroupID: groupID}},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Priority: 2,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"},
|
||||
},
|
||||
AccountGroups: []AccountGroup{{GroupID: groupID}},
|
||||
},
|
||||
},
|
||||
accountsByID: map[int64]*Account{},
|
||||
}
|
||||
for i := range repo.accounts {
|
||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||
}
|
||||
|
||||
group := &Group{ID: groupID, Platform: PlatformComposite, Status: StatusActive, Hydrated: true}
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
groupRepo: &mockGroupRepoForGateway{groups: map[int64]*Group{groupID: group}},
|
||||
cfg: testConfig(),
|
||||
}
|
||||
ctx := WithCompositeRouteDecision(context.Background(), CompositeRouteDecision{
|
||||
Matched: true,
|
||||
Source: CompositeRouteSourceAccount,
|
||||
GroupID: groupID,
|
||||
PublicModel: "reasoning-alias",
|
||||
TargetPlatform: PlatformAnthropic,
|
||||
UpstreamModel: "reasoning-alias",
|
||||
Endpoint: CompositeRouteEndpointResponses,
|
||||
})
|
||||
|
||||
account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "reasoning-alias", nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, account)
|
||||
require.Equal(t, int64(2), account.ID)
|
||||
}
|
||||
|
||||
// TestGatewayService_SelectAccountForModelWithPlatform_Antigravity 测试 antigravity 单平台选择
|
||||
func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -2532,6 +2532,11 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string {
|
||||
// isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context)
|
||||
// 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持
|
||||
func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Context, account *Account, requestedModel string) bool {
|
||||
if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount {
|
||||
if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if account.Platform == PlatformAntigravity {
|
||||
if strings.TrimSpace(requestedModel) == "" {
|
||||
return true
|
||||
|
||||
@@ -561,7 +561,7 @@ func claudeCodexDisplayName(modelID string) string {
|
||||
// routed through a custom provider. The response is also suitable for saving
|
||||
// as model_catalog_json in clients that do not refresh custom-provider catalogs.
|
||||
func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) {
|
||||
return buildCodexModelsManifest(modelIDs, nil)
|
||||
return buildCodexModelsManifest(modelIDs, nil, nil)
|
||||
}
|
||||
|
||||
// BuildCodexModelsManifestForGroup derives input capabilities from the
|
||||
@@ -598,6 +598,13 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
|
||||
}
|
||||
}
|
||||
imageInputModels := make(map[string]bool, len(modelIDs))
|
||||
metadataModels := codexCatalogMetadataModels(
|
||||
effectivePlatform,
|
||||
modelIDs,
|
||||
accounts,
|
||||
compositeRoutes,
|
||||
compositeRoutesAvailable,
|
||||
)
|
||||
for _, modelID := range modelIDs {
|
||||
if groupCodexModelSupportsImageInput(
|
||||
effectivePlatform,
|
||||
@@ -609,10 +616,10 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
|
||||
imageInputModels[strings.TrimSpace(modelID)] = true
|
||||
}
|
||||
}
|
||||
return buildCodexModelsManifest(modelIDs, imageInputModels)
|
||||
return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels)
|
||||
}
|
||||
|
||||
func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]bool) ([]byte, error) {
|
||||
func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]bool, metadataModels map[string]string) ([]byte, error) {
|
||||
seen := make(map[string]struct{}, len(modelIDs))
|
||||
models := make([]configuredCodexModelDescriptor, 0, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
@@ -623,11 +630,16 @@ func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]boo
|
||||
if _, exists := seen[modelID]; exists {
|
||||
continue
|
||||
}
|
||||
if isCodexDedicatedMediaModel(modelID) {
|
||||
metadataModelID := strings.TrimSpace(metadataModels[modelID])
|
||||
if metadataModelID == "" {
|
||||
metadataModelID = modelID
|
||||
}
|
||||
if isCodexDedicatedMediaModel(modelID) || isCodexDedicatedMediaModel(metadataModelID) {
|
||||
continue
|
||||
}
|
||||
seen[modelID] = struct{}{}
|
||||
descriptor := newConfiguredCodexModelDescriptor(modelID)
|
||||
descriptor := newConfiguredCodexModelDescriptor(metadataModelID)
|
||||
descriptor.Slug = modelID
|
||||
if imageInputModels[modelID] {
|
||||
descriptor.InputModalities = []string{"text", "image"}
|
||||
}
|
||||
@@ -638,6 +650,105 @@ func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]boo
|
||||
}{Models: models})
|
||||
}
|
||||
|
||||
func codexCatalogMetadataModels(
|
||||
platform string,
|
||||
modelIDs []string,
|
||||
accounts []Account,
|
||||
compositeRoutes []CompositeModelRoute,
|
||||
compositeRoutesAvailable bool,
|
||||
) map[string]string {
|
||||
metadataModels := make(map[string]string, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
continue
|
||||
}
|
||||
metadataModelID := resolveCodexCatalogMetadataModel(
|
||||
platform,
|
||||
modelID,
|
||||
accounts,
|
||||
compositeRoutes,
|
||||
compositeRoutesAvailable,
|
||||
)
|
||||
if metadataModelID != "" && metadataModelID != modelID {
|
||||
metadataModels[modelID] = metadataModelID
|
||||
}
|
||||
}
|
||||
return metadataModels
|
||||
}
|
||||
|
||||
func resolveCodexCatalogMetadataModel(
|
||||
platform string,
|
||||
modelID string,
|
||||
accounts []Account,
|
||||
compositeRoutes []CompositeModelRoute,
|
||||
compositeRoutesAvailable bool,
|
||||
) string {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
return ""
|
||||
}
|
||||
if platform == PlatformComposite {
|
||||
if !compositeRoutesAvailable {
|
||||
return modelID
|
||||
}
|
||||
if route, matched := matchCompositeRoute(compositeRoutes, modelID, CompositeRouteEndpointResponses); matched {
|
||||
if upstreamModel := strings.TrimSpace(route.UpstreamModel); upstreamModel != "" {
|
||||
return upstreamModel
|
||||
}
|
||||
return modelID
|
||||
}
|
||||
if codexCompositeRouteMatchesModel(compositeRoutes, modelID) {
|
||||
return modelID
|
||||
}
|
||||
|
||||
claimedPlatforms := make(map[string]struct{})
|
||||
for _, account := range accounts {
|
||||
accountPlatform := strings.TrimSpace(account.Platform)
|
||||
if !isConcreteRequestPlatform(accountPlatform) || !codexExplicitModelMappingClaims(account, modelID) {
|
||||
continue
|
||||
}
|
||||
claimedPlatforms[accountPlatform] = struct{}{}
|
||||
}
|
||||
if len(claimedPlatforms) > 1 {
|
||||
return modelID
|
||||
}
|
||||
for accountPlatform := range claimedPlatforms {
|
||||
return uniqueCodexMappedModel(accounts, accountPlatform, modelID)
|
||||
}
|
||||
|
||||
detectedPlatform, detected := DetectModelPlatform(modelID)
|
||||
if !detected {
|
||||
return modelID
|
||||
}
|
||||
platform = detectedPlatform
|
||||
}
|
||||
return uniqueCodexMappedModel(accounts, platform, modelID)
|
||||
}
|
||||
|
||||
func uniqueCodexMappedModel(accounts []Account, platform string, modelID string) string {
|
||||
targets := make(map[string]struct{})
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != platform {
|
||||
continue
|
||||
}
|
||||
mappedModel, matched := account.ResolveMappedModel(modelID)
|
||||
mappedModel = strings.TrimSpace(mappedModel)
|
||||
if !matched || mappedModel == "" {
|
||||
continue
|
||||
}
|
||||
targets[mappedModel] = struct{}{}
|
||||
}
|
||||
if len(targets) != 1 {
|
||||
return modelID
|
||||
}
|
||||
for target := range targets {
|
||||
return target
|
||||
}
|
||||
return modelID
|
||||
}
|
||||
|
||||
func groupCodexModelSupportsImageInput(
|
||||
platform string,
|
||||
modelID string,
|
||||
|
||||
@@ -324,6 +324,8 @@ func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInp
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
require.Equal(t, "Grok 4.5", models[0]["display_name"])
|
||||
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) {
|
||||
@@ -551,6 +553,106 @@ func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedMod
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
// Scenario: a Composite exact alias inherits metadata from its unique mapped target model.
|
||||
func TestBuildCodexModelsManifestForGroupUsesMappedTargetMetadataForCompositeAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 733
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {{
|
||||
ID: 23,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"},
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"reasoning-alias"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "reasoning-alias", models[0]["slug"])
|
||||
require.Equal(t, "Claude Opus 4.8", models[0]["display_name"])
|
||||
require.Equal(t, "Claude coding and reasoning model routed through Sub2API.", models[0]["description"])
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromManifestModel(t, models[0]))
|
||||
}
|
||||
|
||||
// Scenario: conflicting targets on the same platform keep the public alias but do not guess capabilities.
|
||||
func TestBuildCodexModelsManifestForGroupUsesSafeFallbackForConflictingAliasTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 734
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 24,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"shared-alias": "claude-opus-4-8"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 25,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"shared-alias": "claude-haiku-4-5-20251001"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"shared-alias"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "shared-alias", models[0]["slug"])
|
||||
require.Equal(t, "shared-alias", models[0]["display_name"])
|
||||
require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
|
||||
require.Empty(t, effortsFromManifestModel(t, models[0]))
|
||||
}
|
||||
|
||||
// Scenario: a media-only target remains hidden even when exposed through an ordinary alias.
|
||||
func TestBuildCodexModelsManifestForGroupOmitsDedicatedMediaTargetAlias(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 735
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {{
|
||||
ID: 26,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"creative-alias": "gpt-image-2"},
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"creative-alias"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, decodeCodexManifestModels(t, body))
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in new issue
Block a user