From cb31033972023e596d1f16f6fd944b3ca09a6478 Mon Sep 17 00:00:00 2001 From: Zero Clover Date: Sat, 5 Sep 2026 19:58:47 +0800 Subject: [PATCH] feat(openai): reuse pinned accounts for standard model discovery --- backend/internal/handler/gateway_handler.go | 6 + .../handler/openai_codex_models_handler.go | 62 ++- .../internal/handler/openai_models_handler.go | 52 +++ .../handler/openai_models_handler_test.go | 186 +++++++++ .../routes/gateway_models_pinned_test.go | 94 +++++ backend/internal/service/group.go | 2 +- .../service/openai_codex_models_pinned.go | 109 +++--- .../service/openai_codex_models_service.go | 163 ++++---- .../openai_codex_models_service_test.go | 72 ++-- .../service/openai_gateway_service.go | 2 +- .../internal/service/openai_models_list.go | 360 ++++++++++++++++++ .../service/openai_models_list_test.go | 348 +++++++++++++++++ backend/internal/service/upstream_models.go | 8 +- .../src/i18n/locales/en/admin/overview.ts | 8 +- .../src/i18n/locales/zh/admin/overview.ts | 8 +- .../.openspec.yaml | 2 + .../codex-manifest-pinned-accounts/design.md | 98 +++++ .../proposal.md | 30 ++ .../specs/codex-manifest-cache/spec.md | 71 ++++ .../codex-manifest-pinned-accounts/spec.md | 138 +++++++ .../codex-manifest-pinned-accounts/tasks.md | 57 +++ .../verification.md | 23 ++ 22 files changed, 1700 insertions(+), 199 deletions(-) create mode 100644 backend/internal/handler/openai_models_handler.go create mode 100644 backend/internal/handler/openai_models_handler_test.go create mode 100644 backend/internal/server/routes/gateway_models_pinned_test.go create mode 100644 backend/internal/service/openai_models_list.go create mode 100644 backend/internal/service/openai_models_list_test.go create mode 100644 openspec/changes/codex-manifest-pinned-accounts/.openspec.yaml create mode 100644 openspec/changes/codex-manifest-pinned-accounts/design.md create mode 100644 openspec/changes/codex-manifest-pinned-accounts/proposal.md create mode 100644 openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-cache/spec.md create mode 100644 openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-pinned-accounts/spec.md create mode 100644 openspec/changes/codex-manifest-pinned-accounts/tasks.md create mode 100644 openspec/changes/codex-manifest-pinned-accounts/verification.md diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 941a73f53c..9e56d57eba 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1134,6 +1134,12 @@ func (h *GatewayHandler) Models(c *gin.Context) { platform = forcedPlatform } + if platform == service.PlatformOpenAI && apiKey != nil && apiKey.Group != nil && + apiKey.Group.Platform == service.PlatformOpenAI && apiKey.Group.CodexModelsManifestConfig.Enabled { + h.pinnedOpenAIModels(c, apiKey.Group) + return + } + if platform == service.PlatformComposite { availableModels := h.compositeAvailableModels(c.Request.Context(), groupID) if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() { diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 73d8951ca9..777eeebdb1 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -16,7 +16,8 @@ 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. Groups with explicit account model mappings are generated locally; +// here. Pinned discovery takes precedence over local account model mappings; +// when disabled, groups with explicit 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) { @@ -34,28 +35,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { } 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 - } - // 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器; // 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。 if apiKey.Group.Platform == service.PlatformOpenAI && - apiKey.Group.CodexModelsManifestConfig.Enabled && - len(apiKey.Group.CodexModelsManifestConfig.AccountIDs) > 0 { + apiKey.Group.CodexModelsManifestConfig.Enabled { pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest( c.Request.Context(), apiKey.Group, @@ -84,7 +67,26 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { if c.Request.Context().Err() != nil { return } - writeCodexModelsManifestResponse(c, pinnedManifest) + writeOpenAIModelsResponse(c, pinnedManifest) + return + } + } + + if !apiKey.Group.CodexModelsManifestConfig.Enabled { + 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 { + writeOpenAIModelsResponse(c, configuredManifest) return } } @@ -133,6 +135,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest") return } + if err := service.ApplyPinnedCodexModelsMapping(manifest, account, apiKey.Group); err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply model mappings") + 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 @@ -141,19 +147,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { return } - writeCodexModelsManifestResponse(c, manifest) + writeOpenAIModelsResponse(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_models_handler.go b/backend/internal/handler/openai_models_handler.go new file mode 100644 index 0000000000..c20a49d0cb --- /dev/null +++ b/backend/internal/handler/openai_models_handler.go @@ -0,0 +1,52 @@ +package handler + +import ( + "errors" + "net/http" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group) { + if c.Request.Context().Err() != nil { + return + } + if h.openAIGatewayService == nil { + writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "OpenAI model discovery is not configured") + return + } + response, account, err := h.openAIGatewayService.FetchPinnedOpenAIModelsList( + c.Request.Context(), group, h.maxAccountSwitches, c.GetHeader("If-None-Match"), + ) + if c.Request.Context().Err() != nil { + return + } + if err != nil { + if errors.Is(err, service.ErrNoPinnedCodexModelsAccounts) { + writeOpenAIModelsError(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI model discovery accounts") + return + } + writeOpenAIModelsError(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err)) + return + } + setOpsSelectedAccount(c, account.ID, account.Platform) + writeOpenAIModelsResponse(c, response) +} + +func writeOpenAIModelsError(c *gin.Context, status int, errorType, message string) { + c.JSON(status, gin.H{"error": gin.H{"type": errorType, "message": message}}) +} + +func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) { + 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_models_handler_test.go b/backend/internal/handler/openai_models_handler_test.go new file mode 100644 index 0000000000..24730fca9b --- /dev/null +++ b/backend/internal/handler/openai_models_handler_test.go @@ -0,0 +1,186 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + 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" +) + +func performOrdinaryPinnedModelsRequest(t *testing.T, codex *OpenAIGatewayHandler, group *service.Group, path, etag string) *httptest.ResponseRecorder { + t.Helper() + h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, path, nil) + c.Request.Header.Set("If-None-Match", etag) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group}) + h.Models(c) + return recorder +} + +func ordinaryPinnedModelIDs(t *testing.T, recorder *httptest.ResponseRecorder) []string { + t.Helper() + var response gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.NotNil(t, response.Data, "an empty catalog must be an array, not null") + return modelIDsForTest(response.Data) +} + +func TestOrdinaryPinnedModelsUsesSelectedAccountsAndFinalETag(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, true), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 1: `{"data":[{"id":"unselected"}]}`, + 2: `{"data":[{"id":"special-model","owned_by":"first","created":123},{"id":"gpt-image-1"}]}`, + 3: `{"data":[{"id":"special-model","owned_by":"second"},{"id":"text-embedding-3-large"}]}`, + }} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 91, Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 3}}} + first := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + require.Equal(t, []string{"special-model", "gpt-image-1", "text-embedding-3-large"}, ordinaryPinnedModelIDs(t, first)) + var response gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &response)) + require.Equal(t, "model", response.Data[0].Object) + require.Equal(t, "first", response.Data[0].OwnedBy) + require.EqualValues(t, 123, response.Data[0].Created) + require.Equal(t, []int64{2, 3}, upstream.accountIDs()) + alias := performOrdinaryPinnedModelsRequest(t, h, group, "/models", first.Header().Get("ETag")) + require.Equal(t, http.StatusNotModified, alias.Code) + require.Empty(t, alias.Body.String()) + require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "fresh requests must not hit upstream") + + otherGroup := *group + otherGroup.ID++ + otherGroup.ModelsListConfig = service.GroupModelsListConfig{Enabled: true, Models: []string{"text-embedding-3-large", "special-model"}} + filtered := performOrdinaryPinnedModelsRequest(t, h, &otherGroup, "/v1/models", first.Header().Get("ETag")) + require.Equal(t, http.StatusOK, filtered.Code) + require.Equal(t, []string{"text-embedding-3-large", "special-model"}, ordinaryPinnedModelIDs(t, filtered)) + require.NotEqual(t, first.Header().Get("ETag"), filtered.Header().Get("ETag")) + again := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, first.Body.String(), again.Body.String(), "group filters must not modify account cache") + require.Equal(t, []int64{2, 3}, upstream.accountIDs()) +} + +func TestOrdinaryPinnedModelsFailureAndEmptyPolicies(t *testing.T) { + for _, tc := range []struct { + name string + accountIDs []int64 + bodies map[int64]string + statuses map[int64]int + fallback bool + selected []string + wantStatus int + wantIDs []string + }{ + {name: "partial failure", accountIDs: []int64{2, 3}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, statuses: map[int64]int{3: 503}, wantStatus: 200, wantIDs: []string{"ok"}}, + {name: "all failed", accountIDs: []int64{2, 3}, statuses: map[int64]int{2: 429, 3: 504}, wantStatus: 502}, + {name: "missing members", accountIDs: []int64{99}, wantStatus: 503}, + {name: "empty config", wantStatus: 503}, + {name: "fallback from missing members", accountIDs: []int64{99}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}}, + {name: "fallback from failure", accountIDs: []int64{2}, statuses: map[int64]int{2: 503}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}}, + {name: "valid empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[]}`}, fallback: true, wantStatus: 200}, + {name: "filtered empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, selected: []string{"absent"}, fallback: true, wantStatus: 200}, + {name: "invalid envelope", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"error":"denied"}`}, wantStatus: 502}, + {name: "null data", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":null}`}, wantStatus: 502}, + } { + t.Run(tc.name, func(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + bodies := map[int64]string{1: `{"data":[{"id":"from-scheduler"}]}`} + for id, body := range tc.bodies { + bodies[id] = body + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: bodies, statuses: tc.statuses} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 92, Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{Enabled: len(tc.selected) > 0, Models: tc.selected}, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: tc.accountIDs, FallbackToScheduler: tc.fallback}} + recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String()) + if tc.wantStatus == 200 { + require.Equal(t, len(tc.wantIDs), len(ordinaryPinnedModelIDs(t, recorder))) + if len(tc.wantIDs) > 0 { + require.Equal(t, tc.wantIDs, ordinaryPinnedModelIDs(t, recorder)) + } else { + require.NotContains(t, upstream.accountIDs(), int64(1), "empty success must not trigger fallback") + } + } + }) + } +} + +func TestOrdinaryPinnedModelsSkipsPersistentlyUnavailableAccounts(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, true), + newPinnedCodexAccount(2, service.StatusActive, false, false), + newPinnedCodexAccount(3, service.StatusDisabled, true, false), + newPinnedCodexAccount(4, service.StatusActive, true, false), + } + expired := time.Now().Add(-time.Hour) + accounts[3].ExpiresAt = &expired + accounts[3].AutoPauseOnExpired = true + overloaded := time.Now().Add(time.Hour) + accounts[0].OverloadUntil = &overloaded + accounts[0].TempUnschedulableUntil = &overloaded + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: `{"data":[{"id":"available"}]}`}} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 93, Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1, 2, 3, 4, 99}}} + recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []int64{1}, upstream.accountIDs()) +} + +func TestPinnedModelsMappingFollowsUpstreamDiscoveryForBothRepresentations(t *testing.T) { + for _, codex := range []bool{false, true} { + t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, false), + } + accounts[0].Credentials["model_mapping"] = map[string]any{"unselected-alias": "gpt-5.5"} + accounts[1].Credentials["model_mapping"] = map[string]any{ + "public-alias": "gpt-5.5", "missing-alias": "missing-upstream", "custom-*": "gpt-5.5", + } + body := `{"data":[{"id":"gpt-5.5","owned_by":"provider","created":123},{"id":"unmapped-upstream"}]}` + if codex { + body = `{"models":[{"slug":"gpt-5.5","context_window":424242},{"slug":"unmapped-upstream"}]}` + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{2: body}} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 94, Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{Enabled: true, Models: []string{"custom-concrete", "public-alias", "unselected-alias", "missing-alias"}}, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}} + var recorder *httptest.ResponseRecorder + if codex { + recorder = performPinnedCodexModelsRequest(t, h, group, "") + } else { + recorder = performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + } + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []int64{2}, upstream.accountIDs(), "mappings must not bypass pinned discovery") + if codex { + require.Equal(t, []string{"custom-concrete", "public-alias"}, codexHandlerManifestSlugs(t, recorder)) + require.Contains(t, recorder.Body.String(), `"context_window":424242`) + } else { + require.Equal(t, []string{"custom-concrete", "public-alias"}, ordinaryPinnedModelIDs(t, recorder)) + require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`) + } + }) + } +} diff --git a/backend/internal/server/routes/gateway_models_pinned_test.go b/backend/internal/server/routes/gateway_models_pinned_test.go new file mode 100644 index 0000000000..5fab905059 --- /dev/null +++ b/backend/internal/server/routes/gateway_models_pinned_test.go @@ -0,0 +1,94 @@ +package routes + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler" + servermiddleware "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 pinnedModelsRoutesRepository struct { + service.AccountRepository + account service.Account +} + +func (r *pinnedModelsRoutesRepository) ListByGroup(context.Context, int64) ([]service.Account, error) { + return []service.Account{r.account}, nil +} + +type pinnedModelsRoutesUpstream struct { + service.HTTPUpstream + ordinaryCalls atomic.Int32 + codexCalls atomic.Int32 +} + +func (u *pinnedModelsRoutesUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + body := `{"data":[{"id":"ordinary-upstream-model"}]}` + if req.URL.Query().Has("client_version") { + u.codexCalls.Add(1) + body = `{"models":[{"slug":"gpt-5.5"}]}` + } else { + u.ordinaryCalls.Add(1) + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func TestGatewayRoutesPinnedModelsDispatchesOrdinaryAndCodexRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &pinnedModelsRoutesRepository{account: service.Account{ + ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Status: service.StatusActive, Schedulable: true, + Credentials: map[string]any{"api_key": "test-models-key", "base_url": "https://models.example/v1"}, + }} + upstream := &pinnedModelsRoutesUpstream{} + cfg := &config.Config{RunMode: config.RunModeSimple} + s := service.NewOpenAIGatewayService(repo, nil, nil, nil, nil, nil, nil, cfg, + nil, nil, nil, nil, nil, upstream, nil, nil, nil, nil, nil, nil, nil, nil) + h := &handler.Handlers{ + Gateway: handler.NewGatewayHandler(nil, s, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil), + OpenAIGateway: handler.NewOpenAIGatewayHandler(s, nil, nil, nil, nil, nil, nil, nil, cfg), + AsyncImage: handler.NewAsyncImageHandler(nil, nil), + } + group := &service.Group{ID: 1, Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{7}}} + router := gin.New() + RegisterGatewayRoutes(router, h, servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) { + c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group}) + c.Next() + }), nil, nil, nil, nil, nil, cfg) + for _, path := range []string{"/v1/models", "/models", "/v1/models?client_version=", "/models?client_version="} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, w.Code, "%s: %s", path, w.Body.String()) + var response struct { + Object string `json:"object"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, "list", response.Object) + require.Len(t, response.Data, 1) + require.Equal(t, "ordinary-upstream-model", response.Data[0].ID) + } + for _, path := range []string{"/v1/models?client_version=" + service.CodexCanonicalClientVersion(), "/models?client_version=" + service.CodexCanonicalClientVersion(), "/backend-api/codex/models"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, w.Code, "%s: %s", path, w.Body.String()) + require.Contains(t, w.Body.String(), `"slug":"gpt-5.5"`) + require.NotContains(t, w.Body.String(), `"data"`) + } + require.EqualValues(t, 1, upstream.ordinaryCalls.Load()) + require.EqualValues(t, 1, upstream.codexCalls.Load()) +} diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 36b3a34a63..6037a3413e 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -108,7 +108,7 @@ type Group struct { DefaultMappedModel string MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig ModelsListConfig GroupModelsListConfig - // CodexModelsManifestConfig 开启后,该分组的 Codex /models manifest 请求只用 + // CodexModelsManifestConfig 开启后,普通模型列表与 Codex manifest 优先使用 // 固定账号列表拉取并合并,不经过调度器(仅 openai 平台)。 CodexModelsManifestConfig GroupCodexModelsManifestConfig diff --git a/backend/internal/service/openai_codex_models_pinned.go b/backend/internal/service/openai_codex_models_pinned.go index 4f45f3a1e8..b74b3eafdc 100644 --- a/backend/internal/service/openai_codex_models_pinned.go +++ b/backend/internal/service/openai_codex_models_pinned.go @@ -114,18 +114,53 @@ func mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error) { // - every fetch failed → the last upstream error // - partial failure → successful accounts are still merged and a warning // naming the failed account IDs is logged. -func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Context, group *Group, clientVersion string) (*CodexModelsManifest, *Account, error) { +func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Context, group *Group, clientVersion string) (*OpenAIModelsResponse, *Account, error) { + results, err := s.fetchPinnedOpenAIModels(ctx, group, func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + manifest, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "") + if err != nil { + return nil, err + } + if err := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil { + return nil, err + } + if err := ApplyPinnedCodexModelsMapping(manifest, account, group); err != nil { + return nil, err + } + return manifest, nil + }) + if err != nil { + return nil, nil, err + } + bodies := make([][]byte, 0, len(results)) + for _, result := range results { + bodies = append(bodies, result.response.Body) + } + merged, err := mergeCodexModelsManifestBodies(bodies) + if err != nil { + return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err) + } + return &OpenAIModelsResponse{Body: merged, ETag: codexModelsManifestBodyETag(merged)}, results[0].account, nil +} + +type pinnedOpenAIModelsResult struct { + account *Account + response *OpenAIModelsResponse +} + +// fetchPinnedOpenAIModels shares membership, eligibility, fanout and partial +// failure policy between ordinary model lists and Codex manifests. +func (s *OpenAIGatewayService) fetchPinnedOpenAIModels(ctx context.Context, group *Group, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) { if s == nil || s.accountRepo == nil || group == nil { - return nil, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } cfg := group.CodexModelsManifestConfig if group.Platform != PlatformOpenAI || !cfg.Enabled || len(cfg.AccountIDs) == 0 { - return nil, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } members, err := s.accountRepo.ListByGroup(ctx, group.ID) if err != nil { - return nil, nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err) + return nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err) } memberByID := make(map[int64]Account, len(members)) for _, member := range members { @@ -145,70 +180,46 @@ func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Contex usable = append(usable, member) } if len(usable) == 0 { - return nil, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } - bodies := make([][]byte, len(usable)) + results := make([]pinnedOpenAIModelsResult, len(usable)) fetchErrs := make([]error, len(usable)) - // 各自独立完成、不取消兄弟请求,错误按下标收集,普通 WaitGroup 足够。 var fetchGroup sync.WaitGroup for i := range usable { - account := usable[i] - index := i fetchGroup.Add(1) go func() { defer fetchGroup.Done() - manifest, fetchErr := s.FetchCodexModelsManifest(ctx, &account, clientVersion, "") - if fetchErr != nil { - fetchErrs[index] = fetchErr - return - } - if completeErr := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, &account); completeErr != nil { - fetchErrs[index] = completeErr - return - } - bodies[index] = manifest.Body + response, err := fetch(ctx, &usable[i]) + results[i] = pinnedOpenAIModelsResult{account: &usable[i], response: response} + fetchErrs[i] = err }() } fetchGroup.Wait() - - successBodies := make([][]byte, 0, len(usable)) - successAccounts := make([]*Account, 0, len(usable)) - failedIDs := make([]int64, 0) - for i := range usable { - if fetchErrs[i] != nil || bodies[i] == nil { - failedIDs = append(failedIDs, usable[i].ID) - continue - } - successBodies = append(successBodies, bodies[i]) - successAccounts = append(successAccounts, &usable[i]) + if err := ctx.Err(); err != nil { + return nil, err } - if len(successBodies) == 0 { - var lastErr error - for i := range fetchErrs { + successes := make([]pinnedOpenAIModelsResult, 0, len(results)) + failedIDs := make([]int64, 0) + var lastErr error + for i, result := range results { + if fetchErrs[i] != nil || result.response == nil { + failedIDs = append(failedIDs, usable[i].ID) if fetchErrs[i] != nil { lastErr = fetchErrs[i] } + continue } + successes = append(successes, result) + } + if len(successes) == 0 { if lastErr == nil { - lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "pinned codex models manifest accounts all failed") + lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_FAILED", "pinned model discovery accounts all failed") } - return nil, nil, lastErr + return nil, lastErr } if len(failedIDs) > 0 { - slog.Warn("codex_models_manifest_pinned_partial_failure", - "group_id", group.ID, - "failed_account_ids", failedIDs, - ) + slog.Warn("openai_models_pinned_partial_failure", "group_id", group.ID, "failed_account_ids", failedIDs) } - - merged, err := mergeCodexModelsManifestBodies(successBodies) - if err != nil { - return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err) - } - firstAccount := *successAccounts[0] - return &CodexModelsManifest{ - Body: merged, - ETag: codexModelsManifestBodyETag(merged), - }, &firstAccount, nil + return successes, nil } diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 9e537a9c78..742be217ec 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -30,18 +30,18 @@ import ( var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" const ( - codexModelsManifestCacheBodyLimit = 1 << 20 - // codexModelsManifestCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」 + openAIModelsCacheBodyLimit = 1 << 20 + // openAIModelsCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」 // 估算:缓存同时覆盖 OAuth 与 API Key 账号,且缓存键含 Authorization 与 // Version 头,不同客户端版本各自占一条;64 条在大规模部署下会被淘汰导致 // 额外上游请求。单条清单通常几十 KB,512 条最坏内存占用在几十 MB 量级。 - codexModelsManifestCacheMaxEntries = 512 + openAIModelsCacheMaxEntries = 512 // 三段时效:≤TTL 为新鲜(直接返回缓存,零上游请求);TTL 到 StaleTTL 之间 // 乐观返回旧值并后台单飞刷新(携带上游 ETag,304 续期);超过 StaleTTL 丢弃 // 缓存同步等待刷新。TTL 取 1 分钟:manifest 变化低频,1 分钟内同账号重复 // 请求完全吸收;StaleTTL 取 5 分钟,控制旧内容最长可见时间在分钟级。 - codexModelsManifestCacheTTL = 60 * time.Second - codexModelsManifestCacheStaleTTL = 5 * time.Minute + openAIModelsCacheTTL = 60 * time.Second + openAIModelsCacheStaleTTL = 5 * time.Minute codexModelsManifestRequestTimeout = 15 * time.Second codexAutoModelPrefix = "codex-auto-" ) @@ -100,8 +100,9 @@ func codexProviderQualifiedModelID(modelID string) string { return strings.TrimPrefix(modelID, "models/") } -// CodexModelsManifest carries the client representation plus caching metadata. -type CodexModelsManifest struct { +// OpenAIModelsResponse carries either an OpenAI model list or a Codex manifest +// together with upstream and client caching metadata. +type OpenAIModelsResponse struct { Body []byte ETag string upstreamETag string @@ -118,7 +119,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( ctx context.Context, group *Group, ifNoneMatch string, -) (*CodexModelsManifest, bool, error) { +) (*OpenAIModelsResponse, bool, error) { if s == nil || s.accountRepo == nil || group == nil || group.Platform != PlatformOpenAI { return nil, false, nil } @@ -151,7 +152,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( if err != nil { return nil, false, fmt.Errorf("build group configured Codex models: %w", err) } - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: body, ETag: codexModelsManifestBodyETag(body), } @@ -169,7 +170,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( ctx context.Context, group *Group, - manifest *CodexModelsManifest, + manifest *OpenAIModelsResponse, ifNoneMatch string, ) error { if s == nil || s.accountRepo == nil || group == nil || manifest == nil || manifest.NotModified { @@ -179,9 +180,13 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( return nil } - configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group) - if err != nil { - return fmt.Errorf("load group configured Codex models: %w", err) + var configuredModels []string + if !group.CodexModelsManifestConfig.Enabled { + var err error + 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, @@ -192,6 +197,13 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( if err != nil { return fmt.Errorf("merge group configured Codex models: %w", err) } + if group.CodexModelsManifestConfig.Enabled && group.CustomModelsListEnabled() { + body, err = orderPinnedCodexModelsBySelection(body, group.ModelsListConfig.Models) + if err != nil { + return fmt.Errorf("order pinned Codex models: %w", err) + } + changed = true + } if changed { manifest.Body = body manifest.ETag = codexModelsManifestBodyETag(body) @@ -1477,7 +1489,7 @@ func isRetryableCodexModelsManifestTransportError(err error) bool { return false } -type codexModelsManifestRequest struct { +type openAIModelsRequest struct { url string headers http.Header proxyURL string @@ -1486,61 +1498,63 @@ type codexModelsManifestRequest struct { credentialAccount *Account accountConcurrency int useAPIKeyUpstream bool + // Cached bodies have already been converted to their requested format. + standardModelsList bool } -type codexModelsManifestCacheEntry struct { - manifest *CodexModelsManifest +type openAIModelsCacheEntry struct { + manifest *OpenAIModelsResponse order uint64 expiresAt time.Time staleUntil time.Time } -type codexModelsManifestCacheState uint8 +type openAIModelsCacheState uint8 const ( - codexModelsManifestCacheMiss codexModelsManifestCacheState = iota - codexModelsManifestCacheFresh - codexModelsManifestCacheStale + openAIModelsCacheMiss openAIModelsCacheState = iota + openAIModelsCacheFresh + openAIModelsCacheStale ) -type codexModelsManifestCache struct { +type openAIModelsCache struct { mu sync.Mutex - entries map[string]codexModelsManifestCacheEntry + entries map[string]openAIModelsCacheEntry nextOrder uint64 refresh singleflight.Group } -func (c *codexModelsManifestCache) get(key string, now time.Time) (*CodexModelsManifest, codexModelsManifestCacheState) { +func (c *openAIModelsCache) get(key string, now time.Time) (*OpenAIModelsResponse, openAIModelsCacheState) { c.mu.Lock() defer c.mu.Unlock() entry, ok := c.entries[key] if !ok { - return nil, codexModelsManifestCacheMiss + return nil, openAIModelsCacheMiss } if !now.Before(entry.staleUntil) { delete(c.entries, key) - return nil, codexModelsManifestCacheMiss + return nil, openAIModelsCacheMiss } if now.Before(entry.expiresAt) { - return entry.manifest, codexModelsManifestCacheFresh + return entry.manifest, openAIModelsCacheFresh } - return entry.manifest, codexModelsManifestCacheStale + return entry.manifest, openAIModelsCacheStale } -func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest, now time.Time) { - if manifest == nil || len(manifest.Body) > codexModelsManifestCacheBodyLimit { +func (c *openAIModelsCache) set(key string, manifest *OpenAIModelsResponse, now time.Time) { + if manifest == nil || len(manifest.Body) > openAIModelsCacheBodyLimit { return } - remainingBodyBudget := codexModelsManifestCacheBodyLimit - len(manifest.Body) + remainingBodyBudget := openAIModelsCacheBodyLimit - len(manifest.Body) if len(manifest.upstreamSourceBody) > remainingBodyBudget { return } c.mu.Lock() defer c.mu.Unlock() if c.entries == nil { - c.entries = make(map[string]codexModelsManifestCacheEntry) + c.entries = make(map[string]openAIModelsCacheEntry) } - if _, exists := c.entries[key]; !exists && len(c.entries) >= codexModelsManifestCacheMaxEntries { + if _, exists := c.entries[key]; !exists && len(c.entries) >= openAIModelsCacheMaxEntries { oldestKey := "" var oldestOrder uint64 for candidateKey, entry := range c.entries { @@ -1553,16 +1567,16 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest oldestOrder = entry.order } } - if len(c.entries) >= codexModelsManifestCacheMaxEntries && oldestKey != "" { + if len(c.entries) >= openAIModelsCacheMaxEntries && oldestKey != "" { delete(c.entries, oldestKey) } } c.nextOrder++ - c.entries[key] = codexModelsManifestCacheEntry{ + c.entries[key] = openAIModelsCacheEntry{ manifest: manifest, order: c.nextOrder, - expiresAt: now.Add(codexModelsManifestCacheTTL), - staleUntil: now.Add(codexModelsManifestCacheStaleTTL), + expiresAt: now.Add(openAIModelsCacheTTL), + staleUntil: now.Add(openAIModelsCacheStaleTTL), } } @@ -1572,7 +1586,7 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest // After validating the stable top-level envelope, OAuth response bodies are // passed through verbatim. Custom API key manifests receive only the narrowly // scoped compatibility adjustments required by custom-provider Codex clients. -func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*OpenAIModelsResponse, error) { if account == nil { return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required") } @@ -1660,7 +1674,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc proxyURL = account.Proxy.URL() } - request := codexModelsManifestRequest{ + request := openAIModelsRequest{ url: requestURL.String(), headers: headers, proxyURL: proxyURL, @@ -1671,11 +1685,11 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc useAPIKeyUpstream: useAPIKeyUpstream, } if useAPIKeyUpstream { - return s.fetchCachedCodexModelsManifest(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch) + return s.fetchCachedOpenAIModels(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch) } // OAuth 账号同样经过账号级缓存;闭包保留 agent identity 任务恢复逻辑, // 错误时仍交给 handleCodexModelsManifestAccountAuthError 处理账号状态。 - oauthFetch := func(fetchCtx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { + oauthFetch := func(fetchCtx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { manifest, fetchErr := s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) if !credAccount.IsOpenAIAgentIdentity() || !isAgentIdentityTaskInvalidCodexModelsError(fetchErr) { s.handleCodexModelsManifestAccountAuthError(fetchCtx, account, credAccount, fetchErr) @@ -1699,7 +1713,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc setOpenAIChatGPTAccountHeaders(request.headers, credAccount) return s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) } - return s.fetchCachedCodexModelsManifest(ctx, request, oauthFetch, ifNoneMatch) + return s.fetchCachedOpenAIModels(ctx, request, oauthFetch, ifNoneMatch) } func isAgentIdentityTaskInvalidCodexModelsError(err error) bool { @@ -1739,18 +1753,18 @@ func (s *OpenAIGatewayService) handleCodexModelsManifestAccountAuthError(ctx con s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.statusCode, headers, upstreamErr.body) } -func (s *OpenAIGatewayService) fetchCachedCodexModelsManifest(ctx context.Context, request codexModelsManifestRequest, fetch func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error), ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchCachedOpenAIModels(ctx context.Context, request openAIModelsRequest, fetch func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error), ifNoneMatch string) (*OpenAIModelsResponse, error) { if err := ctx.Err(); err != nil { return nil, err } - cacheKey := buildCodexModelsManifestCacheKey(request) - manifest, state := s.codexModelsManifestCache.get(cacheKey, time.Now()) - if state == codexModelsManifestCacheFresh { - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + cacheKey := buildOpenAIModelsCacheKey(request) + manifest, state := s.openAIModelsCache.get(cacheKey, time.Now()) + if state == openAIModelsCacheFresh { + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } - resultCh := s.refreshCachedCodexModelsManifest(cacheKey, request, fetch) - if state == codexModelsManifestCacheStale { - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + resultCh := s.refreshCachedOpenAIModels(cacheKey, request, fetch) + if state == openAIModelsCacheStale { + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } select { case <-ctx.Done(): @@ -1759,19 +1773,19 @@ func (s *OpenAIGatewayService) fetchCachedCodexModelsManifest(ctx context.Contex if result.Err != nil { return nil, result.Err } - manifest, ok := result.Val.(*CodexModelsManifest) + manifest, ok := result.Val.(*OpenAIModelsResponse) if !ok || manifest == nil { return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "invalid shared Codex models manifest result") } - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } } -func (s *OpenAIGatewayService) refreshCachedCodexModelsManifest(cacheKey string, request codexModelsManifestRequest, fetch func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error)) <-chan singleflight.Result { - return s.codexModelsManifestCache.refresh.DoChan(cacheKey, func() (any, error) { +func (s *OpenAIGatewayService) refreshCachedOpenAIModels(cacheKey string, request openAIModelsRequest, fetch func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error)) <-chan singleflight.Result { + return s.openAIModelsCache.refresh.DoChan(cacheKey, func() (any, error) { ctx, cancel := context.WithTimeout(context.Background(), codexModelsManifestRequestTimeout) defer cancel() - cached, _ := s.codexModelsManifestCache.get(cacheKey, time.Now()) + cached, _ := s.openAIModelsCache.get(cacheKey, time.Now()) ifNoneMatch := "" if cached != nil { ifNoneMatch = cached.upstreamETag @@ -1781,23 +1795,23 @@ func (s *OpenAIGatewayService) refreshCachedCodexModelsManifest(cacheKey string, return nil, err } if manifest.NotModified && cached != nil { - s.codexModelsManifestCache.set(cacheKey, cached, time.Now()) + s.openAIModelsCache.set(cacheKey, cached, time.Now()) return cached, nil } if !manifest.NotModified { - s.codexModelsManifestCache.set(cacheKey, manifest, time.Now()) + s.openAIModelsCache.set(cacheKey, manifest, time.Now()) } return manifest, nil }) } -func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstreamForRequest(request codexModelsManifestRequest) func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { - return func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstreamForRequest(request openAIModelsRequest) func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { + return func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { return s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch) } } -func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchOpenAIModelsUpstream(ctx context.Context, request openAIModelsRequest, ifNoneMatch string) (*OpenAIModelsResponse, error) { reqCtx, cancel := context.WithTimeout(ctx, codexModelsManifestRequestTimeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, request.url, nil) @@ -1842,7 +1856,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotModified { - return &CodexModelsManifest{ETag: resp.Header.Get("ETag"), NotModified: true}, nil + return &OpenAIModelsResponse{ETag: resp.Header.Get("ETag"), NotModified: true}, nil } if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) @@ -1874,6 +1888,17 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont retryable: true, } } + etag := resp.Header.Get("ETag") + return &OpenAIModelsResponse{Body: body, ETag: etag, upstreamETag: etag}, nil +} + +func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request openAIModelsRequest, ifNoneMatch string) (*OpenAIModelsResponse, error) { + response, err := s.fetchOpenAIModelsUpstream(ctx, request, ifNoneMatch) + if err != nil || response.NotModified { + return response, err + } + body := response.Body + upstreamBody := body convertedFromOpenAIModelList := false if request.useAPIKeyUpstream { @@ -1922,8 +1947,8 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } } - etag := resp.Header.Get("ETag") - manifest := &CodexModelsManifest{ + etag := response.upstreamETag + manifest := &OpenAIModelsResponse{ Body: body, ETag: etag, upstreamETag: etag, @@ -2094,7 +2119,7 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou // 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 { +func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *OpenAIModelsResponse, account *Account) error { if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 { return nil } @@ -2422,9 +2447,9 @@ func validateCodexModelsManifestEnvelope(body []byte) error { return nil } -func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string { +func buildOpenAIModelsCacheKey(request openAIModelsRequest) string { hasher := sha256.New() - _, _ = fmt.Fprintf(hasher, "%d\n%d\n%s\n%s\n", request.accountID, request.credentialAccountID, request.proxyURL, request.url) + _, _ = fmt.Fprintf(hasher, "%d\n%d\n%t\n%s\n%s\n", request.accountID, request.credentialAccountID, request.standardModelsList, request.proxyURL, request.url) headerNames := make([]string, 0, len(request.headers)) for name := range request.headers { headerNames = append(headerNames, name) @@ -2439,7 +2464,7 @@ func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string return fmt.Sprintf("%x", hasher.Sum(nil)) } -func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifest { +func cloneOpenAIModelsResponse(manifest *OpenAIModelsResponse) *OpenAIModelsResponse { if manifest == nil { return nil } @@ -2453,14 +2478,14 @@ func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifes return &cloned } -func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch string) *CodexModelsManifest { +func openAIModelsResponseForClient(manifest *OpenAIModelsResponse, ifNoneMatch string) *OpenAIModelsResponse { if manifest == nil { return nil } if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { - return &CodexModelsManifest{ETag: manifest.ETag, NotModified: true} + return &OpenAIModelsResponse{ETag: manifest.ETag, NotModified: true} } - return cloneCodexModelsManifest(manifest) + return cloneOpenAIModelsResponse(manifest) } func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index f6e51d3453..52ef9aa460 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -1081,7 +1081,7 @@ func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) }, }, }} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`), } @@ -1322,7 +1322,7 @@ func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) const groupID int64 = 74 svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ 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"}]}`), } @@ -1357,7 +1357,7 @@ func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault( }, }, }} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`), } @@ -1376,7 +1376,7 @@ func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *test const groupID int64 = 76 svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), } group := &Group{ @@ -1420,7 +1420,7 @@ func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing. }, } upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`) - manifest := &CodexModelsManifest{Body: upstreamBody} + manifest := &OpenAIModelsResponse{Body: upstreamBody} require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) models := decodeCodexManifestModels(t, manifest.Body) @@ -1428,7 +1428,7 @@ func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing. requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro") finalETag := manifest.ETag - second := &CodexModelsManifest{Body: upstreamBody} + second := &OpenAIModelsResponse{Body: upstreamBody} require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag)) require.True(t, second.NotModified) require.Empty(t, second.Body) @@ -2011,7 +2011,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t * t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","service_tiers":[{"id":"provider-priority","name":"Provider Fast","description":"Provider supplied tier."}],"model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`), } account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") @@ -2047,7 +2047,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t * t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[ + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[ {"slug":"gpt-5.6-sol"}, {"slug":"company-coding-model"}, {"slug":"gpt-4o","input_modalities":["text"]} @@ -2122,7 +2122,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWit ContextWindow: 999_000, }, }}) - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{ + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{ "slug":"provider-model", "description":"Provider supplied", "context_window":64000, @@ -2201,7 +2201,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTIma t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-6-astra"},{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)} + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-6-astra"},{"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)) @@ -2229,7 +2229,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels 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"}]}`)} + manifest := &OpenAIModelsResponse{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)) @@ -2547,13 +2547,13 @@ func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t } secondResult := make(chan struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }, 1) go func() { manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") secondResult <- struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }{manifest: manifest, err: err} }() @@ -2863,21 +2863,21 @@ func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) t.Fatalf("initial fetch returned error: %v", err) } - s.codexModelsManifestCache.mu.Lock() - for key, entry := range s.codexModelsManifestCache.entries { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { entry.expiresAt = time.Now().Add(-time.Second) - s.codexModelsManifestCache.entries[key] = entry + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() resultCh := make(chan struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }, 1) go func() { manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") resultCh <- struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }{manifest: manifest, err: err} }() @@ -2888,7 +2888,7 @@ func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) } var staleResult struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error } select { @@ -2956,12 +2956,12 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) { if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil { t.Fatalf("initial fetch returned error: %v", err) } - s.codexModelsManifestCache.mu.Lock() - for key, entry := range s.codexModelsManifestCache.entries { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { entry.expiresAt = time.Now().Add(-time.Second) - s.codexModelsManifestCache.entries[key] = entry + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") if err != nil { @@ -2978,12 +2978,12 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) { deadline := time.Now().Add(time.Second) for { - s.codexModelsManifestCache.mu.Lock() + s.openAIModelsCache.mu.Lock() fresh := false - for _, entry := range s.codexModelsManifestCache.entries { + for _, entry := range s.openAIModelsCache.entries { fresh = time.Now().Before(entry.expiresAt) } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() if fresh { break } @@ -3348,13 +3348,13 @@ func newCodexModelsOAuthCacheServer(t *testing.T, body string) (*httptest.Server } func expireCodexModelsManifestCache(s *OpenAIGatewayService, age time.Duration) { - s.codexModelsManifestCache.mu.Lock() - for key, entry := range s.codexModelsManifestCache.entries { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { entry.expiresAt = time.Now().Add(-age) - entry.staleUntil = time.Now().Add(codexModelsManifestCacheStaleTTL - age) - s.codexModelsManifestCache.entries[key] = entry + entry.staleUntil = time.Now().Add(openAIModelsCacheStaleTTL - age) + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() } func TestFetchCodexModelsManifestOAuthFreshWindowZeroUpstreamRequests(t *testing.T) { @@ -3397,7 +3397,7 @@ func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackgroun expireCodexModelsManifestCache(s, 2*time.Minute) - resultCh := make(chan *CodexModelsManifest, 1) + resultCh := make(chan *OpenAIModelsResponse, 1) errCh := make(chan error, 1) go func() { manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") @@ -3427,9 +3427,9 @@ func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackgroun if fetchErr == nil && manifest != nil && !manifest.NotModified { require.Contains(t, string(manifest.Body), `"new"`) } - s.codexModelsManifestCache.mu.Lock() - fresh := len(s.codexModelsManifestCache.entries) > 0 - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Lock() + fresh := len(s.openAIModelsCache.entries) > 0 + s.openAIModelsCache.mu.Unlock() if fresh { break } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 01ea7b5f48..df7e66eb87 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -483,7 +483,7 @@ type OpenAIGatewayService struct { openaiWSRetryMetrics openAIWSRetryMetrics responseHeaderFilter *responseheaders.CompiledHeaderFilter codexSnapshotThrottle *accountWriteThrottle - codexModelsManifestCache codexModelsManifestCache + openAIModelsCache openAIModelsCache openaiCompatSessionResponses sync.Map openaiCompatAnthropicDigestSessions sync.Map // openaiCodexTurnStateOrigins: 下游会话 seed → openAICodexTurnStateOrigin, diff --git a/backend/internal/service/openai_models_list.go b/backend/internal/service/openai_models_list.go new file mode 100644 index 0000000000..18ff11aa53 --- /dev/null +++ b/backend/internal/service/openai_models_list.go @@ -0,0 +1,360 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// FetchOpenAIModelsList discovers a single account's raw public model catalog. +// API keys use the standard endpoint; OAuth reuses the authenticated, cached +// Codex source. Account mappings and group policy are applied after this cache. +func (s *OpenAIGatewayService) FetchOpenAIModelsList(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + if s == nil || account == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_MODELS_ACCOUNT_REQUIRED", "OpenAI account is required") + } + credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, fmt.Errorf("resolve model list credentials: %w", err) + } + if credentialAccount.IsOpenAIOAuth() { + response, err := s.FetchCodexModelsManifest(ctx, account, CodexCanonicalClientVersion(), "") + if err != nil { + return nil, err + } + body, err := standardOpenAIModelsBody(response.Body, true) + if err != nil { + return nil, invalidOpenAIModelsList(err) + } + return &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)}, nil + } + req, err := buildOpenAIAPIKeyModelsRequest(ctx, credentialAccount, s.validateUpstreamBaseURL) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_REQUEST_INVALID", "cannot build upstream model list request: %v", err) + } + request := openAIModelsRequest{ + url: req.URL.String(), headers: req.Header, + proxyURL: upstreamModelsProxyURL(account), accountID: account.ID, + credentialAccountID: credentialAccount.ID, credentialAccount: credentialAccount, + accountConcurrency: account.Concurrency, useAPIKeyUpstream: true, + standardModelsList: true, + } + response, err := s.fetchCachedOpenAIModels(ctx, request, func(fetchCtx context.Context, etag string) (*OpenAIModelsResponse, error) { + response, err := s.fetchOpenAIModelsUpstream(fetchCtx, request, etag) + if err != nil || response.NotModified { + return response, err + } + body, err := standardOpenAIModelsBody(response.Body, false) + if err != nil { + return nil, invalidOpenAIModelsList(err) + } + response.Body = body + response.ETag = codexModelsManifestBodyETag(body) + return response, nil + }, "") + if err != nil { + return nil, err + } + if response.NotModified { + return nil, invalidOpenAIModelsList(fmt.Errorf("upstream returned 304 without a cached catalog")) + } + return response, nil +} + +func invalidOpenAIModelsList(err error) error { + return &codexModelsManifestUpstreamError{ + err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_INVALID", "invalid upstream model list: %v", err), + retryable: true, + } +} + +// modelCatalogEntries requires a real array. An empty array is authoritative; +// absent fields, null and error envelopes must not become successful empty lists. +func modelCatalogEntries(body []byte, field string) (map[string]json.RawMessage, []json.RawMessage, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, nil, err + } + raw := bytes.TrimSpace(envelope[field]) + if len(raw) == 0 || raw[0] != '[' { + return nil, nil, fmt.Errorf("missing or invalid %s array", field) + } + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, nil, err + } + return envelope, entries, nil +} + +func standardOpenAIModelsBody(body []byte, fromManifest bool) ([]byte, error) { + field, idField := "data", "id" + if fromManifest { + field, idField = "models", "slug" + } + _, entries, err := modelCatalogEntries(body, field) + if err != nil { + return nil, err + } + models := make([]json.RawMessage, 0, len(entries)) + seen := make(map[string]struct{}, len(entries)) + for _, raw := range entries { + var entry map[string]json.RawMessage + if err := json.Unmarshal(raw, &entry); err != nil || entry == nil { + return nil, fmt.Errorf("model entry must be an object") + } + var id string + if err := json.Unmarshal(entry[idField], &id); err != nil || strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("model entry has no valid %s", idField) + } + id = strings.TrimSpace(id) + if _, ok := seen[id]; ok || strings.Contains(id, "*") { + continue + } + seen[id] = struct{}{} + if fromManifest { + entry = make(map[string]json.RawMessage) + } + entry["id"], _ = json.Marshal(id) + entry["object"] = json.RawMessage(`"model"`) + if len(entry["created"]) == 0 || string(entry["created"]) == "null" { + entry["created"] = json.RawMessage(`0`) + } + if len(entry["owned_by"]) == 0 || string(entry["owned_by"]) == "null" { + entry["owned_by"] = json.RawMessage(`"openai"`) + } + encoded, err := json.Marshal(entry) + if err != nil { + return nil, err + } + models = append(models, encoded) + } + return json.Marshal(struct { + Object string `json:"object"` + Data []json.RawMessage `json:"data"` + }{Object: "list", Data: models}) +} + +// projectAccountModelsBody applies the same public-name mapping to both model +// representations while retaining the source entry's metadata. It never changes +// the shared response and never synthesizes models absent from this account. +func projectAccountModelsBody(body []byte, account *Account, group *Group, codex bool) ([]byte, error) { + if account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0 { + return body, nil + } + field, idField := "data", "id" + if codex { + field, idField = "models", "slug" + } + envelope, entries, err := modelCatalogEntries(body, field) + if err != nil { + return nil, err + } + byID := make(map[string]json.RawMessage, len(entries)) + candidates := make([]string, 0, len(entries)) + for _, raw := range entries { + var entry map[string]json.RawMessage + if json.Unmarshal(raw, &entry) != nil { + continue + } + var id string + if json.Unmarshal(entry[idField], &id) != nil { + continue + } + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "*") { + continue + } + if codex && len(FilterCodexModelIDsForGroup([]string{id}, group)) == 0 { + continue + } + if _, ok := byID[id]; !ok { + byID[id] = raw + candidates = append(candidates, id) + } + } + aliases := make([]string, 0, len(account.GetModelMapping())) + for alias := range account.GetModelMapping() { + aliases = append(aliases, alias) + } + sort.Strings(aliases) + candidates = append(candidates, aliases...) + if group != nil && group.CustomModelsListEnabled() { + candidates = append(candidates, group.ModelsListConfig.Models...) + } + projected := make([]json.RawMessage, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, id := range candidates { + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "*") { + continue + } + if _, ok := seen[id]; ok { + continue + } + target, matched := account.ResolveMappedModel(id) + raw, available := byID[strings.TrimSpace(target)] + if !matched || !available { + continue + } + seen[id] = struct{}{} + var entry map[string]json.RawMessage + if err := json.Unmarshal(raw, &entry); err != nil { + return nil, err + } + entry[idField], _ = json.Marshal(id) + if id != target { + entry["display_name"], _ = json.Marshal(id) + } + encoded, err := json.Marshal(entry) + if err != nil { + return nil, err + } + projected = append(projected, encoded) + } + envelope[field], err = json.Marshal(projected) + if err != nil { + return nil, err + } + return json.Marshal(envelope) +} + +// ApplyPinnedCodexModelsMapping is used by pinned discovery and its scheduler +// fallback. The ordinary (non-pinned) Codex path retains its local catalog policy. +func ApplyPinnedCodexModelsMapping(response *OpenAIModelsResponse, account *Account, group *Group) error { + if group == nil || group.Platform != PlatformOpenAI || !group.CodexModelsManifestConfig.Enabled { + return nil + } + body, err := projectAccountModelsBody(response.Body, account, group, true) + if err != nil { + return err + } + response.Body = body + response.ETag = codexModelsManifestBodyETag(body) + return nil +} + +// FetchPinnedOpenAIModelsList includes explicitly enabled scheduler fallback. +// An authoritative empty catalog is success, including after group filtering. +func (s *OpenAIGatewayService) FetchPinnedOpenAIModelsList(ctx context.Context, group *Group, maxAccountSwitches int, ifNoneMatch string) (*OpenAIModelsResponse, *Account, error) { + fetch := func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + response, err := s.FetchOpenAIModelsList(ctx, account) + if err != nil { + return nil, err + } + response.Body, err = projectAccountModelsBody(response.Body, account, group, false) + return response, err + } + results, err := s.fetchPinnedOpenAIModels(ctx, group, fetch) + if err != nil && ctx.Err() == nil && group != nil && group.CodexModelsManifestConfig.FallbackToScheduler { + results, err = s.fetchScheduledOpenAIModels(ctx, group, maxAccountSwitches, fetch) + } + if err != nil { + return nil, nil, err + } + models := make([]json.RawMessage, 0) + byID := make(map[string]json.RawMessage) + for _, result := range results { + _, entries, err := modelCatalogEntries(result.response.Body, "data") + if err != nil { + return nil, nil, err + } + for _, raw := range entries { + var model struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &model); err != nil { + return nil, nil, err + } + if _, exists := byID[model.ID]; !exists { + byID[model.ID] = raw + models = append(models, raw) + } + } + } + if group.CustomModelsListEnabled() { + models = selectModelCatalogEntries(byID, group.ModelsListConfig.Models) + } + body, err := json.Marshal(struct { + Object string `json:"object"` + Data []json.RawMessage `json:"data"` + }{Object: "list", Data: models}) + if err != nil { + return nil, nil, err + } + response := &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)} + return openAIModelsResponseForClient(response, ifNoneMatch), results[0].account, nil +} + +func selectModelCatalogEntries(byID map[string]json.RawMessage, selected []string) []json.RawMessage { + models := make([]json.RawMessage, 0, len(selected)) + seen := make(map[string]struct{}, len(selected)) + for _, id := range selected { + id = strings.TrimSpace(id) + if _, ok := seen[id]; ok { + continue + } + if raw, exists := byID[id]; exists { + models = append(models, raw) + seen[id] = struct{}{} + } + } + return models +} + +func orderPinnedCodexModelsBySelection(body []byte, selected []string) ([]byte, error) { + envelope, entries, err := modelCatalogEntries(body, "models") + if err != nil { + return nil, err + } + byID := make(map[string]json.RawMessage, len(entries)) + for _, raw := range entries { + var entry struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(raw, &entry); err != nil { + return nil, err + } + byID[entry.Slug] = raw + } + envelope["models"], err = json.Marshal(selectModelCatalogEntries(byID, selected)) + if err != nil { + return nil, err + } + return json.Marshal(envelope) +} + +func (s *OpenAIGatewayService) fetchScheduledOpenAIModels(ctx context.Context, group *Group, maxSwitches int, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) { + if maxSwitches <= 0 { + maxSwitches = 3 + } + excluded := make(map[int64]struct{}) + var lastErr error + for attempt := 0; attempt <= maxSwitches; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + account, err := s.SelectAccountForModelWithExclusions(ctx, &group.ID, "", "", excluded) + if err != nil { + if lastErr != nil { + return nil, lastErr + } + return nil, ErrNoPinnedCodexModelsAccounts + } + response, err := fetch(ctx, account) + if err == nil { + return []pinnedOpenAIModelsResult{{account: account, response: response}}, nil + } + lastErr = err + if !IsRetryableCodexModelsManifestError(err) { + return nil, err + } + excluded[account.ID] = struct{}{} + } + return nil, lastErr +} diff --git a/backend/internal/service/openai_models_list_test.go b/backend/internal/service/openai_models_list_test.go new file mode 100644 index 0000000000..b0598bd3bc --- /dev/null +++ b/backend/internal/service/openai_models_list_test.go @@ -0,0 +1,348 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func ordinaryModelsUpstreamResponse(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + +func TestFetchOpenAIModelsListUsesStandardRequestAndIsolatesCodexCache(t *testing.T) { + var calls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, accountID int64, concurrency int) (*http.Response, error) { + calls.Add(1) + assert.EqualValues(t, 2, accountID) + assert.EqualValues(t, 3, concurrency) + assert.Equal(t, "/v1/models", req.URL.Path) + assert.Equal(t, "value", getHeaderRaw(req.Header, "x-account-header")) + if req.URL.Query().Has("client_version") { + return ordinaryModelsUpstreamResponse(`{"models":[{"slug":"codex-only"}]}`), nil + } + assert.Empty(t, req.Header.Get("Originator")) + assert.Empty(t, req.Header.Get("Version")) + return ordinaryModelsUpstreamResponse(`{"object":"list","data":[{"id":"special-upstream-model","owned_by":"provider","created":1234,"custom":true},{"id":"gpt-image-1"},{"id":"text-embedding-3-large"}]}`), nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["header_overrides"] = map[string]any{"X-Account-Header": "value"} + account.Credentials["header_override_enabled"] = true + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Contains(t, string(response.Body), `"id":"gpt-image-1"`) + require.Contains(t, string(response.Body), `"id":"text-embedding-3-large"`) + require.Contains(t, string(response.Body), `"created":1234`) + require.Contains(t, string(response.Body), `"custom":true`) + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, CodexCanonicalClientVersion(), "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"slug":"codex-only"`) + again, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(response.Body), string(again.Body)) + require.EqualValues(t, 2, calls.Load()) + account.Credentials["api_key"] = "rotated-token" + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.EqualValues(t, 3, calls.Load(), "credentials must participate in the cache key") +} + +func TestFetchOpenAIModelsListOAuthSharesManifestCache(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"special-oauth-model"},{"slug":"gpt-image-1"}]}`) + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.JSONEq(t, `{"object":"list","data":[{"id":"special-oauth-model","object":"model","owned_by":"openai","created":0},{"id":"gpt-image-1","object":"model","owned_by":"openai","created":0}]}`, string(response.Body)) + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, CodexCanonicalClientVersion(), "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"slug":"special-oauth-model"`) + require.NotContains(t, string(manifest.Body), `"data"`) + require.EqualValues(t, 1, calls.Load()) + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.EqualValues(t, 1, calls.Load()) +} + +func TestFetchOpenAIModelsListCacheWindowsAndSingleflight(t *testing.T) { + var calls atomic.Int32 + started := make(chan int32, 3) + release := make(chan struct{}, 3) + t.Cleanup(func() { close(release) }) + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + call := calls.Add(1) + if call > 1 && call < 4 { + started <- call + <-release + } + if call >= 4 { + response := ordinaryModelsUpstreamResponse(`{"error":"unavailable"}`) + response.StatusCode = http.StatusServiceUnavailable + return response, nil + } + return ordinaryModelsUpstreamResponse(`{"data":[{"id":"version-` + string(rune('0'+call)) + `"}]}`), nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + first, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + for range 5 { + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + } + require.EqualValues(t, 1, calls.Load()) + expireCodexModelsManifestCache(s, 2*time.Minute) + stale, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(first.Body), string(stale.Body)) + select { + case call := <-started: + require.EqualValues(t, 2, call) + case <-time.After(2 * time.Second): + t.Fatal("stale response did not start a background refresh") + } + for range 5 { + cached, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(first.Body), string(cached.Body)) + } + require.EqualValues(t, 2, calls.Load(), "concurrent stale reads must share the refresh") + release <- struct{}{} + require.Eventually(t, func() bool { + s.openAIModelsCache.mu.Lock() + defer s.openAIModelsCache.mu.Unlock() + for _, entry := range s.openAIModelsCache.entries { + if strings.Contains(string(entry.manifest.Body), "version-2") { + return true + } + } + return false + }, 2*time.Second, time.Millisecond) + + expireCodexModelsManifestCache(s, 6*time.Minute) + responses := make(chan *OpenAIModelsResponse, 2) + fetchErrors := make(chan error, 2) + var wait sync.WaitGroup + for range 2 { + wait.Add(1) + go func() { + defer wait.Done() + response, err := s.FetchOpenAIModelsList(context.Background(), account) + responses <- response + fetchErrors <- err + }() + } + select { + case call := <-started: + require.EqualValues(t, 3, call) + case <-time.After(2 * time.Second): + t.Fatal("expired catalog did not start a synchronous refresh") + } + select { + case <-responses: + t.Fatal("expired catalog returned before the upstream refresh completed") + case <-time.After(20 * time.Millisecond): + } + release <- struct{}{} + wait.Wait() + for range 2 { + require.NoError(t, <-fetchErrors) + require.Contains(t, string((<-responses).Body), "version-3") + } + require.EqualValues(t, 3, calls.Load()) + expireCodexModelsManifestCache(s, 6*time.Minute) + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.Error(t, err) + require.Nil(t, response) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) +} + +func TestFetchOpenAIModelsListRevalidatesUpstreamETag(t *testing.T) { + var calls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + response := ordinaryModelsUpstreamResponse(`{"data":[{"id":"unchanged"}]}`) + response.Header.Set("ETag", `"upstream"`) + if calls.Add(1) > 1 { + assert.Equal(t, `"upstream"`, req.Header.Get("If-None-Match")) + response.StatusCode = http.StatusNotModified + } + return response, nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + first, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + expireCodexModelsManifestCache(s, 2*time.Minute) + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Eventually(t, func() bool { + s.openAIModelsCache.mu.Lock() + defer s.openAIModelsCache.mu.Unlock() + for _, entry := range s.openAIModelsCache.entries { + return entry.expiresAt.After(time.Now()) + } + return false + }, 2*time.Second, time.Millisecond) + again, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, first.ETag, again.ETag) + require.Equal(t, string(first.Body), string(again.Body)) + require.EqualValues(t, 2, calls.Load()) +} + +func TestProjectAccountModelsPassthroughIgnoresStaleMappings(t *testing.T) { + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Extra = map[string]any{"openai_passthrough": true} + account.Credentials["model_mapping"] = map[string]any{"obsolete": "missing"} + body := []byte(`{"object":"list","data":[{"id":"live-model"}]}`) + projected, err := projectAccountModelsBody(body, account, nil, false) + require.NoError(t, err) + require.JSONEq(t, string(body), string(projected)) +} + +func TestFetchOpenAIModelsListEmptyAndMalformedResponses(t *testing.T) { + for _, body := range []string{`{"data":[]}`, `{"data":null}`, `{}`, `{"data":{}}`, `{"data":[{}]}`, `{"data":[null]}`} { + t.Run(body, func(t *testing.T) { + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(body), nil + }}) + response, err := s.FetchOpenAIModelsList(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + if body != `{"data":[]}` { + require.Error(t, err) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) + return + } + require.NoError(t, err) + var result struct { + Data []json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response.Body, &result)) + require.NotNil(t, result.Data) + require.Empty(t, result.Data) + }) + } +} + +func TestPinnedOpenAIModelsListMixedAccountsShareColdCacheAcrossGroups(t *testing.T) { + _, oauthCalls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"shared-model"},{"slug":"oauth-special"}]}`) + var apiCalls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + apiCalls.Add(1) + return ordinaryModelsUpstreamResponse(`{"data":[{"id":"shared-model","owned_by":"api-provider"},{"id":"api-special"}]}`), nil + }}) + apiAccount := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + oauthAccount := newCodexModelsTestAccount() + for _, account := range []*Account{apiAccount, oauthAccount} { + account.Status, account.Schedulable = StatusActive, true + } + accounts := []Account{*apiAccount, *oauthAccount} + s.accountRepo = splitCodexModelsAccountRepo{all: map[int64][]Account{10: accounts, 11: accounts}} + groups := []*Group{ + {ID: 10, Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 1}}, + ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"oauth-special", "shared-model"}}}, + {ID: 11, Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 1}}}, + } + type result struct { + response *OpenAIModelsResponse + account *Account + err error + } + results := make([]result, len(groups)) + var wait sync.WaitGroup + for i, group := range groups { + wait.Add(1) + go func() { + defer wait.Done() + results[i].response, results[i].account, results[i].err = s.FetchPinnedOpenAIModelsList(context.Background(), group, 3, "") + }() + } + wait.Wait() + for i, result := range results { + require.NoError(t, result.err) + require.EqualValues(t, 2, result.account.ID) + var catalog struct { + Data []struct { + ID string `json:"id"` + Owner string `json:"owned_by"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(result.response.Body, &catalog)) + ids := make([]string, 0, len(catalog.Data)) + for _, model := range catalog.Data { + ids = append(ids, model.ID) + if model.ID == "shared-model" { + require.Equal(t, "api-provider", model.Owner) + } + } + if i == 0 { + require.Equal(t, []string{"oauth-special", "shared-model"}, ids) + } else { + require.Equal(t, []string{"shared-model", "api-special", "oauth-special"}, ids) + } + } + require.EqualValues(t, 1, apiCalls.Load()) + require.EqualValues(t, 1, oauthCalls.Load()) +} + +func TestFetchOpenAIModelsListResolvesShadowOAuthCredentials(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"parent-model"}]}`) + parent := newCodexModelsTestAccount() + shadow := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parent.ID} + s := &OpenAIGatewayService{accountRepo: newStubCredRepo(parent)} + response, err := s.FetchOpenAIModelsList(context.Background(), shadow) + require.NoError(t, err) + require.Contains(t, string(response.Body), `"id":"parent-model"`) + require.EqualValues(t, 1, calls.Load()) +} + +func TestProjectAccountModelsCannotExposeCodexMediaThroughAlias(t *testing.T) { + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["model_mapping"] = map[string]any{"image-alias": "gpt-image-1", "auto-alias": "codex-auto-fast"} + body := []byte(`{"models":[{"slug":"gpt-image-1"},{"slug":"codex-auto-fast"}]}`) + projected, err := projectAccountModelsBody(body, account, &Group{}, true) + require.NoError(t, err) + require.JSONEq(t, `{"models":[]}`, string(projected)) +} + +func TestFetchOpenAIModelsListRejectsUnexpectedCold304(t *testing.T) { + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNotModified, Header: make(http.Header), Body: http.NoBody}, nil + }}) + response, err := s.FetchOpenAIModelsList(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + require.Error(t, err) + require.Nil(t, response) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) +} + +func TestOpenAIModelsCacheSeparatesRepresentationsForIdenticalRequests(t *testing.T) { + s := &OpenAIGatewayService{} + request := openAIModelsRequest{url: "https://models.example/v1/models", accountID: 7} + var calls atomic.Int32 + fetch := func(body string) func(context.Context, string) (*OpenAIModelsResponse, error) { + return func(context.Context, string) (*OpenAIModelsResponse, error) { + calls.Add(1) + return &OpenAIModelsResponse{Body: []byte(body)}, nil + } + } + manifestBody := `{"models":[{"slug":"shared"}]}` + listBody := `{"object":"list","data":[{"id":"shared"}]}` + _, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(manifestBody), "") + require.NoError(t, err) + request.standardModelsList = true + list, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(listBody), "") + require.NoError(t, err) + require.JSONEq(t, listBody, string(list.Body)) + request.standardModelsList = false + manifest, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(manifestBody), "") + require.NoError(t, err) + require.JSONEq(t, manifestBody, string(manifest.Body)) + require.EqualValues(t, 2, calls.Load()) +} diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 9d45d2f617..0916596657 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -991,6 +991,12 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex if account.IsOpenAIOAuth() { return s.buildOpenAIOAuthUpstreamModelsRequest(ctx, account) } + return buildOpenAIAPIKeyModelsRequest(ctx, account, s.validateUpstreamBaseURL) +} + +// buildOpenAIAPIKeyModelsRequest is shared by admin discovery and public model +// listing. Codex content negotiation is intentionally absent from this request. +func buildOpenAIAPIKeyModelsRequest(ctx context.Context, account *Account, validateBaseURL func(string) (string, error)) (*http.Request, error) { if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, @@ -1007,7 +1013,7 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com" } - normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + normalizedBaseURL, err := validateBaseURL(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid OpenAI base URL", err) } diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index b0b7ba8e74..71e6462ac0 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1079,11 +1079,11 @@ export default { invertSelection: 'Invert' }, codexModelsManifest: { - title: 'Pinned Accounts for Codex Model Manifest', - hint: 'When enabled, Codex client /models requests for this group are fetched only from the pinned accounts and merged by slug, bypassing the scheduler. Pinned accounts in rate-limit or overload windows are still used.', - enable: 'Fetch manifest with specific accounts', + title: 'Pinned Accounts for Model Lists', + hint: 'When enabled, ordinary model lists and Codex Model Manifest are discovered from the pinned accounts first, then merged and filtered using account mappings and the group model list. Rate-limited or overloaded pinned accounts are still used.', + enable: 'Fetch model lists with specific accounts', enabledHint: 'Accounts are limited to OpenAI accounts bound to this group, at most 10.', - disabledHint: 'Not enabled: manifest requests go through scheduler account selection.', + disabledHint: 'Disabled: ordinary lists use local mappings or defaults; Codex uses a local catalog when configured, otherwise scheduler discovery.', accounts: 'Pinned accounts', searchPlaceholder: 'Search accounts (OpenAI accounts in this group)', searchEmpty: 'No matching accounts', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index a06abf9784..b95deb5811 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1076,11 +1076,11 @@ export default { invertSelection: '反选' }, codexModelsManifest: { - title: '固定账号获取 Codex Model Manifest', - hint: '开启后,该分组的 Codex 客户端 /models 请求只用选定账号向上游拉取并按 slug 合并,不经过调度器;限流/过载中的选定账号仍会被使用。', - enable: '使用特定账号获取 manifest', + title: '固定账号获取模型列表', + hint: '开启后,普通模型列表与 Codex Model Manifest 均优先从选定账号获取并合并,再应用账号映射和分组列表过滤;限流/过载中的选定账号仍会被使用。', + enable: '使用特定账号获取模型列表', enabledHint: '账号来源限定为当前分组内的 OpenAI 账号,最多选择 10 个。', - disabledHint: '未启用:manifest 请求经由调度器选账。', + disabledHint: '未启用:普通列表使用本地映射或默认模型;Codex 优先使用本地目录,无本地目录时由调度器选账。', accounts: '选定账号', searchPlaceholder: '搜索账号(当前分组内 OpenAI 账号)', searchEmpty: '未找到匹配账号', diff --git a/openspec/changes/codex-manifest-pinned-accounts/.openspec.yaml b/openspec/changes/codex-manifest-pinned-accounts/.openspec.yaml new file mode 100644 index 0000000000..1d9aeef992 --- /dev/null +++ b/openspec/changes/codex-manifest-pinned-accounts/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/codex-manifest-pinned-accounts/design.md b/openspec/changes/codex-manifest-pinned-accounts/design.md new file mode 100644 index 0000000000..0c174b399b --- /dev/null +++ b/openspec/changes/codex-manifest-pinned-accounts/design.md @@ -0,0 +1,98 @@ +## Context + +动机见 proposal.md。与方案相关的现状: + +- Codex Model Manifest 入口是 `OpenAIGatewayHandler.CodexModels`:先尝试用账号模型映射本地生成;否则循环 `SelectAccountForModelWithExclusions` 选账号、`FetchCodexModelsManifest` 拉取、`CompleteAPIKeyCodexModelsManifestForClient` 补全、`MergeGroupConfiguredCodexModels` 做分组过滤与 ETag。 +- `FetchCodexModelsManifest` 对 API Key 账号走 `fetchCachedAPIKeyCodexModelsManifest`(30 秒新鲜、5 分钟乐观、单飞后台刷新),对 OAuth 账号直接请求上游且带 agent identity 任务恢复逻辑,不缓存。 +- 分组配置落地链路:ent schema → SQL 迁移 → `service.Group` → `group_repo` 创建与更新 setter → `api_key_repo` 分组字段投影与 `groupEntityToService` → 认证快照 `api_key_auth_cache.go` 结构体及 impl 两处映射 → 管理端 DTO / mapper → `admin_group.go` 创建与更新 → 分组复制。`models_list_config` 是完整样板。 +- 前端 `GroupsView.vue` 已有 7000 行;模型路由的账号选择用「标签 + 搜索输入 + 下拉」内联实现,`Select.vue` 仅支持单选。`components/admin/group/` 下已有抽出的表单片段组件(如 `ReasoningEffortPolicyFields.vue`)。 +- 后端管理端账号列表接口支持 `platform`、`group`、`search` 过滤。 + +## Goals / Non-Goals + +**Goals:** +- 固定账号模式的运行时逻辑放在 service 层,handler 只做分支与错误映射。 +- 合并逻辑是纯函数,可独立单测。 +- 缓存策略只有一套实现,OAuth 与 API Key 账号共用;固定账号模式不引入第二层分组级缓存。 +- 新字段在所有分组加载路径上都可见,特别是认证快照与 API Key 投影。 + +**Non-Goals:** +- 不改动调度器逻辑与普通请求的账号选择。 +- 不为账号解绑或删除增加对该配置的级联清理,运行时容忍失效 ID。 +- 固定账号关闭时保留本地生成 manifest 的路径;开启时以指定账号上游发现为先,映射在发现后应用。 +- 创建分组对话框不提供该配置。 + +## Decisions + +### D1:配置以单个 JSONB 列存储 +`groups.codex_models_manifest_config`,领域类型 `domain.GroupCodexModelsManifestConfig{Enabled bool; AccountIDs []int64; FallbackToScheduler bool}`,JSON 键为 `enabled`、`account_ids`、`fallback_to_scheduler`。 +- 备选:三个独立列。否决:三个字段语义耦合,JSON 保证原子写入,且与 `models_list_config` 一致。 +- 迁移文件 `234_group_codex_models_manifest_config.sql`:`ADD COLUMN IF NOT EXISTS ... JSONB NOT NULL DEFAULT '{}'::jsonb`。ent schema 使用 `field.JSON(...).Default(domain.GroupCodexModelsManifestConfig{})`。 + +### D2:校验放在 admin service,创建路径拒绝开启 +- 新文件 `group_codex_models_manifest.go` 提供 `normalizeCodexModelsManifestConfig`(平台非 openai 归零;去重保序;`enabled=false` 时保留列表便于再次启用)与 `validateCodexModelsManifestConfig(ctx, groupID, cfg)`(`enabled=true` 时:非空、≤10、全部属于 `accountRepo.ListByGroup(groupID)` 且平台 openai)。 +- 创建路径:`enabled=true` 直接 400。原因:账号绑定发生在分组创建之后,创建时无法校验成员关系;前端也不展示。 +- 备选:校验放 handler。否决:需要访问账号仓储,且创建与更新两条路径共用,service 是唯一收口。 + +### D3:运行时入口与回退策略 +- handler 在本地生成分支之前判断 `group.Platform == openai && cfg.Enabled`,调用 `gatewayService.FetchPinnedCodexModelsManifest(ctx, group, clientVersion)`。 +- 返回值:`(*OpenAIModelsResponse, *Account, error)`,`*Account` 为配置顺序中第一个成功账号,用于 `setOpsSelectedAccount`。 +- 「无可用账号」以哨兵错误 `ErrNoPinnedCodexModelsAccounts` 表示;「全部失败」返回最后一个上游错误。handler 根据 `FallbackToScheduler` 决定:true 时跌入现有调度器循环;false 时无可用账号返回 503、全部失败按 `infraerrors.Code(err)` 返回。 +- 可用性判定:`IsActive() && Schedulable && !(AutoPauseOnExpired && 已过期)`。刻意不使用 `IsSchedulable()`,因为它包含限流、过载与临时不可调度窗口。 +- 账号来源:`accountRepo.ListByGroup(group.ID)`(active 成员),按配置顺序筛选并跳过缺失 ID。 +- 备选:复用 `BuildGroupConfiguredCodexModelsManifest` 已加载的账号列表以省一次查询。否决:那两个列表是可调度集合,会漏掉限流中的账号;一次按分组的查询成本可接受。 + +### D4:并发拉取与纯函数合并 +- 用 `sync.WaitGroup` 对每个可用账号并发执行:`FetchCodexModelsManifest(ctx, acc, clientVersion, "")` → API Key 账号再 `CompleteAPIKeyCodexModelsManifestForClient`。结果写入按配置顺序索引的切片,失败记录到同下标的错误切片;各账号独立完成,不因单账号失败取消其他请求。 +- Codex 合并函数 `mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error)`:以第一个 body 的顶层信封为基底,`models` 按 slug 并集,先出现者优先;slug 为空或解析失败的条目按出现顺序保留一次。输出后设置 `ETag = codexModelsManifestBodyETag(body)`,再交给 `MergeGroupConfiguredCodexModels` 做分组过滤与 304 判断。 +- 部分失败:`slog.Warn` 带 group_id 与失败账号 ID 列表。 +- 实现放在新文件 `openai_codex_models_pinned.go`,避免继续膨胀 2300 行的主文件。 + +### D5:缓存统一并调整时效 +- 将 `fetchCachedAPIKeyCodexModelsManifest` 泛化为 `fetchCachedOpenAIModels(ctx, request, fetch, ifNoneMatch)`,`fetch func(ctx, ifNoneMatch) (*CodexModelsManifest, error)` 由调用方提供:API Key 路径传 `fetchCodexModelsManifestUpstream`;OAuth 路径传一个包含 agent identity 任务恢复逻辑的闭包。`handleCodexModelsManifestAccountAuthError` 在 OAuth 闭包返回错误时照旧调用。 +- 常量:`openAIModelsCacheTTL` 30s → 60s;`openAIModelsCacheStaleTTL` 保持 5 分钟;超过 5 分钟 `get` 删除条目返回 miss,调用方同步等待单飞结果,这与「超期强制等待上游刷新」一致,无需新状态。 +- 缓存键已包含 Authorization 与 Version 头,令牌刷新自然失效旧条目;`client_version` 不同的客户端各占一个条目。 +- 缓存键不含分组信息,多个分组共用同一账号时共享同一条目,新鲜期内零上游请求,乐观期与超期刷新均按键单飞,同一时刻一个账号只发一次上游请求;分组级处理(并集合并、自定义列表过滤、别名合并)都在缓存体的克隆上进行,不写回缓存。 +- `openAIModelsCacheMaxEntries` 64 → 512。OAuth 路径接入后条目数为「账号数 × 客户端版本数」,64 条在大规模部署下会被淘汰导致额外上游请求;单条清单通常几十 KB,最坏情况内存占用为几十 MB 量级。 +- 后台刷新使用 `context.Background()` 加上游 15 秒超时,与现状一致。 +- 备选:为固定账号模式增加分组级合并结果缓存。否决:账号级缓存命中后合并只是内存操作,再加一层会引入两套时效与失效问题。 + +### D6:前端拆出独立组件 +- 新组件 `components/admin/group/CodexManifestAccountsField.vue`:props 为 `groupId`、`modelValue`(`{enabled, account_ids, fallback_to_scheduler}`)与已选账号名称映射;内部实现开关、标签列表、带防抖的搜索输入与下拉、回退子开关。搜索调用 `adminAPI.accounts.list(1, 20, {search, platform: 'openai', group: String(groupId)})`。 +- `GroupsView.vue` 只在编辑对话框 OpenAI 区块挂载组件,打开编辑时用 `adminAPI.accounts.getById` 解析已存 ID 的名称,失败则显示 `#`。提交时开关打开且列表为空则 toast 报错并阻止。 +- 备选:在 GroupsView 内联复制模型路由的搜索状态。否决:会再引入一套按 key 索引的搜索状态,文件已过大。 + +## Risks / Trade-offs + +- [固定账号模式下每次缓存超期会向 N 个账号并发请求] → 账号数上限 10;账号级缓存 1 分钟新鲜期把稳态请求量压到低于现状。 +- [限流中的账号被强制用于拉取 manifest 可能加重其 429] → 这是需求明确要求的行为;manifest 请求不占并发槽,且被缓存吸收;失败时按部分合并处理。 +- [部分账号失败导致模型列表短暂缺项] → 记录警告日志;下一次超期刷新恢复。用户已确认接受。 +- [OAuth manifest 首次接入缓存,乐观期内令牌已被撤销仍会返回旧内容最多 4 分钟] → manifest 非敏感数据,且撤销后的下一次刷新会失败并按现有 401 处理流程处理账号。 +- [配置引用的账号被解绑或删除后成为脏 ID] → 运行时跳过;编辑对话框显示 `#` 提示管理员清理;全部失效时按回退配置处理。 +- [认证快照漏投影新字段会让开关静默失效] → 仓库已有投影对账集成测试,任务中包含更新该测试。 + +## Migration Plan + +1. 部署包含迁移 234 的后端版本;迁移幂等,默认值 `{}` 反序列化为关闭状态,存量分组行为不变。 +2. 缓存时效变化随部署即时生效,无需数据迁移。 +3. 回滚:回退代码即可,列保留无副作用;如需彻底清理可手工 `DROP COLUMN`。 + +### D7:普通模型列表共用固定账号来源 + +- `GatewayHandler.Models` 在 OpenAI 分组开启固定账号时调用 `FetchPinnedOpenAIModelsList`;普通 `/models` 别名共用该入口。 +- 共享原有配置、固定账号成员筛选、并发收集与部分失败规则;标准列表失败且允许回退时用现有 OpenAI 调度器选账,再调用普通目录获取。无账号为 503,全部上游失败保留上游错误语义,不以静态默认列表掩盖失败。 +- API Key 使用标准模型 URL,不添加 Codex `client_version` 或身份请求头;从管理端抽出 API Key 请求构造。OAuth 复用 `FetchCodexModelsManifest` 和规范客户端版本,保留凭据引用、Agent Identity 恢复、401 状态处理。 +- 响应/缓存元数据类型泛化为 `OpenAIModelsResponse`;抽出原始 HTTP 响应获取,Codex 专用转换/元数据补全仅在 manifest 路径执行。两种请求使用同一缓存实现,以响应格式、完整 URL、账号、凭据、代理和请求头区分条目;相同 OAuth 请求共享条目。 +- 标准列表保留上游 `id`、`created`、`owned_by` 等字段。OAuth slug 转成标准条目;无创建时间采用 0。有效空数组是成功结果,缺失或错误的数组结构是上游错误。 + +### D8:固定账号目录的映射与过滤 + +- 每个成功账号先基于它的原始目录做公开名称投影,随后按配置顺序取并集。透传或无映射账号保留上游具体 ID;有映射账号仅暴露映射目标存在于该账号目录的具体公开名称。 +- 具体映射键以及分组选择的具体名称可作为别名候选;通配映射可匹配上游/分组提供的具体候选,但通配规则本身不作为模型 ID 返回。复用 `ResolveMappedModel` 的匹配优先级。 +- Codex 别名继承对应上游条目的能力元数据;固定账号开启时不再注入其他分组账号的本地别名。固定账号回退选出的账号也应用相同投影。 +- 分组列表过滤最后执行,遵守配置顺序;过滤后为空返回 200 空数组,不使用静态默认模型。最终响应体计算 ETag 并处理 304。 +- 前端文案说明该配置覆盖普通模型列表和 Codex Model Manifest。持久化配置名称和结构不变。 + +### D9:扩展验证 + +新增路由/handler 与 service 测试覆盖普通两条路由、固定账号优先、原始媒体和未知模型、映射与通配别名、部分/全部失败及调度器回退、凭据/请求协议隔离、OAuth 缓存复用、三段缓存时效、分组过滤隔离与空目录、最终 ETag。运行受影响 Go 测试(含 race)、完整 unit 检查及前端类型/组件检查。 diff --git a/openspec/changes/codex-manifest-pinned-accounts/proposal.md b/openspec/changes/codex-manifest-pinned-accounts/proposal.md new file mode 100644 index 0000000000..e21dda639b --- /dev/null +++ b/openspec/changes/codex-manifest-pinned-accounts/proposal.md @@ -0,0 +1,30 @@ +## Why + +OpenAI 分组在没有账号模型映射时,每次 Codex 客户端请求 Model Manifest 都会经由调度器挑选一个账号向上游拉取。调度结果受优先级、负载因子和限流窗口影响,而分组内账号的模型权限并不一致(例如部分账号拥有 Trusted Access for Cyber 的特殊模型),导致同一个 API Key 前后看到的模型列表不确定。此外 OAuth 账号的 manifest 拉取完全没有缓存,每次客户端刷新都直接打到 chatgpt.com。 + +## What Changes + +- 为平台为 OpenAI 的分组新增「使用特定账号获取 Codex Model Manifest」配置:一个默认关闭的开关、一个必须至少选择一个账号的多选账号列表,以及「选定账号全部不可用时回退调度器」的子选项(默认关闭,即返回 503)。 +- 开关打开后,该分组的 API Key 请求 Codex Model Manifest 时只使用选定账号向上游拉取,忽略优先级、负载因子、限流与过载窗口;多个账号并发请求,`models` 按 slug 取并集后返回。部分账号失败时以成功部分合并;全部失败按配置返回 503 或回退到现有调度器路径。 +- 管理端分组编辑对话框的 OpenAI 区块新增该配置的开关与带搜索的多选账号下拉,账号来源限定为当前分组内的 OpenAI 账号。创建对话框不展示该配置(分组创建时尚无账号绑定)。 +- 分组复制时该配置重置为关闭。 +- 普通 `/v1/models` 与 `/models`(无非空 `client_version`)复用同一固定账号配置;API Key 请求标准上游模型列表,OAuth 从现有 Codex manifest 提取 ID,输出 OpenAI 列表。 +- **BREAKING**:固定账号开启时,普通列表和 Codex manifest 都优先进行指定账号发现,随后按来源账号模型映射生成公开目录并应用分组过滤;显式模型映射不再短路固定账号发现。 +- **BREAKING**(行为层面):Codex Model Manifest 缓存统一为 1 分钟新鲜(命中即返回,不请求上游)、1 到 5 分钟乐观返回缓存并后台刷新、超过 5 分钟同步等待上游刷新后再返回。缓存覆盖范围从仅 API Key 账号扩展到 OAuth 账号,固定账号模式的每个账号也走同一缓存。 + +## Capabilities + +### New Capabilities +- `codex-manifest-pinned-accounts`:OpenAI 分组的固定账号 Manifest 配置(数据模型、管理端校验与 UI)、运行时的固定账号并发拉取与合并、不可用时的回退策略。 +- `codex-manifest-cache`:Codex Model Manifest 的按账号缓存策略:新鲜期、乐观期与强制刷新期的行为,以及对所有账号类型生效。 + +### Modified Capabilities + + +## Impact + +- **数据库**:`groups` 新增 JSONB 列 `codex_models_manifest_config`,新增迁移文件;ent schema 与生成代码更新。 +- **后端**:`Group` 领域模型、分组仓储的创建与更新、API Key 加载分组的字段投影、认证快照缓存、管理端分组 DTO 与校验、分组复制逻辑;`openai_codex_models_handler.go` 新增固定账号分支;`openai_codex_models_service.go` 新增固定账号并发拉取与合并、缓存策略调整并扩展到 OAuth 路径。 +- **管理端 API**:`POST/PUT /admin/groups` 请求与响应新增 `codex_models_manifest_config` 字段;`GET /admin/accounts` 已支持按分组过滤,无需改动。 +- **前端**:`GroupsView.vue` 编辑对话框 OpenAI 区块、`types/index.ts`、中英文 i18n。 +- **上游影响**:固定账号模式下每次缓存失效会向 N 个账号并发请求;缓存新鲜期延长到 1 分钟后,稳态上游请求量低于当前。 diff --git a/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-cache/spec.md b/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-cache/spec.md new file mode 100644 index 0000000000..d26e516987 --- /dev/null +++ b/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-cache/spec.md @@ -0,0 +1,71 @@ +## Purpose + +规定 Codex Model Manifest 按账号缓存的时效策略,使所有账号类型的 manifest 拉取在新鲜期内不打上游、乐观期内先返回缓存再后台刷新、超期后同步刷新。 + +## ADDED Requirements + +### Requirement: 所有账号类型的 manifest 拉取都经过缓存 +系统 MUST 对 OAuth 账号与 API Key 账号的 Codex Model Manifest 上游拉取统一使用同一套按账号缓存。缓存键 MUST 区分账号、凭据账号、代理与请求头(含授权头与客户端版本),凭据变化后 MUST 视为新的缓存项。缓存键 MUST NOT 包含分组信息,多个分组共用同一账号时 MUST 共享同一缓存项;固定账号模式下每个选定账号的拉取 MUST 同样经过该缓存。缓存 MUST 至少容纳 512 个条目。 + +#### Scenario: OAuth 账号命中缓存 +- **WHEN** 同一 OAuth 账号在新鲜期内被再次用于获取 manifest +- **THEN** 系统 MUST 直接返回缓存内容,MUST NOT 向 chatgpt.com 发起请求 + +#### Scenario: 多个分组共用同一账号 +- **WHEN** 两个分组在同一时刻通过同一账号请求 manifest 且缓存已超期 +- **THEN** 系统 MUST 只向上游发起一次请求,两个分组 MUST 各自基于该结果做分组级处理 +- **THEN** 一个分组的过滤结果 MUST NOT 影响另一个分组收到的内容 + +#### Scenario: 凭据刷新后 +- **WHEN** 账号的访问令牌被刷新 +- **THEN** 下一次拉取 MUST 视为缓存未命中并同步请求上游 + +### Requirement: 新鲜期 1 分钟内强制使用缓存 +缓存项写入后 1 分钟内 MUST 视为新鲜:系统 MUST 直接返回缓存内容且 MUST NOT 触发任何上游请求(包括后台刷新)。 + +#### Scenario: 新鲜期内并发请求 +- **WHEN** 缓存写入后 30 秒内收到 10 个同账号 manifest 请求 +- **THEN** 上游请求次数 MUST 为 0 + +### Requirement: 1 到 5 分钟乐观返回并后台刷新 +缓存项写入后超过 1 分钟且不超过 5 分钟时 MUST 视为过期但可用:系统 MUST 立即返回缓存内容,并在后台对同一缓存键做单飞刷新。后台刷新 MUST 携带上游 ETag,上游返回 304 时 MUST 续期现有缓存项。 + +#### Scenario: 乐观期返回旧值 +- **WHEN** 缓存写入后 3 分钟收到请求 +- **THEN** 系统 MUST 立即返回缓存内容 +- **THEN** 系统 MUST 触发一次后台上游刷新,同一时刻多个请求 MUST 只触发一次 + +#### Scenario: 后台刷新失败 +- **WHEN** 乐观期后台刷新失败 +- **THEN** 已返回给客户端的响应 MUST 不受影响 +- **THEN** 缓存项 MUST 保持不变直到超期 + +### Requirement: 超过 5 分钟强制同步刷新 +缓存项写入后超过 5 分钟 MUST 视为失效:系统 MUST 丢弃该缓存项,同步等待上游响应后再返回;上游失败时 MUST 向客户端返回错误而不是旧缓存。 + +#### Scenario: 超期后同步等待 +- **WHEN** 缓存写入后 6 分钟收到请求 +- **THEN** 系统 MUST 等待上游响应后返回新内容 +- **THEN** 新内容 MUST 写入缓存并重新开始 1 分钟新鲜期 + +#### Scenario: 超期后上游失败 +- **WHEN** 缓存已超期且上游请求失败 +- **THEN** 系统 MUST 返回上游错误,MUST NOT 返回旧缓存 + +### Requirement: 条件请求基于缓存内容的 ETag +客户端携带的 `If-None-Match` MUST 与返回给客户端的最终响应体 ETag 比较,而不是透传给上游。缓存命中且 ETag 匹配时 MUST 返回 304。 + +#### Scenario: 缓存命中且 ETag 匹配 +- **WHEN** 客户端 `If-None-Match` 等于当前缓存内容的 ETag +- **THEN** 系统 MUST 返回 304 且不请求上游 + +### Requirement: 普通模型发现使用相同缓存策略 +普通固定账号模型列表的上游获取 MUST 使用本规范的同一套按账号缓存与单飞实现。完整请求 URL MUST 参与缓存键;API Key 的普通请求与 Codex 协议请求 MUST 分离。OAuth 普通列表使用规范版本获取的 manifest MUST 与相同请求条件的 Codex manifest 共享缓存。分组后处理 MUST NOT 修改缓存。 + +#### Scenario: OAuth 两种列表共用缓存 +- **WHEN** 相同 OAuth 账号的普通列表与相同版本 Codex manifest 在新鲜期内连续请求 +- **THEN** 系统 MUST 仅发起一次上游请求,并输出各自正确的响应结构 + +#### Scenario: API Key 两种协议分离 +- **WHEN** 相同 API Key 账号先后被用于普通列表和带 client_version 的 Codex manifest +- **THEN** 各请求 MUST 获取并缓存自己的上游响应,MUST NOT 复用另一协议已转换的内容 diff --git a/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-pinned-accounts/spec.md b/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-pinned-accounts/spec.md new file mode 100644 index 0000000000..c4eeb6ff40 --- /dev/null +++ b/openspec/changes/codex-manifest-pinned-accounts/specs/codex-manifest-pinned-accounts/spec.md @@ -0,0 +1,138 @@ +## Purpose + +让 OpenAI 分组管理员指定一组固定账号来获取普通模型列表与 Codex Model Manifest,使同一分组的 API Key 始终看到确定且合并后的模型列表,而不受调度器选账结果影响。 + +## ADDED Requirements + +### Requirement: OpenAI 分组可配置固定账号获取 Codex Model Manifest +系统 SHALL 为平台为 `openai` 的分组提供 `codex_models_manifest_config` 配置,包含 `enabled`(默认 false)、`account_ids`(账号 ID 列表)和 `fallback_to_scheduler`(默认 false)。`enabled=false` 时系统 MUST 保持现有的本地目录优先、无本地目录时调度器获取 manifest 的行为;普通列表保持本地映射/默认列表行为。 + +#### Scenario: 默认关闭 +- **WHEN** 分组未设置或 `enabled=false` +- **THEN** Codex Model Manifest 请求 MUST 走现有本地生成或调度器选账路径 +- **THEN** `account_ids` 与 `fallback_to_scheduler` MUST 不影响任何运行时行为 + +#### Scenario: 非 OpenAI 平台分组 +- **WHEN** 分组平台不是 `openai` 且请求携带 `enabled=true` 的配置 +- **THEN** 系统 MUST 将该配置归一化为关闭状态后落库,而不是返回错误 + +### Requirement: 开启时必须至少选择一个分组内的 OpenAI 账号 +当 `enabled=true` 时,管理端更新接口 MUST 校验 `account_ids` 去重后至少包含一个账号,且每个账号 MUST 是当前分组内状态为 active、平台为 `openai` 的账号;账号数量 MUST NOT 超过 10 个。校验失败 MUST 返回 400,不落库。 + +#### Scenario: 开启但未选择账号 +- **WHEN** 管理员保存 `enabled=true` 且 `account_ids` 为空 +- **THEN** 系统 MUST 返回 400,错误码为 `INVALID_CODEX_MODELS_MANIFEST_CONFIG` + +#### Scenario: 选择了不属于当前分组的账号 +- **WHEN** `account_ids` 包含未绑定到该分组、已停用或平台不是 `openai` 的账号 +- **THEN** 系统 MUST 返回 400,错误信息指出无效账号 ID + +#### Scenario: 创建分组时开启 +- **WHEN** 创建分组请求携带 `enabled=true` +- **THEN** 系统 MUST 返回 400,提示创建后再在编辑中配置 + +#### Scenario: 重复账号 ID +- **WHEN** `account_ids` 含重复 ID +- **THEN** 系统 MUST 去重后保存,保持首次出现的顺序 + +### Requirement: 固定账号模式只使用选定账号获取 manifest +当 `enabled=true` 时,该分组 API Key 的 Codex Model Manifest 请求 MUST 只向选定账号发起上游请求,MUST NOT 调用调度器。选定账号的可用性判定 MUST 只考虑账号状态为 active、可调度开关打开、未因过期自动暂停;MUST 忽略优先级、负载因子、限流窗口与过载窗口。已从分组移除或已删除的账号 MUST 被跳过。 + +#### Scenario: 限流中的选定账号仍被使用 +- **WHEN** 某个选定账号处于限流或过载窗口内 +- **THEN** 系统 MUST 仍然使用该账号请求 manifest + +#### Scenario: 停用的选定账号被跳过 +- **WHEN** 某个选定账号状态为 inactive 或可调度开关关闭 +- **THEN** 系统 MUST 跳过该账号,不向其发起请求 + +#### Scenario: 选定账号已不在分组内 +- **WHEN** 配置中的账号 ID 已从分组解绑或已删除 +- **THEN** 系统 MUST 跳过该 ID,并继续处理其余账号 + +### Requirement: 多个选定账号并发请求并合并模型列表 +系统 MUST 对所有可用的选定账号并发发起 manifest 请求,并把各账号响应的 `models` 按 `slug` 取并集:同一 slug 以配置顺序中靠前账号的条目为准;顶层其余字段取配置顺序中第一个成功账号的响应。合并结果 MUST 继续应用分组自定义模型列表过滤;别名 MUST 仅来自成功拉取账号的映射投影,并基于最终响应体计算 ETag。 + +#### Scenario: 并集合并 +- **WHEN** 账号 1 返回模型 A、B,账号 2 返回模型 A、C +- **THEN** 客户端 MUST 收到模型 A、B、C,且 A 的条目来自账号 1 + +#### Scenario: 部分账号失败 +- **WHEN** 一个选定账号上游返回错误而其他账号成功 +- **THEN** 系统 MUST 以成功账号的响应合并并返回 200 +- **THEN** 系统 MUST 记录包含失败账号 ID 的警告日志 + +#### Scenario: 分组自定义模型列表仍然生效 +- **WHEN** 分组启用了自定义 `/v1/models` 列表 +- **THEN** 合并后的 manifest MUST 只保留列表中允许的模型 + +#### Scenario: ETag 条件请求 +- **WHEN** 客户端 `If-None-Match` 与合并后最终响应体的 ETag 匹配 +- **THEN** 系统 MUST 返回 304 且响应体为空 + +### Requirement: 选定账号全部不可用或全部失败时按配置回退 +当没有任何可用的选定账号,或所有可用账号的上游请求全部失败时:`fallback_to_scheduler=false` 时系统 MUST 返回上游错误(全部失败时)或 503(无可用账号时);`fallback_to_scheduler=true` 时系统 MUST 回退到现有调度器选账路径。 + +#### Scenario: 默认不回退 +- **WHEN** `fallback_to_scheduler=false` 且所有选定账号都不可用 +- **THEN** 系统 MUST 返回 503,错误类型为 `upstream_error` + +#### Scenario: 全部失败且不回退 +- **WHEN** `fallback_to_scheduler=false` 且所有可用选定账号的上游请求均失败 +- **THEN** 系统 MUST 返回最后一个上游错误对应的状态码与信息 + +#### Scenario: 开启回退 +- **WHEN** `fallback_to_scheduler=true` 且所有选定账号不可用或全部失败 +- **THEN** 系统 MUST 使用调度器选择账号并按现有流程返回 manifest + +### Requirement: 管理端展示与编辑固定账号配置 +分组编辑对话框在平台为 `openai` 时 MUST 展示该配置:一个开关;开关打开后展示带搜索的多选账号下拉与「全部不可用时回退调度器」子开关。账号下拉 MUST 只列出当前分组内平台为 `openai` 的账号,并支持按名称搜索。分组创建对话框 MUST NOT 展示该配置。 + +#### Scenario: 开启后未选账号即提交 +- **WHEN** 管理员打开开关但未选择任何账号并点击保存 +- **THEN** 前端 MUST 阻止提交并提示至少选择一个账号 + +#### Scenario: 回显已保存账号 +- **WHEN** 打开一个已配置固定账号的分组编辑对话框 +- **THEN** 已选账号 MUST 以名称标签展示;无法解析名称的账号 MUST 以 `#` 展示 + +#### Scenario: 分组复制 +- **WHEN** 管理员复制一个开启了固定账号配置的分组 +- **THEN** 新分组的该配置 MUST 为关闭且账号列表为空 + +### Requirement: 普通模型列表复用固定账号配置 +当 OpenAI 分组开启固定账号时,普通 `/v1/models` 与 `/models` 请求 MUST 使用同一组指定账号向上游发现模型,并输出 `{object:"list",data:[...]}`。API Key 请求 MUST 使用标准模型列表端点,不添加 `client_version`;OAuth MUST 使用已有 Codex manifest 链路,将 slug 转为标准 ID。普通列表 MUST 保留上游媒体模型与未知具体模型。 + +#### Scenario: 普通客户端发现特殊模型 +- **WHEN** 不带 `client_version` 的请求使用开启固定账号的分组,所选账号返回非内置的模型 +- **THEN** 普通列表 MUST 包含该模型且 MUST NOT 请求未选账号 + +#### Scenario: API Key 与 OAuth 账号混合 +- **WHEN** 所选账号包含 API Key 与 OAuth +- **THEN** 系统 MUST 按各自协议获取目录并按 ID 合并,重复 ID MUST 取配置顺序靠前账号的条目 + +#### Scenario: 普通请求回退 +- **WHEN** 固定账号全不可用或全失败且开启回退 +- **THEN** 系统 MUST 经由调度器选账号获取普通模型目录,而不是返回本地默认列表 + +### Requirement: 固定账号发现先于本地目录生成 +固定账号开启时,普通列表与 Codex manifest MUST 先获取指定账号目录,再按照来源账号的模型映射生成公开名称,最后应用分组列表过滤。MUST NOT 因为存在显式模型映射而跳过上游发现。透传账号 MUST 忽略残留模型映射;普通模式的具体别名仅在映射目标存在于来源账号目录时可见。MUST NOT 注入未成功拉取账号的别名或把通配模式作为模型 ID 返回。 + +#### Scenario: 有映射的固定账号 +- **WHEN** 所选账号配置 public-name 到 upstream-name 的映射且上游返回 upstream-name +- **THEN** 系统 MUST 请求该账号上游并以 public-name 返回该模型,Codex 条目 MUST 继承上游能力元数据 + +#### Scenario: 固定账号开启但列表为空 +- **WHEN** 运行时配置开启但账号 ID 列表为空 +- **THEN** 系统 MUST 按无可用固定账号的失败/回退规则处理,MUST NOT 静默使用本地目录 + +#### Scenario: 成功空目录或过滤为空 +- **WHEN** 上游返回有效空数组或分组过滤排除了全部模型 +- **THEN** 普通模型列表 MUST 返回 200 且 data 为空数组,MUST NOT 返回默认模型或触发失败回退 + +### Requirement: 普通模型列表的条件请求 +普通固定账号列表 MUST 基于映射、合并和过滤后的最终响应体计算 ETag。 + +#### Scenario: 普通列表 ETag 匹配 +- **WHEN** 客户端 If-None-Match 与最终普通列表响应的 ETag 匹配 +- **THEN** 系统 MUST 返回 304 且无响应体 diff --git a/openspec/changes/codex-manifest-pinned-accounts/tasks.md b/openspec/changes/codex-manifest-pinned-accounts/tasks.md new file mode 100644 index 0000000000..a8faeb1274 --- /dev/null +++ b/openspec/changes/codex-manifest-pinned-accounts/tasks.md @@ -0,0 +1,57 @@ +## 1. 数据模型与持久化 + +- [x] 1.1 在 `backend/internal/domain` 新增 `GroupCodexModelsManifestConfig{Enabled, AccountIDs, FallbackToScheduler}`,JSON 键为 `enabled`、`account_ids`、`fallback_to_scheduler` +- [x] 1.2 在 `backend/ent/schema/group.go` 新增 JSONB 字段 `codex_models_manifest_config`(默认空结构体),执行 `go generate ./ent` +- [x] 1.3 新增迁移 `backend/migrations/234_group_codex_models_manifest_config.sql`(`ADD COLUMN IF NOT EXISTS ... JSONB NOT NULL DEFAULT '{}'::jsonb`) +- [x] 1.4 `service.Group` 新增 `CodexModelsManifestConfig` 字段并添加类型别名;`group_repo.go` 创建与更新两处 setter 写入该字段 +- [x] 1.5 `api_key_repo.go`:分组字段投影列表与 `groupEntityToService` 加入新字段 +- [x] 1.6 `api_key_auth_cache.go` 快照结构体与 `api_key_auth_cache_impl.go` 两处映射加入新字段 +- [x] 1.7 `admin_group_duplicate.go` 复制分组时将该配置重置为关闭且账号列表为空 +- [x] 1.8 更新 API Key 分组投影对账集成测试,覆盖新字段 + +## 2. 管理端配置接口与校验 + +- [x] 2.1 新增 `backend/internal/service/group_codex_models_manifest.go`:`normalizeCodexModelsManifestConfig`(非 openai 平台归零、去重保序)与 `validateCodexModelsManifestConfig`(开启时非空、≤10、全部为分组内 active 的 openai 账号),错误码 `INVALID_CODEX_MODELS_MANIFEST_CONFIG` +- [x] 2.2 `admin_service.go` 的 `CreateGroupInput`/`UpdateGroupInput` 与 `admin_group.go` 创建、更新路径接入归一化与校验;创建路径 `enabled=true` 返回 400 +- [x] 2.3 `handler/admin/group_handler.go` 的创建与更新请求结构体、`dto/types.go` 响应结构体、`dto/mappers.go` 增加 `codex_models_manifest_config` +- [x] 2.4 在 `admin_service_group_test.go` 增加测试:开启但空列表被拒、非成员或非 openai 账号被拒、超过 10 个被拒、非 openai 平台被归零、重复 ID 去重保序、创建时开启被拒 + +## 3. 缓存策略统一 + +- [x] 3.1 将 `codexModelsManifestCacheTTL` 调整为 60 秒,保持 `codexModelsManifestCacheStaleTTL` 为 5 分钟,将 `codexModelsManifestCacheMaxEntries` 提高到 512,并更新常量注释说明三段时效与容量依据 +- [x] 3.2 将 `fetchCachedAPIKeyCodexModelsManifest`/`refreshCachedAPIKeyCodexModelsManifest` 泛化为接受 `fetch` 闭包的 `fetchCachedCodexModelsManifest`/`refreshCachedCodexModelsManifest` +- [x] 3.3 `FetchCodexModelsManifest` 的 OAuth 分支改为经缓存调用,闭包内保留 agent identity 任务恢复逻辑,错误时仍调用 `handleCodexModelsManifestAccountAuthError` +- [x] 3.4 在 `openai_codex_models_service_test.go` 增加或调整测试:OAuth 账号新鲜期内零上游请求、乐观期返回旧值且单飞后台刷新、超期后同步等待并写回、超期上游失败返回错误、令牌变化后缓存未命中、If-None-Match 基于缓存 ETag 返回 304、同一账号被两个分组同时请求时只发一次上游请求且各分组过滤互不影响 +- [x] 3.5 检查现有测试对 30 秒 TTL 或 OAuth 不缓存的隐含假设并修正 + +## 4. 固定账号模式运行时 + +- [x] 4.1 新增 `backend/internal/service/openai_codex_models_pinned.go`:`mergeCodexModelsManifestBodies` 纯函数(第一个信封为基底、`models` 按 slug 并集且先出现者优先) +- [x] 4.2 同文件实现 `FetchPinnedCodexModelsManifest(ctx, group, clientVersion)`:`ListByGroup` 取成员、按配置顺序筛选可用账号(active、Schedulable、未过期;忽略限流与过载)、errgroup 并发拉取与补全、按下标收集结果、部分失败记录 `slog.Warn`、全部不可用返回 `ErrNoPinnedCodexModelsAccounts`、全部失败返回最后一个错误、成功时设置合并体 ETag 并返回首个成功账号 +- [x] 4.3 `openai_codex_models_handler.go`:本地生成分支之后加入固定账号分支,成功时 `setOpsSelectedAccount` 后调用 `MergeGroupConfiguredCodexModels` 并写响应;按 `FallbackToScheduler` 决定回退调度器循环或返回 503 / 上游错误 +- [x] 4.4 为合并函数编写单元测试:并集与顺序、重复 slug 取靠前账号条目、信封字段来源、无 slug 条目处理 +- [x] 4.5 在 `openai_codex_models_handler_test.go` 增加测试:两个账号返回不同模型时客户端收到并集且未调用调度器、限流账号仍被使用、停用账号被跳过、不在分组的 ID 被跳过、部分失败仍 200、全部不可用时默认 503、开启回退时走调度器、自定义模型列表过滤仍生效、ETag 匹配返回 304 + +## 5. 前端 + +- [x] 5.1 `frontend/src/types/index.ts` 新增 `CodexModelsManifestConfig` 类型,并加入 `AdminGroup`、创建与更新请求类型 +- [x] 5.2 新增 `frontend/src/components/admin/group/CodexManifestAccountsField.vue`:开关、已选账号标签、带防抖的搜索下拉(`adminAPI.accounts.list` 按 `platform=openai` 与 `group` 过滤)、回退子开关 +- [x] 5.3 `GroupsView.vue` 编辑对话框 OpenAI 区块挂载组件;打开编辑时解析已存账号名称(失败显示 `#`);提交时开关打开且列表为空则提示并阻止;更新请求携带该字段;创建请求固定发送关闭状态 +- [x] 5.4 中英文 i18n(`overview.ts` 的 `admin.groups.codexModelsManifest.*`):标题、开关文案、启用与未启用提示、账号标签、搜索占位、回退开关文案、至少选择一个账号的错误提示 +- [x] 5.5 为新组件编写 Vitest 测试:开关切换显示、选择与移除账号、开启后空列表触发校验 + +## 6. 验证 + +- [x] 6.1 `cd backend && go test -tags=unit ./...` 与 `golangci-lint run ./...` 通过 +- [x] 6.2 `cd backend && go test -tags=integration ./internal/repository/...` 通过(含投影对账测试) +- [x] 6.3 `cd frontend && pnpm test` 与类型检查通过 +- [ ] 6.4 本地启动后手动验证:编辑 OpenAI 分组开启固定账号并选择两个权限不同的账号,Codex 客户端拉取 manifest 得到并集;1 分钟内重复请求不打上游 + +## 7. 普通模型列表扩展 + +- [x] 7.1 泛化模型响应/缓存基础,抽出原始上游传输和 API Key 标准请求构造,OAuth 复用现有认证与缓存 +- [x] 7.2 固定账号筛选与并发获取共用,新增普通模型目录聚合及调度器回退 +- [x] 7.3 实现来源账号映射投影、分组最终过滤、空目录与 ETag;Codex 固定账号优先于本地生成 +- [x] 7.4 普通 `/v1/models` 与 `/models` 接入固定账号逻辑,中英文 UI 文案覆盖两个入口 +- [x] 7.5 添加两种上游、映射优先级、失败回退、跨分组及跨协议缓存、三段时效和 ETag 回归测试 +- [x] 7.6 完成受影响测试、race、unit/lint、前端类型与组件验证及 OpenSpec 校验,记录验证范围 diff --git a/openspec/changes/codex-manifest-pinned-accounts/verification.md b/openspec/changes/codex-manifest-pinned-accounts/verification.md new file mode 100644 index 0000000000..6e26b14c65 --- /dev/null +++ b/openspec/changes/codex-manifest-pinned-accounts/verification.md @@ -0,0 +1,23 @@ +# 普通模型列表扩展验证(2026-09-05) + +## 已实现 + +普通 `/v1/models` 与 `/models` 共用分组固定账号配置。API Key 使用标准模型端点;OAuth/Agent Identity 继续使用现有 Codex 上游认证链路。两种目录共享固定账号筛选、并发、部分失败处理和缓存策略;固定账号发现先于本地目录,映射、分组过滤和 ETag 在缓存之后应用。 + +普通列表保留上游未知模型、媒体模型及模型元数据。有效空目录/过滤为空不会触发静态默认或调度器回退。无可用账号返回 503,上游失败遵守回退配置;客户端错误使用 OpenAI 错误信封。 + +## 自动验证 + +- `GOCACHE=/tmp/sub2api-go-cache go test -tags=unit ./... -timeout=5m`:通过,55 个含测试的包成功。 +- 最后补充响应格式缓存隔离和冷缓存 304 错误处理后,执行 `GOCACHE=/tmp/sub2api-go-cache go test -race -tags=unit -p 2 ./internal/service ./internal/handler ./internal/server/routes -run 'Test.*(OpenAIModels|CodexModels|OrdinaryPinned|PinnedModels|ProjectAccountModels)' -count=1 -timeout=3m`:三个包全部通过。 +- 最终代码执行 `GOCACHE=/tmp/sub2api-go-cache GOLANGCI_LINT_CACHE=/tmp/sub2api-models-lint-cache golangci-lint run ./... --timeout=5m`:0 issues。 +- 前端使用已安装依赖执行 `./node_modules/.bin/vitest run src/components/admin/group/__tests__/CodexManifestAccountsField.spec.ts src/views/admin/__tests__/GroupsView.duplicate.spec.ts`:2 个文件、10 个测试通过。 +- `./node_modules/.bin/vue-tsc --noEmit`:通过。 +- 两个修改的 i18n 文件 ESLint:通过。 +- `openspec validate codex-manifest-pinned-accounts --strict`、`git diff --check`:通过。 + +回归覆盖普通两条路由(含空 client_version)、三个 Codex 入口、API Key/OAuth 混合账号、影子账号凭据解析、优先级/临时限流、停用/过期/非成员跳过、部分失败/全部失败/调度器回退、映射别名和通配规则、跨分组并发缓存隔离、OAuth 跨入口缓存复用、API Key 协议隔离、三段时效/单飞/ETag 续期、最终响应 304、空目录、媒体别名不能绕过 Codex 专用过滤。 + +## 验证边界 + +测试使用本地模拟上游和仓储,没有访问真实 OpenAI/ChatGPT 账号或部署服务。原任务 6.4 的真实客户端手动联调仍未勾选;本次新增 7.1—7.6 已完成。