diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 93067da278..599a04a34e 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2774,13 +2774,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) { return } - models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account) + catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account) if err != nil { var syncErr *service.UpstreamModelSyncError if errors.As(err, &syncErr) { switch syncErr.Kind { case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported: response.BadRequest(c, syncErr.SafeMessage()) + case service.UpstreamModelSyncErrorInternal: + response.InternalError(c, syncErr.SafeMessage()) default: slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind) response.Error(c, http.StatusBadGateway, syncErr.SafeMessage()) @@ -2793,29 +2795,35 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) { return } - response.Success(c, gin.H{"models": models}) + response.Success(c, catalog) } // SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed). // POST /api/v1/admin/accounts/models/sync-upstream-preview func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { var req struct { - Platform string `json:"platform" binding:"required"` - Type string `json:"type" binding:"required"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key" binding:"required"` + Platform string `json:"platform" binding:"required"` + Type string `json:"type" binding:"required"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key" binding:"required"` + ModelMapping map[string]string `json:"model_mapping"` } if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } + modelMapping := make(map[string]any, len(req.ModelMapping)) + for sourceModel, upstreamModel := range req.ModelMapping { + modelMapping[sourceModel] = upstreamModel + } tempAccount := &service.Account{ Platform: req.Platform, Type: req.Type, Credentials: map[string]any{ - "api_key": req.APIKey, - "base_url": req.BaseURL, + "api_key": req.APIKey, + "base_url": req.BaseURL, + "model_mapping": modelMapping, }, } @@ -2824,13 +2832,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { return } - models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount) + catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount) if err != nil { var syncErr *service.UpstreamModelSyncError if errors.As(err, &syncErr) { switch syncErr.Kind { case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported: response.BadRequest(c, syncErr.SafeMessage()) + case service.UpstreamModelSyncErrorInternal: + response.InternalError(c, syncErr.SafeMessage()) default: slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind) response.Error(c, http.StatusBadGateway, syncErr.SafeMessage()) @@ -2843,7 +2853,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { return } - response.Success(c, gin.H{"models": models}) + response.Success(c, catalog) } // SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index ef2f65d4e3..28dc6d1492 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine { } type syncUpstreamHTTPUpstream struct { - resp *http.Response - err error + resp *http.Response + responses []*http.Response + err error } func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { if u.err != nil { return nil, u.err } + if len(u.responses) > 0 { + resp := u.responses[0] + u.responses = u.responses[1:] + return resp, nil + } return u.resp, nil } @@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi ) handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil) router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels) + router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview) return router } @@ -347,6 +354,99 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin require.Contains(t, rec.Body.String(), "No OpenAI API key is available") } +func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) { + svc := &availableModelsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, Status: service.StatusActive, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }, + } + upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"custom-thinking-model", + "reasoning":true, + "default_reasoning_level":"high", + "supported_reasoning_levels":["low","high"], + "input_modalities":["text","image"], + "context_window":256000 + }]}`)), + }} + router := setupSyncUpstreamModelsRouter(svc, upstream) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data service.UpstreamModelCatalog `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models) + metadata := resp.Data.Metadata["custom-thinking-model"] + require.NotNil(t, metadata.Reasoning) + require.True(t, *metadata.Reasoning) + require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) +} + +// Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。 +func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) { + upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "configured-provider": { + "api": "https://provider.example/v1", + "models": { + "glm-5.3": { + "id": "glm-5.3", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","high"]}], + "modalities": {"input":["text"],"output":["text"]}, + "limit": {"context":1000000,"output":131072} + } + } + } + }`)), + }, + }} + router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/admin/accounts/models/sync-upstream-preview", + strings.NewReader(`{ + "platform":"openai", + "type":"apikey", + "base_url":"https://provider.example/v1", + "api_key":"key", + "model_mapping":{"public-glm":"glm-5.3"} + }`), + ) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data service.UpstreamModelCatalog `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, []string{"glm-5.3"}, resp.Data.Models) + require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels) +} + func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) { svc := &availableModelsAdminService{ stubAdminService: newStubAdminService(), @@ -377,3 +477,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502") require.NotContains(t, rec.Body.String(), "SECRET_TOKEN") } + +// Scenario: 能力补全失败显示部分成功。 +func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) { + svc := &availableModelsAdminService{ + stubAdminService: newStubAdminService(), + account: service.Account{ + ID: 46, + Name: "opencode-id-only-model-list", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Credentials: map[string]any{ + "api_key": "opencode-key", + "base_url": "https://opencode.ai/zen/v1", + }, + }, + } + upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)), + }, + { + StatusCode: http.StatusBadGateway, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)), + }, + }} + router := setupSyncUpstreamModelsRouter(svc, upstream) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var resp struct { + Data struct { + Models []string `json:"models"` + Warnings []struct { + Code string `json:"code"` + } `json:"warnings"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models) + require.Len(t, resp.Data.Warnings, 1) + require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code) +} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 1128329be8..d07cd57e13 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1140,6 +1140,79 @@ func (h *GatewayHandler) Models(c *gin.Context) { }) } +// CodexModels returns the effective group model list using the manifest shape +// expected by Codex custom providers. Official OpenAI groups continue to use +// OpenAIGatewayHandler.CodexModels so their live upstream metadata is preserved. +func (h *GatewayHandler) CodexModels(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil || apiKey.Group == nil { + h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required") + return + } + + forcedPlatform := "" + if value, exists := middleware2.GetForcePlatformFromContext(c); exists { + forcedPlatform = strings.TrimSpace(value) + } + modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group, forcedPlatform) + modelIDs = service.FilterCodexModelIDsForGroup(modelIDs, apiKey.Group) + body, err := h.gatewayService.BuildCodexModelsManifestForGroup( + c.Request.Context(), + apiKey.Group, + forcedPlatform, + modelIDs, + ) + if err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + etag := service.CodexModelsManifestETag(body) + c.Header("ETag", etag) + if service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), etag) { + c.Status(http.StatusNotModified) + c.Writer.WriteHeaderNow() + return + } + c.Data(http.StatusOK, "application/json", body) +} + +func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group, platformOverride string) []string { + if h == nil || h.gatewayService == nil || group == nil { + return nil + } + + groupID := &group.ID + platform := strings.TrimSpace(platformOverride) + if platform == "" { + platform = group.Platform + } + if platform == service.PlatformComposite { + availableModels := h.compositeAvailableModels(ctx, groupID) + fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite) + if group.CustomModelsListEnabled() { + return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models) + } + if len(availableModels) > 0 { + return availableModels + } + return fallbackModels + } + + availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) + fallbackModels := defaultCodexModelIDsForPlatform(platform) + if group.CustomModelsListEnabled() { + return filterModelsByCustomList( + customModelsListSource(platform, availableModels, fallbackModels), + fallbackModels, + group.ModelsListConfig.Models, + ) + } + if len(availableModels) > 0 { + return availableModels + } + return fallbackModels +} + func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string { if h == nil || h.gatewayService == nil { return nil @@ -1340,9 +1413,26 @@ 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 } +func defaultCodexModelIDsForPlatform(platform string) []string { + switch platform { + case service.PlatformDeepseek: + return []string{"deepseek-v4-pro", "deepseek-v4-flash"} + default: + return defaultModelIDsForPlatform(platform) + } +} + func defaultModelIDsForPlatform(platform string) []string { switch platform { case service.PlatformOpenAI: @@ -1361,14 +1451,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: diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 313b3216fb..05069bf25a 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -25,6 +25,22 @@ type gatewayModelsResponseForTest struct { Data []gatewayModelItemForTest `json:"data"` } +type codexModelsResponseForTest struct { + Models []struct { + Slug string `json:"slug"` + SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"` + InputModalities []string `json:"input_modalities"` + ModelMessages map[string]json.RawMessage `json:"model_messages"` + TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"` + AvailabilityNUX json.RawMessage `json:"availability_nux"` + Upgrade json.RawMessage `json:"upgrade"` + } `json:"models"` +} + +type codexReasoningLevelForTest struct { + Effort string `json:"effort"` +} + type gatewayModelItemForTest struct { ID string `json:"id"` Object string `json:"object"` @@ -52,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( @@ -70,6 +90,247 @@ 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 { + name string + platform string + model string + efforts []string + modalities []string + }{ + { + name: "Grok", + platform: service.PlatformGrok, + model: "grok-4.6", + efforts: []string{"low", "medium", "high", "xhigh"}, + modalities: []string{"text", "image"}, + }, + { + name: "DeepSeek", + platform: service.PlatformDeepseek, + model: "deepseek-v4-pro", + efforts: []string{"low", "high", "max"}, + modalities: []string{"text"}, + }, + { + name: "provider-qualified Claude", + platform: service.PlatformAnthropic, + model: "anthropic/claude-sonnet-4-6", + efforts: []string{"low", "medium", "high", "max"}, + modalities: []string{"text"}, + }, + } + + for index, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(100 + index) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: tt.platform, + Credentials: map[string]any{ + "model_mapping": map[string]any{tt.model: tt.model}, + }, + }, + }, + }, + }) + + 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: tt.platform}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Len(t, got.Models, 1) + require.Equal(t, tt.model, got.Models[0].Slug) + require.NotEmpty(t, got.Models[0].ModelMessages) + require.NotEmpty(t, got.Models[0].TruncationPolicy) + require.NotNil(t, got.Models[0].AvailabilityNUX) + require.NotNil(t, got.Models[0].Upgrade) + require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels)) + require.Equal(t, tt.modalities, got.Models[0].InputModalities) + }) + } +} + +// Scenario: Composite manifests aggregate only administrator-configured models. +func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 120 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 3, + Platform: service.PlatformOpenAI, + Status: service.StatusActive, + Schedulable: true, + Credentials: map[string]any{}, + }, + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}, + }, + }, + { + ID: 2, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.6": "grok-4.6"}, + }, + }, + }, + }, + }) + + 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)) + require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models)) +} + +func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 122 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: {{ + ID: 1, + Platform: service.PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"}, + }, + }}, + }, + }) + group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek} + + first := httptest.NewRecorder() + firstContext, _ := gin.CreateTestContext(first) + firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + h.CodexModels(firstContext) + + require.Equal(t, http.StatusOK, first.Code) + etag := first.Header().Get("ETag") + require.NotEmpty(t, etag) + require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag) + + second := httptest.NewRecorder() + secondContext, _ := gin.CreateTestContext(second) + secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + secondContext.Request.Header.Set("If-None-Match", "W/"+etag) + secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group}) + h.CodexModels(secondContext) + + require.Equal(t, http.StatusNotModified, second.Code) + require.Empty(t, second.Body.Bytes()) + require.Equal(t, etag, second.Header().Get("ETag")) +} + +// Scenario: group models_list_config limits the generated Codex manifest. +func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 121 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}, + }, + }, + { + ID: 2, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.6": "grok-4.6"}, + }, + }, + }, + }, + }) + + 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, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"grok-4.6"}, + }, + }, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models)) +} + +func codexModelSlugsForTest(models []struct { + Slug string `json:"slug"` + SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"` + InputModalities []string `json:"input_modalities"` + ModelMessages map[string]json.RawMessage `json:"model_messages"` + TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"` + AvailabilityNUX json.RawMessage `json:"availability_nux"` + Upgrade json.RawMessage `json:"upgrade"` +}) []string { + slugs := make([]string, 0, len(models)) + for _, model := range models { + slugs = append(slugs, model.Slug) + } + return slugs +} + +func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string { + efforts := make([]string, 0, len(levels)) + for _, level := range levels { + efforts = append(efforts, level.Effort) + } + return efforts +} + func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) { gin.SetMode(gin.TestMode) @@ -193,6 +454,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) @@ -457,6 +774,89 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) } } +func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) { + require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek)) + require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic)) +} + +func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 130 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformDeepseek, + Status: service.StatusActive, + Schedulable: true, + Credentials: map[string]any{}, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := make([]string, 0, len(got.Models)) + for _, model := range got.Models { + slugs = append(slugs, model.Slug) + } + require.Contains(t, slugs, "deepseek-v4-pro") + require.Contains(t, slugs, "deepseek-v4-flash") + require.NotContains(t, slugs, "claude-sonnet-4-6") + require.NotContains(t, slugs, "claude-opus-4-6") +} + +func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 131 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "foo-*": "deepseek-v4-pro", + "deepseek-v4-pro": "deepseek-v4-pro", + }, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := make([]string, 0, len(got.Models)) + for _, model := range got.Models { + slugs = append(slugs, model.Slug) + } + require.Equal(t, []string{"deepseek-v4-pro"}, slugs) +} + func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 2714dc294f..3a38ce5154 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -15,9 +15,9 @@ import ( // Codex CLI and the Codex desktop app refresh their model picker from // GET {base_url}/models?client_version=... (custom provider mode) or // GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land -// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive -// provider-compatibility normalization and use a short-lived, asynchronously -// revalidated cache to tolerate canceled client requests. +// here. Groups with explicit account model mappings are generated locally; +// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests +// receive provider-compatibility normalization plus short-lived caching. func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { if c.Request.Context().Err() != nil { return @@ -32,6 +32,24 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { return } + ifNoneMatch := c.GetHeader("If-None-Match") + configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest( + c.Request.Context(), + apiKey.Group, + ifNoneMatch, + ) + if err != nil { + if c.Request.Context().Err() != nil { + return + } + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + if configured { + writeCodexModelsManifestResponse(c, configuredManifest) + return + } + maxAccountSwitches := h.maxAccountSwitches if maxAccountSwitches <= 0 { maxAccountSwitches = 3 @@ -56,7 +74,9 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { // 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。 setOpsSelectedAccount(c, account.ID, account.Platform) - manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match")) + // The client ETag represents the final group-specific body, so fetch the + // source manifest before applying local filtering and alias metadata. + manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), "") if err != nil { if c.Request.Context().Err() != nil { return @@ -70,18 +90,31 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err)) return } + if err := h.gatewayService.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest") + return + } + if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } if c.Request.Context().Err() != nil { return } - if manifest.ETag != "" { - c.Header("ETag", manifest.ETag) - } - if manifest.NotModified { - c.Status(http.StatusNotModified) - return - } - c.Data(http.StatusOK, "application/json", manifest.Body) + writeCodexModelsManifestResponse(c, manifest) return } } + +func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) { + if manifest.ETag != "" { + c.Header("ETag", manifest.ETag) + } + if manifest.NotModified { + c.Status(http.StatusNotModified) + c.Writer.WriteHeaderNow() + return + } + c.Data(http.StatusOK, "application/json", manifest.Body) +} diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index f8d1fada6c..9eabd3e5b2 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -16,6 +17,7 @@ import ( middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" ) type codexModelsFailoverAccountRepo struct { @@ -43,6 +45,14 @@ func (r codexModelsFailoverAccountRepo) ListSchedulableByPlatform(_ context.Cont return accounts, nil } +func (r codexModelsFailoverAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]service.Account, error) { + 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 @@ -116,6 +126,236 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) { } } +func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(43) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "custom-openai", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://upstream.example/v1", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{ + firstBody: `{"object":"list","data":[{"id":"codex-auto-review"},{"id":"gpt-5.6"}]}`, + } + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + group := &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"codex-auto-review", "gpt-5.6"}, + }, + } + + first := performCodexModelsRequestForGroup(t, handler, group, "") + if first.Code != http.StatusOK { + t.Fatalf("first status: got %d, want %d; body=%s", first.Code, http.StatusOK, first.Body.String()) + } + if body := first.Body.String(); !strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") { + t.Fatalf("first body did not include the explicitly selected models: %s", body) + } + oldETag := first.Header().Get("ETag") + if oldETag == "" { + t.Fatal("first response did not include an ETag") + } + + group.ModelsListConfig.Enabled = false + second := performCodexModelsRequestForGroup(t, handler, group, oldETag) + if second.Code != http.StatusOK { + t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String()) + } + if body := second.Body.String(); strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") { + t.Fatalf("second body was not the filtered manifest: %s", body) + } + if newETag := second.Header().Get("ETag"); newETag == "" || newETag == oldETag { + t.Fatalf("second ETag: got %q, want a new final-body ETag", newETag) + } + + third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag")) + if third.Code != http.StatusNotModified { + t.Fatalf("third status: got %d, want %d; body=%s", third.Code, http.StatusNotModified, third.Body.String()) + } + if third.Body.Len() != 0 { + t.Fatalf("third body: got %q, want empty", third.Body.String()) + } +} + +func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "shared-api-key", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-shared", + "base_url": "https://upstream.example/v1", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{ + firstBody: `{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`, + } + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + groupA := &service.Group{ + ID: 91, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-a"}, + }, + } + groupB := &service.Group{ + ID: 92, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-b"}, + }, + } + + firstA := performCodexModelsRequestForGroup(t, handler, groupA, "") + require.Equal(t, http.StatusOK, firstA.Code, firstA.Body.String()) + require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, firstA)) + + firstB := performCodexModelsRequestForGroup(t, handler, groupB, "") + require.Equal(t, http.StatusOK, firstB.Code, firstB.Body.String()) + require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, firstB)) + + etagA := firstA.Header().Get("ETag") + require.NotEmpty(t, etagA) + + var wg sync.WaitGroup + results := make([]*httptest.ResponseRecorder, 8) + for i := range results { + wg.Add(1) + go func(index int) { + defer wg.Done() + if index%2 == 0 { + results[index] = performCodexModelsRequestForGroup(t, handler, groupA, etagA) + return + } + results[index] = performCodexModelsRequestForGroup(t, handler, groupB, "") + }(i) + } + wg.Wait() + + sawGroupB := false + for _, recorder := range results { + require.NotNil(t, recorder) + switch recorder.Code { + case http.StatusNotModified: + require.Empty(t, recorder.Body.Bytes()) + case http.StatusOK: + slugs := codexHandlerManifestSlugs(t, recorder) + if len(slugs) == 1 && slugs[0] == "model-b" { + sawGroupB = true + continue + } + require.Equal(t, []string{"model-a"}, slugs) + default: + t.Fatalf("unexpected status %d body=%s", recorder.Code, recorder.Body.String()) + } + } + require.True(t, sawGroupB) +} + +// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。 +func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(44) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "ark-compatible", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Priority: 0, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-ark", + "base_url": "https://ark.example/v1", + "model_mapping": map[string]any{ + "glm-5.3": "glm-5.3", + }, + }, + }, + { + ID: 2, + Name: "chatgpt-oauth", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: true, + Priority: 1, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-test", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound} + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + + recorder := performCodexModelsRequestForGroup(t, handler, &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + }, "") + + if got := upstream.calls(); len(got) != 0 { + t.Fatalf("upstream account calls: got %v, want none", got) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + var envelope struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) + } + if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" { + t.Fatalf("models: got %v, want only glm-5.3", envelope.Models) + } + if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok { + t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0]) + } +} + func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) { handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable) @@ -148,8 +388,23 @@ func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } - if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want { - t.Fatalf("body: got %q, want %q", got, want) + requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol") + }) + } +} + +// Scenario: an API-key upstream without /models is excluded only for this discovery request. +func TestCodexModelsFailsOverWhenAPIKeyModelsEndpointIsUnavailable(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed} { + t.Run(http.StatusText(status), func(t *testing.T) { + handler, upstream, groupID := newCodexModelsFailoverTestHandler(status) + recorder := performCodexModelsRequest(t, handler, groupID) + + if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) { + t.Fatalf("upstream account calls: got %v, want %v", got, want) + } + if recorder.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } }) } @@ -183,9 +438,7 @@ func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } - if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want { - t.Fatalf("body: got %q, want %q", got, want) - } + requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol") } func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) { @@ -193,7 +446,6 @@ func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) { http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, - http.StatusNotFound, 600, } for _, status := range statuses { @@ -300,23 +552,79 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount } func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder { - return performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformOpenAI) + return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, "") } func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder { + return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: platform}, "") +} + +func performCodexModelsRequestForGroup(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder { t.Helper() recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil) + if etag != "" { + c.Request.Header.Set("If-None-Match", etag) + } c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ - GroupID: &groupID, - Group: &service.Group{ID: groupID, Platform: platform}, + GroupID: &group.ID, + Group: group, }) handler.CodexModels(c) return recorder } +func codexHandlerManifestSlugs(t *testing.T, recorder *httptest.ResponseRecorder) []string { + t.Helper() + + var envelope struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) + } + slugs := make([]string, 0, len(envelope.Models)) + for _, model := range envelope.Models { + slugs = append(slugs, model.Slug) + } + return slugs +} + +func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.ResponseRecorder, slug string) { + t.Helper() + + var envelope struct { + Models []map[string]any `json:"models"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) + } + if len(envelope.Models) != 1 { + t.Fatalf("models count: got %d, want 1; body=%s", len(envelope.Models), recorder.Body.String()) + } + model := envelope.Models[0] + if got := model["slug"]; got != slug { + t.Fatalf("slug: got %v, want %q", got, slug) + } + if levels, ok := model["supported_reasoning_levels"].([]any); !ok || len(levels) == 0 { + t.Fatalf("supported_reasoning_levels must be populated: %v", model["supported_reasoning_levels"]) + } + if messages, ok := model["model_messages"].(map[string]any); !ok || messages["instructions_template"] == "" { + t.Fatalf("model_messages.instructions_template must be populated: %v", model["model_messages"]) + } + if policy, ok := model["truncation_policy"].(map[string]any); !ok || len(policy) == 0 { + t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"]) + } + modalities, ok := model["input_modalities"].([]any) + if !ok || len(modalities) != 1 || modalities[0] != "text" { + t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"]) + } +} + func equalInt64Slices(got, want []int64) bool { if len(got) != len(want) { return false diff --git a/backend/internal/pkg/claude/effort_catalog.go b/backend/internal/pkg/claude/effort_catalog.go new file mode 100644 index 0000000000..ab587a4c01 --- /dev/null +++ b/backend/internal/pkg/claude/effort_catalog.go @@ -0,0 +1,69 @@ +package claude + +import ( + "strings" + "unicode" +) + +var ( + effortLowMediumHigh = []string{"low", "medium", "high"} + effortLowMediumHighMax = []string{"low", "medium", "high", "max"} + effortLowMediumHighXHighMax = []string{"low", "medium", "high", "xhigh", "max"} +) + +var effortFamilies = []struct { + family string + levels []string +}{ + {family: "claude-mythos-preview", levels: effortLowMediumHighMax}, + {family: "claude-mythos-5", levels: effortLowMediumHighXHighMax}, + {family: "claude-fable-5", levels: effortLowMediumHighXHighMax}, + {family: "claude-sonnet-4-6", levels: effortLowMediumHighMax}, + {family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax}, + {family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax}, + {family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax}, + {family: "claude-opus-4-6", levels: effortLowMediumHighMax}, + {family: "claude-opus-4-5", levels: effortLowMediumHigh}, + {family: "claude-opus-5", levels: effortLowMediumHighXHighMax}, +} + +// EffortLevelsForModel returns the output_config.effort values accepted by a +// Claude model, ordered from the lightest to the deepest reasoning level. +func EffortLevelsForModel(model string) []string { + id := normalizeEffortModelID(model) + for _, entry := range effortFamilies { + if id == entry.family || strings.HasPrefix(id, entry.family+"-") { + return append([]string(nil), entry.levels...) + } + } + return nil +} + +func normalizeEffortModelID(model string) string { + id := strings.ToLower(strings.TrimSpace(model)) + id = strings.TrimPrefix(id, "models/") + if slash := strings.IndexByte(id, '/'); slash >= 0 { + id = strings.TrimPrefix(strings.TrimSpace(id[slash+1:]), "models/") + } + id = strings.TrimPrefix(id, "anthropic.") + id = strings.TrimSuffix(id, "-thinking") + if mapped, ok := ModelIDReverseOverrides[id]; ok { + id = mapped + } + if len(id) >= 9 { + suffix := id[len(id)-9:] + if suffix[0] == '-' { + digits := true + for _, r := range suffix[1:] { + if !unicode.IsDigit(r) { + digits = false + break + } + } + if digits { + id = id[:len(id)-9] + } + } + } + return id +} diff --git a/backend/internal/pkg/claude/effort_catalog_test.go b/backend/internal/pkg/claude/effort_catalog_test.go new file mode 100644 index 0000000000..718e20eb57 --- /dev/null +++ b/backend/internal/pkg/claude/effort_catalog_test.go @@ -0,0 +1,29 @@ +package claude + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEffortLevelsForModel(t *testing.T) { + t.Parallel() + + tests := []struct { + model string + want []string + }{ + {model: "claude-opus-4-6", want: []string{"low", "medium", "high", "max"}}, + {model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}}, + {model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}}, + {model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}}, + {model: "claude-haiku-4-5-20251001", want: nil}, + {model: "gpt-5.6", want: nil}, + } + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, EffortLevelsForModel(tt.model)) + }) + } +} diff --git a/backend/internal/pkg/xai/models.go b/backend/internal/pkg/xai/models.go index 6fab34dc39..65634598f1 100644 --- a/backend/internal/pkg/xai/models.go +++ b/backend/internal/pkg/xai/models.go @@ -251,6 +251,30 @@ func IsGrokModelID(model string) bool { return false } +// IsGrokImagineModel reports whether model is a Grok Imagine image or video +// model. These media models cannot act as the primary Codex agent model. +func IsGrokImagineModel(model string) bool { + normalized := strings.ToLower(StripGrokProviderPrefix(model)) + if normalized == "" { + return false + } + if strings.HasPrefix(normalized, "imagine") { + return true + } + switch { + case normalized == "grok-imagine", + normalized == "grok-imagine-1", + normalized == "grok-imagine-edit", + normalized == "grok-video-1.5": + return true + case strings.HasPrefix(normalized, "grok-imagine-image"), + strings.HasPrefix(normalized, "grok-imagine-video"): + return true + default: + return false + } +} + // IsGrokTextResponsesModelID reports whether model is a known Grok text model // for the Responses API. Imagine image/video and unknown custom ids return false. func IsGrokTextResponsesModelID(model string) bool { diff --git a/backend/internal/pkg/xai/models_test.go b/backend/internal/pkg/xai/models_test.go index fb4860ee4d..f9d58e5a0c 100644 --- a/backend/internal/pkg/xai/models_test.go +++ b/backend/internal/pkg/xai/models_test.go @@ -58,6 +58,16 @@ func TestIsGrokModelID(t *testing.T) { require.False(t, IsGrokModelID("claude-sonnet-4")) } +func TestIsGrokImagineModel(t *testing.T) { + t.Parallel() + require.True(t, IsGrokImagineModel("grok-imagine-image")) + require.True(t, IsGrokImagineModel("grok-imagine-video-1.5-preview")) + require.True(t, IsGrokImagineModel("xai/grok-imagine-image-quality")) + require.True(t, IsGrokImagineModel("grok-video-1.5")) + require.False(t, IsGrokImagineModel("grok-4.6")) + require.False(t, IsGrokImagineModel("grok-build-0.1")) +} + func TestDefaultModelsIncludesGrok46(t *testing.T) { t.Parallel() ids := DefaultModelIDs() diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 6c0bfd908d..fe753b2389 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -65,13 +65,13 @@ func RegisterGatewayRoutes( h.Gateway.CountTokens(c) } } + codexModelsHandler := func(c *gin.Context) { + dispatchCodexModelsGateway(c, h.OpenAIGateway.CodexModels, h.Gateway.CodexModels) + } modelsHandler := func(c *gin.Context) { if c.Query("client_version") != "" { - switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformComposite: - h.OpenAIGateway.CodexModels(c) - return - } + codexModelsHandler(c) + return } h.Gateway.Models(c) } @@ -377,7 +377,7 @@ func RegisterGatewayRoutes( codexDirect.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) }) - codexDirect.GET("/models", h.OpenAIGateway.CodexModels) + codexDirect.GET("/models", codexModelsHandler) } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { @@ -504,6 +504,14 @@ func RegisterGatewayRoutes( } +func dispatchCodexModelsGateway(c *gin.Context, openAIHandler, generatedHandler gin.HandlerFunc) { + if getGroupPlatform(c) == service.PlatformOpenAI { + openAIHandler(c) + return + } + generatedHandler(c) +} + // getGroupPlatform extracts the group platform from the API Key stored in context. func getGroupPlatform(c *gin.Context) string { apiKey, ok := middleware.GetAPIKeyFromContext(c) diff --git a/backend/internal/server/routes/gateway_codex_models_test.go b/backend/internal/server/routes/gateway_codex_models_test.go index 04a8b8fa67..0c6542a0fa 100644 --- a/backend/internal/server/routes/gateway_codex_models_test.go +++ b/backend/internal/server/routes/gateway_codex_models_test.go @@ -2,8 +2,12 @@ package routes import ( "net/http" + "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -22,3 +26,39 @@ func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) { require.NotEmpty(t, registered["/models"], "GET /models should be registered") require.Equal(t, registered["/v1/models"], registered["/models"], "root alias should use the same platform-aware handler") } + +func TestDispatchCodexModelsGatewayKeepsOnlyOpenAIOnLiveManifestHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + platform string + wantOpenAI bool + }{ + {platform: service.PlatformOpenAI, wantOpenAI: true}, + {platform: service.PlatformComposite}, + {platform: service.PlatformGrok}, + {platform: service.PlatformDeepseek}, + } + + for _, tt := range tests { + t.Run(tt.platform, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{Platform: tt.platform}, + }) + called := "" + + dispatchCodexModelsGateway(c, + func(c *gin.Context) { called = "openai" }, + func(c *gin.Context) { called = "generated" }, + ) + + if tt.wantOpenAI { + require.Equal(t, "openai", called) + } else { + require.Equal(t, "generated", called) + } + }) + } +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index e7ce083ff5..4e304eca10 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -147,6 +147,9 @@ type AccountTestService struct { cfg *config.Config settingService *SettingService tlsFPProfileService *TLSFingerprintProfileService + modelMetadataRegistryMu sync.Mutex + modelMetadataRegistry map[string]modelsDevProvider + modelMetadataRegistryAt time.Time pluginManager *PluginManager agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator diff --git a/backend/internal/service/composite_model_route.go b/backend/internal/service/composite_model_route.go index a008a3e9fa..1201dad5e4 100644 --- a/backend/internal/service/composite_model_route.go +++ b/backend/internal/service/composite_model_route.go @@ -23,8 +23,19 @@ const ( CompositeRouteSourceExplicit = "route" CompositeRouteSourceDetector = "detector" + CompositeRouteSourceAccount = "account_model" ) +// CompositeModelOwnership identifies the concrete provider that exposes a +// public model through an account-level exact mapping. +type CompositeModelOwnership struct { + TargetPlatform string + Matched bool + Ambiguous bool +} + +type CompositeModelOwnershipResolver func(context.Context, int64, string) (CompositeModelOwnership, error) + var ( ErrCompositeRouteNotFound = infraerrors.NotFound("COMPOSITE_ROUTE_NOT_FOUND", "composite route not found") ErrCompositeRouteExists = infraerrors.Conflict("COMPOSITE_ROUTE_EXISTS", "composite route already exists") diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 5373a7fc6f..bff5928024 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -8,6 +8,150 @@ import ( "github.com/stretchr/testify/require" ) +type compositeOwnershipAccountRepo struct { + AccountRepository + accounts []Account +} + +func (r *compositeOwnershipAccountRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) { + return r.accounts, nil +} + +// Scenario: 唯一平台的精确别名可路由 +func TestResolveCompositeModelOwnershipKeepsProviderAccountsIsolated(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-public": "gpt-5"}, + }, + }, + { + ID: 2, + Platform: PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"}, + }, + }, + }, + } + svc := &GatewayService{accountRepo: repo} + + deepSeekOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "reasoning-alias") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, deepSeekOwnership) + + openAIOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "gpt-public") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, openAIOwnership) +} + +// Scenario: 通配符和空映射不声明所有权 +func TestResolveCompositeModelOwnershipRequiresNonEmptyExactMappings(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + { + ID: 1, + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"*": "gpt-5", "gpt-*": "gpt-5", "empty-alias": ""}, + }, + }, + { + ID: 2, + Platform: PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-public": "grok-4"}, + }, + }, + }, + } + svc := &GatewayService{accountRepo: repo} + + for _, model := range []string{"gpt-5", "empty-alias", "unknown-alias"} { + ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, model) + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{}, ownership, "model=%s", model) + } + + ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "grok-public") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformGrok, Matched: true}, ownership) +} + +func TestResolveCompositeModelOwnershipAllowsSamePlatformAndRejectsCrossPlatformAliases(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{ + {ID: 1, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5", "ambiguous": "gpt-5"}}}, + {ID: 2, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5.1"}}}, + {ID: 3, Platform: PlatformDeepseek, Credentials: map[string]any{"model_mapping": map[string]any{"ambiguous": "deepseek-v4-pro"}}}, + }, + } + svc := &GatewayService{accountRepo: repo} + + samePlatform, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "shared-openai") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, samePlatform) + + ambiguous, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "ambiguous") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{Ambiguous: true}, ambiguous) +} + +func TestNewGatewayServiceWiresCompositeModelOwnershipResolver(t *testing.T) { + groupID := int64(7) + repo := &compositeOwnershipAccountRepo{ + accounts: []Account{{ + ID: 1, + Platform: PlatformDeepseek, + Credentials: map[string]any{"model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"}}, + }}, + } + resolver := NewCompositeRouteResolver(nil) + svc := NewGatewayService( + repo, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + resolver, + nil, + nil, + ) + require.Same(t, resolver, svc.compositeResolver) + + decision, err := resolver.Resolve(context.Background(), groupID, "reasoning-alias", CompositeRouteEndpointResponses) + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) +} + func TestDetectModelPlatform(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/composite_route_resolver.go b/backend/internal/service/composite_route_resolver.go index 3d69f13553..f2e1928ace 100644 --- a/backend/internal/service/composite_route_resolver.go +++ b/backend/internal/service/composite_route_resolver.go @@ -8,13 +8,20 @@ import ( ) type CompositeRouteResolver struct { - repo CompositeModelRouteRepository + repo CompositeModelRouteRepository + modelOwnershipResolver CompositeModelOwnershipResolver } func NewCompositeRouteResolver(repo CompositeModelRouteRepository) *CompositeRouteResolver { return &CompositeRouteResolver{repo: repo} } +func (r *CompositeRouteResolver) SetModelOwnershipResolver(resolver CompositeModelOwnershipResolver) { + if r != nil { + r.modelOwnershipResolver = resolver + } +} + func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, model, endpoint string) (CompositeRouteDecision, error) { model = strings.TrimSpace(model) endpoint = normalizeCompositeRouteEndpoint(endpoint) @@ -51,6 +58,35 @@ func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, mod } } + if r != nil && r.modelOwnershipResolver != nil && groupID > 0 { + ownership, err := r.modelOwnershipResolver(ctx, groupID, model) + if err != nil { + // A recognizable model can still use the existing detector when the + // account catalog is temporarily unavailable. Unknown aliases cannot. + if _, detectable := DetectModelPlatform(model); !detectable { + return decision, fmt.Errorf("resolve account model ownership: %w", err) + } + } else if ownership.Ambiguous { + decision.Reason = "model is exposed by multiple provider platforms" + return decision, nil + } else if ownership.Matched { + platform := strings.TrimSpace(ownership.TargetPlatform) + if !isConcreteRequestPlatform(platform) { + decision.Reason = "account model ownership has no concrete target platform" + return decision, nil + } + return CompositeRouteDecision{ + Matched: true, + Source: CompositeRouteSourceAccount, + GroupID: groupID, + PublicModel: model, + TargetPlatform: platform, + UpstreamModel: model, + Endpoint: endpoint, + }, nil + } + } + if platform, ok := DetectModelPlatform(model); ok { return CompositeRouteDecision{ Matched: true, diff --git a/backend/internal/service/composite_route_resolver_test.go b/backend/internal/service/composite_route_resolver_test.go index 60e4bd0b82..474b29387b 100644 --- a/backend/internal/service/composite_route_resolver_test.go +++ b/backend/internal/service/composite_route_resolver_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "testing" "github.com/stretchr/testify/require" @@ -69,6 +70,98 @@ func TestCompositeRouteResolverExplicitExactRouteRewritesModel(t *testing.T) { require.Equal(t, int64(10), decision.Route.ID) } +// Scenario: 唯一平台的精确别名可路由 +func TestCompositeRouteResolverUsesAccountModelOwnershipForUnprefixedAlias(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(_ context.Context, groupID int64, model string) (CompositeModelOwnership, error) { + require.Equal(t, int64(7), groupID) + require.Equal(t, "reasoning-alias", model) + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) + require.Equal(t, "reasoning-alias", decision.UpstreamModel) +} + +func TestCompositeRouteResolverAccountOwnershipOverridesBuiltInDetector(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceAccount, decision.Source) + require.Equal(t, PlatformDeepseek, decision.TargetPlatform) +} + +// Scenario: 显式路由保持最高优先级 +func TestCompositeRouteResolverExplicitRouteBeatsAccountOwnership(t *testing.T) { + resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []CompositeModelRoute{{ + ID: 10, + GroupID: 7, + PublicModel: "reasoning-alias", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformOpenAI, + UpstreamModel: "gpt-5", + Endpoint: CompositeRouteEndpointAny, + Enabled: true, + }}, + }) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.True(t, decision.Matched) + require.Equal(t, CompositeRouteSourceExplicit, decision.Source) + require.Equal(t, PlatformOpenAI, decision.TargetPlatform) + require.Equal(t, "gpt-5", decision.UpstreamModel) +} + +// Scenario: 跨平台同名别名不被猜测 +func TestCompositeRouteResolverDoesNotGuessAmbiguousAccountOwnership(t *testing.T) { + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{Ambiguous: true}, nil + }) + + decision, err := resolver.Resolve(context.Background(), 7, "shared-alias", CompositeRouteEndpointChatCompletions) + + require.NoError(t, err) + require.False(t, decision.Matched) + require.Empty(t, decision.TargetPlatform) + require.Equal(t, "model is exposed by multiple provider platforms", decision.Reason) +} + +func TestCompositeRouteResolverOwnershipLookupErrorFallsBackOnlyForDetectableModels(t *testing.T) { + lookupErr := errors.New("account catalog unavailable") + resolver := NewCompositeRouteResolver(nil) + resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) { + return CompositeModelOwnership{}, lookupErr + }) + + detected, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses) + require.NoError(t, err) + require.True(t, detected.Matched) + require.Equal(t, CompositeRouteSourceDetector, detected.Source) + require.Equal(t, PlatformOpenAI, detected.TargetPlatform) + + unknown, err := resolver.Resolve(context.Background(), 7, "company-model", CompositeRouteEndpointResponses) + require.ErrorIs(t, err, lookupErr) + require.False(t, unknown.Matched) +} + func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T) { resolver := NewCompositeRouteResolver(compositeRouteRepoStub{ routes: []CompositeModelRoute{ diff --git a/backend/internal/service/gateway_hotpath_optimization_test.go b/backend/internal/service/gateway_hotpath_optimization_test.go index 1bdf48bd41..8855b13e52 100644 --- a/backend/internal/service/gateway_hotpath_optimization_test.go +++ b/backend/internal/service/gateway_hotpath_optimization_test.go @@ -564,6 +564,46 @@ func TestGetAvailableModels_UsesShortCacheAndSupportsInvalidation(t *testing.T) require.Equal(t, int64(2), store) } +// Scenario: 账号模型变更会失效所属平台缓存 +func TestResolveCompositeModelOwnershipUsesModelsCacheInvalidation(t *testing.T) { + groupID := int64(9) + repo := &modelsListAccountRepoStub{ + byGroup: map[int64][]Account{ + groupID: {{ + ID: 1, + Platform: PlatformDeepseek, + Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "deepseek-v4-pro"}}, + }}, + }, + } + svc := &GatewayService{ + accountRepo: repo, + modelsListCache: gocache.New(time.Minute, time.Minute), + modelsListCacheTTL: time.Minute, + } + + first, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, first) + require.Equal(t, int64(1), repo.listByGroupCalls.Load()) + + repo.byGroup[groupID] = []Account{{ + ID: 2, + Platform: PlatformOpenAI, + Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "gpt-5"}}, + }} + cached, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, first, cached) + require.Equal(t, int64(1), repo.listByGroupCalls.Load()) + + svc.InvalidateAvailableModelsCache(&groupID, PlatformDeepseek) + refreshed, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model") + require.NoError(t, err) + require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, refreshed) + require.Equal(t, int64(2), repo.listByGroupCalls.Load()) +} + func TestGetAvailableModels_ErrorAndGlobalListBranches(t *testing.T) { resetGatewayHotpathStatsForTest() diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 664b6bf08b..a8834bc403 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -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() diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index be63f804f5..977a28a99d 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -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 diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 37bab2f97d..8910cec117 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -71,8 +71,9 @@ const ( ) const ( - cacheTTLTarget5m = "5m" - cacheTTLTarget1h = "1h" + cacheTTLTarget5m = "5m" + cacheTTLTarget1h = "1h" + compositeModelOwnershipCachePrefix = "composite-owner|" ) // ForceCacheBillingContextKey 强制缓存计费上下文键 @@ -528,6 +529,10 @@ func modelsListCacheKey(groupID *int64, platform string) string { return fmt.Sprintf("%d|%s", derefGroupID(groupID), strings.TrimSpace(platform)) } +func compositeModelOwnershipCacheKey(groupID int64, model string) string { + return fmt.Sprintf("%s%d|%s", compositeModelOwnershipCachePrefix, groupID, strings.TrimSpace(model)) +} + func prefetchedStickyGroupIDFromContext(ctx context.Context) (int64, bool) { return PrefetchedStickyGroupIDFromContext(ctx) } @@ -858,6 +863,9 @@ func NewGatewayService( balanceNotifyService: balanceNotifyService, userPlatformQuotaRepo: userPlatformQuotaRepo, } + if compositeResolver != nil { + compositeResolver.SetModelOwnershipResolver(svc.resolveCompositeModelOwnership) + } svc.userGroupRateResolver = newUserGroupRateResolver( userGroupRateRepo, svc.userGroupRateCache, @@ -1447,6 +1455,59 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, return cloneStringSlice(models) } +func (s *GatewayService) resolveCompositeModelOwnership(ctx context.Context, groupID int64, model string) (CompositeModelOwnership, error) { + model = strings.TrimSpace(model) + if s == nil || s.accountRepo == nil || groupID <= 0 || model == "" { + return CompositeModelOwnership{}, nil + } + + cacheKey := compositeModelOwnershipCacheKey(groupID, model) + if s.modelsListCache != nil { + if cached, found := s.modelsListCache.Get(cacheKey); found { + if ownership, ok := cached.(CompositeModelOwnership); ok { + return ownership, nil + } + } + } + + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID) + if err != nil { + return CompositeModelOwnership{}, err + } + + platforms := make(map[string]struct{}) + for _, account := range accounts { + platform := strings.TrimSpace(account.Platform) + if !isConcreteRequestPlatform(platform) || !explicitModelMappingClaims(account, model) { + continue + } + platforms[platform] = struct{}{} + } + + ownership := CompositeModelOwnership{} + if len(platforms) == 1 { + for platform := range platforms { + ownership.TargetPlatform = platform + } + ownership.Matched = true + } else if len(platforms) > 1 { + ownership.Ambiguous = true + } + + if s.modelsListCache != nil { + s.modelsListCache.Set(cacheKey, ownership, s.modelsListCacheTTL) + } + return ownership, nil +} + +func explicitModelMappingClaims(account Account, model string) bool { + if account.Credentials == nil || model == "" { + return false + } + mapped, ok := stringMappingFromRaw(account.Credentials["model_mapping"])[model] + return ok && strings.TrimSpace(mapped) != "" +} + // GetSchedulablePlatforms returns the concrete platforms that currently have // schedulable accounts in the target group. func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} { @@ -1479,6 +1540,7 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform if s == nil || s.modelsListCache == nil { return } + s.invalidateCompositeModelOwnershipCache(groupID) normalizedPlatform := strings.TrimSpace(platform) // 完整匹配时精准失效;否则按维度批量失效。 @@ -1507,6 +1569,26 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform } } +func (s *GatewayService) invalidateCompositeModelOwnershipCache(groupID *int64) { + for key := range s.modelsListCache.Items() { + if !strings.HasPrefix(key, compositeModelOwnershipCachePrefix) { + continue + } + if groupID == nil { + s.modelsListCache.Delete(key) + continue + } + parts := strings.SplitN(strings.TrimPrefix(key, compositeModelOwnershipCachePrefix), "|", 2) + if len(parts) != 2 { + continue + } + cachedGroupID, err := strconv.ParseInt(parts[0], 10, 64) + if err == nil && cachedGroupID == *groupID { + s.modelsListCache.Delete(key) + } + } +} + const debugGatewayBodyDefaultFilename = "gateway_debug.log" // initDebugGatewayBodyFile 初始化网关调试日志文件。 diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go new file mode 100644 index 0000000000..c779a05ab9 --- /dev/null +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -0,0 +1,313 @@ +package service + +import "strings" + +func groupCodexModelMetadata( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) (codexModelMetadataOverride, bool) { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return codexModelMetadataOverride{}, false + } + upstreamModel := modelID + if platform == PlatformComposite { + var resolved bool + platform, upstreamModel, resolved = resolveCodexCompositeModelTarget( + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if !resolved { + if codexExplicitModelTargetsConflict(accounts, modelID) { + return codexModelMetadataOverride{ + reasoningConflict: true, + inputModalitiesConflict: true, + }, true + } + return codexModelMetadataOverride{}, false + } + } + if !isConcreteRequestPlatform(platform) { + return codexModelMetadataOverride{}, false + } + + explicitClaims := false + if upstreamModel == modelID { + for _, account := range accounts { + if account.Platform == platform && codexExplicitModelMappingClaims(account, modelID) { + explicitClaims = true + break + } + } + } + explicitTargetsConflict := explicitClaims && codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID) + publicAlias := upstreamModel != modelID + candidates := make([]UpstreamModelMetadata, 0) + for i := range accounts { + account := &accounts[i] + if account.Platform != platform { + continue + } + var lookupModel string + if explicitClaims { + if !codexExplicitModelMappingClaims(*account, modelID) { + continue + } + lookupModel = account.GetMappedModel(modelID) + } else { + if !account.IsModelSupported(upstreamModel) { + continue + } + lookupModel = account.GetMappedModel(upstreamModel) + } + if strings.TrimSpace(lookupModel) != modelID { + publicAlias = true + } + metadata, ok := account.GetUpstreamModelMetadata(lookupModel) + if !ok { + if explicitTargetsConflict { + return codexModelMetadataOverride{ + reasoningConflict: true, + inputModalitiesConflict: true, + }, true + } + return codexModelMetadataOverride{}, false + } + candidates = append(candidates, metadata) + } + if len(candidates) == 0 { + return codexModelMetadataOverride{}, false + } + metadata := intersectUpstreamModelMetadata(modelID, candidates) + if publicAlias { + metadata.DisplayName = modelID + metadata.Description = configuredCodexCustomDescription + } + return metadata, true +} + +func codexExplicitModelTargetsConflict(accounts []Account, modelID string) bool { + targets := make(map[string]struct{}) + for i := range accounts { + account := &accounts[i] + mappedModel, matched := account.ResolveMappedModel(modelID) + mappedModel = strings.TrimSpace(mappedModel) + if !matched || mappedModel == "" { + continue + } + targets[strings.TrimSpace(account.Platform)+"\x00"+mappedModel] = struct{}{} + } + return len(targets) > 1 +} + +func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, modelID string) bool { + 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{}{} + } + return len(targets) > 1 +} + +func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride { + result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}} + for _, candidate := range candidates { + if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" { + result.DisplayName = strings.TrimSpace(candidate.DisplayName) + } + if result.Description == "" && strings.TrimSpace(candidate.Description) != "" { + result.Description = strings.TrimSpace(candidate.Description) + } + } + + reasoningKnown := true + reasoningValue := false + for i, candidate := range candidates { + if candidate.Reasoning == nil { + reasoningKnown = false + break + } + if i == 0 { + reasoningValue = *candidate.Reasoning + continue + } + if reasoningValue != *candidate.Reasoning { + reasoningKnown = false + result.reasoningConflict = true + break + } + } + if reasoningKnown { + result.Reasoning = &reasoningValue + if reasoningValue { + levels := normalizeReasoningLevels(candidates[0].SupportedReasoningLevels) + for _, candidate := range candidates[1:] { + levels = intersectOrderedStrings(levels, normalizeReasoningLevels(candidate.SupportedReasoningLevels)) + } + result.SupportedReasoningLevels = levels + if len(levels) == 0 { + result.reasoningConflict = true + } else { + sharedDefault := normalizeReasoningLevel(candidates[0].DefaultReasoningLevel) + for _, candidate := range candidates[1:] { + if normalizeReasoningLevel(candidate.DefaultReasoningLevel) != sharedDefault { + sharedDefault = "" + break + } + } + if !stringSliceContains(levels, sharedDefault) { + sharedDefault = levels[0] + } + result.DefaultReasoningLevel = sharedDefault + } + } + } + + modalitiesKnown := true + modalities := normalizeCodexInputModalities(candidates[0].InputModalities) + if len(modalities) == 0 { + modalitiesKnown = false + } + for _, candidate := range candidates[1:] { + candidateModalities := normalizeCodexInputModalities(candidate.InputModalities) + if len(candidateModalities) == 0 { + modalitiesKnown = false + break + } + modalities = intersectOrderedStrings(modalities, candidateModalities) + } + if modalitiesKnown && len(modalities) > 0 { + result.InputModalities = modalities + } else if modalitiesKnown { + result.inputModalitiesConflict = true + } + + contextKnown := true + for i, candidate := range candidates { + if candidate.ContextWindow <= 0 { + contextKnown = false + break + } + if i == 0 || candidate.ContextWindow < result.ContextWindow { + result.ContextWindow = candidate.ContextWindow + } + } + if !contextKnown { + result.ContextWindow = 0 + } + return result +} + +func applyUpstreamModelMetadataToCodexDescriptor( + descriptor *configuredCodexModelDescriptor, + metadata codexModelMetadataOverride, +) { + if descriptor == nil { + return + } + if strings.TrimSpace(metadata.DisplayName) != "" { + descriptor.DisplayName = strings.TrimSpace(metadata.DisplayName) + } + if strings.TrimSpace(metadata.Description) != "" { + descriptor.Description = strings.TrimSpace(metadata.Description) + } + if metadata.reasoningConflict { + descriptor.DefaultReasoningLevel = nil + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{} + } else if metadata.Reasoning != nil && !*metadata.Reasoning { + none := "none" + descriptor.DefaultReasoningLevel = &none + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{{ + Effort: "none", + Description: configuredCodexReasoningLevelDescription("none"), + }} + } else if metadata.Reasoning != nil && *metadata.Reasoning { + levels := normalizeReasoningLevels(metadata.SupportedReasoningLevels) + if len(levels) == 0 { + descriptor.DefaultReasoningLevel = nil + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{} + } else { + defaultLevel := normalizeReasoningLevel(metadata.DefaultReasoningLevel) + if !stringSliceContains(levels, defaultLevel) { + defaultLevel = levels[0] + } + descriptor.DefaultReasoningLevel = &defaultLevel + descriptor.SupportedReasoningLevels = make([]configuredCodexReasoningLevel, 0, len(levels)) + for _, level := range levels { + descriptor.SupportedReasoningLevels = append(descriptor.SupportedReasoningLevels, configuredCodexReasoningLevel{ + Effort: level, + Description: configuredCodexReasoningLevelDescription(level), + }) + } + } + } + if metadata.inputModalitiesConflict { + descriptor.InputModalities = []string{"text"} + } else if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 { + descriptor.InputModalities = modalities + } + if metadata.ContextWindow > 0 { + descriptor.ContextWindow = metadata.ContextWindow + descriptor.MaxContextWindow = metadata.ContextWindow + } +} + +func configuredCodexReasoningLevelDescription(level string) string { + switch level { + case "none": + return "Use the model's default behavior without configurable reasoning" + case "minimal": + return "Minimal reasoning for the fastest responses" + case "low": + return "Fast responses with lighter reasoning" + case "medium": + return "Balanced reasoning for most coding tasks" + case "high": + return "Greater reasoning depth for coding and agent tasks" + case "xhigh": + return "Extra-high reasoning depth for difficult tasks" + case "max": + return "Maximum reasoning depth for complex tasks" + default: + return "Reasoning effort supported by the upstream model" + } +} + +func intersectOrderedStrings(left, right []string) []string { + rightSet := make(map[string]struct{}, len(right)) + for _, value := range right { + rightSet[value] = struct{}{} + } + intersection := make([]string, 0, len(left)) + for _, value := range left { + if _, ok := rightSet[value]; ok { + intersection = append(intersection, value) + } + } + return intersection +} + +func stringSliceContains(values []string, target string) bool { + if target == "" { + return false + } + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go new file mode 100644 index 0000000000..5161617d7b --- /dev/null +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -0,0 +1,385 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// Scenario: mixed groups prefer capability metadata synced for the routed account. +func TestBuildCodexModelsManifestForGroupUsesSyncedAccountMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 735 + account := Account{ + ID: 25, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/v1", + "model_mapping": map[string]any{"x-preview-f-free": "x-preview-f-free"}, + }, + Extra: map[string]any{ + UpstreamModelMetadataExtraKey: map[string]any{ + "source": "models.dev", + "models": map[string]any{ + "x-preview-f-free": map[string]any{ + "id": "x-preview-f-free", + "display_name": "Ox Alpha Free (Unlimited)", + "description": "Stealth reasoning model", + "reasoning": true, + "supported_reasoning_levels": []any{"low", "high", "max"}, + "input_modalities": []any{"text", "image"}, + "context_window": float64(1_000_000), + "max_output_tokens": float64(131_072), + }, + }, + }, + }, + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {account}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"x-preview-f-free"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "Ox Alpha Free (Unlimited)", models[0]["display_name"]) + require.Equal(t, "low", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"low", "high", "max"}, 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: an explicitly non-reasoning model remains directly selectable in Codex. +func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 737 + reasoning := false + account := Account{ + ID: 28, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"company-coding-model": "company-coding-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "company-coding-model": { + ID: "company-coding-model", Reasoning: &reasoning, + InputModalities: []string{"text"}, ContextWindow: 64_000, + }, + }}) + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {account}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"company-coding-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "none", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"none"}, effortsFromManifestModel(t, models[0])) +} + +// Scenario: multiple schedulable accounts advertise only their shared capabilities. +func TestBuildCodexModelsManifestForGroupIntersectsSyncedAccountMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 736 + reasoning := true + newAccount := func(id int64, levels, modalities []string, contextWindow int64) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"shared-model": "shared-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "shared-model": { + ID: "shared-model", Reasoning: &reasoning, + SupportedReasoningLevels: levels, + InputModalities: modalities, + ContextWindow: contextWindow, + }, + }}) + return account + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + newAccount(26, []string{"low", "high"}, []string{"text", "image"}, 256_000), + newAccount(27, []string{"high", "max"}, []string{"text"}, 128_000), + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []string{"high"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, "high", models[0]["default_reasoning_level"]) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.EqualValues(t, 128_000, models[0]["context_window"]) +} + +// Scenario: the same public alias may target different models on one platform when complete snapshots can be intersected. +func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithoutLeakingAlias(t *testing.T) { + t.Parallel() + + const groupID int64 = 739 + reasoning := true + newAccount := func(id int64, target, displayName, description string, levels, modalities []string, contextWindow int64) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"my-coder": target}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + target: { + ID: target, DisplayName: displayName, Description: description, Reasoning: &reasoning, + SupportedReasoningLevels: levels, + InputModalities: modalities, + ContextWindow: contextWindow, + }, + }}) + return account + } + openAIAccount := newAccount( + 31, + "gpt-5.6-sol", + "GPT-5.6 Sol", + "OpenAI upstream model", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 272_000, + ) + arkAccount := newAccount( + 32, + "glm-5.3", + "GLM 5.3", + "Ark upstream model", + []string{"low", "medium", "high"}, + []string{"text"}, + 1_000_000, + ) + + for _, accounts := range [][]Account{{openAIAccount, arkAccount}, {arkAccount, openAIAccount}} { + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: accounts, + }}} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, 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, "Custom model routed through Sub2API.", models[0]["description"]) + 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: temporarily unschedulable mapped accounts still participate in capability intersection. +func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t *testing.T) { + t.Parallel() + + const groupID int64 = 741 + 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 := &GatewayService{accountRepo: splitCodexModelsAccountRepo{ + schedulable: map[int64][]Account{groupID: {schedulable}}, + catalog: map[int64][]Account{groupID: {schedulable, unschedulable}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, 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: deleting an account can widen the advertised contract. +func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemoved(t *testing.T) { + t.Parallel() + + const groupID int64 = 742 + remaining := newCodexCatalogMappedAccount( + 41, + "gpt-5.6-sol", + "GPT-5.6 Sol", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 1_000_000, + true, + nil, + ) + svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{ + schedulable: map[int64][]Account{groupID: {remaining}}, + catalog: map[int64][]Account{groupID: {remaining}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) +} + +func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFails(t *testing.T) { + t.Parallel() + + const groupID int64 = 743 + repo := &countingCodexModelsAccountRepo{ + accounts: []Account{newCodexCatalogMappedAccount( + 41, + "gpt-5.6-sol", + "GPT-5.6 Sol", + []string{"low", "medium", "high", "xhigh"}, + []string{"text", "image"}, + 1_000_000, + true, + nil, + )}, + listByGroupErr: errors.New("group listing unavailable"), + } + svc := &GatewayService{accountRepo: repo} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) +} + +// Scenario: a Composite alias claimed across platforms remains ambiguous and fails closed. +func TestBuildCodexModelsManifestForGroupKeepsCrossPlatformAliasAmbiguityClosed(t *testing.T) { + t.Parallel() + + const groupID int64 = 740 + reasoning := true + newAccount := func(id int64, platform, target string) Account { + account := Account{ + ID: id, Platform: platform, Type: AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{"shared-alias": target}}, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + target: { + ID: target, DisplayName: target, Reasoning: &reasoning, + SupportedReasoningLevels: []string{"low", "high"}, + InputModalities: []string{"text", "image"}, + ContextWindow: 128_000, + }, + }}) + return account + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + newAccount(33, PlatformOpenAI, "gpt-5.6-sol"), + newAccount(34, PlatformGrok, "grok-4.6"), + }, + }}} + + 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]["display_name"]) + require.Empty(t, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupDoesNotAdvertiseNoneWhenAccountReasoningConflicts(t *testing.T) { + t.Parallel() + + const groupID int64 = 738 + reasoning := true + noReasoning := false + newAccount := func(id int64, metadata UpstreamModelMetadata) Account { + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"shared-model": "shared-model"}, + }, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "shared-model": metadata, + }}) + return account + } + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: { + newAccount(29, UpstreamModelMetadata{ + ID: "shared-model", Reasoning: &reasoning, + SupportedReasoningLevels: []string{"low", "high"}, + InputModalities: []string{"text"}, ContextWindow: 128_000, + }), + newAccount(30, UpstreamModelMetadata{ + ID: "shared-model", Reasoning: &noReasoning, + InputModalities: []string{"text"}, ContextWindow: 128_000, + }), + }, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + _, hasDefault := models[0]["default_reasoning_level"] + require.False(t, hasDefault) + require.Empty(t, models[0]["supported_reasoning_levels"]) +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 7c06dc99bd..95be9ebbaf 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -16,8 +16,11 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "golang.org/x/net/http2" "golang.org/x/sync/singleflight" ) @@ -32,14 +35,1191 @@ const ( codexModelsManifestCacheTTL = 30 * time.Second codexModelsManifestCacheStaleTTL = 5 * time.Minute codexModelsManifestRequestTimeout = 15 * time.Second + codexAutoModelPrefix = "codex-auto-" ) +// FilterCodexModelIDsForGroup removes dedicated media-generation models, +// wildcard mapping keys, and Codex automatic modes from a client catalog. +// Automatic modes are retained only when the group's enabled custom model list +// explicitly selects the exact slug; account model mappings describe routing +// and are not feature opt-ins. Wildcard keys such as "foo-*" are routing +// patterns, not concrete Codex models. +func FilterCodexModelIDsForGroup(modelIDs []string, group *Group) []string { + explicitlyEnabled := make(map[string]struct{}) + if group != nil && group.CustomModelsListEnabled() { + for _, modelID := range group.ModelsListConfig.Models { + modelID = strings.TrimSpace(modelID) + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + explicitlyEnabled[modelID] = struct{}{} + } + } + } + + filtered := make([]string, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + if isCodexDedicatedMediaModel(modelID) { + continue + } + if strings.Contains(modelID, "*") { + continue + } + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + if _, ok := explicitlyEnabled[modelID]; !ok { + continue + } + } + filtered = append(filtered, modelID) + } + return filtered +} + +func isCodexDedicatedMediaModel(modelID string) bool { + canonical := codexProviderQualifiedModelID(modelID) + return IsGPTImageGenerationModel(canonical) || + isImageGenerationModel(canonical) || + xai.IsGrokImagineModel(modelID) +} + +func codexProviderQualifiedModelID(modelID string) string { + modelID = strings.TrimSpace(modelID) + if slash := strings.LastIndexByte(modelID, '/'); slash >= 0 { + modelID = strings.TrimSpace(modelID[slash+1:]) + } + return strings.TrimPrefix(modelID, "models/") +} + // CodexModelsManifest carries the client representation plus caching metadata. type CodexModelsManifest struct { - Body []byte - ETag string - upstreamETag string - NotModified bool + Body []byte + ETag string + upstreamETag string + upstreamSourceBody []byte + convertedFromOpenAIModelList bool + NotModified bool +} + +// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog exclusively +// from the public model names configured on accounts in an OpenAI group. The +// boolean result distinguishes "no explicit configuration" from a configured +// catalog that becomes empty after group-level filtering. +func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( + ctx context.Context, + group *Group, + ifNoneMatch string, +) (*CodexModelsManifest, bool, error) { + if s == nil || s.accountRepo == nil || group == nil || group.Platform != PlatformOpenAI { + return nil, false, nil + } + + 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 := openAIConfiguredCodexModelIDsForGroup(visible, group) + if len(configuredModels) == 0 { + return nil, false, nil + } + + body, err := buildCodexModelsManifestForAccounts( + PlatformOpenAI, + configuredModels, + catalog, + nil, + true, + ) + if err != nil { + return nil, false, fmt.Errorf("initialize group configured Codex models: %w", err) + } + body, _, err = mergeConfiguredCodexModelsManifest( + body, + nil, + group.ModelsListConfig.Models, + group.CustomModelsListEnabled(), + ) + if err != nil { + return nil, false, fmt.Errorf("build group configured Codex models: %w", err) + } + manifest := &CodexModelsManifest{ + Body: body, + ETag: codexModelsManifestBodyETag(body), + } + if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { + manifest.Body = nil + manifest.NotModified = true + } + return manifest, true, nil +} + +// MergeGroupConfiguredCodexModels adds account model aliases that are visible +// to the authenticated OpenAI group without discarding metadata from upstream +// Codex model entries. A group's custom models list also filters the picker, +// matching the standard /v1/models display policy. +func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( + ctx context.Context, + group *Group, + manifest *CodexModelsManifest, + ifNoneMatch string, +) error { + if s == nil || s.accountRepo == nil || group == nil || manifest == nil || manifest.NotModified { + return nil + } + if group.Platform != PlatformOpenAI || len(manifest.Body) == 0 { + return nil + } + + configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group) + if err != nil { + return fmt.Errorf("load group configured Codex models: %w", err) + } + body, changed, err := mergeConfiguredCodexModelsManifest( + manifest.Body, + configuredModels, + group.ModelsListConfig.Models, + group.CustomModelsListEnabled(), + ) + if err != nil { + return fmt.Errorf("merge group configured Codex models: %w", err) + } + if changed { + manifest.Body = body + manifest.ETag = codexModelsManifestBodyETag(body) + } + if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { + manifest.Body = nil + manifest.NotModified = true + } + return nil +} + +func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, group *Group) ([]string, error) { + if group == nil { + return nil, nil + } + accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, group.ID) + if err != nil { + return nil, err + } + return openAIConfiguredCodexModelIDsForGroup(accounts, group), 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) + for i := range accounts { + account := &accounts[i] + if account.Platform != PlatformOpenAI { + continue + } + for modelID := range account.GetModelMapping() { + modelID = strings.TrimSpace(modelID) + if modelID == "" || strings.Contains(modelID, "*") { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + models = append(models, modelID) + } + } + sort.Strings(models) + return models +} + +func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string { + models := openAIConfiguredCodexModelIDs(accounts) + if group == nil || !group.CustomModelsListEnabled() { + return models + } + + seen := make(map[string]struct{}, len(models)+len(group.ModelsListConfig.Models)) + for _, modelID := range models { + seen[modelID] = struct{}{} + } + for _, selectedModel := range group.ModelsListConfig.Models { + selectedModel = strings.TrimSpace(selectedModel) + if selectedModel == "" || strings.Contains(selectedModel, "*") { + continue + } + for i := range accounts { + account := &accounts[i] + if account.Platform != PlatformOpenAI { + continue + } + mappedModel, matched := account.ResolveMappedModel(selectedModel) + if !matched || strings.TrimSpace(mappedModel) == "" { + continue + } + if _, exists := seen[selectedModel]; !exists { + seen[selectedModel] = struct{}{} + models = append(models, selectedModel) + } + break + } + } + sort.Strings(models) + return models +} + +const ( + configuredCodexModelPriority = 50 + configuredCodexCustomDescription = "Custom model routed through Sub2API." + configuredCodexFallbackContext = 272_000 + configuredCodexDeepSeekV4Context = 1_000_000 + configuredCodexGrokContext = 500_000 + configuredCodexGrokBuildContext = 256_000 + configuredCodexGPT56MaxContext = 872_000 + configuredCodexToolOutputMaxTokens = 10_000 +) + +type configuredCodexReasoningLevel struct { + Effort string `json:"effort"` + Description string `json:"description"` +} + +type configuredCodexTruncationPolicy struct { + Mode string `json:"mode"` + Limit int64 `json:"limit"` +} + +type configuredCodexModelMessages struct { + InstructionsTemplate string `json:"instructions_template"` + InstructionsVariables any `json:"instructions_variables"` + Approvals any `json:"approvals"` + CollaborationModes any `json:"collaboration_modes"` + AutoReview any `json:"auto_review"` + Permissions any `json:"permissions"` + MultiAgent any `json:"multi_agent"` + TokenBudget any `json:"token_budget"` + GuardianV2 any `json:"guardian_v2"` +} + +// configuredCodexModelDescriptor is the minimum complete ModelInfo contract +// understood by current Codex clients. Several nullable fields are intentionally +// emitted: unlike ordinary OpenAI /v1/models entries, the Codex manifest parser +// requires them to be present. +type configuredCodexModelDescriptor struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + DefaultReasoningLevel *string `json:"default_reasoning_level,omitempty"` + SupportedReasoningLevels []configuredCodexReasoningLevel `json:"supported_reasoning_levels"` + ShellType string `json:"shell_type"` + Visibility string `json:"visibility"` + SupportedInAPI bool `json:"supported_in_api"` + Priority int `json:"priority"` + AdditionalSpeedTiers []string `json:"additional_speed_tiers"` + ServiceTiers []any `json:"service_tiers"` + DefaultServiceTier any `json:"default_service_tier"` + AvailabilityNUX any `json:"availability_nux"` + Upgrade any `json:"upgrade"` + ModelMessages configuredCodexModelMessages `json:"model_messages"` + IncludeSkillsUsageInstructions bool `json:"include_skills_usage_instructions"` + IncludePluginUsageInstructions bool `json:"include_plugin_usage_instructions"` + IncludeAppsUsageInstructions bool `json:"include_apps_usage_instructions"` + SupportsReasoningSummaryParameter bool `json:"supports_reasoning_summary_parameter"` + DefaultReasoningSummary string `json:"default_reasoning_summary"` + SupportVerbosity bool `json:"support_verbosity"` + DefaultVerbosity *string `json:"default_verbosity"` + ApplyPatchToolType *string `json:"apply_patch_tool_type"` + WebSearchToolType string `json:"web_search_tool_type"` + TruncationPolicy configuredCodexTruncationPolicy `json:"truncation_policy"` + SupportsImageDetailOriginal bool `json:"supports_image_detail_original"` + SupportsParallelToolCalls bool `json:"supports_parallel_tool_calls"` + ContextWindow int64 `json:"context_window"` + MaxContextWindow int64 `json:"max_context_window"` + AutoCompactTokenLimit any `json:"auto_compact_token_limit"` + CompHash any `json:"comp_hash"` + EffectiveContextWindowPercent int64 `json:"effective_context_window_percent"` + ExperimentalSupportedTools []string `json:"experimental_supported_tools"` + InputModalities []string `json:"input_modalities"` + SupportsSearchTool bool `json:"supports_search_tool"` + UseResponsesLite bool `json:"use_responses_lite"` + NodeREPLAutoReviewRequired bool `json:"node_repl_auto_review_required"` + NodeREPLDisabled bool `json:"node_repl_disabled"` + AutoReviewModelOverride any `json:"auto_review_model_override"` + ModelSpecialty any `json:"model_specialty"` + ToolMode any `json:"tool_mode"` + MultiAgentVersion any `json:"multi_agent_version"` +} + +type codexModelMetadataOverride struct { + UpstreamModelMetadata + reasoningConflict bool + inputModalitiesConflict bool +} + +func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescriptor { + modelID = strings.TrimSpace(modelID) + noReasoningLevel := "none" + descriptor := configuredCodexModelDescriptor{ + Slug: modelID, + DisplayName: modelID, + Description: configuredCodexCustomDescription, + DefaultReasoningLevel: &noReasoningLevel, + SupportedReasoningLevels: []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, + ShellType: "unified_exec", + Visibility: "list", + SupportedInAPI: true, + Priority: configuredCodexModelPriority, + AdditionalSpeedTiers: []string{}, + ServiceTiers: []any{}, + ModelMessages: configuredCodexModelMessages{InstructionsTemplate: openai.CodexBaseInstructionsForModel(modelID)}, + SupportsReasoningSummaryParameter: true, + DefaultReasoningSummary: "auto", + WebSearchToolType: "text", + TruncationPolicy: configuredCodexTruncationPolicy{Mode: "bytes", Limit: configuredCodexToolOutputMaxTokens}, + ContextWindow: configuredCodexFallbackContext, + MaxContextWindow: configuredCodexFallbackContext, + EffectiveContextWindowPercent: 95, + ExperimentalSupportedTools: []string{}, + InputModalities: []string{"text"}, + } + + if isDeepSeekCodexModel(modelID) { + defaultReasoningLevel := "high" + descriptor.DisplayName = deepSeekCodexDisplayName(modelID) + descriptor.Description = "DeepSeek coding and reasoning model routed through Sub2API." + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "max", Description: "Maximum reasoning depth for complex tasks"}, + } + descriptor.SupportsParallelToolCalls = true + descriptor.ContextWindow = configuredCodexDeepSeekV4Context + descriptor.MaxContextWindow = configuredCodexDeepSeekV4Context + } + + if isGrokCodexModel(modelID) { + descriptor.DisplayName = grokCodexDisplayName(modelID) + descriptor.Description = "Grok coding and reasoning model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + descriptor.ContextWindow = grokCodexContextWindow(modelID) + descriptor.MaxContextWindow = descriptor.ContextWindow + if grokCodexSupportsReasoningEffort(modelID) { + defaultReasoningLevel := "high" + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = configuredCodexGrokReasoningLevels(modelID) + } + } + + if isClaudeCodexModel(modelID) { + descriptor.DisplayName = claudeCodexDisplayName(modelID) + descriptor.Description = "Claude coding and reasoning model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + if levels := configuredCodexClaudeReasoningLevels(modelID); len(levels) > 0 { + defaultReasoningLevel := claudeCodexDefaultReasoningLevel(levels) + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = levels + } + } + + if isOpenAICodexGPTModel(modelID) { + descriptor.DisplayName = openaiCodexDisplayName(modelID) + descriptor.Description = "OpenAI GPT coding model routed through Sub2API." + descriptor.SupportsParallelToolCalls = true + if isOpenAICodexReasoningGPTModel(modelID) { + defaultReasoningLevel := "medium" + if getNormalizedCodexModel(modelID) == "gpt-5.6-sol" { + defaultReasoningLevel = "low" + } + descriptor.DefaultReasoningLevel = &defaultReasoningLevel + descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID) + descriptor.DefaultReasoningSummary = "none" + descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens} + if isOpenAIGPT56Model(modelID) { + descriptor.MaxContextWindow = configuredCodexGPT56MaxContext + } + } + if SupportsVerbosity(modelID) { + defaultVerbosity := "low" + descriptor.SupportVerbosity = true + descriptor.DefaultVerbosity = &defaultVerbosity + } + } + + return descriptor +} + +func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel { + levels := []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "medium", Description: "Balanced reasoning for most coding tasks"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + } + if grokSupportsXHighReasoningEffort(modelID) { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "xhigh", + Description: "Extra-high reasoning depth for difficult tasks", + }) + } + return levels +} + +func configuredCodexClaudeReasoningLevels(modelID string) []configuredCodexReasoningLevel { + descriptions := map[string]string{ + "low": "Fast responses with lighter reasoning", + "medium": "Balanced reasoning for most coding tasks", + "high": "Greater reasoning depth for coding and agent tasks", + "xhigh": "Extra-high reasoning depth for difficult tasks", + "max": "Maximum reasoning depth for complex tasks", + } + levels := claude.EffortLevelsForModel(modelID) + out := make([]configuredCodexReasoningLevel, 0, len(levels)) + for _, effort := range levels { + out = append(out, configuredCodexReasoningLevel{ + Effort: effort, + Description: descriptions[effort], + }) + } + return out +} + +func claudeCodexDefaultReasoningLevel(levels []configuredCodexReasoningLevel) string { + for _, preferred := range []string{"medium", "high", "low"} { + for _, level := range levels { + if level.Effort == preferred { + return preferred + } + } + } + if len(levels) == 0 { + return "" + } + return levels[0].Effort +} + +func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasoningLevel { + levels := []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "medium", Description: "Balanced reasoning for most coding tasks"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, + } + normalized := getNormalizedCodexModel(modelID) + if isOpenAIGPT56Model(modelID) { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "max", + Description: "Maximum reasoning depth for complex tasks", + }) + } + if normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { + levels = append(levels, configuredCodexReasoningLevel{ + Effort: "ultra", + Description: "Maximum reasoning with automatic task delegation", + }) + } + return levels +} + +func isOpenAICodexGPTModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + if normalized == "" || strings.HasPrefix(normalized, "gpt-image") { + return false + } + return strings.HasPrefix(normalized, "gpt-") +} + +func isOpenAICodexReasoningGPTModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + return strings.HasPrefix(normalized, "gpt-5") +} + +func isOpenAICodexImageInputModel(modelID string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + return strings.HasPrefix(normalized, "gpt-5") || + strings.HasPrefix(normalized, "gpt-4o") || + strings.HasPrefix(normalized, "gpt-4.1") || + strings.HasPrefix(normalized, "gpt-4.5") || + strings.HasPrefix(normalized, "gpt-4-turbo") || + strings.HasPrefix(normalized, "gpt-4-vision") +} + +func isOfficialOpenAICodexCatalogModel(modelID string) bool { + normalized := strings.ToLower(codexProviderQualifiedModelID(modelID)) + if normalized == "" || isCodexDedicatedMediaModel(normalized) { + return false + } + if strings.HasPrefix(normalized, "codex-") { + return true + } + if strings.HasPrefix(normalized, "o1") || strings.HasPrefix(normalized, "o3") || strings.HasPrefix(normalized, "o4") { + return true + } + if !strings.HasPrefix(normalized, "gpt-") { + return false + } + for _, incompatibleFamily := range []string{"audio", "realtime", "transcribe", "tts"} { + if strings.Contains(normalized, incompatibleFamily) { + return false + } + } + return true +} + +func openaiCodexDisplayName(modelID string) string { + normalized := canonicalizeOpenAIModelAliasSpelling(modelID) + if normalized == "" { + return modelID + } + for _, model := range openai.DefaultModels { + if strings.EqualFold(model.ID, normalized) && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + return modelID +} + +func deepSeekCodexDisplayName(modelID string) string { + switch strings.ToLower(strings.TrimSpace(modelID)) { + case "deepseek-v4-pro", "deepseek-4-pro": + return "DeepSeek V4 Pro" + case "deepseek-v4-flash", "deepseek-4-flash": + return "DeepSeek V4 Flash" + default: + return modelID + } +} + +func isDeepSeekCodexModel(modelID string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "deepseek-") +} + +func isGrokCodexModel(modelID string) bool { + return xai.IsGrokModelID(modelID) +} + +func grokCodexSupportsReasoningEffort(modelID string) bool { + if grokSupportsReasoningEffort(modelID) { + return true + } + canonical := xai.ResolveGrokTextResponsesModelID(modelID) + if canonical == "" || strings.EqualFold(canonical, modelID) { + return false + } + return grokSupportsReasoningEffort(canonical) +} + +func grokCodexDisplayName(modelID string) string { + normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID))) + if normalized == "" { + return modelID + } + if name := grokDefaultDisplayName(normalized); name != "" { + return name + } + canonical := strings.ToLower(xai.ResolveGrokTextResponsesModelID(normalized)) + if canonical != "" && canonical != normalized { + if name := grokDefaultDisplayName(canonical); name != "" { + return name + } + } + return modelID +} + +func grokDefaultDisplayName(modelID string) string { + for _, model := range xai.DefaultModels() { + if model.ID == modelID { + return strings.TrimSpace(model.DisplayName) + } + } + return "" +} + +func grokCodexContextWindow(modelID string) int64 { + normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID))) + if strings.HasPrefix(normalized, "grok-build") { + return configuredCodexGrokBuildContext + } + return configuredCodexGrokContext +} + +func isClaudeCodexModel(modelID string) bool { + platform, detected := DetectModelPlatform(modelID) + return detected && platform == PlatformAnthropic +} + +func claudeCodexDisplayName(modelID string) string { + normalized := strings.ToLower(codexProviderQualifiedModelID(modelID)) + normalized = strings.TrimPrefix(normalized, "anthropic.") + if normalized == "" { + return modelID + } + for _, model := range claude.DefaultModels { + if strings.EqualFold(model.ID, normalized) && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + if canonical, ok := claude.ModelIDOverrides[normalized]; ok { + for _, model := range claude.DefaultModels { + if model.ID == canonical && strings.TrimSpace(model.DisplayName) != "" { + return model.DisplayName + } + } + } + return modelID +} + +// BuildCodexModelsManifest builds a standalone Codex model catalog for models +// 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, nil, nil) +} + +// BuildCodexModelsManifestForGroup derives input capabilities from the +// 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, + platformOverride string, + modelIDs []string, +) ([]byte, error) { + if s == nil || s.accountRepo == nil || group == nil { + return BuildCodexModelsManifest(modelIDs) + } + effectivePlatform := strings.TrimSpace(platformOverride) + if effectivePlatform == "" { + effectivePlatform = group.Platform + } + if effectivePlatform != PlatformComposite && !isConcreteRequestPlatform(effectivePlatform) { + return BuildCodexModelsManifest(modelIDs) + } + + _, catalog, err := loadCodexGroupCatalogAccounts(ctx, s.accountRepo, group.ID) + if err != nil { + return BuildCodexModelsManifest(modelIDs) + } + var compositeRoutes []CompositeModelRoute + compositeRoutesAvailable := true + if effectivePlatform == PlatformComposite && s.compositeResolver != nil && s.compositeResolver.repo != nil { + compositeRoutes, err = s.compositeResolver.repo.ListByGroup(ctx, group.ID, false) + if err != nil { + compositeRoutesAvailable = false + } + } + return buildCodexModelsManifestForAccounts( + effectivePlatform, + modelIDs, + catalog, + compositeRoutes, + compositeRoutesAvailable, + ) +} + +func buildCodexModelsManifestForAccounts( + effectivePlatform string, + modelIDs []string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) ([]byte, error) { + imageInputModels := make(map[string]bool, len(modelIDs)) + metadataModels := codexCatalogMetadataModels( + effectivePlatform, + modelIDs, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + modelMetadata := make(map[string]codexModelMetadataOverride, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if groupCodexModelSupportsImageInput( + effectivePlatform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) { + imageInputModels[modelID] = true + } + if metadata, ok := groupCodexModelMetadata( + effectivePlatform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ); ok { + modelMetadata[modelID] = metadata + } + } + return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels, modelMetadata) +} + +func buildCodexModelsManifest( + modelIDs []string, + imageInputModels map[string]bool, + metadataModels map[string]string, + modelMetadata map[string]codexModelMetadataOverride, +) ([]byte, error) { + seen := make(map[string]struct{}, len(modelIDs)) + models := make([]configuredCodexModelDescriptor, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + metadataModelID := strings.TrimSpace(metadataModels[modelID]) + if metadataModelID == "" { + metadataModelID = modelID + } + if isCodexDedicatedMediaModel(modelID) || isCodexDedicatedMediaModel(metadataModelID) { + continue + } + seen[modelID] = struct{}{} + descriptor := newConfiguredCodexModelDescriptor(metadataModelID) + descriptor.Slug = modelID + if imageInputModels[modelID] { + descriptor.InputModalities = []string{"text", "image"} + } + if metadata, ok := modelMetadata[modelID]; ok { + applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata) + } + if metadataModelID != modelID { + descriptor.DisplayName = modelID + descriptor.Description = configuredCodexCustomDescription + } + models = append(models, descriptor) + } + return json.Marshal(struct { + Models []configuredCodexModelDescriptor `json:"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( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) bool { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return false + } + upstreamModel := modelID + if platform == PlatformComposite { + var resolved bool + platform, upstreamModel, resolved = resolveCodexCompositeModelTarget( + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if !resolved { + return false + } + } + if platform != PlatformOpenAI && platform != PlatformGrok { + return false + } + + candidates := 0 + for i := range accounts { + account := &accounts[i] + if account.Platform != platform || !account.IsModelSupported(upstreamModel) { + continue + } + candidates++ + if !accountCodexModelSupportsImageInput(account, account.GetMappedModel(upstreamModel)) { + return false + } + } + return candidates > 0 +} + +func resolveCodexCompositeModelTarget( + modelID string, + accounts []Account, + routes []CompositeModelRoute, + routesAvailable bool, +) (string, string, bool) { + if !routesAvailable { + return "", "", false + } + if route, matched := matchCompositeRoute(routes, modelID, CompositeRouteEndpointResponses); matched { + upstreamModel := strings.TrimSpace(route.UpstreamModel) + if upstreamModel == "" { + upstreamModel = modelID + } + return route.TargetPlatform, upstreamModel, true + } + if codexCompositeRouteMatchesModel(routes, modelID) { + return "", "", false + } + + claimedPlatforms := make(map[string]struct{}) + for _, account := range accounts { + platform := strings.TrimSpace(account.Platform) + if !isConcreteRequestPlatform(platform) || !codexExplicitModelMappingClaims(account, modelID) { + continue + } + claimedPlatforms[platform] = struct{}{} + } + if len(claimedPlatforms) > 1 { + return "", "", false + } + for platform := range claimedPlatforms { + return platform, modelID, true + } + + platform, detected := DetectModelPlatform(modelID) + if !detected { + return "", "", false + } + return platform, modelID, true +} + +func codexCompositeRouteMatchesModel(routes []CompositeModelRoute, modelID string) bool { + for _, route := range routes { + publicModel := strings.TrimSpace(route.PublicModel) + if publicModel == "" { + continue + } + switch normalizeCompositeRouteMatchType(route.MatchType) { + case CompositeRouteMatchPrefix: + if strings.HasPrefix(modelID, publicModel) { + return true + } + default: + if modelID == publicModel { + return true + } + } + } + return false +} + +func codexExplicitModelMappingClaims(account Account, modelID string) bool { + if account.Credentials == nil || strings.TrimSpace(modelID) == "" { + return false + } + mapped := strings.TrimSpace(account.GetModelMapping()[modelID]) + return mapped != "" +} + +func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) bool { + if account == nil { + return false + } + switch account.Platform { + case PlatformOpenAI: + if !isOpenAICodexImageInputModel(upstreamModel) { + return false + } + if account.IsOpenAIOAuth() { + return true + } + if !account.IsOpenAIApiKey() { + return false + } + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) + case PlatformGrok: + if !isOfficialGrokCodexBaseURL(account.GetGrokBaseURL()) { + return false + } + canonical := xai.ResolveGrokTextResponsesModelID(upstreamModel) + return isGrokCodexImageInputModel(canonical) + default: + return false + } +} + +func isGrokCodexImageInputModel(model string) bool { + switch strings.ToLower(strings.TrimSpace(model)) { + case "grok-4.3", + "grok-4.5", + "grok-4.6", + "grok-build-0.1", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-4.20-multi-agent-0309": + return true + default: + return false + } +} + +func isOfficialGrokCodexBaseURL(raw string) bool { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" { + return false + } + return xai.IsOfficialBaseURLHost(strings.TrimSuffix(parsed.Hostname(), ".")) +} + +// BuildDeepSeekCodexModelsManifest preserves the historical entry point for +// callers that still use the provider-specific function name. +func BuildDeepSeekCodexModelsManifest(modelIDs []string) ([]byte, error) { + return BuildCodexModelsManifest(modelIDs) +} + +func mergeConfiguredCodexModelsManifest( + body []byte, + configuredModels []string, + selectedModels []string, + filterBySelection bool, +) ([]byte, bool, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, false, err + } + var upstreamModels []json.RawMessage + if err := json.Unmarshal(envelope["models"], &upstreamModels); err != nil { + return nil, false, err + } + + selected := make(map[string]struct{}, len(selectedModels)) + for _, modelID := range selectedModels { + modelID = strings.TrimSpace(modelID) + if modelID != "" { + selected[modelID] = struct{}{} + } + } + seen := make(map[string]struct{}, len(upstreamModels)+len(configuredModels)) + merged := make([]json.RawMessage, 0, len(upstreamModels)+len(configuredModels)) + changed := false + for _, rawModel := range upstreamModels { + var descriptor struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(rawModel, &descriptor); err != nil || strings.TrimSpace(descriptor.Slug) == "" { + if filterBySelection { + changed = true + continue + } + merged = append(merged, rawModel) + continue + } + descriptor.Slug = strings.TrimSpace(descriptor.Slug) + if isCodexDedicatedMediaModel(descriptor.Slug) { + changed = true + continue + } + if filterBySelection { + if _, allowed := selected[descriptor.Slug]; !allowed { + changed = true + continue + } + } + if strings.HasPrefix(descriptor.Slug, codexAutoModelPrefix) { + _, explicitlyEnabled := selected[descriptor.Slug] + explicitlyEnabled = filterBySelection && explicitlyEnabled + if !explicitlyEnabled { + changed = true + continue + } + visibleModel, visibilityChanged, err := codexModelWithVisibility(rawModel, "list") + if err != nil { + return nil, false, err + } + rawModel = visibleModel + changed = changed || visibilityChanged + } + seen[descriptor.Slug] = struct{}{} + merged = append(merged, rawModel) + } + + for _, modelID := range configuredModels { + if isCodexDedicatedMediaModel(modelID) { + continue + } + if filterBySelection { + if _, allowed := selected[modelID]; !allowed { + continue + } + } + if strings.HasPrefix(modelID, codexAutoModelPrefix) { + if _, explicitlyEnabled := selected[modelID]; !filterBySelection || !explicitlyEnabled { + continue + } + } + if _, exists := seen[modelID]; exists { + continue + } + rawModel, err := json.Marshal(newConfiguredCodexModelDescriptor(modelID)) + if err != nil { + return nil, false, err + } + merged = append(merged, rawModel) + seen[modelID] = struct{}{} + changed = true + } + if !changed { + return body, false, nil + } + + rawModels, err := json.Marshal(merged) + if err != nil { + return nil, false, err + } + envelope["models"] = rawModels + mergedBody, err := json.Marshal(envelope) + if err != nil { + return nil, false, err + } + return mergedBody, true, nil +} + +func codexModelWithVisibility(rawModel json.RawMessage, visibility string) (json.RawMessage, bool, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(rawModel, &fields); err != nil { + return nil, false, err + } + var current string + if rawVisibility, ok := fields["visibility"]; ok { + if err := json.Unmarshal(rawVisibility, ¤t); err == nil && current == visibility { + return rawModel, false, nil + } + } + rawVisibility, err := json.Marshal(visibility) + if err != nil { + return nil, false, err + } + fields["visibility"] = rawVisibility + updated, err := json.Marshal(fields) + if err != nil { + return nil, false, err + } + return updated, true, nil } type codexModelsManifestUpstreamError struct { @@ -55,12 +1235,13 @@ func (e *codexModelsManifestUpstreamError) Error() string { return e.err.Error() func (e *codexModelsManifestUpstreamError) Unwrap() error { return e.err } // IsRetryableCodexModelsManifestError reports whether another selected account -// may succeed without changing the request. Configuration and upstream 4xx -// responses, except 429 and ChatGPT-backend 401, are intentionally not -// retried. A manifest 401 from the ChatGPT Codex backend reflects the selected -// OAuth account's upstream token rather than the client request (the client's -// own API key was already validated locally), so a different account may still -// serve the manifest. Custom API key upstreams keep the old no-failover 401 +// may succeed without changing the request. API key upstream 404/405 responses +// mean that the selected account does not expose a model-discovery endpoint, so +// another account may still serve the manifest. Other upstream 4xx responses, +// except 429 and ChatGPT-backend 401, are intentionally not retried. A manifest +// 401 from the ChatGPT Codex backend reflects the selected OAuth account's +// upstream token rather than the client request (the client's own API key was +// already validated locally). Custom API key upstreams keep the no-failover 401 // behavior because their /models auth semantics are not authoritative for the // account. func IsRetryableCodexModelsManifestError(err error) bool { @@ -68,6 +1249,14 @@ func IsRetryableCodexModelsManifestError(err error) bool { return errors.As(err, &upstreamErr) && upstreamErr.retryable } +func isRetryableCodexModelsManifestStatus(statusCode int, useAPIKeyUpstream bool) bool { + return (useAPIKeyUpstream && + (statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed)) || + (statusCode == http.StatusUnauthorized && !useAPIKeyUpstream) || + statusCode == http.StatusTooManyRequests || + (statusCode >= http.StatusInternalServerError && statusCode < 600) +} + func isRetryableCodexModelsManifestTransportError(err error) bool { if err == nil || errors.Is(err, context.Canceled) { return false @@ -193,6 +1382,10 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest if manifest == nil || len(manifest.Body) > codexModelsManifestCacheBodyLimit { return } + remainingBodyBudget := codexModelsManifestCacheBodyLimit - len(manifest.Body) + if len(manifest.upstreamSourceBody) > remainingBodyBudget { + return + } c.mu.Lock() defer c.mu.Unlock() if c.entries == nil { @@ -255,14 +1448,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token") } case credAccount.IsOpenAIApiKey(): - baseURL := strings.TrimSpace(credAccount.GetCredential("base_url")) - if baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) { - return nil, infraerrors.New( - http.StatusBadGateway, - "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED", - "Codex models manifest requires a custom API key upstream base URL", - ) - } + baseURL := strings.TrimSpace(credAccount.GetOpenAIBaseURL()) authToken = strings.TrimSpace(credAccount.GetOpenAIApiKey()) if authToken == "" { return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_MISSING", "account has no API key for the Codex models upstream") @@ -508,9 +1694,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont statusCode: resp.StatusCode, headers: resp.Header.Clone(), body: body, - retryable: (resp.StatusCode == http.StatusUnauthorized && !request.useAPIKeyUpstream) || - resp.StatusCode == http.StatusTooManyRequests || - (resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode < 600), + retryable: isRetryableCodexModelsManifestStatus(resp.StatusCode, request.useAPIKeyUpstream), } } @@ -529,8 +1713,11 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } upstreamBody := body + convertedFromOpenAIModelList := false if request.useAPIKeyUpstream { - body = convertOpenAIModelListToCodexManifest(body) + convertedBody := convertOpenAIModelListToCodexManifest(body) + convertedFromOpenAIModelList = !bytes.Equal(convertedBody, body) + body = convertedBody } if err := validateCodexModelsManifestEnvelope(body); err != nil { return nil, &codexModelsManifestUpstreamError{ @@ -544,6 +1731,22 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } if request.useAPIKeyUpstream { + body, err = completeAPIKeyCodexModelsManifestMetadata( + body, + false, + request.credentialAccount != nil && isOfficialOpenAIModelsBaseURL(request.credentialAccount.GetOpenAIBaseURL()), + ) + if err != nil { + return nil, &codexModelsManifestUpstreamError{ + err: infraerrors.Newf( + http.StatusBadGateway, + "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST", + "codex models manifest upstream metadata could not be completed: %v", + err, + ), + retryable: true, + } + } body, err = adjustAPIKeyCodexModelsManifest(body) if err != nil { return nil, &codexModelsManifestUpstreamError{ @@ -558,7 +1761,12 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } etag := resp.Header.Get("ETag") - manifest := &CodexModelsManifest{Body: body, ETag: etag} + manifest := &CodexModelsManifest{ + Body: body, + ETag: etag, + upstreamSourceBody: append([]byte(nil), upstreamBody...), + convertedFromOpenAIModelList: convertedFromOpenAIModelList, + } if request.useAPIKeyUpstream { manifest.upstreamETag = etag if !bytes.Equal(body, upstreamBody) { @@ -573,6 +1781,12 @@ func codexModelsManifestBodyETag(body []byte) string { return fmt.Sprintf(`"%x"`, sum) } +// CodexModelsManifestETag returns the strong ETag for a generated client +// catalog. It is based on the final JSON body after local filtering. +func CodexModelsManifestETag(body []byte) string { + return codexModelsManifestBodyETag(body) +} + var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{ "gpt-5.6-sol": {}, "gpt-5.6-terra": {}, @@ -636,9 +1850,8 @@ func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) { // convertOpenAIModelListToCodexManifest rewrites a standard OpenAI // GET /v1/models response ({"object":"list","data":[{"id":...},...]}) into the -// Codex manifest envelope ({"models":[{"slug":...},...]}) so custom API key -// upstreams that only implement the standard endpoint can serve Codex model -// discovery. Bodies that already carry a top-level models field, are not the +// same complete Codex manifest used by locally generated custom-provider +// catalogs. Bodies that already carry a top-level models field, are not the // standard list shape, or yield no usable model IDs are returned unchanged so // envelope validation reports the original payload. func convertOpenAIModelListToCodexManifest(body []byte) []byte { @@ -659,27 +1872,316 @@ func convertOpenAIModelListToCodexManifest(body []byte) []byte { if err := json.Unmarshal(data, &entries); err != nil { return body } - type codexModelEntry struct { - Slug string `json:"slug"` - } - models := make([]codexModelEntry, 0, len(entries)) + modelIDs := make([]string, 0, len(entries)) for _, entry := range entries { id := strings.TrimSpace(entry.ID) if id == "" { continue } - models = append(models, codexModelEntry{Slug: id}) + modelIDs = append(modelIDs, id) } - if len(models) == 0 { + if len(modelIDs) == 0 { return body } - converted, err := json.Marshal(map[string][]codexModelEntry{"models": models}) + converted, err := BuildCodexModelsManifest(modelIDs) if err != nil { return body } return converted } +// completeAPIKeyCodexModelsManifestMetadata fills fields omitted by standard +// OpenAI-compatible /models endpoints. Existing provider metadata always wins; +// only absent or null values are synthesized. +// CompleteAPIKeyCodexModelsManifestForClient fills the complete ModelInfo +// contract immediately before a group-specific API key manifest is returned. +// The shared upstream cache remains independent from local group policy. +func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *CodexModelsManifest, account *Account) error { + if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 { + return nil + } + body := manifest.Body + if len(manifest.upstreamSourceBody) > 0 { + body = append([]byte(nil), manifest.upstreamSourceBody...) + if manifest.convertedFromOpenAIModelList { + body = convertOpenAIModelListToCodexManifest(body) + } + } + var err error + body, err = applySyncedAPIKeyCodexModelMetadata(body, account, manifest.convertedFromOpenAIModelList) + if err != nil { + return err + } + body, err = completeAPIKeyCodexModelsManifestMetadata( + body, + true, + isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()), + ) + if err != nil { + return err + } + body, err = adjustAPIKeyCodexModelsManifest(body) + if err != nil { + return err + } + manifest.Body = body + manifest.ETag = codexModelsManifestBodyETag(manifest.Body) + return nil +} + +func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwriteLocalDefaults bool) ([]byte, error) { + snapshot := account.GetUpstreamModelMetadataSnapshot() + if snapshot == nil || len(snapshot.Models) == 0 { + return body, nil + } + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode JSON object: %w", err) + } + var models []json.RawMessage + if err := json.Unmarshal(envelope["models"], &models); err != nil { + return nil, fmt.Errorf("decode top-level models array: %w", err) + } + + changed := false + for i, rawModel := range models { + var model map[string]json.RawMessage + if err := json.Unmarshal(rawModel, &model); err != nil || model == nil { + continue + } + var slug string + if err := json.Unmarshal(model["slug"], &slug); err != nil { + continue + } + slug = strings.TrimSpace(slug) + metadata, ok := snapshot.Models[slug] + if !ok { + continue + } + + descriptor := newConfiguredCodexModelDescriptor(slug) + applyUpstreamModelMetadataToCodexDescriptor( + &descriptor, + codexModelMetadataOverride{UpstreamModelMetadata: metadata}, + ) + descriptorBody, err := json.Marshal(descriptor) + if err != nil { + return nil, fmt.Errorf("encode synced model %q: %w", slug, err) + } + var syncedFields map[string]json.RawMessage + if err := json.Unmarshal(descriptorBody, &syncedFields); err != nil { + return nil, fmt.Errorf("decode synced model %q: %w", slug, err) + } + + fields := make([]string, 0, 7) + if strings.TrimSpace(metadata.DisplayName) != "" { + fields = append(fields, "display_name") + } + if strings.TrimSpace(metadata.Description) != "" { + fields = append(fields, "description") + } + if metadata.Reasoning != nil { + fields = append(fields, "default_reasoning_level", "supported_reasoning_levels") + } + if len(normalizeCodexInputModalities(metadata.InputModalities)) > 0 { + fields = append(fields, "input_modalities") + } + if metadata.ContextWindow > 0 { + fields = append(fields, "context_window", "max_context_window") + } + + modelChanged := false + for _, field := range fields { + value, exists := syncedFields[field] + if !exists { + continue + } + current, currentExists := model[field] + current = bytes.TrimSpace(current) + if !overwriteLocalDefaults && currentExists && len(current) > 0 && !bytes.Equal(current, []byte("null")) { + continue + } + if bytes.Equal(current, bytes.TrimSpace(value)) { + continue + } + model[field] = value + modelChanged = true + } + if !modelChanged { + continue + } + encoded, err := json.Marshal(model) + if err != nil { + return nil, fmt.Errorf("encode model %q with synced metadata: %w", slug, err) + } + models[i] = encoded + changed = true + } + if !changed { + return body, nil + } + + encodedModels, err := json.Marshal(models) + if err != nil { + return nil, fmt.Errorf("encode models with synced metadata: %w", err) + } + envelope["models"] = encodedModels + updated, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("encode manifest with synced metadata: %w", err) + } + return updated, nil +} + +func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officialOpenAI bool) ([]byte, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode JSON object: %w", err) + } + var models []json.RawMessage + if err := json.Unmarshal(envelope["models"], &models); err != nil { + return nil, fmt.Errorf("decode top-level models array: %w", err) + } + + changed := false + if officialOpenAI { + filtered := make([]json.RawMessage, 0, len(models)) + for _, rawModel := range models { + var model struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(rawModel, &model); err != nil || strings.TrimSpace(model.Slug) == "" { + filtered = append(filtered, rawModel) + continue + } + if !isOfficialOpenAICodexCatalogModel(model.Slug) { + changed = true + continue + } + filtered = append(filtered, rawModel) + } + models = filtered + } + for i, rawModel := range models { + var model map[string]json.RawMessage + if err := json.Unmarshal(rawModel, &model); err != nil || model == nil { + continue + } + var slug string + if err := json.Unmarshal(model["slug"], &slug); err != nil { + continue + } + slug = strings.TrimSpace(slug) + if slug == "" { + continue + } + + completeDescriptor := completeAll || isDeepSeekCodexModel(slug) + forceOfficialImage := officialOpenAI && isOpenAICodexImageInputModel(slug) + if !completeDescriptor && !forceOfficialImage { + continue + } + + descriptor := newConfiguredCodexModelDescriptor(slug) + if forceOfficialImage { + descriptor.InputModalities = []string{"text", "image"} + descriptor.SupportsImageDetailOriginal = true + } + defaultBody, err := json.Marshal(descriptor) + if err != nil { + return nil, fmt.Errorf("encode default model %q: %w", slug, err) + } + var defaults map[string]json.RawMessage + if err := json.Unmarshal(defaultBody, &defaults); err != nil { + return nil, fmt.Errorf("decode default model %q: %w", slug, err) + } + + modelChanged := false + if completeDescriptor { + merged, err := mergeMissingCodexModelFields(model, defaults) + if err != nil { + return nil, fmt.Errorf("complete model %q: %w", slug, err) + } + modelChanged = merged + } + if forceOfficialImage { + modalities, err := json.Marshal([]string{"text", "image"}) + if err != nil { + return nil, fmt.Errorf("encode input modalities for model %q: %w", slug, err) + } + if !bytes.Equal(bytes.TrimSpace(model["input_modalities"]), modalities) { + model["input_modalities"] = modalities + modelChanged = true + } + imageDetailOriginal := json.RawMessage("true") + if !bytes.Equal(bytes.TrimSpace(model["supports_image_detail_original"]), imageDetailOriginal) { + model["supports_image_detail_original"] = imageDetailOriginal + modelChanged = true + } + } + if !modelChanged { + continue + } + encoded, err := json.Marshal(model) + if err != nil { + return nil, fmt.Errorf("encode completed model %q: %w", slug, err) + } + models[i] = encoded + changed = true + } + if !changed { + return body, nil + } + + encodedModels, err := json.Marshal(models) + if err != nil { + return nil, fmt.Errorf("encode top-level models array: %w", err) + } + envelope["models"] = encodedModels + completed, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("encode JSON object: %w", err) + } + return completed, nil +} + +func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage) (bool, error) { + changed := false + for key, defaultValue := range defaults { + currentValue, exists := current[key] + if !exists || (bytes.Equal(bytes.TrimSpace(currentValue), []byte("null")) && + !bytes.Equal(bytes.TrimSpace(defaultValue), []byte("null"))) { + current[key] = defaultValue + changed = true + continue + } + + var currentObject map[string]json.RawMessage + var defaultObject map[string]json.RawMessage + if err := json.Unmarshal(currentValue, ¤tObject); err != nil || currentObject == nil { + continue + } + if err := json.Unmarshal(defaultValue, &defaultObject); err != nil || defaultObject == nil { + continue + } + nestedChanged, err := mergeMissingCodexModelFields(currentObject, defaultObject) + if err != nil { + return false, err + } + if !nestedChanged { + continue + } + mergedValue, err := json.Marshal(currentObject) + if err != nil { + return false, fmt.Errorf("encode field %q: %w", key, err) + } + current[key] = mergedValue + changed = true + } + return changed, nil +} + func validateCodexModelsManifestEnvelope(body []byte) error { var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); err != nil { @@ -720,6 +2222,20 @@ func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string return fmt.Sprintf("%x", hasher.Sum(nil)) } +func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifest { + if manifest == nil { + return nil + } + cloned := *manifest + if manifest.Body != nil { + cloned.Body = append([]byte(nil), manifest.Body...) + } + if manifest.upstreamSourceBody != nil { + cloned.upstreamSourceBody = append([]byte(nil), manifest.upstreamSourceBody...) + } + return &cloned +} + func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch string) *CodexModelsManifest { if manifest == nil { return nil @@ -727,7 +2243,7 @@ func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch str if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { return &CodexModelsManifest{ETag: manifest.ETag, NotModified: true} } - return manifest + return cloneCodexModelsManifest(manifest) } func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { @@ -752,6 +2268,12 @@ func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { return false } +// CodexModelsManifestETagMatches applies If-None-Match semantics to a Codex +// catalog ETag, including weak and comma-separated validators. +func CodexModelsManifestETagMatches(ifNoneMatch, etag string) bool { + return codexModelsManifestETagMatches(ifNoneMatch, etag) +} + func isOfficialOpenAIModelsBaseURL(raw string) bool { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 503d494857..77a65cfb38 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -28,6 +29,1165 @@ type codexModelsHTTPUpstreamStub struct { do func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) } +type codexModelsVisibilityAccountRepo struct { + AccountRepository + byGroup map[int64][]Account +} + +func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) { + accounts := r.byGroup[groupID] + 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 + listByGroupErr error + calls atomic.Int32 +} + +func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) { + r.calls.Add(1) + if r.err != nil { + return nil, r.err + } + 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 TestFilterCodexModelIDsForGroupOmitsWildcardKeys(t *testing.T) { + t.Parallel() + + got := FilterCodexModelIDsForGroup( + []string{"deepseek-v4-pro", "foo-*", " bar-* ", "gpt-5.5"}, + &Group{Platform: PlatformDeepseek}, + ) + require.Equal(t, []string{"deepseek-v4-pro", "gpt-5.5"}, got) +} + +func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any { + t.Helper() + + var envelope struct { + Models []map[string]any `json:"models"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + return envelope.Models +} + +func codexManifestModelSlugs(t *testing.T, body []byte) []string { + t.Helper() + + models := decodeCodexManifestModels(t, body) + slugs := make([]string, 0, len(models)) + for _, model := range models { + slug, ok := model["slug"].(string) + require.True(t, ok) + slugs = append(slugs, slug) + } + return slugs +} + +func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slug string) { + t.Helper() + + require.Equal(t, slug, model["slug"]) + require.NotEmpty(t, model["display_name"]) + require.NotEmpty(t, model["description"]) + require.Equal(t, "unified_exec", model["shell_type"]) + require.Equal(t, "list", model["visibility"]) + require.Equal(t, true, model["supported_in_api"]) + require.NotNil(t, model["priority"]) + require.Equal(t, []any{}, model["additional_speed_tiers"]) + require.Equal(t, []any{}, model["service_tiers"]) + require.Contains(t, model, "default_service_tier") + require.Contains(t, model, "availability_nux") + require.Contains(t, model, "upgrade") + require.Contains(t, model, "default_verbosity") + require.Contains(t, model, "apply_patch_tool_type") + require.Contains(t, model, "auto_compact_token_limit") + require.Contains(t, model, "comp_hash") + require.Contains(t, model, "auto_review_model_override") + require.Contains(t, model, "model_specialty") + require.Contains(t, model, "tool_mode") + require.Contains(t, model, "multi_agent_version") + require.Equal(t, true, model["supports_reasoning_summary_parameter"]) + require.Contains(t, model, "include_skills_usage_instructions") + require.Contains(t, model, "include_plugin_usage_instructions") + require.Contains(t, model, "include_apps_usage_instructions") + require.Contains(t, model, "supports_image_detail_original") + require.Contains(t, model, "node_repl_auto_review_required") + require.Contains(t, model, "node_repl_disabled") + require.Contains(t, model, "truncation_policy") + require.Contains(t, model, "supports_parallel_tool_calls") + require.Contains(t, model, "experimental_supported_tools") + modelMessages, ok := model["model_messages"].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, modelMessages["instructions_template"]) + for _, key := range []string{ + "instructions_variables", + "approvals", + "collaboration_modes", + "auto_review", + "permissions", + "multi_agent", + "token_budget", + "guardian_v2", + } { + require.Contains(t, modelMessages, key) + } +} + +func effortsFromManifestModel(t *testing.T, model map[string]any) []string { + t.Helper() + + levels, ok := model["supported_reasoning_levels"].([]any) + require.True(t, ok) + efforts := make([]string, 0, len(levels)) + for _, rawLevel := range levels { + level, ok := rawLevel.(map[string]any) + require.True(t, ok) + effort, ok := level["effort"].(string) + require.True(t, ok) + efforts = append(efforts, effort) + } + return efforts +} + +func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t *testing.T) { + t.Parallel() + + deepSeek := newConfiguredCodexModelDescriptor("deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Pro", deepSeek.DisplayName) + require.Equal(t, int64(1_000_000), deepSeek.ContextWindow) + require.Equal(t, int64(1_000_000), deepSeek.MaxContextWindow) + require.NotNil(t, deepSeek.DefaultReasoningLevel) + require.Equal(t, "high", *deepSeek.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "low", Description: "Fast responses with lighter reasoning"}, + {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"}, + {Effort: "max", Description: "Maximum reasoning depth for complex tasks"}, + }, deepSeek.SupportedReasoningLevels) + require.True(t, deepSeek.SupportsParallelToolCalls) + require.Equal(t, []string{"text"}, deepSeek.InputModalities) + + grok := newConfiguredCodexModelDescriptor("grok-4.6") + require.Equal(t, "Grok 4.6", grok.DisplayName) + require.Equal(t, int64(500_000), grok.ContextWindow) + require.Equal(t, int64(500_000), grok.MaxContextWindow) + require.NotNil(t, grok.DefaultReasoningLevel) + require.Equal(t, "high", *grok.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grok.SupportedReasoningLevels)) + require.True(t, grok.SupportsParallelToolCalls) + require.Equal(t, []string{"text"}, grok.InputModalities) + require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"}) + + grokAlias := newConfiguredCodexModelDescriptor("xai/grok-4.6-latest") + require.Equal(t, "Grok 4.6", grokAlias.DisplayName) + require.NotNil(t, grokAlias.DefaultReasoningLevel) + require.Equal(t, "high", *grokAlias.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grokAlias.SupportedReasoningLevels)) + + grok45 := newConfiguredCodexModelDescriptor("grok-4.5") + require.Equal(t, []string{"low", "medium", "high"}, effortsFromConfiguredCodexLevels(grok45.SupportedReasoningLevels)) + + grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning") + require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName) + require.NotNil(t, grokNonReasoning.DefaultReasoningLevel) + require.Equal(t, "none", *grokNonReasoning.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, grokNonReasoning.SupportedReasoningLevels) + + claude := newConfiguredCodexModelDescriptor("claude-opus-4-6") + require.Equal(t, "Claude Opus 4.6", claude.DisplayName) + require.NotNil(t, claude.DefaultReasoningLevel) + require.Equal(t, "medium", *claude.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(claude.SupportedReasoningLevels)) + require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "xhigh"}) + require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.True(t, claude.SupportsParallelToolCalls) + + claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels)) + + providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6") + require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName) + require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(providerQualifiedClaude.SupportedReasoningLevels)) + + claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001") + require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName) + require.NotNil(t, claudeHaiku.DefaultReasoningLevel) + require.Equal(t, "none", *claudeHaiku.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(claudeHaiku.SupportedReasoningLevels)) + + gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol") + require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName) + require.Equal(t, "OpenAI GPT coding model routed through Sub2API.", gpt56.Description) + require.NotNil(t, gpt56.DefaultReasoningLevel) + require.Equal(t, "low", *gpt56.DefaultReasoningLevel) + require.Equal(t, configuredCodexGPTReasoningLevels("gpt-5.6-sol"), gpt56.SupportedReasoningLevels) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt56.SupportedReasoningLevels)) + require.True(t, gpt56.SupportsParallelToolCalls) + require.True(t, gpt56.SupportVerbosity) + require.Equal(t, []string{"text"}, gpt56.InputModalities) + require.Equal(t, int64(872_000), gpt56.MaxContextWindow) + require.Equal(t, configuredCodexTruncationPolicy{Mode: "tokens", Limit: 10_000}, gpt56.TruncationPolicy) + require.NotNil(t, gpt56.DefaultVerbosity) + require.Equal(t, "low", *gpt56.DefaultVerbosity) + require.True(t, gpt56.SupportsReasoningSummaryParameter) + require.Equal(t, "none", gpt56.DefaultReasoningSummary) + + gpt56Luna := newConfiguredCodexModelDescriptor("gpt-5.6-luna") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels)) + require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel) + + gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") + require.Equal(t, "GPT-5.5", gpt55.DisplayName) + require.NotNil(t, gpt55.DefaultReasoningLevel) + require.Equal(t, "medium", *gpt55.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(gpt55.SupportedReasoningLevels)) + require.NotContains(t, gpt55.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"}) + require.NotNil(t, gpt55.DefaultVerbosity) + require.Equal(t, "low", *gpt55.DefaultVerbosity) + + gpt4o := newConfiguredCodexModelDescriptor("gpt-4o") + require.Equal(t, "gpt-4o", gpt4o.DisplayName) + require.NotNil(t, gpt4o.DefaultReasoningLevel) + require.Equal(t, "none", *gpt4o.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(gpt4o.SupportedReasoningLevels)) + require.True(t, gpt4o.SupportsParallelToolCalls) + + image := newConfiguredCodexModelDescriptor("gpt-image-2") + require.Equal(t, "gpt-image-2", image.DisplayName) + require.NotNil(t, image.DefaultReasoningLevel) + require.Equal(t, "none", *image.DefaultReasoningLevel) + require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(image.SupportedReasoningLevels)) + + custom := newConfiguredCodexModelDescriptor("company-coding-model") + require.Equal(t, "company-coding-model", custom.DisplayName) + require.Equal(t, int64(272_000), custom.ContextWindow) + require.NotNil(t, custom.DefaultReasoningLevel) + require.Equal(t, "none", *custom.DefaultReasoningLevel) + require.Equal(t, []configuredCodexReasoningLevel{ + {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")}, + }, custom.SupportedReasoningLevels) + require.False(t, custom.SupportsParallelToolCalls) + require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate) + require.Equal(t, "auto", custom.DefaultReasoningSummary) + require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy) +} + +func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string { + efforts := make([]string, 0, len(levels)) + for _, level := range levels { + efforts = append(efforts, level.Effort) + } + return efforts +} + +// Scenario: 无推理模型可直接选中。 +func TestBuildCodexModelsManifestUsesSingleNoneReasoningChoiceForCustomModel(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{"company-coding-model"}) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "none", models[0]["default_reasoning_level"]) + levels, ok := models[0]["supported_reasoning_levels"].([]any) + require.True(t, ok) + require.Len(t, levels, 1) + firstLevel, ok := levels[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "none", firstLevel["effort"]) +} + +// Scenario: 已知推理模型保留真实档位。 +func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{"gpt-5.6-sol"}) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, "low", models[0]["default_reasoning_level"]) + levels, ok := models[0]["supported_reasoning_levels"].([]any) + require.True(t, ok) + require.Len(t, levels, 6) + firstLevel, ok := levels[0].(map[string]any) + require.True(t, ok) + require.NotEqual(t, "none", firstLevel["effort"]) +} + +// Scenario: 专用图片生成模型不进入 Codex 主模型目录。 +func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{ + "grok-4.6", + "gpt-image-1", + "gpt-image-1.5", + "gpt-image-2", + "openai/gpt-image-2", + "gemini-2.5-flash-image", + "gemini-3.1-flash-image-preview", + "gemini-3-pro-image", + "google/gemini-3-pro-image", + "google/models/gemini-2.5-flash-image-preview", + "grok-imagine-image", + "grok-imagine-video", + "xai/grok-imagine-image-quality", + "grok-4.5", + }) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + slugs := make([]string, 0, len(models)) + for _, model := range models { + slug, _ := model["slug"].(string) + slugs = append(slugs, slug) + } + require.Equal(t, []string{"grok-4.6", "grok-4.5"}, slugs) +} + +func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInput(t *testing.T) { + t.Parallel() + + const groupID int64 = 701 + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 1, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token", + }, + }}, + }}, + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"grok-4.5"}, + ) + require.NoError(t, err) + + 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) { + t.Parallel() + + const groupID int64 = 702 + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + }}, + }}, + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"gpt-5.6-sol"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabilities(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + accounts []Account + modalities []any + }{ + { + name: "official Grok 4.6", + model: "grok-4.6", + accounts: []Account{{ + ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official Grok Build vision host", + model: "grok-build-0.1", + accounts: []Account{{ + ID: 11, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official Grok 4.20 vision model", + model: "grok-4.20-0309-reasoning", + accounts: []Account{{ + ID: 23, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "Grok 3 Mini is text only", + model: "grok-3-mini", + accounts: []Account{{ + ID: 24, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text"}, + }, + { + name: "Grok Composer has only Chat image bridge", + model: "grok-composer-2.5-fast", + accounts: []Account{{ + ID: 12, Platform: PlatformGrok, Type: AccountTypeOAuth, + }}, + modalities: []any{"text"}, + }, + { + name: "custom Grok host", + model: "grok-4.5", + accounts: []Account{{ + ID: 13, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example.test/v1"}, + }}, + modalities: []any{"text"}, + }, + { + name: "malformed Grok host", + model: "grok-4.5", + accounts: []Account{{ + ID: 19, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "::invalid::url"}, + }}, + modalities: []any{"text"}, + }, + { + name: "mixed official and custom Grok candidates", + model: "grok-4.5", + accounts: []Account{ + {ID: 14, Platform: PlatformGrok, Type: AccountTypeOAuth}, + { + ID: 15, Platform: PlatformGrok, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example.test/v1"}, + }, + }, + modalities: []any{"text"}, + }, + { + name: "DeepSeek V4", + model: "deepseek-v4-pro", + accounts: []Account{{ + ID: 16, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text"}, + }, + { + name: "official OpenAI API key", + model: "gpt-5.6-sol", + accounts: []Account{{ + ID: 17, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text", "image"}, + }, + { + name: "official OpenAI legacy text model", + model: "gpt-3.5-turbo", + accounts: []Account{{ + ID: 20, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + }}, + modalities: []any{"text"}, + }, + { + name: "custom OpenAI-compatible host", + model: "gpt-5.6-sol", + accounts: []Account{{ + ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"}, + }}, + modalities: []any{"text"}, + }, + } + + for i, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + groupID := int64(710 + i) + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: tt.accounts, + }}} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{tt.model}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, tt.modalities, models[0]["input_modalities"]) + }) + } +} + +func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) { + t.Parallel() + + const groupID int64 = 730 + routeRepo := compositeRouteRepoStub{routes: []CompositeModelRoute{{ + ID: 1, + GroupID: groupID, + PublicModel: "vision-alias", + MatchType: CompositeRouteMatchExact, + TargetPlatform: PlatformGrok, + UpstreamModel: "grok-4.5", + Endpoint: CompositeRouteEndpointResponses, + Enabled: true, + }}} + svc := &GatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ID: 20, Platform: PlatformGrok, Type: AccountTypeOAuth}}, + }}, + compositeResolver: NewCompositeRouteResolver(routeRepo), + } + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedModel(t *testing.T) { + t.Parallel() + + const groupID int64 = 731 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: {{ + ID: 21, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{"vision-alias": "grok-4.5"}, + }, + }}, + }}} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias"}, + ) + require.NoError(t, err) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + 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, "reasoning-alias", models[0]["display_name"]) + require.Equal(t, "Custom 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() + + const groupID int64 = 732 + repo := &countingCodexModelsAccountRepo{accounts: []Account{{ + ID: 22, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "vision-alias-a": "grok-4.5", + "vision-alias-b": "grok-4.6", + }, + }, + }}} + svc := &GatewayService{accountRepo: repo} + _, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformComposite}, + "", + []string{"vision-alias-a", "vision-alias-b", "deepseek-v4-pro"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) +} + +func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) { + t.Parallel() + + repo := &countingCodexModelsAccountRepo{} + svc := &GatewayService{accountRepo: repo} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: 733, Platform: PlatformDeepseek}, + "", + []string{"deepseek-v4-pro"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupFallsBackWhenCapabilityLookupFails(t *testing.T) { + t.Parallel() + + repo := &countingCodexModelsAccountRepo{err: errors.New("account repository unavailable")} + svc := &GatewayService{accountRepo: repo} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: 734, Platform: PlatformComposite}, + "", + []string{"gpt-5.6-sol", "grok-4.5"}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), repo.calls.Load()) + + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 2) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.Equal(t, []any{"text"}, models[1]["input_modalities"]) +} + +func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) { + t.Parallel() + + const groupID int64 = 71 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "deepseek-4-pro": "deepseek-v4-pro", + }, + }, + }, + }, + 72: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"other-group-model": "upstream-model"}, + }, + }, + }, + }, + }} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`), + } + + err := svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 2) + require.Equal(t, "gpt-5.6", models[0]["slug"]) + require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"]) + requireCompleteConfiguredCodexModel(t, models[1], "deepseek-4-pro") + require.EqualValues(t, 1_000_000, models[1]["context_window"]) + require.EqualValues(t, 1_000_000, models[1]["max_context_window"]) + require.Equal(t, "high", models[1]["default_reasoning_level"]) + require.Len(t, models[1]["supported_reasoning_levels"], 3) + require.NotContains(t, string(manifest.Body), "other-group-model") + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +// Scenario: OpenAI 分组存在账号模型配置时直接生成本地 Codex 清单。 +func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) { + t.Parallel() + + const groupID int64 = 77 + reasoning := true + arkAccount := Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "glm-5.3": "glm-5.3", + "gpt-image-2": "gpt-image-2", + }, + }, + } + arkAccount.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "glm-5.3": { + ID: "glm-5.3", + DisplayName: "GLM 5.3", + Description: "Ark coding model", + Reasoning: &reasoning, + DefaultReasoningLevel: "medium", + SupportedReasoningLevels: []string{"low", "medium", "high"}, + InputModalities: []string{"text"}, + ContextWindow: 1_000_000, + }, + }}) + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + }, + arkAccount, + }, + }, + }} + group := &Group{ID: groupID, Platform: PlatformOpenAI} + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "") + require.NoError(t, err) + require.True(t, configured) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "glm-5.3", models[0]["slug"]) + require.Equal(t, "GLM 5.3", models[0]["display_name"]) + require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, "medium", models[0]["default_reasoning_level"]) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) + + notModified, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( + context.Background(), + group, + "W/"+manifest.ETag, + ) + require.NoError(t, err) + require.True(t, configured) + require.True(t, notModified.NotModified) + require.Empty(t, notModified.Body) + require.Equal(t, manifest.ETag, notModified.ETag) +} + +// Scenario: OpenAI 通配映射展开组内精确选择,但不发布通配符 slug。 +func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWildcardMapping(t *testing.T) { + t.Parallel() + + const groupID int64 = 80 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: {{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"}, + }, + }}, + }, + }} + group := &Group{ + ID: groupID, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"gpt-5.6"}, + }, + } + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "") + require.NoError(t, err) + require.True(t, configured) + require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body)) + require.NotContains(t, string(manifest.Body), "gpt-*") +} + +// 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() + + const groupID int64 = 78 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: {{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}}, + }, + }} + + manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + ) + require.NoError(t, err) + require.False(t, configured) + require.Nil(t, manifest) +} + +func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) { + t.Parallel() + + const groupID int64 = 74 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"codex-auto-future","visibility":"list"},{"slug":"gpt-image-2","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + )) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "gpt-5.6", models[0]["slug"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +// Scenario: OpenAI 账号映射不启用 Auto Review。 +func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault(t *testing.T) { + t.Parallel() + + const groupID int64 = 75 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + openai.CodexUsageProbeModel: openai.CodexUsageProbeModel, + }, + }, + }, + }, + }, + }} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + manifest, + "", + )) + require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body)) +} + +// Scenario: 启用的分组自定义列表允许 Auto Review。 +func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *testing.T) { + t.Parallel() + + const groupID int64 = 76 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), + } + group := &Group{ + ID: groupID, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{openai.CodexUsageProbeModel}, + }, + } + + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) + require.Equal(t, []string{"codex-auto-review"}, codexManifestModelSlugs(t, manifest.Body)) +} + +func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing.T) { + t.Parallel() + + const groupID int64 = 73 + svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + groupID: { + { + Platform: PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "deepseek-4-pro": "deepseek-v4-pro", + "hidden-alias": "hidden-upstream", + }, + }, + }, + }, + }, + }} + group := &Group{ + ID: groupID, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"deepseek-4-pro"}, + }, + } + upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`) + manifest := &CodexModelsManifest{Body: upstreamBody} + + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro") + + finalETag := manifest.ETag + second := &CodexModelsManifest{Body: upstreamBody} + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag)) + require.True(t, second.NotModified) + require.Empty(t, second.Body) + require.Equal(t, finalETag, second.ETag) +} + type codexModelsBlockingBody struct { ctx context.Context readStarted chan struct{} @@ -123,6 +1283,35 @@ func TestIsRetryableCodexModelsManifestTransportError(t *testing.T) { } } +func TestIsRetryableCodexModelsManifestStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + useAPIKeyUpstream bool + retryable bool + }{ + {name: "api key 404", statusCode: http.StatusNotFound, useAPIKeyUpstream: true, retryable: true}, + {name: "api key 405", statusCode: http.StatusMethodNotAllowed, useAPIKeyUpstream: true, retryable: true}, + {name: "oauth 404", statusCode: http.StatusNotFound}, + {name: "oauth 405", statusCode: http.StatusMethodNotAllowed}, + {name: "api key 401", statusCode: http.StatusUnauthorized, useAPIKeyUpstream: true}, + {name: "oauth 401", statusCode: http.StatusUnauthorized, retryable: true}, + {name: "api key 400", statusCode: http.StatusBadRequest, useAPIKeyUpstream: true}, + {name: "api key 403", statusCode: http.StatusForbidden, useAPIKeyUpstream: true}, + {name: "rate limited", statusCode: http.StatusTooManyRequests, retryable: true}, + {name: "server error", statusCode: http.StatusServiceUnavailable, retryable: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.retryable, isRetryableCodexModelsManifestStatus(tt.statusCode, tt.useAPIKeyUpstream)) + }) + } +} + func newCodexModelsAPIKeyTestService(upstream HTTPUpstream) *OpenAIGatewayService { return &OpenAIGatewayService{ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{ @@ -407,7 +1596,7 @@ func TestFetchCodexModelsManifestMissingToken(t *testing.T) { } func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { - manifestBody := `{"models":[{"slug":"gpt-5.6"}]}` + manifestBody := `{"models":[{"slug":"deepseek-v4-pro"}]}` var gotRequest *http.Request var gotProxyURL string var gotAccountID int64 @@ -464,12 +1653,48 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { if gotProxyURL != "" || gotAccountID != 2 || gotConcurrency != 3 { t.Errorf("upstream routing metadata: proxy=%q account_id=%d concurrency=%d", gotProxyURL, gotAccountID, gotConcurrency) } - if string(manifest.Body) != manifestBody { - t.Errorf("body not passed through verbatim: got %q", manifest.Body) - } - if manifest.ETag != `W/"api-key-manifest"` { - t.Errorf("etag not passed through: got %q", manifest.ETag) - } + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Pro", models[0]["display_name"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) + require.Equal(t, `W/"api-key-manifest"`, manifest.upstreamETag) +} + +// Scenario: 完整上游清单没有 ETag 时,最终正文仍生成强 ETag 并支持 304。 +func TestFetchCodexModelsManifestAPIKeyCompleteBodyWithoutUpstreamETagUsesFinalBodyETag(t *testing.T) { + completeBody, err := BuildCodexModelsManifest([]string{"custom-complete-model"}) + require.NoError(t, err) + + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + calls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader(completeBody)), + }, nil + }} + svc := newCodexModelsAPIKeyTestService(upstream) + svc.accountRepo = codexModelsVisibilityAccountRepo{} + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + group := &Group{ID: 82, Platform: PlatformOpenAI} + + first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account)) + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, first, "")) + require.Equal(t, codexModelsManifestBodyETag(first.Body), first.ETag) + require.NotEmpty(t, first.ETag) + + second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account)) + require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, first.ETag)) + require.True(t, second.NotModified) + require.Empty(t, second.Body) + require.Equal(t, int32(1), calls.Load()) } func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) { @@ -494,13 +1719,224 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin if err != nil { t.Fatalf("FetchCodexModelsManifest returned error: %v", err) } - if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"},{"slug":"gpt-5.6-codex"}]}`; got != want { - t.Errorf("converted body: got %q, want %q", got, want) - } + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 2) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6") + requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex") require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) require.Equal(t, `W/"openai-list"`, manifest.upstreamETag) } +func TestConvertOpenAIModelListToCodexManifestUsesCompleteDescriptors(t *testing.T) { + upstreamBody := `{"object":"list","data":[{"id":"gpt-5.5","object":"model"}]}` + + converted := convertOpenAIModelListToCodexManifest([]byte(upstreamBody)) + models := decodeCodexManifestModels(t, converted) + + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.5") + require.Equal(t, "GPT-5.5", models[0]["display_name"]) + require.Equal(t, "medium", models[0]["default_reasoning_level"]) + require.Len(t, models[0]["supported_reasoning_levels"], 4) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{ + Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`), + } + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6") + require.Equal(t, "Provider supplied", models[0]["description"]) + require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"]) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) + require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0])) + modelMessages, ok := models[0]["model_messages"].(map[string]any) + require.True(t, ok) + require.NotEmpty(t, modelMessages["instructions_template"]) + require.Equal(t, map[string]any{"enabled": true}, modelMessages["auto_review"]) + require.Equal(t, map[string]any{"mode": "tokens", "limit": float64(10_000)}, models[0]["truncation_policy"]) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) + + var envelope map[string]any + require.NoError(t, json.Unmarshal(manifest.Body, &envelope)) + require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"]) +} + +// Scenario: 标准 /models 型号列表优先使用已同步账号能力,再使用本地 descriptor 兜底。 +func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConvertedModelList(t *testing.T) { + t.Parallel() + + reasoning := true + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "future-reasoner": { + ID: "future-reasoner", DisplayName: "Future Reasoner", Description: "Synced upstream capability", + Reasoning: &reasoning, DefaultReasoningLevel: "ultra", + SupportedReasoningLevels: []string{"low", "high", "ultra"}, + InputModalities: []string{"text", "image"}, + ContextWindow: 999_000, + }, + }}) + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"future-reasoner","object":"model"}]}`)), + }, nil + }} + svc := newCodexModelsAPIKeyTestService(upstream) + + manifest, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "Future Reasoner", models[0]["display_name"]) + require.Equal(t, "Synced upstream capability", models[0]["description"]) + require.Equal(t, "ultra", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"low", "high", "ultra"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 999_000, models[0]["context_window"]) + require.EqualValues(t, 999_000, models[0]["max_context_window"]) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWithoutOverwritingExplicitMetadata(t *testing.T) { + t.Parallel() + + reasoning := true + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "provider-model": { + ID: "provider-model", DisplayName: "Synced Display", Description: "Synced description", + Reasoning: &reasoning, DefaultReasoningLevel: "ultra", + SupportedReasoningLevels: []string{"high", "ultra"}, + InputModalities: []string{"text", "image"}, + ContextWindow: 999_000, + }, + }}) + manifest := &CodexModelsManifest{Body: []byte(`{"models":[{ + "slug":"provider-model", + "description":"Provider supplied", + "context_window":64000, + "max_context_window":64000 + }]}`)} + svc := &OpenAIGatewayService{} + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + require.Equal(t, "Synced Display", models[0]["display_name"]) + require.Equal(t, "Provider supplied", models[0]["description"]) + require.Equal(t, "ultra", models[0]["default_reasoning_level"]) + require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, models[0])) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) + require.EqualValues(t, 64_000, models[0]["context_window"]) + require.EqualValues(t, 64_000, models[0]["max_context_window"]) +} + +// Scenario: 原生 manifest 的缺失字段在命中缓存后仍使用账号当前同步快照,而不是缓存中的本地默认值。 +func TestCompleteAPIKeyCodexModelsManifestForClientUsesCurrentSnapshotForCachedNativeManifest(t *testing.T) { + t.Parallel() + + reasoning := true + account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") + setSnapshot := func(displayName string, contextWindow int64) { + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "deepseek-v4-pro": { + ID: "deepseek-v4-pro", DisplayName: displayName, + Reasoning: &reasoning, DefaultReasoningLevel: "ultra", + SupportedReasoningLevels: []string{"high", "ultra"}, + InputModalities: []string{"text", "image"}, + ContextWindow: contextWindow, + }, + }}) + } + setSnapshot("Synced DeepSeek", 256_000) + + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + calls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "slug":"deepseek-v4-pro", + "description":"Provider supplied" + }]}`)), + }, nil + }} + svc := newCodexModelsAPIKeyTestService(upstream) + + first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account)) + firstModel := decodeCodexManifestModels(t, first.Body)[0] + require.Equal(t, "Synced DeepSeek", firstModel["display_name"]) + require.Equal(t, "Provider supplied", firstModel["description"]) + require.Equal(t, "ultra", firstModel["default_reasoning_level"]) + require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, firstModel)) + require.Equal(t, []any{"text", "image"}, firstModel["input_modalities"]) + require.EqualValues(t, 256_000, firstModel["context_window"]) + + setSnapshot("Refreshed DeepSeek", 512_000) + second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "") + require.NoError(t, err) + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account)) + secondModel := decodeCodexManifestModels(t, second.Body)[0] + require.Equal(t, "Refreshed DeepSeek", secondModel["display_name"]) + require.Equal(t, "Provider supplied", secondModel["description"]) + require.EqualValues(t, 512_000, secondModel["context_window"]) + require.Equal(t, int32(1), calls.Load(), "second response should use the cached upstream source body") +} + +func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTImageInput(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)} + account := newCodexModelsAPIKeyTestAccount("") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 4) + + bySlug := make(map[string]map[string]any, len(models)) + for _, model := range models { + slug, ok := model["slug"].(string) + require.True(t, ok) + bySlug[slug] = model + } + for _, slug := range []string{"gpt-5.6-sol", "gpt-4o"} { + require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"]) + require.Equal(t, true, bySlug[slug]["supports_image_detail_original"]) + } + for _, slug := range []string{"gpt-3.5-turbo", "gpt-4"} { + require.Equal(t, []any{"text"}, bySlug[slug]["input_modalities"]) + require.Equal(t, false, bySlug[slug]["supports_image_detail_original"]) + } + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + +func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels(t *testing.T) { + t.Parallel() + + svc := &OpenAIGatewayService{} + manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)} + account := newCodexModelsAPIKeyTestAccount("") + + require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) + require.Equal(t, []string{"gpt-5.6-sol", "o4-mini", "codex-mini-latest"}, codexManifestModelSlugs(t, manifest.Body)) + require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) +} + func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) { tests := []struct { name string @@ -572,17 +2008,27 @@ func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) { require.Equal(t, manifestBody, string(manifest.Body)) } -func TestConvertOpenAIModelListToCodexManifest(t *testing.T) { +func TestConvertOpenAIModelListToCompleteCodexManifest(t *testing.T) { + t.Parallel() + + body := []byte(`{"object":"list","data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}`) + models := decodeCodexManifestModels(t, convertOpenAIModelListToCodexManifest(body)) + + require.Len(t, models, 2) + requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-flash") + requireCompleteConfiguredCodexModel(t, models[1], "deepseek-v4-pro") + require.Equal(t, "DeepSeek V4 Flash", models[0]["display_name"]) + require.Equal(t, "DeepSeek V4 Pro", models[1]["display_name"]) + require.EqualValues(t, 1_000_000, models[0]["context_window"]) + require.EqualValues(t, 1_000_000, models[1]["context_window"]) +} + +func TestConvertOpenAIModelListToCodexManifestLeavesUnsupportedBodiesUnchanged(t *testing.T) { tests := []struct { name string body string want string }{ - { - name: "standard list", - body: `{"object":"list","data":[{"id":"m-1"},{"id":"m-2"}]}`, - want: `{"models":[{"slug":"m-1"},{"slug":"m-2"}]}`, - }, { name: "codex manifest unchanged", body: `{"models":[{"slug":"m-1"}]}`, @@ -910,6 +2356,77 @@ func TestFetchCodexModelsManifestAPIKeyFreshCacheHandlesETagLocally(t *testing.T } } +func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) { + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + calls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`)), + }, nil + }} + s := newCodexModelsAPIKeyTestService(upstream) + s.accountRepo = codexModelsVisibilityAccountRepo{} + account := newCodexModelsAPIKeyTestAccount("https://upstream.example") + + first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Contains(t, string(first.Body), "model-a") + require.Contains(t, string(first.Body), "model-b") + + require.NoError(t, s.CompleteAPIKeyCodexModelsManifestForClient(first, account)) + require.NoError(t, s.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ + ID: 81, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-a"}, + }, + }, + first, + "", + )) + require.Equal(t, []string{"model-a"}, codexManifestModelSlugs(t, first.Body)) + + second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, second.Body)) + require.Equal(t, int32(1), calls.Load()) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, fetchErr) + require.NoError(t, s.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ + ID: 82, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-b"}, + }, + }, + manifest, + "", + )) + require.Equal(t, []string{"model-b"}, codexManifestModelSlugs(t, manifest.Body)) + }() + } + wg.Wait() + + third, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, third.Body)) + require.Equal(t, int32(1), calls.Load()) +} + func TestFetchCodexModelsManifestAPIKeyCacheKeyIsolatesRequestIdentity(t *testing.T) { var calls atomic.Int32 upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { @@ -966,7 +2483,10 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { calls.Add(1) body := `{"models":[]}` - if strings.Contains(req.URL.Host, "large") { + switch { + case strings.Contains(req.URL.Host, "large-source"): + body = `{"object":"list","data":[{"id":"model-a","padding":"` + strings.Repeat("x", 1<<20) + `"}]}` + case strings.Contains(req.URL.Host, "large"): body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}` } return &http.Response{ @@ -990,8 +2510,12 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. large.ID = 3 fetch(large) fetch(large) - if got := calls.Load(); got != 3 { - t.Fatalf("body-size bounded cache calls: got %d, want 3", got) + largeSource := newCodexModelsAPIKeyTestAccount("https://large-source.example") + largeSource.ID = 4 + fetch(largeSource) + fetch(largeSource) + if got := calls.Load(); got != 5 { + t.Fatalf("body-size bounded cache calls: got %d, want 5", got) } for i := int64(10); i < 75; i++ { @@ -1002,14 +2526,14 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. last := newCodexModelsAPIKeyTestAccount("https://bounded.example") last.ID = 74 fetch(last) - if got := calls.Load(); got != 68 { - t.Fatalf("most recent cache entry was not retained: calls=%d, want 68", got) + if got := calls.Load(); got != 70 { + t.Fatalf("most recent cache entry was not retained: calls=%d, want 70", got) } first := newCodexModelsAPIKeyTestAccount("https://bounded.example") first.ID = 10 fetch(first) - if got := calls.Load(); got != 69 { - t.Errorf("oldest cache entry was not evicted: calls=%d, want 69", got) + if got := calls.Load(); got != 71 { + t.Errorf("oldest cache entry was not evicted: calls=%d, want 71", got) } } @@ -1462,7 +2986,7 @@ func TestFetchCodexModelsManifestAPIKeyUpstreamError(t *testing.T) { } } -func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T) { +func TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(t *testing.T) { tests := []struct { name string baseURL string @@ -1474,23 +2998,32 @@ func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { - t.Fatal("official OpenAI API key must not be used as a Codex manifest upstream") - return nil, nil + var gotURL string + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + gotURL = req.URL.String() + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol"}]}`)), + }, nil }}) - _, err := s.FetchCodexModelsManifest( + manifest, err := s.FetchCodexModelsManifest( context.Background(), newCodexModelsAPIKeyTestAccount(tt.baseURL), "0.144.0", "", ) - if err == nil { - t.Fatal("expected unsupported API key upstream error, got nil") - } - if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED" { - t.Errorf("error reason: got %q", infraerrors.Reason(err)) - } + require.NoError(t, err) + parsedURL, parseErr := url.Parse(gotURL) + require.NoError(t, parseErr) + require.Equal(t, "api.openai.com", strings.ToLower(parsedURL.Hostname())) + require.Equal(t, "/v1/models", parsedURL.Path) + require.Equal(t, "0.144.0", parsedURL.Query().Get("client_version")) + models := decodeCodexManifestModels(t, manifest.Body) + require.Len(t, models, 1) + requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6-sol") + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) }) } } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 8dc048850c..9a4c2389e3 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -24,6 +24,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") } + filteredBody, filterErr := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body) + if filterErr != nil { + return nil, filterErr + } + body = filteredBody clearGrokResponsesClientToolMapping(c) clearOpenAIResponsesClientToolMapping(c) clearOpenAIResponsesNamespaceNames(c) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 6272875cc5..dbe220773c 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -55,6 +55,69 @@ func buildOpenAIResponsesURLForPlatform(platform string, base string) string { return buildOpenAIResponsesURL(base) } +func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool { + if account == nil { + return false + } + if account.IsOpenAIOAuthLike() { + return true + } + if !account.IsOpenAIApiKey() { + return false + } + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) +} + +// Codex 0.149.0 needs a single advertised effort to directly select a visible +// non-reasoning model. Treat that catalog-only "none" value as omission for +// compatible upstreams, while preserving official OpenAI request semantics. +func filterOpenAIResponsesNoneReasoningEffortForAccount(account *Account, body []byte) ([]byte, error) { + if len(body) == 0 || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) { + return body, nil + } + + out := body + for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + effort := gjson.GetBytes(out, path) + if effort.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(effort.String()), "none") { + continue + } + next, err := sjson.DeleteBytes(out, path) + if err != nil { + return body, fmt.Errorf("strip %s none placeholder: %w", path, err) + } + out = next + } + if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 { + next, err := sjson.DeleteBytes(out, "reasoning") + if err != nil { + return body, fmt.Errorf("strip empty reasoning object: %w", err) + } + out = next + } + return out, nil +} + +func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body map[string]any) { + if body == nil || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) { + return + } + if effort, ok := body["reasoning_effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") { + delete(body, "reasoning_effort") + } + reasoning, ok := body["reasoning"].(map[string]any) + if !ok { + return + } + if effort, ok := reasoning["effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") { + delete(reasoning, "effort") + } + if len(reasoning) == 0 { + delete(body, "reasoning") + } +} + // normalizeDeepSeekResponsesRequestBody 适配 DeepSeek 无状态 Responses 端点: // 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端 // 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。 diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index 543d294880..8b5fbc8288 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -271,6 +271,66 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) { require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists()) } +func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) { + tests := []struct { + name string + account *Account + body string + wantNested bool + wantFlat bool + wantSummary bool + wantReasoning bool + }{ + { + name: "custom compatible endpoint strips none placeholders", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}}, + body: `{"reasoning":{"effort":"none"},"reasoning_effort":"NONE"}`, + wantReasoning: false, + }, + { + name: "third-party platform keeps other reasoning members", + account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, + body: `{"reasoning":{"effort":" none ","summary":"auto"}}`, + wantSummary: true, + wantReasoning: true, + }, + { + name: "non-none effort is unchanged", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}}, + body: `{"reasoning":{"effort":"high"},"reasoning_effort":"low"}`, + wantNested: true, + wantFlat: true, + wantReasoning: true, + }, + { + name: "official OpenAI API key preserves none", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + body: `{"reasoning":{"effort":"none"},"reasoning_effort":"none"}`, + wantNested: true, + wantFlat: true, + wantReasoning: true, + }, + { + name: "OpenAI OAuth preserves none", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + body: `{"reasoning":{"effort":"none"}}`, + wantNested: true, + wantReasoning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(tt.account, []byte(tt.body)) + require.NoError(t, err) + require.Equal(t, tt.wantNested, gjson.GetBytes(got, "reasoning.effort").Exists()) + require.Equal(t, tt.wantFlat, gjson.GetBytes(got, "reasoning_effort").Exists()) + require.Equal(t, tt.wantSummary, gjson.GetBytes(got, "reasoning.summary").Exists()) + require.Equal(t, tt.wantReasoning, gjson.GetBytes(got, "reasoning").Exists()) + }) + } +} + // Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。 func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go index 7b1eb9cba8..bb994b1aa4 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback_test.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback_test.go @@ -57,6 +57,36 @@ func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletion require.False(t, result.Stream) } +// Scenario: 第三方无推理模型不收到兼容档位。 +func TestForwardResponses_ForceChatCompletionsOmitsNoneReasoningEffort(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"company-coding-model","input":"hello","reasoning":{"effort":"none"},"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_none","object":"chat.completion","model":"company-coding-model","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, + )), + }} + svc := &OpenAIGatewayService{ + cfg: rawChatCompletionsTestConfig(), + httpUpstream: upstream, + } + + result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "company-coding-model", gjson.GetBytes(upstream.lastBody, "model").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning_effort").Exists()) + require.Nil(t, result.ReasoningEffort) +} + func TestForwardResponses_PassthroughFlagWithUnsupportedResponsesUsesAccountMapping(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 2bb0c200b5..a6c0528045 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -115,7 +115,7 @@ func (s *OpenAIGatewayService) shouldBridgeOpenAIWSHTTP(account *Account, payloa return threshold > 0 && int64(payloadBytes) >= threshold } -func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) { +func prepareOpenAIWSHTTPBridgeBody(account *Account, payload []byte) ([]byte, error) { var body map[string]any if err := decodeOpenAIJSONUseNumber(payload, &body); err != nil { return nil, err @@ -126,6 +126,7 @@ func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) { delete(body, "type") delete(body, "generate") delete(body, "previous_response_id") + deleteOpenAIResponsesNoneReasoningEffortFromObject(account, body) body["stream"] = true return json.Marshal(body) } @@ -305,7 +306,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } responseModelObserver := &upstreamResponseModelObserver{} - body, err := prepareOpenAIWSHTTPBridgeBody(payload) + body, err := prepareOpenAIWSHTTPBridgeBody(account, payload) if err != nil { return nil, fmt.Errorf("prepare http bridge body: %w", err) } @@ -836,7 +837,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( } func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, seedPayload, currentPayload []byte, originalModel string) (string, error) { - body, err := prepareOpenAIWSHTTPBridgeBody(seedPayload) + body, err := prepareOpenAIWSHTTPBridgeBody(account, seedPayload) if err != nil { return "", err } diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 13752a55ef..bbff773d76 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -31,7 +31,7 @@ func TestResolveOpenAIWSClientFirstMessageTimeout(t *testing.T) { } func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { - body, err := prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`)) + body, err := prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`)) require.NoError(t, err) require.False(t, gjson.GetBytes(body, "type").Exists()) require.False(t, gjson.GetBytes(body, "generate").Exists()) @@ -40,10 +40,26 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) { require.True(t, gjson.GetBytes(body, "stream").Bool()) require.Equal(t, "hi", gjson.GetBytes(body, "input").String()) require.Equal(t, "900719925474099312345", gjson.GetBytes(body, "sequence").Raw) - _, err = prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create"}{"trailing":true}`)) + _, err = prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create"}{"trailing":true}`)) require.Error(t, err) } +func TestPrepareOpenAIWSHTTPBridgeBodyStripsNoneReasoningForCompatibleEndpoint(t *testing.T) { + payload := []byte(`{"type":"response.create","model":"company-coding-model","reasoning":{"effort":"none"},"input":"hi"}`) + compatible := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://compat.example/v1", + }} + + body, err := prepareOpenAIWSHTTPBridgeBody(compatible, payload) + require.NoError(t, err) + require.False(t, gjson.GetBytes(body, "reasoning.effort").Exists()) + require.False(t, gjson.GetBytes(body, "reasoning").Exists()) + + officialBody, err := prepareOpenAIWSHTTPBridgeBody(&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, payload) + require.NoError(t, err) + require.Equal(t, "none", gjson.GetBytes(officialBody, "reasoning.effort").String()) +} + func TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 790b2243d2..d3d88c4269 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -3,17 +3,128 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "io" + "log/slog" "net/http" + "net/url" "sort" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" ) +const ( + upstreamModelsBodyLimit int64 = 8 << 20 + modelsDevRegistryURL = "https://models.dev/api.json" + modelsDevRegistryTTL = 6 * time.Hour + UpstreamModelMetadataExtraKey = "upstream_model_metadata" + UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete" +) + +type UpstreamModelMetadata struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Reasoning *bool `json:"reasoning,omitempty"` + DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"` + SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` + InputModalities []string `json:"input_modalities,omitempty"` + ContextWindow int64 `json:"context_window,omitempty"` + MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` +} + +type UpstreamModelMetadataSnapshot struct { + Source string `json:"source"` + SyncedAt string `json:"synced_at"` + Models map[string]UpstreamModelMetadata `json:"models"` +} + +type UpstreamModelCatalog struct { + Models []string `json:"models"` + Metadata map[string]UpstreamModelMetadata `json:"metadata,omitempty"` + Warnings []UpstreamModelSyncWarning `json:"warnings,omitempty"` +} + +type UpstreamModelSyncWarning struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type modelsDevProvider struct { + ID string `json:"id"` + Name string `json:"name"` + API string `json:"api"` + Models map[string]modelsDevModel `json:"models"` +} + +type modelsDevModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Reasoning *bool `json:"reasoning"` + ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` + Modalities modelsDevModalities `json:"modalities"` + Limit modelsDevLimit `json:"limit"` +} + +type modelsDevReasoningOption struct { + Type string `json:"type"` + Values []any `json:"values"` +} + +type modelsDevModalities struct { + Input []string `json:"input"` + Output []string `json:"output"` +} + +type modelsDevLimit struct { + Context int64 `json:"context"` + Output int64 `json:"output"` +} + +func (a *Account) SetUpstreamModelMetadataSnapshot(snapshot UpstreamModelMetadataSnapshot) { + if a == nil { + return + } + if a.Extra == nil { + a.Extra = make(map[string]any) + } + a.Extra[UpstreamModelMetadataExtraKey] = snapshot +} + +func (a *Account) GetUpstreamModelMetadataSnapshot() *UpstreamModelMetadataSnapshot { + if a == nil || a.Extra == nil { + return nil + } + raw, ok := a.Extra[UpstreamModelMetadataExtraKey] + if !ok || raw == nil { + return nil + } + body, err := json.Marshal(raw) + if err != nil { + return nil + } + var snapshot UpstreamModelMetadataSnapshot + if err := json.Unmarshal(body, &snapshot); err != nil || len(snapshot.Models) == 0 { + return nil + } + return &snapshot +} + +func (a *Account) GetUpstreamModelMetadata(modelID string) (UpstreamModelMetadata, bool) { + snapshot := a.GetUpstreamModelMetadataSnapshot() + if snapshot == nil { + return UpstreamModelMetadata{}, false + } + metadata, ok := snapshot.Models[strings.TrimSpace(modelID)] + return metadata, ok +} + // UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping. type UpstreamModelSyncErrorKind string @@ -24,13 +135,16 @@ const ( UpstreamModelSyncErrorUnsupported UpstreamModelSyncErrorKind = "unsupported" // UpstreamModelSyncErrorUpstream means the configured upstream failed or returned an unusable response. UpstreamModelSyncErrorUpstream UpstreamModelSyncErrorKind = "upstream" + // UpstreamModelSyncErrorInternal means local persistence or service state failed after a valid upstream response. + UpstreamModelSyncErrorInternal UpstreamModelSyncErrorKind = "internal" ) // UpstreamModelSyncError keeps internal failure details wrapped while exposing a safe client message. type UpstreamModelSyncError struct { - Kind UpstreamModelSyncErrorKind - Message string - Err error + Kind UpstreamModelSyncErrorKind + Message string + StatusCode int + Err error } func (e *UpstreamModelSyncError) Error() string { @@ -70,49 +184,432 @@ func newUpstreamModelSyncUpstreamError(message string, err error) error { return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUpstream, Message: message, Err: err} } -// FetchUpstreamSupportedModels fetches the live model list from the account's upstream API format. +func newUpstreamModelSyncInternalError(message string, err error) error { + return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorInternal, Message: message, Err: err} +} + +// FetchUpstreamSupportedModels fetches only live model IDs. The admin sync path +// uses SyncUpstreamModelCatalog so capability metadata can also be persisted. func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, account *Account) ([]string, error) { + models, _, err := s.fetchUpstreamModelList(ctx, account) + return models, err +} + +// SyncUpstreamModelCatalog fetches the account's live model list, enriches +// missing capability fields from the provider registry used by the upstream, +// and persists a normalized account snapshot when metadata is available. +func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) { + models, body, err := s.fetchUpstreamModelList(ctx, account) + if err != nil { + configuredModels := configuredUpstreamModelsForCapabilitySync(account) + if !upstreamModelListEndpointUnsupported(err) || len(configuredModels) == 0 { + return nil, err + } + models = configuredModels + body = nil + slog.Info("upstream model list endpoint unavailable; using configured models for capability sync", + "account_id", upstreamModelSyncAccountID(account), + "platform", upstreamModelSyncPlatform(account), + "status_code", upstreamModelSyncStatusCode(err), + "model_count", len(models), + ) + } + catalog := &UpstreamModelCatalog{Models: models, Metadata: make(map[string]UpstreamModelMetadata)} + if len(body) > 0 { + _, directMetadata, parseErr := extractUpstreamModelCatalog(body, account != nil && account.IsGrok()) + if parseErr == nil { + catalog.Metadata = directMetadata + } + } + + source := "upstream" + metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata) + if metadataIncomplete { + if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); registryErr == nil { + for modelID, fallback := range registryMetadata { + current := catalog.Metadata[modelID] + merged, changed := mergeUpstreamModelMetadata(current, fallback) + catalog.Metadata[modelID] = merged + if changed { + source = "models.dev" + } + } + } else { + slog.Warn("upstream model capability metadata enrichment failed", + "account_id", upstreamModelSyncAccountID(account), + "platform", upstreamModelSyncPlatform(account), + "error", registryErr, + ) + } + } + + if upstreamCatalogNeedsRegistry(models, catalog.Metadata) { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataIncompleteCode, + Message: "Model IDs were synced, but capability metadata is incomplete.", + }) + return catalog, nil + } + if len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil { + return catalog, nil + } + snapshot := UpstreamModelMetadataSnapshot{ + Source: source, + SyncedAt: time.Now().UTC().Format(time.RFC3339), + Models: catalog.Metadata, + } + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { + return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) + } + account.SetUpstreamModelMetadataSnapshot(snapshot) + return catalog, nil +} + +func upstreamModelSyncStatusCode(err error) int { + var syncErr *UpstreamModelSyncError + if errors.As(err, &syncErr) { + return syncErr.StatusCode + } + return 0 +} + +func upstreamModelListEndpointUnsupported(err error) bool { + statusCode := upstreamModelSyncStatusCode(err) + return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed +} + +func configuredUpstreamModelsForCapabilitySync(account *Account) []string { + if account == nil { + return nil + } + models := make([]string, 0) + for _, mappedModel := range account.GetModelMapping() { + mappedModel = strings.TrimSpace(mappedModel) + if mappedModel == "" || strings.Contains(mappedModel, "*") { + continue + } + models = append(models, mappedModel) + } + return dedupeAndSortModelIDs(models) +} + +func upstreamModelSyncAccountID(account *Account) int64 { + if account == nil { + return 0 + } + return account.ID +} + +func upstreamModelSyncPlatform(account *Account) string { + if account == nil { + return "" + } + return account.Platform +} + +func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamModelMetadata) bool { + for _, modelID := range models { + modelID = strings.TrimSpace(modelID) + model, ok := metadata[modelID] + if !ok || !upstreamModelMetadataIsUseful(model) { + return true + } + if model.Reasoning == nil || len(model.InputModalities) == 0 || model.ContextWindow <= 0 { + return true + } + if *model.Reasoning && len(model.SupportedReasoningLevels) == 0 { + return true + } + } + return false +} + +func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool { + return strings.TrimSpace(metadata.DisplayName) != "" || + strings.TrimSpace(metadata.Description) != "" || + metadata.Reasoning != nil || + len(metadata.SupportedReasoningLevels) > 0 || + len(metadata.InputModalities) > 0 || + metadata.ContextWindow > 0 || + metadata.MaxOutputTokens > 0 +} + +func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) { + merged := primary + changed := false + if strings.TrimSpace(merged.ID) == "" && strings.TrimSpace(fallback.ID) != "" { + merged.ID = strings.TrimSpace(fallback.ID) + changed = true + } + if strings.TrimSpace(merged.DisplayName) == "" && strings.TrimSpace(fallback.DisplayName) != "" { + merged.DisplayName = strings.TrimSpace(fallback.DisplayName) + changed = true + } + if strings.TrimSpace(merged.Description) == "" && strings.TrimSpace(fallback.Description) != "" { + merged.Description = strings.TrimSpace(fallback.Description) + changed = true + } + if merged.Reasoning == nil && fallback.Reasoning != nil { + reasoning := *fallback.Reasoning + merged.Reasoning = &reasoning + changed = true + } + if strings.TrimSpace(merged.DefaultReasoningLevel) == "" && strings.TrimSpace(fallback.DefaultReasoningLevel) != "" { + merged.DefaultReasoningLevel = strings.TrimSpace(fallback.DefaultReasoningLevel) + changed = true + } + if len(merged.SupportedReasoningLevels) == 0 && len(fallback.SupportedReasoningLevels) > 0 { + merged.SupportedReasoningLevels = append([]string(nil), fallback.SupportedReasoningLevels...) + changed = true + } + if len(merged.InputModalities) == 0 && len(fallback.InputModalities) > 0 { + merged.InputModalities = append([]string(nil), fallback.InputModalities...) + changed = true + } + if merged.ContextWindow <= 0 && fallback.ContextWindow > 0 { + merged.ContextWindow = fallback.ContextWindow + changed = true + } + if merged.MaxOutputTokens <= 0 && fallback.MaxOutputTokens > 0 { + merged.MaxOutputTokens = fallback.MaxOutputTokens + changed = true + } + return merged, changed +} + +func (s *AccountTestService) fetchModelsDevMetadata( + ctx context.Context, + account *Account, + modelIDs []string, +) (map[string]UpstreamModelMetadata, error) { + if s == nil || s.httpUpstream == nil || account == nil { + return nil, fmt.Errorf("model metadata registry is not configured") + } + registry, err := s.fetchModelsDevRegistry(ctx, account) + if err != nil { + return nil, err + } + provider, ok := matchModelsDevProvider(registry, upstreamModelRegistryBaseURL(account)) + if !ok { + return nil, fmt.Errorf("no model metadata provider matches account base URL") + } + + metadata := make(map[string]UpstreamModelMetadata) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + model, found := provider.Models[modelID] + if !found { + for candidateID, candidate := range provider.Models { + if strings.EqualFold(strings.TrimSpace(candidateID), modelID) || strings.EqualFold(strings.TrimSpace(candidate.ID), modelID) { + model = candidate + found = true + break + } + } + } + if !found { + continue + } + entry := upstreamMetadataFromModelsDevModel(modelID, model) + if upstreamModelMetadataIsUseful(entry) { + metadata[modelID] = entry + } + } + return metadata, nil +} + +func (s *AccountTestService) fetchModelsDevRegistry(ctx context.Context, account *Account) (map[string]modelsDevProvider, error) { + now := time.Now() + s.modelMetadataRegistryMu.Lock() + if len(s.modelMetadataRegistry) > 0 && now.Sub(s.modelMetadataRegistryAt) < modelsDevRegistryTTL { + cached := s.modelMetadataRegistry + s.modelMetadataRegistryMu.Unlock() + return cached, nil + } + s.modelMetadataRegistryMu.Unlock() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevRegistryURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := s.doUpstreamModelsRequest(req, upstreamModelsProxyURL(account), account) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("model metadata registry returned HTTP %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > upstreamModelsBodyLimit { + return nil, fmt.Errorf("model metadata registry response exceeds %d bytes", upstreamModelsBodyLimit) + } + var registry map[string]modelsDevProvider + if err := json.Unmarshal(body, ®istry); err != nil { + return nil, fmt.Errorf("parse model metadata registry: %w", err) + } + if len(registry) == 0 { + return nil, fmt.Errorf("model metadata registry is empty") + } + + s.modelMetadataRegistryMu.Lock() + s.modelMetadataRegistry = registry + s.modelMetadataRegistryAt = now + s.modelMetadataRegistryMu.Unlock() + return registry, nil +} + +func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) UpstreamModelMetadata { + levels := reasoningLevelsFromModelsDevOptions(model.ReasoningOptions) + reasoning := model.Reasoning + if reasoning == nil && len(levels) > 0 { + inferred := true + reasoning = &inferred + } + metadata := UpstreamModelMetadata{ + ID: strings.TrimSpace(modelID), + DisplayName: strings.TrimSpace(model.Name), + Description: strings.TrimSpace(model.Description), + Reasoning: reasoning, + SupportedReasoningLevels: levels, + InputModalities: normalizeCodexInputModalities(model.Modalities.Input), + ContextWindow: model.Limit.Context, + MaxOutputTokens: model.Limit.Output, + } + if len(levels) > 0 { + metadata.DefaultReasoningLevel = levels[0] + } + if strings.TrimSpace(model.ID) != "" { + metadata.ID = strings.TrimSpace(model.ID) + } + return metadata +} + +func reasoningLevelsFromModelsDevOptions(options []modelsDevReasoningOption) []string { + levels := make([]string, 0) + for _, option := range options { + if !strings.EqualFold(strings.TrimSpace(option.Type), "effort") { + continue + } + for _, value := range option.Values { + if value == nil { + levels = append(levels, "none") + continue + } + if effort, ok := value.(string); ok { + levels = append(levels, effort) + } + } + } + return normalizeReasoningLevels(levels) +} + +func upstreamModelRegistryBaseURL(account *Account) string { + if account == nil { + return "" + } + switch { + case account.IsOpenAI() || account.IsCNProvider(): + return account.GetOpenAIFormatBaseURL() + case account.IsGrok(): + return account.GetGrokBaseURL() + case account.IsGemini(): + return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) + case account.IsAnthropic(): + return account.GetBaseURL() + case account.Platform == PlatformAntigravity: + return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) + default: + return strings.TrimSpace(account.GetCredential("base_url")) + } +} + +func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL) + if accountBaseURL == "" { + return modelsDevProvider{}, false + } + var best modelsDevProvider + bestScore := -1 + for _, provider := range registry { + providerBaseURL := normalizeModelRegistryBaseURL(provider.API) + if providerBaseURL == "" { + continue + } + if accountBaseURL != providerBaseURL && + !strings.HasPrefix(accountBaseURL, providerBaseURL+"/") && + !strings.HasPrefix(providerBaseURL, accountBaseURL+"/") { + continue + } + if len(providerBaseURL) > bestScore { + best = provider + bestScore = len(providerBaseURL) + } + } + return best, bestScore >= 0 +} + +func normalizeModelRegistryBaseURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + path := strings.TrimRight(parsed.Path, "/") + if strings.HasSuffix(strings.ToLower(path), "/models") { + path = strings.TrimRight(path[:len(path)-len("/models")], "/") + } + return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) + path +} + +func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account *Account) ([]string, []byte, error) { if s == nil { - return nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil) + return nil, nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil) } if account == nil { - return nil, newUpstreamModelSyncConfigError("Account is required", nil) + return nil, nil, newUpstreamModelSyncConfigError("Account is required", nil) } if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey { - return s.fetchAntigravityOAuthUpstreamModels(ctx, account) + models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account) + return models, nil, err } if s.httpUpstream == nil { - return nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil) + return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil) } req, err := s.buildUpstreamModelsRequest(ctx, account) if err != nil { - return nil, err + return nil, nil, err } proxyURL := upstreamModelsProxyURL(account) resp, err := s.doUpstreamModelsRequest(req, proxyURL, account) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err) } defer func() { _ = resp.Body.Close() }() bodyLimit := resolveModelsListReadLimit(s.cfg) body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) } if int64(len(body)) > bodyLimit { - return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit)) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit)) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { - return nil, newUpstreamModelSyncUpstreamError( - fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode), - fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode), - ) + return nil, nil, &UpstreamModelSyncError{ + Kind: UpstreamModelSyncErrorUpstream, + Message: fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode), + StatusCode: resp.StatusCode, + Err: fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode), + } } extractModels := extractUpstreamModelIDs @@ -121,13 +618,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a } models, err := extractModels(body) if err != nil { - return nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err) } if len(models) == 0 { - return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) + return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) } - return models, nil + return models, body, nil } func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { @@ -577,12 +1074,29 @@ type upstreamModelEntry struct { type upstreamModelEntryMetadata struct { ID string `json:"id"` + Slug string `json:"slug"` Model string `json:"model"` ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` Name string `json:"name"` } +type upstreamModelCapabilityEntry struct { + upstreamModelEntry + DisplayName string `json:"display_name"` + Description string `json:"description"` + Reasoning *bool `json:"reasoning"` + DefaultReasoningLevel string `json:"default_reasoning_level"` + SupportedReasoningLevels []json.RawMessage `json:"supported_reasoning_levels"` + ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` + InputModalities []string `json:"input_modalities"` + Modalities modelsDevModalities `json:"modalities"` + ContextWindow int64 `json:"context_window"` + MaxContextWindow int64 `json:"max_context_window"` + MaxOutputTokens int64 `json:"max_output_tokens"` + Limit modelsDevLimit `json:"limit"` +} + func extractUpstreamModelIDs(body []byte) ([]string, error) { return extractUpstreamModelIDsWithSelector(body, upstreamModelEntryID) } @@ -591,6 +1105,166 @@ func extractGrokUpstreamModelIDs(body []byte) ([]string, error) { return extractUpstreamModelIDsWithSelector(body, grokUpstreamModelEntryID) } +func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]UpstreamModelMetadata, error) { + entries, err := extractUpstreamModelRawEntries(body) + if err != nil { + return nil, nil, err + } + selectID := upstreamModelEntryID + if grok { + selectID = grokUpstreamModelEntryID + } + + models := make([]string, 0, len(entries)) + metadata := make(map[string]UpstreamModelMetadata) + for _, raw := range entries { + var capability upstreamModelCapabilityEntry + if err := json.Unmarshal(raw, &capability); err != nil { + continue + } + modelID := strings.TrimSpace(selectID(capability.upstreamModelEntry)) + if modelID == "" { + continue + } + models = append(models, modelID) + entry := upstreamMetadataFromCapabilityEntry(modelID, capability) + if upstreamModelMetadataIsUseful(entry) { + metadata[modelID] = entry + } + } + return dedupeAndSortModelIDs(models), metadata, nil +} + +func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) { + var response struct { + Data []json.RawMessage `json:"data"` + Models []json.RawMessage `json:"models"` + } + if err := json.Unmarshal(body, &response); err == nil && (response.Data != nil || response.Models != nil) { + entries := make([]json.RawMessage, 0, len(response.Data)+len(response.Models)) + entries = append(entries, response.Data...) + entries = append(entries, response.Models...) + return entries, nil + } + var entries []json.RawMessage + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("parse upstream model catalog: %w", err) + } + return entries, nil +} + +func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapabilityEntry) UpstreamModelMetadata { + levels := reasoningLevelsFromRawEntries(entry.SupportedReasoningLevels) + if len(levels) == 0 { + levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions) + } + reasoning := entry.Reasoning + if reasoning == nil && len(levels) > 0 { + inferred := len(levels) != 1 || levels[0] != "none" + reasoning = &inferred + } + modalities := entry.InputModalities + if len(modalities) == 0 { + modalities = entry.Modalities.Input + } + contextWindow := entry.ContextWindow + if contextWindow <= 0 { + contextWindow = entry.MaxContextWindow + } + if contextWindow <= 0 { + contextWindow = entry.Limit.Context + } + maxOutputTokens := entry.MaxOutputTokens + if maxOutputTokens <= 0 { + maxOutputTokens = entry.Limit.Output + } + defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel) + if defaultReasoningLevel == "" && len(levels) > 0 { + defaultReasoningLevel = levels[0] + } + displayName := strings.TrimSpace(entry.DisplayName) + if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID { + displayName = strings.TrimSpace(entry.Name) + } + return UpstreamModelMetadata{ + ID: modelID, + DisplayName: displayName, + Description: strings.TrimSpace(entry.Description), + Reasoning: reasoning, + DefaultReasoningLevel: defaultReasoningLevel, + SupportedReasoningLevels: levels, + InputModalities: normalizeCodexInputModalities(modalities), + ContextWindow: contextWindow, + MaxOutputTokens: maxOutputTokens, + } +} + +func reasoningLevelsFromRawEntries(entries []json.RawMessage) []string { + levels := make([]string, 0, len(entries)) + for _, raw := range entries { + var effort string + if err := json.Unmarshal(raw, &effort); err == nil { + levels = append(levels, effort) + continue + } + var level struct { + Effort string `json:"effort"` + } + if err := json.Unmarshal(raw, &level); err == nil { + levels = append(levels, level.Effort) + } + } + return normalizeReasoningLevels(levels) +} + +func normalizeReasoningLevels(levels []string) []string { + seen := make(map[string]struct{}, len(levels)) + normalized := make([]string, 0, len(levels)) + for _, level := range levels { + level = normalizeReasoningLevel(level) + if level == "" { + continue + } + if _, exists := seen[level]; exists { + continue + } + seen[level] = struct{}{} + normalized = append(normalized, level) + } + return normalized +} + +func normalizeReasoningLevel(level string) string { + level = strings.ToLower(strings.TrimSpace(level)) + switch level { + case "off", "disabled": + return "none" + case "extra-high", "extra_high": + return "xhigh" + case "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra": + return level + default: + return "" + } +} + +func normalizeCodexInputModalities(modalities []string) []string { + seen := make(map[string]struct{}, len(modalities)) + normalized := make([]string, 0, len(modalities)) + for _, modality := range modalities { + modality = strings.ToLower(strings.TrimSpace(modality)) + if modality != "text" && modality != "image" { + continue + } + if _, exists := seen[modality]; exists { + continue + } + seen[modality] = struct{}{} + normalized = append(normalized, modality) + } + return normalized +} + func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamModelEntry) string) ([]string, error) { var response struct { Data []upstreamModelEntry `json:"data"` @@ -646,6 +1320,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string { entry.ModelID, entry.ModelIDSnake, entry.ID, + entry.Slug, } if len(entry.Meta) > 0 { var meta upstreamModelEntryMetadata @@ -655,6 +1330,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string { meta.ModelID, meta.ModelIDSnake, meta.ID, + meta.Slug, meta.Name, ) } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 114c647d45..b358406b8b 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "io" "net/http" @@ -13,6 +14,28 @@ import ( "github.com/stretchr/testify/require" ) +type upstreamModelMetadataRepoStub struct { + AccountRepository + accountID int64 + updates map[string]any + err error +} + +func headerValuesEqualFold(header http.Header, name string) []string { + for key, values := range header { + if strings.EqualFold(key, name) { + return values + } + } + return nil +} + +func (r *upstreamModelMetadataRepoStub) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + r.accountID = id + r.updates = updates + return r.err +} + func upstreamModelSyncTestConfig() *config.Config { return &config.Config{ Security: config.SecurityConfig{ @@ -394,6 +417,384 @@ func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) { require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization")) } +// Scenario: ID-only 模型列表从 Models.dev 补齐能力。 +func TestSyncUpstreamModelCatalogEnrichesOpenCodeIDOnlyListAndPersistsSnapshot(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"x-preview-f-free","object":"model"}]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "opencode": { + "id": "opencode", + "name": "OpenCode Zen", + "api": "https://opencode.ai/zen/v1", + "models": { + "x-preview-f-free": { + "id": "x-preview-f-free", + "name": "Ox Alpha Free (Unlimited)", + "description": "Stealth reasoning model for coding, agentic tasks, and tool use", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","high","max"]}], + "modalities": {"input":["text","image","video"],"output":["text"]}, + "limit": {"context":1000000,"output":131072} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{ + accountRepo: repo, + httpUpstream: upstream, + cfg: upstreamModelSyncTestConfig(), + } + account := &Account{ + ID: 91, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "opencode-key", + "base_url": "https://opencode.ai/zen/v1", + "header_override_enabled": true, + "header_overrides": map[string]any{ + "X-Custom-Account-Header": "account-secret", + }, + }, + } + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"x-preview-f-free"}, catalog.Models) + require.Len(t, upstream.requests, 2) + require.Equal(t, "https://opencode.ai/zen/v1/models", upstream.requests[0].URL.String()) + require.Equal(t, []string{"account-secret"}, headerValuesEqualFold(upstream.requests[0].Header, "X-Custom-Account-Header")) + require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String()) + require.Empty(t, upstream.requests[1].Header.Get("Authorization")) + require.Empty(t, upstream.requests[1].Header.Get("x-api-key")) + require.Empty(t, headerValuesEqualFold(upstream.requests[1].Header, "X-Custom-Account-Header")) + + metadata := catalog.Metadata["x-preview-f-free"] + require.Equal(t, "Ox Alpha Free (Unlimited)", metadata.DisplayName) + require.NotNil(t, metadata.Reasoning) + require.True(t, *metadata.Reasoning) + require.Equal(t, []string{"low", "high", "max"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) + require.Equal(t, int64(1_000_000), metadata.ContextWindow) + require.Equal(t, int64(131_072), metadata.MaxOutputTokens) + require.Equal(t, int64(91), repo.accountID) + + rawSnapshot, ok := repo.updates[UpstreamModelMetadataExtraKey] + require.True(t, ok) + encoded, err := json.Marshal(rawSnapshot) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Equal(t, "models.dev", snapshot.Source) + require.Equal(t, metadata, snapshot.Models["x-preview-f-free"]) +} + +// Scenario: 不提供 /models 的兼容上游使用管理员已配置模型继续同步能力。 +func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusNotFound, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "configured-provider": { + "id": "configured-provider", + "name": "Configured Provider", + "api": "https://provider.example/v1", + "models": { + "glm-5.3": { + "id": "glm-5.3", + "name": "GLM-5.3", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high"]}], + "modalities": {"input":["text"],"output":["text"]}, + "limit": {"context":1000000,"output":131072} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + account := &Account{ + ID: 97, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "key", + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{ + "public-glm": "glm-5.3", + "duplicate": "glm-5.3", + "wildcard": "glm-*", + "empty": "", + }, + }, + } + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"glm-5.3"}, catalog.Models) + require.Empty(t, catalog.Warnings) + require.Len(t, upstream.requests, 2) + require.Equal(t, "https://provider.example/v1/models", upstream.requests[0].URL.String()) + require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String()) + metadata := catalog.Metadata["glm-5.3"] + require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text"}, metadata.InputModalities) + require.Equal(t, int64(1_000_000), metadata.ContextWindow) + require.NotNil(t, repo.updates) +} + +func TestSyncUpstreamModelCatalogDoesNotUseConfiguredModelsForRealUpstreamFailures(t *testing.T) { + tests := []struct { + name string + statusCode int + }{ + {name: "unauthorized", statusCode: http.StatusUnauthorized}, + {name: "rate limited", statusCode: http.StatusTooManyRequests}, + {name: "server error", statusCode: http.StatusBadGateway}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: tt.statusCode, + Body: io.NopCloser(strings.NewReader(`{"error":"failed"}`)), + }} + svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 98, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "key", + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"public-glm": "glm-5.3"}, + }, + }) + require.Error(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, tt.statusCode, upstreamModelSyncStatusCode(err)) + }) + } +} + +func TestSyncUpstreamModelCatalogRequiresConfiguredModelsForUnsupportedListEndpoint(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusMethodNotAllowed, + Body: io.NopCloser(strings.NewReader(`{"error":"method not allowed"}`)), + }} + svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 99, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.Error(t, err) + require.Equal(t, http.StatusMethodNotAllowed, upstreamModelSyncStatusCode(err)) + require.Len(t, upstream.requests, 1) +} + +// Scenario: 完整上游模型清单优先保存能力。 +func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "slug":"custom-thinking-model", + "display_name":"Upstream Display", + "description":"Upstream description", + "default_reasoning_level":"high", + "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}], + "input_modalities":["text","image"], + "context_window":256000 + }]}`)), + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 92, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.NoError(t, err) + require.Len(t, upstream.requests, 1, "complete upstream metadata must not be replaced by a registry fetch") + metadata := catalog.Metadata["custom-thinking-model"] + require.Equal(t, "Upstream Display", metadata.DisplayName) + require.Equal(t, "high", metadata.DefaultReasoningLevel) + require.Equal(t, []string{"low", "high", "ultra"}, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, metadata.InputModalities) + require.Equal(t, int64(256_000), metadata.ContextWindow) +} + +// Scenario: 上游 /models 增删型号后,正式同步用最新清单替换能力快照。 +func TestSyncUpstreamModelCatalogReplacesSnapshotWhenUpstreamModelsChange(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[ + {"id":"old-model","reasoning":false,"input_modalities":["text"],"context_window":128000}, + {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000} + ]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"data":[ + {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000}, + {"id":"new-model","reasoning":false,"input_modalities":["text"],"context_window":256000} + ]}`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + account := &Account{ + ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "key", + "base_url": "https://provider.example/v1", + }, + } + + first, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"kept-model", "old-model"}, first.Models) + + second, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, []string{"kept-model", "new-model"}, second.Models) + require.NotContains(t, second.Metadata, "old-model") + require.Contains(t, second.Metadata, "new-model") + + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.NotContains(t, snapshot.Models, "old-model") + require.Contains(t, snapshot.Models, "new-model") +} + +// Scenario: 上游明确声明无推理能力时保存 false。 +func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"company-coding-model", + "display_name":"Company Coding Model", + "reasoning":false, + "input_modalities":["text"], + "context_window":64000 + }]}`)), + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 94, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + metadata := catalog.Metadata["company-coding-model"] + require.NotNil(t, metadata.Reasoning) + require.False(t, *metadata.Reasoning) + require.Empty(t, metadata.SupportedReasoningLevels) + require.Equal(t, []string{"text"}, metadata.InputModalities) + require.Equal(t, int64(64_000), metadata.ContextWindow) + require.NotNil(t, repo.updates) +} + +func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"company-coding-model", + "reasoning":false, + "input_modalities":["text"], + "context_window":64000 + }]}`)), + }} + repo := &upstreamModelMetadataRepoStub{err: errors.New("database unavailable")} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 95, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.Error(t, err) + var syncErr *UpstreamModelSyncError + require.ErrorAs(t, err, &syncErr) + require.Equal(t, UpstreamModelSyncErrorInternal, syncErr.Kind) +} + +// Scenario: 元数据源失败时保留已有快照。 +func TestSyncUpstreamModelCatalogDoesNotOverwriteSnapshotWhenRegistryFails(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`))}, + {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))}, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 93, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://opencode.ai/zen/v1"}, + Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{ + "source": "models.dev", "models": map[string]any{"x-preview-f-free": map[string]any{"reasoning": true}}, + }}, + }) + require.NoError(t, err) + require.Equal(t, []string{"x-preview-f-free"}, catalog.Models) + require.Empty(t, catalog.Metadata) + require.Equal(t, []UpstreamModelSyncWarning{{ + Code: UpstreamModelMetadataIncompleteCode, + Message: "Model IDs were synced, but capability metadata is incomplete.", + }}, catalog.Warnings) + require.Nil(t, repo.updates, "a failed metadata enrichment must not erase a previously saved snapshot") +} + +func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"models":[{ + "id":"partially-described-model", + "display_name":"Partial Model" + }]}`))}, + {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))}, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{ + "source": "upstream", "models": map[string]any{"partially-described-model": map[string]any{ + "reasoning": true, "supported_reasoning_levels": []any{"low", "high"}, + }}, + }}, + }) + require.NoError(t, err) + require.Equal(t, []string{"partially-described-model"}, catalog.Models) + require.Equal(t, "Partial Model", catalog.Metadata["partially-described-model"].DisplayName) + require.Equal(t, UpstreamModelMetadataIncompleteCode, catalog.Warnings[0].Code) + require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot") +} + func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { t.Parallel() diff --git a/frontend/src/api/__tests__/codex.spec.ts b/frontend/src/api/__tests__/codex.spec.ts new file mode 100644 index 0000000000..57b19d7557 --- /dev/null +++ b/frontend/src/api/__tests__/codex.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildCodexModelsManifestUrl, + fetchCodexModelsManifest +} from '../codex' + +describe('Codex models API', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('builds the authenticated Codex manifest endpoint from the public API base', () => { + expect(buildCodexModelsManifestUrl('https://example.com/api/v1/')).toBe( + 'https://example.com/api/v1/models?client_version=0.147.0' + ) + }) + + it('fetches a manifest with the current API key without adding it to the catalog', async () => { + const manifest = { + models: [ + { + slug: 'grok-4.6', + default_reasoning_level: 'high', + supported_reasoning_levels: [ + { effort: 'low', description: 'Fast responses' }, + { effort: 'xhigh', description: 'Extra-high reasoning depth' } + ], + input_modalities: ['text', 'image'], + model_messages: { instructions_template: 'Use the routed model.' } + }, + { + slug: 'deepseek-v4-pro', + default_reasoning_level: 'high', + supported_reasoning_levels: [ + { effort: 'low', description: 'Fast responses' }, + { effort: 'max', description: 'Maximum reasoning depth' } + ], + input_modalities: ['text'], + model_messages: { instructions_template: 'Use the routed model.' } + } + ] + } + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => manifest + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/v1/models?client_version=0.147.0', + expect.objectContaining({ + headers: { + Accept: 'application/json', + Authorization: 'Bearer sk-user-test' + } + }) + ) + expect(result.modelCount).toBe(2) + expect(JSON.parse(result.content)).toEqual(manifest) + expect(result.content).toContain('"effort": "xhigh"') + expect(result.content).toContain('"input_modalities"') + expect(result.content).toContain('"instructions_template"') + expect(result.content).not.toContain('sk-user-test') + }) + + it('rejects a successful response that is not a Codex manifest', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ object: 'list', data: [] }) + })) + + await expect(fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test')) + .rejects.toThrow('valid manifest') + }) +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 73bfd29af3..ee053ad9ec 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -541,6 +541,25 @@ export async function getAvailableModels(id: number): Promise { export interface SyncUpstreamModelsResult { models: string[] + metadata?: Record + warnings?: UpstreamModelSyncWarning[] +} + +export interface UpstreamModelSyncWarning { + code: string + message: string +} + +export interface UpstreamModelMetadata { + id: string + display_name?: string + description?: string + reasoning?: boolean + default_reasoning_level?: string + supported_reasoning_levels?: string[] + input_modalities?: string[] + context_window?: number + max_output_tokens?: number } /** @@ -558,6 +577,7 @@ export interface SyncUpstreamPreviewParams { type: string base_url?: string api_key: string + model_mapping?: Record } /** diff --git a/frontend/src/api/codex.ts b/frontend/src/api/codex.ts new file mode 100644 index 0000000000..948fa1eee9 --- /dev/null +++ b/frontend/src/api/codex.ts @@ -0,0 +1,56 @@ +export interface CodexModelsManifestResult { + content: string + modelCount: number +} + +const DEFAULT_CODEX_CLIENT_VERSION = '0.147.0' + +function normalizeCodexBaseUrl(baseUrl: string): string { + const fallback = typeof window !== 'undefined' ? window.location.origin : '' + const value = (baseUrl || fallback).trim().replace(/\/+$/, '') + if (!value) return '/v1' + return /\/v1$/i.test(value) ? value : `${value}/v1` +} + +export function buildCodexModelsManifestUrl( + baseUrl: string, + clientVersion = DEFAULT_CODEX_CLIENT_VERSION +): string { + const url = normalizeCodexBaseUrl(baseUrl) + const params = new URLSearchParams({ client_version: clientVersion }) + return `${url}/models?${params.toString()}` +} + +function isCodexModelsManifest(value: unknown): value is { models: unknown[] } { + return typeof value === 'object' && value !== null && Array.isArray((value as { models?: unknown }).models) +} + +export async function fetchCodexModelsManifest( + baseUrl: string, + apiKey: string, + signal?: AbortSignal +): Promise { + const response = await fetch(buildCodexModelsManifestUrl(baseUrl), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}` + }, + cache: 'no-store', + signal + }) + + if (!response.ok) { + throw new Error(`Codex models request failed with status ${response.status}`) + } + + const payload: unknown = await response.json() + if (!isCodexModelsManifest(payload)) { + throw new Error('Codex models response is not a valid manifest') + } + + return { + content: JSON.stringify(payload, null, 2), + modelCount: payload.models.length + } +} diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 2c9621f17f..ba30abfdac 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1402,7 +1402,12 @@
- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -1884,7 +1889,12 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ t('admin.accounts.supportsAllModels') }} @@ -2220,7 +2230,12 @@

- +

{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }} {{ @@ -4072,11 +4087,17 @@ const syncPreviewCredentials = computed(() => { const baseUrl = isCNPlatform.value && apiProtocol.value === 'adaptive' ? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim() : apiKeyBaseUrl.value.trim() + const modelMapping = buildModelMappingObject( + modelRestrictionMode.value, + allowedModels.value, + modelMappings.value + ) return { platform: form.platform, type: form.type, base_url: baseUrl || undefined, - api_key: apiKeyValue.value + api_key: apiKeyValue.value, + ...(modelMapping ? { model_mapping: modelMapping } : {}) } }) @@ -4093,6 +4114,7 @@ const modelMappings = ref([]) const openAICompactModelMappings = ref([]) const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const allowedModels = ref([]) +const upstreamModelsPreviewed = ref(false) const DEFAULT_POOL_MODE_RETRY_COUNT = 3 const MAX_POOL_MODE_RETRY_COUNT = 10 const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429] @@ -4579,6 +4601,7 @@ watch( } // Clear model-related settings allowedModels.value = [] + upstreamModelsPreviewed.value = false modelMappings.value = [] // Antigravity: 默认使用映射模式并填充默认映射 if (newPlatform === 'antigravity') { @@ -4970,6 +4993,23 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => { submitting.value = true try { const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload)) + const modelMapping = payload.credentials.model_mapping + const hasConcreteMappedTarget = payload.type === 'apikey' && + typeof modelMapping === 'object' && + modelMapping !== null && + Object.values(modelMapping).some((target) => + typeof target === 'string' && target.trim() !== '' && !target.includes('*') + ) + if (upstreamModelsPreviewed.value || hasConcreteMappedTarget) { + try { + const result = await adminAPI.accounts.syncUpstreamModels(account.id) + if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) + } + } catch { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsFailed')) + } + } if ( payload.type === 'apikey' && payload.upstream_billing_probe_enabled === true @@ -5110,6 +5150,7 @@ const resetForm = () => { grokOAuth.resetState() oauthFlowRef.value?.reset() antigravityMixedChannelConfirmed.value = false + upstreamModelsPreviewed.value = false clearMixedChannelDialog() } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index e93d4726e1..b1a6d10e97 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -4132,6 +4132,10 @@ const syncAntigravityUpstreamModels = async () => { } } + if (result.warnings?.some((warning) => warning.code === 'upstream_model_metadata_incomplete')) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) + return + } if (addedCount > 0) { appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length })) } else { diff --git a/frontend/src/components/account/ModelWhitelistSelector.vue b/frontend/src/components/account/ModelWhitelistSelector.vue index b6086c6d6b..a3b1309fbf 100644 --- a/frontend/src/components/account/ModelWhitelistSelector.vue +++ b/frontend/src/components/account/ModelWhitelistSelector.vue @@ -172,6 +172,7 @@ const props = defineProps<{ const emit = defineEmits<{ 'update:modelValue': [value: string[]] + 'upstream-synced': [] }>() const appStore = useAppStore() @@ -312,6 +313,10 @@ const syncUpstreamModels = async () => { return } + if (!props.accountId) { + emit('upstream-synced') + } + const newModels = [...props.modelValue] let addedCount = 0 for (const model of upstreamModels) { @@ -322,6 +327,10 @@ const syncUpstreamModels = async () => { } emit('update:modelValue', newModels) + if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) { + appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete')) + return + } if (addedCount > 0) { appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length })) } else { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index e5a0c8c9c1..26305c4233 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -5,12 +5,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { createAccountMock, probeUpstreamBillingMock, + syncUpstreamModelsMock, + showWarningMock, importCodexSessionMock, createOpenAICodexPATMock, authIsSimpleMode, } = vi.hoisted(() => ({ createAccountMock: vi.fn(), probeUpstreamBillingMock: vi.fn(), + syncUpstreamModelsMock: vi.fn(), + showWarningMock: vi.fn(), importCodexSessionMock: vi.fn(), createOpenAICodexPATMock: vi.fn(), authIsSimpleMode: { value: true }, @@ -20,7 +24,7 @@ vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn(), - showWarning: vi.fn(), + showWarning: showWarningMock, }), })) @@ -37,6 +41,7 @@ vi.mock('@/api/admin', () => ({ accounts: { create: createAccountMock, probeUpstreamBilling: probeUpstreamBillingMock, + syncUpstreamModels: syncUpstreamModelsMock, checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }), importCodexSession: importCodexSessionMock, createOpenAICodexPAT: createOpenAICodexPATMock, @@ -120,8 +125,12 @@ const ModelWhitelistSelectorStub = defineComponent({ platform: String, syncCredentials: Object, }, - emits: ['update:modelValue'], - template: '

', + emits: ['update:modelValue', 'upstream-synced'], + template: ``, }) function mountModal(groups: any[] = []) { @@ -190,6 +199,8 @@ describe('CreateAccountModal OpenAI long-context billing', () => { authIsSimpleMode.value = true createAccountMock.mockReset().mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' }) probeUpstreamBillingMock.mockReset().mockResolvedValue({}) + syncUpstreamModelsMock.mockReset().mockResolvedValue({ models: [], metadata: {} }) + showWarningMock.mockReset() importCodexSessionMock.mockReset().mockResolvedValue({ created: 1, updated: 0, @@ -236,6 +247,71 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + it('persists upstream model metadata after creating an account from preview', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledOnce() + expect(syncUpstreamModelsMock).toHaveBeenCalledWith(42) + }) + + it('includes the current concrete model mapping in preview credentials', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click') + await flushPromises() + + expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({ + model_mapping: { 'public-glm': 'public-glm' } + }) + }) + + it('runs formal capability sync after creating an account with explicit mappings', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('Mapped account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await selectButtonByText(wrapper, 'admin.accounts.modelMapping') + await selectButtonByText(wrapper, 'admin.accounts.addMapping') + await wrapper.get('input[placeholder="admin.accounts.requestModel"]').setValue('public-glm') + await wrapper.get('input[placeholder="admin.accounts.actualModel"]').setValue('glm-5.3') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock.mock.calls[0]?.[0]?.credentials?.model_mapping).toEqual({ + 'public-glm': 'glm-5.3' + }) + expect(syncUpstreamModelsMock).toHaveBeenCalledWith(42) + }) + + it('warns when post-create capability metadata remains incomplete', async () => { + syncUpstreamModelsMock.mockResolvedValue({ + models: ['x-preview-f-free'], + warnings: [{ code: 'upstream_model_metadata_incomplete', message: 'metadata incomplete' }], + }) + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(showWarningMock).toHaveBeenCalledWith( + 'admin.accounts.syncUpstreamModelsMetadataIncomplete' + ) + }) + // namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平 it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => { const wrapper = mountModal() diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index 51f18611cc..928ab6e721 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -1,7 +1,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -const copyToClipboard = vi.fn().mockResolvedValue(true) +const { + copyToClipboard, + showError, + showSuccess, + showInfo, + showWarning, + syncUpstreamModels, + syncUpstreamModelsPreview +} = vi.hoisted(() => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), + showError: vi.fn(), + showSuccess: vi.fn(), + showInfo: vi.fn(), + showWarning: vi.fn(), + syncUpstreamModels: vi.fn(), + syncUpstreamModelsPreview: vi.fn() +})) vi.mock('vue-i18n', async () => { const actual = await vi.importActual('vue-i18n') @@ -15,12 +31,20 @@ vi.mock('vue-i18n', async () => { vi.mock('@/stores/app', () => ({ useAppStore: () => ({ - showError: vi.fn(), - showSuccess: vi.fn(), - showInfo: vi.fn() + showError, + showSuccess, + showInfo, + showWarning }) })) +vi.mock('@/api/admin/accounts', () => ({ + accountsAPI: { + syncUpstreamModels, + syncUpstreamModelsPreview + } +})) + vi.mock('@/composables/useClipboard', () => ({ useClipboard: () => ({ copyToClipboard @@ -29,11 +53,12 @@ vi.mock('@/composables/useClipboard', () => ({ import ModelWhitelistSelector from '../ModelWhitelistSelector.vue' -function mountSelector() { +function mountSelector(props: Record = {}) { return mount(ModelWhitelistSelector, { props: { modelValue: [], - platform: 'openai' + platform: 'openai', + ...props, }, global: { stubs: { @@ -58,6 +83,12 @@ function findModelRow(wrapper: ReturnType, modelId: string describe('ModelWhitelistSelector', () => { beforeEach(() => { copyToClipboard.mockClear() + showError.mockReset() + showSuccess.mockReset() + showInfo.mockReset() + showWarning.mockReset() + syncUpstreamModels.mockReset() + syncUpstreamModelsPreview.mockReset() }) it('copies a model ID without selecting the model', async () => { @@ -86,4 +117,71 @@ describe('ModelWhitelistSelector', () => { expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-5.6-sol']]]) expect(copyToClipboard).not.toHaveBeenCalled() }) + + it('warns when model IDs sync but capability metadata is incomplete', async () => { + syncUpstreamModels.mockResolvedValue({ + models: ['x-preview-f-free'], + warnings: [ + { + code: 'upstream_model_metadata_incomplete', + message: 'Model IDs were synced, but capability metadata could not be updated.' + } + ] + }) + const wrapper = mount(ModelWhitelistSelector, { + props: { + modelValue: [], + platform: 'openai', + accountId: 46 + }, + global: { + stubs: { + ModelIcon: true + } + } + }) + + const syncButton = wrapper + .findAll('button') + .find(button => button.text() === 'admin.accounts.syncUpstreamModels') + expect(syncButton).toBeDefined() + await syncButton!.trigger('click') + await flushPromises() + + expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]]) + expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataIncomplete') + expect(showSuccess).not.toHaveBeenCalled() + }) + + it('reports a successful preview so account creation can persist metadata', async () => { + syncUpstreamModelsPreview.mockResolvedValue({ + models: ['x-preview-f-free'], + metadata: { + 'x-preview-f-free': { + id: 'x-preview-f-free', + reasoning: true, + supported_reasoning_levels: ['low', 'high', 'max'], + }, + }, + }) + const wrapper = mountSelector({ + syncCredentials: { + platform: 'openai', + type: 'apikey', + base_url: 'https://opencode.ai/zen/v1', + api_key: 'test-key', + }, + }) + const syncButton = wrapper + .findAll('button') + .find(button => button.text() === 'admin.accounts.syncUpstreamModels') + + expect(syncButton).toBeDefined() + await syncButton?.trigger('click') + await flushPromises() + + expect(syncUpstreamModelsPreview).toHaveBeenCalledOnce() + expect(wrapper.emitted('upstream-synced')).toEqual([[]]) + expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]]) + }) }) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index c8ae79d229..fc5356cf5e 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -172,6 +172,65 @@
+
+
+
+

+ {{ t('keys.useKeyModal.codexModelCatalog.title') }} +

+

+ {{ t('keys.useKeyModal.codexModelCatalog.description') }} +

+

+ {{ codexModelCatalogPath }} +

+
+ + +
+

+ {{ t('keys.useKeyModal.codexModelCatalog.modelsCount', { count: codexModelManifestModelCount }) }} +

+

+ {{ t('keys.useKeyModal.codexModelCatalog.errorDescription') }} +

+
+
@@ -198,10 +257,18 @@