Packages
A well-typed, idiomatic Gleam client for Anthropic's Claude API with streaming support and tool use
Current section
Files
Jump to
Current section
Files
src/anthropic/validation.gleam
//// Request validation for Anthropic API
////
//// This module provides comprehensive validation for API requests before
//// they are sent, catching common errors early and providing helpful messages.
////
//// ## Validation Checks
////
//// - Non-empty messages list
//// - Valid model name format
//// - max_tokens within bounds (1 to 4096 for most models)
//// - Valid temperature range (0.0 to 1.0)
//// - Valid top_p range (0.0 to 1.0)
//// - Valid top_k (positive integer)
//// - Tool definition validation
////
//// ## Example
////
//// ```gleam
//// let request = create_request("claude-sonnet-4-20250514", messages, 1024)
////
//// case validate_request(request) {
//// Ok(_) -> api.create_message(client, request)
//// Error(errors) -> {
//// io.println("Validation failed:")
//// list.each(errors, fn(e) { io.println(" - " <> e.message) })
//// }
//// }
//// ```
import anthropic/types/error.{type AnthropicError}
import anthropic/types/message.{type ContentBlock, type Message, TextBlock}
import anthropic/types/request.{type CreateMessageRequest}
import anthropic/types/tool.{type Tool}
import gleam/float
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
// =============================================================================
// Validation Error Types
// =============================================================================
/// Field that failed validation
pub type ValidationField {
MessagesField
ModelField
MaxTokensField
TemperatureField
TopPField
TopKField
SystemField
StopSequencesField
ToolsField
ToolChoiceField
}
/// Convert a ValidationField to a string
pub fn field_to_string(field: ValidationField) -> String {
case field {
MessagesField -> "messages"
ModelField -> "model"
MaxTokensField -> "max_tokens"
TemperatureField -> "temperature"
TopPField -> "top_p"
TopKField -> "top_k"
SystemField -> "system"
StopSequencesField -> "stop_sequences"
ToolsField -> "tools"
ToolChoiceField -> "tool_choice"
}
}
/// A single validation error
pub type ValidationError {
ValidationError(
/// The field that failed validation
field: ValidationField,
/// Human-readable error message
message: String,
/// Optional value that caused the error
value: Option(String),
)
}
/// Create a validation error
pub fn validation_error(
field: ValidationField,
message: String,
) -> ValidationError {
ValidationError(field: field, message: message, value: None)
}
/// Create a validation error with value context
pub fn validation_error_with_value(
field: ValidationField,
message: String,
value: String,
) -> ValidationError {
ValidationError(field: field, message: message, value: Some(value))
}
/// Convert a ValidationError to a string
pub fn error_to_string(error: ValidationError) -> String {
let base = field_to_string(error.field) <> ": " <> error.message
case error.value {
Some(v) -> base <> " (got: " <> v <> ")"
None -> base
}
}
/// Convert a list of validation errors to a combined string
pub fn errors_to_string(errors: List(ValidationError)) -> String {
errors
|> list.map(error_to_string)
|> string.join("; ")
}
// =============================================================================
// Validation Limits
// =============================================================================
/// Model-specific token limits
pub type ModelLimits {
ModelLimits(
/// Minimum max_tokens value
min_tokens: Int,
/// Maximum max_tokens value
max_tokens: Int,
/// Maximum context window
context_window: Int,
)
}
/// Get limits for a model
pub fn get_model_limits(model: String) -> ModelLimits {
// Default limits for Claude 3.x and newer models
case string.contains(model, "claude-3-opus") {
True ->
ModelLimits(min_tokens: 1, max_tokens: 4096, context_window: 200_000)
False ->
case
string.contains(model, "claude-3-sonnet")
|| string.contains(model, "claude-sonnet")
{
True ->
ModelLimits(min_tokens: 1, max_tokens: 8192, context_window: 200_000)
False ->
case
string.contains(model, "claude-3-haiku")
|| string.contains(model, "claude-3-5-haiku")
{
True ->
ModelLimits(
min_tokens: 1,
max_tokens: 8192,
context_window: 200_000,
)
False ->
// Default limits
ModelLimits(
min_tokens: 1,
max_tokens: 8192,
context_window: 200_000,
)
}
}
}
}
// =============================================================================
// Individual Validators
// =============================================================================
/// Validate the messages list
pub fn validate_messages(
messages: List(Message),
) -> Result(Nil, List(ValidationError)) {
let errors = []
// Check for empty messages
let errors = case list.is_empty(messages) {
True -> [
validation_error(MessagesField, "messages list cannot be empty"),
..errors
]
False -> errors
}
// Check for empty content in messages
let errors =
list.index_fold(messages, errors, fn(acc, msg, idx) {
case list.is_empty(msg.content) {
True -> [
validation_error_with_value(
MessagesField,
"message content cannot be empty",
"message[" <> int.to_string(idx) <> "]",
),
..acc
]
False -> acc
}
})
// Check for valid alternating pattern (user/assistant)
let errors = case validate_message_alternation(messages) {
Ok(_) -> errors
Error(err) -> [err, ..errors]
}
case errors {
[] -> Ok(Nil)
errs -> Error(list.reverse(errs))
}
}
/// Validate that messages alternate correctly
fn validate_message_alternation(
messages: List(Message),
) -> Result(Nil, ValidationError) {
case messages {
[] -> Ok(Nil)
[first, ..] ->
case first.role {
message.User -> check_alternation(messages, message.User)
message.Assistant ->
Error(validation_error(
MessagesField,
"conversation must start with a user message",
))
}
}
}
fn check_alternation(
messages: List(Message),
expected_role: message.Role,
) -> Result(Nil, ValidationError) {
case messages {
[] -> Ok(Nil)
[msg, ..rest] ->
case msg.role == expected_role {
True -> {
let next_role = case expected_role {
message.User -> message.Assistant
message.Assistant -> message.User
}
check_alternation(rest, next_role)
}
False ->
Error(validation_error(
MessagesField,
"messages must alternate between user and assistant roles",
))
}
}
}
/// Validate the model name
pub fn validate_model(model: String) -> Result(Nil, List(ValidationError)) {
let trimmed = string.trim(model)
let errors = []
// Check for empty model
let errors = case string.is_empty(trimmed) {
True -> [
validation_error(ModelField, "model name cannot be empty"),
..errors
]
False -> errors
}
// Check model name format (alphanumeric, hyphens, periods)
let errors = case string.is_empty(trimmed) {
True -> errors
False ->
case is_valid_model_name(trimmed) {
True -> errors
False -> [
validation_error_with_value(
ModelField,
"model name contains invalid characters",
trimmed,
),
..errors
]
}
}
case errors {
[] -> Ok(Nil)
errs -> Error(list.reverse(errs))
}
}
/// Check if a model name has valid format
fn is_valid_model_name(name: String) -> Bool {
// Valid model names: alphanumeric, hyphens, periods, underscores
name
|> string.to_graphemes
|> list.all(fn(char) {
is_alphanumeric(char) || char == "." || char == "-" || char == "_"
})
}
fn is_alphanumeric(char: String) -> Bool {
let lower = "abcdefghijklmnopqrstuvwxyz"
let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
let digits = "0123456789"
string.contains(lower, char)
|| string.contains(upper, char)
|| string.contains(digits, char)
}
/// Validate max_tokens
pub fn validate_max_tokens(
max_tokens: Int,
model: String,
) -> Result(Nil, List(ValidationError)) {
let limits = get_model_limits(model)
let errors = []
let errors = case max_tokens < limits.min_tokens {
True -> [
validation_error_with_value(
MaxTokensField,
"max_tokens must be at least " <> int.to_string(limits.min_tokens),
int.to_string(max_tokens),
),
..errors
]
False -> errors
}
let errors = case max_tokens > limits.max_tokens {
True -> [
validation_error_with_value(
MaxTokensField,
"max_tokens cannot exceed " <> int.to_string(limits.max_tokens),
int.to_string(max_tokens),
),
..errors
]
False -> errors
}
case errors {
[] -> Ok(Nil)
errs -> Error(list.reverse(errs))
}
}
/// Validate temperature
pub fn validate_temperature(
temperature: Option(Float),
) -> Result(Nil, List(ValidationError)) {
case temperature {
None -> Ok(Nil)
Some(t) ->
case t >=. 0.0 && t <=. 1.0 {
True -> Ok(Nil)
False ->
Error([
validation_error_with_value(
TemperatureField,
"temperature must be between 0.0 and 1.0",
float.to_string(t),
),
])
}
}
}
/// Validate top_p
pub fn validate_top_p(
top_p: Option(Float),
) -> Result(Nil, List(ValidationError)) {
case top_p {
None -> Ok(Nil)
Some(p) ->
case p >=. 0.0 && p <=. 1.0 {
True -> Ok(Nil)
False ->
Error([
validation_error_with_value(
TopPField,
"top_p must be between 0.0 and 1.0",
float.to_string(p),
),
])
}
}
}
/// Validate top_k
pub fn validate_top_k(top_k: Option(Int)) -> Result(Nil, List(ValidationError)) {
case top_k {
None -> Ok(Nil)
Some(k) ->
case k > 0 {
True -> Ok(Nil)
False ->
Error([
validation_error_with_value(
TopKField,
"top_k must be a positive integer",
int.to_string(k),
),
])
}
}
}
/// Validate system prompt
pub fn validate_system(
system: Option(String),
) -> Result(Nil, List(ValidationError)) {
case system {
None -> Ok(Nil)
Some(s) ->
case string.is_empty(string.trim(s)) {
True ->
Error([
validation_error(
SystemField,
"system prompt cannot be empty when provided",
),
])
False -> Ok(Nil)
}
}
}
/// Validate stop sequences
pub fn validate_stop_sequences(
sequences: Option(List(String)),
) -> Result(Nil, List(ValidationError)) {
case sequences {
None -> Ok(Nil)
Some(seqs) -> {
let errors =
seqs
|> list.index_fold([], fn(acc, seq, idx) {
case string.is_empty(seq) {
True -> [
validation_error_with_value(
StopSequencesField,
"stop sequence cannot be empty",
"stop_sequences[" <> int.to_string(idx) <> "]",
),
..acc
]
False -> acc
}
})
case errors {
[] -> Ok(Nil)
errs -> Error(list.reverse(errs))
}
}
}
}
/// Validate tool definitions
pub fn validate_tools(
tools: Option(List(Tool)),
) -> Result(Nil, List(ValidationError)) {
case tools {
None -> Ok(Nil)
Some(tool_list) -> {
let errors =
tool_list
|> list.index_fold([], fn(acc, t, idx) {
validate_single_tool(t, idx)
|> list.append(acc)
})
case errors {
[] -> Ok(Nil)
errs -> Error(list.reverse(errs))
}
}
}
}
/// Validate a single tool definition
fn validate_single_tool(t: Tool, index: Int) -> List(ValidationError) {
let prefix = "tools[" <> int.to_string(index) <> "]"
let errors = []
// Validate tool name
let errors = case string.is_empty(string.trim(t.name)) {
True -> [
validation_error_with_value(
ToolsField,
"tool name cannot be empty",
prefix,
),
..errors
]
False -> errors
}
// Validate tool name format (alphanumeric, underscores, hyphens, max 64 chars)
let errors = case is_valid_tool_name(t.name) {
True -> errors
False -> [
validation_error_with_value(
ToolsField,
"tool name must match ^[a-zA-Z0-9_-]{1,64}$",
prefix <> ".name=" <> t.name,
),
..errors
]
}
errors
}
/// Check if a tool name is valid
fn is_valid_tool_name(name: String) -> Bool {
let len = string.length(name)
// Must be 1-64 characters, alphanumeric, underscores, hyphens
len >= 1
&& len <= 64
&& {
name
|> string.to_graphemes
|> list.all(fn(char) { is_alphanumeric(char) || char == "_" || char == "-" })
}
}
// =============================================================================
// Complete Request Validation
// =============================================================================
/// Validate a complete CreateMessageRequest
///
/// Returns Ok(Nil) if the request is valid, or Error with a list of all
/// validation errors found.
pub fn validate_request(
request: CreateMessageRequest,
) -> Result(Nil, List(ValidationError)) {
// Collect all validation errors
let errors =
[]
|> collect_errors(validate_messages(request.messages))
|> collect_errors(validate_model(request.model))
|> collect_errors(validate_max_tokens(request.max_tokens, request.model))
|> collect_errors(validate_temperature(request.temperature))
|> collect_errors(validate_top_p(request.top_p))
|> collect_errors(validate_top_k(request.top_k))
|> collect_errors(validate_system(request.system))
|> collect_errors(validate_stop_sequences(request.stop_sequences))
|> collect_errors(validate_tools(request.tools))
case errors {
[] -> Ok(Nil)
errs -> Error(list.reverse(errs))
}
}
/// Helper to collect errors from a validation result
fn collect_errors(
existing: List(ValidationError),
result: Result(Nil, List(ValidationError)),
) -> List(ValidationError) {
case result {
Ok(_) -> existing
Error(new_errors) -> list.append(new_errors, existing)
}
}
// =============================================================================
// Quick Validation Helpers
// =============================================================================
/// Quick check if a request is valid (returns Bool)
pub fn is_valid(request: CreateMessageRequest) -> Bool {
case validate_request(request) {
Ok(_) -> True
Error(_) -> False
}
}
/// Validate a request and return an AnthropicError if invalid
pub fn validate_or_error(
request: CreateMessageRequest,
) -> Result(CreateMessageRequest, AnthropicError) {
case validate_request(request) {
Ok(_) -> Ok(request)
Error(errors) ->
Error(error.invalid_request_error(
"Request validation failed: " <> errors_to_string(errors),
))
}
}
// =============================================================================
// Content Validation
// =============================================================================
/// Validate that content blocks are non-empty
pub fn validate_content_blocks(
blocks: List(ContentBlock),
) -> Result(Nil, List(ValidationError)) {
case list.is_empty(blocks) {
True ->
Error([validation_error(MessagesField, "content blocks cannot be empty")])
False -> {
let errors =
blocks
|> list.index_fold([], fn(acc, block, idx) {
case block {
TextBlock(text: text) ->
case string.is_empty(string.trim(text)) {
True -> [
validation_error_with_value(
MessagesField,
"text content cannot be empty",
"content[" <> int.to_string(idx) <> "]",
),
..acc
]
False -> acc
}
_ -> acc
}
})
case errors {
[] -> Ok(Nil)
errs -> Error(list.reverse(errs))
}
}
}
}