feat(openai): reuse pinned accounts for standard model discovery

This commit is contained in:
Zero Clover
2026-09-05 19:58:47 +08:00
parent f3bbb95310
commit cb31033972
22 changed files with 1700 additions and 199 deletions
@@ -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() {
@@ -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)
}
@@ -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)
}
@@ -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"`)
}
})
}
}