Files
sub2api/backend/internal/service/openai_codex_models_service.go
T

547 lines
19 KiB
Go

package service
import (
"context"
"crypto/sha256"
"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 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
}
type codexModelsManifestUpstreamError struct {
err error
retryable bool
statusCode int
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, are intentionally not retried.
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.
//
// 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)
}
clientVersion = strings.TrimSpace(clientVersion)
if clientVersion == "" {
clientVersion = openAICodexProbeVersion
}
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")
headers.Set("Originator", "codex_cli_rs")
headers.Set("Version", clientVersion)
headers.Set("User-Agent", codexCLIUserAgent)
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) {
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)
}
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.ETag
}
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,
body: body,
retryable: 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),
}
}
return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, 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
}