Files
goclaw/internal/providers/sse_reader.go
T
Duc Nguyen 016263eb84 fix(providers): unbounded SSE line reader to stop image_generation "token too long" crash (#1349)
bufio.Scanner has a fixed max-token size (SSEScanBufMax, 1MB). Codex's
native image_generation streams a full base64 image in a SINGLE SSE
data line (response.image_generation_call.partial_image and the final
image_generation_call item), which regularly exceeds 1MB for anything
larger than a thumbnail. Scanner.Scan() then fails with:

  stream read error: bufio.Scanner: token too long

killing the whole turn after the model already generated the image.

Replace bufio.Scanner with bufio.Reader.ReadString, which grows to fit
a line of any length instead of erroring past a fixed cap. SSEScanBufMax
is now unused and removed; SSEScanBufInit is reused as the reader's
initial buffer size.
2026-07-05 00:56:31 +07:00

140 lines
3.7 KiB
Go

package providers
import (
"bufio"
"context"
"io"
"strings"
"sync"
)
// SSEScanner reads an SSE (Server-Sent Events) stream line by line,
// extracting data payloads. Shared by OpenAI, Anthropic, and Codex providers.
type SSEScanner struct {
reader *bufio.Reader
data string
eventType string
err error
done bool
}
// NewSSEScanner creates an SSE scanner.
//
// It reads lines with bufio.Reader.ReadString, which grows to fit a line of
// any length, instead of bufio.Scanner, whose fixed max-token size (formerly
// SSEScanBufMax) overflowed with "token too long" on a single data line
// carrying a full base64 image — image generation streams multi-MB frames in
// a single SSE line. SSEScanBufInit is reused as the reader's initial size.
func NewSSEScanner(r io.Reader) *SSEScanner {
return &SSEScanner{reader: bufio.NewReaderSize(r, SSEScanBufInit)}
}
// Next advances to the next data line. Returns false when the stream ends
// or "[DONE]" is encountered. After Next returns false, call Err() for errors.
func (s *SSEScanner) Next() bool {
if s.done {
return false
}
for {
line, err := s.reader.ReadString('\n')
if len(line) > 0 {
// Trim the trailing newline ("\n" or "\r\n").
line = strings.TrimSuffix(line, "\n")
line = strings.TrimSuffix(line, "\r")
if after, ok := strings.CutPrefix(line, "event: "); ok {
s.eventType = after
} else if after, ok := strings.CutPrefix(line, "event:"); ok {
s.eventType = strings.TrimSpace(after)
} else {
payload, isData := "", false
if after, ok := strings.CutPrefix(line, "data: "); ok {
payload, isData = after, true
} else if after, ok := strings.CutPrefix(line, "data:"); ok {
payload, isData = after, true
}
if isData {
// "[DONE]" is the OpenAI/Codex stream terminator.
if payload == "[DONE]" {
s.done = true
return false
}
s.data = payload
// A final line may arrive together with io.EOF (no trailing
// newline). Deliver it now; the next call reports the end.
if err != nil {
s.done = true
if err != io.EOF {
s.err = err
}
}
return true
}
// Non event/data line (blank line, ":" comment, other field): skip.
}
}
if err != nil {
if err != io.EOF {
s.err = err
}
s.done = true
return false
}
}
}
// Data returns the current data payload (valid after Next returns true).
func (s *SSEScanner) Data() string {
return s.data
}
// EventType returns the last seen event type (e.g. "message_start", "content_block_delta").
func (s *SSEScanner) EventType() string {
return s.eventType
}
// Err returns the first non-EOF error encountered during scanning.
func (s *SSEScanner) Err() error {
return s.err
}
// CtxBody wraps an http.Response.Body so that ctx cancellation closes the
// underlying socket, unblocking a goroutine stuck inside a blocking read.
// Safe for concurrent Close (sync.Once). Caller MUST defer Close() to release
// the watchdog goroutine even on success.
type CtxBody struct {
body io.ReadCloser
done chan struct{}
once sync.Once
}
// NewCtxBody returns a ReadCloser that closes body when ctx is cancelled.
func NewCtxBody(ctx context.Context, body io.ReadCloser) *CtxBody {
cb := &CtxBody{body: body, done: make(chan struct{})}
go func() {
select {
case <-ctx.Done():
cb.closeOnce()
case <-cb.done:
// normal close path; watchdog exits cleanly
}
}()
return cb
}
func (cb *CtxBody) Read(p []byte) (int, error) { return cb.body.Read(p) }
// Close closes the underlying body exactly once (safe for concurrent calls).
func (cb *CtxBody) Close() error {
return cb.closeOnce()
}
func (cb *CtxBody) closeOnce() error {
var err error
cb.once.Do(func() {
close(cb.done)
err = cb.body.Close()
})
return err
}