diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index ba96ac3923..690cba9ce6 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -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 diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index dd7de94aae..8349a0075c 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -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 diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 1084b020ee..6333f16a71 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -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 {