Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/ruby_llm/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@
]
},
"capabilities": [
"function_calling"
"function_calling",
"structured_output"
],
"pricing": {
"text_tokens": {
Expand Down Expand Up @@ -372,7 +373,8 @@
]
},
"capabilities": [
"function_calling"
"function_calling",
"structured_output"
],
"pricing": {
"text_tokens": {
Expand Down
19 changes: 19 additions & 0 deletions lib/ruby_llm/providers/anthropic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Anthropic < Provider
include Anthropic::Streaming
include Anthropic::Tools

STRUCTURED_OUTPUTS_BETA = 'structured-outputs-2025-11-13'

def api_base
'https://api.anthropic.com'
end
Expand All @@ -22,6 +24,23 @@ def headers
}
end

def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, &block) # rubocop:disable Metrics/ParameterLists
headers = add_structured_output_beta_header(headers) if schema
super
end

private

def add_structured_output_beta_header(headers)
existing_beta = headers['anthropic-beta']
new_beta = if existing_beta
"#{existing_beta},#{STRUCTURED_OUTPUTS_BETA}"
else
STRUCTURED_OUTPUTS_BETA
end
headers.merge('anthropic-beta' => new_beta)
end

class << self
def capabilities
Anthropic::Capabilities
Expand Down
23 changes: 19 additions & 4 deletions lib/ruby_llm/providers/anthropic/capabilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,25 @@ def supports_vision?(model_id)
end

def supports_functions?(model_id)
model_id.match?(/claude-3/)
claude3_or_newer?(model_id)
end

def supports_json_mode?(model_id)
model_id.match?(/claude-3/)
claude3_or_newer?(model_id)
end

def supports_structured_output?(model_id)
# Structured outputs supported on Claude 4+ (Sonnet 4.5, Opus 4.1, etc.)
# https://docs.anthropic.com/en/docs/build-with-claude/structured-outputs
claude4_or_newer?(model_id)
end

def claude3_or_newer?(model_id)
model_id.match?(/claude-[34]|claude-(sonnet|opus|haiku)-[4-9]/)
end

def claude4_or_newer?(model_id)
model_id.match?(/claude-[4-9]|claude-(sonnet|opus|haiku)-[4-9]/)
end

def supports_extended_thinking?(model_id)
Expand Down Expand Up @@ -92,12 +106,13 @@ def modalities_for(model_id)
def capabilities_for(model_id)
capabilities = ['streaming']

if model_id.match?(/claude-3/)
if claude3_or_newer?(model_id)
capabilities << 'function_calling'
capabilities << 'batch'
end

capabilities << 'reasoning' if model_id.match?(/claude-3-7|-4/)
capabilities << 'structured_output' if supports_structured_output?(model_id)
capabilities << 'reasoning' if model_id.match?(/claude-3-7|claude-[4-9]|claude-(sonnet|opus)-[4-9]/)
capabilities << 'citations' if model_id.match?(/claude-3\.5|claude-3-7/)
capabilities
end
Expand Down
15 changes: 12 additions & 3 deletions lib/ruby_llm/providers/anthropic/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ def completion_url
'/v1/messages'
end

def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists
system_messages, chat_messages = separate_messages(messages)
system_content = build_system_content(system_messages)

build_base_payload(chat_messages, model, stream).tap do |payload|
add_optional_fields(payload, system_content:, tools:, temperature:)
add_optional_fields(payload, system_content:, tools:, temperature:, schema:)
end
end

Expand Down Expand Up @@ -54,10 +54,19 @@ def build_base_payload(chat_messages, model, stream)
}
end

def add_optional_fields(payload, system_content:, tools:, temperature:)
def add_optional_fields(payload, system_content:, tools:, temperature:, schema: nil)
payload[:tools] = tools.values.map { |t| Tools.function_for(t) } if tools.any?
payload[:system] = system_content unless system_content.empty?
payload[:temperature] = temperature unless temperature.nil?
add_output_format(payload, schema) if schema
end

def add_output_format(payload, schema)
SchemaValidator.new(schema).validate!
payload[:output_format] = {
type: 'json_schema',
schema: schema
}
end

def parse_completion_response(response)
Expand Down
84 changes: 84 additions & 0 deletions lib/ruby_llm/providers/anthropic/schema_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# frozen_string_literal: true

module RubyLLM
module Providers
class Anthropic
# Validates JSON schemas for Anthropic structured outputs.
#
# Anthropic requires all object schemas to have `additionalProperties: false`.
# This constraint ensures the model produces deterministic, well-defined JSON
# without unexpected extra fields. The API will reject schemas that don't meet
# this requirement.
#
# This validator recursively checks all objects in the schema, including:
# - Top-level object schemas
# - Nested objects in `properties`
# - Objects in array `items`
# - Objects in `anyOf`, `oneOf`, and `allOf` combinators
#
# @see https://docs.anthropic.com/en/docs/build-with-claude/structured-outputs
class SchemaValidator
def initialize(schema)
@schema = schema
end

def validate!
validate_node(@schema, 'schema')
end

private

def validate_node(schema, path)
return unless schema.is_a?(Hash)

validate_object_schema(schema, path)
validate_properties(schema, path)
validate_items(schema, path)
validate_combinators(schema, path)
end

def validate_object_schema(schema, path)
return unless value(schema, :type) == 'object' && value(schema, :additionalProperties) != false

raise ArgumentError,
"#{path}: Object schemas must set 'additionalProperties' to false for Anthropic structured outputs."
end

def validate_properties(schema, path)
properties = value(schema, :properties)
return unless properties.is_a?(Hash)

properties.each do |key, prop_schema|
validate_node(prop_schema, "#{path}.properties.#{key}")
end
end

def validate_items(schema, path)
items = value(schema, :items)
validate_node(items, "#{path}.items") if items
end

def validate_combinators(schema, path)
%i[anyOf oneOf allOf].each do |keyword|
schemas = value(schema, keyword)
next unless schemas.is_a?(Array)

schemas.each_with_index do |sub_schema, index|
validate_node(sub_schema, "#{path}.#{keyword}[#{index}]")
end
end
end

def value(schema, key)
return unless schema.is_a?(Hash)

symbol_key = key.is_a?(Symbol) ? key : key.to_sym
return schema[symbol_key] if schema.key?(symbol_key)

string_key = key.to_s
schema[string_key] if schema.key?(string_key)
end
end
end
end
end

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading