//// Functions for calling the Anthropic Messages API. //// //// ## Streaming limitation //// //// The streaming functions (`create_stream`, `build_stream_request`) rely on //// `gleam_httpc`, which buffers the **entire** HTTP response before returning. //// This means SSE events are parsed from the complete buffered body rather than //// received incrementally as the API generates tokens. For true incremental //// streaming, a different HTTP client with chunked transfer-encoding support //// would be required. //// //// The streaming functions are still useful for obtaining the granular SSE //// event structure (content block deltas, token-level events, etc.) even //// though all events arrive at once after the response completes. import gleam/erlang/process import gleam/http import gleam/http/request.{type Request} import gleam/httpc import gleam/json import gleam/option.{type Option, None, Some} import gleam/result import claude/client.{type Config} import claude/json/decode import claude/json/encode import claude/streaming.{type StreamEvent} import claude/types/error.{type ApiError} import claude/types/message.{type Message, type MessageParam} import claude/types/tool.{type Registry, type ToolChoice} /// Bundles all parameters for a Messages API request. /// /// Instead of passing 8 separate named arguments to `build_request`, `create`, /// etc., callers construct a `RequestParams` value and pass it as one argument. pub type RequestParams { RequestParams( config: Config, model: Option(String), max_tokens: Option(Int), messages: List(MessageParam), tools: Registry, system: Option(String), tool_choice: Option(ToolChoice), thinking: Option(Int), ) } /// Create a new `RequestParams` with sensible defaults. /// /// Only `config` and `messages` are required. The rest default to `None` /// (or an empty tool registry), which causes the API call to use the /// client-level defaults. pub fn new_params( config config: Config, messages messages: List(MessageParam), ) -> RequestParams { RequestParams( config: config, model: None, max_tokens: None, messages: messages, tools: tool.registry(), system: None, tool_choice: None, thinking: None, ) } /// Build an HTTP request for the Messages API without sending it. /// /// This is useful for testing and inspection. The request can later be sent /// with `gleam/httpc.send`. pub fn build_request( params: RequestParams, ) -> Result(Request(String), ApiError) { let actual_model = option.unwrap(params.model, params.config.default_model) let actual_max_tokens = option.unwrap(params.max_tokens, params.config.default_max_tokens) let body = encode.create_params( model: actual_model, max_tokens: actual_max_tokens, messages: params.messages, tools: params.tools, system: params.system, tool_choice: params.tool_choice, thinking: params.thinking, stream: False, ) |> json.to_string let url = params.config.base_url <> "/v1/messages" request.to(url) |> result.map_error(fn(_) { error.BadRequestError(message: "Invalid base URL: " <> url) }) |> result.map(fn(req) { req |> request.set_method(http.Post) |> request.set_header("content-type", "application/json") |> request.set_header("x-api-key", params.config.api_key) |> request.set_header("anthropic-version", "2023-06-01") |> request.set_body(body) }) } /// Send a Messages API request and return the decoded response. /// /// On success (HTTP 200), decodes the response body into a Message. /// On error status codes, maps to the appropriate ApiError. /// On connection failure, returns a ConnectionError. pub fn create( params: RequestParams, ) -> Result(Message, ApiError) { use req <- result.try(build_request(params)) case httpc.send(req) { Ok(resp) -> case resp.status { 200 -> case decode.message(resp.body) { Ok(msg) -> Ok(msg) Error(_decode_errors) -> Error(error.BadRequestError( message: "Failed to decode response: " <> resp.body, )) } status -> Error(error.from_status(status, resp.body)) } Error(_http_error) -> Error(error.ConnectionError(message: "Failed to connect to API")) } } /// Send a Messages API request with automatic retry for transient errors. /// /// Retries on RateLimitError and ServerError up to `max_retries` times. /// Uses exponential backoff starting at 1 second, doubling each retry. /// If a RateLimitError includes a retry_after value, that is used instead. pub fn create_with_retry( params: RequestParams, max_retries: Int, ) -> Result(Message, ApiError) { retry_loop(params, max_retries, 0, 1000) } fn retry_loop( params: RequestParams, max_retries: Int, attempt: Int, backoff_ms: Int, ) -> Result(Message, ApiError) { case create(params) { Ok(msg) -> Ok(msg) Error(err) -> { case attempt >= max_retries { True -> Error(err) False -> { case err { error.RateLimitError(retry_after: Some(ms), ..) -> { process.sleep(ms) retry_loop(params, max_retries, attempt + 1, backoff_ms * 2) } error.RateLimitError(..) -> { process.sleep(backoff_ms) retry_loop(params, max_retries, attempt + 1, backoff_ms * 2) } error.ServerError(..) -> { process.sleep(backoff_ms) retry_loop(params, max_retries, attempt + 1, backoff_ms * 2) } _ -> Error(err) } } } } } } /// Convenience: send a simple user message with config defaults. pub fn create_simple( config config: Config, message message: String, ) -> Result(Message, ApiError) { create( RequestParams( config: config, model: None, max_tokens: None, messages: [message.new_user(message)], tools: tool.registry(), system: None, tool_choice: None, thinking: None, ), ) } /// Build an HTTP request for the streaming Messages API without sending it. /// /// Identical to `build_request` but includes `"stream": true` in the body. /// /// **Important limitation:** This builds a request intended for streaming, but /// when sent via `gleam_httpc` (as `create_stream` does), the entire response /// will be buffered before returning. The request itself is correct -- the API /// will respond with SSE-formatted data -- but `gleam_httpc` does not support /// reading the response incrementally. For true incremental streaming, a /// different HTTP client with chunked transfer-encoding support would be needed. pub fn build_stream_request( params: RequestParams, ) -> Result(Request(String), ApiError) { let actual_model = option.unwrap(params.model, params.config.default_model) let actual_max_tokens = option.unwrap(params.max_tokens, params.config.default_max_tokens) let body = encode.create_params( model: actual_model, max_tokens: actual_max_tokens, messages: params.messages, tools: params.tools, system: params.system, tool_choice: params.tool_choice, thinking: params.thinking, stream: True, ) |> json.to_string let url = params.config.base_url <> "/v1/messages" request.to(url) |> result.map_error(fn(_) { error.BadRequestError(message: "Invalid base URL: " <> url) }) |> result.map(fn(req) { req |> request.set_method(http.Post) |> request.set_header("content-type", "application/json") |> request.set_header("x-api-key", params.config.api_key) |> request.set_header("anthropic-version", "2023-06-01") |> request.set_body(body) }) } /// Send a streaming Messages API request and return parsed SSE events. /// /// **Important limitation:** This function uses `gleam_httpc` which buffers /// the complete HTTP response before returning. The SSE events are parsed /// from the buffered response body, so events are NOT received incrementally /// as they are generated by the API. For true streaming, a different HTTP /// client with chunked transfer-encoding support would be needed. /// /// This function is still useful for obtaining the granular SSE event /// structure (e.g., content block deltas, token-level events) even though /// the events arrive all at once after the full response is received. /// /// On success (HTTP 200), parses the body as SSE and returns events. /// On error status codes, maps to the appropriate ApiError. /// On connection failure, returns a ConnectionError. pub fn create_stream( params: RequestParams, ) -> Result(List(StreamEvent), ApiError) { use req <- result.try(build_stream_request(params)) case httpc.send(req) { Ok(resp) -> case resp.status { 200 -> Ok(streaming.parse_sse(resp.body)) status -> Error(error.from_status(status, resp.body)) } Error(_http_error) -> Error(error.ConnectionError(message: "Failed to connect to API")) } }