Class: Riffer::Providers::Base

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

Overview

Base class for all LLM providers. A template-method flow: subclasses implement the hooks (+build_request_params+, execute_generate, execute_stream, extract_token_usage, extract_content, extract_tool_calls) and the base class orchestrates them.

Direct Known Subclasses

AmazonBedrock, Anthropic, Gemini, Mock, OpenAI, OpenRouter

Constant Summary collapse

WIRE_SEPARATOR =

Returns:

  • (String)
"__"
REQUEST_PARAM_ATTRIBUTES =

A deliberate whitelist — caller options outside it stay off spans.

Returns:

  • (Hash[Symbol, String])
{
  temperature: "gen_ai.request.temperature",
  max_tokens: "gen_ai.request.max_tokens",
  max_output_tokens: "gen_ai.request.max_tokens",
  top_p: "gen_ai.request.top_p",
  top_k: "gen_ai.request.top_k",
  frequency_penalty: "gen_ai.request.frequency_penalty",
  presence_penalty: "gen_ai.request.presence_penalty",
  seed: "gen_ai.request.seed",
  stop_sequences: "gen_ai.request.stop_sequences"
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.semconv_provider_nameString

Returns the provider name stamped as gen_ai.provider.name on trace spans, ideally a GenAI semconv well-known value. Defaults to the snake_cased class name rather than raising like the abstract provider methods, so enabling tracing never breaks an otherwise-working custom provider.

: () -> String

Returns:

  • (String)


30
31
32
33
34
35
# File 'lib/riffer/providers/base.rb', line 30

def self.semconv_provider_name
  class_name = name
  return "unknown" unless class_name

  Riffer::Helpers::ClassNameConverter.convert(class_name.split("::").last.to_s)
end

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

Returns the preferred skill adapter for this provider; override in subclasses (optionally introspecting model) for provider-specific formats.

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

Parameters:

  • (String, nil)

Returns:



20
21
22
# File 'lib/riffer/providers/base.rb', line 20

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

Instance Method Details

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

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



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/riffer/providers/base.rb', line 146

def apply_pricing(usage)
  rates = pricing_rates
  return usage unless rates

  cost = rates.cost_for(
    input_tokens: usage.input_tokens,
    output_tokens: usage.output_tokens,
    cache_read_tokens: usage.cache_read_tokens,
    cache_write_tokens: usage.cache_write_tokens
  )
  Riffer::Providers::TokenUsage.new(
    input_tokens: usage.input_tokens,
    output_tokens: usage.output_tokens,
    cache_write_tokens: usage.cache_write_tokens,
    cache_read_tokens: usage.cache_read_tokens,
    cost: cost
  )
end

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

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

Parameters:

Returns:

  • (Hash[Symbol, untyped])


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

def build_request_params(messages, model, options)
  raise NotImplementedError, "Subclasses must implement #build_request_params"
end

#capture_input(span, messages) ⇒ void

This method returns an undefined value.

-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Array) -> void



271
272
273
274
275
276
277
# File 'lib/riffer/providers/base.rb', line 271

def capture_input(span, messages)
  return unless capture_messages?(span)

  span.set_attribute("gen_ai.input.messages", Riffer::Tracing::Capture.input_messages(messages))
  system_instructions = Riffer::Tracing::Capture.system_instructions(messages)
  span.set_attribute("gen_ai.system_instructions", system_instructions) if system_instructions
end

#capture_messages?(span) ⇒ Boolean

-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> bool

Parameters:

Returns:

  • (Boolean)


289
290
291
# File 'lib/riffer/providers/base.rb', line 289

def capture_messages?(span)
  Riffer.config.tracing.capture_messages && span.recording?
end

#capture_output(span, content:, tool_calls:, finish_reason:) ⇒ void

This method returns an undefined value.

-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), content: String?, tool_calls: Array, finish_reason: Symbol?) -> void

Parameters:



281
282
283
284
285
# File 'lib/riffer/providers/base.rb', line 281

def capture_output(span, content:, tool_calls:, finish_reason:)
  return unless capture_messages?(span)

  span.set_attribute("gen_ai.output.messages", Riffer::Tracing::Capture.output_messages(content: content, tool_calls: tool_calls, finish_reason: finish_reason))
end

#chat_span_attributes(model, options) ⇒ Hash[String, untyped]

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

Parameters:

  • (String, nil)
  • (Hash[Symbol, untyped])

Returns:

  • (Hash[String, untyped])


229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/riffer/providers/base.rb', line 229

def chat_span_attributes(model, options)
  attributes = {
    "gen_ai.operation.name" => "chat",
    "gen_ai.provider.name" => self.class.semconv_provider_name
  } #: Hash[String, untyped]
  attributes["gen_ai.request.model"] = model if model

  REQUEST_PARAM_ATTRIBUTES.each do |key, attribute|
    value = options[key]
    attributes[attribute] = value unless value.nil?
  end

  attributes.merge(tag_attributes(options[:tags] || {}))
end

#decode_tool_name(wire_name, tools:) ⇒ String

-- : (String, tools: Array) -> String

Parameters:

Returns:

  • (String)


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

def decode_tool_name(wire_name, tools:)
  tool = tools.find { |t| encode_tool_name(t.name) == wire_name }
  tool ? tool.name : wire_name
end

#depends_on(gem_name) ⇒ true

: (String) -> true

Parameters:

  • (String)

Returns:

  • (true)


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

def depends_on(gem_name)
  Riffer::Helpers::Dependencies.depends_on(gem_name)
end

#encode_tool_name(name) ⇒ String

-- : (String) -> String

Parameters:

  • (String)

Returns:

  • (String)


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

def encode_tool_name(name)
  name.gsub("/", WIRE_SEPARATOR)
end

#execute_generate(params) ⇒ Object

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

Parameters:

  • (Hash[Symbol, untyped])

Returns:

  • (Object)


129
130
131
# File 'lib/riffer/providers/base.rb', line 129

def execute_generate(params)
  raise NotImplementedError, "Subclasses must implement #execute_generate"
end

#execute_stream(params, yielder) ⇒ void

This method returns an undefined value.

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

Parameters:



135
136
137
# File 'lib/riffer/providers/base.rb', line 135

def execute_stream(params, yielder)
  raise NotImplementedError, "Subclasses must implement #execute_stream"
end

#extract_content(response) ⇒ String

-- : (untyped) -> String

Parameters:

  • (Object)

Returns:

  • (String)


190
191
192
# File 'lib/riffer/providers/base.rb', line 190

def extract_content(response)
  raise NotImplementedError, "Subclasses must implement #extract_content"
end

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

Defaults to nil rather than raising — finish reasons are optional, so providers that don't report one stay valid.

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

Parameters:

  • (Object)

Returns:



184
185
186
# File 'lib/riffer/providers/base.rb', line 184

def extract_finish_reason(response)
  nil
end

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

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

Parameters:

  • (Object)

Returns:



141
142
143
# File 'lib/riffer/providers/base.rb', line 141

def extract_token_usage(response)
  raise NotImplementedError, "Subclasses must implement #extract_token_usage"
end

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

-- : (untyped) -> Array

Parameters:

  • (Object)

Returns:



196
197
198
# File 'lib/riffer/providers/base.rb', line 196

def extract_tool_calls(response)
  raise NotImplementedError, "Subclasses must implement #extract_tool_calls"
end

#generate_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options) ⇒ Riffer::Messages::Assistant

Generates text using the provider.

-- : (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, **untyped) -> Riffer::Messages::Assistant

Parameters:

  • prompt: (String, nil) (defaults to: nil)
  • system: (String, nil) (defaults to: nil)
  • messages: (Array[Hash[Symbol, untyped] | Riffer::Messages::Base], nil) (defaults to: nil)
  • model: (String, nil) (defaults to: nil)
  • files: (Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart], nil) (defaults to: nil)
  • (Object)

Returns:



41
42
43
44
45
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
# File 'lib/riffer/providers/base.rb', line 41

def generate_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options)
  validate_input!(prompt: prompt, system: system, messages: messages)
  @current_tools = options[:tools] || [] #: Array[singleton(Riffer::Tool)]
  @current_model = model
  messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
  validate_normalized_messages!(messages)
  messages = merge_consecutive_messages(messages)
  params = build_request_params(messages, model, options)

  in_chat_span(model, messages, options) do |span|
    response = execute_generate(params)

    content = extract_content(response)
    tool_calls = extract_tool_calls(response)
    token_usage = extract_token_usage(response)
    finish_reason = extract_finish_reason(response)
    structured_output = parse_structured_output(content) if options[:structured_output] && tool_calls.empty?

    Riffer::Tracing.record_usage(span, token_usage)
    record_finish_reason(span, finish_reason&.reason, finish_reason&.raw)
    capture_output(span, content: content, tool_calls: tool_calls, finish_reason: finish_reason&.reason)

    Riffer::Messages::Assistant.new(
      content,
      tool_calls: tool_calls,
      token_usage: token_usage,
      structured_output: structured_output,
      finish_reason: finish_reason&.reason
    )
  end
end

#in_chat_span(model, messages, options) ⇒ void

This method returns an undefined value.

-- : [R] (String?, Array, Hash[Symbol, untyped]) { (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span) -> R } -> R



215
216
217
218
219
220
221
222
223
224
225
# File 'lib/riffer/providers/base.rb', line 215

def in_chat_span(model, messages, options)
  Riffer::Tracing.in_span(model ? "chat #{model}" : "chat", attributes: chat_span_attributes(model, options), kind: :client) do |span|
    capture_input(span, messages)
    yield span
  rescue => error
    # The backend records the exception and error status on the re-raise;
    # error.type is the one semconv attribute it doesn't set.
    span.set_attribute("error.type", error.class.name)
    raise
  end
end

#merge_consecutive_messages(messages) ⇒ Array[Riffer::Messages::Base]

-- : (Array) -> Array

Parameters:

Returns:



318
319
320
321
322
323
324
# File 'lib/riffer/providers/base.rb', line 318

def merge_consecutive_messages(messages)
  messages.chunk { |msg| msg.role }.flat_map do |role, group|
    next group if role == :tool || group.size == 1

    group.inject { |merged, msg| merged + msg }
  end
end

#normalize_messages(prompt:, system:, messages:, files: nil) ⇒ Array[Riffer::Messages::Base]

-- : (prompt: String?, system: String?, messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Array

Parameters:

Returns:



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/riffer/providers/base.rb', line 339

def normalize_messages(prompt:, system:, messages:, files: nil)
  if messages && files && !files.empty?
    raise Riffer::ArgumentError, "cannot provide both files and messages; attach files to individual messages instead"
  end

  if messages
    return messages.map { |msg| Riffer::Messages::Base.from_hash(msg) }
  end

  result = [] #: Array[Riffer::Messages::Base]
  result << Riffer::Messages::System.new(system) if system
  file_parts = (files || []).map { |f| Riffer::Messages::FilePart.from_hash(f) }
  prompt_text = prompt #: String
  result << Riffer::Messages::User.new(prompt_text, files: file_parts)
  result
end

#parse_structured_output(content) ⇒ Hash[Symbol, untyped]?

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

Parameters:

  • (String)

Returns:

  • (Hash[Symbol, untyped], nil)


303
304
305
306
307
# File 'lib/riffer/providers/base.rb', line 303

def parse_structured_output(content)
  JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
  nil
end

#parse_tool_arguments(arguments) ⇒ Hash[String, untyped]

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

Parameters:

  • (String, Hash[String, untyped], nil)

Returns:

  • (Hash[String, untyped])


311
312
313
314
# File 'lib/riffer/providers/base.rb', line 311

def parse_tool_arguments(arguments)
  return {} if arguments.nil? || arguments.empty?
  arguments.is_a?(String) ? JSON.parse(arguments) : arguments
end

#pricing_ratesRiffer::Config::Pricing::Rates?

-- : () -> Riffer::Config::Pricing::Rates?



167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/riffer/providers/base.rb', line 167

def pricing_rates
  model = @current_model
  return nil unless model

  pricing = Riffer.config.pricing
  return nil if pricing.empty?

  key = Riffer::Providers::Repository.key_for(self.class)
  return nil unless key

  pricing.rates_for("#{key}/#{model}")
end

#record_finish_reason(span, reason, raw) ⇒ void

This method returns an undefined value.

-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Symbol?, String?) -> void

Parameters:



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

def record_finish_reason(span, reason, raw)
  return unless reason

  span.set_attribute("gen_ai.response.finish_reasons", [reason.to_s])
  span.set_attribute("riffer.finish_reason.raw", raw) if raw && raw != reason.to_s
end

#record_stream_outcome(span, recorder) ⇒ void

This method returns an undefined value.

-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Tracing::StreamRecorder) -> void



263
264
265
266
267
# File 'lib/riffer/providers/base.rb', line 263

def record_stream_outcome(span, recorder)
  Riffer::Tracing.record_usage(span, recorder.token_usage)
  record_finish_reason(span, recorder.finish_reason, recorder.raw_finish_reason)
  capture_output(span, content: recorder.content, tool_calls: recorder.tool_calls, finish_reason: recorder.finish_reason)
end

#stream_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options) ⇒ Enumerator[Riffer::StreamEvents::Base, void]

Streams text from the provider.

-- : (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]

Parameters:

  • prompt: (String, nil) (defaults to: nil)
  • system: (String, nil) (defaults to: nil)
  • messages: (Array[Hash[Symbol, untyped] | Riffer::Messages::Base], nil) (defaults to: nil)
  • model: (String, nil) (defaults to: nil)
  • files: (Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart], nil) (defaults to: nil)
  • (Object)

Returns:



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/riffer/providers/base.rb', line 77

def stream_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options)
  validate_input!(prompt: prompt, system: system, messages: messages)
  @current_tools = options[:tools] || [] #: Array[singleton(Riffer::Tool)]
  @current_model = model
  messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
  validate_normalized_messages!(messages)
  messages = merge_consecutive_messages(messages)
  params = build_request_params(messages, model, options)

  # The enumerator body runs in its own fiber, where the fiber-local OTEL
  # context is empty — capture here so the chat span parents to the caller's
  # trace.
  trace_context = Riffer::Tracing.current_context
  Enumerator.new do |yielder|
    Riffer::Tracing.with_context(trace_context) do
      in_chat_span(model, messages, options) do |span|
        sink = span.recording? ? Riffer::Tracing::StreamRecorder.new(yielder) : yielder
        execute_stream(params, sink)
        record_stream_outcome(span, sink) if sink.is_a?(Riffer::Tracing::StreamRecorder)
      end
    end
  end
end

#tag_attributes(tags) ⇒ Hash[String, String]

Maps normalized tags to their namespaced span attribute form. An empty map yields an empty hash, so merging it is a no-op.

: (Hash[String, String]) -> Hash[String, String]

Parameters:

  • (Hash[String, String])

Returns:

  • (Hash[String, String])


248
249
250
# File 'lib/riffer/providers/base.rb', line 248

def tag_attributes(tags)
  tags.transform_keys { |key| "riffer.tag.#{key}" }
end

#validate_input!(prompt:, system:, messages:) ⇒ void

This method returns an undefined value.

-- : (prompt: String?, system: String?, messages: Array[Hash[Symbol | String, untyped] | Riffer::Messages::Base]?) -> void

Parameters:

  • prompt: (String, nil)
  • system: (String, nil)
  • messages: (Array[Hash[Symbol | String, untyped] | Riffer::Messages::Base], nil)


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

def validate_input!(prompt:, system:, messages:)
  if messages.nil?
    raise Riffer::ArgumentError, "prompt is required when messages is not provided" if prompt.nil?
  else
    raise Riffer::ArgumentError, "cannot provide both prompt and messages" unless prompt.nil?
    raise Riffer::ArgumentError, "cannot provide both system and messages" unless system.nil?
  end
end

#validate_normalized_messages!(messages) ⇒ void

This method returns an undefined value.

-- : (Array) -> void

Parameters:



358
359
360
361
# File 'lib/riffer/providers/base.rb', line 358

def validate_normalized_messages!(messages)
  has_user = messages.any? { |msg| msg.is_a?(Riffer::Messages::User) }
  raise Riffer::ArgumentError, "messages must include at least one user message" unless has_user
end

#yield_finish_reason(yielder, finish_reason) ⇒ void

This method returns an undefined value.

-- : (Riffer::Providers::_EventSink, Riffer::Providers::FinishReason?) -> void



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

def yield_finish_reason(yielder, finish_reason)
  return unless finish_reason

  yielder << Riffer::StreamEvents::FinishReasonDone.new(finish_reason: finish_reason.reason, raw_finish_reason: finish_reason.raw)
end