diff --git a/internal/api/server.go b/internal/api/server.go index 0117a488a..f2a84ddec 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "os" @@ -532,6 +533,7 @@ func (s *Server) setupRoutes() { v1.GET("/responses", openaiResponsesHandlers.ResponsesWebsocket) v1.POST("/responses", openaiResponsesHandlers.Responses) v1.POST("/responses/compact", openaiResponsesHandlers.Compact) + v1.POST("/alpha/search", s.codexAlphaSearch) } openaiV1 := s.engine.Group("/openai/v1") @@ -621,6 +623,71 @@ func (s *Server) setupRoutes() { // Management routes are registered lazily by registerManagementRoutes when a secret is configured. } +// codexAlphaSearch forwards the standalone search endpoint used by current +// Codex clients. Unlike /responses, this payload is already in Codex search +// format and must not pass through a protocol translator. +func (s *Server) codexAlphaSearch(c *gin.Context) { + if s == nil || s.handlers == nil || s.handlers.AuthManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Codex auth manager unavailable"}) + return + } + + var selected *auth.Auth + for _, candidate := range s.handlers.AuthManager.List() { + if candidate != nil && candidate.Provider == "codex" && !candidate.Disabled && !candidate.Unavailable { + selected = candidate + break + } + } + if selected == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "No available Codex OAuth credential"}) + return + } + + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 16<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read search request"}) + return + } + headers := make(http.Header) + headers.Set("Content-Type", "application/json") + headers.Set("Accept", "application/json") + headers.Set("Originator", "codex_cli_rs") + for _, name := range []string{"Version", "User-Agent", "Session_id", "X-Client-Request-Id"} { + if value := strings.TrimSpace(c.GetHeader(name)); value != "" { + headers.Set(name, value) + } + } + if accountID, ok := selected.Metadata["account_id"].(string); ok && strings.TrimSpace(accountID) != "" { + headers.Set("Chatgpt-Account-Id", accountID) + } + + req, err := s.handlers.AuthManager.NewHttpRequest( + c.Request.Context(), selected, http.MethodPost, + "https://chatgpt.com/backend-api/codex/alpha/search", body, headers, + ) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + resp, err := s.handlers.AuthManager.HttpRequest(c.Request.Context(), selected, req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + upstreamBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to read Codex search response"}) + return + } + if contentType := resp.Header.Get("Content-Type"); contentType != "" { + c.Header("Content-Type", contentType) + } + c.Status(resp.StatusCode) + _, _ = c.Writer.Write(upstreamBody) +} + // AttachWebsocketRoute registers a websocket upgrade handler on the primary Gin engine. // The handler is served as-is without additional middleware beyond the standard stack already configured. func (s *Server) AttachWebsocketRoute(path string, handler http.Handler) { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index d7fba41a2..95e729af5 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -19,9 +21,53 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" ) +type codexSearchCaptureExecutor struct { + request *http.Request + body []byte +} + +func (e *codexSearchCaptureExecutor) Identifier() string { return "codex" } + +func (e *codexSearchCaptureExecutor) Execute(context.Context, *auth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, nil +} + +func (e *codexSearchCaptureExecutor) ExecuteStream(context.Context, *auth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) { + return nil, nil +} + +func (e *codexSearchCaptureExecutor) Refresh(_ context.Context, a *auth.Auth) (*auth.Auth, error) { + return a, nil +} + +func (e *codexSearchCaptureExecutor) CountTokens(context.Context, *auth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{}, nil +} + +func (e *codexSearchCaptureExecutor) PrepareRequest(req *http.Request, a *auth.Auth) error { + token, _ := a.Metadata["access_token"].(string) + req.Header.Set("Authorization", "Bearer "+token) + return nil +} + +func (e *codexSearchCaptureExecutor) HttpRequest(_ context.Context, _ *auth.Auth, req *http.Request) (*http.Response, error) { + e.request = req.Clone(req.Context()) + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + e.body = body + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"results":[{"url":"https://example.com"}]}`)), + }, nil +} + func newTestServer(t *testing.T) *Server { t.Helper() return newTestServerWithOptions(t) @@ -93,6 +139,53 @@ func TestHealthz(t *testing.T) { }) } +func TestCodexAlphaSearchForwardsRequest(t *testing.T) { + server := newTestServer(t) + executor := &codexSearchCaptureExecutor{} + server.handlers.AuthManager.RegisterExecutor(executor) + credential := &auth.Auth{ + ID: "codex-auth", + Provider: "codex", + Status: auth.StatusActive, + Metadata: map[string]any{"access_token": "codex-token", "account_id": "account-123"}, + } + if _, err := server.handlers.AuthManager.Register(context.Background(), credential); err != nil { + t.Fatalf("register Codex auth: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/alpha/search", strings.NewReader(`{"query":"GPT-5.6"}`)) + req.Header.Set("Authorization", "Bearer test-key") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Session_id", "session-123") + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + if executor.request == nil { + t.Fatal("Codex executor did not receive a request") + } + if got, want := executor.request.URL.String(), "https://chatgpt.com/backend-api/codex/alpha/search"; got != want { + t.Fatalf("upstream URL = %q, want %q", got, want) + } + if got, want := string(executor.body), `{"query":"GPT-5.6"}`; got != want { + t.Fatalf("upstream body = %q, want %q", got, want) + } + if got := executor.request.Header.Get("Authorization"); got != "Bearer codex-token" { + t.Fatalf("Authorization = %q", got) + } + if got := executor.request.Header.Get("Chatgpt-Account-Id"); got != "account-123" { + t.Fatalf("Chatgpt-Account-Id = %q", got) + } + if got := executor.request.Header.Get("Session_id"); got != "session-123" { + t.Fatalf("Session_id = %q", got) + } + if got := rr.Header().Get("Content-Type"); got != "application/json" { + t.Fatalf("response Content-Type = %q", got) + } +} + func TestManagementResponseExposesPluginSupportHeaderForCORS(t *testing.T) { t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")