raix 2.0.3 → 2.0.5
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 +14 -0
- data/Gemfile.lock +1 -1
- data/Rakefile +25 -0
- data/lib/raix/chat_completion.rb +31 -10
- data/lib/raix/configuration.rb +9 -2
- data/lib/raix/function_tool_adapter.rb +12 -4
- 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: 3f9cda9662a819d937b2be909563e7bbd145111b2f70006a596b6fc30cf25fe6
|
|
4
|
+
data.tar.gz: 280b9bee2cc6b3f47ded7b0ae88b406842184c48597efc4c9aa085458bff4cab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dc7ee985af7a5b832803e7aa9300986f83fd520315e4d3668e68fa1d9084be08782517054dac91e9a187e190de2876215e0dfa71bb2f09ffb462d9f275b22e71
|
|
7
|
+
data.tar.gz: 49380b2201f8563dd7e69c30a76866b16b89d2085c6bc3e953afd6f233343d4f9c93ba891c02122228d2579341b9c64c423f16751d588d97e5105c6443d2da07
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [2.0.5] - 2026-06-04
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `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.
|
|
7
|
+
- `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.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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).
|
|
10
|
+
|
|
11
|
+
## [2.0.4] - 2026-05-19
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- `ruby_llm_request` now preserves the upstream provider's `id`, `model`, and `provider` fields, plus the full `usage` payload (including `prompt_tokens_details.cached_tokens` and `completion_tokens_details.reasoning_tokens`) on the OpenAI-compatible response hash. Previously the conversion dropped everything except `choices` and basic token counts, which broke callers that needed the generation id for authoritative cost lookups (e.g. OpenRouter's `/api/v1/generation` endpoint) or that wanted to verify prompt-cache hits via `cached_tokens`.
|
|
15
|
+
- Added `require "active_support/core_ext/module/delegation"` so `Raix::ChatCompletion` loads cleanly without an external preload of ActiveSupport. The class uses `delegate :configuration, to: :class` but did not pull in the required core ext, so a bare `require "raix"` would raise `NoMethodError` for `delegate`.
|
|
16
|
+
|
|
3
17
|
## [2.0.3] - 2026-04-30
|
|
4
18
|
|
|
5
19
|
### 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
|
@@ -4,6 +4,7 @@ require "active_support/concern"
|
|
|
4
4
|
require "active_support/core_ext/object/blank"
|
|
5
5
|
require "active_support/core_ext/string/filters"
|
|
6
6
|
require "active_support/core_ext/hash/indifferent_access"
|
|
7
|
+
require "active_support/core_ext/module/delegation"
|
|
7
8
|
require "ruby_llm"
|
|
8
9
|
|
|
9
10
|
module Raix
|
|
@@ -56,9 +57,7 @@ module Raix
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
# Instance level access to the class-level configuration.
|
|
59
|
-
|
|
60
|
-
self.class.configuration
|
|
61
|
-
end
|
|
60
|
+
delegate :configuration, to: :class
|
|
62
61
|
|
|
63
62
|
# This method performs chat completion based on the provided transcript and parameters.
|
|
64
63
|
#
|
|
@@ -356,7 +355,7 @@ module Raix
|
|
|
356
355
|
chat.with_instructions(content)
|
|
357
356
|
when "user"
|
|
358
357
|
has_user_message = true
|
|
359
|
-
chat.add_message(role: :user, content:)
|
|
358
|
+
chat.add_message(role: :user, content: MultimodalContentAdapter.translate(content))
|
|
360
359
|
when "assistant"
|
|
361
360
|
if msg[:tool_calls] || msg["tool_calls"]
|
|
362
361
|
chat.add_message(role: :assistant, content:, tool_calls: msg[:tool_calls] || msg["tool_calls"])
|
|
@@ -398,8 +397,34 @@ module Raix
|
|
|
398
397
|
# Non-streaming mode - return OpenAI-compatible response format
|
|
399
398
|
response_message = has_user_message ? chat.complete : chat.ask
|
|
400
399
|
|
|
401
|
-
#
|
|
400
|
+
# Pull through the raw provider payload when available. OpenRouter's
|
|
401
|
+
# `id` is the only handle we have to look up authoritative billing
|
|
402
|
+
# cost via /api/v1/generation, and callers that watch the response
|
|
403
|
+
# snapshot for `model` / cached-token counts shouldn't have to break
|
|
404
|
+
# out of the OpenAI-compatible shape to get them.
|
|
405
|
+
raw_body = response_message.raw.respond_to?(:body) ? response_message.raw.body : nil
|
|
406
|
+
raw_body = {} unless raw_body.is_a?(Hash)
|
|
407
|
+
|
|
408
|
+
usage_payload = {
|
|
409
|
+
"prompt_tokens" => response_message.input_tokens,
|
|
410
|
+
"completion_tokens" => response_message.output_tokens,
|
|
411
|
+
"total_tokens" => (response_message.input_tokens || 0) + (response_message.output_tokens || 0)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
# Merge prompt_tokens_details / completion_tokens_details (cached tokens,
|
|
415
|
+
# reasoning tokens) when the provider supplied them.
|
|
416
|
+
if (upstream_usage = raw_body["usage"]).is_a?(Hash)
|
|
417
|
+
upstream_usage.each do |key, value|
|
|
418
|
+
next if usage_payload.key?(key)
|
|
419
|
+
|
|
420
|
+
usage_payload[key] = value
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
402
424
|
{
|
|
425
|
+
"id" => raw_body["id"],
|
|
426
|
+
"model" => raw_body["model"] || response_message.model_id,
|
|
427
|
+
"provider" => raw_body["provider"],
|
|
403
428
|
"choices" => [
|
|
404
429
|
{
|
|
405
430
|
"message" => {
|
|
@@ -410,11 +435,7 @@ module Raix
|
|
|
410
435
|
"finish_reason" => response_message.tool_call? ? "tool_calls" : "stop"
|
|
411
436
|
}
|
|
412
437
|
],
|
|
413
|
-
"usage" =>
|
|
414
|
-
"prompt_tokens" => response_message.input_tokens,
|
|
415
|
-
"completion_tokens" => response_message.output_tokens,
|
|
416
|
-
"total_tokens" => (response_message.input_tokens || 0) + (response_message.output_tokens || 0)
|
|
417
|
-
}
|
|
438
|
+
"usage" => usage_payload
|
|
418
439
|
}
|
|
419
440
|
end
|
|
420
441
|
rescue StandardError => e
|
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
|
|
@@ -7,10 +7,18 @@ module Raix
|
|
|
7
7
|
tool_class = Class.new(RubyLLM::Tool) do
|
|
8
8
|
description function_def[:description] if function_def[:description]
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
# Forward the full JSON-schema parameter dict to RubyLLM rather than
|
|
11
|
+
# rebuilding it field-by-field via `param(...)`. The per-field path
|
|
12
|
+
# only carried `type` and `description`, which silently dropped richer
|
|
13
|
+
# schema like `additionalProperties`, `items`, `enum`, or nested
|
|
14
|
+
# `properties` — leaving providers (notably Gemini's structured output)
|
|
15
|
+
# to invent degenerate shapes for `type: object` arguments.
|
|
16
|
+
if function_def[:parameters].is_a?(Hash) && function_def[:parameters][:properties].present?
|
|
17
|
+
# RubyLLM's `params(schema)` path forwards the schema verbatim and, unlike the
|
|
18
|
+
# per-field `param(...)` path, does not inject the OpenAI strict-mode guards. Default
|
|
19
|
+
# them on so existing tools keep strict behavior, while letting a function declaration
|
|
20
|
+
# override either by setting it explicitly.
|
|
21
|
+
params({ additionalProperties: false, strict: true }.merge(function_def[:parameters]))
|
|
14
22
|
end
|
|
15
23
|
|
|
16
24
|
# Store reference to the instance and function name
|
|
@@ -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.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Obie Fernandez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-04
|
|
10
|
+
date: 2026-06-04 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
|