When /v1/responses streaming hits the user/account concurrency wait, the wait loop sends SSE ping comments to keep the connection alive, which flushes HTTP 200 + headers. If the wait then times out (or any other post-flush error fires), handleStreamingAwareError previously emitted a generic `event: error` frame. Codex CLI requires the stream to end with a Responses terminal event (response.completed/failed/incomplete/cancelled), so it reports "stream closed before response.completed" and the user-facing rate-limit intent is lost. This change detects inbound = /v1/responses in both handleStreamingAwareError implementations and emits a protocol-compliant response.failed event whose field set mirrors apicompat.makeResponsesCompletedEvent (id/object/model/status/output/error). The synthetic id reuses ctxkey.RequestID so client errors can be grepped against server logs. sequence_number is intentionally omitted to preserve monotonicity on streams that already emitted real events. Other inbound endpoints (/v1/chat/completions, /v1/messages) keep their legacy formats untouched. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
112 lines
3.8 KiB
Go
112 lines
3.8 KiB
Go
package handler
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// writeResponsesFailedSSE emits a `response.failed` SSE event in the OpenAI
|
||
// Responses API protocol after the stream has already started.
|
||
//
|
||
// 必要性:一旦 SSE 头和任意数据(例如等待槽位时的 ping comment)已经 flush,
|
||
// HTTP 200 状态码就被固化。此后若网关需要回报错误,只能继续通过 SSE 事件传达。
|
||
// 通用的 `event: error` 帧不是 Responses 协议规定的终止事件,
|
||
// Codex CLI 等严格 SDK 会因为没收到 `response.completed/failed/incomplete/cancelled`
|
||
// 而抛出 "stream closed before response.completed"。
|
||
//
|
||
// 字段集对齐 apicompat.makeResponsesCompletedEvent:id/object/model/status/output/error。
|
||
// 故意不写 sequence_number:本函数被调用时无法可靠拿到当前流的 last sequence,
|
||
// 而 OpenAI spec 将 sequence_number 设为可选;省略避免破坏单调性约束。
|
||
//
|
||
// 返回 true 表示已尝试 SSE 写出(不论 Write 是否成功,caller 都应直接 return)。
|
||
// 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
|
||
// 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏,
|
||
// 应当让请求处理函数 return,由上层关闭连接。
|
||
func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
|
||
flusher, ok := c.Writer.(http.Flusher)
|
||
if !ok {
|
||
return false
|
||
}
|
||
rid := synthesizeResponseID(c)
|
||
model := requestModel(c)
|
||
code := mapResponsesErrorCode(errType)
|
||
|
||
var b strings.Builder
|
||
b.Grow(256 + len(message) + len(model))
|
||
b.WriteString(`{"type":"response.failed","response":{`)
|
||
b.WriteString(`"id":`)
|
||
b.WriteString(strconv.Quote(rid))
|
||
b.WriteString(`,"object":"response"`)
|
||
if model != "" {
|
||
b.WriteString(`,"model":`)
|
||
b.WriteString(strconv.Quote(model))
|
||
}
|
||
b.WriteString(`,"status":"failed","output":[],"error":{"code":`)
|
||
b.WriteString(strconv.Quote(code))
|
||
b.WriteString(`,"message":`)
|
||
b.WriteString(strconv.Quote(message))
|
||
b.WriteString(`}}}`)
|
||
|
||
if _, err := fmt.Fprintf(c.Writer, "event: response.failed\ndata: %s\n\n", b.String()); err != nil {
|
||
_ = c.Error(err)
|
||
return true
|
||
}
|
||
flusher.Flush()
|
||
return true
|
||
}
|
||
|
||
// synthesizeResponseID 为合成的 response.failed 事件生成一个稳定的 id。
|
||
// 优先复用 server 端生成的 request_id(存在 request.Context 里,由 request_logger 写入),
|
||
// 以便客户端报错能与 server 日志关联;缺失时回退 uuid。
|
||
func synthesizeResponseID(c *gin.Context) string {
|
||
if c != nil && c.Request != nil {
|
||
if rid, ok := c.Request.Context().Value(ctxkey.RequestID).(string); ok {
|
||
if rid = strings.TrimSpace(rid); rid != "" {
|
||
return "resp_" + strings.ReplaceAll(rid, "-", "")
|
||
}
|
||
}
|
||
}
|
||
return "resp_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||
}
|
||
|
||
// requestModel 取当前请求的 inbound model(由 setOpsRequestContext 写入)。
|
||
// 缺失时返回 "";caller 据此决定是否忽略该字段。
|
||
func requestModel(c *gin.Context) string {
|
||
if c == nil {
|
||
return ""
|
||
}
|
||
if v, ok := c.Get(opsModelKey); ok {
|
||
if s, ok := v.(string); ok {
|
||
return strings.TrimSpace(s)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。
|
||
// 无明确映射时原样返回,保证至少可读。
|
||
func mapResponsesErrorCode(errType string) string {
|
||
switch errType {
|
||
case "rate_limit_error":
|
||
return "rate_limit_exceeded"
|
||
case "invalid_request_error":
|
||
return "invalid_request"
|
||
case "permission_error":
|
||
return "permission_denied"
|
||
case "authentication_error":
|
||
return "authentication_failed"
|
||
case "upstream_error":
|
||
return "upstream_error"
|
||
case "server_error", "api_error", "":
|
||
return "server_error"
|
||
default:
|
||
return errType
|
||
}
|
||
}
|