ruby_llm 2.2.0 → 2.2.1

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.
@@ -281,7 +281,30 @@ module RubyLLM
281
281
  .flatten
282
282
 
283
283
  provider_models = provider_fetch[:models] + preserved_models
284
- merge_models(provider_models, models_dev_fetch[:models])
284
+ models_dev_models = models_dev_fetch[:models]
285
+ if models_dev_fetch[:fetched]
286
+ models_dev_models += models_dev_gaps(existing_models, provider_fetch[:models], models_dev_models)
287
+ end
288
+ merge_models(provider_models, models_dev_models)
289
+ end
290
+
291
+ # models.dev sometimes drops a model its provider still serves. Until it
292
+ # lists the model again, keep the pricing, modalities and limits it last
293
+ # gave, and say so, so the gap gets reported upstream.
294
+ def models_dev_gaps(existing_models, listed_models, models_dev_models) # :nodoc:
295
+ models_dev_by_key = index_by_key(models_dev_models)
296
+ listed_by_key = index_by_key(listed_models)
297
+
298
+ existing_models.select do |model|
299
+ next false unless model.metadata[:source] == 'models.dev'
300
+
301
+ key = "#{model.provider}:#{model.id}"
302
+ listed = listed_by_key[key]
303
+ next false if listed.nil? || find_models_dev_model(key, models_dev_by_key, listed)
304
+
305
+ RubyLLM.logger.warn("kept from the previous registry: #{key} (missing from models.dev)")
306
+ true
307
+ end
285
308
  end
286
309
 
287
310
  def merge_models(provider_models, models_dev_models) # :nodoc:
@@ -106,15 +106,84 @@ module RubyLLM
106
106
  response_schema(value)
107
107
  end
108
108
  end
109
- nullable_type(schema)
109
+ numeric_enum(type_union(schema))
110
110
  end
111
111
 
112
- def nullable_type(schema)
112
+ TYPE_KEYWORDS = {
113
+ 'string' => %w[format maxLength minLength pattern],
114
+ 'number' => %w[format maximum minimum exclusiveMaximum exclusiveMinimum multipleOf],
115
+ 'integer' => %w[format maximum minimum exclusiveMaximum exclusiveMinimum multipleOf],
116
+ 'object' => %w[properties required maxProperties minProperties propertyOrdering],
117
+ 'array' => %w[items maxItems minItems prefixItems uniqueItems]
118
+ }.freeze
119
+ TYPED_KEYWORDS = TYPE_KEYWORDS.values.flatten.uniq.freeze
120
+ ENUM_VALUE_TYPES = {
121
+ 'string' => ->(value) { value.is_a?(String) },
122
+ 'number' => ->(value) { value.is_a?(Numeric) },
123
+ 'integer' => ->(value) { value.is_a?(Integer) },
124
+ 'boolean' => ->(value) { [true, false].include?(value) },
125
+ 'object' => ->(value) { value.is_a?(Hash) },
126
+ 'array' => ->(value) { value.is_a?(Array) },
127
+ 'null' => lambda(&:nil?)
128
+ }.freeze
129
+ private_constant :TYPE_KEYWORDS, :TYPED_KEYWORDS, :ENUM_VALUE_TYPES
130
+
131
+ # Gemini's Schema takes one type, so a JSON Schema type array becomes
132
+ # nullable plus anyOf, each member keeping the keywords and enum
133
+ # values of its type.
134
+ def type_union(schema)
113
135
  key = schema.key?(:type) ? :type : 'type'
114
- types = Array(schema[key])
115
- return schema unless types.length > 1 && types.include?('null')
136
+ return schema unless schema[key].is_a?(Array)
116
137
 
117
- schema.merge(key => (types - ['null']).first, nullable: true)
138
+ types = schema[key].map(&:to_s).uniq
139
+ return schema.merge(key => types.first) if types.one?
140
+
141
+ types = enum_types(schema, types)
142
+ others = types - ['null']
143
+ return schema.merge(key => 'null') if others.empty?
144
+
145
+ union = type_union_members(schema.except(key), others)
146
+ types.include?('null') ? union.merge(nullable: true) : union
147
+ end
148
+
149
+ def type_union_members(schema, types)
150
+ return union_member(schema, types.first) if types.one?
151
+
152
+ { anyOf: types.map { |type| union_member(schema, type) } }
153
+ end
154
+
155
+ def union_member(schema, type)
156
+ own = TYPE_KEYWORDS.fetch(type, [])
157
+ member = schema.reject { |name, _| TYPED_KEYWORDS.include?(name.to_s) && !own.include?(name.to_s) }
158
+ values = member[enum_key(member)]
159
+ member = member.merge(enum_key(member) => values.select(&enum_value_of(type))) if values.is_a?(Array)
160
+ numeric_enum({ type: }.merge(member))
161
+ end
162
+
163
+ # Gemini's Schema.enum is a list of strings; it constrains an integer
164
+ # or number schema to those values written as strings.
165
+ def numeric_enum(schema)
166
+ type = schema[:type] || schema['type']
167
+ values = schema[enum_key(schema)]
168
+ return schema unless %w[integer number].include?(type.to_s) && values.is_a?(Array)
169
+
170
+ schema.merge(enum_key(schema) => values.map { |value| value.is_a?(Numeric) ? value.to_s : value })
171
+ end
172
+
173
+ def enum_types(schema, types)
174
+ values = schema[enum_key(schema)]
175
+ return types unless values.is_a?(Array)
176
+
177
+ matching = types.select { |type| values.any?(&enum_value_of(type)) }
178
+ matching.empty? ? types : matching
179
+ end
180
+
181
+ def enum_key(schema)
182
+ schema.key?(:enum) ? :enum : 'enum'
183
+ end
184
+
185
+ def enum_value_of(type)
186
+ ENUM_VALUE_TYPES.fetch(type) { ->(_) { true } }
118
187
  end
119
188
 
120
189
  def batch_name(id)
@@ -27,12 +27,14 @@ module RubyLLM
27
27
 
28
28
  def parse_judgment_response(response, questions:)
29
29
  body = response.body
30
+ tokens = parse_tokens(body['usage'])
31
+ # The provider billed this attempt even if its answers don't parse.
32
+ @usage_tracker&.observe_tokens(tokens)
30
33
  named = body.fetch('answers').to_h { |answer| [answer.fetch('name'), answer] }
31
34
  answers = questions.values.to_h do |question|
32
35
  [question.name, parse_answer(named.fetch(question.name.to_s), question)]
33
36
  end
34
- RubyLLM::Judgment.new(answers:, model: body.fetch('model'), raw: response, model_info: @model,
35
- tokens: parse_tokens(body['usage']))
37
+ RubyLLM::Judgment.new(answers:, model: body.fetch('model'), raw: response, model_info: @model, tokens:)
36
38
  rescue KeyError, NoMethodError, TypeError => e
37
39
  raise Error.new('OpenAI Decisions returned an invalid judgment', provider_message: e.message, response:,
38
40
  redact: @config&.redact_error_messages)
@@ -9,14 +9,14 @@ module RubyLLM
9
9
  def parse_judgment_response(response, questions:)
10
10
  body = response.body
11
11
  usage = body['usage'] || {}
12
+ tokens = Tokens.new(input: usage['input_tokens'], output: usage['output_tokens'],
13
+ reported_cost: reported_cost(usage))
14
+ # The provider billed this attempt even if its answers don't parse.
15
+ @usage_tracker&.observe_tokens(tokens)
12
16
  answers = questions.values.to_h do |question|
13
17
  [question.name, parse_answer(body.fetch('answers').fetch(question.name.to_s), question)]
14
18
  end
15
- RubyLLM::Judgment.new(
16
- answers:, model: body['model'], raw: response, model_info: @model,
17
- tokens: Tokens.new(input: usage['input_tokens'], output: usage['output_tokens'],
18
- reported_cost: reported_cost(usage))
19
- )
19
+ RubyLLM::Judgment.new(answers:, model: body['model'], raw: response, model_info: @model, tokens:)
20
20
  rescue KeyError, NoMethodError, TypeError => e
21
21
  raise Error.new('System One returned an invalid judgment', provider_message: e.message, response:,
22
22
  redact: @config&.redact_error_messages)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module RubyLLM
4
4
  # The version of the ruby_llm gem, as a string.
5
- VERSION = '2.2.0'
5
+ VERSION = '2.2.1'
6
6
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  Read this when the task adds tools, agents, subagents, or per-request state.
4
4
 
5
- - Subclass `RubyLLM::Tool`. Use `description`, `parameter`, or a `parameters` block, and keyword arguments on `execute`. Register tools with `with_tools`; configure choice and concurrency with `with_tool_options`. For models that loop on tools, set `with_tool_options(rounds: 25)` (off by default, 2.2): after that many model responses asking for tools, `complete` raises `RubyLLM::ToolRoundLimitError` with every call answered, and calling `complete` again continues with a fresh count. Keep application authorization in the tool or underlying service. Use `requires_approval` when the product needs a user's decision before execution, and `ask` inside `execute` when the tool needs the user's answer to go on: the call runs again from the top with the answer, so ask first, then act (2.2). Call `halt(result)` in a tool that says the last word, so the turn ends without another model call (2.2). Declare `exclusive` on tools that share state, such as a browser page, so they run alone in a concurrent round (2.2). With large tool sets, pass `defer: true` to `with_tools` or `with_mcp` (or declare `defer` on the class) so the model searches for tools instead of receiving them all (2.1).
5
+ - Subclass `RubyLLM::Tool`. Use `description`, `parameter`, or a `parameters` block, and keyword arguments on `execute`. Register tools with `with_tools`; configure choice and concurrency with `with_tool_options`. For models that loop on tools, set `with_tool_options(rounds: 25)` (off by default, 2.2): after that many model responses asking for tools, `complete` raises `RubyLLM::ToolRoundLimitError` with every call answered, and calling `complete` again continues with a fresh count. To stop one response from making hundreds of calls, add `calls: 50` (2.2.1): the calls past it get an error result telling the model to go step by step, without running or asking for approval. Keep application authorization in the tool or underlying service. Use `requires_approval` when the product needs a user's decision before execution, and `ask` inside `execute` when the tool needs the user's answer to go on: the call runs again from the top with the answer, so ask first, then act (2.2). Call `halt(result)` in a tool that says the last word, so the turn ends without another model call (2.2). Declare `exclusive` on tools that share state, such as a browser page, so they run alone in a concurrent round (2.2). With large tool sets, pass `defer: true` to `with_tools` or `with_mcp` (or declare `defer` on the class) so the model searches for tools instead of receiving them all (2.1).
6
6
  - To hand work to another agent, put the agent class in `tools` (or `ResearchAgent.to_tool(customer:)` to fix its inputs): the model gives it a task, and a fresh agent answers within the call (2.2). That subagent can't wait, so it raises `RubyLLM::ConfigurationError` if it stops for an approval, a question, or a task. For a subagent that waits for a person in Rails, write a tool whose `execute` creates the subagent's chat with `BillingAgent.create!` and asks it, returns `RubyLLM::Task.new(status: :input_required, data: { "chat_id" => chat.id })` while that chat is `waiting?`, and whose `resume` finds it with `BillingAgent.find` and calls `complete`. The person answers the subagent chat's `pending_questions`; see `agents/#subagents-that-wait-for-a-person`.
7
7
  - When tools need a tenant, `Current` attributes, or other per-request state, set it with `around_tool_call` (or the agent macro), which runs in the tool's own thread or fiber (2.2), rather than passing the chat into every tool.
8
8
  - To turn a tool's exceptions into results the model reads, declare `rescue_from` on a base tool class, on `RubyLLM::Tool` for every tool, on `RubyLLM::CodeExecution`, or on an MCP server's class, rather than prepending a module around `call` (2.2). Subclasses use handlers declared later on their parents. The handler's return value becomes the call's result; re-raise to stop the chat. Handlers cover the calls a chat makes. Calling a tool directly (`tool.call(**args)` or `execute`) is still supported and isn't wrapped by handlers, so its exceptions reach the caller. A question from `ask` and a cancellation never reach it.
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: 2.2.0
4
+ version: 2.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
@@ -884,7 +884,7 @@ metadata:
884
884
  funding_uri: https://github.com/sponsors/crmne
885
885
  rubygems_mfa_required: 'true'
886
886
  post_install_message: |
887
- RubyLLM 2.2.0
887
+ RubyLLM 2.2.1
888
888
 
889
889
  Upgrading? Read the upgrade guide before you boot:
890
890