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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +139 -0
- data/Gemfile.lock +19 -16
- data/README.md +2 -2
- data/lib/raix/chat_completion.rb +369 -193
- data/lib/raix/function_dispatch.rb +11 -39
- data/lib/raix/function_tool_adapter.rb +21 -41
- data/lib/raix/mcp.rb +6 -34
- data/lib/raix/multimodal_content_adapter.rb +41 -25
- data/lib/raix/prompt_declarations.rb +1 -1
- data/lib/raix/transcript_adapter.rb +4 -2
- data/lib/raix/version.rb +1 -1
- metadata +4 -4
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 `
|
|
18
|
-
# only
|
|
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 `
|
|
24
|
-
# per-field `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
11
|
-
#
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
18
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
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
|
|
69
|
+
return url if url.match?(%r{\Ahttps?://}i)
|
|
54
70
|
|
|
55
|
-
match = url.match(/\Adata:[^;,]*;base64,(.+)\z/
|
|
56
|
-
return
|
|
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(¤t_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(¤t_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.
|
|
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
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:
|
|
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-
|
|
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: '
|
|
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: '
|
|
67
|
+
version: '2.0'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: zeitwerk
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|