diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go index 3d44abbb85fd..436637933b3a 100644 --- a/relay/chat_completions_via_responses.go +++ b/relay/chat_completions_via_responses.go @@ -1,6 +1,7 @@ package relay import ( + "bufio" "fmt" "io" "net/http" @@ -151,6 +152,13 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad httpResp = resp.(*http.Response) clientStream := info.IsStream upstreamStream := isResponsesEventStreamContentType(httpResp.Header.Get("Content-Type")) + if !upstreamStream { + // Some backends (e.g. the ChatGPT Codex /responses endpoint) return a + // 200 SSE stream with no Content-Type header at all. The header check + // above misses this and the caller then treats the SSE body as JSON, + // failing with "invalid character 'e'". Sniff the body prefix instead. + upstreamStream = isResponsesEventStreamSSEBody(httpResp.Body, &httpResp.Body) + } info.IsStream = clientStream || upstreamStream if httpResp.StatusCode != http.StatusOK { newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) @@ -187,3 +195,56 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad func isResponsesEventStreamContentType(contentType string) bool { return strings.Contains(strings.ToLower(contentType), "text/event-stream") } + +// isResponsesEventStreamSSEBody detects an SSE response body (a leading +// "event:" or "data:" prefix, after an optional BOM and/or whitespace) when +// the upstream omits the Content-Type header. It peeks the body one byte at a +// time so a short live SSE prefix (e.g. "event: ping\n\n") is recognized before +// EOF, and it restores every consumed byte via a fresh bufio.Reader so the +// downstream stream handlers receive the full original body. JSON-like bodies +// (leading '{', '[', '"') are rejected early. The (possibly repopulated) body +// is written back through out; the caller must use out for subsequent reads. +func isResponsesEventStreamSSEBody(rc io.ReadCloser, out *io.ReadCloser) bool { + if rc == nil { + return false + } + br := bufio.NewReader(rc) + buf := make([]byte, 0, 64) + for len(buf) < cap(buf) { + b, err := br.ReadByte() + if err != nil { + break // EOF or read error: not enough to confirm SSE + } + buf = append(buf, b) + trimmed := strings.TrimPrefix(string(buf), "\xef\xbb\xbf") + trimmed = strings.TrimLeft(trimmed, " \r\n") + if strings.HasPrefix(trimmed, "event:") || strings.HasPrefix(trimmed, "data:") { + *out = &peekReadCloser{Reader: br, closer: rc} + return true + } + // JSON-like start → definitely not SSE, stop probing. + if len(buf) >= 1 && (buf[0] == '{' || buf[0] == '[' || buf[0] == '"') { + break + } + } + // Not SSE (or inconclusive): hand the still-buffered body back so the + // non-stream JSON handler reads the complete original response. + *out = &peekReadCloser{Reader: br, closer: rc} + return false +} + +// peekReadCloser wraps a bufio.Reader plus the original closer so downstream +// consumers read from the buffer (which still holds every byte) and close +// correctly. +type peekReadCloser struct { + Reader *bufio.Reader + closer io.ReadCloser +} + +func (p *peekReadCloser) Read(b []byte) (int, error) { + return p.Reader.Read(b) +} + +func (p *peekReadCloser) Close() error { + return p.closer.Close() +}