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 <noreply@anthropic.com>
This commit is contained in:
Astrenix
2026-07-19 02:09:57 +08:00
co-authored by Claude Fable 5
parent d4b9797ff7
commit a1e1997632
2 changed files with 131 additions and 0 deletions
@@ -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 {
@@ -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