Class: Riffer::Providers::Base
- Inherits:
-
Object
- Object
- Riffer::Providers::Base
- 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
Constant Summary collapse
- WIRE_SEPARATOR =
"__"- REQUEST_PARAM_ATTRIBUTES =
A deliberate whitelist — caller options outside it stay off spans.
{ 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
-
.semconv_provider_name ⇒ String
Returns the provider name stamped as gen_ai.provider.name on trace spans, ideally a GenAI semconv well-known value.
-
.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.
Instance Method Summary collapse
-
#apply_pricing(usage) ⇒ Riffer::Providers::TokenUsage
: (Riffer::Providers::TokenUsage) -> Riffer::Providers::TokenUsage.
-
#build_client ⇒ Object
-- : () -> untyped.
-
#build_request_params(messages, model, options) ⇒ Hash[Symbol, untyped]
-- : (Array, String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped].
-
#capture_input(span, messages) ⇒ void
-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Array) -> void.
-
#capture_messages?(span) ⇒ Boolean
-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> bool.
-
#capture_output(span, content:, tool_calls:, finish_reason:) ⇒ void
-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), content: String?, tool_calls: Array, finish_reason: Symbol?) -> void.
-
#chat_span_attributes(model, options) ⇒ Hash[String, untyped]
-- : (String?, Hash[Symbol, untyped]) -> Hash[String, untyped].
-
#client ⇒ Object
Returns the client for the current LLM call.
-
#decode_tool_name(wire_name, tools:) ⇒ String
-- : (String, tools: Array) -> String.
-
#depends_on(gem_name) ⇒ true
: (String) -> true.
-
#encode_tool_name(name) ⇒ String
-- : (String) -> String.
-
#execute_generate(params) ⇒ Object
-- : (Hash[Symbol, untyped]) -> untyped.
-
#execute_stream(params, yielder) ⇒ void
-- : (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void.
-
#extract_content(response) ⇒ String
-- : (untyped) -> String.
-
#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.
-
#extract_token_usage(response) ⇒ Riffer::Providers::TokenUsage?
-- : (untyped) -> Riffer::Providers::TokenUsage?.
-
#extract_tool_calls(response) ⇒ Array[Riffer::Messages::Assistant::ToolCall]
-- : (untyped) -> Array.
-
#generate_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options) ⇒ Riffer::Messages::Assistant
Generates text using the provider.
-
#global_client ⇒ Object
Returns the consumer-configured client for this provider; nil when none is configured, and for providers that take no configuration at all.
-
#in_chat_span(model, messages, options) ⇒ void
-- : [R] (String?, Array, Hash[Symbol, untyped]) { (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span) -> R } -> R.
- #merge_consecutive_messages(messages) ⇒ Array[Riffer::Messages::Base]
-
#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.
-
#parse_structured_output(content) ⇒ Hash[Symbol, untyped]?
-- : (String) -> Hash[Symbol, untyped]?.
-
#parse_tool_arguments(arguments) ⇒ Hash[String, untyped]
-- : ((String | Hash[String, untyped])?) -> Hash[String, untyped].
-
#pricing_rates ⇒ Riffer::Config::Pricing::Rates?
-- : () -> Riffer::Config::Pricing::Rates?.
-
#record_finish_reason(span, reason, raw) ⇒ void
-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Symbol?, String?) -> void.
-
#record_stream_outcome(span, recorder) ⇒ void
-- : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Tracing::StreamRecorder) -> void.
-
#stream_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options) ⇒ Enumerator[Riffer::StreamEvents::Base, void]
Streams text from the provider.
-
#tag_attributes(tags) ⇒ Hash[String, String]
Maps normalized tags to their namespaced span attribute form.
-
#validate_input!(prompt:, system:, messages:) ⇒ void
-- : (prompt: String?, system: String?, messages: Array[Hash[Symbol | String, untyped] | Riffer::Messages::Base]?) -> void.
-
#validate_normalized_messages!(messages) ⇒ void
-- : (Array) -> void.
-
#yield_finish_reason(yielder, finish_reason) ⇒ void
-- : (Riffer::Providers::_EventSink, Riffer::Providers::FinishReason?) -> void.
Class Method Details
.semconv_provider_name ⇒ String
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
32 33 34 35 36 37 38 39 |
# File 'lib/riffer/providers/base.rb', line 32 def self.semconv_provider_name class_name = name # Anonymous classes stay uncached: a class assigned to a constant # later must pick up its real name, not a frozen "unknown". return "unknown" unless class_name @semconv_provider_name ||= Riffer::Helpers::Identifier.derive(class_name.split("::").last) 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)
22 23 24 |
# File 'lib/riffer/providers/base.rb', line 22 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
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/riffer/providers/base.rb', line 177 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_client ⇒ Object
-- : () -> untyped
135 136 137 |
# File 'lib/riffer/providers/base.rb', line 135 def build_client raise NotImplementedError, "Subclasses must implement #build_client" end |
#build_request_params(messages, model, options) ⇒ Hash[Symbol, untyped]
-- : (Array, String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
154 155 156 |
# File 'lib/riffer/providers/base.rb', line 154 def build_request_params(, model, ) 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
314 315 316 317 318 319 320 |
# File 'lib/riffer/providers/base.rb', line 314 def capture_input(span, ) return unless (span) span.set_attribute("gen_ai.input.messages", Riffer::Tracing::Capture.()) system_instructions = Riffer::Tracing::Capture.system_instructions() 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
339 340 341 |
# File 'lib/riffer/providers/base.rb', line 339 def (span) Riffer.config.tracing. && 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
324 325 326 327 328 329 330 331 332 333 334 335 |
# File 'lib/riffer/providers/base.rb', line 324 def capture_output(span, content:, tool_calls:, finish_reason:) return unless (span) span.set_attribute( "gen_ai.output.messages", Riffer::Tracing::Capture.( 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]
265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/riffer/providers/base.rb', line 265 def chat_span_attributes(model, ) 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 = [key] attributes[attribute] = value if value end attributes.merge(tag_attributes([:tags] || {})) end |
#client ⇒ Object
Returns the client for the current LLM call. A configured client wins, resolved on every call so a Proc can vary the client by process or credential lifetime; otherwise the provider builds one from the configured credentials, memoized for the life of the provider.
: () -> untyped
118 119 120 121 122 123 |
# File 'lib/riffer/providers/base.rb', line 118 def client configured = global_client return Riffer::Helpers::CallOrValue.resolve(configured) if configured @client ||= build_client end |
#decode_tool_name(wire_name, tools:) ⇒ String
-- : (String, tools: Array) -> String
147 148 149 150 |
# File 'lib/riffer/providers/base.rb', line 147 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
108 109 110 |
# File 'lib/riffer/providers/base.rb', line 108 def depends_on(gem_name) Riffer::Helpers::Dependencies.depends_on(gem_name) end |
#encode_tool_name(name) ⇒ String
-- : (String) -> String
141 142 143 |
# File 'lib/riffer/providers/base.rb', line 141 def encode_tool_name(name) name.gsub("/", WIRE_SEPARATOR) end |
#execute_generate(params) ⇒ Object
-- : (Hash[Symbol, untyped]) -> untyped
160 161 162 |
# File 'lib/riffer/providers/base.rb', line 160 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
166 167 168 |
# File 'lib/riffer/providers/base.rb', line 166 def execute_stream(params, yielder) raise NotImplementedError, "Subclasses must implement #execute_stream" end |
#extract_content(response) ⇒ String
-- : (untyped) -> String
221 222 223 |
# File 'lib/riffer/providers/base.rb', line 221 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?
215 216 217 |
# File 'lib/riffer/providers/base.rb', line 215 def extract_finish_reason(_response) nil end |
#extract_token_usage(response) ⇒ Riffer::Providers::TokenUsage?
-- : (untyped) -> Riffer::Providers::TokenUsage?
172 173 174 |
# File 'lib/riffer/providers/base.rb', line 172 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
227 228 229 |
# File 'lib/riffer/providers/base.rb', line 227 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
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 72 73 74 75 |
# File 'lib/riffer/providers/base.rb', line 45 def generate_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **) validate_input!(prompt: prompt, system: system, messages: ) @current_tools = [:tools] || [] #: Array[singleton(Riffer::Tool)] @current_model = model = (prompt: prompt, system: system, messages: , files: files) () = () params = build_request_params(, model, ) in_chat_span(model, , ) 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 [: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 |
#global_client ⇒ Object
Returns the consumer-configured client for this provider; nil when none is configured, and for providers that take no configuration at all.
: () -> untyped
129 130 131 |
# File 'lib/riffer/providers/base.rb', line 129 def global_client nil 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
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 |
# File 'lib/riffer/providers/base.rb', line 247 def in_chat_span(model, , ) Riffer::Tracing.in_span( model ? "chat #{model}" : "chat", attributes: chat_span_attributes(model, ), kind: :client, ) do |span| capture_input(span, ) yield span rescue StandardError => e # 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", e.class.name) raise end end |
#merge_consecutive_messages(messages) ⇒ Array[Riffer::Messages::Base]
372 373 374 375 376 377 378 |
# File 'lib/riffer/providers/base.rb', line 372 def () .chunk(&: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
393 394 395 396 397 398 399 400 401 402 403 404 405 406 |
# File 'lib/riffer/providers/base.rb', line 393 def (prompt:, system:, messages:, files: nil) if && files && !files.empty? raise Riffer::ArgumentError, "cannot provide both files and messages; attach files to individual messages instead" end return .map { |msg| Riffer::Messages::Base.from_hash(msg) } if 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]?
356 357 358 359 360 |
# File 'lib/riffer/providers/base.rb', line 356 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]
364 365 366 367 368 |
# File 'lib/riffer/providers/base.rb', line 364 def parse_tool_arguments(arguments) return {} if arguments.nil? || arguments.empty? arguments.is_a?(String) ? JSON.parse(arguments) : arguments end |
#pricing_rates ⇒ Riffer::Config::Pricing::Rates?
-- : () -> Riffer::Config::Pricing::Rates?
198 199 200 201 202 203 204 205 206 207 208 209 |
# File 'lib/riffer/providers/base.rb', line 198 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
290 291 292 293 294 295 |
# File 'lib/riffer/providers/base.rb', line 290 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
299 300 301 302 303 304 305 306 307 308 309 310 |
# File 'lib/riffer/providers/base.rb', line 299 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) time_to_first_chunk = recorder.time_to_first_chunk span.set_attribute("gen_ai.response.time_to_first_chunk", time_to_first_chunk) if time_to_first_chunk 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]
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/riffer/providers/base.rb', line 81 def stream_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **) validate_input!(prompt: prompt, system: system, messages: ) @current_tools = [:tools] || [] #: Array[singleton(Riffer::Tool)] @current_model = model = (prompt: prompt, system: system, messages: , files: files) () = () params = build_request_params(, model, ) # 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, , ) 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]
284 285 286 |
# File 'lib/riffer/providers/base.rb', line 284 def tag_attributes() .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
382 383 384 385 386 387 388 389 |
# File 'lib/riffer/providers/base.rb', line 382 def validate_input!(prompt:, system:, messages:) if .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" if prompt raise Riffer::ArgumentError, "cannot provide both system and messages" if system end end |
#validate_normalized_messages!(messages) ⇒ void
This method returns an undefined value.
-- : (Array) -> void
410 411 412 413 |
# File 'lib/riffer/providers/base.rb', line 410 def () has_user = .any?(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
345 346 347 348 349 350 351 352 |
# File 'lib/riffer/providers/base.rb', line 345 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 |