Packages

Gleam SDK for the Anthropic Claude API with agentic tool-use loop and BEAM concurrency

Current section

Files

Jump to
claude_gleam src claude messages.gleam
Raw

src/claude/messages.gleam

//// 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"))
}
}