Skip to content
skyl

Client.Stream

Returns events as the model produces them. Only the handshake is retried.

Stream runs a request and delivers incremental events. It returns as soon as the provider accepts the request — before any token exists — and the returned Stream is consumed afterwards.

Reference#

func (c *Client) Stream(ctx context.Context, req *Request) (Stream, error)

Parameters

  • ctx — bounds the entire stream, not just the handshake. The reader is bound to it, so cancelling terminates the stream.
  • req — the request. Validated before anything is sent.

Returns

A Stream and a nil error once the provider has accepted the request. On failure, a nil stream and a classified error.

The caller must close the returned stream.

Caveats

  • Only the handshake is retried. Once bytes are flowing, a mid-stream failure surfaces through Stream.Err rather than being retried — replaying a partially consumed response would duplicate output the caller has already seen.
  • WithTimeout is not applied. The stream outlives the call, so a per-attempt deadline would kill a working stream. ctx is the only bound.
  • The stream hook event fires at the handshake and carries no usage, because no token exists yet. Usage arrives on stream_end.
  • A Stream is not safe for concurrent use.

Usage#

The standard loop

goCompiles
stream, err := client.Stream(ctx, req)
if err != nil {
	return err
}
defer stream.Close()

for stream.Next() {
	if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
		fmt.Print(ev.Text)
	}
}

// Next returning false means the stream finished OR failed. Only Err says which.
return stream.Err()
stream, err := client.Stream(ctx, req)
if err != nil {
	return err
}
defer stream.Close()

for stream.Next() {
	if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
		fmt.Print(ev.Text)
	}
}

// Next returning false means the stream finished OR failed. Only Err says which.
return stream.Err()

Handling every event type

var text strings.Builder
var calls []skyl.ToolCall
var usage skyl.Usage

for stream.Next() {
	switch ev := stream.Event(); ev.Type {
	case skyl.EventTextDelta:
		text.WriteString(ev.Text)
	case skyl.EventThinkingDelta:
		// Only provider/anthropic ever emits this.
	case skyl.EventToolCall:
		if ev.ToolCall != nil {
			calls = append(calls, *ev.ToolCall)
		}
	case skyl.EventDone:
		if ev.Usage != nil {
			usage = *ev.Usage
		}
	}
}
if err := stream.Err(); err != nil {
	return err
}
var text strings.Builder
var calls []skyl.ToolCall
var usage skyl.Usage

for stream.Next() {
	switch ev := stream.Event(); ev.Type {
	case skyl.EventTextDelta:
		text.WriteString(ev.Text)
	case skyl.EventThinkingDelta:
		// Only provider/anthropic ever emits this.
	case skyl.EventToolCall:
		if ev.ToolCall != nil {
			calls = append(calls, *ev.ToolCall)
		}
	case skyl.EventDone:
		if ev.Usage != nil {
			usage = *ev.Usage
		}
	}
}
if err := stream.Err(); err != nil {
	return err
}

Bounding a stream's lifetime

goCompiles
// WithTimeout does not apply here, so set a deadline yourself if you want one.
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()

stream, err := client.Stream(ctx, req)
// WithTimeout does not apply here, so set a deadline yourself if you want one.
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()

stream, err := client.Stream(ctx, req)

In an HTTP handler

goCompiles
func (h *Handler) Chat(w http.ResponseWriter, r *http.Request) {
	// r.Context() is cancelled on client disconnect, which propagates upstream —
	// a client hanging up must not leave a paid request running.
	stream, err := h.client.Stream(r.Context(), req)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	defer stream.Close()

	flusher, _ := w.(http.Flusher)
	for stream.Next() {
		if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
			fmt.Fprintf(w, "data: %s\n\n", ev.Text)
			if flusher != nil {
				flusher.Flush()
			}
		}
	}
}
func (h *Handler) Chat(w http.ResponseWriter, r *http.Request) {
	// r.Context() is cancelled on client disconnect, which propagates upstream —
	// a client hanging up must not leave a paid request running.
	stream, err := h.client.Stream(r.Context(), req)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	defer stream.Close()

	flusher, _ := w.(http.Flusher)
	for stream.Next() {
		if ev := stream.Event(); ev.Type == skyl.EventTextDelta {
			fmt.Fprintf(w, "data: %s\n\n", ev.Text)
			if flusher != nil {
				flusher.Flush()
			}
		}
	}
}

Troubleshooting#

Answers are occasionally cut short and I see no error

You are not checking stream.Err(). A connection dropped mid-generation reaches EOF with no reader error, so without the check a truncated response is indistinguishable from a complete one.

Reproduce it deliberately with the sandbox model sandbox-stream-truncate.

My stream dies after exactly 30 seconds

You set http.Client.Timeout on a custom HTTP client. That bounds the entire request including reading the body, which for a stream is the whole generation.

Remove it and bound the transport phases instead — DialContext, TLSHandshakeTimeout, ResponseHeaderTimeout.

Streaming reports zero tokens but Complete reports them fine

On OpenAI and openaicompat, streaming usage requires the host to honour stream_options.include_usage. Many compatible hosts do not, and report zero silently.

I want to retry a stream that failed halfway

skyl will not, because output may already have been shown. Retry at your own level, where you know whether anything was displayed — safe in a batch job that buffers, wrong in a UI.

Edit this page on GitHub