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 +4 -4
- data/CHANGELOG.md +16 -0
- data/Gemfile.lock +1 -1
- data/Rakefile +25 -0
- data/lib/raix/chat_completion.rb +93 -3
- data/lib/raix/configuration.rb +9 -2
- data/lib/raix/function_tool_adapter.rb +44 -6
- data/lib/raix/multimodal_content_adapter.rb +63 -0
- data/lib/raix/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 238b5d66fbe10f61f2264758964451d8e3a45da5b7ea0056e50a2cb87674d608
|
|
4
|
+
data.tar.gz: f80fc111967055c7d62d3facecb6e10190efaa6d9ad0e8df32034aae837bf2d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
data/lib/raix/chat_completion.rb
CHANGED
|
@@ -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:
|
|
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
|
data/lib/raix/configuration.rb
CHANGED
|
@@ -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
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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
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
|
+
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-
|
|
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
|