Class: Riffer::Providers::Anthropic

Inherits:
Base
  • Object
show all
Defined in:
lib/riffer/providers/anthropic.rb,
sig/_private/riffer/providers/anthropic.rbs,
sig/generated/riffer/providers/anthropic.rbs

Overview

Anthropic provider for Claude models via the Anthropic API. Requires the anthropic gem.

Constant Summary collapse

WEB_SEARCH_TOOL_TYPE =

Signature:

  • String

Returns:

  • (String)
"web_search_20250305"
FINISH_REASONS =

Returns:

  • (Hash[String, Symbol])
{
  "end_turn" => :stop,
  "stop_sequence" => :stop,
  "max_tokens" => :length,
  "tool_use" => :tool_calls,
  "refusal" => :content_filter
}.freeze

Constants inherited from Base

Base::REQUEST_PARAM_ATTRIBUTES, Base::WIRE_SEPARATOR

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#apply_pricing, #capture_input, #capture_messages?, #capture_output, #chat_span_attributes, #decode_tool_name, #depends_on, #encode_tool_name, #generate_text, #in_chat_span, #merge_consecutive_messages, #normalize_messages, #parse_structured_output, #parse_tool_arguments, #pricing_rates, #record_finish_reason, #record_stream_outcome, #stream_text, #tag_attributes, #validate_input!, #validate_normalized_messages!, #yield_finish_reason

Constructor Details

#initialize(api_key: nil, **options) ⇒ Anthropic

-- : (?api_key: String?, **untyped) -> void

Parameters:

  • api_key: (String, nil) (defaults to: nil)
  • (Object)


34
35
36
37
38
39
40
# File 'lib/riffer/providers/anthropic.rb', line 34

def initialize(api_key: nil, **options)
  depends_on "anthropic"

  api_key ||= Riffer.config.anthropic.api_key

  @client = ::Anthropic::Client.new(api_key: api_key, **options)
end

Class Method Details

.semconv_provider_nameString

The GenAI semconv well-known provider name.

: () -> String

Returns:

  • (String)


28
29
30
# File 'lib/riffer/providers/anthropic.rb', line 28

def self.semconv_provider_name
  "anthropic"
end

.skills_adapter(model = nil) ⇒ singleton(Riffer::Skills::Adapter)

Returns the XML skill adapter for Anthropic/Claude.

-- : (?String?) -> singleton(Riffer::Skills::Adapter)

Parameters:

  • (String, nil)

Returns:



21
22
23
# File 'lib/riffer/providers/anthropic.rb', line 21

def self.skills_adapter(model = nil)
  Riffer::Skills::XmlAdapter
end

Instance Method Details

#build_finish_reason(stop_reason) ⇒ Riffer::Providers::FinishReason?

-- : (untyped) -> Riffer::Providers::FinishReason?

Parameters:

  • (Object)

Returns:



118
119
120
121
122
123
# File 'lib/riffer/providers/anthropic.rb', line 118

def build_finish_reason(stop_reason)
  return nil unless stop_reason

  raw = stop_reason.to_s
  Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(raw, :other), raw: raw)
end

#build_request_params(messages, model, options) ⇒ Hash[Symbol, untyped]

-- : (Array, String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/riffer/providers/anthropic.rb', line 46

def build_request_params(messages, model, options)
  partitioned_messages = partition_messages(messages)
  tools = options[:tools]
  structured_output = options[:structured_output]
  web_search = options[:web_search]
  tags = options[:tags] || {}

  max_tokens = options.fetch(:max_tokens, 4096)

  params = {
    model: model,
    messages: partitioned_messages[:conversation],
    max_tokens: max_tokens,
    **options.except(:tools, :max_tokens, :structured_output, :web_search, :tags)
  } #: Hash[Symbol, untyped]

  params[:system] = partitioned_messages[:system] if partitioned_messages[:system]

  # Anthropic's only request-metadata field is metadata.user_id (opaque, no
  # PII). It carries the reserved user_id tag; all other tags are dropped
  # here and survive only on spans.
  user_id = tags["user_id"]
  params[:metadata] = {user_id: user_id} if user_id

  anthropic_tools = [] #: Array[Hash[Symbol, untyped]]
  anthropic_tools.concat(tools.map { |t| convert_tool_to_anthropic_format(t) }) if tools && !tools.empty?

  if web_search
    web_search_tool = {type: WEB_SEARCH_TOOL_TYPE, name: "web_search"}
    web_search_tool.merge!(web_search) if web_search.is_a?(Hash)
    anthropic_tools << web_search_tool
  end

  if structured_output
    # Use strict schema to make optional fields nullable. Without this,
    # Anthropic may return empty strings or whitespace instead of null
    # for optional fields that the model has no value for.
    params[:output_config] = {
      format: {
        type: "json_schema",
        schema: structured_output.json_schema(strict: true)
      }
    }
  end

  params[:tools] = anthropic_tools unless anthropic_tools.empty?

  params
end

#build_token_usage(usage) ⇒ Riffer::Providers::TokenUsage

Anthropic's input_tokens excludes the cache buckets; TokenUsage's input includes them.

: (untyped) -> Riffer::Providers::TokenUsage

Parameters:

  • (Object)

Returns:



129
130
131
132
133
134
135
136
137
138
139
# File 'lib/riffer/providers/anthropic.rb', line 129

def build_token_usage(usage)
  cache_write = usage.cache_creation_input_tokens
  cache_read = usage.cache_read_input_tokens

  apply_pricing(Riffer::Providers::TokenUsage.new(
    input_tokens: usage.input_tokens + (cache_write || 0) + (cache_read || 0),
    output_tokens: usage.output_tokens,
    cache_write_tokens: cache_write,
    cache_read_tokens: cache_read
  ))
end

#convert_assistant_to_anthropic_format(message) ⇒ Hash[Symbol, untyped]

-- : (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/riffer/providers/anthropic.rb', line 389

def convert_assistant_to_anthropic_format(message)
  content = [] #: Array[Hash[Symbol, untyped]]
  content << {type: "text", text: message.content} if message.content && !message.content.empty?

  message.tool_calls.each do |tc|
    content << {
      type: "tool_use",
      id: tc.call_id,
      name: encode_tool_name(tc.name),
      input: parse_tool_arguments(tc.arguments)
    }
  end

  {role: "assistant", content: content}
end

#convert_file_part_to_anthropic_format(file) ⇒ Hash[Symbol, untyped]

-- : (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


407
408
409
410
411
412
413
414
415
416
417
# File 'lib/riffer/providers/anthropic.rb', line 407

def convert_file_part_to_anthropic_format(file)
  type = file.image? ? "image" : "document"

  source = if file.url?
    {type: "url", url: file.url}
  else
    {type: "base64", media_type: file.media_type, data: file.data}
  end

  {type: type, source: source}
end

#convert_tool_to_anthropic_format(tool) ⇒ Hash[Symbol, untyped]

-- : (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


421
422
423
424
425
426
427
# File 'lib/riffer/providers/anthropic.rb', line 421

def convert_tool_to_anthropic_format(tool)
  {
    name: encode_tool_name(tool.name),
    description: tool.description,
    input_schema: tool.parameters_schema(strict: true)
  }
end

#execute_generate(params) ⇒ Object

-- : (Hash[Symbol, untyped]) -> untyped

Parameters:

  • (Hash[Symbol, untyped])

Returns:

  • (Object)


98
99
100
# File 'lib/riffer/providers/anthropic.rb', line 98

def execute_generate(params)
  @client.messages.create(**params)
end

#execute_stream(params, yielder) ⇒ void

This method returns an undefined value.

-- : (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void

Parameters:



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/riffer/providers/anthropic.rb', line 181

def execute_stream(params, yielder)
  current_state = {
    text: nil,
    reasoning: nil,
    tool_call: nil,
    web_search_index: nil,
    web_search_json: nil,
    web_search_query: nil
  } #: Hash[Symbol, untyped]

  # Workaround for anthropics/anthropic-sdk-ruby#182: force identity
  # encoding so Net::HTTP/Zlib doesn't buffer SSE chunks until EOF.
  stream = @client.messages.stream(
    **params,
    request_options: {extra_headers: {"accept-encoding" => "identity"}}
  )

  begin
    stream.each do |event|
      case event
      when ::Anthropic::Models::RawContentBlockStartEvent
        handle_raw_content_block_start(event, state: current_state)
      when ::Anthropic::Models::RawContentBlockDeltaEvent
        handle_raw_content_block_delta(event, state: current_state)
      when ::Anthropic::Helpers::Streaming::TextEvent
        handle_text_event(event, state: current_state, yielder: yielder)
      when ::Anthropic::Helpers::Streaming::ThinkingEvent
        handle_thinking_event(event, state: current_state, yielder: yielder)
      when ::Anthropic::Helpers::Streaming::InputJsonEvent
        handle_input_json_event(event, state: current_state, yielder: yielder)
      when ::Anthropic::Helpers::Streaming::ContentBlockStopEvent
        block = event.content_block
        handle_content_block_stop_text(event, state: current_state, yielder: yielder) if block.is_a?(::Anthropic::Models::TextBlock) && current_state[:text]
        handle_content_block_stop_tool_use(event, state: current_state, yielder: yielder) if block.is_a?(::Anthropic::Models::ToolUseBlock)
        handle_content_block_stop_thinking(event, state: current_state, yielder: yielder) if block.is_a?(::Anthropic::Models::ThinkingBlock) && current_state[:reasoning]
        handle_content_block_stop_server_tool_use(event, state: current_state, yielder: yielder) if block.is_a?(::Anthropic::Models::ServerToolUseBlock)
        handle_content_block_stop_web_search_result(event, state: current_state, yielder: yielder) if block.is_a?(::Anthropic::Models::WebSearchToolResultBlock)
      when ::Anthropic::Helpers::Streaming::MessageStopEvent
        handle_message_stop(event, accumulated_message: stream.accumulated_message, yielder: yielder)
      end
    end
  ensure
    # Anthropic SDK does not auto-close the underlying HTTP stream when
    # iteration is interrupted (raise / fiber cancellation), so the SSE
    # socket leaks until GC. close is idempotent and a no-op after EOF.
    stream.close
  end
end

#extract_content(response) ⇒ String

-- : (untyped) -> String

Parameters:

  • (Object)

Returns:

  • (String)


143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/riffer/providers/anthropic.rb', line 143

def extract_content(response)
  message = response #: Anthropic::Models::Message
  content_blocks = message.content
  return "" if content_blocks.nil? || content_blocks.empty?

  text_content = ""

  content_blocks.each do |block|
    text_content = block.text if block.is_a?(::Anthropic::Models::TextBlock)
  end

  text_content
end

#extract_finish_reason(response) ⇒ Riffer::Providers::FinishReason?

-- : (untyped) -> Riffer::Providers::FinishReason?

Parameters:

  • (Object)

Returns:



111
112
113
114
# File 'lib/riffer/providers/anthropic.rb', line 111

def extract_finish_reason(response)
  message = response #: Anthropic::Models::Message
  build_finish_reason(message.stop_reason)
end

#extract_token_usage(response) ⇒ Riffer::Providers::TokenUsage?

-- : (untyped) -> Riffer::Providers::TokenUsage?

Parameters:

  • (Object)

Returns:



104
105
106
107
# File 'lib/riffer/providers/anthropic.rb', line 104

def extract_token_usage(response)
  message = response #: Anthropic::Models::Message
  build_token_usage(message.usage)
end

#extract_tool_calls(response) ⇒ Array[Riffer::Messages::Assistant::ToolCall]

-- : (untyped) -> Array

Parameters:

  • (Object)

Returns:



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/riffer/providers/anthropic.rb', line 159

def extract_tool_calls(response)
  message = response #: Anthropic::Models::Message
  content_blocks = message.content
  return [] if content_blocks.nil? || content_blocks.empty?

  tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]

  content_blocks.each do |block|
    if block.is_a?(::Anthropic::Models::ToolUseBlock)
      tool_calls << Riffer::Messages::Assistant::ToolCall.new(
        call_id: block.id,
        name: decode_tool_name(block.name, tools: @current_tools),
        arguments: block.input.to_json
      )
    end
  end

  tool_calls
end

#handle_content_block_stop_server_tool_use(_event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



314
315
316
317
318
319
320
321
322
# File 'lib/riffer/providers/anthropic.rb', line 314

def handle_content_block_stop_server_tool_use(_event, state:, yielder:)
  return unless state[:web_search_json]

  input = JSON.parse(state[:web_search_json], symbolize_names: true)
  state[:web_search_query] = input[:query]
  state[:web_search_index] = nil
  state[:web_search_json] = nil
  yielder << Riffer::StreamEvents::WebSearchStatus.new("searching", query: input[:query])
end

#handle_content_block_stop_text(_event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



307
308
309
310
# File 'lib/riffer/providers/anthropic.rb', line 307

def handle_content_block_stop_text(_event, state:, yielder:)
  yielder << Riffer::StreamEvents::TextDone.new(state[:text])
  state[:text] = nil
end

#handle_content_block_stop_thinking(_event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



300
301
302
303
# File 'lib/riffer/providers/anthropic.rb', line 300

def handle_content_block_stop_thinking(_event, state:, yielder:)
  yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning])
  state[:reasoning] = nil
end

#handle_content_block_stop_tool_use(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



286
287
288
289
290
291
292
293
294
295
296
# File 'lib/riffer/providers/anthropic.rb', line 286

def handle_content_block_stop_tool_use(event, state:, yielder:)
  content_block = event.content_block
  arguments = content_block.input.is_a?(String) ? content_block.input : content_block.input.to_json
  yielder << Riffer::StreamEvents::ToolCallDone.new(
    item_id: content_block.id,
    call_id: content_block.id,
    name: decode_tool_name(content_block.name, tools: @current_tools),
    arguments: arguments
  )
  state[:tool_call] = nil
end

#handle_content_block_stop_web_search_result(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



326
327
328
329
330
331
332
333
334
335
# File 'lib/riffer/providers/anthropic.rb', line 326

def handle_content_block_stop_web_search_result(event, state:, yielder:)
  content_block = event.content_block
  sources = (content_block.content || []).filter_map do |item|
    next unless item.type.to_s == "web_search_result"
    {title: item.title, url: item.url}
  end

  yielder << Riffer::StreamEvents::WebSearchDone.new(state[:web_search_query] || "", sources: sources)
  state[:web_search_query] = nil
end

#handle_input_json_event(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



272
273
274
275
276
277
278
279
280
281
282
# File 'lib/riffer/providers/anthropic.rb', line 272

def handle_input_json_event(event, state:, yielder:)
  if state[:tool_call].nil?
    state[:tool_call] = {id: nil, name: nil, arguments: +""}
  end
  state[:tool_call][:arguments] << event.partial_json
  yielder << Riffer::StreamEvents::ToolCallDelta.new(
    item_id: state[:tool_call][:id] || "pending",
    name: state[:tool_call][:name],
    arguments_delta: event.partial_json
  )
end

#handle_message_stop(_event, accumulated_message:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, accumulated_message: untyped, yielder: Riffer::Providers::_EventSink) -> void

Parameters:



339
340
341
342
343
344
345
346
347
# File 'lib/riffer/providers/anthropic.rb', line 339

def handle_message_stop(_event, accumulated_message:, yielder:)
  message = accumulated_message #: Anthropic::Models::Message?
  yield_finish_reason(yielder, build_finish_reason(message&.stop_reason))

  usage = message&.usage
  return unless usage

  yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: build_token_usage(usage))
end

#handle_raw_content_block_delta(event, state:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped]) -> void

Parameters:

  • (Object)
  • state: (Hash[Symbol, untyped])


242
243
244
245
246
247
# File 'lib/riffer/providers/anthropic.rb', line 242

def handle_raw_content_block_delta(event, state:)
  return unless state[:web_search_index] == event.index

  delta = event.delta
  state[:web_search_json] << delta.partial_json if delta.respond_to?(:partial_json)
end

#handle_raw_content_block_start(event, state:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped]) -> void

Parameters:

  • (Object)
  • state: (Hash[Symbol, untyped])


232
233
234
235
236
237
238
# File 'lib/riffer/providers/anthropic.rb', line 232

def handle_raw_content_block_start(event, state:)
  content_block = event.content_block
  if content_block.type.to_s == "server_tool_use" && content_block.name.to_s == "web_search"
    state[:web_search_index] = event.index
    state[:web_search_json] = +""
  end
end

#handle_text_event(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



251
252
253
254
255
256
257
258
259
260
# File 'lib/riffer/providers/anthropic.rb', line 251

def handle_text_event(event, state:, yielder:)
  # Mutating append instead of += to avoid reallocating and copying the whole
  # accumulated buffer on every delta (O(n^2) per content block). The buffer
  # is handed to TextDone and cleared on block stop, so no reader observes the
  # pre-append string. Seed with an unfrozen String so << is legal under
  # frozen_string_literal.
  state[:text] ||= +""
  state[:text] << event.text
  yielder << Riffer::StreamEvents::TextDelta.new(event.text)
end

#handle_thinking_event(event, state:, yielder:) ⇒ void

This method returns an undefined value.

-- : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void

Parameters:



264
265
266
267
268
# File 'lib/riffer/providers/anthropic.rb', line 264

def handle_thinking_event(event, state:, yielder:)
  state[:reasoning] ||= +""
  state[:reasoning] << event.thinking
  yielder << Riffer::StreamEvents::ReasoningDelta.new(event.thinking)
end

#partition_messages(messages) ⇒ Hash[Symbol, untyped]

-- : (Array) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/riffer/providers/anthropic.rb', line 351

def partition_messages(messages)
  system_prompts = [] #: Array[Hash[Symbol, untyped]]
  conversation_messages = [] #: Array[Hash[Symbol, untyped]]

  messages.each do |message|
    case message
    when Riffer::Messages::System
      system_prompts << {type: "text", text: message.content}
    when Riffer::Messages::User
      if message.files.empty?
        conversation_messages << {role: "user", content: message.content}
      else
        content = [{type: "text", text: message.content}]
        message.files.each { |file| content << convert_file_part_to_anthropic_format(file) }
        conversation_messages << {role: "user", content: content}
      end
    when Riffer::Messages::Assistant
      conversation_messages << convert_assistant_to_anthropic_format(message)
    when Riffer::Messages::Tool
      conversation_messages << {
        role: "user",
        content: [{
          type: "tool_result",
          tool_use_id: message.tool_call_id,
          content: message.content
        }]
      }
    end
  end

  {
    system: system_prompts.empty? ? nil : system_prompts,
    conversation: conversation_messages
  }
end