raix 2.0.4 → 2.0.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec3b7449e51440e1d669e323dea0c371a3d18d7d4083090d61599ff1dbce3bbe
4
- data.tar.gz: 6f2029218c2099ab1172d1d458be9ccb33836078d5abc9b04865cae0d497bc07
3
+ metadata.gz: 238b5d66fbe10f61f2264758964451d8e3a45da5b7ea0056e50a2cb87674d608
4
+ data.tar.gz: f80fc111967055c7d62d3facecb6e10190efaa6d9ad0e8df32034aae837bf2d2
5
5
  SHA512:
6
- metadata.gz: 76e1d77e80023dbc634e4b2f72eaa269f05d539ea97470df1355e69e73d75a6cf4bd2bd7e627ab2cf01fcb381fd85c4b153d28005f32190ff1b8ae69c3bea275
7
- data.tar.gz: 6fedf4bae5c406b72b83a1aacbc985849eb7c461237e7cc1e12d9ca73ea05a861912f13739bc92ba8ad992bf88f5a31fa530dc9468c5bdb4763f53caf3833843
6
+ metadata.gz: b45d5cf20fda5333e00b563af114e670d69cb22fb32b386753b05bc85cfc5e2c88a7a627f9d759697ba0c507ab93c63b0a242099bfce47f4ea8b82b34c9267a2
7
+ data.tar.gz: cabedce14ff63b6b7227aa8e2457541769170a4b1a0c8a52a0eeef73a0e62c53ff567cbfdfbf15d9045dca02582dccd9daf0155878d7b59d01b55a718b4bd185
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [2.0.6] - 2026-07-23
4
+
5
+ ### Fixed
6
+ - `max_tool_calls` is now enforced under the RubyLLM backend. RubyLLM drives the tool-call loop internally (`complete` → `handle_tool_calls` → `complete`) and only returns once the model stops calling tools, so the counting loop in `Raix::ChatCompletion` never saw a tool round — `max_tool_calls` was dead code and agentic loops ran unbounded. The budget is now enforced inside the generated `FunctionToolAdapter` tool wrapper, which increments a per-completion counter on each invocation and returns a `RubyLLM::Tool::Halt` (refusing to run the underlying function) once the count exceeds the budget. Enforcement is per call because a single model response can pack several parallel tool calls, all of which RubyLLM executes in one round even after one halts. On a halt, `chat_completion` issues one final completion with no tools (and drops `tool_choice`) and returns its text, appending the existing `"Maximum tool calls (N) exceeded…"` system message.
7
+ - `stop_tool_calls_and_respond!` works again. It set a flag that only the (now unreachable) counting loop read, so under RubyLLM it did nothing. The tool wrapper now checks the flag after a successful tool execution and halts the loop, forcing a final text response.
8
+ - `Raix::ChatCompletion` no longer crashes when RubyLLM's tool loop is halted. `chat.complete`/`#ask` return a `RubyLLM::Tool::Halt` (not a `Message`) in that case, and the response conversion called `#raw` / `#input_tokens` on it. `ruby_llm_request` now detects the `Halt` and hands it back for final-response handling.
9
+ - Assistant tool-call messages replayed from the transcript into a fresh `RubyLLM::Chat` are now translated from Raix's OpenAI-style array-of-hashes shape into the `{ id => RubyLLM::ToolCall }` shape RubyLLM's providers expect. Previously this path (reachable via the forced final completion above) raised `NoMethodError: undefined method 'id' for nil` while rendering the request payload.
10
+
11
+ ## [2.0.5] - 2026-06-04
12
+
13
+ ### Fixed
14
+ - `Raix::Configuration` no longer defaults `temperature` to `0.0`. The default was being injected into every request payload, which OpenRouter rejects with `404 No endpoints found that can handle the requested parameters` when routed to providers whose `supported_parameters` list omits `temperature` (notably Anthropic's Claude 4.7 family) and `provider.require_parameters: true` is set — which Raix sets automatically whenever `json: true` is passed to `chat_completion`. Callers that want a specific temperature should set one explicitly (`self.temperature = 0.0` on the including class, or `Raix.configure { |c| c.temperature = 0.0 }` globally); when unset, Raix now omits the parameter and the provider's own server-side default applies. `max_tokens`, `max_completion_tokens`, and `model` defaults are unchanged.
15
+ - `Raix::FunctionToolAdapter` now forwards the full JSON-Schema dict for each function parameter to RubyLLM instead of rebuilding it from `type` + `description` only. Rich schema fields like `additionalProperties`, `items`, `enum`, and nested `properties` were silently dropped, leaving providers (notably Gemini's structured output via OpenRouter) to invent degenerate shapes for `type: object` arguments — e.g. emitting `{"prefix" => false}` instead of `{"prefix:title" => "..."}`. Function declarations with rich object schemas now reach the provider intact.
16
+ - The outer tool-args schema continues to inject `additionalProperties: false` and `strict: true` by default for OpenAI strict-mode compatibility, but consumers can override either by setting them explicitly in the function declaration.
17
+ - Multimodal `transcript` content is no longer silently dropped on the RubyLLM backend. OpenAI-style content arrays (a `text` part plus one or more `{ type: "image_url", image_url: { url: ... } }` parts) were passed to RubyLLM verbatim, which treats the array as plain text — so a vision model received text only and confabulated an answer. Raix now translates `image_url` parts into RubyLLM attachments via `Raix::MultimodalContentAdapter`, decoding base64 `data:` URIs into binary IO (RubyLLM's `Attachment` does not natively recognize `data:` URIs) and passing http(s) URLs through. Text-only completions are unaffected (#51).
18
+
3
19
  ## [2.0.4] - 2026-05-19
4
20
 
5
21
  ### Fixed
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- raix (2.0.4)
4
+ raix (2.0.6)
5
5
  activesupport (>= 6.0)
6
6
  faraday-retry (~> 2.0)
7
7
  ostruct
data/Rakefile CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
+ require "shellwords"
6
+ require "tmpdir"
5
7
 
6
8
  RSpec::Core::RakeTask.new(:spec)
7
9
 
@@ -16,3 +18,26 @@ RuboCop::RakeTask.new(:rubocop) do |task|
16
18
  end
17
19
 
18
20
  task default: %i[spec rubocop]
21
+
22
+ namespace :release do
23
+ desc "Create a GitHub release for the current version (runs automatically after `rake release`)"
24
+ task :github do
25
+ version = Bundler::GemHelper.gemspec.version.to_s
26
+ tag = "v#{version}"
27
+ section = File.read("CHANGELOG.md").match(/^## \[#{Regexp.escape(version)}\][^\n]*\n(.*?)(?=^## \[|\z)/m)
28
+
29
+ if section.nil?
30
+ warn "release:github — no CHANGELOG entry for #{version}; skipping GitHub release."
31
+ next
32
+ end
33
+
34
+ notes_file = File.join(Dir.tmpdir, "raix-release-notes-#{version}.md")
35
+ File.write(notes_file, "#{section[1].strip}\n")
36
+
37
+ sh "gh release create #{tag.shellescape} --title #{"Release #{version}".shellescape} --notes-file #{notes_file.shellescape} --latest=true"
38
+ end
39
+ end
40
+
41
+ Rake::Task["release"].enhance do
42
+ Rake::Task["release:github"].invoke
43
+ end
@@ -127,6 +127,11 @@ module Raix
127
127
  # Track tool call count
128
128
  tool_call_count = 0
129
129
 
130
+ # Reset the per-completion tool-call counter that FunctionToolAdapter's
131
+ # generated wrappers use to enforce max_tool_calls under the RubyLLM
132
+ # backend (see #increment_tool_call_count).
133
+ @tool_call_count = 0
134
+
130
135
  # set the model to the default if not provided
131
136
  self.model ||= configuration.model
132
137
 
@@ -144,7 +149,27 @@ module Raix
144
149
  run_before_completion_hooks(params, messages)
145
150
 
146
151
  begin
152
+ # Snapshot the transcript length before the request. If a generated tool
153
+ # halts RubyLLM's loop, force_final_response_after_halt uses this to
154
+ # recover exactly the assistant/tool messages appended while executing
155
+ # tools and replay them after the original messages — which may not live
156
+ # in the transcript at all when `messages:` was passed explicitly. Only
157
+ # tools can trigger a halt, so skip the transcript read entirely when
158
+ # none are registered.
159
+ transcript_size_before = params[:tools].present? ? transcript.flatten.compact.size : 0
160
+
147
161
  response = ruby_llm_request(params:, model: openai || model, messages:, openai_override: openai)
162
+
163
+ # RubyLLM runs the entire tool-call loop inside chat.complete/#ask. When
164
+ # a generated tool halts that loop — because max_tool_calls was exceeded
165
+ # or stop_tool_calls_and_respond! was called — the return value is a
166
+ # RubyLLM::Tool::Halt rather than a Message. Re-issue one final
167
+ # completion with no tools; the shared handling below then returns its
168
+ # text/JSON as usual.
169
+ if response.is_a?(RubyLLM::Tool::Halt)
170
+ response = force_final_response_after_halt(response, params:, openai:, messages:, transcript_size_before:)
171
+ end
172
+
148
173
  retry_count = 0
149
174
  content = nil
150
175
 
@@ -298,8 +323,47 @@ module Raix
298
323
  public_send(function_name, arguments, cache)
299
324
  end
300
325
 
326
+ # Increments and returns the per-completion tool-call counter. Called by the
327
+ # generated FunctionToolAdapter wrappers to enforce max_tool_calls: RubyLLM
328
+ # runs the whole tool loop inside a single chat.complete, so Raix never sees
329
+ # the individual rounds and has to count from inside the tool. Internal API.
330
+ def increment_tool_call_count
331
+ @tool_call_count = @tool_call_count.to_i + 1
332
+ end
333
+
301
334
  private
302
335
 
336
+ # Issues the single final completion after RubyLLM's tool loop was halted,
337
+ # then completes once with no tools and returns the OpenAI-compatible hash.
338
+ #
339
+ # The final request is rebuilt from the original `messages` (the transcript
340
+ # or the caller-supplied `messages:` argument that drove the first request)
341
+ # plus the assistant/tool exchange FunctionDispatch appended while executing
342
+ # tools before the halt. `transcript_size_before` marks where that exchange
343
+ # begins, so the caller's system/user prompt is preserved even when it was
344
+ # never written into the transcript — otherwise the forced final call would
345
+ # answer from tool results (or stale transcript) alone.
346
+ def force_final_response_after_halt(halt, params:, openai:, messages:, transcript_size_before:)
347
+ adapter = MessageAdapters::Base.new(self)
348
+ tool_exchange = transcript.flatten.compact.drop(transcript_size_before).map { |msg| adapter.transform(msg) }
349
+ messages += tool_exchange
350
+
351
+ reason = halt.content
352
+ if reason.is_a?(FunctionToolAdapter::ToolCallsCapReached)
353
+ messages << { role: "system",
354
+ content: "Maximum tool calls (#{reason.max_tool_calls}) exceeded. Please provide a final response to the user without calling any more tools." }
355
+ end
356
+
357
+ # Force a final response without tools. Drop tool_choice as well: a
358
+ # lingering tool_choice that forces tool use with no tools registered is a
359
+ # provider error.
360
+ final_params = params.dup
361
+ final_params[:tools] = nil
362
+ final_params.delete(:tool_choice)
363
+
364
+ ruby_llm_request(params: final_params, model: openai || model, messages:, openai_override: openai)
365
+ end
366
+
303
367
  def filtered_tools(tool_names)
304
368
  return nil if tool_names.blank?
305
369
 
@@ -355,10 +419,10 @@ module Raix
355
419
  chat.with_instructions(content)
356
420
  when "user"
357
421
  has_user_message = true
358
- chat.add_message(role: :user, content:)
422
+ chat.add_message(role: :user, content: MultimodalContentAdapter.translate(content))
359
423
  when "assistant"
360
- if msg[:tool_calls] || msg["tool_calls"]
361
- chat.add_message(role: :assistant, content:, tool_calls: msg[:tool_calls] || msg["tool_calls"])
424
+ if (tool_calls = msg[:tool_calls] || msg["tool_calls"])
425
+ chat.add_message(role: :assistant, content:, tool_calls: normalize_tool_calls_for_ruby_llm(tool_calls))
362
426
  else
363
427
  chat.add_message(role: :assistant, content:)
364
428
  end
@@ -397,6 +461,13 @@ module Raix
397
461
  # Non-streaming mode - return OpenAI-compatible response format
398
462
  response_message = has_user_message ? chat.complete : chat.ask
399
463
 
464
+ # A generated tool can halt RubyLLM's internal tool loop (max_tool_calls
465
+ # exceeded or stop_tool_calls_and_respond!). When that happens
466
+ # chat.complete/#ask returns a RubyLLM::Tool::Halt, not a Message, so it
467
+ # has no #raw / #input_tokens. Hand it straight back to chat_completion,
468
+ # which forces a final tool-less completion.
469
+ return response_message if response_message.is_a?(RubyLLM::Tool::Halt)
470
+
400
471
  # Pull through the raw provider payload when available. OpenRouter's
401
472
  # `id` is the only handle we have to look up authoritative billing
402
473
  # cost via /api/v1/generation, and callers that watch the response
@@ -450,5 +521,24 @@ module Raix
450
521
  # Default to openrouter for model IDs with provider prefix
451
522
  :openrouter
452
523
  end
524
+
525
+ # Raix's transcript stores assistant tool calls in OpenAI's array-of-hashes
526
+ # shape (`[{ id:, type:, function: { name:, arguments: } }]`), but RubyLLM's
527
+ # providers format tool calls from a Hash keyed by call id whose values
528
+ # respond to #id/#name/#arguments (RubyLLM::ToolCall). Translate so a
529
+ # transcript that already contains tool exchanges can be replayed back into
530
+ # a fresh RubyLLM chat — notably for the forced final completion after a
531
+ # max_tool_calls / stop_tool_calls_and_respond! halt.
532
+ def normalize_tool_calls_for_ruby_llm(tool_calls)
533
+ return tool_calls if tool_calls.is_a?(Hash) && tool_calls.values.all?(RubyLLM::ToolCall)
534
+
535
+ Array(tool_calls).each_with_object({}) do |raw, acc|
536
+ tc = raw.respond_to?(:with_indifferent_access) ? raw.with_indifferent_access : raw
537
+ function = tc[:function] || {}
538
+ arguments = function[:arguments]
539
+ arguments = JSON.parse(arguments) if arguments.is_a?(String) && arguments.present?
540
+ acc[tc[:id]] = RubyLLM::ToolCall.new(id: tc[:id], name: function[:name], arguments: arguments || {})
541
+ end
542
+ end
453
543
  end
454
544
  end
@@ -51,12 +51,19 @@ module Raix
51
51
  DEFAULT_MAX_TOKENS = 1000
52
52
  DEFAULT_MAX_COMPLETION_TOKENS = 16_384
53
53
  DEFAULT_MODEL = "meta-llama/llama-3.3-8b-instruct:free"
54
- DEFAULT_TEMPERATURE = 0.0
55
54
  DEFAULT_MAX_TOOL_CALLS = 25
56
55
 
57
56
  # Initializes a new instance of the Configuration class with default values.
57
+ #
58
+ # Note: temperature is intentionally not defaulted. Setting a non-nil
59
+ # temperature here would force it into every request payload, and some
60
+ # providers (e.g. Anthropic's Claude 4.7 family on OpenRouter) do not list
61
+ # `temperature` in their supported parameters. Combined with
62
+ # `provider.require_parameters: true` (which Raix sets when `json: true`),
63
+ # an injected default of 0.0 causes OpenRouter to reject the request with
64
+ # "No endpoints found that can handle the requested parameters." Callers
65
+ # who want a specific temperature should set one explicitly.
58
66
  def initialize(fallback: nil)
59
- self.temperature = DEFAULT_TEMPERATURE
60
67
  self.max_completion_tokens = DEFAULT_MAX_COMPLETION_TOKENS
61
68
  self.max_tokens = DEFAULT_MAX_TOKENS
62
69
  self.model = DEFAULT_MODEL
@@ -3,23 +3,61 @@
3
3
  module Raix
4
4
  # Adapter to convert Raix function declarations to RubyLLM::Tool instances
5
5
  class FunctionToolAdapter
6
+ # Sentinels wrapped in a RubyLLM::Tool::Halt so that ChatCompletion can tell
7
+ # *why* RubyLLM's internal tool loop was halted from inside a generated tool
8
+ # wrapper. Internal API — not intended for use outside Raix.
9
+ ToolCallsCapReached = Struct.new(:max_tool_calls)
10
+ StopToolCallsRequested = Struct.new(:result)
11
+
6
12
  def self.create_tool_from_function(function_def, instance)
7
13
  tool_class = Class.new(RubyLLM::Tool) do
8
14
  description function_def[:description] if function_def[:description]
9
15
 
10
- # Define parameters based on function definition
11
- function_def[:parameters][:properties]&.each do |param_name, param_def|
12
- required = function_def[:parameters][:required]&.include?(param_name)
13
- param param_name.to_sym, type: param_def[:type], desc: param_def[:description], required:
16
+ # Forward the full JSON-schema parameter dict to RubyLLM rather than
17
+ # rebuilding it field-by-field via `param(...)`. The per-field path
18
+ # only carried `type` and `description`, which silently dropped richer
19
+ # schema like `additionalProperties`, `items`, `enum`, or nested
20
+ # `properties` — leaving providers (notably Gemini's structured output)
21
+ # to invent degenerate shapes for `type: object` arguments.
22
+ if function_def[:parameters].is_a?(Hash) && function_def[:parameters][:properties].present?
23
+ # RubyLLM's `params(schema)` path forwards the schema verbatim and, unlike the
24
+ # per-field `param(...)` path, does not inject the OpenAI strict-mode guards. Default
25
+ # them on so existing tools keep strict behavior, while letting a function declaration
26
+ # override either by setting it explicitly.
27
+ params({ additionalProperties: false, strict: true }.merge(function_def[:parameters]))
14
28
  end
15
29
 
16
30
  # Store reference to the instance and function name
17
31
  define_method(:raix_instance) { instance }
18
32
  define_method(:raix_function_name) { function_def[:name] }
19
33
 
20
- # Override execute to call the Raix function
34
+ # Override execute to call the Raix function.
35
+ #
36
+ # The max_tool_calls budget is enforced here, inside the generated
37
+ # wrapper, because RubyLLM drives the tool loop internally
38
+ # (complete -> handle_tool_calls -> complete) and never yields control
39
+ # back to Raix between rounds. Counting must therefore happen per tool
40
+ # invocation: a single model response can pack several parallel tool
41
+ # calls, all of which RubyLLM executes in one round even after one has
42
+ # halted, so each wrapper checks the shared counter independently.
21
43
  define_method(:execute) do |**args|
22
- raix_instance.public_send(raix_function_name, args.with_indifferent_access, nil)
44
+ if raix_instance.increment_tool_call_count > raix_instance.max_tool_calls
45
+ # Refuse to run the underlying function and halt the loop. RubyLLM
46
+ # returns this Halt from chat.complete; ChatCompletion turns it into
47
+ # a final, tool-less completion.
48
+ halt(ToolCallsCapReached.new(raix_instance.max_tool_calls))
49
+ else
50
+ result = raix_instance.public_send(raix_function_name, args.with_indifferent_access, nil)
51
+
52
+ # stop_tool_calls_and_respond! sets a flag that only the old (now
53
+ # unreachable) counting loop used to read. Honor it here so it still
54
+ # forces a final text response under the RubyLLM backend.
55
+ if raix_instance.stop_tool_calls_and_respond
56
+ halt(StopToolCallsRequested.new(result))
57
+ else
58
+ result
59
+ end
60
+ end
23
61
  end
24
62
  end
25
63
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/hash/indifferent_access"
4
+ require "base64"
5
+ require "stringio"
6
+
7
+ module Raix
8
+ # Translates OpenAI-style multimodal content arrays (a `text` part plus one or
9
+ # more `image_url` parts) into a RubyLLM::Content so images survive the trip to
10
+ # the provider.
11
+ #
12
+ # RubyLLM's `add_message`/`ask` treat a raw array of OpenAI content hashes as
13
+ # plain text, so an `{ type: "image_url", image_url: { url: ... } }` part is
14
+ # silently dropped and a vision model receives text only. See
15
+ # https://github.com/OlympiaAI/raix/issues/51
16
+ #
17
+ # Anything that is not an array of hashes containing at least one `image_url`
18
+ # part is returned untouched, so existing text completions are unaffected.
19
+ class MultimodalContentAdapter
20
+ def self.translate(content)
21
+ new(content).translate
22
+ end
23
+
24
+ def initialize(content)
25
+ @content = content
26
+ end
27
+
28
+ def translate
29
+ return @content unless translatable?
30
+
31
+ parts = @content.map(&:with_indifferent_access)
32
+ attachments = parts.select { |part| part[:type].to_s == "image_url" }
33
+ .filter_map { |part| attachment_source(part.dig(:image_url, :url)) }
34
+ return @content if attachments.empty?
35
+
36
+ text = parts.select { |part| part[:type].to_s == "text" }.filter_map { |part| part[:text] }.join("\n")
37
+ RubyLLM::Content.new(text.empty? ? nil : text, attachments)
38
+ end
39
+
40
+ private
41
+
42
+ def translatable?
43
+ @content.is_a?(Array) &&
44
+ @content.all? { |part| part.is_a?(Hash) } &&
45
+ @content.any? { |part| (part[:type] || part["type"]).to_s == "image_url" }
46
+ end
47
+
48
+ # RubyLLM::Attachment recognizes http(s) URLs, file paths, and IO objects, but
49
+ # not base64 `data:` URIs (it would treat one as a filesystem path). Decode
50
+ # those into a binary StringIO, which Attachment handles as an IO source.
51
+ def attachment_source(url)
52
+ return if url.nil? || url.empty?
53
+ return url unless url.start_with?("data:")
54
+
55
+ match = url.match(/\Adata:[^;,]*;base64,(.+)\z/m)
56
+ return url unless match
57
+
58
+ io = StringIO.new(Base64.decode64(match[1]))
59
+ io.set_encoding(Encoding::BINARY) if io.respond_to?(:set_encoding)
60
+ io
61
+ end
62
+ end
63
+ end
data/lib/raix/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raix
4
- VERSION = "2.0.4"
4
+ VERSION = "2.0.6"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raix
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.4
4
+ version: 2.0.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-19 00:00:00.000000000 Z
10
+ date: 2026-07-23 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -109,6 +109,7 @@ files:
109
109
  - lib/raix/mcp/stdio_client.rb
110
110
  - lib/raix/mcp/tool.rb
111
111
  - lib/raix/message_adapters/base.rb
112
+ - lib/raix/multimodal_content_adapter.rb
112
113
  - lib/raix/predicate.rb
113
114
  - lib/raix/prompt_declarations.rb
114
115
  - lib/raix/response_format.rb