背景:Codex CLI / Codex App 会从 provider 的 GET {base_url}/models?client_version=...
(自定义 provider 模式)或 GET /backend-api/codex/models(chatgpt_base_url 模式)
刷新模型选单,期望 ChatGPT Codex manifest 格式({"models":[{slug,...}]})。
sub2api 此前只提供 OpenAI 兼容格式的 /v1/models,Codex 客户端解析失败后静默
回落到本地缓存,导致指向 sub2api 的 Codex 客户端模型选单永久冻结在切换
provider 当天的状态,新模型(如 gpt-5.6 系列)永远不会出现。
方案:新增 manifest 透传——用组内可调度 OAuth 账号的凭据向
chatgpt.com/backend-api/codex/models 实时转发请求,响应体与 ETag 原样透传。
不在网关侧解析或维护模型清单:manifest schema 随 Codex 客户端版本演进,
透传保证网关无需跟进 schema 变化,且返回的始终是账号真实的模型权限
(区别于静态 DefaultModels 的"理论列表")。
路由:
- GET /backend-api/codex/models(新增)
- GET /v1/models 带 client_version 查询参数且组平台为 openai 时分发到
manifest 透传(client_version 是 Codex 客户端的天然指纹,普通 OpenAI
客户端不携带);其余请求保持原有 OpenAI 格式行为不变。
上游失败时按 fast-fail 返回错误,不伪造列表;Codex 客户端自身会回落缓存。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
108 lines
4.3 KiB
Go
108 lines
4.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
|
)
|
|
|
|
// chatgptCodexModelsURL is the ChatGPT Codex models manifest endpoint.
|
|
// Package-level variable so tests can point it at a stub server.
|
|
var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models"
|
|
|
|
const codexModelsManifestBodyLimit int64 = 8 << 20
|
|
|
|
// CodexModelsManifest carries the raw upstream manifest payload plus caching
|
|
// metadata so handlers can pass both through to the client untouched.
|
|
type CodexModelsManifest struct {
|
|
Body []byte
|
|
ETag string
|
|
NotModified bool
|
|
}
|
|
|
|
// FetchCodexModelsManifest fetches the live Codex models manifest from the
|
|
// ChatGPT backend using the account's OAuth credentials.
|
|
//
|
|
// The response body is passed through verbatim: the manifest schema evolves
|
|
// with Codex client releases, and interpreting it here would force the gateway
|
|
// to chase upstream changes. Passing it through keeps the gateway
|
|
// schema-agnostic and always reflects the account's real entitlements.
|
|
func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) {
|
|
if account == nil {
|
|
return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required")
|
|
}
|
|
credAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_CREDENTIALS_FAILED", "resolve credential account: %v", err)
|
|
}
|
|
accessToken := credAccount.GetOpenAIAccessToken()
|
|
if accessToken == "" {
|
|
return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token")
|
|
}
|
|
|
|
clientVersion = strings.TrimSpace(clientVersion)
|
|
if clientVersion == "" {
|
|
clientVersion = openAICodexProbeVersion
|
|
}
|
|
requestURL := chatgptCodexModelsURL + "?client_version=" + url.QueryEscape(clientVersion)
|
|
|
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, requestURL, nil)
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "create codex models request: %v", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Originator", "codex_cli_rs")
|
|
req.Header.Set("Version", clientVersion)
|
|
req.Header.Set("User-Agent", codexCLIUserAgent)
|
|
if ifNoneMatch = strings.TrimSpace(ifNoneMatch); ifNoneMatch != "" {
|
|
req.Header.Set("If-None-Match", ifNoneMatch)
|
|
}
|
|
setOpenAIChatGPTAccountHeaders(req.Header, credAccount)
|
|
|
|
proxyURL := ""
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
proxyURL = account.Proxy.URL()
|
|
}
|
|
client, err := httpclient.GetClient(httpclient.Options{
|
|
ProxyURL: proxyURL,
|
|
Timeout: 15 * time.Second,
|
|
ResponseHeaderTimeout: 10 * time.Second,
|
|
})
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest request failed: %v", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode == http.StatusNotModified {
|
|
return &CodexModelsManifest{ETag: resp.Header.Get("ETag"), NotModified: true}, nil
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
message := strings.TrimSpace(string(body))
|
|
if message == "" {
|
|
message = resp.Status
|
|
}
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest upstream error %d: %s", resp.StatusCode, message)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit))
|
|
if err != nil {
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err)
|
|
}
|
|
return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, nil
|
|
}
|