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 =

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


384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
# File 'lib/riffer/providers/anthropic.rb', line 384

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


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

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


416
417
418
419
420
421
422
# File 'lib/riffer/providers/anthropic.rb', line 416

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:



309
310
311
312
313
314
315
316
317
# File 'lib/riffer/providers/anthropic.rb', line 309

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:



302
303
304
305
# File 'lib/riffer/providers/anthropic.rb', line 302

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:



295
296
297
298
# File 'lib/riffer/providers/anthropic.rb', line 295

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:



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

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:



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

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:



267
268
269
270
271
272
273
274
275
276
277
# File 'lib/riffer/providers/anthropic.rb', line 267

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:



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

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

def handle_text_event(event, state:, yielder:)
  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:



259
260
261
262
263
# File 'lib/riffer/providers/anthropic.rb', line 259

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


346
347
348
349
350
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
# File 'lib/riffer/providers/anthropic.rb', line 346

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