ruby_llm_community 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +22 -0
- data/README.md +172 -0
- data/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt +108 -0
- data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +8 -0
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +15 -0
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +14 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +6 -0
- data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install_generator.rb +121 -0
- data/lib/ruby_llm/active_record/acts_as.rb +382 -0
- data/lib/ruby_llm/aliases.json +217 -0
- data/lib/ruby_llm/aliases.rb +56 -0
- data/lib/ruby_llm/attachment.rb +164 -0
- data/lib/ruby_llm/chat.rb +226 -0
- data/lib/ruby_llm/chunk.rb +6 -0
- data/lib/ruby_llm/configuration.rb +73 -0
- data/lib/ruby_llm/connection.rb +126 -0
- data/lib/ruby_llm/content.rb +52 -0
- data/lib/ruby_llm/context.rb +29 -0
- data/lib/ruby_llm/embedding.rb +30 -0
- data/lib/ruby_llm/error.rb +84 -0
- data/lib/ruby_llm/image.rb +53 -0
- data/lib/ruby_llm/message.rb +81 -0
- data/lib/ruby_llm/mime_type.rb +67 -0
- data/lib/ruby_llm/model/info.rb +101 -0
- data/lib/ruby_llm/model/modalities.rb +22 -0
- data/lib/ruby_llm/model/pricing.rb +51 -0
- data/lib/ruby_llm/model/pricing_category.rb +48 -0
- data/lib/ruby_llm/model/pricing_tier.rb +34 -0
- data/lib/ruby_llm/model.rb +7 -0
- data/lib/ruby_llm/models.json +29924 -0
- data/lib/ruby_llm/models.rb +214 -0
- data/lib/ruby_llm/models_schema.json +168 -0
- data/lib/ruby_llm/provider.rb +221 -0
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +179 -0
- data/lib/ruby_llm/providers/anthropic/chat.rb +120 -0
- data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
- data/lib/ruby_llm/providers/anthropic/media.rb +116 -0
- data/lib/ruby_llm/providers/anthropic/models.rb +56 -0
- data/lib/ruby_llm/providers/anthropic/streaming.rb +45 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +108 -0
- data/lib/ruby_llm/providers/anthropic.rb +37 -0
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +76 -0
- data/lib/ruby_llm/providers/bedrock/media.rb +73 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +82 -0
- data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +63 -0
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +71 -0
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +79 -0
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +92 -0
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +91 -0
- data/lib/ruby_llm/providers/bedrock/streaming.rb +36 -0
- data/lib/ruby_llm/providers/bedrock.rb +83 -0
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +131 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +17 -0
- data/lib/ruby_llm/providers/deepseek.rb +30 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +351 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +146 -0
- data/lib/ruby_llm/providers/gemini/embeddings.rb +39 -0
- data/lib/ruby_llm/providers/gemini/images.rb +48 -0
- data/lib/ruby_llm/providers/gemini/media.rb +55 -0
- data/lib/ruby_llm/providers/gemini/models.rb +41 -0
- data/lib/ruby_llm/providers/gemini/streaming.rb +66 -0
- data/lib/ruby_llm/providers/gemini/tools.rb +82 -0
- data/lib/ruby_llm/providers/gemini.rb +36 -0
- data/lib/ruby_llm/providers/gpustack/chat.rb +17 -0
- data/lib/ruby_llm/providers/gpustack/models.rb +55 -0
- data/lib/ruby_llm/providers/gpustack.rb +33 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +163 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +26 -0
- data/lib/ruby_llm/providers/mistral/embeddings.rb +36 -0
- data/lib/ruby_llm/providers/mistral/models.rb +49 -0
- data/lib/ruby_llm/providers/mistral.rb +32 -0
- data/lib/ruby_llm/providers/ollama/chat.rb +28 -0
- data/lib/ruby_llm/providers/ollama/media.rb +50 -0
- data/lib/ruby_llm/providers/ollama.rb +29 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +306 -0
- data/lib/ruby_llm/providers/openai/chat.rb +87 -0
- data/lib/ruby_llm/providers/openai/embeddings.rb +36 -0
- data/lib/ruby_llm/providers/openai/images.rb +38 -0
- data/lib/ruby_llm/providers/openai/media.rb +81 -0
- data/lib/ruby_llm/providers/openai/models.rb +39 -0
- data/lib/ruby_llm/providers/openai/response.rb +116 -0
- data/lib/ruby_llm/providers/openai/response_media.rb +76 -0
- data/lib/ruby_llm/providers/openai/streaming.rb +191 -0
- data/lib/ruby_llm/providers/openai/tools.rb +100 -0
- data/lib/ruby_llm/providers/openai.rb +44 -0
- data/lib/ruby_llm/providers/openai_base.rb +44 -0
- data/lib/ruby_llm/providers/openrouter/models.rb +88 -0
- data/lib/ruby_llm/providers/openrouter.rb +26 -0
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +138 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +17 -0
- data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
- data/lib/ruby_llm/providers/perplexity.rb +52 -0
- data/lib/ruby_llm/railtie.rb +17 -0
- data/lib/ruby_llm/stream_accumulator.rb +103 -0
- data/lib/ruby_llm/streaming.rb +162 -0
- data/lib/ruby_llm/tool.rb +100 -0
- data/lib/ruby_llm/tool_call.rb +31 -0
- data/lib/ruby_llm/utils.rb +49 -0
- data/lib/ruby_llm/version.rb +5 -0
- data/lib/ruby_llm.rb +98 -0
- data/lib/tasks/aliases.rake +235 -0
- data/lib/tasks/models_docs.rake +224 -0
- data/lib/tasks/models_update.rake +108 -0
- data/lib/tasks/release.rake +32 -0
- data/lib/tasks/vcr.rake +99 -0
- metadata +128 -7
@@ -0,0 +1,164 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
# A class representing a file attachment.
|
5
|
+
class Attachment
|
6
|
+
attr_reader :source, :filename, :mime_type
|
7
|
+
|
8
|
+
def initialize(source, filename: nil)
|
9
|
+
@source = source
|
10
|
+
if url?
|
11
|
+
@source = URI source
|
12
|
+
@filename = filename || File.basename(@source.path).to_s
|
13
|
+
elsif path?
|
14
|
+
@source = Pathname.new source
|
15
|
+
@filename = filename || @source.basename.to_s
|
16
|
+
elsif active_storage?
|
17
|
+
@filename = filename || extract_filename_from_active_storage
|
18
|
+
else
|
19
|
+
@filename = filename
|
20
|
+
end
|
21
|
+
|
22
|
+
determine_mime_type
|
23
|
+
end
|
24
|
+
|
25
|
+
def url?
|
26
|
+
@source.is_a?(URI) || (@source.is_a?(String) && @source.match?(%r{^https?://}))
|
27
|
+
end
|
28
|
+
|
29
|
+
def path?
|
30
|
+
@source.is_a?(Pathname) || (@source.is_a?(String) && !url?)
|
31
|
+
end
|
32
|
+
|
33
|
+
def io_like?
|
34
|
+
@source.respond_to?(:read) && !path? && !active_storage?
|
35
|
+
end
|
36
|
+
|
37
|
+
def active_storage?
|
38
|
+
return false unless defined?(ActiveStorage)
|
39
|
+
|
40
|
+
@source.is_a?(ActiveStorage::Blob) ||
|
41
|
+
@source.is_a?(ActiveStorage::Attached::One) ||
|
42
|
+
@source.is_a?(ActiveStorage::Attached::Many)
|
43
|
+
end
|
44
|
+
|
45
|
+
def content
|
46
|
+
return @content if defined?(@content) && !@content.nil?
|
47
|
+
|
48
|
+
if url?
|
49
|
+
fetch_content
|
50
|
+
elsif path?
|
51
|
+
load_content_from_path
|
52
|
+
elsif active_storage?
|
53
|
+
load_content_from_active_storage
|
54
|
+
elsif io_like?
|
55
|
+
load_content_from_io
|
56
|
+
else
|
57
|
+
RubyLLM.logger.warn "Source is neither a URL, path, ActiveStorage, nor IO-like: #{@source.class}"
|
58
|
+
nil
|
59
|
+
end
|
60
|
+
|
61
|
+
@content
|
62
|
+
end
|
63
|
+
|
64
|
+
def encoded
|
65
|
+
Base64.strict_encode64(content)
|
66
|
+
end
|
67
|
+
|
68
|
+
def type
|
69
|
+
return :image if image?
|
70
|
+
return :audio if audio?
|
71
|
+
return :pdf if pdf?
|
72
|
+
return :text if text?
|
73
|
+
|
74
|
+
:unknown
|
75
|
+
end
|
76
|
+
|
77
|
+
def image?
|
78
|
+
RubyLLM::MimeType.image? mime_type
|
79
|
+
end
|
80
|
+
|
81
|
+
def audio?
|
82
|
+
RubyLLM::MimeType.audio? mime_type
|
83
|
+
end
|
84
|
+
|
85
|
+
def pdf?
|
86
|
+
RubyLLM::MimeType.pdf? mime_type
|
87
|
+
end
|
88
|
+
|
89
|
+
def text?
|
90
|
+
RubyLLM::MimeType.text? mime_type
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_h
|
94
|
+
{ type: type, source: @source }
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def determine_mime_type
|
100
|
+
return @mime_type = active_storage_content_type if active_storage? && active_storage_content_type.present?
|
101
|
+
|
102
|
+
@mime_type = RubyLLM::MimeType.for(url? ? nil : @source, name: @filename)
|
103
|
+
@mime_type = RubyLLM::MimeType.for(content) if @mime_type == 'application/octet-stream'
|
104
|
+
@mime_type = 'audio/wav' if @mime_type == 'audio/x-wav' # Normalize WAV type
|
105
|
+
end
|
106
|
+
|
107
|
+
def fetch_content
|
108
|
+
response = Connection.basic.get @source.to_s
|
109
|
+
@content = response.body
|
110
|
+
end
|
111
|
+
|
112
|
+
def load_content_from_path
|
113
|
+
@content = File.read(@source)
|
114
|
+
end
|
115
|
+
|
116
|
+
def load_content_from_io
|
117
|
+
@source.rewind if @source.respond_to? :rewind
|
118
|
+
@content = @source.read
|
119
|
+
end
|
120
|
+
|
121
|
+
def load_content_from_active_storage
|
122
|
+
return unless defined?(ActiveStorage)
|
123
|
+
|
124
|
+
@content = case @source
|
125
|
+
when ActiveStorage::Blob
|
126
|
+
@source.download
|
127
|
+
when ActiveStorage::Attached::One
|
128
|
+
@source.blob&.download
|
129
|
+
when ActiveStorage::Attached::Many
|
130
|
+
# For multiple attachments, just take the first one
|
131
|
+
# This maintains the single-attachment interface
|
132
|
+
@source.blobs.first&.download
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def extract_filename_from_active_storage # rubocop:disable Metrics/PerceivedComplexity
|
137
|
+
return 'attachment' unless defined?(ActiveStorage)
|
138
|
+
|
139
|
+
case @source
|
140
|
+
when ActiveStorage::Blob
|
141
|
+
@source.filename.to_s
|
142
|
+
when ActiveStorage::Attached::One
|
143
|
+
@source.blob&.filename&.to_s || 'attachment'
|
144
|
+
when ActiveStorage::Attached::Many
|
145
|
+
@source.blobs.first&.filename&.to_s || 'attachment'
|
146
|
+
else
|
147
|
+
'attachment'
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def active_storage_content_type
|
152
|
+
return unless defined?(ActiveStorage)
|
153
|
+
|
154
|
+
case @source
|
155
|
+
when ActiveStorage::Blob
|
156
|
+
@source.content_type
|
157
|
+
when ActiveStorage::Attached::One
|
158
|
+
@source.blob&.content_type
|
159
|
+
when ActiveStorage::Attached::Many
|
160
|
+
@source.blobs.first&.content_type
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,226 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
# Represents a conversation with an AI model. Handles message history,
|
5
|
+
# streaming responses, and tool integration with a simple, conversational API.
|
6
|
+
#
|
7
|
+
# Example:
|
8
|
+
# chat = RubyLLM.chat
|
9
|
+
# chat.ask "What's the best way to learn Ruby?"
|
10
|
+
# chat.ask "Can you elaborate on that?"
|
11
|
+
class Chat
|
12
|
+
include Enumerable
|
13
|
+
|
14
|
+
attr_reader :model, :messages, :tools, :params, :headers, :schema
|
15
|
+
|
16
|
+
def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
|
17
|
+
if assume_model_exists && !provider
|
18
|
+
raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
|
19
|
+
end
|
20
|
+
|
21
|
+
@context = context
|
22
|
+
@config = context&.config || RubyLLM.config
|
23
|
+
model_id = model || @config.default_model
|
24
|
+
with_model(model_id, provider: provider, assume_exists: assume_model_exists)
|
25
|
+
@temperature = 0.7
|
26
|
+
@messages = []
|
27
|
+
@tools = {}
|
28
|
+
@cache_prompts = { system: false, user: false, tools: false }
|
29
|
+
@params = {}
|
30
|
+
@headers = {}
|
31
|
+
@schema = nil
|
32
|
+
@on = {
|
33
|
+
new_message: nil,
|
34
|
+
end_message: nil,
|
35
|
+
tool_call: nil,
|
36
|
+
tool_result: nil
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
def ask(message = nil, with: nil, &)
|
41
|
+
add_message role: :user, content: Content.new(message, with)
|
42
|
+
complete(&)
|
43
|
+
end
|
44
|
+
|
45
|
+
alias say ask
|
46
|
+
|
47
|
+
def with_instructions(instructions, replace: false)
|
48
|
+
@messages = @messages.reject { |msg| msg.role == :system } if replace
|
49
|
+
|
50
|
+
add_message role: :system, content: instructions
|
51
|
+
self
|
52
|
+
end
|
53
|
+
|
54
|
+
def with_tool(tool)
|
55
|
+
tool_instance = tool.is_a?(Class) ? tool.new : tool
|
56
|
+
@tools[tool_instance.name.to_sym] = tool_instance
|
57
|
+
self
|
58
|
+
end
|
59
|
+
|
60
|
+
def with_tools(*tools, replace: false)
|
61
|
+
@tools.clear if replace
|
62
|
+
tools.compact.each { |tool| with_tool tool }
|
63
|
+
self
|
64
|
+
end
|
65
|
+
|
66
|
+
def with_model(model_id, provider: nil, assume_exists: false)
|
67
|
+
@model, @provider = Models.resolve(model_id, provider:, assume_exists:, config: @config)
|
68
|
+
@connection = @provider.connection
|
69
|
+
self
|
70
|
+
end
|
71
|
+
|
72
|
+
def with_temperature(temperature)
|
73
|
+
@temperature = temperature
|
74
|
+
self
|
75
|
+
end
|
76
|
+
|
77
|
+
def with_context(context)
|
78
|
+
@context = context
|
79
|
+
@config = context.config
|
80
|
+
with_model(@model.id, provider: @provider.slug, assume_exists: true)
|
81
|
+
self
|
82
|
+
end
|
83
|
+
|
84
|
+
def with_params(**params)
|
85
|
+
@params = params
|
86
|
+
self
|
87
|
+
end
|
88
|
+
|
89
|
+
def with_headers(**headers)
|
90
|
+
@headers = headers
|
91
|
+
self
|
92
|
+
end
|
93
|
+
|
94
|
+
def with_schema(schema)
|
95
|
+
schema_instance = schema.is_a?(Class) ? schema.new : schema
|
96
|
+
|
97
|
+
# Accept both RubyLLM::Schema instances and plain JSON schemas
|
98
|
+
@schema = if schema_instance.respond_to?(:to_json_schema)
|
99
|
+
schema_instance.to_json_schema[:schema]
|
100
|
+
else
|
101
|
+
schema_instance
|
102
|
+
end
|
103
|
+
|
104
|
+
self
|
105
|
+
end
|
106
|
+
|
107
|
+
def on_new_message(&block)
|
108
|
+
@on[:new_message] = block
|
109
|
+
self
|
110
|
+
end
|
111
|
+
|
112
|
+
def on_end_message(&block)
|
113
|
+
@on[:end_message] = block
|
114
|
+
self
|
115
|
+
end
|
116
|
+
|
117
|
+
def on_tool_call(&block)
|
118
|
+
@on[:tool_call] = block
|
119
|
+
self
|
120
|
+
end
|
121
|
+
|
122
|
+
def on_tool_result(&block)
|
123
|
+
@on[:tool_result] = block
|
124
|
+
self
|
125
|
+
end
|
126
|
+
|
127
|
+
def each(&)
|
128
|
+
messages.each(&)
|
129
|
+
end
|
130
|
+
|
131
|
+
def cache_prompts(system: false, user: false, tools: false)
|
132
|
+
@cache_prompts = { system: system, user: user, tools: tools }
|
133
|
+
self
|
134
|
+
end
|
135
|
+
|
136
|
+
def complete(&) # rubocop:disable Metrics/PerceivedComplexity
|
137
|
+
response = @provider.complete(
|
138
|
+
messages,
|
139
|
+
tools: @tools,
|
140
|
+
temperature: @temperature,
|
141
|
+
model: @model.id,
|
142
|
+
cache_prompts: @cache_prompts.dup,
|
143
|
+
params: @params,
|
144
|
+
headers: @headers,
|
145
|
+
schema: @schema,
|
146
|
+
&wrap_streaming_block(&)
|
147
|
+
)
|
148
|
+
|
149
|
+
@on[:new_message]&.call unless block_given?
|
150
|
+
|
151
|
+
# Parse JSON if schema was set
|
152
|
+
if @schema && response.content.is_a?(String)
|
153
|
+
begin
|
154
|
+
response.content = JSON.parse(response.content)
|
155
|
+
rescue JSON::ParserError
|
156
|
+
# If parsing fails, keep content as string
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
add_message response
|
161
|
+
@on[:end_message]&.call(response)
|
162
|
+
|
163
|
+
if response.tool_call?
|
164
|
+
handle_tool_calls(response, &)
|
165
|
+
else
|
166
|
+
response
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def add_message(message_or_attributes)
|
171
|
+
message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
|
172
|
+
messages << message
|
173
|
+
message
|
174
|
+
end
|
175
|
+
|
176
|
+
def reset_messages!
|
177
|
+
@messages.clear
|
178
|
+
end
|
179
|
+
|
180
|
+
private
|
181
|
+
|
182
|
+
def wrap_streaming_block(&block)
|
183
|
+
return nil unless block_given?
|
184
|
+
|
185
|
+
first_chunk_received = false
|
186
|
+
|
187
|
+
proc do |chunk|
|
188
|
+
# Create message on first content chunk
|
189
|
+
unless first_chunk_received
|
190
|
+
first_chunk_received = true
|
191
|
+
@on[:new_message]&.call
|
192
|
+
end
|
193
|
+
|
194
|
+
# Pass chunk to user's block
|
195
|
+
block.call chunk
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def handle_tool_calls(response, &)
|
200
|
+
halt_result = nil
|
201
|
+
|
202
|
+
response.tool_calls.each_value do |tool_call|
|
203
|
+
@on[:new_message]&.call
|
204
|
+
@on[:tool_call]&.call(tool_call)
|
205
|
+
result = execute_tool tool_call
|
206
|
+
@on[:tool_result]&.call(result)
|
207
|
+
message = add_message role: :tool, content: result.to_s, tool_call_id: tool_call.id
|
208
|
+
@on[:end_message]&.call(message)
|
209
|
+
|
210
|
+
halt_result = result if result.is_a?(Tool::Halt)
|
211
|
+
end
|
212
|
+
|
213
|
+
halt_result || complete(&)
|
214
|
+
end
|
215
|
+
|
216
|
+
def execute_tool(tool_call)
|
217
|
+
tool = tools[tool_call.name.to_sym]
|
218
|
+
args = tool_call.arguments
|
219
|
+
tool.call(args)
|
220
|
+
end
|
221
|
+
|
222
|
+
def instance_variables
|
223
|
+
super - %i[@connection @config]
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
# Global configuration for RubyLLM. Manages API keys, default models,
|
5
|
+
# and provider-specific settings.
|
6
|
+
#
|
7
|
+
# Configure via:
|
8
|
+
# RubyLLM.configure do |config|
|
9
|
+
# config.openai_api_key = ENV['OPENAI_API_KEY']
|
10
|
+
# config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
|
11
|
+
# end
|
12
|
+
class Configuration
|
13
|
+
# Provider-specific configuration
|
14
|
+
attr_accessor :openai_api_key,
|
15
|
+
:openai_api_base,
|
16
|
+
:openai_organization_id,
|
17
|
+
:openai_project_id,
|
18
|
+
:openai_use_system_role,
|
19
|
+
:anthropic_api_key,
|
20
|
+
:gemini_api_key,
|
21
|
+
:deepseek_api_key,
|
22
|
+
:perplexity_api_key,
|
23
|
+
:bedrock_api_key,
|
24
|
+
:bedrock_secret_key,
|
25
|
+
:bedrock_region,
|
26
|
+
:bedrock_session_token,
|
27
|
+
:openrouter_api_key,
|
28
|
+
:ollama_api_base,
|
29
|
+
:gpustack_api_base,
|
30
|
+
:gpustack_api_key,
|
31
|
+
:mistral_api_key,
|
32
|
+
# Default models
|
33
|
+
:default_model,
|
34
|
+
:default_embedding_model,
|
35
|
+
:default_image_model,
|
36
|
+
# Connection configuration
|
37
|
+
:request_timeout,
|
38
|
+
:max_retries,
|
39
|
+
:retry_interval,
|
40
|
+
:retry_backoff_factor,
|
41
|
+
:retry_interval_randomness,
|
42
|
+
:http_proxy,
|
43
|
+
# Logging configuration
|
44
|
+
:logger,
|
45
|
+
:log_file,
|
46
|
+
:log_level,
|
47
|
+
:log_stream_debug
|
48
|
+
|
49
|
+
def initialize
|
50
|
+
# Connection configuration
|
51
|
+
@request_timeout = 120
|
52
|
+
@max_retries = 3
|
53
|
+
@retry_interval = 0.1
|
54
|
+
@retry_backoff_factor = 2
|
55
|
+
@retry_interval_randomness = 0.5
|
56
|
+
@http_proxy = nil
|
57
|
+
|
58
|
+
# Default models
|
59
|
+
@default_model = 'gpt-4.1-nano'
|
60
|
+
@default_embedding_model = 'text-embedding-3-small'
|
61
|
+
@default_image_model = 'gpt-image-1'
|
62
|
+
|
63
|
+
# Logging configuration
|
64
|
+
@log_file = $stdout
|
65
|
+
@log_level = ENV['RUBYLLM_DEBUG'] ? Logger::DEBUG : Logger::INFO
|
66
|
+
@log_stream_debug = ENV['RUBYLLM_STREAM_DEBUG'] == 'true'
|
67
|
+
end
|
68
|
+
|
69
|
+
def instance_variables
|
70
|
+
super.reject { |ivar| ivar.to_s.match?(/_id|_key|_secret|_token$/) }
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
# Connection class for managing API connections to various providers.
|
5
|
+
class Connection
|
6
|
+
attr_reader :provider, :connection, :config
|
7
|
+
|
8
|
+
def self.basic(&)
|
9
|
+
Faraday.new do |f|
|
10
|
+
f.response :logger,
|
11
|
+
RubyLLM.logger,
|
12
|
+
bodies: false,
|
13
|
+
response: false,
|
14
|
+
errors: true,
|
15
|
+
headers: false,
|
16
|
+
log_level: :debug
|
17
|
+
f.response :raise_error
|
18
|
+
yield f if block_given?
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(provider, config)
|
23
|
+
@provider = provider
|
24
|
+
@config = config
|
25
|
+
|
26
|
+
ensure_configured!
|
27
|
+
@connection ||= Faraday.new(provider.api_base) do |faraday|
|
28
|
+
setup_timeout(faraday)
|
29
|
+
setup_logging(faraday)
|
30
|
+
setup_retry(faraday)
|
31
|
+
setup_middleware(faraday)
|
32
|
+
setup_http_proxy(faraday)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def post(url, payload, &)
|
37
|
+
body = payload.is_a?(Hash) ? JSON.generate(payload, ascii_only: false) : payload
|
38
|
+
@connection.post url, body do |req|
|
39
|
+
req.headers.merge! @provider.headers if @provider.respond_to?(:headers)
|
40
|
+
yield req if block_given?
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def get(url, &)
|
45
|
+
@connection.get url do |req|
|
46
|
+
req.headers.merge! @provider.headers if @provider.respond_to?(:headers)
|
47
|
+
yield req if block_given?
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def setup_timeout(faraday)
|
54
|
+
faraday.options.timeout = @config.request_timeout
|
55
|
+
end
|
56
|
+
|
57
|
+
def setup_logging(faraday)
|
58
|
+
faraday.response :logger,
|
59
|
+
RubyLLM.logger,
|
60
|
+
bodies: true,
|
61
|
+
response: true,
|
62
|
+
errors: true,
|
63
|
+
headers: false,
|
64
|
+
log_level: :debug do |logger|
|
65
|
+
logger.filter(%r{[A-Za-z0-9+/=]{100,}}, 'data":"[BASE64 DATA]"')
|
66
|
+
logger.filter(/[-\d.e,\s]{100,}/, '[EMBEDDINGS ARRAY]')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def setup_retry(faraday)
|
71
|
+
faraday.request :retry, {
|
72
|
+
max: @config.max_retries,
|
73
|
+
interval: @config.retry_interval,
|
74
|
+
interval_randomness: @config.retry_interval_randomness,
|
75
|
+
backoff_factor: @config.retry_backoff_factor,
|
76
|
+
exceptions: retry_exceptions,
|
77
|
+
retry_statuses: [429, 500, 502, 503, 504, 529]
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
def setup_middleware(faraday)
|
82
|
+
faraday.request :json
|
83
|
+
faraday.response :json
|
84
|
+
faraday.adapter Faraday.default_adapter
|
85
|
+
faraday.use :llm_errors, provider: @provider
|
86
|
+
end
|
87
|
+
|
88
|
+
def setup_http_proxy(faraday)
|
89
|
+
return unless @config.http_proxy
|
90
|
+
|
91
|
+
faraday.proxy = @config.http_proxy
|
92
|
+
end
|
93
|
+
|
94
|
+
def retry_exceptions
|
95
|
+
[
|
96
|
+
Errno::ETIMEDOUT,
|
97
|
+
Timeout::Error,
|
98
|
+
Faraday::TimeoutError,
|
99
|
+
Faraday::ConnectionFailed,
|
100
|
+
Faraday::RetriableResponse,
|
101
|
+
RubyLLM::RateLimitError,
|
102
|
+
RubyLLM::ServerError,
|
103
|
+
RubyLLM::ServiceUnavailableError,
|
104
|
+
RubyLLM::OverloadedError
|
105
|
+
]
|
106
|
+
end
|
107
|
+
|
108
|
+
def ensure_configured!
|
109
|
+
return if @provider.configured?
|
110
|
+
|
111
|
+
missing = @provider.configuration_requirements.reject { |req| @config.send(req) }
|
112
|
+
config_block = <<~RUBY
|
113
|
+
RubyLLM.configure do |config|
|
114
|
+
#{missing.map { |key| "config.#{key} = ENV['#{key.to_s.upcase}']" }.join("\n ")}
|
115
|
+
end
|
116
|
+
RUBY
|
117
|
+
|
118
|
+
raise ConfigurationError,
|
119
|
+
"#{@provider.name} provider is not configured. Add this to your initialization:\n\n#{config_block}"
|
120
|
+
end
|
121
|
+
|
122
|
+
def instance_variables
|
123
|
+
super - %i[@config @connection]
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
# Represents the content sent to or received from an LLM.
|
5
|
+
# Selects the appropriate attachment class based on the content type.
|
6
|
+
class Content
|
7
|
+
attr_reader :text, :attachments
|
8
|
+
|
9
|
+
def initialize(text = nil, attachments = nil)
|
10
|
+
@text = text
|
11
|
+
@attachments = []
|
12
|
+
|
13
|
+
process_attachments(attachments)
|
14
|
+
raise ArgumentError, 'Text and attachments cannot be both nil' if @text.nil? && @attachments.empty?
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_attachment(source, filename: nil)
|
18
|
+
@attachments << Attachment.new(source, filename:)
|
19
|
+
self
|
20
|
+
end
|
21
|
+
|
22
|
+
def format
|
23
|
+
if @text && @attachments.empty?
|
24
|
+
@text
|
25
|
+
else
|
26
|
+
self
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# For Rails serialization
|
31
|
+
def to_h
|
32
|
+
{ text: @text, attachments: @attachments.map(&:to_h) }
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def process_attachments_array_or_string(attachments)
|
38
|
+
Utils.to_safe_array(attachments).each do |file|
|
39
|
+
add_attachment(file)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def process_attachments(attachments)
|
44
|
+
if attachments.is_a?(Hash)
|
45
|
+
# Ignores types (like :image, :audio, :text, :pdf) since we have robust MIME type detection
|
46
|
+
attachments.each_value { |attachment| process_attachments_array_or_string(attachment) }
|
47
|
+
else
|
48
|
+
process_attachments_array_or_string attachments
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
# Holds per-call configs
|
5
|
+
class Context
|
6
|
+
attr_reader :config
|
7
|
+
|
8
|
+
def initialize(config)
|
9
|
+
@config = config
|
10
|
+
@connections = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
def chat(*args, **kwargs, &)
|
14
|
+
Chat.new(*args, **kwargs, context: self, &)
|
15
|
+
end
|
16
|
+
|
17
|
+
def embed(*args, **kwargs, &)
|
18
|
+
Embedding.embed(*args, **kwargs, context: self, &)
|
19
|
+
end
|
20
|
+
|
21
|
+
def paint(*args, **kwargs, &)
|
22
|
+
Image.paint(*args, **kwargs, context: self, &)
|
23
|
+
end
|
24
|
+
|
25
|
+
def connection_for(provider_instance)
|
26
|
+
provider_instance.connection
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
# Core embedding interface. Provides a clean way to generate embeddings
|
5
|
+
# from text using various provider models.
|
6
|
+
class Embedding
|
7
|
+
attr_reader :vectors, :model, :input_tokens
|
8
|
+
|
9
|
+
def initialize(vectors:, model:, input_tokens: 0)
|
10
|
+
@vectors = vectors
|
11
|
+
@model = model
|
12
|
+
@input_tokens = input_tokens
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.embed(text, # rubocop:disable Metrics/ParameterLists
|
16
|
+
model: nil,
|
17
|
+
provider: nil,
|
18
|
+
assume_model_exists: false,
|
19
|
+
context: nil,
|
20
|
+
dimensions: nil)
|
21
|
+
config = context&.config || RubyLLM.config
|
22
|
+
model ||= config.default_embedding_model
|
23
|
+
model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
|
24
|
+
config: config)
|
25
|
+
model_id = model.id
|
26
|
+
|
27
|
+
provider_instance.embed(text, model: model_id, dimensions:)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|