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 api.gleam
Raw

src/anthropic/api.gleam

//// API functions for Anthropic Messages API
////
//// This module provides the core functions for interacting with Claude's
//// Messages API, including message creation and response parsing.
import anthropic/client.{type Client, messages_endpoint}
import anthropic/types/error.{type AnthropicError}
import anthropic/types/message.{
type ContentBlock, type Role, Assistant, TextBlock, ToolUseBlock, User,
}
import anthropic/types/request.{
type CreateMessageRequest, type CreateMessageResponse, type StopReason,
type Usage, EndTurn, MaxTokens, StopSequence, ToolUse,
}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
// =============================================================================
// Message Creation
// =============================================================================
/// Create a message using the Anthropic Messages API
///
/// This function sends a request to Claude and returns the response.
///
/// ## Example
///
/// ```gleam
/// let request = create_request(
/// "claude-sonnet-4-20250514",
/// [user_message("Hello, Claude!")],
/// 1024,
/// )
/// case create_message(client, request) {
/// Ok(response) -> io.println(response_text(response))
/// Error(err) -> io.println(error_to_string(err))
/// }
/// ```
pub fn create_message(
client: Client,
request: CreateMessageRequest,
) -> Result(CreateMessageResponse, AnthropicError) {
// Validate the request
use _ <- result.try(validate_request(request))
// Encode request to JSON
let body = request.request_to_json_string(request)
// Make the API call
use response_body <- result.try(client.post_and_handle(
client,
messages_endpoint,
body,
))
// Parse the response
parse_response(response_body)
}
// =============================================================================
// Request Validation
// =============================================================================
/// Validate a CreateMessageRequest before sending
fn validate_request(
request: CreateMessageRequest,
) -> Result(Nil, AnthropicError) {
// Check for empty messages
case list.is_empty(request.messages) {
True -> Error(error.invalid_request_error("messages list cannot be empty"))
False -> Ok(Nil)
}
|> result.try(fn(_) {
// Check for valid model name
case string.is_empty(string.trim(request.model)) {
True -> Error(error.invalid_request_error("model name cannot be empty"))
False -> Ok(Nil)
}
})
|> result.try(fn(_) {
// Check for positive max_tokens
case request.max_tokens > 0 {
True -> Ok(Nil)
False ->
Error(error.invalid_request_error("max_tokens must be greater than 0"))
}
})
}
// =============================================================================
// Response Parsing
// =============================================================================
/// Parse a response body into CreateMessageResponse
fn parse_response(body: String) -> Result(CreateMessageResponse, AnthropicError) {
case parse_json_to_dynamic(body) {
Ok(dyn) -> decode_response(dyn)
Error(_) -> Error(error.json_error("Failed to parse response JSON"))
}
}
/// Parse a JSON string to Dynamic
@external(erlang, "gleam_json_ffi", "decode")
fn parse_json_to_dynamic(json: String) -> Result(Dynamic, Nil)
/// Decode a dynamic value into CreateMessageResponse
fn decode_response(
value: Dynamic,
) -> Result(CreateMessageResponse, AnthropicError) {
let decoder = response_decoder()
case decode.run(value, decoder) {
Ok(response) -> Ok(response)
Error(errors) ->
Error(error.json_error(
"Failed to decode response: " <> decode_errors_to_string(errors),
))
}
}
/// Convert decode errors to a string
fn decode_errors_to_string(errors: List(decode.DecodeError)) -> String {
errors
|> list.map(fn(e) { "expected " <> e.expected <> ", got " <> e.found })
|> string.join("; ")
}
/// Decoder for CreateMessageResponse
fn response_decoder() -> decode.Decoder(CreateMessageResponse) {
use id <- decode.field("id", decode.string)
use response_type <- decode.field("type", decode.string)
use role_str <- decode.field("role", decode.string)
use content <- decode.field("content", decode.list(content_block_decoder()))
use model <- decode.field("model", decode.string)
use usage <- decode.field("usage", usage_decoder())
use stop_reason <- decode.field(
"stop_reason",
decode.optional(decode.string)
|> decode.map(fn(opt) {
case opt {
Some(s) -> parse_stop_reason(s)
None -> None
}
}),
)
use stop_sequence <- decode.field(
"stop_sequence",
decode.optional(decode.string),
)
let role = parse_role(role_str)
decode.success(request.CreateMessageResponse(
id: id,
response_type: response_type,
role: role,
content: content,
model: model,
stop_reason: stop_reason,
stop_sequence: stop_sequence,
usage: usage,
))
}
/// Decoder for ContentBlock
fn content_block_decoder() -> decode.Decoder(ContentBlock) {
use block_type <- decode.field("type", decode.string)
case block_type {
"text" -> text_block_decoder()
"tool_use" -> tool_use_block_decoder()
_ ->
// Return a placeholder for unknown types
decode.success(TextBlock(
text: "[Unknown content type: " <> block_type <> "]",
))
}
}
/// Decoder for text blocks
fn text_block_decoder() -> decode.Decoder(ContentBlock) {
use text <- decode.field("text", decode.string)
decode.success(TextBlock(text: text))
}
/// Decoder for tool use blocks
fn tool_use_block_decoder() -> decode.Decoder(ContentBlock) {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
// Input is stored as a JSON string - we'll convert the dynamic to string
use input <- decode.field("input", input_decoder())
decode.success(ToolUseBlock(id: id, name: name, input: input))
}
/// Decoder for tool input (converts dynamic to JSON string)
fn input_decoder() -> decode.Decoder(String) {
decode.new_primitive_decoder("Object", fn(data) {
// Convert the dynamic value to a JSON string representation
let json_str = dynamic_to_json_string(data)
Ok(json_str)
})
}
/// Convert a dynamic value to a JSON string using Erlang's built-in json module
fn dynamic_to_json_string(value: Dynamic) -> String {
// Use Erlang's built-in json:encode which returns iodata
let iodata = json_encode(value)
iolist_to_binary(iodata)
}
/// Encode dynamic value to JSON using Erlang's built-in json module (OTP 27+)
@external(erlang, "json", "encode")
fn json_encode(value: Dynamic) -> Dynamic
/// Convert iodata to binary string
@external(erlang, "erlang", "iolist_to_binary")
fn iolist_to_binary(data: Dynamic) -> String
/// Decoder for Usage
fn usage_decoder() -> decode.Decoder(Usage) {
use input_tokens <- decode.field("input_tokens", decode.int)
use output_tokens <- decode.field("output_tokens", decode.int)
decode.success(request.Usage(
input_tokens: input_tokens,
output_tokens: output_tokens,
))
}
/// Parse a role string
fn parse_role(str: String) -> Role {
case str {
"user" -> User
"assistant" -> Assistant
_ -> Assistant
}
}
/// Parse a stop reason string
fn parse_stop_reason(str: String) -> Option(StopReason) {
case str {
"end_turn" -> Some(EndTurn)
"max_tokens" -> Some(MaxTokens)
"stop_sequence" -> Some(StopSequence)
"tool_use" -> Some(ToolUse)
_ -> None
}
}