mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 16:20:27 +00:00
feat(openai): reuse pinned accounts for standard model discovery
This commit is contained in:
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user