Skip to content
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
1 change: 1 addition & 0 deletions lib/ruby_llm/providers/openrouter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module Providers
# OpenRouter API integration.
class OpenRouter < OpenAI
include OpenRouter::Models
include OpenRouter::Images

def api_base
'https://openrouter.ai/api/v1'
Expand Down
69 changes: 69 additions & 0 deletions lib/ruby_llm/providers/openrouter/images.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module RubyLLM
module Providers
class OpenRouter
# Image generation methods for the OpenRouter API integration.
# OpenRouter uses the chat completions endpoint for image generation
# instead of a dedicated images endpoint.
module Images
module_function

def images_url
'chat/completions'
end

def render_image_payload(prompt, model:, size:)
RubyLLM.logger.debug "Ignoring size #{size}. OpenRouter image generation does not support size parameter."
{
model: model,
messages: [
{
role: 'user',
content: prompt
}
],
modalities: %w[image text]
}
end

def parse_image_response(response, model:)
data = response.body
message = data.dig('choices', 0, 'message')

unless message&.key?('images') && message['images']&.any?
raise Error.new(nil, 'Unexpected response format from OpenRouter image generation API')
end

image_data = message['images'].first
image_url = image_data.dig('image_url', 'url') || image_data['url']

raise Error.new(nil, 'No image URL found in OpenRouter response') unless image_url

build_image_from_url(image_url, model)
end

def build_image_from_url(image_url, model)
if image_url.start_with?('data:')
# Parse data URL format: data:image/png;base64,<data>
match = image_url.match(/^data:([^;]+);base64,(.+)$/)
raise Error.new(nil, 'Invalid data URL format from OpenRouter') unless match

Image.new(
data: match[2],
mime_type: match[1],
model_id: model
)
else
# Regular URL
Image.new(
url: image_url,
mime_type: 'image/png',
model_id: model
)
end
end
end
end
end
end

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions spec/ruby_llm/image_generation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,21 @@ def save_and_verify_image(image)

save_and_verify_image image
end

it 'openrouter/google/gemini-2.5-flash-image-preview can paint images' do
image = RubyLLM.paint(
'a siamese cat',
model: 'google/gemini-2.5-flash-image-preview',
provider: :openrouter,
assume_model_exists: true
)

expect(image.base64?).to be(true)
expect(image.data).to be_present
expect(image.mime_type).to include('image')
expect(image.model_id).to eq('google/gemini-2.5-flash-image-preview')

save_and_verify_image image
end
end
end
180 changes: 180 additions & 0 deletions spec/ruby_llm/providers/open_router/images_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe RubyLLM::Providers::OpenRouter::Images do
let(:images_module) { described_class }

describe '.images_url' do
it 'returns the chat completions endpoint' do
expect(images_module.images_url).to eq('chat/completions')
end
end

describe '.render_image_payload' do
it 'renders a chat completion payload with image modality' do
payload = images_module.render_image_payload('a cute cat', model: 'test-model', size: '1024x1024')

expect(payload[:model]).to eq('test-model')
expect(payload[:messages]).to eq([{ role: 'user', content: 'a cute cat' }])
expect(payload[:modalities]).to eq(%w[image text])
end

it 'ignores size parameter and logs debug message' do
allow(RubyLLM.logger).to receive(:debug)
images_module.render_image_payload('a cute cat', model: 'test-model', size: '512x512')
expect(RubyLLM.logger).to have_received(:debug).with(/Ignoring size/)
end
end

describe '.parse_image_response' do
context 'with base64 data URL response' do
let(:response_body) do
{
'choices' => [{
'message' => {
'role' => 'assistant',
'content' => 'Here is an image of a cat',
'images' => [{
'type' => 'image_url',
'image_url' => {
'url' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE='
}
}]
}
}]
}
end
let(:response) { instance_double(Faraday::Response, body: response_body) }

it 'parses base64 image data from response' do
image = images_module.parse_image_response(response, model: 'test-model')

expect(image).to be_a(RubyLLM::Image)
expect(image.base64?).to be(true)
expect(image.data).to eq('iVBORw0KGgoAAAANSUhEUgAAAAE=')
expect(image.mime_type).to eq('image/png')
expect(image.model_id).to eq('test-model')
end
end

context 'with jpeg image data URL' do
let(:response_body) do
{
'choices' => [{
'message' => {
'images' => [{
'image_url' => { 'url' => 'data:image/jpeg;base64,/9j/4AAQSkZJRg==' }
}]
}
}]
}
end
let(:response) { instance_double(Faraday::Response, body: response_body) }

it 'parses jpeg image data' do
image = images_module.parse_image_response(response, model: 'test-model')

expect(image.mime_type).to eq('image/jpeg')
expect(image.data).to eq('/9j/4AAQSkZJRg==')
end
end

context 'with direct url field (alternative format)' do
let(:response_body) do
{
'choices' => [{
'message' => {
'images' => [{ 'url' => 'data:image/png;base64,abc123' }]
}
}]
}
end
let(:response) { instance_double(Faraday::Response, body: response_body) }

it 'parses image from direct url field' do
image = images_module.parse_image_response(response, model: 'test-model')

expect(image.data).to eq('abc123')
end
end

context 'with regular URL response' do
let(:response_body) do
{
'choices' => [{
'message' => {
'images' => [{
'image_url' => { 'url' => 'https://example.com/image.png' }
}]
}
}]
}
end
let(:response) { instance_double(Faraday::Response, body: response_body) }

it 'creates image with URL instead of base64 data' do
image = images_module.parse_image_response(response, model: 'test-model')

expect(image.base64?).to be(false)
expect(image.url).to eq('https://example.com/image.png')
expect(image.mime_type).to eq('image/png')
end
end

context 'with missing images in response' do
let(:response_body) do
{
'choices' => [{
'message' => { 'content' => 'Sorry, I cannot generate images' }
}]
}
end
let(:response) { instance_double(Faraday::Response, body: response_body) }

it 'raises an error' do
expect do
images_module.parse_image_response(response, model: 'test-model')
end.to raise_error(RubyLLM::Error, /Unexpected response format/)
end
end

context 'with empty images array' do
let(:response_body) do
{
'choices' => [{
'message' => { 'images' => [] }
}]
}
end
let(:response) { instance_double(Faraday::Response, body: response_body) }

it 'raises an error' do
expect do
images_module.parse_image_response(response, model: 'test-model')
end.to raise_error(RubyLLM::Error, /Unexpected response format/)
end
end

context 'with invalid data URL format' do
let(:response_body) do
{
'choices' => [{
'message' => {
'images' => [{
'image_url' => { 'url' => 'data:invalid-format' }
}]
}
}]
}
end
let(:response) { instance_double(Faraday::Response, body: response_body) }

it 'raises an error for invalid data URL' do
expect do
images_module.parse_image_response(response, model: 'test-model')
end.to raise_error(RubyLLM::Error, /Invalid data URL format/)
end
end
end
end