From 1464770f6bde308b81a7192535ccf9b3f4c3e9e5 Mon Sep 17 00:00:00 2001 From: GcsSloop Date: Wed, 25 Mar 2026 14:32:54 +0800 Subject: [PATCH] fix(api): transparently proxy responses compact endpoint --- backend/internal/api/responses_handler.go | 72 +++++++++++++++++++ .../internal/api/responses_handler_test.go | 51 ++++++++++++- backend/internal/bootstrap/bootstrap.go | 2 + backend/internal/providers/codex/adapter.go | 6 +- 4 files changed, 129 insertions(+), 2 deletions(-) diff --git a/backend/internal/api/responses_handler.go b/backend/internal/api/responses_handler.go index cccdfdf..fc909ac 100644 --- a/backend/internal/api/responses_handler.go +++ b/backend/internal/api/responses_handler.go @@ -113,6 +113,8 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && (r.URL.Path == "/v1/responses" || r.URL.Path == "/responses"): h.handleResponses(w, r) + case r.Method == http.MethodPost && isTransparentResponsesSubpath(r.URL.Path): + h.handleResponsesTransparentSubpath(w, r) case r.Method == http.MethodGet && (r.URL.Path == "/v1/models" || r.URL.Path == "/models"): h.handleModels(w, r) case r.Method == http.MethodGet && isModelDetailPath(r.URL.Path): @@ -137,6 +139,49 @@ func (h *ResponsesHandler) handleResponses(w http.ResponseWriter, r *http.Reques h.handleResponsesThin(w, r, req, rawBody) } +func (h *ResponsesHandler) handleResponsesTransparentSubpath(w http.ResponseWriter, r *http.Request) { + account, err := h.selectThinGatewayAccount() + if err != nil { + if errors.Is(err, errThinGatewayRequiresResponsesAccount) || errors.Is(err, errThinGatewayActiveAccountUnsupported) { + writeThinGatewayUnsupported(w, err.Error()) + return + } + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + if err := ensureOfficialAccountSession(r.Context(), h.client, h.accounts, &account); err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + credential, err := resolveCredential(account) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + + rawBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + upstreamReq, err := h.buildThinResponsesProxySubpathRequest(r.Context(), account, credential, normalizedResponsesSubpath(r.URL.Path), rawBody) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + resp, err := h.client.Do(upstreamReq) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + + copyResponseHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + func (h *ResponsesHandler) handleResponsesThin(w http.ResponseWriter, r *http.Request, req gatewayopenai.ResponsesRequest, rawBody []byte) { candidates, err := h.orderedThinGatewayCandidates() if err != nil { @@ -380,6 +425,22 @@ func (h *ResponsesHandler) buildThinResponsesProxyRequest(ctx context.Context, a }) } +func (h *ResponsesHandler) buildThinResponsesProxySubpathRequest(ctx context.Context, account accounts.Account, credential string, endpointPath string, rawBody []byte) (*http.Request, error) { + if usesOfficialCodexAdapter(account) { + accountID, err := resolveLocalAccountID(account) + if err != nil { + return nil, err + } + return providercodex.NewAdapter(resolveAccountBaseURL(account)).BuildResponsesEndpointRequest(ctx, credential, accountID, endpointPath, rawBody, false) + } + return provideropenai.NewAdapter(resolveAccountBaseURL(account)).BuildRequest(ctx, providers.Request{ + Path: endpointPath, + Method: http.MethodPost, + APIKey: credential, + Body: rawBody, + }) +} + func shouldRetryOfficialResponsesTransportError(account accounts.Account, err error) bool { return usesOfficialCodexAdapter(account) && errors.Is(err, io.EOF) } @@ -560,6 +621,17 @@ func isEventStreamResponse(headers http.Header) bool { return strings.Contains(strings.ToLower(headers.Get("Content-Type")), "text/event-stream") } +func isTransparentResponsesSubpath(path string) bool { + return path == "/responses/compact" || path == "/v1/responses/compact" +} + +func normalizedResponsesSubpath(path string) string { + if strings.HasPrefix(path, "/v1/") { + return strings.TrimPrefix(path, "/v1") + } + return path +} + func (h *ResponsesHandler) startThinAudit(r *http.Request, req gatewayopenai.ResponsesRequest, accountID int64, inputItems []gatewayopenai.ResponsesInputItem) (int64, int) { conversationID, err := h.conversations.CreateConversation(conversations.Conversation{ ClientID: r.RemoteAddr, diff --git a/backend/internal/api/responses_handler_test.go b/backend/internal/api/responses_handler_test.go index 984bd26..c854b38 100644 --- a/backend/internal/api/responses_handler_test.go +++ b/backend/internal/api/responses_handler_test.go @@ -839,7 +839,6 @@ func TestResponsesHandlerThinModeDisablesSyntheticEndpoints(t *testing.T) { {method: http.MethodPost, path: "/v1/responses/resp_1/cancel"}, {method: http.MethodDelete, path: "/v1/responses/resp_1"}, {method: http.MethodPost, path: "/v1/responses/input_tokens"}, - {method: http.MethodPost, path: "/v1/responses/compact"}, } for _, tc := range tests { @@ -854,6 +853,56 @@ func TestResponsesHandlerThinModeDisablesSyntheticEndpoints(t *testing.T) { } } +func TestResponsesHandlerThinModePassesThroughCompactSubpath(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/responses/compact" { + t.Fatalf("path = %q, want /v1/responses/compact", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Fatalf("method = %q, want POST", r.Method) + } + if got := r.Header.Get("Authorization"); got != "Bearer sk-third-party" { + t.Fatalf("authorization = %q, want Bearer sk-third-party", got) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll returned error: %v", err) + } + if string(body) != `{"model":"gpt-5.4","input":"compact-me"}` { + t.Fatalf("body = %s, want passthrough payload", string(body)) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":"resp_compact_1","status":"completed","object":"response"}`) + })) + defer upstream.Close() + + handler := newResponsesHandlerTestHandler(t, accounts.Account{ + ProviderType: accounts.ProviderOpenAICompatible, + AccountName: "team3", + AuthMode: accounts.AuthModeAPIKey, + BaseURL: upstream.URL + "/v1", + CredentialRef: "sk-third-party", + Status: accounts.StatusActive, + Priority: 100, + SupportsResponses: true, + }) + + req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewBufferString(`{"model":"gpt-5.4","input":"compact-me"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"id":"resp_compact_1"`) { + t.Fatalf("body = %s, want compact passthrough response", rec.Body.String()) + } +} + func TestResponsesHandlerThinModeRetriesOfficialEOFOnce(t *testing.T) { t.Parallel() diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 2661394..d347242 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -170,7 +170,9 @@ func NewApp(_ context.Context, cfg Config) (*App, error) { apiMux.Handle("/chat/completions", api.RequireProxyEnabled(gatewayHandler)) apiMux.Handle("/v1/chat/completions", api.RequireProxyEnabled(gatewayHandler)) apiMux.Handle("/responses", api.RequireProxyEnabled(responsesHandler)) + apiMux.Handle("/responses/", api.RequireProxyEnabled(responsesHandler)) apiMux.Handle("/v1/responses", api.RequireProxyEnabled(responsesHandler)) + apiMux.Handle("/v1/responses/", api.RequireProxyEnabled(responsesHandler)) apiMux.Handle("/models", api.RequireProxyEnabled(responsesHandler)) apiMux.Handle("/v1/models", api.RequireProxyEnabled(responsesHandler)) diff --git a/backend/internal/providers/codex/adapter.go b/backend/internal/providers/codex/adapter.go index 62f5b90..07cb92c 100644 --- a/backend/internal/providers/codex/adapter.go +++ b/backend/internal/providers/codex/adapter.go @@ -18,7 +18,11 @@ func NewAdapter(baseURL string) *Adapter { } func (a *Adapter) BuildResponsesRequest(ctx context.Context, credential string, accountID string, body []byte, stream bool) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/responses", bytes.NewReader(body)) + return a.BuildResponsesEndpointRequest(ctx, credential, accountID, "/responses", body, stream) +} + +func (a *Adapter) BuildResponsesEndpointRequest(ctx context.Context, credential string, accountID string, endpointPath string, body []byte, stream bool) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+endpointPath, bytes.NewReader(body)) if err != nil { return nil, err }