raix 2.0.6 → 3.0.0

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.
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
3
  module Raix
5
4
  # Provides declarative function definition for ChatCompletion classes.
6
5
  #
@@ -64,45 +63,18 @@ module Raix
64
63
  end
65
64
  end
66
65
 
66
+ # Runs the function body and returns its result. ChatCompletion owns the
67
+ # transcript record of the exchange: it appends the model's own tool-call
68
+ # turn (real ids, signatures) alongside this result, then continues the
69
+ # conversation to get a final response.
67
70
  define_method(name) do |arguments, cache|
68
- id = SecureRandom.uuid[0, 23]
69
-
70
- content = if cache.present?
71
- cache.fetch([name, arguments]) do
72
- instance_exec(arguments, &block)
73
- end
74
- else
75
- instance_exec(arguments, &block)
76
- end
77
-
78
- # add in one operation to prevent race condition and potential wrong
79
- # interleaving of tool calls in multi-threaded environments
80
- transcript << [
81
- {
82
- role: "assistant",
83
- content: nil,
84
- tool_calls: [
85
- {
86
- id:,
87
- type: "function",
88
- function: {
89
- name:,
90
- arguments: arguments.to_json
91
- }
92
- }
93
- ]
94
- },
95
- {
96
- role: "tool",
97
- tool_call_id: id,
98
- name:,
99
- content: content.to_s
100
- }
101
- ]
102
-
103
- # Return the content - ChatCompletion will automatically continue
104
- # the conversation after tool execution to get a final response
105
- content
71
+ if cache.present?
72
+ cache.fetch([name, arguments]) do
73
+ instance_exec(arguments, &block)
74
+ end
75
+ else
76
+ instance_exec(arguments, &block)
77
+ end
106
78
  end
107
79
  end
108
80
  end
@@ -1,63 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raix
4
- # Adapter to convert Raix function declarations to RubyLLM::Tool instances
4
+ # Adapter to convert Raix function declarations to RubyLLM::Tool instances.
5
+ #
6
+ # The generated tools exist so RubyLLM sends the right schema to the
7
+ # provider. Raix drives the tool loop itself (see ChatCompletion), so it
8
+ # dispatches functions through `dispatch_tool_function` rather than letting
9
+ # RubyLLM execute these wrappers. `execute` is still implemented correctly so
10
+ # a generated tool behaves sanely if invoked directly.
5
11
  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
-
12
12
  def self.create_tool_from_function(function_def, instance)
13
13
  tool_class = Class.new(RubyLLM::Tool) do
14
14
  description function_def[:description] if function_def[:description]
15
15
 
16
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
17
+ # rebuilding it field-by-field via `parameter(...)`. The per-field path
18
+ # only carries `type` and `description`, which silently drops richer
19
19
  # schema like `additionalProperties`, `items`, `enum`, or nested
20
20
  # `properties` — leaving providers (notably Gemini's structured output)
21
21
  # to invent degenerate shapes for `type: object` arguments.
22
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
23
+ # RubyLLM's `parameters(schema)` path forwards the schema verbatim and, unlike the
24
+ # per-field `parameter(...)` path, does not inject the OpenAI strict-mode guards. Default
25
25
  # them on so existing tools keep strict behavior, while letting a function declaration
26
26
  # override either by setting it explicitly.
27
- params({ additionalProperties: false, strict: true }.merge(function_def[:parameters]))
27
+ parameters({ additionalProperties: false, strict: true }.merge(function_def[:parameters]))
28
28
  end
29
29
 
30
30
  # Store reference to the instance and function name
31
31
  define_method(:raix_instance) { instance }
32
32
  define_method(:raix_function_name) { function_def[:name] }
33
33
 
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.
43
34
  define_method(:execute) do |**args|
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
35
+ raix_instance.public_send(raix_function_name, args.with_indifferent_access, nil)
61
36
  end
62
37
  end
63
38
 
@@ -77,11 +52,16 @@ module Raix
77
52
  tool_instance
78
53
  end
79
54
 
80
- def self.convert_tools_for_ruby_llm(raix_instance)
55
+ # Converts the instance's declared functions. Pass `only:` (an array of
56
+ # function names) to convert just that subset, which is how
57
+ # `available_tools` restricts what the model can see.
58
+ def self.convert_tools_for_ruby_llm(raix_instance, only: nil)
81
59
  return [] unless raix_instance.class.respond_to?(:functions)
82
- return [] if raix_instance.class.functions.blank?
83
60
 
84
- raix_instance.class.functions.map do |function_def|
61
+ functions = Array(raix_instance.class.functions)
62
+ functions = functions.select { |function_def| only.include?(function_def[:name].to_s) } if only
63
+
64
+ functions.map do |function_def|
85
65
  create_tool_from_function(function_def, raix_instance)
86
66
  end
87
67
  end
data/lib/raix/mcp.rb CHANGED
@@ -7,13 +7,11 @@
7
7
  # `tools/list`) and exposes each remote tool as if it were an inline
8
8
  # `function` declared with Raix::FunctionDispatch. When the tool is
9
9
  # invoked by the model, the generated instance method forwards the
10
- # request to the remote server using `tools/call`, captures the result,
11
- # and appends the appropriate messages to the transcript so that the
12
- # conversation history stays consistent.
10
+ # request to the remote server using `tools/call` and returns the result.
11
+ # ChatCompletion records the exchange in the transcript during a tool round.
13
12
 
14
13
  require "active_support/concern"
15
14
  require "active_support/inflector"
16
- require "securerandom"
17
15
  require "uri"
18
16
 
19
17
  module Raix
@@ -124,36 +122,10 @@ module Raix
124
122
  stored_schema = self.class.instance_variable_get(:@tool_schemas)&.dig(local_name)
125
123
  coerced_arguments = coerce_arguments(arguments, stored_schema)
126
124
 
127
- content_text = client.call_tool(remote_name, **coerced_arguments)
128
- call_id = SecureRandom.uuid
129
-
130
- # Mirror FunctionDispatch transcript behaviour
131
- transcript << [
132
- {
133
- role: "assistant",
134
- content: nil,
135
- tool_calls: [
136
- {
137
- id: call_id,
138
- type: "function",
139
- function: {
140
- name: local_name.to_s,
141
- arguments: arguments.to_json
142
- }
143
- }
144
- ]
145
- },
146
- {
147
- role: "tool",
148
- tool_call_id: call_id,
149
- name: local_name.to_s,
150
- content: content_text
151
- }
152
- ]
153
-
154
- # Return the content - ChatCompletion will automatically continue
155
- # the conversation after tool execution
156
- content_text
125
+ # Return the content. ChatCompletion records the exchange in the
126
+ # transcript (the model's own tool-call turn plus this result) and
127
+ # continues the conversation.
128
+ client.call_tool(remote_name, **coerced_arguments)
157
129
  end
158
130
  end
159
131
 
@@ -5,18 +5,28 @@ require "base64"
5
5
  require "stringio"
6
6
 
7
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.
8
+ # Translates OpenAI-style structured content arrays into the pieces RubyLLM
9
+ # wants: a String of text, a list of attachment sources, and whether the
10
+ # message ends a prompt cache prefix.
11
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
12
+ # RubyLLM only accepts String content, so a message whose content is an array
13
+ # of `{ type: "text" }` / `{ type: "image_url" }` parts has to be taken apart
14
+ # before it can be added to a chat. Images become attachments, text parts are
15
+ # joined, and a part carrying `cache_control` marks the message as a cache
16
+ # boundary. See https://github.com/OlympiaAI/raix/issues/51
16
17
  #
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.
18
+ # Content that is not an array of hashes passes through untouched, so plain
19
+ # text completions are unaffected.
19
20
  class MultimodalContentAdapter
21
+ # `cache_ttl` carries the first `cache_control.ttl` seen, so the caller can
22
+ # pass it to RubyLLM's chat-level caching options. RubyLLM marks cache
23
+ # boundaries per message, so a boundary inside a content array lands at
24
+ # the end of that message.
25
+ Result = Struct.new(:content, :attachments, :cache_boundary, :cache_ttl) do
26
+ alias_method :cache_boundary?, :cache_boundary
27
+ end
28
+
29
+ # @return [Result]
20
30
  def self.translate(content)
21
31
  new(content).translate
22
32
  end
@@ -26,34 +36,40 @@ module Raix
26
36
  end
27
37
 
28
38
  def translate
29
- return @content unless translatable?
39
+ return Result.new(@content, [], false) unless structured?
30
40
 
31
41
  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
42
 
43
+ attachments = parts.select { |part| part[:type].to_s == "image_url" }
44
+ .filter_map { |part| attachment_for(part.dig(:image_url, :url)) }
36
45
  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)
46
+ cache_controls = parts.filter_map { |part| part[:cache_control].presence }
47
+ cache_ttl = cache_controls.filter_map { |control| control.is_a?(Hash) ? control[:ttl] : nil }.first
48
+
49
+ Result.new(text.empty? ? nil : text, attachments, cache_controls.any?, cache_ttl)
38
50
  end
39
51
 
40
52
  private
41
53
 
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" }
54
+ # An empty array counts: dynamically assembled content whose parts were all
55
+ # filtered out becomes nil content, which ChatCompletion sends as "".
56
+ def structured?
57
+ @content.is_a?(Array) && @content.all? { |part| part.is_a?(Hash) }
46
58
  end
47
59
 
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)
60
+ # OpenAI's `image_url` accepts an http(s) URL or a base64 `data:` URI, and
61
+ # that is all this adapter forwards. RubyLLM::Attachment would treat any
62
+ # other String as a filesystem path to read, so bare paths are skipped
63
+ # rather than passed through.
64
+ #
65
+ # RubyLLM does not decode `data:` URIs itself; those become a binary
66
+ # StringIO, and RubyLLM sniffs the media type from the bytes.
67
+ def attachment_for(url)
52
68
  return if url.nil? || url.empty?
53
- return url unless url.start_with?("data:")
69
+ return url if url.match?(%r{\Ahttps?://}i)
54
70
 
55
- match = url.match(/\Adata:[^;,]*;base64,(.+)\z/m)
56
- return url unless match
71
+ match = url.match(/\Adata:[^;,]*;base64,(.+)\z/mi)
72
+ return unless match
57
73
 
58
74
  io = StringIO.new(Base64.decode64(match[1]))
59
75
  io.set_encoding(Encoding::BINARY) if io.respond_to?(:set_encoding)
@@ -95,7 +95,7 @@ module Raix
95
95
  else
96
96
  __system_prompt = instance_exec(&current_prompt.system) if current_prompt.system.present? # rubocop:disable Lint/UnderscorePrefixedVariableName
97
97
  __system_prompt ||= system_prompt if respond_to?(:system_prompt)
98
- __system_prompt ||= self.class.system_prompt.presence
98
+ __system_prompt ||= self.class.system_prompt.presence if self.class.respond_to?(:system_prompt)
99
99
  transcript << { system: __system_prompt } if __system_prompt
100
100
  transcript << { user: instance_exec(&current_prompt.text) } # text is required
101
101
 
@@ -41,7 +41,7 @@ module Raix
41
41
 
42
42
  # Clear all messages
43
43
  def clear
44
- @ruby_llm_chat.reset_messages!
44
+ @ruby_llm_chat.messages = []
45
45
  @pending_messages.clear
46
46
  self
47
47
  end
@@ -63,8 +63,10 @@ module Raix
63
63
  def add_message_from_hash(hash)
64
64
  # Raix abbreviated format: { system: "text" }, { user: "text" }, { assistant: "text" }
65
65
  if hash.key?(:system) || hash.key?("system")
66
+ # Pending only, like every other role. Writing it into the memoized
67
+ # chat as well sent each system prompt twice and rejected structured
68
+ # (array) content before ChatCompletion could translate it.
66
69
  content = hash[:system] || hash["system"]
67
- @ruby_llm_chat.with_instructions(content)
68
70
  @pending_messages << { role: "system", content: }
69
71
  elsif hash.key?(:user) || hash.key?("user")
70
72
  content = hash[:user] || hash["user"]
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.6"
4
+ VERSION = "3.0.0"
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.6
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-07-23 00:00:00.000000000 Z
10
+ date: 2026-09-22 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '1.9'
60
+ version: '2.0'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '1.9'
67
+ version: '2.0'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: zeitwerk
70
70
  requirement: !ruby/object:Gem::Requirement