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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/questions/_question.html.erb.tt +2 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/questions/_question.html.erb.tt +2 -1
- data/lib/ruby_llm/active_record/chat_approvals.rb +3 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +14 -36
- data/lib/ruby_llm/active_record/chat_queue.rb +14 -1
- data/lib/ruby_llm/active_record/run_claims.rb +31 -3
- data/lib/ruby_llm/agent.rb +1 -0
- data/lib/ruby_llm/aliases.json +9 -0
- data/lib/ruby_llm/chat/file_references.rb +1 -4
- data/lib/ruby_llm/chat/tool_call_state.rb +11 -0
- data/lib/ruby_llm/chat.rb +86 -11
- data/lib/ruby_llm/configuration.rb +1 -1
- data/lib/ruby_llm/file_store/tools/read.rb +9 -3
- data/lib/ruby_llm/file_store.rb +42 -8
- data/lib/ruby_llm/models.json +3459 -1978
- data/lib/ruby_llm/models.rb +24 -1
- data/lib/ruby_llm/protocols/gemini/batches.rb +74 -5
- data/lib/ruby_llm/protocols/openai/decisions.rb +4 -2
- data/lib/ruby_llm/protocols/system_one/responses.rb +5 -5
- data/lib/ruby_llm/version.rb +1 -1
- data/skills/rubyllm/references/tools-and-agents.md +1 -1
- metadata +2 -2
data/lib/ruby_llm/models.rb
CHANGED
|
@@ -281,7 +281,30 @@ module RubyLLM
|
|
|
281
281
|
.flatten
|
|
282
282
|
|
|
283
283
|
provider_models = provider_fetch[:models] + preserved_models
|
|
284
|
-
|
|
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
|
-
|
|
109
|
+
numeric_enum(type_union(schema))
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
return schema unless types.length > 1 && types.include?('null')
|
|
136
|
+
return schema unless schema[key].is_a?(Array)
|
|
116
137
|
|
|
117
|
-
|
|
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)
|
data/lib/ruby_llm/version.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
887
|
+
RubyLLM 2.2.1
|
|
888
888
|
|
|
889
889
|
Upgrading? Read the upgrade guide before you boot:
|
|
890
890
|
|