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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
|
||||||
|
if normalizedClaudeModel != model {
|
||||||
|
for _, pattern := range availablePatterns {
|
||||||
|
if pattern == normalizedClaudeModel {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1434,14 +1442,7 @@ func defaultModelIDsForPlatform(platform string) []string {
|
|||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
case service.PlatformAnthropic:
|
case service.PlatformAnthropic:
|
||||||
ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels()))
|
return claude.DefaultModelIDs()
|
||||||
for _, model := range claude.DefaultModels {
|
|
||||||
ids = append(ids, model.ID)
|
|
||||||
}
|
|
||||||
for _, model := range antigravity.DefaultModels() {
|
|
||||||
ids = append(ids, model.ID)
|
|
||||||
}
|
|
||||||
return mergeModelIDs(ids, nil)
|
|
||||||
case service.PlatformGrok:
|
case service.PlatformGrok:
|
||||||
return xai.DefaultModelIDs()
|
return xai.DefaultModelIDs()
|
||||||
case service.PlatformComposite:
|
case service.PlatformComposite:
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
|
|||||||
require.Contains(t, compositeIDs, antigravityIDs[0])
|
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.
|
// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
|
||||||
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -433,6 +443,62 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
|||||||
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
|
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) {
|
func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,61 @@ func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T
|
|||||||
require.Equal(t, PlatformAnthropic, acc.Platform, "应只返回 anthropic 平台账户")
|
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 单平台选择
|
// TestGatewayService_SelectAccountForModelWithPlatform_Antigravity 测试 antigravity 单平台选择
|
||||||
func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) {
|
func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -2532,6 +2532,11 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string {
|
|||||||
// isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context)
|
// isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context)
|
||||||
// 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持
|
// 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持
|
||||||
func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Context, account *Account, requestedModel string) bool {
|
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 account.Platform == PlatformAntigravity {
|
||||||
if strings.TrimSpace(requestedModel) == "" {
|
if strings.TrimSpace(requestedModel) == "" {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -561,7 +561,7 @@ func claudeCodexDisplayName(modelID string) string {
|
|||||||
// routed through a custom provider. The response is also suitable for saving
|
// 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.
|
// as model_catalog_json in clients that do not refresh custom-provider catalogs.
|
||||||
func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) {
|
func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) {
|
||||||
return buildCodexModelsManifest(modelIDs, nil)
|
return buildCodexModelsManifest(modelIDs, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCodexModelsManifestForGroup derives input capabilities from the
|
// BuildCodexModelsManifestForGroup derives input capabilities from the
|
||||||
@@ -598,6 +598,13 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageInputModels := make(map[string]bool, len(modelIDs))
|
imageInputModels := make(map[string]bool, len(modelIDs))
|
||||||
|
metadataModels := codexCatalogMetadataModels(
|
||||||
|
effectivePlatform,
|
||||||
|
modelIDs,
|
||||||
|
accounts,
|
||||||
|
compositeRoutes,
|
||||||
|
compositeRoutesAvailable,
|
||||||
|
)
|
||||||
for _, modelID := range modelIDs {
|
for _, modelID := range modelIDs {
|
||||||
if groupCodexModelSupportsImageInput(
|
if groupCodexModelSupportsImageInput(
|
||||||
effectivePlatform,
|
effectivePlatform,
|
||||||
@@ -609,10 +616,10 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
|
|||||||
imageInputModels[strings.TrimSpace(modelID)] = true
|
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))
|
seen := make(map[string]struct{}, len(modelIDs))
|
||||||
models := make([]configuredCodexModelDescriptor, 0, len(modelIDs))
|
models := make([]configuredCodexModelDescriptor, 0, len(modelIDs))
|
||||||
for _, modelID := range modelIDs {
|
for _, modelID := range modelIDs {
|
||||||
@@ -623,11 +630,16 @@ func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]boo
|
|||||||
if _, exists := seen[modelID]; exists {
|
if _, exists := seen[modelID]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if isCodexDedicatedMediaModel(modelID) {
|
metadataModelID := strings.TrimSpace(metadataModels[modelID])
|
||||||
|
if metadataModelID == "" {
|
||||||
|
metadataModelID = modelID
|
||||||
|
}
|
||||||
|
if isCodexDedicatedMediaModel(modelID) || isCodexDedicatedMediaModel(metadataModelID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[modelID] = struct{}{}
|
seen[modelID] = struct{}{}
|
||||||
descriptor := newConfiguredCodexModelDescriptor(modelID)
|
descriptor := newConfiguredCodexModelDescriptor(metadataModelID)
|
||||||
|
descriptor.Slug = modelID
|
||||||
if imageInputModels[modelID] {
|
if imageInputModels[modelID] {
|
||||||
descriptor.InputModalities = []string{"text", "image"}
|
descriptor.InputModalities = []string{"text", "image"}
|
||||||
}
|
}
|
||||||
@@ -638,6 +650,105 @@ func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]boo
|
|||||||
}{Models: models})
|
}{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(
|
func groupCodexModelSupportsImageInput(
|
||||||
platform string,
|
platform string,
|
||||||
modelID string,
|
modelID string,
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInp
|
|||||||
models := decodeCodexManifestModels(t, body)
|
models := decodeCodexManifestModels(t, body)
|
||||||
require.Len(t, models, 1)
|
require.Len(t, models, 1)
|
||||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
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) {
|
func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) {
|
||||||
@@ -551,6 +553,106 @@ func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedMod
|
|||||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
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) {
|
func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -627,6 +627,29 @@ function downloadCodexModelManifest() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codexCatalogModelSlugs = computed(() => {
|
||||||
|
if (!codexModelManifestContent.value) return []
|
||||||
|
try {
|
||||||
|
const payload: unknown = JSON.parse(codexModelManifestContent.value)
|
||||||
|
if (typeof payload !== 'object' || payload === null || !('models' in payload)) return []
|
||||||
|
const models = (payload as { models?: unknown }).models
|
||||||
|
if (!Array.isArray(models)) return []
|
||||||
|
return models.flatMap((model) => {
|
||||||
|
if (typeof model !== 'object' || model === null || !('slug' in model)) return []
|
||||||
|
const slug = (model as { slug?: unknown }).slug
|
||||||
|
if (typeof slug !== 'string' || !slug.trim()) return []
|
||||||
|
return [slug.trim()]
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectCodexCatalogModel(preferredModel: string): string {
|
||||||
|
if (codexCatalogModelSlugs.value.includes(preferredModel)) return preferredModel
|
||||||
|
return codexCatalogModelSlugs.value[0] || preferredModel
|
||||||
|
}
|
||||||
|
|
||||||
const escapeHtml = (value: string) => value
|
const escapeHtml = (value: string) => value
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
@@ -884,10 +907,12 @@ function generateOpenAIFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
|||||||
const isWindows = activeTab.value === 'windows'
|
const isWindows = activeTab.value === 'windows'
|
||||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||||
|
|
||||||
|
const model = selectCodexCatalogModel('gpt-5.5')
|
||||||
|
|
||||||
// config.toml content
|
// config.toml content
|
||||||
const configContent = `model_provider = "OpenAI"
|
const configContent = `model_provider = "OpenAI"
|
||||||
model = "gpt-5.5"
|
model = "${model}"
|
||||||
review_model = "gpt-5.5"
|
review_model = "${model}"
|
||||||
model_reasoning_effort = "xhigh"
|
model_reasoning_effort = "xhigh"
|
||||||
disable_response_storage = true
|
disable_response_storage = true
|
||||||
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
|
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
|
||||||
@@ -1087,6 +1112,7 @@ function generateGrokCodexFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
|||||||
const shell = activeTab.value
|
const shell = activeTab.value
|
||||||
const isWindowsPath = shell === 'windows' || shell === 'cmd' || shell === 'powershell'
|
const isWindowsPath = shell === 'windows' || shell === 'cmd' || shell === 'powershell'
|
||||||
const configDir = isWindowsPath ? '%userprofile%\\.codex' : '~/.codex'
|
const configDir = isWindowsPath ? '%userprofile%\\.codex' : '~/.codex'
|
||||||
|
const model = selectCodexCatalogModel('grok-4.5')
|
||||||
|
|
||||||
let envPath: string
|
let envPath: string
|
||||||
let envContent: string
|
let envContent: string
|
||||||
@@ -1112,10 +1138,10 @@ function generateGrokCodexFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
|||||||
# Switch model: grok-4.5 | grok-4.3 | grok-build-0.1 | grok-4.20-multi-agent-0309 (text / web_search)
|
# Switch model: grok-4.5 | grok-4.3 | grok-build-0.1 | grok-4.20-multi-agent-0309 (text / web_search)
|
||||||
|
|
||||||
model_provider = "sub2api"
|
model_provider = "sub2api"
|
||||||
model = "grok-4.5"
|
model = "${model}"
|
||||||
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
|
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
|
||||||
# Optional:
|
# Optional:
|
||||||
# review_model = "grok-4.5"
|
# review_model = "${model}"
|
||||||
# model_reasoning_effort = "medium"
|
# model_reasoning_effort = "medium"
|
||||||
# model_context_window = 500000
|
# model_context_window = 500000
|
||||||
# disable_response_storage = true
|
# disable_response_storage = true
|
||||||
@@ -1156,7 +1182,8 @@ function generateRoutedCodexFiles(
|
|||||||
): FileConfig[] {
|
): FileConfig[] {
|
||||||
const isWindows = activeTab.value === 'windows'
|
const isWindows = activeTab.value === 'windows'
|
||||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||||
const model = platform === 'deepseek' ? 'deepseek-v4-pro' : 'gpt-5.5'
|
const preferredModel = platform === 'deepseek' ? 'deepseek-v4-pro' : 'gpt-5.5'
|
||||||
|
const model = selectCodexCatalogModel(preferredModel)
|
||||||
const label = platform === 'deepseek' ? 'DeepSeek' : 'Composite'
|
const label = platform === 'deepseek' ? 'DeepSeek' : 'Composite'
|
||||||
const envContent = isWindows
|
const envContent = isWindows
|
||||||
? `$env:SUB2API_API_KEY="${apiKey}"`
|
? `$env:SUB2API_API_KEY="${apiKey}"`
|
||||||
@@ -1190,11 +1217,12 @@ supports_websockets = false`
|
|||||||
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||||
const isWindows = activeTab.value === 'windows'
|
const isWindows = activeTab.value === 'windows'
|
||||||
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
|
||||||
|
const model = selectCodexCatalogModel('gpt-5.5')
|
||||||
|
|
||||||
// config.toml content with WebSocket v2
|
// config.toml content with WebSocket v2
|
||||||
const configContent = `model_provider = "OpenAI"
|
const configContent = `model_provider = "OpenAI"
|
||||||
model = "gpt-5.5"
|
model = "${model}"
|
||||||
review_model = "gpt-5.5"
|
review_model = "${model}"
|
||||||
model_reasoning_effort = "xhigh"
|
model_reasoning_effort = "xhigh"
|
||||||
disable_response_storage = true
|
disable_response_storage = true
|
||||||
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
|
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
|
||||||
|
|||||||
@@ -618,10 +618,10 @@ describe('UseKeyModal', () => {
|
|||||||
const manifest = {
|
const manifest = {
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
slug: 'gpt-5.5',
|
slug: 'claude-opus-4-8',
|
||||||
default_reasoning_level: 'medium',
|
default_reasoning_level: 'medium',
|
||||||
supported_reasoning_levels: [{ effort: 'xhigh', description: 'Extra-high reasoning depth' }],
|
supported_reasoning_levels: [{ effort: 'max', description: 'Maximum reasoning depth' }],
|
||||||
input_modalities: ['text', 'image'],
|
input_modalities: ['text'],
|
||||||
model_messages: { instructions_template: 'Use the routed model.' }
|
model_messages: { instructions_template: 'Use the routed model.' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -684,6 +684,13 @@ describe('UseKeyModal', () => {
|
|||||||
expect(wrapper.get('[data-testid="codex-model-catalog"]').text())
|
expect(wrapper.get('[data-testid="codex-model-catalog"]').text())
|
||||||
.toContain('keys.useKeyModal.codexModelCatalog.download')
|
.toContain('keys.useKeyModal.codexModelCatalog.download')
|
||||||
|
|
||||||
|
const loadedUnixConfig = wrapper.findAll('pre code')
|
||||||
|
.map((code) => code.text())
|
||||||
|
.find((content) => content.includes('[model_providers.sub2api]'))
|
||||||
|
expect(loadedUnixConfig).toContain('model = "claude-opus-4-8"')
|
||||||
|
expect(loadedUnixConfig).toContain('review_model = "claude-opus-4-8"')
|
||||||
|
expect(loadedUnixConfig).not.toContain('model = "gpt-5.5"')
|
||||||
|
|
||||||
const downloadButton = wrapper.findAll('button').find((button) =>
|
const downloadButton = wrapper.findAll('button').find((button) =>
|
||||||
button.text().includes('keys.useKeyModal.codexModelCatalog.download')
|
button.text().includes('keys.useKeyModal.codexModelCatalog.download')
|
||||||
)
|
)
|
||||||
@@ -705,4 +712,51 @@ describe('UseKeyModal', () => {
|
|||||||
'model_catalog_json = "%userprofile%\\\\.codex\\\\codex-models.json"'
|
'model_catalog_json = "%userprofile%\\\\.codex\\\\codex-models.json"'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Scenario: the platform-preferred model remains selected when the downloaded catalog contains it.
|
||||||
|
it('keeps the preferred Composite default when it exists in the catalog', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
models: [
|
||||||
|
{ slug: 'claude-opus-4-8' },
|
||||||
|
{ slug: 'gpt-5.5' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
const wrapper = mount(UseKeyModal, {
|
||||||
|
props: {
|
||||||
|
show: true,
|
||||||
|
apiKey: 'sk-composite-test',
|
||||||
|
baseUrl: 'https://example.com/v1',
|
||||||
|
platform: 'composite'
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
BaseDialog: {
|
||||||
|
template: '<div><slot /><slot name="footer" /></div>'
|
||||||
|
},
|
||||||
|
Icon: {
|
||||||
|
template: '<span />'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const codexTab = wrapper.findAll('button').find((button) =>
|
||||||
|
button.text().includes('keys.useKeyModal.cliTabs.codexCli')
|
||||||
|
)
|
||||||
|
expect(codexTab).toBeDefined()
|
||||||
|
await codexTab!.trigger('click')
|
||||||
|
await wrapper.get('[data-testid="codex-model-catalog-fetch"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const config = wrapper.findAll('pre code')
|
||||||
|
.map((code) => code.text())
|
||||||
|
.find((content) => content.includes('[model_providers.sub2api]'))
|
||||||
|
expect(config).toContain('model = "gpt-5.5"')
|
||||||
|
expect(config).toContain('review_model = "gpt-5.5"')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user