Skip to content

Improve OpenRouter Support #144

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@ RubyLLM.configure do |config|
config.openrouter_api_key = ENV.fetch('OPENROUTER_API_KEY', nil)
config.ollama_api_base = ENV.fetch('OLLAMA_API_BASE', nil)

# --- Open Router Configuration ---
config.openrouter_referer = 'https://rubyllm.com'
config.openrouter_title = 'RubyLLM'
config.openrouter_provider_order = nil
config.openrouter_provider_allow_fallbacks = true
config.openrouter_provider_require_parameters = false
config.openrouter_provider_data_collection = 'allow'
config.openrouter_provider_ignore = nil
config.openrouter_provider_quantizations = nil
config.openrouter_provider_sort = nil

# --- AWS Bedrock Credentials ---
# Uses standard AWS credential chain (environment, shared config, IAM role)
# if these specific keys aren't set. Region is required if using Bedrock.
Expand Down Expand Up @@ -224,4 +235,3 @@ default_response = default_chat.ask("Query using global production settings...")
* **Thread Safety:** Each context is independent, making them safe for use across different threads.

Contexts provide a clean and safe mechanism for managing diverse configuration needs within a single application.

12 changes: 11 additions & 1 deletion lib/ruby_llm/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,18 @@ class Configuration
:bedrock_secret_key,
:bedrock_region,
:bedrock_session_token,
:openrouter_api_key,
:ollama_api_base,
# OpenRouter-specific configuration
:openrouter_api_key,
:openrouter_referer,
:openrouter_title,
:openrouter_provider_order,
:openrouter_provider_allow_fallbacks,
:openrouter_provider_require_parameters,
:openrouter_provider_data_collection,
:openrouter_provider_ignore,
:openrouter_provider_quantizations,
:openrouter_provider_sort,
# Default models
:default_model,
:default_embedding_model,
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/anthropic/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def format_message(msg)
def format_basic_message(msg)
{
role: convert_role(msg.role),
content: Media.format_content(msg.content)
content: self::Media.format_content(msg.content)
}
end

Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/bedrock/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def format_message(msg)
def format_basic_message(msg)
{
role: Anthropic::Chat.convert_role(msg.role),
content: Media.format_content(msg.content)
content: self::Media.format_content(msg.content)
}
end

Expand Down
14 changes: 14 additions & 0 deletions lib/ruby_llm/providers/deepseek/media.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module RubyLLM
module Providers
module DeepSeek
# Handles formatting of media content (images, audio) for DeepSeek APIs
module Media
include OpenAI::Media

module_function :format_content, :format_image, :format_pdf, :format_audio, :format_text
end
end
end
end
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/gemini/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def format_parts(msg)
}
}]
else
Media.format_content(msg.content)
self::Media.format_content(msg.content)
end
end

Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/providers/openai/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def format_messages(messages)
messages.map do |msg|
{
role: format_role(msg.role),
content: Media.format_content(msg.content),
content: self::Media.format_content(msg.content),
tool_calls: format_tool_calls(msg.tool_calls),
tool_call_id: msg.tool_call_id
}.compact
Expand Down
9 changes: 7 additions & 2 deletions lib/ruby_llm/providers/openrouter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,23 @@ module Providers
# OpenRouter API integration.
module OpenRouter
extend OpenAI
extend OpenRouter::Chat
extend OpenRouter::Models
extend OpenRouter::Media

module_function

def api_base(_config)
'https://openrouter.ai/api/v1'
end

# @see https://openrouter.ai/docs/api-reference/overview#headers
def headers(config)
{
'Authorization' => "Bearer #{config.openrouter_api_key}"
}
'Authorization' => "Bearer #{config.openrouter_api_key}",
'HTTP-Referer' => config.openrouter_referer, # Optional: Site URL for rankings on openrouter.ai.
'X-Title' => config.openrouter_title # Optional: Site title for rankings on openrouter.ai.
}.compact
end

def slug
Expand Down
83 changes: 83 additions & 0 deletions lib/ruby_llm/providers/openrouter/chat.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

module RubyLLM
module Providers
module OpenRouter
# Chat methods of the OpenRouter API integration
module Chat
def completion_url
'chat/completions'
end

module_function

def render_payload(messages, tools:, temperature:, model:, stream: false) # rubocop:disable Metrics/MethodLength
{
model: model,
messages: format_messages(messages),
temperature: temperature,
stream: stream,
provider: format_provider_options # @todo Allow for assistant overriding
}.tap do |payload|
if tools.any?
payload[:tools] = tools.map { |_, tool| tool_for(tool) }
payload[:tool_choice] = 'auto'
end
payload[:stream_options] = { include_usage: true } if stream
end
end

def parse_completion_response(response) # rubocop:disable Metrics/MethodLength
data = response.body
return if data.empty?

raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')

message_data = data.dig('choices', 0, 'message')
return unless message_data

Message.new(
role: :assistant,
content: message_data['content'],
tool_calls: parse_tool_calls(message_data['tool_calls']),
input_tokens: data['usage']['prompt_tokens'],
output_tokens: data['usage']['completion_tokens'],
model_id: data['model']
)
end

def format_messages(messages)
messages.map do |msg|
{
role: format_role(msg.role),
content: self::Media.format_content(msg.content),
tool_calls: format_tool_calls(msg.tool_calls),
tool_call_id: msg.tool_call_id
}.compact
end
end

def format_role(role)
case role
when :system
'developer'
else
role.to_s
end
end

def format_provider_options
{
order: @connection.config.openrouter_provider_order,
allow_fallbacks: @connection.config.openrouter_provider_allow_fallbacks,
require_parameters: @connection.config.openrouter_provider_require_parameters,
data_collection: @connection.config.openrouter_provider_data_collection,
ignore: @connection.config.openrouter_provider_ignore,
quantizations: @connection.config.openrouter_provider_quantizations,
sort: @connection.config.openrouter_provider_sort
}.compact
end
end
end
end
end
72 changes: 72 additions & 0 deletions lib/ruby_llm/providers/openrouter/media.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

module RubyLLM
module Providers
module OpenRouter
# Handles formatting of media content (images, audio) for OpenRouter APIs
module Media
module_function

def format_content(content) # rubocop:disable Metrics/MethodLength
return content unless content.is_a?(Array)

content.map do |part|
case part[:type]
when 'image'
format_image(part)
when 'input_audio'
format_audio(part)
when 'pdf'
format_pdf(part)
else
part
end
end
end

# @see https://openrouter.ai/docs/features/images-and-pdfs#image-inputs
def format_image(part)
{
type: 'image_url',
image_url: {
url: format_image_url(part[:source]),
detail: 'auto'
}
}
end

def format_image_url(source)
if source[:type] == 'base64'
"data:#{source[:media_type]};base64,#{source[:data]}"
else
source[:url]
end
end

def format_audio(part)
{
type: 'input_audio',
input_audio: part[:input_audio]
}
end

# @see https://openrouter.ai/docs/features/images-and-pdfs#pdf-support
def format_pdf(part)
{
type: 'file',
file: {
filename: File.basename(part[:source]),
file_data: format_file_data(part[:content] || part[:source])
}
}
end

def format_file_data(source)
source = Faraday.get(source).body if source.start_with?('http')

"data:application/pdf;base64,#{Base64.strict_encode64(source)}"
end
end
end
end
end

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions spec/ruby_llm/chat_pdf_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe RubyLLM::Chat do
include_context 'with configured RubyLLM'

describe 'pdf model' do
shared_examples 'PDF_MODELS' do |model, provider|
it "#{provider}/#{model} understands PDFs" do # rubocop:disable RSpec/MultipleExpectations
chat = RubyLLM.chat(model: model, provider: provider)
response = chat.ask('Summarize this document', with: { pdf: pdf_locator })
expect(response.content).not_to be_empty

response = chat.ask 'go on'
expect(response.content).not_to be_empty
end

it "#{provider}/#{model} handles multiple PDFs" do # rubocop:disable RSpec/MultipleExpectations
chat = RubyLLM.chat(model: model, provider: provider)
# Using same file twice for testing
response = chat.ask('Compare these documents', with: { pdf: [pdf_locator, pdf_locator] })
expect(response.content).not_to be_empty

response = chat.ask 'go on'
expect(response.content).not_to be_empty
end
end

PDF_MODELS.each do |model_info|
model = model_info[:model]
provider = model_info[:provider]

context 'with Paths' do
let(:pdf_locator) { File.expand_path('../fixtures/sample.pdf', __dir__) }

it_behaves_like 'PDF_MODELS', model, provider
end

context 'with URLs' do
let(:pdf_locator) { 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf' }

it_behaves_like 'PDF_MODELS', model, provider
end
end
end
end