Class: Riffer::Providers::OpenRouter

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

Overview

OpenRouter provider (https://openrouter.ai). Requires the openai gem — OpenRouter exposes an OpenAI-compatible endpoint, so this reuses the OpenAI SDK with a base_url override. api_key falls back to config, then OPENROUTER_API_KEY.

Constant Summary collapse

BASE_URL =

: String

Returns:

  • (String)
"https://openrouter.ai/api/v1"
FINISH_REASONS =

Returns:

  • (Hash[String, Symbol])
{
  "stop" => :stop,
  "length" => :length,
  "tool_calls" => :tool_calls,
  "function_call" => :tool_calls,
  "content_filter" => :content_filter,
  "error" => :error
}.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, skills_adapter, #stream_text, #tag_attributes, #validate_input!, #validate_normalized_messages!, #yield_finish_reason

Constructor Details

#initialize(api_key: nil, **options) ⇒ OpenRouter

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

Parameters:

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


31
32
33
34
35
36
37
# File 'lib/riffer/providers/open_router.rb', line 31

def initialize(api_key: nil, **options)
  depends_on "openai"

  api_key ||= Riffer.config.openrouter.api_key || ENV["OPENROUTER_API_KEY"]

  @client = ::OpenAI::Client.new(api_key: api_key, base_url: BASE_URL, **options)
end

Class Method Details

.semconv_provider_nameString

The GenAI semconv well-known provider name.

: () -> String

Returns:

  • (String)


25
26
27
# File 'lib/riffer/providers/open_router.rb', line 25

def self.semconv_provider_name
  "openrouter"
end

Instance Method Details

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

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

Parameters:

  • (Object)

Returns:



120
121
122
123
124
125
126
127
# File 'lib/riffer/providers/open_router.rb', line 120

def build_finish_reason(finish_reason)
  return nil unless finish_reason

  raw = finish_reason.to_s
  return nil if raw.empty?

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


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
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/riffer/providers/open_router.rb', line 43

def build_request_params(messages, model, options)
  reasoning = options[:reasoning]
  tools = options[:tools]
  structured_output = options[:structured_output]
  tags = options[:tags] || {}

  params = {
    model: model,
    messages: convert_messages_to_chat_completions_format(messages),
    **options.except(:reasoning, :tools, :structured_output, :tags)
  } #: Hash[Symbol, untyped]

  unless tags.empty?
    params[:metadata] = tags
    # OpenRouter exposes the legacy Chat Completions user field rather than
    # safety_identifier; the reserved user_id maps there and stays in metadata.
    user = tags["user_id"]
    params[:user] = user if user
  end

  if reasoning
    params[:reasoning] = reasoning.is_a?(String) ? {effort: reasoning} : reasoning
  end

  if tools && !tools.empty?
    params[:tools] = tools.map { |t| convert_tool_to_chat_completions_format(t) }
  end

  if structured_output
    params[:response_format] = {
      type: "json_schema",
      json_schema: {
        name: "response",
        schema: structured_output.json_schema(strict: true),
        strict: true
      }
    }
  end

  params.compact
end

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

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

Parameters:

  • (Object)

Returns:



103
104
105
106
107
108
109
# File 'lib/riffer/providers/open_router.rb', line 103

def build_token_usage(usage)
  apply_pricing(Riffer::Providers::TokenUsage.new(
    input_tokens: usage.prompt_tokens,
    output_tokens: usage.completion_tokens,
    cache_read_tokens: usage.prompt_tokens_details&.cached_tokens
  ))
end

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

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

Parameters:

Returns:

  • (Hash[Symbol, untyped])


321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/riffer/providers/open_router.rb', line 321

def convert_assistant_to_chat_completions_format(message)
  msg = {role: "assistant"} #: Hash[Symbol, untyped]
  msg[:content] = message.content if message.content && !message.content.empty?

  unless message.tool_calls.empty?
    msg[:tool_calls] = message.tool_calls.map do |tc|
      {
        id: tc.call_id,
        type: "function",
        function: {
          name: encode_tool_name(tc.name),
          arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
        }
      }
    end
  end

  msg
end

#convert_file_part_to_chat_completions_format(file) ⇒ Hash[Symbol, untyped]

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

Parameters:

Returns:

  • (Hash[Symbol, untyped])


343
344
345
346
347
348
349
350
351
352
353
# File 'lib/riffer/providers/open_router.rb', line 343

def convert_file_part_to_chat_completions_format(file)
  if file.image?
    image_url = file.url? ? file.url : "data:#{file.media_type};base64,#{file.data}"
    {type: "image_url", image_url: {url: image_url}}
  else
    data_uri = "data:#{file.media_type};base64,#{file.data}"
    block = {type: "file", file: {file_data: data_uri}} #: Hash[Symbol, untyped]
    block[:file][:filename] = file.filename if file.filename
    block
  end
end

#convert_messages_to_chat_completions_format(messages) ⇒ Array[Hash[Symbol, untyped]]

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

Parameters:

Returns:

  • (Array[Hash[Symbol, untyped]])


296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/riffer/providers/open_router.rb', line 296

def convert_messages_to_chat_completions_format(messages)
  messages.flat_map do |message|
    case message
    when Riffer::Messages::System
      {role: "system", content: message.content}
    when Riffer::Messages::User
      if message.files.empty?
        {role: "user", content: message.content}
      else
        content = [{type: "text", text: message.content}]
        message.files.each { |file| content << convert_file_part_to_chat_completions_format(file) }
        {role: "user", content: content}
      end
    when Riffer::Messages::Assistant
      convert_assistant_to_chat_completions_format(message)
    when Riffer::Messages::Tool
      {role: "tool", tool_call_id: message.tool_call_id, content: message.content}
    else
      raise Riffer::ArgumentError, "unsupported message type: #{message.class}"
    end
  end
end

#convert_tool_to_chat_completions_format(tool) ⇒ Hash[Symbol, untyped]

-- : (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


357
358
359
360
361
362
363
364
365
366
367
# File 'lib/riffer/providers/open_router.rb', line 357

def convert_tool_to_chat_completions_format(tool)
  {
    type: "function",
    function: {
      name: encode_tool_name(tool.name),
      description: tool.description,
      parameters: tool.parameters_schema(strict: true),
      strict: true
    }
  }
end

#emit_tool_call_done_events(state:, yielder:) ⇒ void

This method returns an undefined value.

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

Parameters:



274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/riffer/providers/open_router.rb', line 274

def emit_tool_call_done_events(state:, yielder:)
  state[:tool_calls].each do |index, entry|
    fallback = "tool_#{index}"
    yielder << Riffer::StreamEvents::ToolCallDone.new(
      item_id: entry[:id] || fallback,
      call_id: entry[:id] || fallback,
      name: entry[:name],
      arguments: entry[:arguments]
    )
  end
  state[:tool_calls] = {}
end

#execute_generate(params) ⇒ Object

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

Parameters:

  • (Hash[Symbol, untyped])

Returns:

  • (Object)


87
88
89
# File 'lib/riffer/providers/open_router.rb', line 87

def execute_generate(params)
  @client.chat.completions.create(**params)
end

#execute_stream(params, yielder) ⇒ void

This method returns an undefined value.

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

Parameters:



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/riffer/providers/open_router.rb', line 159

def execute_stream(params, yielder)
  # OpenRouter omits usage from streams unless explicitly opted in.
  stream_options = (params[:stream_options] || {}).merge(include_usage: true)
  stream_params = params.merge(stream_options: stream_options)

  state = {
    text: +"",
    reasoning: +"",
    tool_calls: {},
    finish_reason: nil
  } #: Hash[Symbol, untyped]

  # Use stream_raw (not stream) — the latter yields a higher-level
  # ChatChunkEvent helper that aggregates content/tool calls into typed
  # events. We want raw ChatCompletionChunk objects with
  # +choices.first.delta+ so we can map deltas to Riffer::StreamEvents
  # ourselves.
  stream = @client.chat.completions.stream_raw(**stream_params)
  begin
    stream.each do |chunk|
      handle_stream_chunk(chunk, state: state, yielder: yielder)
    end
  ensure
    # The OpenAI SDK does not auto-close the SSE socket on iteration
    # interrupt, so close explicitly. Idempotent and a no-op after EOF.
    stream.close
  end

  # Chat Completions has no per-tool terminal event, so flush any leftover
  # tool calls here in case finish_reason is missing or not "tool_calls".
  emit_tool_call_done_events(state: state, yielder: yielder) unless state[:tool_calls].empty?

  yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
  yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
  yield_finish_reason(yielder, build_finish_reason(state[:finish_reason]))
end

#extract_content(response) ⇒ String

-- : (untyped) -> String

Parameters:

  • (Object)

Returns:

  • (String)


131
132
133
134
# File 'lib/riffer/providers/open_router.rb', line 131

def extract_content(response)
  typed_response = response #: OpenAI::Models::Chat::ChatCompletion
  typed_response.choices.first&.message&.content || ""
end

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

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

Parameters:

  • (Object)

Returns:



113
114
115
116
# File 'lib/riffer/providers/open_router.rb', line 113

def extract_finish_reason(response)
  typed_response = response #: OpenAI::Models::Chat::ChatCompletion
  build_finish_reason(typed_response.choices.first&.finish_reason)
end

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

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

Parameters:

  • (Object)

Returns:



93
94
95
96
97
98
99
# File 'lib/riffer/providers/open_router.rb', line 93

def extract_token_usage(response)
  typed_response = response #: OpenAI::Models::Chat::ChatCompletion
  usage = typed_response.usage
  return nil unless usage

  build_token_usage(usage)
end

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

-- : (untyped) -> Array

Parameters:

  • (Object)

Returns:



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/riffer/providers/open_router.rb', line 138

def extract_tool_calls(response)
  typed_response = response #: OpenAI::Models::Chat::ChatCompletion
  message = typed_response.choices.first&.message
  return [] unless message

  tool_calls = message.tool_calls
  return [] if tool_calls.nil? || tool_calls.empty?

  tool_calls.filter_map do |tc|
    next unless tc.is_a?(::OpenAI::Models::Chat::ChatCompletionMessageFunctionToolCall)

    Riffer::Messages::Assistant::ToolCall.new(
      call_id: tc.id,
      name: decode_tool_name(tc.function.name, tools: @current_tools),
      arguments: tc.function.arguments
    )
  end
end

#finish_reason_is_tool_calls?(choice) ⇒ Boolean

-- : (untyped) -> bool

Parameters:

  • (Object)

Returns:

  • (Boolean)


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

def finish_reason_is_tool_calls?(choice)
  typed_choice = choice #: OpenAI::Models::Chat::ChatCompletionChunk::Choice
  typed_choice.finish_reason.to_s == "tool_calls"
end

#handle_reasoning_delta(delta, state:, yielder:) ⇒ void

This method returns an undefined value.

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

Parameters:



233
234
235
236
237
238
239
240
241
242
# File 'lib/riffer/providers/open_router.rb', line 233

def handle_reasoning_delta(delta, state:, yielder:)
  # The openai gem's typed Delta model strips fields not in OpenAI's spec
  # (so +delta.reasoning+ raises NoMethodError), but the underlying data
  # hash retains them. Access via +#[]+ which reads from BaseModel#@data.
  reasoning = delta[:reasoning] if delta.respond_to?(:[])
  return if reasoning.nil? || reasoning.empty?

  state[:reasoning] << reasoning
  yielder << Riffer::StreamEvents::ReasoningDelta.new(reasoning)
end

#handle_stream_chunk(chunk, state:, yielder:) ⇒ void

This method returns an undefined value.

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

Parameters:



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/riffer/providers/open_router.rb', line 198

def handle_stream_chunk(chunk, state:, yielder:)
  typed_chunk = chunk #: OpenAI::Models::Chat::ChatCompletionChunk
  choice = typed_chunk.choices&.first
  delta = choice&.delta

  if delta
    handle_text_delta(delta, state: state, yielder: yielder)
    handle_reasoning_delta(delta, state: state, yielder: yielder)
    handle_tool_call_deltas(delta, state: state, yielder: yielder)
  end

  state[:finish_reason] = choice.finish_reason if choice&.finish_reason

  if choice && finish_reason_is_tool_calls?(choice)
    emit_tool_call_done_events(state: state, yielder: yielder)
  end

  return unless typed_chunk.usage

  yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: build_token_usage(typed_chunk.usage))
end

#handle_text_delta(delta, state:, yielder:) ⇒ void

This method returns an undefined value.

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

Parameters:



222
223
224
225
226
227
228
229
# File 'lib/riffer/providers/open_router.rb', line 222

def handle_text_delta(delta, state:, yielder:)
  typed_delta = delta #: OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta
  content = typed_delta.content
  return if content.nil? || content.empty?

  state[:text] << content
  yielder << Riffer::StreamEvents::TextDelta.new(content)
end

#handle_tool_call_deltas(delta, state:, yielder:) ⇒ void

This method returns an undefined value.

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

Parameters:



246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/riffer/providers/open_router.rb', line 246

def handle_tool_call_deltas(delta, state:, yielder:)
  typed_delta = delta #: OpenAI::Models::Chat::ChatCompletionChunk::Choice::Delta
  tool_calls = typed_delta.tool_calls
  return if tool_calls.nil? || tool_calls.empty?

  tool_calls.each do |tc|
    entry = state[:tool_calls][tc.index] ||= {id: nil, name: nil, arguments: +""}
    entry[:id] = tc.id if tc.id

    fn = tc.function
    next unless fn

    entry[:name] = decode_tool_name(fn.name, tools: @current_tools) if fn.name

    args_delta = fn.arguments
    next if args_delta.nil? || args_delta.empty?

    entry[:arguments] << args_delta
    yielder << Riffer::StreamEvents::ToolCallDelta.new(
      item_id: entry[:id] || "tool_#{tc.index}",
      name: entry[:name],
      arguments_delta: args_delta
    )
  end
end