ruby_llm 1.9.1 → 1.9.2
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/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +1 -1
- data/lib/ruby_llm/aliases.json +110 -18
- data/lib/ruby_llm/models.json +17715 -7028
- data/lib/ruby_llm/models.rb +142 -20
- data/lib/ruby_llm/models_schema.json +2 -2
- data/lib/ruby_llm/providers/bedrock/models.rb +21 -15
- data/lib/ruby_llm/streaming.rb +39 -20
- data/lib/ruby_llm/tool.rb +1 -1
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/tasks/models.rake +2 -2
- metadata +1 -1
data/lib/ruby_llm/models.rb
CHANGED
|
@@ -5,6 +5,18 @@ module RubyLLM
|
|
|
5
5
|
class Models
|
|
6
6
|
include Enumerable
|
|
7
7
|
|
|
8
|
+
MODELS_DEV_PROVIDER_MAP = {
|
|
9
|
+
'openai' => 'openai',
|
|
10
|
+
'anthropic' => 'anthropic',
|
|
11
|
+
'google' => 'gemini',
|
|
12
|
+
'google-vertex' => 'vertexai',
|
|
13
|
+
'amazon-bedrock' => 'bedrock',
|
|
14
|
+
'deepseek' => 'deepseek',
|
|
15
|
+
'mistral' => 'mistral',
|
|
16
|
+
'openrouter' => 'openrouter',
|
|
17
|
+
'perplexity' => 'perplexity'
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
8
20
|
class << self
|
|
9
21
|
def instance
|
|
10
22
|
@instance ||= new
|
|
@@ -27,8 +39,8 @@ module RubyLLM
|
|
|
27
39
|
|
|
28
40
|
def refresh!(remote_only: false)
|
|
29
41
|
provider_models = fetch_from_providers(remote_only: remote_only)
|
|
30
|
-
|
|
31
|
-
merged_models = merge_models(provider_models,
|
|
42
|
+
models_dev_models = fetch_from_models_dev
|
|
43
|
+
merged_models = merge_models(provider_models, models_dev_models)
|
|
32
44
|
@instance = new(merged_models)
|
|
33
45
|
end
|
|
34
46
|
|
|
@@ -91,32 +103,41 @@ module RubyLLM
|
|
|
91
103
|
instance.respond_to?(method, include_private) || super
|
|
92
104
|
end
|
|
93
105
|
|
|
94
|
-
def
|
|
95
|
-
RubyLLM.logger.info 'Fetching models from
|
|
106
|
+
def fetch_from_models_dev
|
|
107
|
+
RubyLLM.logger.info 'Fetching models from models.dev API...'
|
|
96
108
|
|
|
97
109
|
connection = Connection.basic do |f|
|
|
98
110
|
f.request :json
|
|
99
111
|
f.response :json, parser_options: { symbolize_names: true }
|
|
100
112
|
end
|
|
101
|
-
response = connection.get 'https://api.
|
|
102
|
-
|
|
113
|
+
response = connection.get 'https://models.dev/api.json'
|
|
114
|
+
providers = response.body || {}
|
|
115
|
+
|
|
116
|
+
models = providers.flat_map do |provider_key, provider_data|
|
|
117
|
+
provider_slug = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
|
|
118
|
+
next [] unless provider_slug
|
|
119
|
+
|
|
120
|
+
(provider_data[:models] || {}).values.map do |model_data|
|
|
121
|
+
Model::Info.new(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
103
124
|
models.reject { |model| model.provider.nil? || model.id.nil? }
|
|
104
125
|
end
|
|
105
126
|
|
|
106
|
-
def merge_models(provider_models,
|
|
107
|
-
|
|
127
|
+
def merge_models(provider_models, models_dev_models)
|
|
128
|
+
models_dev_by_key = index_by_key(models_dev_models)
|
|
108
129
|
provider_by_key = index_by_key(provider_models)
|
|
109
130
|
|
|
110
|
-
all_keys =
|
|
131
|
+
all_keys = models_dev_by_key.keys | provider_by_key.keys
|
|
111
132
|
|
|
112
133
|
models = all_keys.map do |key|
|
|
113
|
-
|
|
134
|
+
models_dev_model = find_models_dev_model(key, models_dev_by_key)
|
|
114
135
|
provider_model = provider_by_key[key]
|
|
115
136
|
|
|
116
|
-
if
|
|
117
|
-
add_provider_metadata(
|
|
118
|
-
elsif
|
|
119
|
-
|
|
137
|
+
if models_dev_model && provider_model
|
|
138
|
+
add_provider_metadata(models_dev_model, provider_model)
|
|
139
|
+
elsif models_dev_model
|
|
140
|
+
models_dev_model
|
|
120
141
|
else
|
|
121
142
|
provider_model
|
|
122
143
|
end
|
|
@@ -125,18 +146,18 @@ module RubyLLM
|
|
|
125
146
|
models.sort_by { |m| [m.provider, m.id] }
|
|
126
147
|
end
|
|
127
148
|
|
|
128
|
-
def
|
|
149
|
+
def find_models_dev_model(key, models_dev_by_key)
|
|
129
150
|
# Direct match
|
|
130
|
-
return
|
|
151
|
+
return models_dev_by_key[key] if models_dev_by_key[key]
|
|
131
152
|
|
|
132
153
|
# VertexAI uses same models as Gemini
|
|
133
154
|
provider, model_id = key.split(':', 2)
|
|
134
155
|
return unless provider == 'vertexai'
|
|
135
156
|
|
|
136
|
-
gemini_model =
|
|
157
|
+
gemini_model = models_dev_by_key["gemini:#{model_id}"]
|
|
137
158
|
return unless gemini_model
|
|
138
159
|
|
|
139
|
-
# Return Gemini's
|
|
160
|
+
# Return Gemini's models.dev data but with VertexAI as provider
|
|
140
161
|
Model::Info.new(gemini_model.to_h.merge(provider: 'vertexai'))
|
|
141
162
|
end
|
|
142
163
|
|
|
@@ -146,11 +167,97 @@ module RubyLLM
|
|
|
146
167
|
end
|
|
147
168
|
end
|
|
148
169
|
|
|
149
|
-
def add_provider_metadata(
|
|
150
|
-
data =
|
|
170
|
+
def add_provider_metadata(models_dev_model, provider_model)
|
|
171
|
+
data = models_dev_model.to_h
|
|
151
172
|
data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
|
|
173
|
+
data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
|
|
152
174
|
Model::Info.new(data)
|
|
153
175
|
end
|
|
176
|
+
|
|
177
|
+
def models_dev_model_to_info(model_data, provider_slug, provider_key)
|
|
178
|
+
modalities = normalize_models_dev_modalities(model_data[:modalities])
|
|
179
|
+
capabilities = models_dev_capabilities(model_data, modalities)
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
id: model_data[:id],
|
|
183
|
+
name: model_data[:name] || model_data[:id],
|
|
184
|
+
provider: provider_slug,
|
|
185
|
+
family: model_data[:family],
|
|
186
|
+
created_at: model_data[:release_date] || model_data[:last_updated],
|
|
187
|
+
context_window: model_data.dig(:limit, :context),
|
|
188
|
+
max_output_tokens: model_data.dig(:limit, :output),
|
|
189
|
+
knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
|
|
190
|
+
modalities: modalities,
|
|
191
|
+
capabilities: capabilities,
|
|
192
|
+
pricing: models_dev_pricing(model_data[:cost]),
|
|
193
|
+
metadata: models_dev_metadata(model_data, provider_key)
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def models_dev_capabilities(model_data, modalities)
|
|
198
|
+
capabilities = []
|
|
199
|
+
capabilities << 'function_calling' if model_data[:tool_call]
|
|
200
|
+
capabilities << 'structured_output' if model_data[:structured_output]
|
|
201
|
+
capabilities << 'reasoning' if model_data[:reasoning]
|
|
202
|
+
capabilities << 'vision' if modalities[:input].intersect?(%w[image video pdf])
|
|
203
|
+
capabilities.uniq
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def models_dev_pricing(cost)
|
|
207
|
+
return {} unless cost
|
|
208
|
+
|
|
209
|
+
text_standard = {
|
|
210
|
+
input_per_million: cost[:input],
|
|
211
|
+
output_per_million: cost[:output],
|
|
212
|
+
cached_input_per_million: cost[:cache_read],
|
|
213
|
+
reasoning_output_per_million: cost[:reasoning]
|
|
214
|
+
}.compact
|
|
215
|
+
|
|
216
|
+
audio_standard = {
|
|
217
|
+
input_per_million: cost[:input_audio],
|
|
218
|
+
output_per_million: cost[:output_audio]
|
|
219
|
+
}.compact
|
|
220
|
+
|
|
221
|
+
pricing = {}
|
|
222
|
+
pricing[:text_tokens] = { standard: text_standard } if text_standard.any?
|
|
223
|
+
pricing[:audio_tokens] = { standard: audio_standard } if audio_standard.any?
|
|
224
|
+
pricing
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def models_dev_metadata(model_data, provider_key)
|
|
228
|
+
metadata = {
|
|
229
|
+
source: 'models.dev',
|
|
230
|
+
provider_id: provider_key,
|
|
231
|
+
open_weights: model_data[:open_weights],
|
|
232
|
+
attachment: model_data[:attachment],
|
|
233
|
+
temperature: model_data[:temperature],
|
|
234
|
+
last_updated: model_data[:last_updated],
|
|
235
|
+
status: model_data[:status],
|
|
236
|
+
interleaved: model_data[:interleaved],
|
|
237
|
+
cost: model_data[:cost],
|
|
238
|
+
limit: model_data[:limit],
|
|
239
|
+
knowledge: model_data[:knowledge]
|
|
240
|
+
}
|
|
241
|
+
metadata.compact
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def normalize_models_dev_modalities(modalities)
|
|
245
|
+
normalized = { input: [], output: [] }
|
|
246
|
+
return normalized unless modalities
|
|
247
|
+
|
|
248
|
+
normalized[:input] = Array(modalities[:input]).compact
|
|
249
|
+
normalized[:output] = Array(modalities[:output]).compact
|
|
250
|
+
normalized
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def normalize_models_dev_knowledge(value)
|
|
254
|
+
return if value.nil?
|
|
255
|
+
return value if value.is_a?(Date)
|
|
256
|
+
|
|
257
|
+
Date.parse(value.to_s)
|
|
258
|
+
rescue ArgumentError
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
154
261
|
end
|
|
155
262
|
|
|
156
263
|
def initialize(models = nil)
|
|
@@ -217,11 +324,26 @@ module RubyLLM
|
|
|
217
324
|
|
|
218
325
|
def find_with_provider(model_id, provider)
|
|
219
326
|
resolved_id = Aliases.resolve(model_id, provider)
|
|
327
|
+
resolved_id = resolve_bedrock_region_id(resolved_id) if provider.to_s == 'bedrock'
|
|
220
328
|
all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
|
|
221
329
|
all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
|
|
222
330
|
raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
|
|
223
331
|
end
|
|
224
332
|
|
|
333
|
+
def resolve_bedrock_region_id(model_id)
|
|
334
|
+
region = RubyLLM.config.bedrock_region.to_s
|
|
335
|
+
return model_id if region.empty?
|
|
336
|
+
|
|
337
|
+
candidate_id = Providers::Bedrock::Models.with_region_prefix(model_id, region)
|
|
338
|
+
return model_id if candidate_id == model_id
|
|
339
|
+
|
|
340
|
+
candidate = all.find { |m| m.provider == 'bedrock' && m.id == candidate_id }
|
|
341
|
+
return model_id unless candidate
|
|
342
|
+
|
|
343
|
+
inference_types = Array(candidate.metadata[:inference_types] || candidate.metadata['inference_types'])
|
|
344
|
+
Providers::Bedrock::Models.normalize_inference_profile_id(model_id, inference_types, region)
|
|
345
|
+
end
|
|
346
|
+
|
|
225
347
|
def find_without_provider(model_id)
|
|
226
348
|
all.find { |m| m.id == model_id } ||
|
|
227
349
|
all.find { |m| m.id == Aliases.resolve(model_id) } ||
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"type": "array",
|
|
56
56
|
"items": {
|
|
57
57
|
"type": "string",
|
|
58
|
-
"enum": ["text", "image", "audio", "embeddings", "moderation"]
|
|
58
|
+
"enum": ["text", "image", "audio", "video", "embeddings", "moderation"]
|
|
59
59
|
},
|
|
60
60
|
"uniqueItems": true,
|
|
61
61
|
"description": "Supported output modalities"
|
|
@@ -165,4 +165,4 @@
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
|
-
}
|
|
168
|
+
}
|
|
@@ -69,28 +69,34 @@ module RubyLLM
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def model_id_with_region(model_id, model_data)
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
normalize_inference_profile_id(
|
|
73
|
+
model_id,
|
|
74
|
+
model_data['inferenceTypesSupported'],
|
|
75
|
+
@config.bedrock_region
|
|
76
|
+
)
|
|
77
|
+
end
|
|
74
78
|
|
|
75
|
-
|
|
79
|
+
def region_prefix(region)
|
|
80
|
+
region = region.to_s
|
|
81
|
+
return 'us' if region.empty?
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
region[0, 2]
|
|
84
|
+
end
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
def with_region_prefix(model_id, region)
|
|
87
|
+
desired_prefix = region_prefix(region)
|
|
88
|
+
return model_id if model_id.start_with?("#{desired_prefix}.")
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
"#{
|
|
90
|
+
clean_model_id = model_id.sub(/^[a-z]{2}\./, '')
|
|
91
|
+
"#{desired_prefix}.#{clean_model_id}"
|
|
85
92
|
end
|
|
86
93
|
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return
|
|
94
|
+
def normalize_inference_profile_id(model_id, inference_types, region)
|
|
95
|
+
types = Array(inference_types)
|
|
96
|
+
return model_id unless types.include?('INFERENCE_PROFILE')
|
|
97
|
+
return model_id if types.include?('ON_DEMAND')
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
region[0, 2]
|
|
99
|
+
with_region_prefix(model_id, region)
|
|
94
100
|
end
|
|
95
101
|
end
|
|
96
102
|
end
|
data/lib/ruby_llm/streaming.rb
CHANGED
|
@@ -60,6 +60,8 @@ module RubyLLM
|
|
|
60
60
|
|
|
61
61
|
if error_chunk?(chunk)
|
|
62
62
|
handle_error_chunk(chunk, env)
|
|
63
|
+
elsif json_error_payload?(chunk)
|
|
64
|
+
handle_json_error_chunk(chunk, env)
|
|
63
65
|
else
|
|
64
66
|
yield handle_sse(chunk, parser, env, &)
|
|
65
67
|
end
|
|
@@ -85,17 +87,24 @@ module RubyLLM
|
|
|
85
87
|
chunk.start_with?('event: error')
|
|
86
88
|
end
|
|
87
89
|
|
|
90
|
+
def json_error_payload?(chunk)
|
|
91
|
+
chunk.lstrip.start_with?('{') && chunk.include?('"error"')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def handle_json_error_chunk(chunk, env)
|
|
95
|
+
parsed_data = JSON.parse(chunk)
|
|
96
|
+
status, _message = parse_streaming_error(parsed_data.to_json)
|
|
97
|
+
error_response = build_stream_error_response(parsed_data, env, status)
|
|
98
|
+
ErrorMiddleware.parse_error(provider: self, response: error_response)
|
|
99
|
+
rescue JSON::ParserError => e
|
|
100
|
+
RubyLLM.logger.debug "Failed to parse JSON error chunk: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
88
103
|
def handle_error_chunk(chunk, env)
|
|
89
104
|
error_data = chunk.split("\n")[1].delete_prefix('data: ')
|
|
90
|
-
status, _message = parse_streaming_error(error_data)
|
|
91
105
|
parsed_data = JSON.parse(error_data)
|
|
92
|
-
|
|
93
|
-
error_response =
|
|
94
|
-
Struct.new(:body, :status).new(parsed_data, status)
|
|
95
|
-
else
|
|
96
|
-
env.merge(body: parsed_data, status: status)
|
|
97
|
-
end
|
|
98
|
-
|
|
106
|
+
status, _message = parse_streaming_error(parsed_data.to_json)
|
|
107
|
+
error_response = build_stream_error_response(parsed_data, env, status)
|
|
99
108
|
ErrorMiddleware.parse_error(provider: self, response: error_response)
|
|
100
109
|
rescue JSON::ParserError => e
|
|
101
110
|
RubyLLM.logger.debug "Failed to parse error chunk: #{e.message}"
|
|
@@ -104,7 +113,8 @@ module RubyLLM
|
|
|
104
113
|
def handle_failed_response(chunk, buffer, env)
|
|
105
114
|
buffer << chunk
|
|
106
115
|
error_data = JSON.parse(buffer)
|
|
107
|
-
|
|
116
|
+
status, _message = parse_streaming_error(error_data.to_json)
|
|
117
|
+
error_response = env.merge(body: error_data, status: status || env.status)
|
|
108
118
|
ErrorMiddleware.parse_error(provider: self, response: error_response)
|
|
109
119
|
rescue JSON::ParserError
|
|
110
120
|
RubyLLM.logger.debug "Accumulating error chunk: #{chunk}"
|
|
@@ -116,27 +126,26 @@ module RubyLLM
|
|
|
116
126
|
when :error
|
|
117
127
|
handle_error_event(data, env)
|
|
118
128
|
else
|
|
119
|
-
yield handle_data(data, &block) unless data == '[DONE]'
|
|
129
|
+
yield handle_data(data, env, &block) unless data == '[DONE]'
|
|
120
130
|
end
|
|
121
131
|
end
|
|
122
132
|
end
|
|
123
133
|
|
|
124
|
-
def handle_data(data)
|
|
125
|
-
JSON.parse(data)
|
|
134
|
+
def handle_data(data, env)
|
|
135
|
+
parsed = JSON.parse(data)
|
|
136
|
+
return parsed unless parsed.is_a?(Hash) && parsed.key?('error')
|
|
137
|
+
|
|
138
|
+
status, _message = parse_streaming_error(parsed.to_json)
|
|
139
|
+
error_response = build_stream_error_response(parsed, env, status)
|
|
140
|
+
ErrorMiddleware.parse_error(provider: self, response: error_response)
|
|
126
141
|
rescue JSON::ParserError => e
|
|
127
142
|
RubyLLM.logger.debug "Failed to parse data chunk: #{e.message}"
|
|
128
143
|
end
|
|
129
144
|
|
|
130
145
|
def handle_error_event(data, env)
|
|
131
|
-
status, _message = parse_streaming_error(data)
|
|
132
146
|
parsed_data = JSON.parse(data)
|
|
133
|
-
|
|
134
|
-
error_response =
|
|
135
|
-
Struct.new(:body, :status).new(parsed_data, status)
|
|
136
|
-
else
|
|
137
|
-
env.merge(body: parsed_data, status: status)
|
|
138
|
-
end
|
|
139
|
-
|
|
147
|
+
status, _message = parse_streaming_error(parsed_data.to_json)
|
|
148
|
+
error_response = build_stream_error_response(parsed_data, env, status)
|
|
140
149
|
ErrorMiddleware.parse_error(provider: self, response: error_response)
|
|
141
150
|
rescue JSON::ParserError => e
|
|
142
151
|
RubyLLM.logger.debug "Failed to parse error event: #{e.message}"
|
|
@@ -149,5 +158,15 @@ module RubyLLM
|
|
|
149
158
|
RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
|
|
150
159
|
[500, "Failed to parse error: #{data}"]
|
|
151
160
|
end
|
|
161
|
+
|
|
162
|
+
def build_stream_error_response(parsed_data, env, status)
|
|
163
|
+
error_status = status || env&.status || 500
|
|
164
|
+
|
|
165
|
+
if faraday_1?
|
|
166
|
+
Struct.new(:body, :status).new(parsed_data, error_status)
|
|
167
|
+
else
|
|
168
|
+
env.merge(body: parsed_data, status: error_status)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
152
171
|
end
|
|
153
172
|
end
|
data/lib/ruby_llm/tool.rb
CHANGED
|
@@ -186,7 +186,7 @@ module RubyLLM
|
|
|
186
186
|
def resolve_direct_schema(schema)
|
|
187
187
|
return extract_schema(schema.to_json_schema) if schema.respond_to?(:to_json_schema)
|
|
188
188
|
return RubyLLM::Utils.deep_dup(schema) if schema.is_a?(Hash)
|
|
189
|
-
if schema.is_a?(Class) && schema.
|
|
189
|
+
if schema.is_a?(Class) && schema.method_defined?(:to_json_schema)
|
|
190
190
|
return extract_schema(schema.new.to_json_schema)
|
|
191
191
|
end
|
|
192
192
|
|
data/lib/ruby_llm/version.rb
CHANGED
data/lib/tasks/models.rake
CHANGED
|
@@ -156,7 +156,7 @@ def generate_models_markdown
|
|
|
156
156
|
|
|
157
157
|
## Model Data Sources
|
|
158
158
|
|
|
159
|
-
- **OpenAI, Anthropic, DeepSeek, Gemini, VertexAI**: Enriched by [
|
|
159
|
+
- **OpenAI, Anthropic, DeepSeek, Gemini, VertexAI**: Enriched by [models.dev](https://models.dev/) *([LLM metadata API](https://models.dev/api.json))*
|
|
160
160
|
- **OpenRouter**: Direct API
|
|
161
161
|
- **Others**: Local capabilities files
|
|
162
162
|
|
|
@@ -354,7 +354,7 @@ def generate_aliases # rubocop:disable Metrics/PerceivedComplexity
|
|
|
354
354
|
|
|
355
355
|
models['bedrock'].each do |bedrock_model|
|
|
356
356
|
next unless bedrock_model.start_with?('anthropic.')
|
|
357
|
-
next unless bedrock_model =~ /anthropic\.(claude-[
|
|
357
|
+
next unless bedrock_model =~ /anthropic\.(claude-[a-z0-9.-]+)-\d{8}/
|
|
358
358
|
|
|
359
359
|
base_name = Regexp.last_match(1)
|
|
360
360
|
anthropic_name = base_name.tr('.', '-')
|