| 1 | package openai |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "io" |
| 6 | "strings" |
| 7 | |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | // sseProbe watches the stream's lines so an incomplete stream can say whether |
| 12 | // the endpoint spoke SSE at all. A body with no `data:` line and a non-stream |
| 13 | // Content-Type is a gateway page or error document, not a dropped connection. |
| 14 | type sseProbe struct { |
| 15 | sawData bool |
| 16 | firstNonData string // first non-blank non-`data:` line, clipped for the error text |
| 17 | } |
| 18 | |
| 19 | // dataLine reports whether line is an SSE `data:` line, recording the first |
| 20 | // other non-blank line for the diagnostic. |
| 21 | func (p *sseProbe) dataLine(line string) bool { |
| 22 | if line == "" { |
| 23 | return false |
| 24 | } |
| 25 | if !strings.HasPrefix(line, "data:") { |
| 26 | if p.firstNonData == "" { |
| 27 | p.firstNonData = clipDiagnosticLine(line) |
| 28 | } |
| 29 | return false |
| 30 | } |
| 31 | p.sawData = true |
| 32 | return true |
| 33 | } |
| 34 | |
| 35 | // incompleteErr is the error for a stream that ended without a terminal event. |
| 36 | func (p *sseProbe) incompleteErr(name, contentType string) error { |
| 37 | ct := strings.TrimSpace(contentType) |
| 38 | if !p.sawData && !isEventStreamContentType(ct) { |
| 39 | return fmt.Errorf("%s: stream ended before any SSE event (Content-Type %q, first line %q) — the endpoint returned a non-streaming response, e.g. a gateway landing page. Check that request_url/base_url points to a full API endpoint such as /v1/chat/completions or /v1/responses, not a gateway root: %w", |
| 40 | name, ct, p.firstNonData, provider.ErrNonStreamingResponse) |
| 41 | } |
| 42 | return fmt.Errorf("%s: stream ended before completion: %w", name, io.ErrUnexpectedEOF) |
| 43 | } |
| 44 | |
| 45 | // isEventStreamContentType reports whether a Content-Type claims a stream. An |
| 46 | // absent header counts as one: some gateways stream without it, so this only |
| 47 | // shapes the diagnostic and never rejects a response. |
| 48 | func isEventStreamContentType(contentType string) bool { |
| 49 | ct := strings.ToLower(strings.TrimSpace(contentType)) |
| 50 | return ct == "" || strings.Contains(ct, "event-stream") || strings.Contains(ct, "ndjson") |
| 51 | } |
| 52 | |
| 53 | // clipDiagnosticLine bounds a non-SSE line before it rides in an error |
| 54 | // message, so a multi-KB HTML page cannot bloat the surface text. |
| 55 | func clipDiagnosticLine(line string) string { |
| 56 | const maxLen = 120 |
| 57 | if len(line) <= maxLen { |
| 58 | return line |
| 59 | } |
| 60 | return line[:maxLen] + "…" |
| 61 | } |
| 62 |