fix(openai): preserve web search for API-key Codex clients

This commit is contained in:
wucm667
2026-07-27 18:42:34 +08:00
parent 59ce11c780
commit 3c62b5ca85
3 changed files with 180 additions and 22 deletions
@@ -15,9 +15,9 @@ 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. The manifest is proxied verbatim from the selected account's ChatGPT
// backend or custom API key upstream. API key manifests use a short-lived,
// asynchronously revalidated cache to tolerate canceled client requests.
// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive
// provider-compatibility normalization and use a short-lived, asynchronously
// revalidated cache to tolerate canceled client requests.
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
if c.Request.Context().Err() != nil {
return
@@ -35,12 +35,12 @@ const (
codexModelsManifestRequestTimeout = 15 * time.Second
)
// CodexModelsManifest carries the raw upstream manifest payload plus caching
// metadata so handlers can pass both through to the client untouched.
// CodexModelsManifest carries the client representation plus caching metadata.
type CodexModelsManifest struct {
Body []byte
ETag string
NotModified bool
Body []byte
ETag string
upstreamETag string
NotModified bool
}
type codexModelsManifestUpstreamError struct {
@@ -228,10 +228,9 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest
// 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, the response body is passed
// through verbatim. Model entries evolve with Codex client releases, so the
// gateway deliberately avoids interpreting their fields and reflects the
// account's real entitlements without chasing upstream schema changes.
// 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")
@@ -421,7 +420,7 @@ func (s *OpenAIGatewayService) refreshCachedAPIKeyCodexModelsManifest(cacheKey s
cached, _ := s.codexModelsManifestCache.get(cacheKey, time.Now())
ifNoneMatch := ""
if cached != nil {
ifNoneMatch = cached.ETag
ifNoneMatch = cached.upstreamETag
}
manifest, err := s.fetchCodexModelsManifestUpstream(context.Background(), request, ifNoneMatch)
if err != nil {
@@ -504,6 +503,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
retryable: isRetryableCodexModelsManifestTransportError(err),
}
}
upstreamBody := body
if request.useAPIKeyUpstream {
body = convertOpenAIModelListToCodexManifest(body)
}
@@ -518,7 +518,95 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
retryable: true,
}
}
return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, nil
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
@@ -495,9 +495,79 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin
if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"},{"slug":"gpt-5.6-codex"}]}`; got != want {
t.Errorf("converted body: got %q, want %q", got, want)
}
if manifest.ETag != `W/"openai-list"` {
t.Errorf("etag not passed through: got %q", manifest.ETag)
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
require.Equal(t, `W/"openai-list"`, manifest.upstreamETag)
}
func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{
name: "affected models disable responses lite and preserve unknown fields",
body: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true,"unknown_model":{"enabled":true}},{"slug":"gpt-5.6-terra","use_responses_lite":true},{"slug":"gpt-5.6-luna","use_responses_lite":true}],"unknown_top":{"version":1}}`,
want: `{"models":[{"slug":"gpt-5.6-sol","unknown_model":{"enabled":true},"use_responses_lite":false},{"slug":"gpt-5.6-terra","use_responses_lite":false},{"slug":"gpt-5.6-luna","use_responses_lite":false}],"unknown_top":{"version":1}}`,
},
{
name: "unaffected model unchanged",
body: ` {"models":[{"slug":"gpt-5.6-codex","use_responses_lite":true}]} `,
want: ` {"models":[{"slug":"gpt-5.6-codex","use_responses_lite":true}]} `,
},
{
name: "false missing and alternate entries unchanged",
body: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-terra"},null,"gpt-5.6-luna",{"slug":17,"use_responses_lite":true}]}`,
want: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-terra"},null,"gpt-5.6-luna",{"slug":17,"use_responses_lite":true}]}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := adjustAPIKeyCodexModelsManifest([]byte(tt.body))
require.NoError(t, err)
require.Equal(t, tt.want, string(got))
})
}
}
func TestFetchCodexModelsManifestAPIKeyDisablesResponsesLiteForAffectedModels(t *testing.T) {
const upstreamBody = `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true},{"slug":"gpt-5.6-codex","use_responses_lite":true}],"metadata":{"version":1}}`
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Etag": []string{`"upstream-strong"`}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}, nil
}}
s := newCodexModelsAPIKeyTestService(upstream)
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsAPIKeyTestAccount("https://upstream.example"), "0.145.0", "")
require.NoError(t, err)
require.JSONEq(t, `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-codex","use_responses_lite":true}],"metadata":{"version":1}}`, string(manifest.Body))
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
require.Equal(t, `"upstream-strong"`, manifest.upstreamETag)
notModified, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsAPIKeyTestAccount("https://upstream.example"), "0.145.0", manifest.ETag)
require.NoError(t, err)
require.True(t, notModified.NotModified)
require.Equal(t, manifest.ETag, notModified.ETag)
}
func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) {
const manifestBody = ` {"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]} `
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(manifestBody))
}))
defer server.Close()
original := chatgptCodexModelsURL
chatgptCodexModelsURL = server.URL
defer func() { chatgptCodexModelsURL = original }()
s := &OpenAIGatewayService{}
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.145.0", "")
require.NoError(t, err)
require.Equal(t, manifestBody, string(manifest.Body))
}
func TestConvertOpenAIModelListToCodexManifest(t *testing.T) {
@@ -995,19 +1065,19 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) {
call := calls.Add(1)
if call == 1 {
header := make(http.Header)
header.Set("ETag", `W/"cached"`)
header.Set("ETag", `"upstream-cached"`)
return &http.Response{
StatusCode: http.StatusOK,
Header: header,
Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"cached"}]}`)),
Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]}`)),
}, nil
}
if got := req.Header.Get("If-None-Match"); got != `W/"cached"` {
if got := req.Header.Get("If-None-Match"); got != `"upstream-cached"` {
t.Errorf("background revalidation If-None-Match: got %q", got)
}
close(refreshDone)
header := make(http.Header)
header.Set("ETag", `W/"cached"`)
header.Set("ETag", `"upstream-cached"`)
return &http.Response{StatusCode: http.StatusNotModified, Header: header, Body: http.NoBody}, nil
}}
s := newCodexModelsAPIKeyTestService(upstream)
@@ -1026,7 +1096,7 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) {
if err != nil {
t.Fatalf("stale fetch returned error: %v", err)
}
if got := string(manifest.Body); got != `{"models":[{"slug":"cached"}]}` {
if got := string(manifest.Body); got != `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false}]}` {
t.Fatalf("stale body: got %q", got)
}
select {
@@ -1052,7 +1122,7 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) {
time.Sleep(10 * time.Millisecond)
}
manifest, err = s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
if err != nil || string(manifest.Body) != `{"models":[{"slug":"cached"}]}` {
if err != nil || string(manifest.Body) != `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false}]}` {
t.Fatalf("renewed cached manifest: body=%q err=%v", manifest.Body, err)
}
if got := calls.Load(); got != 2 {