From 2abce65031d1ea6deb950ee7b52b19bf9143ba28 Mon Sep 17 00:00:00 2001 From: Long Li Date: Wed, 26 Aug 2026 12:46:31 +0900 Subject: [PATCH] fix(codex): harden routed catalog capability sync --- .../internal/handler/admin/account_handler.go | 18 +- .../account_handler_available_models_test.go | 54 +++++ .../service/openai_codex_models_service.go | 206 +++++++++++++++-- .../openai_codex_models_service_test.go | 216 +++++++++++++++++- backend/internal/service/upstream_models.go | 2 +- .../internal/service/upstream_models_test.go | 52 ++++- frontend/src/api/admin/accounts.ts | 1 + .../components/account/CreateAccountModal.vue | 17 +- .../__tests__/CreateAccountModal.spec.ts | 38 ++- frontend/src/components/keys/UseKeyModal.vue | 66 +++++- .../keys/__tests__/UseKeyModal.spec.ts | 39 ++++ frontend/src/i18n/locales/en/dashboard.ts | 5 + frontend/src/i18n/locales/zh/dashboard.ts | 5 + 13 files changed, 677 insertions(+), 42 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 6a83405cc0..599a04a34e 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2802,22 +2802,28 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) { // 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, }, } 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 2942f0578b..28dc6d1492 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -74,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 } @@ -393,6 +394,59 @@ func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) 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(), diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 832a0f7c6c..95be9ebbaf 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -94,10 +94,12 @@ func codexProviderQualifiedModelID(modelID string) string { // 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 @@ -117,7 +119,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( if err != nil { return nil, false, fmt.Errorf("load group configured Codex models: %w", err) } - configuredModels := openAIConfiguredCodexModelIDs(visible) + configuredModels := openAIConfiguredCodexModelIDsForGroup(visible, group) if len(configuredModels) == 0 { return nil, false, nil } @@ -169,7 +171,7 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( return nil } - configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group.ID) + configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group) if err != nil { return fmt.Errorf("load group configured Codex models: %w", err) } @@ -193,12 +195,15 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( return nil } -func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, groupID int64) ([]string, error) { - accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID) +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 openAIConfiguredCodexModelIDs(accounts), nil + return openAIConfiguredCodexModelIDsForGroup(accounts, group), nil } // loadCodexGroupCatalogAccounts separates picker membership from capability @@ -248,6 +253,41 @@ func openAIConfiguredCodexModelIDs(accounts []Account) []string { 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." @@ -1342,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 { @@ -1669,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{ @@ -1714,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) { @@ -1848,21 +1900,140 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 { return nil } - body, err := completeAPIKeyCodexModelsManifestMetadata( - manifest.Body, + 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 } - if !bytes.Equal(body, manifest.Body) { - manifest.Body = body - manifest.ETag = codexModelsManifestBodyETag(body) + 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 { @@ -2059,6 +2230,9 @@ func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifes if manifest.Body != nil { cloned.Body = append([]byte(nil), manifest.Body...) } + if manifest.upstreamSourceBody != nil { + cloned.upstreamSourceBody = append([]byte(nil), manifest.upstreamSourceBody...) + } return &cloned } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index d02e59ddb4..77a65cfb38 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -971,6 +971,38 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t 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() @@ -1630,6 +1662,41 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { 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) { upstreamBody := `{"object":"list","data":[{"id":"gpt-5.6","object":"model"},{"id":" ","object":"model"},{"id":"gpt-5.6-codex","object":"model"}]}` upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { @@ -1702,6 +1769,134 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t * 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() @@ -2288,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{ @@ -2312,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++ { @@ -2324,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) } } diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 364d1dc16c..d3d88c4269 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -1241,7 +1241,7 @@ func normalizeReasoningLevel(level string) string { return "none" case "extra-high", "extra_high": return "xhigh" - case "none", "minimal", "low", "medium", "high", "xhigh", "max": + case "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra": return level default: return "" diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index dfcaac7158..b358406b8b 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -617,7 +617,7 @@ func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) { "display_name":"Upstream Display", "description":"Upstream description", "default_reasoning_level":"high", - "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}], + "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}], "input_modalities":["text","image"], "context_window":256000 }]}`)), @@ -634,11 +634,59 @@ func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) { 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"}, metadata.SupportedReasoningLevels) + 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{ diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index d694bf1333..ee053ad9ec 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -577,6 +577,7 @@ export interface SyncUpstreamPreviewParams { type: string base_url?: string api_key: string + model_mapping?: Record } /** diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index f2264928ca..ba30abfdac 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -4087,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 } : {}) } }) @@ -4987,7 +4993,14 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => { submitting.value = true try { const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload)) - if (upstreamModelsPreviewed.value) { + 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')) { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 9288dfda16..26305c4233 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -126,7 +126,11 @@ const ModelWhitelistSelectorStub = defineComponent({ syncCredentials: Object, }, emits: ['update:modelValue', 'upstream-synced'], - template: '', + template: ``, }) function mountModal(groups: any[] = []) { @@ -257,6 +261,38 @@ describe('CreateAccountModal OpenAI long-context billing', () => { 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'], diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 8f540c4059..fc5356cf5e 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -315,8 +315,8 @@ let codexModelManifestRequestID = 0 const showCodexModelCatalog = computed(() => props.show && - ((props.platform === 'openai' && (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws')) || - ((props.platform === 'grok' || props.platform === 'deepseek' || props.platform === 'composite') && activeClientTab.value === 'codex')) + (activeClientTab.value === 'codex' || + (props.platform === 'openai' && activeClientTab.value === 'codex-ws')) ) const codexModelCatalogPath = computed(() => { @@ -451,12 +451,14 @@ const clientTabs = computed((): TabConfig[] => { case 'gemini': return [ { id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon }, + { id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon }, { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } ] case 'antigravity': return [ { id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }, { id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon }, + { id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon }, { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } ] case 'grok': @@ -476,6 +478,7 @@ const clientTabs = computed((): TabConfig[] => { default: return [ { id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon }, + { id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon }, { id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon } ] } @@ -510,6 +513,13 @@ const currentTabs = computed(() => { }) const platformDescription = computed(() => { + if (activeClientTab.value === 'codex' && + props.platform !== 'openai' && + props.platform !== 'grok' && + props.platform !== 'deepseek' && + props.platform !== 'composite') { + return t('keys.useKeyModal.routedCodex.description') + } switch (props.platform) { case 'openai': if (activeClientTab.value === 'claude') { @@ -542,6 +552,13 @@ const platformDescription = computed(() => { }) const platformNote = computed(() => { + if (activeClientTab.value === 'codex' && + props.platform !== 'openai' && + props.platform !== 'grok' && + props.platform !== 'deepseek' && + props.platform !== 'composite') { + return t('keys.useKeyModal.routedCodex.note') + } switch (props.platform) { case 'openai': if (activeClientTab.value === 'claude') { @@ -715,8 +732,14 @@ const currentFiles = computed((): FileConfig[] => { } return generateOpenAIFiles(baseUrl, apiKey) case 'gemini': + if (activeClientTab.value === 'codex') { + return generateRoutedCodexFiles(apiBase, apiKey, 'gemini') + } return [generateGeminiCliContent(baseUrl, apiKey)] case 'antigravity': + if (activeClientTab.value === 'codex') { + return generateRoutedCodexFiles(apiBase, apiKey, 'antigravity') + } if (activeClientTab.value === 'gemini') { return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)] } @@ -740,6 +763,9 @@ const currentFiles = computed((): FileConfig[] => { } return generateAnthropicFiles(baseRoot, apiKey) default: + if (activeClientTab.value === 'codex' && props.platform) { + return generateRoutedCodexFiles(apiBase, apiKey, props.platform) + } return generateAnthropicFiles(baseUrl, apiKey) } }) @@ -1176,13 +1202,35 @@ supports_websockets = false function generateRoutedCodexFiles( baseUrl: string, apiKey: string, - platform: 'deepseek' | 'composite' + platform: GroupPlatform ): FileConfig[] { const isWindows = activeTab.value === 'windows' const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex' - const preferredModel = platform === 'deepseek' ? 'deepseek-v4-pro' : 'gpt-5.5' + const preferredModels: Partial> = { + openai: 'gpt-5.5', + anthropic: 'claude-sonnet-4-6', + gemini: 'gemini-2.5-pro', + antigravity: 'claude-sonnet-4-6', + grok: 'grok-4.5', + kimi: 'kimi-k2.5', + zhipu: 'glm-4.7', + deepseek: 'deepseek-v4-pro', + composite: 'gpt-5.5' + } + const preferredModel = preferredModels[platform] || '' const model = selectCodexCatalogModel(preferredModel) - const label = platform === 'deepseek' ? 'DeepSeek' : 'Composite' + const labels: Record = { + anthropic: 'Anthropic', + openai: 'OpenAI', + gemini: 'Gemini', + antigravity: 'Antigravity', + grok: 'Grok', + kimi: 'Kimi', + zhipu: 'Zhipu', + deepseek: 'DeepSeek', + composite: 'Composite' + } + const label = labels[platform] const envContent = isWindows ? `$env:SUB2API_API_KEY="${apiKey}"` : `export SUB2API_API_KEY="${apiKey}"` @@ -1195,7 +1243,7 @@ disable_response_storage = true model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}" [model_providers.sub2api] -name = "OpenAI" +name = "Sub2API ${label}" base_url = "${baseUrl}" env_key = "SUB2API_API_KEY" wire_api = "responses" @@ -1207,7 +1255,11 @@ supports_websockets = false` { path: joinConfigPath(configDir, 'config.toml', isWindows), content: configContent, - hint: t(`keys.useKeyModal.${platform}.codexConfigTomlHint`) + hint: t( + platform === 'deepseek' || platform === 'composite' + ? `keys.useKeyModal.${platform}.codexConfigTomlHint` + : 'keys.useKeyModal.routedCodex.configTomlHint' + ) } ] } diff --git a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts index f4baf80fa9..4da87d9e79 100644 --- a/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts +++ b/frontend/src/components/keys/__tests__/UseKeyModal.spec.ts @@ -714,6 +714,45 @@ describe('UseKeyModal', () => { ) }) + it.each(['anthropic', 'gemini', 'antigravity', 'kimi', 'zhipu'] as const)( + 'offers Codex catalog configuration for the %s routed group', + async (platform) => { + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: `sk-${platform}-test`, + baseUrl: 'https://example.com/v1', + platform + }, + global: { + stubs: { + BaseDialog: { + template: '
' + }, + Icon: { + template: '' + } + } + } + }) + + const codexTab = wrapper.findAll('button').find((button) => + button.text().includes('keys.useKeyModal.cliTabs.codexCli') + ) + expect(codexTab).toBeDefined() + await codexTab!.trigger('click') + await nextTick() + + expect(wrapper.find('[data-testid="codex-model-catalog"]').exists()).toBe(true) + const config = wrapper.findAll('pre code') + .map((code) => code.text()) + .find((content) => content.includes('[model_providers.sub2api]')) + expect(config).toContain('model_catalog_json = "~/.codex/codex-models.json"') + expect(config).toContain('base_url = "https://example.com/v1"') + expect(config).toContain('wire_api = "responses"') + } + ) + // Scenario: the platform-preferred model remains selected when the downloaded catalog contains it. it('keeps the preferred Composite default when it exists in the catalog', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 0899f3a6b1..052e121525 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -200,6 +200,11 @@ export default { codexConfigTomlHint: 'Download the model catalog below, save both files under the Codex config directory, and restart Codex.', codexNote: 'Export SUB2API_API_KEY before starting Codex. Model requests are routed by the selected catalog slug.', }, + routedCodex: { + description: 'Configure Codex with the complete model catalog for the current routed group.', + configTomlHint: 'Download the model catalog below, save both files under the Codex config directory, and restart Codex.', + note: 'Export SUB2API_API_KEY before starting Codex. The downloaded catalog contains model metadata only, not your API key.', + }, codexModelCatalog: { title: 'Codex model catalog', description: 'Fetch with this API key, then save the catalog at the path referenced by config.toml.', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 501e9dbc93..7bc7fb56fa 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -204,6 +204,11 @@ export default { codexConfigTomlHint: '下载下方模型目录,将两个文件保存到 Codex 配置目录后重启 Codex。', codexNote: '启动 Codex 前先导出 SUB2API_API_KEY;分组会根据目录中选中的模型路由请求。' }, + routedCodex: { + description: '使用当前路由分组的完整模型目录配置 Codex。', + configTomlHint: '下载下方模型目录,将两个文件保存到 Codex 配置目录后重启 Codex。', + note: '启动 Codex 前先导出 SUB2API_API_KEY。下载的目录只包含模型元数据,不包含 API Key。' + }, codexModelCatalog: { title: 'Codex 模型目录', description: '使用当前 API Key 获取目录,并保存到 config.toml 引用的路径。',