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 resolves from config, then OPENROUTER_API_KEY.

Constant Summary collapse

BASE_URL =

Signature:

  • 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

#initializeOpenRouter

-- : () -> void



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

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

Deliberately not compacted: this borrows the OpenAI SDK to talk to a different vendor, so omitting an unset api_key would let the SDK fall back to OPENAI_API_KEY and send an OpenAI credential to OpenRouter. Passing nil raises in the SDK instead. OPENROUTER_API_KEY is read here rather than left to the SDK for the same reason.

: () -> untyped

Returns:

  • (Object)


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

def build_client
  api_key = Riffer.config.openrouter.api_key || ENV.fetch("OPENROUTER_API_KEY", nil)
  ::OpenAI::Client.new(api_key: api_key, base_url: BASE_URL)
end

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

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

Parameters:

  • (Object)

Returns:



135
136
137
138
139
140
141
142
# File 'lib/riffer/providers/open_router.rb', line 135

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


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

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

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

  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:



116
117
118
119
120
121
122
123
124
# File 'lib/riffer/providers/open_router.rb', line 116

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

#client::OpenAI::Client

Returns:

  • (::OpenAI::Client)


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

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

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

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

Parameters:

Returns:

  • (Hash[Symbol, untyped])


334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/riffer/providers/open_router.rb', line 334

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


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

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


309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/riffer/providers/open_router.rb', line 309

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


370
371
372
373
374
375
376
377
378
379
380
# File 'lib/riffer/providers/open_router.rb', line 370

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:



287
288
289
290
291
292
293
294
295
296
297
298
# File 'lib/riffer/providers/open_router.rb', line 287

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)


100
101
102
# File 'lib/riffer/providers/open_router.rb', line 100

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:



174
175
176
177
178
179
180
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
# File 'lib/riffer/providers/open_router.rb', line 174

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)


146
147
148
149
# File 'lib/riffer/providers/open_router.rb', line 146

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:



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

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:



106
107
108
109
110
111
112
# File 'lib/riffer/providers/open_router.rb', line 106

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:



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/riffer/providers/open_router.rb', line 153

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)


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

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

#global_clientObject

-- : () -> untyped

Returns:

  • (Object)


40
41
42
# File 'lib/riffer/providers/open_router.rb', line 40

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



246
247
248
249
250
251
252
253
254
255
# File 'lib/riffer/providers/open_router.rb', line 246

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:



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/riffer/providers/open_router.rb', line 213

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

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

  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:



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

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:



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/riffer/providers/open_router.rb', line 259

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