mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
fix(openai): preserve web search for API-key Codex clients
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user