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
2 changes: 2 additions & 0 deletions lib/ruby_llm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'deepseek' => 'DeepSeek',
'gpustack' => 'GPUStack',
'llm' => 'LLM',
'llm_gateway' => 'LLMGateway',
'mistral' => 'Mistral',
'openai' => 'OpenAI',
'openrouter' => 'OpenRouter',
Expand Down Expand Up @@ -127,6 +128,7 @@ def logger
RubyLLM::Provider.register :deepseek, RubyLLM::Providers::DeepSeek
RubyLLM::Provider.register :gemini, RubyLLM::Providers::Gemini
RubyLLM::Provider.register :gpustack, RubyLLM::Providers::GPUStack
RubyLLM::Provider.register :llm_gateway, RubyLLM::Providers::LLMGateway
RubyLLM::Provider.register :mistral, RubyLLM::Providers::Mistral
RubyLLM::Provider.register :ollama, RubyLLM::Providers::Ollama
RubyLLM::Provider.register :openai, RubyLLM::Providers::OpenAI
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_llm/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Models
vertexai
bedrock
openrouter
llm_gateway
azure
ollama
gpustack
Expand Down
68 changes: 68 additions & 0 deletions lib/ruby_llm/providers/llm_gateway.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# frozen_string_literal: true

module RubyLLM
module Providers
# LLMGateway.io API integration.
class LLMGateway < Provider
# LLMGateway's dialect of the Chat Completions API.
class ChatCompletions < Protocols::ChatCompletions
include LLMGateway::Chat
include LLMGateway::Images
include LLMGateway::Models
include LLMGateway::Streaming
end

protocol :chat_completions, ChatCompletions
files LLMGateway::Files

def api_base
@config.llm_gateway_api_base || 'https://api.llmgateway.io/v1'
end

def headers
{
'Authorization' => "Bearer #{@config.llm_gateway_api_key}"
}
end

def parse_error(response)
return if response.body.empty?

body = try_parse_json(response.body)
case body
when Hash
parse_error_part_message body
when Array
body.map do |part|
parse_error_part_message part
end.join('. ')
else
body
end
end

class << self
def configuration_options
%i[llm_gateway_api_key llm_gateway_api_base]
end

def configuration_requirements
%i[llm_gateway_api_key]
end
end

private

def parse_error_part_message(part)
message = part.dig('error', 'message')
raw = try_parse_json(part.dig('error', 'metadata', 'raw'))
return message unless raw.is_a?(Hash)

raw_message = raw.dig('error', 'message')
return [message, raw_message].compact.join(' - ') if raw_message

message
end
end
end
end
117 changes: 117 additions & 0 deletions lib/ruby_llm/providers/llm_gateway/chat.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# frozen_string_literal: true

module RubyLLM
module Providers
class LLMGateway
# Chat methods of the LLMGateway API integration
module Chat
LLM_GATEWAY_INLINE_FILE_THRESHOLD = 50 * 1024 * 1024
LLM_GATEWAY_FILE_UPLOAD_LIMIT = 100 * 1024 * 1024

module_function

# rubocop:disable Metrics/ParameterLists
def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil,
thinking: nil, citations: false, tool_prefs: nil)
payload = super
payload.delete(:reasoning_effort)
strip_schema_strict(payload)

reasoning = build_reasoning(thinking)
payload[:reasoning] = reasoning if reasoning
payload
end
# rubocop:enable Metrics/ParameterLists

def strip_schema_strict(payload)
schema_def = payload.dig(:response_format, :json_schema, :schema)
return unless schema_def.is_a?(Hash)

schema_def = RubyLLM::Utils.deep_dup(schema_def)
schema_def.delete(:strict)
schema_def.delete('strict')
payload[:response_format][:json_schema][:schema] = schema_def
end

def build_reasoning(thinking)
return nil unless thinking&.enabled?

reasoning = {}
reasoning[:effort] = thinking.effort if thinking.respond_to?(:effort) && thinking.effort
reasoning[:max_tokens] = thinking.budget if thinking.respond_to?(:budget) && thinking.budget
reasoning[:enabled] = true if reasoning.empty?
reasoning
end

def format_thinking(msg)
thinking = msg.thinking
return {} unless thinking && msg.role == :assistant

details = []
if thinking.text
details << {
type: 'reasoning.text',
text: thinking.text,
signature: thinking.signature
}.compact
elsif thinking.signature
details << {
type: 'reasoning.encrypted',
data: thinking.signature
}
end

details.empty? ? {} : { reasoning_details: details }
end

def supports_provider_file_references?
true
end

def default_large_file_upload_threshold
LLM_GATEWAY_INLINE_FILE_THRESHOLD
end

def provider_file_upload_limit
LLM_GATEWAY_FILE_UPLOAD_LIMIT
end

def provider_file_attachable?(attachment)
attachment.pdf?
end

def extract_thinking_text(message_data)
candidate = message_data['reasoning']
return candidate if candidate.is_a?(String)

details = message_data['reasoning_details']
return nil unless details.is_a?(Array)

text = details.filter_map do |detail|
case detail['type']
when 'reasoning.text'
detail['text']
when 'reasoning.summary'
detail['summary']
end
end.join

text.empty? ? nil : text
end

def extract_thinking_signature(message_data)
details = message_data['reasoning_details']
return nil unless details.is_a?(Array)

signature = details.filter_map do |detail|
detail['signature'] if detail['signature'].is_a?(String)
end.first
return signature if signature

encrypted = details.find { |detail| detail['type'] == 'reasoning.encrypted' && detail['data'].is_a?(String) }
encrypted&.dig('data')
end
end
end
end
end
24 changes: 24 additions & 0 deletions lib/ruby_llm/providers/llm_gateway/files.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module RubyLLM
module Providers
class LLMGateway
# LLMGateway Files API.
class Files < UploadedFile::Protocol
private

def parse_file_response(data)
uploaded_file(
data,
id: data['id'],
filename: data['filename'],
byte_size: data['size_bytes'],
created_at: timestamp(data['created_at']),
mime_type: data['mime_type'],
downloadable: data['downloadable']
)
end
end
end
end
end
69 changes: 69 additions & 0 deletions lib/ruby_llm/providers/llm_gateway/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 LLMGateway
# Image generation methods for the LLMGateway API integration.
# LLMGateway uses the chat completions endpoint for image generation
# instead of a dedicated images endpoint.
module Images
module_function

def images_url(with: nil, mask: nil) # rubocop:disable Lint/UnusedMethodArgument
'chat/completions'
end

def render_image_payload(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists
RubyLLM.logger.debug { "Ignoring size #{size}. LLMGateway 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 LLMGateway 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 LLMGateway 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 LLMGateway') 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
73 changes: 73 additions & 0 deletions lib/ruby_llm/providers/llm_gateway/models.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

module RubyLLM
module Providers
class LLMGateway
# Models methods of the LLMGateway API integration
module Models
module_function

def models_url
'models'
end

def parse_list_models_response(response, slug, _capabilities)
Array(response.body['data']).map do |model_data| # rubocop:disable Metrics/BlockLength
modalities = {
input: Array(model_data.dig('architecture', 'input_modalities')),
output: Array(model_data.dig('architecture', 'output_modalities'))
}

pricing = { text_tokens: { standard: {} } }

pricing_types = {
prompt: :input_per_million,
completion: :output_per_million,
input_cache_read: :cache_read_input_per_million,
internal_reasoning: :reasoning_output_per_million
}

pricing_types.each do |source_key, target_key|
value = model_data.dig('pricing', source_key.to_s).to_f
pricing[:text_tokens][:standard][target_key] = value * 1_000_000 if value.positive?
end

capabilities = supported_parameters_to_capabilities(model_data['supported_parameters'])

Model::Info.new(
id: model_data['id'],
name: model_data['name'],
provider: slug,
family: model_data['family'] || model_data['id'].to_s.split('/').first,
created_at: model_data['created'] ? Time.at(model_data['created']) : nil,
context_window: model_data['context_length'],
max_output_tokens: model_data.dig('top_provider', 'max_completion_tokens'),
modalities: modalities,
capabilities: capabilities,
pricing: pricing,
metadata: {
description: model_data['description'],
architecture: model_data['architecture'],
top_provider: model_data['top_provider'],
per_request_limits: model_data['per_request_limits'],
supported_parameters: model_data['supported_parameters']
}
)
end
end

def supported_parameters_to_capabilities(params)
return [] unless params

capabilities = []
capabilities << 'streaming'
capabilities << 'function_calling' if params.include?('tools') || params.include?('tool_choice')
capabilities << 'structured_output' if params.include?('response_format')
capabilities << 'batch' if params.include?('batch')
capabilities << 'predicted_outputs' if params.include?('logit_bias') && params.include?('top_k')
capabilities
end
end
end
end
end
Loading