Packages

A well-typed, idiomatic Gleam client for Anthropic's Claude API with streaming support and tool use

Current section

Files

Jump to
anthropic_gleam src anthropic streaming handler.gleam
Raw

src/anthropic/streaming/handler.gleam

//// Streaming handler for Anthropic API
////
//// This module provides sans-io functions for parsing Server-Sent Events (SSE)
//// from the Anthropic streaming API. The design follows the sans-io pattern,
//// allowing you to use any HTTP client that supports streaming.
////
//// ## Recommended: Use `api.chat_stream`
////
//// For most use cases, use `api.chat_stream` from the main API module:
////
//// ```gleam
//// import anthropic/api
//// import anthropic/client
//// import anthropic/types/request
//// import anthropic/types/message.{user_message}
////
//// let assert Ok(client) = client.init()
//// let req = request.new("claude-sonnet-4-20250514", [user_message("Hello!")], 1024)
////
//// case api.chat_stream(client, req) {
//// Ok(result) -> io.println(api.stream_text(result))
//// Error(err) -> handle_error(err)
//// }
//// ```
////
//// ## True Real-Time Streaming (Sans-IO)
////
//// For true real-time streaming where you process events as they arrive,
//// use the incremental parsing functions with your own streaming HTTP client:
////
//// ```gleam
//// import anthropic/http
//// import anthropic/streaming/handler.{
//// new_streaming_state, process_chunk, finalize_stream
//// }
////
//// // 1. Build the streaming request
//// let http_request = http.build_streaming_request(api_key, base_url, request)
////
//// // 2. Start streaming with your HTTP client that supports chunked responses
//// // (e.g., using Erlang's httpc with stream option, or any other client)
////
//// // 3. Initialize streaming state
//// let state = new_streaming_state()
////
//// // 4. As each chunk arrives from your HTTP client, process it:
//// let #(events, new_state) = process_chunk(state, chunk)
////
//// // 5. Handle events in real-time as they arrive
//// list.each(events, fn(event) {
//// case handler.get_event_text(event) {
//// Ok(text) -> io.print(text) // Print immediately!
//// Error(_) -> Nil
//// }
//// })
////
//// // 6. Continue with new_state for next chunk...
////
//// // 7. When stream ends, finalize to get any remaining events
//// let final_events = finalize_stream(state)
//// ```
////
//// ## Batch Mode (Deprecated)
////
//// The batch functions `stream_message` and `stream_message_with_callback` in
//// this module are deprecated. Use `api.chat_stream` and
//// `api.chat_stream_with_callback` instead for a unified API.
////
//// **Note**: Batch mode waits for the complete response before returning.
//// Use sans-io incremental parsing for true real-time streaming.
import anthropic/client.{type Client}
import anthropic/config.{api_key_to_string}
import anthropic/error.{type AnthropicError}
import anthropic/http
import anthropic/internal/sse
import anthropic/request as api_request
import anthropic/streaming.{type StreamEvent}
import anthropic/streaming/decoder
import gleam/http as gleam_http
import gleam/http/request
import gleam/http/response.{type Response}
import gleam/httpc
import gleam/list
import gleam/option.{type Option, None}
import gleam/result
import gleam/string
// =============================================================================
// Types
// =============================================================================
/// State for incremental streaming (sans-io)
///
/// Use this to track parsing state across multiple chunks when implementing
/// real-time streaming with your own HTTP client.
pub type StreamingState {
StreamingState(
/// Internal SSE parser state
sse_state: sse.SseParserState,
/// Accumulated events (for building final result)
events: List(StreamEvent),
/// Whether the stream has completed
completed: Bool,
/// Error if one occurred
error: Option(StreamError),
)
}
/// Result of streaming a message (batch mode)
pub type StreamResult {
StreamResult(
/// List of parsed streaming events
events: List(StreamEvent),
)
}
/// Error during streaming
pub type StreamError {
/// HTTP error during request
HttpError(error: AnthropicError)
/// Error parsing SSE data
SseParseError(message: String)
/// Error decoding event JSON
EventDecodeError(message: String)
/// API returned an error response
ApiError(status: Int, body: String)
}
/// Callback function type for processing events as they are parsed
pub type EventCallback =
fn(StreamEvent) -> Nil
// =============================================================================
// Sans-IO Incremental Streaming (True Real-Time)
// =============================================================================
/// Create a new streaming state for incremental parsing
///
/// Use this when implementing true real-time streaming with your own HTTP client.
///
/// ## Example
///
/// ```gleam
/// let state = new_streaming_state()
///
/// // As chunks arrive from your streaming HTTP client:
/// let #(events, new_state) = process_chunk(state, chunk)
/// list.each(events, handle_event_immediately)
/// ```
pub fn new_streaming_state() -> StreamingState {
StreamingState(
sse_state: sse.new_parser_state(),
events: [],
completed: False,
error: None,
)
}
/// Process a chunk of SSE data and return parsed events
///
/// This is the core function for real-time streaming. Call it each time
/// you receive a chunk from your streaming HTTP client.
///
/// Returns a tuple of:
/// - List of events parsed from this chunk (process these immediately!)
/// - Updated state to use for the next chunk
///
/// ## Example
///
/// ```gleam
/// // In your streaming HTTP callback:
/// fn on_chunk(state: StreamingState, chunk: String) -> StreamingState {
/// let #(events, new_state) = process_chunk(state, chunk)
///
/// // Process events immediately as they arrive
/// list.each(events, fn(event) {
/// case get_event_text(event) {
/// Ok(text) -> io.print(text) // Real-time output!
/// Error(_) -> Nil
/// }
/// })
///
/// new_state
/// }
/// ```
pub fn process_chunk(
state: StreamingState,
chunk: String,
) -> #(List(StreamEvent), StreamingState) {
let parse_result = sse.parse_chunk(state.sse_state, chunk)
let events =
parse_result.events
|> list.filter_map(fn(sse_event) {
case decoder.decode_event(sse_event) {
Ok(event) -> Ok(event)
Error(_) -> Error(Nil)
}
})
// Check if stream completed
let completed =
list.any(events, fn(event) {
case event {
streaming.MessageStopEvent -> True
_ -> False
}
})
// Check for errors
let error =
list.find_map(events, fn(event) {
case event {
streaming.ErrorEvent(err) ->
Ok(SseParseError(message: err.error_type <> ": " <> err.message))
_ -> Error(Nil)
}
})
|> option.from_result
let new_state =
StreamingState(
sse_state: parse_result.state,
events: list.append(state.events, events),
completed: completed,
error: error,
)
#(events, new_state)
}
/// Finalize the stream and get any remaining events
///
/// Call this when your HTTP client signals the stream has ended.
/// Returns any events that were buffered but not yet emitted.
pub fn finalize_stream(state: StreamingState) -> List(StreamEvent) {
case sse.flush(state.sse_state) {
Ok(sse_event) -> {
case decoder.decode_event(sse_event) {
Ok(event) -> [event]
Error(_) -> []
}
}
Error(_) -> []
}
}
/// Check if the stream has completed successfully
pub fn is_stream_complete(state: StreamingState) -> Bool {
state.completed
}
/// Check if the stream encountered an error
pub fn has_stream_error(state: StreamingState) -> Bool {
option.is_some(state.error)
}
/// Get the error from the stream if one occurred
pub fn get_stream_error(state: StreamingState) -> Option(StreamError) {
state.error
}
/// Get all accumulated events from the streaming state
pub fn get_accumulated_events(state: StreamingState) -> List(StreamEvent) {
state.events
}
/// Build a StreamResult from the final streaming state
pub fn build_stream_result(state: StreamingState) -> StreamResult {
StreamResult(events: state.events)
}
// =============================================================================
// Sans-IO Batch Parsing (Parse Complete Response)
// =============================================================================
/// Parse an HTTP response body containing SSE events into StreamResult
///
/// This is for batch parsing of a complete response. For real-time streaming,
/// use `new_streaming_state` + `process_chunk` instead.
///
/// ## Example
///
/// ```gleam
/// let http_response = HttpResponse(status: 200, headers: [], body: sse_body)
/// case parse_streaming_response(http_response) {
/// Ok(result) -> get_full_text(result.events)
/// Error(err) -> handle_error(err)
/// }
/// ```
pub fn parse_streaming_response(
response: http.HttpResponse,
) -> Result(StreamResult, StreamError) {
case response.status {
200 -> parse_sse_body(response.body)
status -> Error(ApiError(status: status, body: response.body))
}
}
/// Parse SSE body text into streaming events (batch mode)
///
/// Use this for batch parsing when you have the complete SSE text.
/// For real-time streaming, use `process_chunk` instead.
pub fn parse_sse_body(body: String) -> Result(StreamResult, StreamError) {
let state = new_streaming_state()
let #(events, final_state) = process_chunk(state, body)
let remaining = finalize_stream(final_state)
Ok(StreamResult(events: list.append(events, remaining)))
}
/// Parse a single SSE chunk and return events plus updated SSE state
///
/// Lower-level function that works directly with SSE parser state.
/// Consider using `process_chunk` with `StreamingState` for a higher-level API.
pub fn parse_sse_chunk(
state: sse.SseParserState,
chunk: String,
) -> #(List(StreamEvent), sse.SseParserState) {
let parse_result = sse.parse_chunk(state, chunk)
let events =
parse_result.events
|> list.filter_map(fn(sse_event) {
case decoder.decode_event(sse_event) {
Ok(event) -> Ok(event)
Error(_) -> Error(Nil)
}
})
#(events, parse_result.state)
}
// =============================================================================
// HTTP-Integrated Functions (Batch Mode - Uses gleam_httpc)
// =============================================================================
/// Stream a message request and return all events (batch mode)
///
/// @deprecated Use `api.chat_stream` instead for a unified streaming API
///
/// **Note**: This function collects ALL events before returning. It does NOT
/// provide true real-time streaming. For real-time streaming, use the sans-io
/// functions (`new_streaming_state`, `process_chunk`) with a streaming HTTP client.
///
/// ## Example
///
/// ```gleam
/// // Prefer using api.chat_stream instead:
/// case api.chat_stream(client, request) {
/// Ok(result) -> api.stream_text(result)
/// Error(err) -> handle_error(err)
/// }
/// ```
@deprecated("Use api.chat_stream instead")
pub fn stream_message(
api_client: Client,
message_request: api_request.CreateMessageRequest,
) -> Result(StreamResult, StreamError) {
// Ensure streaming is enabled
let streaming_request = api_request.with_stream(message_request, True)
// Encode request to JSON
let body = api_request.request_to_json_string(streaming_request)
// Make the HTTP request
use http_response <- result.try(
make_streaming_request(api_client, body)
|> result.map_error(fn(err) { HttpError(error: err) }),
)
// Check for error status
case http_response.status {
200 -> parse_sse_body(http_response.body)
status -> Error(ApiError(status: status, body: http_response.body))
}
}
/// Stream a message request with a callback for each event (batch mode)
///
/// @deprecated Use `api.chat_stream_with_callback` instead for a unified streaming API
///
/// **Note**: Despite the callback, this function collects ALL events before
/// calling the callbacks. It does NOT provide true real-time streaming.
/// For real-time streaming, use the sans-io functions with a streaming HTTP client.
///
/// ## Example
///
/// ```gleam
/// // Prefer using api.chat_stream_with_callback instead:
/// api.chat_stream_with_callback(client, request, fn(event) {
/// case api.event_text(event) {
/// Ok(text) -> io.print(text)
/// Error(_) -> Nil
/// }
/// })
/// ```
@deprecated("Use api.chat_stream_with_callback instead")
pub fn stream_message_with_callback(
api_client: Client,
message_request: api_request.CreateMessageRequest,
callback: EventCallback,
) -> Result(StreamResult, StreamError) {
use stream_result <- result.try(stream_message(api_client, message_request))
// Call callback for each event (after all collected)
list.each(stream_result.events, callback)
Ok(stream_result)
}
// =============================================================================
// Internal Functions
// =============================================================================
/// Make a streaming HTTP request using gleam_httpc
fn make_streaming_request(
api_client: Client,
body: String,
) -> Result(Response(String), AnthropicError) {
let base_url = api_client.config.base_url
let full_url = base_url <> client.messages_endpoint
// Parse the URL and create request
use req <- result.try(
request.to(full_url)
|> result.map_error(fn(_) {
error.config_error("Invalid URL: " <> full_url)
}),
)
// Set headers and body for streaming
let req =
req
|> request.set_method(gleam_http.Post)
|> request.set_header("content-type", "application/json")
|> request.set_header(
"x-api-key",
api_key_to_string(api_client.config.api_key),
)
|> request.set_header("anthropic-version", client.api_version)
|> request.set_header("accept", "text/event-stream")
|> request.set_body(body)
// Make the request with extended timeout for streaming
httpc.configure()
|> httpc.timeout(api_client.config.timeout_ms)
|> httpc.dispatch(req)
|> result.map_error(fn(err) { http_error_to_anthropic_error(err) })
}
/// Convert httpc error to AnthropicError
fn http_error_to_anthropic_error(err: httpc.HttpError) -> AnthropicError {
case err {
httpc.InvalidUtf8Response -> error.http_error("Invalid UTF-8 in response")
httpc.FailedToConnect(ip4, ip6) ->
error.network_error(
"Failed to connect to server (IPv4: "
<> connect_error_to_string(ip4)
<> ", IPv6: "
<> connect_error_to_string(ip6)
<> ")",
)
httpc.ResponseTimeout -> error.timeout_error(0)
}
}
/// Convert ConnectError to string
fn connect_error_to_string(err: httpc.ConnectError) -> String {
case err {
httpc.Posix(code) -> "POSIX error: " <> code
httpc.TlsAlert(code, detail) -> "TLS alert " <> code <> ": " <> detail
}
}
// =============================================================================
// Event Processing Utilities (Sans-IO - work with any event list)
// =============================================================================
/// Extract text from a single event if it contains text content
///
/// Useful for real-time streaming to print text as it arrives.
///
/// ## Example
///
/// ```gleam
/// list.each(events, fn(event) {
/// case get_event_text(event) {
/// Ok(text) -> io.print(text)
/// Error(_) -> Nil
/// }
/// })
/// ```
pub fn get_event_text(event: StreamEvent) -> Result(String, Nil) {
case event {
streaming.ContentBlockDeltaEventVariant(delta_event) -> {
case delta_event.delta {
streaming.TextContentDelta(text_delta) -> Ok(text_delta.text)
_ -> Error(Nil)
}
}
_ -> Error(Nil)
}
}
/// Filter events to only text deltas
pub fn get_text_deltas(events: List(StreamEvent)) -> List(String) {
events
|> list.filter_map(get_event_text)
}
/// Get the full text from a stream of events
pub fn get_full_text(events: List(StreamEvent)) -> String {
get_text_deltas(events)
|> string.join("")
}
/// Get the message ID from events
pub fn get_message_id(events: List(StreamEvent)) -> Result(String, Nil) {
events
|> list.find_map(fn(event) {
case event {
streaming.MessageStartEvent(msg) -> Ok(msg.id)
_ -> Error(Nil)
}
})
}
/// Get the model from events
pub fn get_model(events: List(StreamEvent)) -> Result(String, Nil) {
events
|> list.find_map(fn(event) {
case event {
streaming.MessageStartEvent(msg) -> Ok(msg.model)
_ -> Error(Nil)
}
})
}
/// Check if stream completed successfully (from event list)
pub fn is_complete(events: List(StreamEvent)) -> Bool {
list.any(events, fn(event) {
case event {
streaming.MessageStopEvent -> True
_ -> False
}
})
}
/// Check if stream ended with an error (from event list)
pub fn has_error(events: List(StreamEvent)) -> Bool {
list.any(events, fn(event) {
case event {
streaming.ErrorEvent(_) -> True
_ -> False
}
})
}
/// Get error from events if present
pub fn get_error(
events: List(StreamEvent),
) -> Result(streaming.StreamError, Nil) {
events
|> list.find_map(fn(event) {
case event {
streaming.ErrorEvent(err) -> Ok(err)
_ -> Error(Nil)
}
})
}