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"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user