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

#initializeAnthropic

-- : () -> void



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

def initialize
  super
  depends_on "anthropic"
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_clientObject

Compacted for the same reason as the other providers: never hand an SDK an explicit nil credential, so its own ANTHROPIC_API_KEY resolution stays reachable regardless of how that SDK distinguishes nil from absent.

: () -> untyped

Returns:

  • (Object)


52
53
54
# File 'lib/riffer/providers/anthropic.rb', line 52

def build_client
  ::Anthropic::Client.new(**{ api_key: Riffer.config.anthropic.api_key }.compact)
end

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

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

Parameters:

  • (Object)

Returns:



130
131
132
133
134
135
# File 'lib/riffer/providers/anthropic.rb', line 130

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


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
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/riffer/providers/anthropic.rb', line 58

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:



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

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

#client::Anthropic::Client

Returns:

  • (::Anthropic::Client)


5
# File 'sig/_private/riffer/providers/anthropic.rbs', line 5

def client: () -> ::Anthropic::Client

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

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

Parameters:

Returns:

  • (Hash[Symbol, untyped])


410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/riffer/providers/anthropic.rb', line 410

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


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

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


442
443
444
445
446
447
448
# File 'lib/riffer/providers/anthropic.rb', line 442

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)


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

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:



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
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/riffer/providers/anthropic.rb', line 195

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)


157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/riffer/providers/anthropic.rb', line 157

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:



123
124
125
126
# File 'lib/riffer/providers/anthropic.rb', line 123

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:



116
117
118
119
# File 'lib/riffer/providers/anthropic.rb', line 116

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:



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/riffer/providers/anthropic.rb', line 173

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

#global_clientObject

-- : () -> untyped

Returns:

  • (Object)


43
44
45
# File 'lib/riffer/providers/anthropic.rb', line 43

def global_client
  Riffer.config.anthropic.client
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:



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

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:



327
328
329
330
# File 'lib/riffer/providers/anthropic.rb', line 327

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:



320
321
322
323
# File 'lib/riffer/providers/anthropic.rb', line 320

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:



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

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:



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

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:



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

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:



360
361
362
363
364
365
366
367
368
# File 'lib/riffer/providers/anthropic.rb', line 360

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


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

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


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

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:



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

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:



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

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


372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/riffer/providers/anthropic.rb', line 372

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