Skip to content

Commit 1464770

Browse files
committed
fix(api): transparently proxy responses compact endpoint
1 parent 1cd3a7a commit 1464770

4 files changed

Lines changed: 129 additions & 2 deletions

File tree

backend/internal/api/responses_handler.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
113113
switch {
114114
case r.Method == http.MethodPost && (r.URL.Path == "/v1/responses" || r.URL.Path == "/responses"):
115115
h.handleResponses(w, r)
116+
case r.Method == http.MethodPost && isTransparentResponsesSubpath(r.URL.Path):
117+
h.handleResponsesTransparentSubpath(w, r)
116118
case r.Method == http.MethodGet && (r.URL.Path == "/v1/models" || r.URL.Path == "/models"):
117119
h.handleModels(w, r)
118120
case r.Method == http.MethodGet && isModelDetailPath(r.URL.Path):
@@ -137,6 +139,49 @@ func (h *ResponsesHandler) handleResponses(w http.ResponseWriter, r *http.Reques
137139
h.handleResponsesThin(w, r, req, rawBody)
138140
}
139141

142+
func (h *ResponsesHandler) handleResponsesTransparentSubpath(w http.ResponseWriter, r *http.Request) {
143+
account, err := h.selectThinGatewayAccount()
144+
if err != nil {
145+
if errors.Is(err, errThinGatewayRequiresResponsesAccount) || errors.Is(err, errThinGatewayActiveAccountUnsupported) {
146+
writeThinGatewayUnsupported(w, err.Error())
147+
return
148+
}
149+
http.Error(w, err.Error(), http.StatusBadGateway)
150+
return
151+
}
152+
153+
if err := ensureOfficialAccountSession(r.Context(), h.client, h.accounts, &account); err != nil {
154+
http.Error(w, err.Error(), http.StatusBadGateway)
155+
return
156+
}
157+
credential, err := resolveCredential(account)
158+
if err != nil {
159+
http.Error(w, err.Error(), http.StatusBadGateway)
160+
return
161+
}
162+
163+
rawBody, err := io.ReadAll(r.Body)
164+
if err != nil {
165+
http.Error(w, err.Error(), http.StatusBadRequest)
166+
return
167+
}
168+
upstreamReq, err := h.buildThinResponsesProxySubpathRequest(r.Context(), account, credential, normalizedResponsesSubpath(r.URL.Path), rawBody)
169+
if err != nil {
170+
http.Error(w, err.Error(), http.StatusBadGateway)
171+
return
172+
}
173+
resp, err := h.client.Do(upstreamReq)
174+
if err != nil {
175+
http.Error(w, err.Error(), http.StatusBadGateway)
176+
return
177+
}
178+
defer resp.Body.Close()
179+
180+
copyResponseHeaders(w.Header(), resp.Header)
181+
w.WriteHeader(resp.StatusCode)
182+
_, _ = io.Copy(w, resp.Body)
183+
}
184+
140185
func (h *ResponsesHandler) handleResponsesThin(w http.ResponseWriter, r *http.Request, req gatewayopenai.ResponsesRequest, rawBody []byte) {
141186
candidates, err := h.orderedThinGatewayCandidates()
142187
if err != nil {
@@ -380,6 +425,22 @@ func (h *ResponsesHandler) buildThinResponsesProxyRequest(ctx context.Context, a
380425
})
381426
}
382427

428+
func (h *ResponsesHandler) buildThinResponsesProxySubpathRequest(ctx context.Context, account accounts.Account, credential string, endpointPath string, rawBody []byte) (*http.Request, error) {
429+
if usesOfficialCodexAdapter(account) {
430+
accountID, err := resolveLocalAccountID(account)
431+
if err != nil {
432+
return nil, err
433+
}
434+
return providercodex.NewAdapter(resolveAccountBaseURL(account)).BuildResponsesEndpointRequest(ctx, credential, accountID, endpointPath, rawBody, false)
435+
}
436+
return provideropenai.NewAdapter(resolveAccountBaseURL(account)).BuildRequest(ctx, providers.Request{
437+
Path: endpointPath,
438+
Method: http.MethodPost,
439+
APIKey: credential,
440+
Body: rawBody,
441+
})
442+
}
443+
383444
func shouldRetryOfficialResponsesTransportError(account accounts.Account, err error) bool {
384445
return usesOfficialCodexAdapter(account) && errors.Is(err, io.EOF)
385446
}
@@ -560,6 +621,17 @@ func isEventStreamResponse(headers http.Header) bool {
560621
return strings.Contains(strings.ToLower(headers.Get("Content-Type")), "text/event-stream")
561622
}
562623

624+
func isTransparentResponsesSubpath(path string) bool {
625+
return path == "/responses/compact" || path == "/v1/responses/compact"
626+
}
627+
628+
func normalizedResponsesSubpath(path string) string {
629+
if strings.HasPrefix(path, "/v1/") {
630+
return strings.TrimPrefix(path, "/v1")
631+
}
632+
return path
633+
}
634+
563635
func (h *ResponsesHandler) startThinAudit(r *http.Request, req gatewayopenai.ResponsesRequest, accountID int64, inputItems []gatewayopenai.ResponsesInputItem) (int64, int) {
564636
conversationID, err := h.conversations.CreateConversation(conversations.Conversation{
565637
ClientID: r.RemoteAddr,

backend/internal/api/responses_handler_test.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,6 @@ func TestResponsesHandlerThinModeDisablesSyntheticEndpoints(t *testing.T) {
839839
{method: http.MethodPost, path: "/v1/responses/resp_1/cancel"},
840840
{method: http.MethodDelete, path: "/v1/responses/resp_1"},
841841
{method: http.MethodPost, path: "/v1/responses/input_tokens"},
842-
{method: http.MethodPost, path: "/v1/responses/compact"},
843842
}
844843

845844
for _, tc := range tests {
@@ -854,6 +853,56 @@ func TestResponsesHandlerThinModeDisablesSyntheticEndpoints(t *testing.T) {
854853
}
855854
}
856855

856+
func TestResponsesHandlerThinModePassesThroughCompactSubpath(t *testing.T) {
857+
t.Parallel()
858+
859+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
860+
if r.URL.Path != "/v1/responses/compact" {
861+
t.Fatalf("path = %q, want /v1/responses/compact", r.URL.Path)
862+
}
863+
if r.Method != http.MethodPost {
864+
t.Fatalf("method = %q, want POST", r.Method)
865+
}
866+
if got := r.Header.Get("Authorization"); got != "Bearer sk-third-party" {
867+
t.Fatalf("authorization = %q, want Bearer sk-third-party", got)
868+
}
869+
body, err := io.ReadAll(r.Body)
870+
if err != nil {
871+
t.Fatalf("ReadAll returned error: %v", err)
872+
}
873+
if string(body) != `{"model":"gpt-5.4","input":"compact-me"}` {
874+
t.Fatalf("body = %s, want passthrough payload", string(body))
875+
}
876+
w.Header().Set("Content-Type", "application/json")
877+
w.WriteHeader(http.StatusCreated)
878+
_, _ = io.WriteString(w, `{"id":"resp_compact_1","status":"completed","object":"response"}`)
879+
}))
880+
defer upstream.Close()
881+
882+
handler := newResponsesHandlerTestHandler(t, accounts.Account{
883+
ProviderType: accounts.ProviderOpenAICompatible,
884+
AccountName: "team3",
885+
AuthMode: accounts.AuthModeAPIKey,
886+
BaseURL: upstream.URL + "/v1",
887+
CredentialRef: "sk-third-party",
888+
Status: accounts.StatusActive,
889+
Priority: 100,
890+
SupportsResponses: true,
891+
})
892+
893+
req := httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewBufferString(`{"model":"gpt-5.4","input":"compact-me"}`))
894+
req.Header.Set("Content-Type", "application/json")
895+
rec := httptest.NewRecorder()
896+
handler.ServeHTTP(rec, req)
897+
898+
if rec.Code != http.StatusCreated {
899+
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusCreated, rec.Body.String())
900+
}
901+
if !strings.Contains(rec.Body.String(), `"id":"resp_compact_1"`) {
902+
t.Fatalf("body = %s, want compact passthrough response", rec.Body.String())
903+
}
904+
}
905+
857906
func TestResponsesHandlerThinModeRetriesOfficialEOFOnce(t *testing.T) {
858907
t.Parallel()
859908

backend/internal/bootstrap/bootstrap.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ func NewApp(_ context.Context, cfg Config) (*App, error) {
170170
apiMux.Handle("/chat/completions", api.RequireProxyEnabled(gatewayHandler))
171171
apiMux.Handle("/v1/chat/completions", api.RequireProxyEnabled(gatewayHandler))
172172
apiMux.Handle("/responses", api.RequireProxyEnabled(responsesHandler))
173+
apiMux.Handle("/responses/", api.RequireProxyEnabled(responsesHandler))
173174
apiMux.Handle("/v1/responses", api.RequireProxyEnabled(responsesHandler))
175+
apiMux.Handle("/v1/responses/", api.RequireProxyEnabled(responsesHandler))
174176
apiMux.Handle("/models", api.RequireProxyEnabled(responsesHandler))
175177
apiMux.Handle("/v1/models", api.RequireProxyEnabled(responsesHandler))
176178

backend/internal/providers/codex/adapter.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ func NewAdapter(baseURL string) *Adapter {
1818
}
1919

2020
func (a *Adapter) BuildResponsesRequest(ctx context.Context, credential string, accountID string, body []byte, stream bool) (*http.Request, error) {
21-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/responses", bytes.NewReader(body))
21+
return a.BuildResponsesEndpointRequest(ctx, credential, accountID, "/responses", body, stream)
22+
}
23+
24+
func (a *Adapter) BuildResponsesEndpointRequest(ctx context.Context, credential string, accountID string, endpointPath string, body []byte, stream bool) (*http.Request, error) {
25+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+endpointPath, bytes.NewReader(body))
2226
if err != nil {
2327
return nil, err
2428
}

0 commit comments

Comments
 (0)