774 lines
26 KiB
Go
774 lines
26 KiB
Go
package service
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"context"
|
|||
|
|
"crypto/sha256"
|
|||
|
|
"encoding/json"
|
|||
|
|
"errors"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"net"
|
|||
|
|
"net/http"
|
|||
|
|
"net/url"
|
|||
|
|
"sort"
|
|||
|
|
"strings"
|
|||
|
|
"sync"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|||
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
|||
|
|
"golang.org/x/net/http2"
|
|||
|
|
"golang.org/x/sync/singleflight"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 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
|
|||
|
|
codexModelsManifestCacheBodyLimit = 1 << 20
|
|||
|
|
codexModelsManifestCacheMaxEntries = 64
|
|||
|
|
codexModelsManifestCacheTTL = 30 * time.Second
|
|||
|
|
codexModelsManifestCacheStaleTTL = 5 * time.Minute
|
|||
|
|
codexModelsManifestRequestTimeout = 15 * time.Second
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// CodexModelsManifest carries the client representation plus caching metadata.
|
|||
|
|
type CodexModelsManifest struct {
|
|||
|
|
Body []byte
|
|||
|
|
ETag string
|
|||
|
|
upstreamETag string
|
|||
|
|
NotModified bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type codexModelsManifestUpstreamError struct {
|
|||
|
|
err error
|
|||
|
|
retryable bool
|
|||
|
|
statusCode int
|
|||
|
|
headers http.Header
|
|||
|
|
body []byte
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (e *codexModelsManifestUpstreamError) Error() string { return e.err.Error() }
|
|||
|
|
|
|||
|
|
func (e *codexModelsManifestUpstreamError) Unwrap() error { return e.err }
|
|||
|
|
|
|||
|
|
// IsRetryableCodexModelsManifestError reports whether another selected account
|
|||
|
|
// may succeed without changing the request. Configuration and upstream 4xx
|
|||
|
|
// responses, except 429 and ChatGPT-backend 401, are intentionally not
|
|||
|
|
// retried. A manifest 401 from the ChatGPT Codex backend reflects the selected
|
|||
|
|
// OAuth account's upstream token rather than the client request (the client's
|
|||
|
|
// own API key was already validated locally), so a different account may still
|
|||
|
|
// serve the manifest. Custom API key upstreams keep the old no-failover 401
|
|||
|
|
// behavior because their /models auth semantics are not authoritative for the
|
|||
|
|
// account.
|
|||
|
|
func IsRetryableCodexModelsManifestError(err error) bool {
|
|||
|
|
var upstreamErr *codexModelsManifestUpstreamError
|
|||
|
|
return errors.As(err, &upstreamErr) && upstreamErr.retryable
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func isRetryableCodexModelsManifestTransportError(err error) bool {
|
|||
|
|
if err == nil || errors.Is(err, context.Canceled) {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if errors.Is(err, context.DeadlineExceeded) ||
|
|||
|
|
errors.Is(err, io.EOF) ||
|
|||
|
|
errors.Is(err, io.ErrUnexpectedEOF) ||
|
|||
|
|
errors.Is(err, net.ErrClosed) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var opErr *net.OpError
|
|||
|
|
if errors.As(err, &opErr) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
var dnsErr *net.DNSError
|
|||
|
|
if errors.As(err, &dnsErr) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
var goAwayErr http2.GoAwayError
|
|||
|
|
if errors.As(err, &goAwayErr) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
var streamErr http2.StreamError
|
|||
|
|
if errors.As(err, &streamErr) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
var connectionErr http2.ConnectionError
|
|||
|
|
if errors.As(err, &connectionErr) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
var netErr net.Error
|
|||
|
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// net/http uses unexported HTTP/2 error types, so typed matching is not
|
|||
|
|
// possible for errors produced by the standard library transport.
|
|||
|
|
message := strings.ToLower(err.Error())
|
|||
|
|
if strings.Contains(message, "http2:") &&
|
|||
|
|
(strings.Contains(message, "goaway") ||
|
|||
|
|
strings.Contains(message, "refused_stream") ||
|
|||
|
|
strings.Contains(message, "frame too large")) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
if strings.Contains(message, "stream error: stream id ") {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
for _, code := range []http2.ErrCode{
|
|||
|
|
http2.ErrCodeNo,
|
|||
|
|
http2.ErrCodeProtocol,
|
|||
|
|
http2.ErrCodeInternal,
|
|||
|
|
http2.ErrCodeFlowControl,
|
|||
|
|
http2.ErrCodeSettingsTimeout,
|
|||
|
|
http2.ErrCodeStreamClosed,
|
|||
|
|
http2.ErrCodeFrameSize,
|
|||
|
|
http2.ErrCodeRefusedStream,
|
|||
|
|
http2.ErrCodeCancel,
|
|||
|
|
http2.ErrCodeCompression,
|
|||
|
|
http2.ErrCodeConnect,
|
|||
|
|
http2.ErrCodeEnhanceYourCalm,
|
|||
|
|
http2.ErrCodeInadequateSecurity,
|
|||
|
|
http2.ErrCodeHTTP11Required,
|
|||
|
|
} {
|
|||
|
|
if strings.Contains(message, "connection error: "+strings.ToLower(code.String())) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type codexModelsManifestRequest struct {
|
|||
|
|
url string
|
|||
|
|
headers http.Header
|
|||
|
|
proxyURL string
|
|||
|
|
accountID int64
|
|||
|
|
credentialAccountID int64
|
|||
|
|
credentialAccount *Account
|
|||
|
|
accountConcurrency int
|
|||
|
|
useAPIKeyUpstream bool
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type codexModelsManifestCacheEntry struct {
|
|||
|
|
manifest *CodexModelsManifest
|
|||
|
|
order uint64
|
|||
|
|
expiresAt time.Time
|
|||
|
|
staleUntil time.Time
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type codexModelsManifestCacheState uint8
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
codexModelsManifestCacheMiss codexModelsManifestCacheState = iota
|
|||
|
|
codexModelsManifestCacheFresh
|
|||
|
|
codexModelsManifestCacheStale
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
type codexModelsManifestCache struct {
|
|||
|
|
mu sync.Mutex
|
|||
|
|
entries map[string]codexModelsManifestCacheEntry
|
|||
|
|
nextOrder uint64
|
|||
|
|
refresh singleflight.Group
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (c *codexModelsManifestCache) get(key string, now time.Time) (*CodexModelsManifest, codexModelsManifestCacheState) {
|
|||
|
|
c.mu.Lock()
|
|||
|
|
defer c.mu.Unlock()
|
|||
|
|
entry, ok := c.entries[key]
|
|||
|
|
if !ok {
|
|||
|
|
return nil, codexModelsManifestCacheMiss
|
|||
|
|
}
|
|||
|
|
if !now.Before(entry.staleUntil) {
|
|||
|
|
delete(c.entries, key)
|
|||
|
|
return nil, codexModelsManifestCacheMiss
|
|||
|
|
}
|
|||
|
|
if now.Before(entry.expiresAt) {
|
|||
|
|
return entry.manifest, codexModelsManifestCacheFresh
|
|||
|
|
}
|
|||
|
|
return entry.manifest, codexModelsManifestCacheStale
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest, now time.Time) {
|
|||
|
|
if manifest == nil || len(manifest.Body) > codexModelsManifestCacheBodyLimit {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
c.mu.Lock()
|
|||
|
|
defer c.mu.Unlock()
|
|||
|
|
if c.entries == nil {
|
|||
|
|
c.entries = make(map[string]codexModelsManifestCacheEntry)
|
|||
|
|
}
|
|||
|
|
if _, exists := c.entries[key]; !exists && len(c.entries) >= codexModelsManifestCacheMaxEntries {
|
|||
|
|
oldestKey := ""
|
|||
|
|
var oldestOrder uint64
|
|||
|
|
for candidateKey, entry := range c.entries {
|
|||
|
|
if !now.Before(entry.staleUntil) {
|
|||
|
|
delete(c.entries, candidateKey)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if oldestKey == "" || entry.order < oldestOrder {
|
|||
|
|
oldestKey = candidateKey
|
|||
|
|
oldestOrder = entry.order
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if len(c.entries) >= codexModelsManifestCacheMaxEntries && oldestKey != "" {
|
|||
|
|
delete(c.entries, oldestKey)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
c.nextOrder++
|
|||
|
|
c.entries[key] = codexModelsManifestCacheEntry{
|
|||
|
|
manifest: manifest,
|
|||
|
|
order: c.nextOrder,
|
|||
|
|
expiresAt: now.Add(codexModelsManifestCacheTTL),
|
|||
|
|
staleUntil: now.Add(codexModelsManifestCacheStaleTTL),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// FetchCodexModelsManifest fetches the live Codex models manifest from either
|
|||
|
|
// the ChatGPT backend for OAuth accounts or a custom upstream for API key accounts.
|
|||
|
|
//
|
|||
|
|
// 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) {
|
|||
|
|
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)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
clientVersion = strings.TrimSpace(clientVersion)
|
|||
|
|
if clientVersion == "" {
|
|||
|
|
clientVersion = CodexCanonicalClientVersion()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
requestEndpoint := chatgptCodexModelsURL
|
|||
|
|
authToken := ""
|
|||
|
|
useAPIKeyUpstream := false
|
|||
|
|
appendModelsPath := false
|
|||
|
|
switch {
|
|||
|
|
case credAccount.IsOpenAIOAuth():
|
|||
|
|
authToken = strings.TrimSpace(credAccount.GetOpenAIAccessToken())
|
|||
|
|
if authToken == "" && !credAccount.IsOpenAIAgentIdentity() {
|
|||
|
|
return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token")
|
|||
|
|
}
|
|||
|
|
case credAccount.IsOpenAIApiKey():
|
|||
|
|
baseURL := strings.TrimSpace(credAccount.GetCredential("base_url"))
|
|||
|
|
if baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL) {
|
|||
|
|
return nil, infraerrors.New(
|
|||
|
|
http.StatusBadGateway,
|
|||
|
|
"OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED",
|
|||
|
|
"Codex models manifest requires a custom API key upstream base URL",
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
authToken = strings.TrimSpace(credAccount.GetOpenAIApiKey())
|
|||
|
|
if authToken == "" {
|
|||
|
|
return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_MISSING", "account has no API key for the Codex models upstream")
|
|||
|
|
}
|
|||
|
|
normalizedBaseURL, validateErr := s.validateUpstreamBaseURL(baseURL)
|
|||
|
|
if validateErr != nil {
|
|||
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_INVALID", "invalid Codex models upstream base URL: %v", validateErr)
|
|||
|
|
}
|
|||
|
|
requestEndpoint = normalizedBaseURL
|
|||
|
|
useAPIKeyUpstream = true
|
|||
|
|
appendModelsPath = true
|
|||
|
|
default:
|
|||
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_ACCOUNT_TYPE_UNSUPPORTED", "account type %q cannot fetch the Codex models manifest", credAccount.Type)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
requestURL, err := buildCodexModelsManifestURL(requestEndpoint, appendModelsPath, clientVersion)
|
|||
|
|
if err != nil {
|
|||
|
|
if useAPIKeyUpstream {
|
|||
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_INVALID", "invalid Codex models upstream base URL: %v", err)
|
|||
|
|
}
|
|||
|
|
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "parse codex models request URL: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
headers := make(http.Header)
|
|||
|
|
if useAPIKeyUpstream {
|
|||
|
|
headers.Set("Authorization", "Bearer "+authToken)
|
|||
|
|
credAccount.ApplyHeaderOverrides(headers)
|
|||
|
|
} else {
|
|||
|
|
authHeaders, authErr := s.buildOpenAIAuthenticationHeaders(ctx, credAccount, authToken)
|
|||
|
|
if authErr != nil {
|
|||
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication: %v", authErr)
|
|||
|
|
}
|
|||
|
|
for key, values := range authHeaders {
|
|||
|
|
for _, value := range values {
|
|||
|
|
headers.Add(key, value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setOpenAIChatGPTAccountHeaders(headers, credAccount)
|
|||
|
|
}
|
|||
|
|
headers.Set("Accept", "application/json")
|
|||
|
|
overrideUA := ""
|
|||
|
|
if !useAPIKeyUpstream {
|
|||
|
|
overrideUA = credAccount.GetOpenAIUserAgent()
|
|||
|
|
}
|
|||
|
|
identity := resolveCodexOutboundIdentity(overrideUA)
|
|||
|
|
headers.Set("Originator", identity.originator)
|
|||
|
|
headers.Set("User-Agent", identity.userAgent)
|
|||
|
|
// Version 头优先与 client_version 查询参数同源:客户端自报版本合法且不低于上游
|
|||
|
|
// 门槛时原样使用;否则回退规范版本,避免陈旧 version 触发上游 404(issue #3901)。
|
|||
|
|
// client_version 查询参数本身始终按客户端原值透传(内容协商语义,契约见
|
|||
|
|
// TestFetchCodexModelsManifestPassthrough)。
|
|||
|
|
headerVersion := NormalizeCodexClientVersion(clientVersion)
|
|||
|
|
if headerVersion == "" || CompareVersions(headerVersion, codexUpstreamMinVersion) < 0 {
|
|||
|
|
headerVersion = identity.version
|
|||
|
|
}
|
|||
|
|
headers.Set("Version", headerVersion)
|
|||
|
|
|
|||
|
|
proxyURL := ""
|
|||
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|||
|
|
proxyURL = account.Proxy.URL()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
request := codexModelsManifestRequest{
|
|||
|
|
url: requestURL.String(),
|
|||
|
|
headers: headers,
|
|||
|
|
proxyURL: proxyURL,
|
|||
|
|
accountID: account.ID,
|
|||
|
|
credentialAccountID: credAccount.ID,
|
|||
|
|
credentialAccount: credAccount,
|
|||
|
|
accountConcurrency: account.Concurrency,
|
|||
|
|
useAPIKeyUpstream: useAPIKeyUpstream,
|
|||
|
|
}
|
|||
|
|
if useAPIKeyUpstream {
|
|||
|
|
return s.fetchCachedAPIKeyCodexModelsManifest(ctx, request, ifNoneMatch)
|
|||
|
|
}
|
|||
|
|
manifest, fetchErr := s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch)
|
|||
|
|
if !credAccount.IsOpenAIAgentIdentity() || !isAgentIdentityTaskInvalidCodexModelsError(fetchErr) {
|
|||
|
|
s.handleCodexModelsManifestAccountAuthError(ctx, account, credAccount, fetchErr)
|
|||
|
|
return manifest, fetchErr
|
|||
|
|
}
|
|||
|
|
expectedTaskID := strings.TrimSpace(credAccount.GetCredential("task_id"))
|
|||
|
|
if recoverErr := s.recoverAgentIdentityTask(ctx, credAccount, expectedTaskID); recoverErr != nil {
|
|||
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "agent identity task recovery failed: %v", recoverErr)
|
|||
|
|
}
|
|||
|
|
authHeaders, authErr := s.buildOpenAIAuthenticationHeaders(ctx, credAccount, "")
|
|||
|
|
if authErr != nil {
|
|||
|
|
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication after task recovery: %v", authErr)
|
|||
|
|
}
|
|||
|
|
request.headers.Del("Authorization")
|
|||
|
|
request.headers.Del("ChatGPT-Account-ID")
|
|||
|
|
for key, values := range authHeaders {
|
|||
|
|
for _, value := range values {
|
|||
|
|
request.headers.Add(key, value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setOpenAIChatGPTAccountHeaders(request.headers, credAccount)
|
|||
|
|
return s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func isAgentIdentityTaskInvalidCodexModelsError(err error) bool {
|
|||
|
|
var upstreamErr *codexModelsManifestUpstreamError
|
|||
|
|
return errors.As(err, &upstreamErr) &&
|
|||
|
|
isAgentIdentityTaskInvalidHTTPResponse(upstreamErr.statusCode, upstreamErr.body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// handleCodexModelsManifestAccountAuthError feeds manifest 401s from the
|
|||
|
|
// ChatGPT Codex backend into the shared upstream-error state machinery
|
|||
|
|
// (token cache invalidation, temp-unschedulable cooldown, or permanent
|
|||
|
|
// disable for token_revoked/token_invalidated). Without this, an account
|
|||
|
|
// whose OAuth token was revoked upstream stays active and schedulable and
|
|||
|
|
// keeps being selected for every subsequent /models request (#4544).
|
|||
|
|
//
|
|||
|
|
// Scope is deliberately limited to plain OAuth accounts: the manifest
|
|||
|
|
// endpoint authenticates with the same token as /responses forwarding, so a
|
|||
|
|
// 401 is authoritative for the account. Agent Identity accounts are excluded
|
|||
|
|
// because their 401s can be task-scoped and have a dedicated recovery flow,
|
|||
|
|
// and API key manifests come from custom upstreams whose /models auth may
|
|||
|
|
// diverge from their chat endpoints.
|
|||
|
|
func (s *OpenAIGatewayService) handleCodexModelsManifestAccountAuthError(ctx context.Context, account, credAccount *Account, err error) {
|
|||
|
|
if s == nil || account == nil || err == nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if credAccount == nil || !credAccount.IsOpenAIOAuth() || credAccount.IsOpenAIAgentIdentity() {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
var upstreamErr *codexModelsManifestUpstreamError
|
|||
|
|
if !errors.As(err, &upstreamErr) || upstreamErr.statusCode != http.StatusUnauthorized {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
headers := upstreamErr.headers
|
|||
|
|
if headers == nil {
|
|||
|
|
headers = http.Header{}
|
|||
|
|
}
|
|||
|
|
s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.statusCode, headers, upstreamErr.body)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) fetchCachedAPIKeyCodexModelsManifest(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, 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
|
|||
|
|
}
|
|||
|
|
resultCh := s.refreshCachedAPIKeyCodexModelsManifest(cacheKey, request)
|
|||
|
|
if state == codexModelsManifestCacheStale {
|
|||
|
|
return codexModelsManifestForClient(manifest, ifNoneMatch), nil
|
|||
|
|
}
|
|||
|
|
select {
|
|||
|
|
case <-ctx.Done():
|
|||
|
|
return nil, ctx.Err()
|
|||
|
|
case result := <-resultCh:
|
|||
|
|
if result.Err != nil {
|
|||
|
|
return nil, result.Err
|
|||
|
|
}
|
|||
|
|
manifest, ok := result.Val.(*CodexModelsManifest)
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) refreshCachedAPIKeyCodexModelsManifest(cacheKey string, request codexModelsManifestRequest) <-chan singleflight.Result {
|
|||
|
|
return s.codexModelsManifestCache.refresh.DoChan(cacheKey, func() (any, error) {
|
|||
|
|
cached, _ := s.codexModelsManifestCache.get(cacheKey, time.Now())
|
|||
|
|
ifNoneMatch := ""
|
|||
|
|
if cached != nil {
|
|||
|
|
ifNoneMatch = cached.upstreamETag
|
|||
|
|
}
|
|||
|
|
manifest, err := s.fetchCodexModelsManifestUpstream(context.Background(), request, ifNoneMatch)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if manifest.NotModified && cached != nil {
|
|||
|
|
s.codexModelsManifestCache.set(cacheKey, cached, time.Now())
|
|||
|
|
return cached, nil
|
|||
|
|
}
|
|||
|
|
if !manifest.NotModified {
|
|||
|
|
s.codexModelsManifestCache.set(cacheKey, manifest, time.Now())
|
|||
|
|
}
|
|||
|
|
return manifest, nil
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, error) {
|
|||
|
|
reqCtx, cancel := context.WithTimeout(ctx, codexModelsManifestRequestTimeout)
|
|||
|
|
defer cancel()
|
|||
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, request.url, nil)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "create codex models request: %v", err)
|
|||
|
|
}
|
|||
|
|
req.Header = request.headers.Clone()
|
|||
|
|
if ifNoneMatch = strings.TrimSpace(ifNoneMatch); ifNoneMatch != "" {
|
|||
|
|
req.Header.Set("If-None-Match", ifNoneMatch)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var resp *http.Response
|
|||
|
|
if request.useAPIKeyUpstream {
|
|||
|
|
if s.httpUpstream == nil {
|
|||
|
|
return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_UPSTREAM_NOT_CONFIGURED", "Codex models upstream HTTP client is not configured")
|
|||
|
|
}
|
|||
|
|
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
|
|||
|
|
resp, err = s.httpUpstream.Do(req, request.proxyURL, request.accountID, request.accountConcurrency)
|
|||
|
|
} else {
|
|||
|
|
client, clientErr := httpclient.GetClient(httpclient.Options{
|
|||
|
|
ProxyURL: request.proxyURL,
|
|||
|
|
Timeout: codexModelsManifestRequestTimeout,
|
|||
|
|
ResponseHeaderTimeout: 10 * time.Second,
|
|||
|
|
})
|
|||
|
|
if clientErr != nil {
|
|||
|
|
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr)
|
|||
|
|
}
|
|||
|
|
resp, err = client.Do(req)
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, &codexModelsManifestUpstreamError{
|
|||
|
|
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest request failed: %v", err),
|
|||
|
|
retryable: isRetryableCodexModelsManifestTransportError(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))
|
|||
|
|
body = s.redactAgentIdentitySensitiveBody(reqCtx, request.credentialAccount, body)
|
|||
|
|
message := strings.TrimSpace(string(body))
|
|||
|
|
if message == "" {
|
|||
|
|
message = resp.Status
|
|||
|
|
}
|
|||
|
|
return nil, &codexModelsManifestUpstreamError{
|
|||
|
|
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest upstream error %d: %s", resp.StatusCode, message),
|
|||
|
|
statusCode: resp.StatusCode,
|
|||
|
|
headers: resp.Header.Clone(),
|
|||
|
|
body: body,
|
|||
|
|
retryable: (resp.StatusCode == http.StatusUnauthorized && !request.useAPIKeyUpstream) ||
|
|||
|
|
resp.StatusCode == http.StatusTooManyRequests ||
|
|||
|
|
(resp.StatusCode >= http.StatusInternalServerError && resp.StatusCode < 600),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, &codexModelsManifestUpstreamError{
|
|||
|
|
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err),
|
|||
|
|
retryable: isRetryableCodexModelsManifestTransportError(err),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
upstreamBody := body
|
|||
|
|
if request.useAPIKeyUpstream {
|
|||
|
|
body = convertOpenAIModelListToCodexManifest(body)
|
|||
|
|
}
|
|||
|
|
if err := validateCodexModelsManifestEnvelope(body); err != nil {
|
|||
|
|
return nil, &codexModelsManifestUpstreamError{
|
|||
|
|
err: infraerrors.Newf(
|
|||
|
|
http.StatusBadGateway,
|
|||
|
|
"OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST",
|
|||
|
|
"codex models manifest upstream returned an invalid envelope: %v",
|
|||
|
|
err,
|
|||
|
|
),
|
|||
|
|
retryable: true,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if request.useAPIKeyUpstream {
|
|||
|
|
body, err = adjustAPIKeyCodexModelsManifest(body)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, &codexModelsManifestUpstreamError{
|
|||
|
|
err: infraerrors.Newf(
|
|||
|
|
http.StatusBadGateway,
|
|||
|
|
"OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST",
|
|||
|
|
"codex models manifest upstream could not be adjusted: %v",
|
|||
|
|
err,
|
|||
|
|
),
|
|||
|
|
retryable: true,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
etag := resp.Header.Get("ETag")
|
|||
|
|
manifest := &CodexModelsManifest{Body: body, ETag: etag}
|
|||
|
|
if request.useAPIKeyUpstream {
|
|||
|
|
manifest.upstreamETag = etag
|
|||
|
|
if !bytes.Equal(body, upstreamBody) {
|
|||
|
|
manifest.ETag = codexModelsManifestBodyETag(body)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return manifest, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func codexModelsManifestBodyETag(body []byte) string {
|
|||
|
|
sum := sha256.Sum256(body)
|
|||
|
|
return fmt.Sprintf(`"%x"`, sum)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{
|
|||
|
|
"gpt-5.6-sol": {},
|
|||
|
|
"gpt-5.6-terra": {},
|
|||
|
|
"gpt-5.6-luna": {},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// adjustAPIKeyCodexModelsManifest prevents Codex from selecting Responses
|
|||
|
|
// Lite for custom API key providers. Those clients do not install web.run in
|
|||
|
|
// Lite mode, so the affected model manifests must advertise the full Responses
|
|||
|
|
// path. Return the original body when no targeted true value is present.
|
|||
|
|
func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) {
|
|||
|
|
var envelope map[string]json.RawMessage
|
|||
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|||
|
|
return nil, fmt.Errorf("decode JSON object: %w", err)
|
|||
|
|
}
|
|||
|
|
var models []json.RawMessage
|
|||
|
|
if err := json.Unmarshal(envelope["models"], &models); err != nil {
|
|||
|
|
return nil, fmt.Errorf("decode top-level models array: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
changed := false
|
|||
|
|
for i, rawModel := range models {
|
|||
|
|
var model map[string]json.RawMessage
|
|||
|
|
if err := json.Unmarshal(rawModel, &model); err != nil || model == nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
var slug string
|
|||
|
|
if err := json.Unmarshal(model["slug"], &slug); err != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if _, targeted := apiKeyCodexModelsWithoutResponsesLite[slug]; !targeted {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
var useResponsesLite bool
|
|||
|
|
if err := json.Unmarshal(model["use_responses_lite"], &useResponsesLite); err != nil || !useResponsesLite {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
model["use_responses_lite"] = json.RawMessage("false")
|
|||
|
|
adjusted, err := json.Marshal(model)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("encode model %q: %w", slug, err)
|
|||
|
|
}
|
|||
|
|
models[i] = adjusted
|
|||
|
|
changed = true
|
|||
|
|
}
|
|||
|
|
if !changed {
|
|||
|
|
return body, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
adjustedModels, err := json.Marshal(models)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("encode top-level models array: %w", err)
|
|||
|
|
}
|
|||
|
|
envelope["models"] = adjustedModels
|
|||
|
|
adjusted, err := json.Marshal(envelope)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("encode JSON object: %w", err)
|
|||
|
|
}
|
|||
|
|
return adjusted, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// convertOpenAIModelListToCodexManifest rewrites a standard OpenAI
|
|||
|
|
// GET /v1/models response ({"object":"list","data":[{"id":...},...]}) into the
|
|||
|
|
// Codex manifest envelope ({"models":[{"slug":...},...]}) so custom API key
|
|||
|
|
// upstreams that only implement the standard endpoint can serve Codex model
|
|||
|
|
// discovery. Bodies that already carry a top-level models field, are not the
|
|||
|
|
// standard list shape, or yield no usable model IDs are returned unchanged so
|
|||
|
|
// envelope validation reports the original payload.
|
|||
|
|
func convertOpenAIModelListToCodexManifest(body []byte) []byte {
|
|||
|
|
var envelope map[string]json.RawMessage
|
|||
|
|
if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil {
|
|||
|
|
return body
|
|||
|
|
}
|
|||
|
|
if _, ok := envelope["models"]; ok {
|
|||
|
|
return body
|
|||
|
|
}
|
|||
|
|
data, ok := envelope["data"]
|
|||
|
|
if !ok {
|
|||
|
|
return body
|
|||
|
|
}
|
|||
|
|
var entries []struct {
|
|||
|
|
ID string `json:"id"`
|
|||
|
|
}
|
|||
|
|
if err := json.Unmarshal(data, &entries); err != nil {
|
|||
|
|
return body
|
|||
|
|
}
|
|||
|
|
type codexModelEntry struct {
|
|||
|
|
Slug string `json:"slug"`
|
|||
|
|
}
|
|||
|
|
models := make([]codexModelEntry, 0, len(entries))
|
|||
|
|
for _, entry := range entries {
|
|||
|
|
id := strings.TrimSpace(entry.ID)
|
|||
|
|
if id == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
models = append(models, codexModelEntry{Slug: id})
|
|||
|
|
}
|
|||
|
|
if len(models) == 0 {
|
|||
|
|
return body
|
|||
|
|
}
|
|||
|
|
converted, err := json.Marshal(map[string][]codexModelEntry{"models": models})
|
|||
|
|
if err != nil {
|
|||
|
|
return body
|
|||
|
|
}
|
|||
|
|
return converted
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func validateCodexModelsManifestEnvelope(body []byte) error {
|
|||
|
|
var envelope map[string]json.RawMessage
|
|||
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|||
|
|
return fmt.Errorf("decode JSON object: %w", err)
|
|||
|
|
}
|
|||
|
|
if envelope == nil {
|
|||
|
|
return errors.New("expected a JSON object")
|
|||
|
|
}
|
|||
|
|
models, ok := envelope["models"]
|
|||
|
|
if !ok {
|
|||
|
|
return errors.New("missing top-level models array")
|
|||
|
|
}
|
|||
|
|
models = bytes.TrimSpace(models)
|
|||
|
|
var entries []json.RawMessage
|
|||
|
|
if len(models) == 0 || models[0] != '[' {
|
|||
|
|
return errors.New("top-level models field is not an array")
|
|||
|
|
}
|
|||
|
|
if err := json.Unmarshal(models, &entries); err != nil {
|
|||
|
|
return fmt.Errorf("decode top-level models array: %w", err)
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string {
|
|||
|
|
hasher := sha256.New()
|
|||
|
|
_, _ = fmt.Fprintf(hasher, "%d\n%d\n%s\n%s\n", request.accountID, request.credentialAccountID, request.proxyURL, request.url)
|
|||
|
|
headerNames := make([]string, 0, len(request.headers))
|
|||
|
|
for name := range request.headers {
|
|||
|
|
headerNames = append(headerNames, name)
|
|||
|
|
}
|
|||
|
|
sort.Strings(headerNames)
|
|||
|
|
for _, name := range headerNames {
|
|||
|
|
_, _ = fmt.Fprintf(hasher, "%s\n", strings.ToLower(name))
|
|||
|
|
for _, value := range request.headers[name] {
|
|||
|
|
_, _ = fmt.Fprintf(hasher, "%s\n", value)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return fmt.Sprintf("%x", hasher.Sum(nil))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch string) *CodexModelsManifest {
|
|||
|
|
if manifest == nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) {
|
|||
|
|
return &CodexModelsManifest{ETag: manifest.ETag, NotModified: true}
|
|||
|
|
}
|
|||
|
|
return manifest
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool {
|
|||
|
|
etag = strings.TrimSpace(etag)
|
|||
|
|
if etag == "" {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
normalize := func(value string) string {
|
|||
|
|
value = strings.TrimSpace(value)
|
|||
|
|
if len(value) >= 2 && strings.EqualFold(value[:2], "W/") {
|
|||
|
|
value = strings.TrimSpace(value[2:])
|
|||
|
|
}
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
want := normalize(etag)
|
|||
|
|
for _, candidate := range strings.Split(ifNoneMatch, ",") {
|
|||
|
|
candidate = strings.TrimSpace(candidate)
|
|||
|
|
if candidate == "*" || normalize(candidate) == want {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func isOfficialOpenAIModelsBaseURL(raw string) bool {
|
|||
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|||
|
|
if err != nil {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
hostname := strings.TrimSuffix(parsed.Hostname(), ".")
|
|||
|
|
return strings.EqualFold(hostname, "api.openai.com")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func buildCodexModelsManifestURL(endpoint string, appendModelsPath bool, clientVersion string) (*url.URL, error) {
|
|||
|
|
requestURL, err := url.Parse(endpoint)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if requestURL.Fragment != "" {
|
|||
|
|
return nil, fmt.Errorf("URL fragments are not supported")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
query := requestURL.Query()
|
|||
|
|
requestURL.RawQuery = ""
|
|||
|
|
requestURL.ForceQuery = false
|
|||
|
|
if appendModelsPath {
|
|||
|
|
requestURL, err = url.Parse(buildOpenAIModelsURL(requestURL.String()))
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
query.Set("client_version", clientVersion)
|
|||
|
|
requestURL.RawQuery = query.Encode()
|
|||
|
|
return requestURL, nil
|
|||
|
|
}
|