fix(openai): also match bare /responses route in handleStreamingAwareError
The first revision compared GetInboundEndpoint(c) against EndpointResponses
("/v1/responses"). NormalizeInboundEndpoint only recognizes paths that
contain the literal "/v1/responses" substring, but the project actually
registers six /responses routes — three of which (top-level
r.POST("/responses", ...) and codexDirect's "/backend-api/codex/responses")
have FullPath values without the "/v1" prefix and therefore fall through
to the default branch.
Codex CLI users targeting the bare /responses route at the production
deployment (observed 2026-05-24 ~11:05 UTC, user 16) never reached the
new writeResponsesFailedSSE path: the endpoint check was false, the
legacy `event: error` frame fired, and the strict SDK kept reporting
"stream closed before response.completed".
Replace the strict equality check with inboundIsResponses(c), which
uses suffix detection on FullPath (falling back to URL.Path when
FullPath is empty in test fixtures) and covers all six route variants:
/v1/responses[/...]
/responses[/...]
/backend-api/codex/responses[/...]
Add test table covering all routes plus negative cases.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5e5c2062bf
commit
cff2f291be
@ -1423,7 +1423,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
|
|||||||
// /v1/responses 的严格 SDK(Codex CLI)要求终止事件必须属于
|
// /v1/responses 的严格 SDK(Codex CLI)要求终止事件必须属于
|
||||||
// response.completed/failed/incomplete/cancelled 集合。
|
// response.completed/failed/incomplete/cancelled 集合。
|
||||||
// Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。
|
// Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。
|
||||||
if GetInboundEndpoint(c) == EndpointResponses {
|
if inboundIsResponses(c) {
|
||||||
if writeResponsesFailedSSE(c, errType, message) {
|
if writeResponsesFailedSSE(c, errType, message) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1695,7 +1695,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status
|
|||||||
// response.completed/failed/incomplete/cancelled 集合。
|
// response.completed/failed/incomplete/cancelled 集合。
|
||||||
// 通用 `event: error` 帧不被识别为终止事件,会导致
|
// 通用 `event: error` 帧不被识别为终止事件,会导致
|
||||||
// "stream closed before response.completed"。
|
// "stream closed before response.completed"。
|
||||||
if GetInboundEndpoint(c) == EndpointResponses {
|
if inboundIsResponses(c) {
|
||||||
if writeResponsesFailedSSE(c, errType, message) {
|
if writeResponsesFailedSSE(c, errType, message) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,36 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inboundIsResponses 判断当前请求是否落在任何 /responses 路由上。
|
||||||
|
//
|
||||||
|
// 不能直接用 GetInboundEndpoint(c) == EndpointResponses 比较,因为
|
||||||
|
// NormalizeInboundEndpoint 只识别包含 "/v1/responses" 子串的路径;
|
||||||
|
// 项目里实际注册了多组路由(gateway_v1、top-level bare、codex direct),
|
||||||
|
// 其中 r.POST("/responses", ...) 和 codexDirect.POST("/responses", ...)
|
||||||
|
// 的 c.FullPath() 不含 "/v1/" 前缀,会被归一化为原始路径,
|
||||||
|
// 导致协议合规终止事件没法发出去。
|
||||||
|
//
|
||||||
|
// 这里用 FullPath 的后缀判断,覆盖所有变体:
|
||||||
|
// - /v1/responses
|
||||||
|
// - /v1/responses/compact
|
||||||
|
// - /responses
|
||||||
|
// - /responses/compact
|
||||||
|
// - /backend-api/codex/responses
|
||||||
|
// - /backend-api/codex/responses/compact
|
||||||
|
func inboundIsResponses(c *gin.Context) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p := strings.TrimRight(c.FullPath(), "/")
|
||||||
|
if p == "" && c.Request != nil && c.Request.URL != nil {
|
||||||
|
p = strings.TrimRight(c.Request.URL.Path, "/")
|
||||||
|
}
|
||||||
|
if p == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(p, "/responses") || strings.Contains(p, "/responses/")
|
||||||
|
}
|
||||||
|
|
||||||
// synthesizeResponseID 为合成的 response.failed 事件生成一个稳定的 id。
|
// synthesizeResponseID 为合成的 response.failed 事件生成一个稳定的 id。
|
||||||
// 优先复用 server 端生成的 request_id(存在 request.Context 里,由 request_logger 写入),
|
// 优先复用 server 端生成的 request_id(存在 request.Context 里,由 request_logger 写入),
|
||||||
// 以便客户端报错能与 server 日志关联;缺失时回退 uuid。
|
// 以便客户端报错能与 server 日志关联;缺失时回退 uuid。
|
||||||
|
|||||||
@ -175,6 +175,57 @@ func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testin
|
|||||||
assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`), "got: %q", body)
|
assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`), "got: %q", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 项目里 /responses 注册在多组路由:/v1/responses(gateway)、裸 /responses(top-level)、
|
||||||
|
// /backend-api/codex/responses(codex direct)。我们 fix 必须覆盖全部,
|
||||||
|
// 否则一些客户端走的路径就不会发 response.failed,照样报 stream closed。
|
||||||
|
// 这是生产 2026-05-24 ~11:05 UTC user 16 实际命中的 bug。
|
||||||
|
func TestInboundIsResponses_CoversAllRoutes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
route string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"/v1/responses", true},
|
||||||
|
{"/v1/responses/compact", true},
|
||||||
|
{"/responses", true}, // <-- 用户 16 实际走这条
|
||||||
|
{"/responses/compact", true},
|
||||||
|
{"/backend-api/codex/responses", true},
|
||||||
|
{"/backend-api/codex/responses/compact", true},
|
||||||
|
{"/v1/chat/completions", false},
|
||||||
|
{"/v1/messages", false},
|
||||||
|
{"/", false},
|
||||||
|
{"/responses-fake", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.route, func(t *testing.T) {
|
||||||
|
c, _ := newGinContextForEndpoint(t, tc.route)
|
||||||
|
assert.Equal(t, tc.want, inboundIsResponses(c), "route=%q", tc.route)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 c.Request.URL.Path 作为 fallback(当 c.FullPath() 为空时,例如某些测试 fixture)。
|
||||||
|
func TestInboundIsResponses_FallsBackToURLPath(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/responses", nil)
|
||||||
|
// 这种情况下 c.FullPath() 是 "",必须 fallback 到 URL.Path
|
||||||
|
assert.True(t, inboundIsResponses(c), "URL.Path fallback must work when FullPath is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回归生产事故:用户 16 走 /responses 路径,必须发 response.failed。
|
||||||
|
func TestOpenAIHandleStreamingAwareError_BareResponsesRouteEmitsResponseFailed(t *testing.T) {
|
||||||
|
c, w := newGinContextForEndpoint(t, "/responses")
|
||||||
|
h := &OpenAIGatewayHandler{}
|
||||||
|
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||||
|
"Concurrency limit exceeded for user, please retry later", true)
|
||||||
|
|
||||||
|
resp, errObj := parseResponsesFailedSSE(t, w.Body.String())
|
||||||
|
id, _ := resp["id"].(string)
|
||||||
|
assert.True(t, strings.HasPrefix(id, "resp_"))
|
||||||
|
assert.Equal(t, "rate_limit_exceeded", errObj["code"])
|
||||||
|
}
|
||||||
|
|
||||||
// Synthesized response.failed id falls back to uuid when no request_id is present.
|
// Synthesized response.failed id falls back to uuid when no request_id is present.
|
||||||
func TestSynthesizeResponseID_FallbackUUID(t *testing.T) {
|
func TestSynthesizeResponseID_FallbackUUID(t *testing.T) {
|
||||||
c, _ := newGinContextForEndpoint(t, EndpointResponses)
|
c, _ := newGinContextForEndpoint(t, EndpointResponses)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user