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.
@@ -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
- parsera_models = fetch_from_parsera
31
- merged_models = merge_models(provider_models, parsera_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 fetch_from_parsera
95
- RubyLLM.logger.info 'Fetching models from Parsera API...'
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.parsera.org/v1/llm-specs'
102
- models = response.body.map { |data| Model::Info.new(data) }
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, parsera_models)
107
- parsera_by_key = index_by_key(parsera_models)
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 = parsera_by_key.keys | provider_by_key.keys
131
+ all_keys = models_dev_by_key.keys | provider_by_key.keys
111
132
 
112
133
  models = all_keys.map do |key|
113
- parsera_model = find_parsera_model(key, parsera_by_key)
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 parsera_model && provider_model
117
- add_provider_metadata(parsera_model, provider_model)
118
- elsif parsera_model
119
- parsera_model
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 find_parsera_model(key, parsera_by_key)
149
+ def find_models_dev_model(key, models_dev_by_key)
129
150
  # Direct match
130
- return parsera_by_key[key] if parsera_by_key[key]
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 = parsera_by_key["gemini:#{model_id}"]
157
+ gemini_model = models_dev_by_key["gemini:#{model_id}"]
137
158
  return unless gemini_model
138
159
 
139
- # Return Gemini's Parsera data but with VertexAI as provider
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(parsera_model, provider_model)
150
- data = parsera_model.to_h
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
- return model_id unless model_data['inferenceTypesSupported']&.include?('INFERENCE_PROFILE')
73
- return model_id if model_data['inferenceTypesSupported']&.include?('ON_DEMAND')
72
+ normalize_inference_profile_id(
73
+ model_id,
74
+ model_data['inferenceTypesSupported'],
75
+ @config.bedrock_region
76
+ )
77
+ end
74
78
 
75
- desired_region_prefix = inference_profile_region_prefix
79
+ def region_prefix(region)
80
+ region = region.to_s
81
+ return 'us' if region.empty?
76
82
 
77
- # Return unchanged if model already has the correct region prefix
78
- return model_id if model_id.start_with?("#{desired_region_prefix}.")
83
+ region[0, 2]
84
+ end
79
85
 
80
- # Remove any existing region prefix (e.g., "us.", "eu.", "ap.")
81
- clean_model_id = model_id.sub(/^[a-z]{2}\./, '')
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
- # Apply the desired region prefix
84
- "#{desired_region_prefix}.#{clean_model_id}"
90
+ clean_model_id = model_id.sub(/^[a-z]{2}\./, '')
91
+ "#{desired_prefix}.#{clean_model_id}"
85
92
  end
86
93
 
87
- def inference_profile_region_prefix
88
- # Extract region prefix from bedrock_region (e.g., "eu-west-3" -> "eu")
89
- region = @config.bedrock_region.to_s
90
- return 'us' if region.empty? # Default fallback
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
- # Take first two characters as the region prefix
93
- region[0, 2]
99
+ with_region_prefix(model_id, region)
94
100
  end
95
101
  end
96
102
  end
@@ -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 = if faraday_1?
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
- error_response = env.merge(body: error_data)
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 = if faraday_1?
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.instance_methods.include?(:to_json_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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyLLM
4
- VERSION = '1.9.1'
4
+ VERSION = '1.9.2'
5
5
  end
@@ -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 [🚀 Parsera](https://parsera.org/) *([free LLM metadata API](https://api.parsera.org/v1/llm-specs) - [go say thanks!](https://github.com/parsera-labs/api-llm-specs))*
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-[\d.]+-[a-z]+)/
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('.', '-')
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 1.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino