From a1e19976326aad15965bc2874f714ce4de16a935 Mon Sep 17 00:00:00 2001 From: Astrenix <121576066+Astrenix@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:09:57 +0800 Subject: [PATCH] fix(openai): accept standard OpenAI model list for Codex model discovery Custom API key upstreams that only implement the standard OpenAI GET /v1/models endpoint return {"object":"list","data":[{"id":...}]}, which the Codex models manifest validation rejected with "missing top-level models array", surfacing HTTP 502 to Codex clients. Convert the standard list envelope into the Codex manifest shape ({"models":[{"slug":...}]}) for API key upstreams before validation. Bodies that already carry a top-level models field, are not the standard list shape, or yield no usable model IDs are left unchanged so existing validation and account failover behavior is preserved. Fixes #4490 Co-Authored-By: Claude Fable 5 --- .../service/openai_codex_models_service.go | 49 +++++++++++ .../openai_codex_models_service_test.go | 82 +++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 32a9bd57d3..1bcf26f047 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -463,6 +463,9 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont retryable: isRetryableCodexModelsManifestTransportError(err), } } + if request.useAPIKeyUpstream { + body = convertOpenAIModelListToCodexManifest(body) + } if err := validateCodexModelsManifestEnvelope(body); err != nil { return nil, &codexModelsManifestUpstreamError{ err: infraerrors.Newf( @@ -477,6 +480,52 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, 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 { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 3075993680..6595a17f6b 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -470,6 +470,88 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) { } } +func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) { + upstreamBody := `{"object":"list","data":[{"id":"gpt-5.6","object":"model"},{"id":" ","object":"model"},{"id":"gpt-5.6-codex","object":"model"}]}` + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + header := make(http.Header) + header.Set("ETag", `W/"openai-list"`) + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }, nil + }} + + s := newCodexModelsAPIKeyTestService(upstream) + manifest, err := s.FetchCodexModelsManifest( + context.Background(), + newCodexModelsAPIKeyTestAccount("https://upstream.example/v1"), + "0.144.0", + "", + ) + if err != nil { + t.Fatalf("FetchCodexModelsManifest returned error: %v", err) + } + 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) + } +} + +func TestConvertOpenAIModelListToCodexManifest(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + name: "standard list", + body: `{"object":"list","data":[{"id":"m-1"},{"id":"m-2"}]}`, + want: `{"models":[{"slug":"m-1"},{"slug":"m-2"}]}`, + }, + { + name: "codex manifest unchanged", + body: `{"models":[{"slug":"m-1"}]}`, + want: `{"models":[{"slug":"m-1"}]}`, + }, + { + name: "empty data unchanged", + body: `{"object":"list","data":[]}`, + want: `{"object":"list","data":[]}`, + }, + { + name: "data not an array unchanged", + body: `{"object":"list","data":{"id":"m-1"}}`, + want: `{"object":"list","data":{"id":"m-1"}}`, + }, + { + name: "entries without usable IDs unchanged", + body: `{"object":"list","data":[{"id":""},{"object":"model"}]}`, + want: `{"object":"list","data":[{"id":""},{"object":"model"}]}`, + }, + { + name: "invalid JSON unchanged", + body: `{"data":`, + want: `{"data":`, + }, + { + name: "non-object unchanged", + body: `[]`, + want: `[]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := string(convertOpenAIModelListToCodexManifest([]byte(tt.body))); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) { tests := []struct { name string