Skip to content
Draft
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
6 changes: 6 additions & 0 deletions lib/ruby_llm/attachment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ def to_h
{ type: type, source: @source }
end

def size
return nil unless path?

File.size(@source)
end

private

def determine_mime_type
Expand Down
1 change: 1 addition & 0 deletions lib/ruby_llm/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ def complete(&) # rubocop:disable Metrics/PerceivedComplexity

def add_message(message_or_attributes)
message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
message = @provider.preprocess_message(message)
messages << message
message
end
Expand Down
3 changes: 3 additions & 0 deletions lib/ruby_llm/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Configuration
:anthropic_api_key,
:gemini_api_key,
:gemini_api_base,
:gemini_large_file_threshold,
:vertexai_project_id,
:vertexai_location,
:deepseek_api_key,
Expand Down Expand Up @@ -62,6 +63,8 @@ def initialize
@default_image_model = 'gpt-image-1'
@default_transcription_model = 'whisper-1'

@gemini_large_file_threshold = 20_000_000

@model_registry_file = File.expand_path('models.json', __dir__)
@model_registry_class = 'Model'
@use_new_acts_as = false
Expand Down
4 changes: 4 additions & 0 deletions lib/ruby_llm/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ def parse_tool_calls(_tool_calls)
nil
end

def preprocess_message(message)
message
end

class << self
def name
to_s.split('::').last
Expand Down
43 changes: 43 additions & 0 deletions lib/ruby_llm/providers/gemini.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,49 @@ def headers
}
end

def preprocess_message(message)
return message unless message.content.is_a?(Content)

large_attachments = find_large_attachments(message.content.attachments)
return message if large_attachments.empty?

transform_message_with_uploads(message, large_attachments)
end

private

def find_large_attachments(attachments)
threshold = @config.gemini_large_file_threshold || 20_000_000
attachments.select do |attachment|
attachment.size && attachment.size > threshold
end
end

def transform_message_with_uploads(message, large_attachments)
message.dup.tap do |new_message|
content = message.content.dup
content.instance_variable_set(:@attachments, message.content.attachments.map do |att|
if large_attachments.include?(att)
# Upload and replace with URI
file_uri = upload_attachment(att)
create_attachment_from_uri(file_uri, att.filename)
else
att
end
end)
new_message.content = content
end
end

def create_attachment_from_uri(uri, filename)
Attachment.new(uri, filename:)
end

def upload_attachment(attachment)
service = FileUploadService.new(@config)
service.upload(attachment)
end

class << self
def capabilities
Gemini::Capabilities
Expand Down
88 changes: 88 additions & 0 deletions lib/ruby_llm/providers/gemini/file_upload_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

module RubyLLM
module Providers
class Gemini
# Service for uploading large files to Gemini's Files API
class FileUploadService
MAX_POLL_TIME = 60
POLLING_INTERVAL = 2

attr_reader :config

def initialize(config)
@config = config
@faraday = Faraday.new(url: api_base) do |f|
f.headers['x-goog-api-key'] = @config.gemini_api_key
f.request :json
f.response :json
f.adapter :net_http
end
end

def upload(attachment)
upload_id = initiate_upload(attachment)
file_uri = upload_file_content(attachment, upload_id)
wait_for_processing(file_uri)
file_uri
end

private

def api_base
@config.gemini_api_base || 'https://generativelanguage.googleapis.com'
end

def initiate_upload(attachment)
response = @faraday.post('upload/v1beta/files') do |req|
req.headers = req.headers.merge(
'X-Goog-Upload-Protocol' => 'resumable',
'X-Goog-Upload-Command' => 'start',
'X-Goog-Upload-Header-Content-Length' => attachment.size.to_s,
'X-Goog-Upload-Header-Content-Type' => attachment.mime_type
)
req.body = {
file: {
display_name: attachment.filename
}
}.to_json
end
response.headers['x-guploader-uploadid']
end

def upload_file_content(attachment, upload_id)
response = @faraday.post('upload/v1beta/files') do |req|
req.params = req.params.merge(
'upload_id' => upload_id,
'upload_protocol' => 'resumable'
)
req.headers = req.headers.merge(
'Content-Length' => attachment.size.to_s,
'X-Goog-Upload-Offset' => '0',
'X-Goog-Upload-Command' => 'upload, finalize'
)
req.body = File.binread(attachment.source)
end
response.body['file']['uri']
end

def wait_for_processing(file_uri)
file_id = file_uri.split('/').last

start_time = Time.now
while Time.now - start_time < MAX_POLL_TIME
response = @faraday.get("v1beta/files/#{file_id}")
status = response.body['state']
return if status == 'ACTIVE'

sleep POLLING_INTERVAL
end

raise TimeoutError, "File processing timeout after #{MAX_POLL_TIME}s"
end

class TimeoutError < StandardError; end
end
end
end
end
21 changes: 16 additions & 5 deletions lib/ruby_llm/providers/gemini/media.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,23 @@ def format_content(content)
end

def format_attachment(attachment)
{
inline_data: {
mime_type: attachment.mime_type,
data: attachment.encoded
# If attachment has a URI (from FileUploadService), use file_data
if attachment.url?
{
file_data: {
mime_type: attachment.mime_type,
file_uri: attachment.source.to_s
}
}
}
else
# Use inline_data for path-based or IO-based attachments
{
inline_data: {
mime_type: attachment.mime_type,
data: attachment.encoded
}
}
end
end

def format_text_file(text_file)
Expand Down
Binary file added spec/fixtures/ruby_large.mp4
Binary file not shown.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading