Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions backend/internal/api/responses_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion backend/internal/api/responses_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()

Expand Down
2 changes: 2 additions & 0 deletions backend/internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
6 changes: 5 additions & 1 deletion backend/internal/providers/codex/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading