33package fake
44
55import (
6+ "bufio"
67 "bytes"
78 "context"
89 "fmt"
@@ -21,10 +22,42 @@ import (
2122 "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder"
2223)
2324
25+ // ProxyOptions configures the fake proxy behavior.
26+ type ProxyOptions struct {
27+ // SimulateStream adds delays between SSE chunks to simulate real streaming.
28+ SimulateStream bool
29+ // StreamChunkDelay is the delay between SSE chunks when SimulateStream is true.
30+ // Defaults to 15ms if not set.
31+ StreamChunkDelay time.Duration
32+ }
33+
34+ // ProxyOption is a function that configures ProxyOptions.
35+ type ProxyOption func (* ProxyOptions )
36+
37+ // WithSimulateStream enables simulated streaming with delays between chunks.
38+ func WithSimulateStream (enabled bool ) ProxyOption {
39+ return func (o * ProxyOptions ) {
40+ o .SimulateStream = enabled
41+ }
42+ }
43+
44+ // WithStreamChunkDelay sets the delay between SSE chunks.
45+ func WithStreamChunkDelay (d time.Duration ) ProxyOption {
46+ return func (o * ProxyOptions ) {
47+ o .StreamChunkDelay = d
48+ }
49+ }
50+
2451// StartProxy starts an internal HTTP proxy that replays cassette responses.
2552// It returns the proxy URL and a cleanup function that should be called when done.
26- func StartProxy (cassettePath string ) (string , func () error , error ) {
27- return StartProxyWithOptions (cassettePath , recorder .ModeReplayOnly , nil , nil )
53+ func StartProxy (cassettePath string , opts ... ProxyOption ) (string , func () error , error ) {
54+ options := & ProxyOptions {
55+ StreamChunkDelay : 15 * time .Millisecond ,
56+ }
57+ for _ , opt := range opts {
58+ opt (options )
59+ }
60+ return StartProxyWithOptions (cassettePath , recorder .ModeReplayOnly , nil , nil , options )
2861}
2962
3063// StartRecordingProxy starts a proxy that records AI API interactions to a cassette file.
@@ -51,7 +84,7 @@ func StartStreamingRecordingProxy(
5184 e := echo .New ()
5285 e .HideBanner = true
5386 e .HidePort = true
54- e .Any ("/*" , Handle (streamRec , headerUpdater ))
87+ e .Any ("/*" , Handle (streamRec , headerUpdater , nil ))
5588
5689 httpServer := httptest .NewServer (e )
5790
@@ -102,17 +135,23 @@ func APIKeyHeaderUpdater(host string, req *http.Request) {
102135// - mode: recorder mode (ModeReplayOnly, ModeRecordOnce, etc.)
103136// - matcher: custom matcher function (nil uses DefaultMatcher)
104137// - headerUpdater: optional function to update request headers (for recording with real API keys)
138+ // - options: proxy options for stream simulation, etc.
105139func StartProxyWithOptions (
106140 cassettePath string ,
107141 mode recorder.Mode ,
108142 matcher recorder.MatcherFunc ,
109143 headerUpdater func (host string , req * http.Request ),
144+ options * ProxyOptions ,
110145) (string , func () error , error ) {
111146 hasMatcher := matcher != nil
112147 if ! hasMatcher {
113148 matcher = DefaultMatcher (nil )
114149 }
115150
151+ if options == nil {
152+ options = & ProxyOptions {}
153+ }
154+
116155 transport , err := recorder .New (cassettePath ,
117156 recorder .WithMode (mode ),
118157 recorder .WithMatcher (matcher ),
@@ -127,7 +166,7 @@ func StartProxyWithOptions(
127166 e := echo .New ()
128167 e .HideBanner = true
129168 e .HidePort = true
130- e .Any ("/*" , Handle (transport , headerUpdater ))
169+ e .Any ("/*" , Handle (transport , headerUpdater , options ))
131170
132171 httpServer := httptest .NewServer (e )
133172
@@ -227,7 +266,11 @@ func TargetURLForHost(host string) func(req *http.Request) string {
227266
228267// Handle creates an echo handler that proxies requests through the VCR transport.
229268// The headerUpdater is called with the host and request to update headers (e.g., for adding API keys).
230- func Handle (transport http.RoundTripper , headerUpdater func (host string , req * http.Request )) echo.HandlerFunc {
269+ // The options parameter controls streaming simulation behavior.
270+ func Handle (transport http.RoundTripper , headerUpdater func (host string , req * http.Request ), options * ProxyOptions ) echo.HandlerFunc {
271+ if options == nil {
272+ options = & ProxyOptions {}
273+ }
231274 return func (c echo.Context ) error {
232275 ctx := c .Request ().Context ()
233276
@@ -269,6 +312,9 @@ func Handle(transport http.RoundTripper, headerUpdater func(host string, req *ht
269312 c .Response ().WriteHeader (resp .StatusCode )
270313
271314 if IsStreamResponse (resp ) {
315+ if options .SimulateStream {
316+ return SimulatedStreamCopy (c , resp , options .StreamChunkDelay )
317+ }
272318 return StreamCopy (c , resp )
273319 }
274320
@@ -277,6 +323,44 @@ func Handle(transport http.RoundTripper, headerUpdater func(host string, req *ht
277323 }
278324}
279325
326+ // SimulatedStreamCopy copies a streaming SSE response to the client with artificial delays
327+ // between events to simulate real-time streaming behavior.
328+ func SimulatedStreamCopy (c echo.Context , resp * http.Response , chunkDelay time.Duration ) error {
329+ ctx := c .Request ().Context ()
330+ writer := c .Response ().Writer
331+
332+ scanner := bufio .NewScanner (resp .Body )
333+ // SSE events can be large, increase buffer size
334+ scanner .Buffer (make ([]byte , 64 * 1024 ), 1024 * 1024 )
335+
336+ for scanner .Scan () {
337+ select {
338+ case <- ctx .Done ():
339+ slog .WarnContext (ctx , "client disconnected, stop streaming" )
340+ return nil
341+ default :
342+ }
343+
344+ line := scanner .Text ()
345+ // Write the line with newline
346+ if _ , err := writer .Write ([]byte (line + "\n " )); err != nil {
347+ return err
348+ }
349+ c .Response ().Flush ()
350+
351+ // Add delay after data lines (SSE events start with "data:")
352+ if strings .HasPrefix (line , "data:" ) {
353+ select {
354+ case <- ctx .Done ():
355+ return nil
356+ case <- time .After (chunkDelay ):
357+ }
358+ }
359+ }
360+
361+ return scanner .Err ()
362+ }
363+
280364// streamReadResult holds the result of a streaming read operation.
281365type streamReadResult struct {
282366 n int64
@@ -330,6 +414,8 @@ func StreamCopy(c echo.Context, resp *http.Response) error {
330414}
331415
332416// IsStreamResponse checks if the response should be streamed.
417+ // It checks Content-Type headers first, then falls back to peeking at the body
418+ // for SSE format (useful when headers are stripped in recorded cassettes).
333419func IsStreamResponse (resp * http.Response ) bool {
334420 ct := strings .ToLower (resp .Header .Get ("Content-Type" ))
335421 if strings .Contains (ct , "text/event-stream" ) {
@@ -341,7 +427,46 @@ func IsStreamResponse(resp *http.Response) bool {
341427 return true
342428 }
343429
344- return strings .Contains (ct , "application/octet-stream" ) ||
430+ if strings .Contains (ct , "application/octet-stream" ) ||
345431 strings .Contains (ct , "application/x-ndjson" ) ||
346- strings .Contains (ct , "application/stream+json" )
432+ strings .Contains (ct , "application/stream+json" ) {
433+ return true
434+ }
435+
436+ // If no streaming headers detected, peek at the body to check for SSE format.
437+ // This handles cassettes where headers were stripped during recording.
438+ if resp .Body != nil {
439+ // Read enough to detect SSE prefixes ("data:" or "event:")
440+ peek := make ([]byte , 6 )
441+ n , err := resp .Body .Read (peek )
442+ if err == nil || n > 0 {
443+ // Reconstruct the body with the peeked bytes prepended
444+ resp .Body = & peekReader {peeked : peek [:n ], rest : resp .Body }
445+ // Check for SSE format markers
446+ if bytes .HasPrefix (peek [:n ], []byte ("data:" )) || bytes .HasPrefix (peek [:n ], []byte ("event:" )) {
447+ return true
448+ }
449+ }
450+ }
451+
452+ return false
453+ }
454+
455+ // peekReader wraps a reader with already-peeked bytes.
456+ type peekReader struct {
457+ peeked []byte
458+ rest io.ReadCloser
459+ }
460+
461+ func (p * peekReader ) Read (b []byte ) (int , error ) {
462+ if len (p .peeked ) > 0 {
463+ n := copy (b , p .peeked )
464+ p .peeked = p .peeked [n :]
465+ return n , nil
466+ }
467+ return p .rest .Read (b )
468+ }
469+
470+ func (p * peekReader ) Close () error {
471+ return p .rest .Close ()
347472}
0 commit comments