Merge PR #4239: fix(codex): proxy GPT-5.6 standalone search

This commit is contained in:
Luis Pater
2026-07-12 22:41:36 +08:00
2 changed files with 160 additions and 0 deletions

View File

@@ -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) {

View File

@@ -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")