diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go new file mode 100644 index 0000000000..e64c555d14 --- /dev/null +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -0,0 +1,53 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// CodexModels serves the Codex models manifest for Codex clients. +// +// 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 ChatGPT backend with a +// schedulable OAuth account's credentials, so clients pointed at the gateway +// see the account's real, always-current model entitlements instead of a +// frozen local cache. +func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey.Group == nil { + h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required") + return + } + if apiKey.Group.Platform != service.PlatformOpenAI { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI groups") + return + } + + account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, "", "") + if err != nil { + h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI accounts") + return + } + + manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match")) + if err != nil { + h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err)) + return + } + + if manifest.ETag != "" { + c.Header("ETag", manifest.ETag) + } + if manifest.NotModified { + c.Status(http.StatusNotModified) + return + } + c.Data(http.StatusOK, "application/json", manifest.Body) +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index d22e339c75..ba5b4f61d1 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -121,7 +121,16 @@ func RegisterGatewayRoutes( } h.Gateway.CountTokens(c) }) - gateway.GET("/models", h.Gateway.Models) + // Codex CLI / Codex app refresh their model picker from the provider's + // /models endpoint with a client_version query and expect the ChatGPT + // Codex manifest format; other clients keep the OpenAI-style list. + gateway.GET("/models", func(c *gin.Context) { + if isOpenAIGatewayPlatform(c) && c.Query("client_version") != "" { + h.OpenAIGateway.CodexModels(c) + return + } + h.Gateway.Models(c) + }) gateway.GET("/usage", h.Gateway.Usage) // OpenAI Responses API: auto-route based on group platform gateway.POST("/responses", func(c *gin.Context) { @@ -214,6 +223,7 @@ func RegisterGatewayRoutes( codexDirect.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) }) + codexDirect.GET("/models", h.OpenAIGateway.CodexModels) } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { diff --git a/backend/internal/server/routes/gateway_codex_models_test.go b/backend/internal/server/routes/gateway_codex_models_test.go new file mode 100644 index 0000000000..74af755919 --- /dev/null +++ b/backend/internal/server/routes/gateway_codex_models_test.go @@ -0,0 +1,22 @@ +package routes + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) { + router := newGatewayRoutesTestRouter() + + registered := make(map[string]bool) + for _, route := range router.Routes() { + if route.Method == http.MethodGet { + registered[route.Path] = true + } + } + + require.True(t, registered["/backend-api/codex/models"], "GET /backend-api/codex/models should be registered") + require.True(t, registered["/v1/models"], "GET /v1/models should be registered") +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go new file mode 100644 index 0000000000..8a919fa2b0 --- /dev/null +++ b/backend/internal/service/openai_codex_models_service.go @@ -0,0 +1,107 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" +) + +// chatgptCodexModelsURL is the ChatGPT Codex models manifest endpoint. +// Package-level variable so tests can point it at a stub server. +var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" + +const codexModelsManifestBodyLimit int64 = 8 << 20 + +// CodexModelsManifest carries the raw upstream manifest payload plus caching +// metadata so handlers can pass both through to the client untouched. +type CodexModelsManifest struct { + Body []byte + ETag string + NotModified bool +} + +// FetchCodexModelsManifest fetches the live Codex models manifest from the +// ChatGPT backend using the account's OAuth credentials. +// +// The response body is passed through verbatim: the manifest schema evolves +// with Codex client releases, and interpreting it here would force the gateway +// to chase upstream changes. Passing it through keeps the gateway +// schema-agnostic and always reflects the account's real entitlements. +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") + } + credAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_CREDENTIALS_FAILED", "resolve credential account: %v", err) + } + accessToken := credAccount.GetOpenAIAccessToken() + if accessToken == "" { + return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token") + } + + clientVersion = strings.TrimSpace(clientVersion) + if clientVersion == "" { + clientVersion = openAICodexProbeVersion + } + requestURL := chatgptCodexModelsURL + "?client_version=" + url.QueryEscape(clientVersion) + + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "create codex models request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + req.Header.Set("Originator", "codex_cli_rs") + req.Header.Set("Version", clientVersion) + req.Header.Set("User-Agent", codexCLIUserAgent) + if ifNoneMatch = strings.TrimSpace(ifNoneMatch); ifNoneMatch != "" { + req.Header.Set("If-None-Match", ifNoneMatch) + } + setOpenAIChatGPTAccountHeaders(req.Header, credAccount) + + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + client, err := httpclient.GetClient(httpclient.Options{ + ProxyURL: proxyURL, + Timeout: 15 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + }) + if err != nil { + return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotModified { + return &CodexModelsManifest{ETag: resp.Header.Get("ETag"), NotModified: true}, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + message := strings.TrimSpace(string(body)) + if message == "" { + message = resp.Status + } + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest upstream error %d: %s", resp.StatusCode, message) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, codexModelsManifestBodyLimit)) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err) + } + return &CodexModelsManifest{Body: body, ETag: resp.Header.Get("ETag")}, nil +} diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go new file mode 100644 index 0000000000..c9eae35629 --- /dev/null +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func newCodexModelsTestAccount() *Account { + return &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "test-access-token", + "chatgpt_account_id": "acc-123", + }, + } +} + +func TestFetchCodexModelsManifestPassthrough(t *testing.T) { + manifestBody := `{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5"}]}` + + var gotAuth, gotAccountID, gotOriginator, gotClientVersion string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotAccountID = r.Header.Get("chatgpt-account-id") + gotOriginator = r.Header.Get("Originator") + gotClientVersion = r.URL.Query().Get("client_version") + w.Header().Set("ETag", `W/"abc123"`) + w.Header().Set("Content-Type", "application/json") + _, _ = 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.137.0", "") + if err != nil { + t.Fatalf("FetchCodexModelsManifest returned error: %v", err) + } + + if string(manifest.Body) != manifestBody { + t.Errorf("body not passed through verbatim: got %q", manifest.Body) + } + if manifest.ETag != `W/"abc123"` { + t.Errorf("etag not passed through: got %q", manifest.ETag) + } + if gotAuth != "Bearer test-access-token" { + t.Errorf("authorization header: got %q", gotAuth) + } + if gotAccountID != "acc-123" { + t.Errorf("chatgpt-account-id header: got %q", gotAccountID) + } + if gotOriginator != "codex_cli_rs" { + t.Errorf("originator header: got %q", gotOriginator) + } + if gotClientVersion != "0.137.0" { + t.Errorf("client_version query: got %q", gotClientVersion) + } +} + +func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) { + var gotClientVersion string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClientVersion = r.URL.Query().Get("client_version") + _, _ = w.Write([]byte(`{"models":[]}`)) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{} + if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil { + t.Fatalf("FetchCodexModelsManifest returned error: %v", err) + } + if gotClientVersion != openAICodexProbeVersion { + t.Errorf("default client_version: got %q, want %q", gotClientVersion, openAICodexProbeVersion) + } +} + +func TestFetchCodexModelsManifestNotModified(t *testing.T) { + var gotIfNoneMatch string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotIfNoneMatch = r.Header.Get("If-None-Match") + w.Header().Set("ETag", `W/"abc123"`) + w.WriteHeader(http.StatusNotModified) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{} + manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", `W/"abc123"`) + if err != nil { + t.Fatalf("FetchCodexModelsManifest returned error: %v", err) + } + if !manifest.NotModified { + t.Error("expected NotModified to be true") + } + if gotIfNoneMatch != `W/"abc123"` { + t.Errorf("if-none-match header: got %q", gotIfNoneMatch) + } +} + +func TestFetchCodexModelsManifestUpstreamError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"detail":"boom"}`, http.StatusInternalServerError) + })) + defer server.Close() + + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + defer func() { chatgptCodexModelsURL = original }() + + s := &OpenAIGatewayService{} + if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", ""); err == nil { + t.Fatal("expected error for upstream 500, got nil") + } +} + +func TestFetchCodexModelsManifestMissingToken(t *testing.T) { + account := newCodexModelsTestAccount() + delete(account.Credentials, "access_token") + + s := &OpenAIGatewayService{} + if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", ""); err == nil { + t.Fatal("expected error for missing access token, got nil") + } +}