ruby_llm 1.14.1 → 1.16.0
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/README.md +6 -7
- data/lib/generators/ruby_llm/generator_helpers.rb +8 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
- data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +1 -1
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +3 -3
- data/lib/ruby_llm/active_record/acts_as.rb +4 -26
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +123 -29
- data/lib/ruby_llm/active_record/chat_methods.rb +41 -24
- data/lib/ruby_llm/active_record/message_methods.rb +87 -4
- data/lib/ruby_llm/active_record/model_methods.rb +7 -9
- data/lib/ruby_llm/active_record/payload_helpers.rb +3 -0
- data/lib/ruby_llm/active_record/tool_call_methods.rb +3 -0
- data/lib/ruby_llm/agent.rb +4 -2
- data/lib/ruby_llm/aliases.json +108 -75
- data/lib/ruby_llm/aliases.rb +3 -0
- data/lib/ruby_llm/attachment.rb +41 -40
- data/lib/ruby_llm/chat.rb +229 -59
- data/lib/ruby_llm/configuration.rb +14 -1
- data/lib/ruby_llm/connection.rb +36 -7
- data/lib/ruby_llm/content.rb +15 -1
- data/lib/ruby_llm/cost.rb +224 -0
- data/lib/ruby_llm/deprecator.rb +24 -0
- data/lib/ruby_llm/embedding.rb +31 -1
- data/lib/ruby_llm/error.rb +11 -75
- data/lib/ruby_llm/error_middleware.rb +81 -0
- data/lib/ruby_llm/image.rb +39 -4
- data/lib/ruby_llm/instrumentation.rb +36 -0
- data/lib/ruby_llm/message.rb +20 -0
- data/lib/ruby_llm/mime_type.rb +25 -0
- data/lib/ruby_llm/model/info.rb +53 -2
- data/lib/ruby_llm/model/pricing.rb +19 -9
- data/lib/ruby_llm/model/pricing_category.rb +13 -2
- data/lib/ruby_llm/model/pricing_tier.rb +20 -9
- data/lib/ruby_llm/model_registry.rb +39 -0
- data/lib/ruby_llm/models.json +17817 -13942
- data/lib/ruby_llm/models.rb +97 -31
- data/lib/ruby_llm/models_schema.json +3 -0
- data/lib/ruby_llm/provider.rb +20 -4
- data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
- data/lib/ruby_llm/providers/anthropic/models.rb +2 -0
- data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
- data/lib/ruby_llm/providers/azure/media.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
- data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
- data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
- data/lib/ruby_llm/providers/bedrock.rb +2 -2
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +43 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
- data/lib/ruby_llm/providers/gemini/images.rb +2 -2
- data/lib/ruby_llm/providers/gemini/media.rb +16 -9
- data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
- data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
- data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
- data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
- data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
- data/lib/ruby_llm/providers/mistral/media.rb +55 -0
- data/lib/ruby_llm/providers/mistral/models.rb +2 -0
- data/lib/ruby_llm/providers/mistral.rb +2 -2
- data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
- data/lib/ruby_llm/providers/openai/capabilities.rb +82 -12
- data/lib/ruby_llm/providers/openai/chat.rb +61 -7
- data/lib/ruby_llm/providers/openai/images.rb +58 -6
- data/lib/ruby_llm/providers/openai/media.rb +40 -16
- data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
- data/lib/ruby_llm/providers/openai/tools.rb +2 -0
- data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
- data/lib/ruby_llm/providers/openrouter/images.rb +2 -2
- data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
- data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
- data/lib/ruby_llm/providers/perplexity.rb +2 -2
- data/lib/ruby_llm/providers/vertexai.rb +5 -1
- data/lib/ruby_llm/providers/xai/chat.rb +9 -0
- data/lib/ruby_llm/providers/xai/models.rb +15 -27
- data/lib/ruby_llm/providers/xai.rb +2 -2
- data/lib/ruby_llm/railtie.rb +11 -1
- data/lib/ruby_llm/stream_accumulator.rb +45 -30
- data/lib/ruby_llm/streaming.rb +4 -0
- data/lib/ruby_llm/tokens.rb +8 -0
- data/lib/ruby_llm/tool.rb +24 -7
- data/lib/ruby_llm/tool_concurrency.rb +105 -0
- data/lib/ruby_llm/transcription.rb +2 -1
- data/lib/ruby_llm/utils.rb +39 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +11 -6
- data/lib/tasks/models.rake +45 -16
- data/lib/tasks/release.rake +50 -23
- metadata +35 -13
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
# Represents the cost of token usage for a model response.
|
|
5
|
+
class Cost
|
|
6
|
+
COMPONENTS = %i[input output cache_read cache_write thinking].freeze
|
|
7
|
+
PER_MILLION = 1_000_000.0
|
|
8
|
+
|
|
9
|
+
attr_reader :tokens, :model, :category
|
|
10
|
+
|
|
11
|
+
def self.aggregate(costs)
|
|
12
|
+
costs = costs.compact.select(&:tokens?)
|
|
13
|
+
return new(amounts: {}, has_tokens: false) if costs.empty?
|
|
14
|
+
|
|
15
|
+
missing = COMPONENTS.select do |component|
|
|
16
|
+
costs.any? { |cost| cost.missing?(component) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
amounts = COMPONENTS.to_h do |component|
|
|
20
|
+
[component, missing.include?(component) ? nil : aggregate_component(costs, component)]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
new(amounts:, missing:, has_tokens: true)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# rubocop:disable Metrics/ParameterLists
|
|
27
|
+
def initialize(tokens: nil, model: nil, amounts: nil, missing: [], has_tokens: nil, category: :text_tokens,
|
|
28
|
+
input_details: nil)
|
|
29
|
+
@tokens = tokens
|
|
30
|
+
@model = normalize_model(model)
|
|
31
|
+
@amounts = amounts
|
|
32
|
+
@missing = missing
|
|
33
|
+
@has_tokens = has_tokens
|
|
34
|
+
@category = category.to_sym
|
|
35
|
+
@input_details = input_details
|
|
36
|
+
end
|
|
37
|
+
# rubocop:enable Metrics/ParameterLists
|
|
38
|
+
|
|
39
|
+
def input
|
|
40
|
+
amount_for(:input)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def output
|
|
44
|
+
amount_for(:output)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cache_read
|
|
48
|
+
amount_for(:cache_read)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def cache_write
|
|
52
|
+
amount_for(:cache_write)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def thinking
|
|
56
|
+
amount_for(:thinking)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
alias reasoning thinking
|
|
60
|
+
|
|
61
|
+
alias cached_input cache_read
|
|
62
|
+
alias cache_creation cache_write
|
|
63
|
+
|
|
64
|
+
def total
|
|
65
|
+
return nil unless tokens?
|
|
66
|
+
return nil if COMPONENTS.any? { |component| missing?(component) }
|
|
67
|
+
|
|
68
|
+
costs = COMPONENTS.filter_map { |component| public_send(component) }
|
|
69
|
+
return nil if costs.empty?
|
|
70
|
+
|
|
71
|
+
costs.sum
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
input: input,
|
|
77
|
+
output: output,
|
|
78
|
+
cache_read: cache_read,
|
|
79
|
+
cache_write: cache_write,
|
|
80
|
+
thinking: thinking,
|
|
81
|
+
total: total
|
|
82
|
+
}.compact
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tokens?
|
|
86
|
+
return @has_tokens unless @has_tokens.nil?
|
|
87
|
+
|
|
88
|
+
COMPONENTS.any? { |component| !tokens_for(component).nil? }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def missing?(component)
|
|
92
|
+
return @missing.include?(component) if aggregate?
|
|
93
|
+
return image_input_missing? if component == :input && detailed_image_input?
|
|
94
|
+
return false if component == :thinking && !thinking_priced_separately?
|
|
95
|
+
|
|
96
|
+
tokens = tokens_for(component)
|
|
97
|
+
tokens.to_i.positive? && price_for(component).nil?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private_class_method def self.aggregate_component(costs, component)
|
|
101
|
+
values = costs.filter_map { |cost| cost.public_send(component) }
|
|
102
|
+
values.empty? ? nil : values.sum
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def amount_for(component)
|
|
108
|
+
return @amounts[component] if aggregate?
|
|
109
|
+
return image_input_amount if component == :input && detailed_image_input?
|
|
110
|
+
|
|
111
|
+
token_count = tokens_for(component)
|
|
112
|
+
return nil if token_count.nil?
|
|
113
|
+
|
|
114
|
+
token_count = token_count.to_i
|
|
115
|
+
return 0.0 if token_count.zero?
|
|
116
|
+
|
|
117
|
+
price = price_for(component)
|
|
118
|
+
return nil unless price
|
|
119
|
+
|
|
120
|
+
token_count * price / PER_MILLION
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def aggregate?
|
|
124
|
+
!@amounts.nil?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def tokens_for(component)
|
|
128
|
+
return unless tokens
|
|
129
|
+
|
|
130
|
+
case component
|
|
131
|
+
when :input
|
|
132
|
+
tokens.input
|
|
133
|
+
when :output
|
|
134
|
+
tokens.output
|
|
135
|
+
when :cache_read
|
|
136
|
+
tokens.cache_read
|
|
137
|
+
when :cache_write
|
|
138
|
+
tokens.cache_write
|
|
139
|
+
when :thinking
|
|
140
|
+
tokens.thinking if thinking_priced_separately?
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def price_for(component)
|
|
145
|
+
case component
|
|
146
|
+
when :input
|
|
147
|
+
text_pricing.input
|
|
148
|
+
when :output
|
|
149
|
+
output_pricing.output
|
|
150
|
+
when :cache_read
|
|
151
|
+
text_pricing.cache_read_input
|
|
152
|
+
when :cache_write
|
|
153
|
+
text_pricing.cache_write_input
|
|
154
|
+
when :thinking
|
|
155
|
+
text_pricing.reasoning_output
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def text_pricing
|
|
160
|
+
model&.pricing&.text_tokens || RubyLLM::Model::PricingCategory.new
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def image_pricing
|
|
164
|
+
model&.pricing&.images || RubyLLM::Model::PricingCategory.new
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def output_pricing
|
|
168
|
+
image_cost? && image_pricing.output ? image_pricing : text_pricing
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def image_cost?
|
|
172
|
+
%i[image images].include?(category)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def detailed_image_input?
|
|
176
|
+
image_cost? && @input_details.is_a?(Hash) && image_input_parts.any? { |_, tokens, _| !tokens.nil? }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def image_input_amount
|
|
180
|
+
return nil if image_input_missing?
|
|
181
|
+
|
|
182
|
+
image_input_parts.filter_map do |_, token_count, price|
|
|
183
|
+
next if token_count.nil? || token_count.to_i.zero?
|
|
184
|
+
|
|
185
|
+
token_count.to_i * price / PER_MILLION
|
|
186
|
+
end.sum
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def image_input_missing?
|
|
190
|
+
image_input_parts.any? do |_, token_count, price|
|
|
191
|
+
token_count.to_i.positive? && price.nil?
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def image_input_parts
|
|
196
|
+
[
|
|
197
|
+
[:text, input_detail('text_tokens'), text_pricing.input],
|
|
198
|
+
[:image, input_detail('image_tokens'), image_pricing.input || text_pricing.input]
|
|
199
|
+
]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def input_detail(key)
|
|
203
|
+
@input_details[key] || @input_details[key.to_sym]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def thinking_priced_separately?
|
|
207
|
+
reasoning_price = text_pricing.reasoning_output
|
|
208
|
+
return false unless reasoning_price
|
|
209
|
+
|
|
210
|
+
output_price = text_pricing.output
|
|
211
|
+
output_price.nil? || reasoning_price != output_price
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def normalize_model(model)
|
|
215
|
+
return RubyLLM.models.find(model.to_s) if model.is_a?(String) || model.is_a?(Symbol)
|
|
216
|
+
return model.to_llm if model.respond_to?(:to_llm)
|
|
217
|
+
return model if model.respond_to?(:pricing)
|
|
218
|
+
|
|
219
|
+
nil
|
|
220
|
+
rescue ModelNotFoundError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
# Owns RubyLLM deprecation warnings so applications and tests can decide how
|
|
5
|
+
# aggressively to handle compatibility paths.
|
|
6
|
+
class Deprecator
|
|
7
|
+
def warn(message)
|
|
8
|
+
case RubyLLM.config.deprecation_behavior
|
|
9
|
+
when :silence
|
|
10
|
+
nil
|
|
11
|
+
when :raise
|
|
12
|
+
raise DeprecationError, message
|
|
13
|
+
else
|
|
14
|
+
RubyLLM.logger.warn(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def deprecate(name, replacement:, removal:)
|
|
19
|
+
warn("#{name} is deprecated and will be removed in RubyLLM #{removal}. Use #{replacement} instead.")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class DeprecationError < StandardError; end
|
|
24
|
+
end
|
data/lib/ruby_llm/embedding.rb
CHANGED
|
@@ -23,7 +23,37 @@ module RubyLLM
|
|
|
23
23
|
config: config)
|
|
24
24
|
model_id = model.id
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
payload = {
|
|
27
|
+
provider: provider_instance.slug,
|
|
28
|
+
provider_class: provider_instance.class.name,
|
|
29
|
+
model: model_id,
|
|
30
|
+
model_info: model,
|
|
31
|
+
input: text,
|
|
32
|
+
dimensions: dimensions
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
RubyLLM.instrument('embedding.ruby_llm', payload, config: config) do |event|
|
|
36
|
+
result = provider_instance.embed(text, model: model_id, dimensions:)
|
|
37
|
+
event[:result] = result
|
|
38
|
+
event[:response_model] = result.model
|
|
39
|
+
event[:input_tokens] = result.input_tokens
|
|
40
|
+
event[:embedding_dimensions] = vector_dimensions(result.vectors)
|
|
41
|
+
event[:embedding_count] = embedding_count(result.vectors)
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.vector_dimensions(vectors)
|
|
47
|
+
return unless vectors.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
vector = vectors.first.is_a?(Array) ? vectors.first : vectors
|
|
50
|
+
vector.length if vector.respond_to?(:length)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.embedding_count(vectors)
|
|
54
|
+
return unless vectors.is_a?(Array)
|
|
55
|
+
|
|
56
|
+
vectors.first.is_a?(Array) ? vectors.size : 1
|
|
27
57
|
end
|
|
28
58
|
end
|
|
29
59
|
end
|
data/lib/ruby_llm/error.rb
CHANGED
|
@@ -23,7 +23,17 @@ module RubyLLM
|
|
|
23
23
|
class InvalidRoleError < StandardError; end
|
|
24
24
|
class InvalidToolChoiceError < StandardError; end
|
|
25
25
|
class ModelNotFoundError < StandardError; end
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
# Raised when RubyLLM cannot format an attachment for the selected provider.
|
|
28
|
+
class UnsupportedAttachmentError < StandardError
|
|
29
|
+
GUIDANCE = 'Consider using a model that supports this attachment type.'
|
|
30
|
+
|
|
31
|
+
def initialize(type = nil)
|
|
32
|
+
message = 'Unsupported attachment type'
|
|
33
|
+
message = "#{message}: #{type}" if type
|
|
34
|
+
super("#{message}. #{GUIDANCE}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
27
37
|
|
|
28
38
|
# Error classes for different HTTP status codes
|
|
29
39
|
class BadRequestError < Error; end
|
|
@@ -35,78 +45,4 @@ module RubyLLM
|
|
|
35
45
|
class ServerError < Error; end
|
|
36
46
|
class ServiceUnavailableError < Error; end
|
|
37
47
|
class UnauthorizedError < Error; end
|
|
38
|
-
|
|
39
|
-
# Faraday middleware that maps provider-specific API errors to RubyLLM errors.
|
|
40
|
-
class ErrorMiddleware < Faraday::Middleware
|
|
41
|
-
def initialize(app, options = {})
|
|
42
|
-
super(app)
|
|
43
|
-
@provider = options[:provider]
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def call(env)
|
|
47
|
-
@app.call(env).on_complete do |response|
|
|
48
|
-
self.class.parse_error(provider: @provider, response: response)
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
class << self
|
|
53
|
-
CONTEXT_LENGTH_PATTERNS = [
|
|
54
|
-
/context length/i,
|
|
55
|
-
/context window/i,
|
|
56
|
-
/maximum context/i,
|
|
57
|
-
/request too large/i,
|
|
58
|
-
/too many tokens/i,
|
|
59
|
-
/token count exceeds/i,
|
|
60
|
-
/input[_\s-]?token/i,
|
|
61
|
-
/input or output tokens? must be reduced/i,
|
|
62
|
-
/reduce the length of messages/i
|
|
63
|
-
].freeze
|
|
64
|
-
|
|
65
|
-
def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
|
|
66
|
-
message = provider&.parse_error(response)
|
|
67
|
-
|
|
68
|
-
case response.status
|
|
69
|
-
when 200..399
|
|
70
|
-
message
|
|
71
|
-
when 400
|
|
72
|
-
if context_length_exceeded?(message)
|
|
73
|
-
raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
raise BadRequestError.new(response, message || 'Invalid request - please check your input')
|
|
77
|
-
when 401
|
|
78
|
-
raise UnauthorizedError.new(response, message || 'Invalid API key - check your credentials')
|
|
79
|
-
when 402
|
|
80
|
-
raise PaymentRequiredError.new(response, message || 'Payment required - please top up your account')
|
|
81
|
-
when 403
|
|
82
|
-
raise ForbiddenError.new(response,
|
|
83
|
-
message || 'Forbidden - you do not have permission to access this resource')
|
|
84
|
-
when 429
|
|
85
|
-
if context_length_exceeded?(message)
|
|
86
|
-
raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
|
|
90
|
-
when 500
|
|
91
|
-
raise ServerError.new(response, message || 'API server error - please try again')
|
|
92
|
-
when 502..504
|
|
93
|
-
raise ServiceUnavailableError.new(response, message || 'API server unavailable - please try again later')
|
|
94
|
-
when 529
|
|
95
|
-
raise OverloadedError.new(response, message || 'Service overloaded - please try again later')
|
|
96
|
-
else
|
|
97
|
-
raise Error.new(response, message || 'An unknown error occurred')
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
def context_length_exceeded?(message)
|
|
104
|
-
return false if message.to_s.empty?
|
|
105
|
-
|
|
106
|
-
CONTEXT_LENGTH_PATTERNS.any? { |pattern| message.match?(pattern) }
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
48
|
end
|
|
111
|
-
|
|
112
|
-
Faraday::Middleware.register_middleware(llm_errors: RubyLLM::ErrorMiddleware)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'ruby_llm/error'
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
# Faraday middleware that maps provider-specific API errors to RubyLLM errors.
|
|
8
|
+
class ErrorMiddleware < Faraday::Middleware
|
|
9
|
+
def initialize(app, options = {})
|
|
10
|
+
super(app)
|
|
11
|
+
@provider = options[:provider]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
@app.call(env).on_complete do |response|
|
|
16
|
+
self.class.parse_error(provider: @provider, response: response)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
CONTEXT_LENGTH_PATTERNS = [
|
|
22
|
+
/context length/i,
|
|
23
|
+
/context window/i,
|
|
24
|
+
/maximum context/i,
|
|
25
|
+
/request too large/i,
|
|
26
|
+
/too many tokens/i,
|
|
27
|
+
/token count exceeds/i,
|
|
28
|
+
/input[_\s-]?token/i,
|
|
29
|
+
/input or output tokens? must be reduced/i,
|
|
30
|
+
/reduce the length of messages/i,
|
|
31
|
+
/prompt is too long/i
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
def parse_error(provider:, response:) # rubocop:disable Metrics/PerceivedComplexity
|
|
35
|
+
message = provider&.parse_error(response)
|
|
36
|
+
|
|
37
|
+
case response.status
|
|
38
|
+
when 200..399
|
|
39
|
+
message
|
|
40
|
+
when 400
|
|
41
|
+
if context_length_exceeded?(message)
|
|
42
|
+
raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
raise BadRequestError.new(response, message || 'Invalid request - please check your input')
|
|
46
|
+
when 401
|
|
47
|
+
raise UnauthorizedError.new(response, message || 'Invalid API key - check your credentials')
|
|
48
|
+
when 402
|
|
49
|
+
raise PaymentRequiredError.new(response, message || 'Payment required - please top up your account')
|
|
50
|
+
when 403
|
|
51
|
+
raise ForbiddenError.new(response,
|
|
52
|
+
message || 'Forbidden - you do not have permission to access this resource')
|
|
53
|
+
when 429
|
|
54
|
+
if context_length_exceeded?(message)
|
|
55
|
+
raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
|
|
59
|
+
when 500
|
|
60
|
+
raise ServerError.new(response, message || 'API server error - please try again')
|
|
61
|
+
when 502..504
|
|
62
|
+
raise ServiceUnavailableError.new(response, message || 'API server unavailable - please try again later')
|
|
63
|
+
when 529
|
|
64
|
+
raise OverloadedError.new(response, message || 'Service overloaded - please try again later')
|
|
65
|
+
else
|
|
66
|
+
raise Error.new(response, message || 'An unknown error occurred')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def context_length_exceeded?(message)
|
|
73
|
+
return false if message.to_s.empty?
|
|
74
|
+
|
|
75
|
+
CONTEXT_LENGTH_PATTERNS.any? { |pattern| message.match?(pattern) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Faraday::Middleware.register_middleware(llm_errors: RubyLLM::ErrorMiddleware)
|
data/lib/ruby_llm/image.rb
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
3
5
|
module RubyLLM
|
|
4
6
|
# Represents a generated image from an AI model.
|
|
5
7
|
class Image
|
|
6
|
-
attr_reader :url, :data, :mime_type, :revised_prompt, :model_id
|
|
8
|
+
attr_reader :url, :data, :mime_type, :revised_prompt, :model_id, :usage
|
|
7
9
|
|
|
8
|
-
def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil)
|
|
10
|
+
def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil, usage: {}) # rubocop:disable Metrics/ParameterLists
|
|
9
11
|
@url = url
|
|
10
12
|
@data = data
|
|
11
13
|
@mime_type = mime_type
|
|
12
14
|
@revised_prompt = revised_prompt
|
|
13
15
|
@model_id = model_id
|
|
16
|
+
@usage = usage
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def base64?
|
|
@@ -36,14 +39,46 @@ module RubyLLM
|
|
|
36
39
|
provider: nil,
|
|
37
40
|
assume_model_exists: false,
|
|
38
41
|
size: '1024x1024',
|
|
39
|
-
context: nil
|
|
42
|
+
context: nil,
|
|
43
|
+
with: nil,
|
|
44
|
+
mask: nil,
|
|
45
|
+
params: {})
|
|
40
46
|
config = context&.config || RubyLLM.config
|
|
41
47
|
model ||= config.default_image_model
|
|
42
48
|
model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
|
|
43
49
|
config: config)
|
|
44
50
|
model_id = model.id
|
|
45
51
|
|
|
46
|
-
provider_instance.paint(prompt, model: model_id, size:)
|
|
52
|
+
provider_instance.paint(prompt, model: model_id, size:, with:, mask:, params:)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tokens
|
|
56
|
+
@tokens ||= Tokens.build(
|
|
57
|
+
input: usage_value('input_tokens'),
|
|
58
|
+
output: usage_value('output_tokens')
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cost
|
|
63
|
+
Cost.new(tokens:, model: model_info, category: :images, input_details: input_tokens_details)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def model_info
|
|
67
|
+
return unless model_id
|
|
68
|
+
|
|
69
|
+
@model_info ||= RubyLLM.models.find(model_id)
|
|
70
|
+
rescue ModelNotFoundError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def input_tokens_details
|
|
77
|
+
usage_value('input_tokens_details')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def usage_value(key)
|
|
81
|
+
usage[key] || usage[key.to_sym]
|
|
47
82
|
end
|
|
48
83
|
end
|
|
49
84
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
# Emits structured RubyLLM events without requiring a specific observability
|
|
5
|
+
# backend. Rails apps can use ActiveSupport::Notifications as the instrumenter.
|
|
6
|
+
module Instrumentation
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def instrument(name, payload = nil, config: nil, **attributes)
|
|
10
|
+
payload = build_payload(payload, attributes)
|
|
11
|
+
instrumenter = instrumenter_for(config)
|
|
12
|
+
return yield(payload) if block_given? && !instrumenter
|
|
13
|
+
|
|
14
|
+
unless instrumenter.respond_to?(:instrument)
|
|
15
|
+
return yield(payload) if block_given?
|
|
16
|
+
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if block_given?
|
|
21
|
+
instrumenter.instrument(name, payload) { yield(payload) }
|
|
22
|
+
else
|
|
23
|
+
instrumenter.instrument(name, payload)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_payload(payload, attributes)
|
|
28
|
+
payload ||= {}
|
|
29
|
+
attributes.empty? ? payload : payload.merge(attributes)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def instrumenter_for(config)
|
|
33
|
+
(config || RubyLLM.config).instrumenter
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/ruby_llm/message.rb
CHANGED
|
@@ -64,6 +64,14 @@ module RubyLLM
|
|
|
64
64
|
tokens&.cache_creation
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
def cache_read_tokens
|
|
68
|
+
tokens&.cache_read
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cache_write_tokens
|
|
72
|
+
tokens&.cache_write
|
|
73
|
+
end
|
|
74
|
+
|
|
67
75
|
def thinking_tokens
|
|
68
76
|
tokens&.thinking
|
|
69
77
|
end
|
|
@@ -72,6 +80,10 @@ module RubyLLM
|
|
|
72
80
|
tokens&.thinking
|
|
73
81
|
end
|
|
74
82
|
|
|
83
|
+
def cost(model: nil)
|
|
84
|
+
Cost.new(tokens:, model: model || model_info)
|
|
85
|
+
end
|
|
86
|
+
|
|
75
87
|
def to_h
|
|
76
88
|
{
|
|
77
89
|
role: role,
|
|
@@ -88,6 +100,14 @@ module RubyLLM
|
|
|
88
100
|
super - [:@raw]
|
|
89
101
|
end
|
|
90
102
|
|
|
103
|
+
def model_info
|
|
104
|
+
return unless model_id
|
|
105
|
+
|
|
106
|
+
@model_info ||= RubyLLM.models.find(model_id)
|
|
107
|
+
rescue ModelNotFoundError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
91
111
|
private
|
|
92
112
|
|
|
93
113
|
def normalize_content(content, role:, tool_calls:)
|
data/lib/ruby_llm/mime_type.rb
CHANGED
|
@@ -27,6 +27,13 @@ module RubyLLM
|
|
|
27
27
|
type == 'application/pdf'
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def document?(type)
|
|
31
|
+
return false if pdf?(type) || text?(type)
|
|
32
|
+
|
|
33
|
+
DOCUMENT_MIME_TYPES.include?(type) ||
|
|
34
|
+
DOCUMENT_MIME_PREFIXES.any? { |prefix| type.start_with?(prefix) }
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
def text?(type)
|
|
31
38
|
type.start_with?('text/') ||
|
|
32
39
|
TEXT_SUFFIXES.any? { |suffix| type.end_with?(suffix) } ||
|
|
@@ -67,5 +74,23 @@ module RubyLLM
|
|
|
67
74
|
'application/yaml', # Standard for YAML
|
|
68
75
|
'application/toml' # TOML configuration files
|
|
69
76
|
].freeze
|
|
77
|
+
|
|
78
|
+
DOCUMENT_MIME_TYPES = [
|
|
79
|
+
'application/msword',
|
|
80
|
+
'application/rtf',
|
|
81
|
+
'application/vnd.apple.keynote',
|
|
82
|
+
'application/vnd.apple.numbers',
|
|
83
|
+
'application/vnd.apple.pages',
|
|
84
|
+
'application/vnd.google-apps.document',
|
|
85
|
+
'application/vnd.google-apps.presentation',
|
|
86
|
+
'application/vnd.google-apps.spreadsheet',
|
|
87
|
+
'application/vnd.ms-excel',
|
|
88
|
+
'application/vnd.ms-powerpoint'
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
DOCUMENT_MIME_PREFIXES = [
|
|
92
|
+
'application/vnd.openxmlformats-officedocument.',
|
|
93
|
+
'application/vnd.oasis.opendocument.'
|
|
94
|
+
].freeze
|
|
70
95
|
end
|
|
71
96
|
end
|