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) ⇒ Anthropic

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

Parameters:

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


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

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

  api_key ||= Riffer.config.anthropic.api_key

  @client = ::Anthropic::Client.new(api_key: api_key, **)
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:



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

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])


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
95
# File 'lib/riffer/providers/anthropic.rb', line 47

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:



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

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])


399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/riffer/providers/anthropic.rb', line 399

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])


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

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])


431
432
433
434
435
436
437
# File 'lib/riffer/providers/anthropic.rb', line 431

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)


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

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:



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
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/riffer/providers/anthropic.rb', line 184

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
        case event.content_block
        when ::Anthropic::Models::TextBlock
          handle_content_block_stop_text(event, state: current_state, yielder: yielder) if current_state[:text]
        when ::Anthropic::Models::ToolUseBlock
          handle_content_block_stop_tool_use(event, state: current_state, yielder: yielder)
        when ::Anthropic::Models::ThinkingBlock
          if current_state[:reasoning]
            handle_content_block_stop_thinking(event, state: current_state, yielder: yielder)
          end
        when ::Anthropic::Models::ServerToolUseBlock
          handle_content_block_stop_server_tool_use(event, state: current_state, yielder: yielder)
        when ::Anthropic::Models::WebSearchToolResultBlock
          handle_content_block_stop_web_search_result(event, state: current_state, yielder: yielder)
        end
      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)


146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/riffer/providers/anthropic.rb', line 146

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:



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

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:



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

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:



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

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|
    next unless 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

  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:



323
324
325
326
327
328
329
330
331
# File 'lib/riffer/providers/anthropic.rb', line 323

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:



316
317
318
319
# File 'lib/riffer/providers/anthropic.rb', line 316

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:



309
310
311
312
# File 'lib/riffer/providers/anthropic.rb', line 309

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:



295
296
297
298
299
300
301
302
303
304
305
# File 'lib/riffer/providers/anthropic.rb', line 295

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:



335
336
337
338
339
340
341
342
343
344
345
# File 'lib/riffer/providers/anthropic.rb', line 335

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:



283
284
285
286
287
288
289
290
291
# File 'lib/riffer/providers/anthropic.rb', line 283

def handle_input_json_event(event, state:, yielder:)
  state[:tool_call] = { id: nil, name: nil, arguments: +"" } if state[:tool_call].nil?
  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:



349
350
351
352
353
354
355
356
357
# File 'lib/riffer/providers/anthropic.rb', line 349

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])


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

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])


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

def handle_raw_content_block_start(event, state:)
  content_block = event.content_block
  return unless 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

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

This method returns an undefined value.

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

Parameters:



262
263
264
265
266
267
268
269
270
271
# File 'lib/riffer/providers/anthropic.rb', line 262

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:



275
276
277
278
279
# File 'lib/riffer/providers/anthropic.rb', line 275

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])


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
386
387
388
389
390
391
392
393
394
395
# File 'lib/riffer/providers/anthropic.rb', line 361

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