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"`)
}
})
}
}
@@ -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())
}
+1 -1
View File
@@ -108,7 +108,7 @@ type Group struct {
DefaultMappedModel string
MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig
ModelsListConfig GroupModelsListConfig
// CodexModelsManifestConfig 开启后,该分组的 Codex /models manifest 请求只用
// CodexModelsManifestConfig 开启后,普通模型列表与 Codex manifest 优先使用
// 固定账号列表拉取并合并,不经过调度器(仅 openai 平台)。
CodexModelsManifestConfig GroupCodexModelsManifestConfig
@@ -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
}
@@ -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 {
@@ -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
}
@@ -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,
@@ -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
}
@@ -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())
}
+7 -1
View File
@@ -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)
}