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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44058aeb590e21ead9e7db9ebdc9a4ecffc9f4771b75663e137b22ecca4cd7d2
4
- data.tar.gz: c59475844e68ef02379d85f7dde02540a58e7659d6b44a7fb83db7f491998635
3
+ metadata.gz: 3f9cda9662a819d937b2be909563e7bbd145111b2f70006a596b6fc30cf25fe6
4
+ data.tar.gz: 280b9bee2cc6b3f47ded7b0ae88b406842184c48597efc4c9aa085458bff4cab
5
5
  SHA512:
6
- metadata.gz: 76c43e2109fc3ec1be374c2b5df9e024ad09274e2ece3df976eb6f961d022123453bb995f6f0680921df5f224c439cbd19ec8eac8d5acc201d80193541396509
7
- data.tar.gz: c0ee5f840e83718168668cf844a2cb3e7969f2cd5e199235b8532049660aab0cf7750abcf0e8748ffda7427fee73c7739fc37060c739efc64bcf7b04867c9da5
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- raix (2.0.3)
4
+ raix (2.0.5)
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
@@ -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
- def configuration
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
- # Convert RubyLLM response to OpenAI format for compatibility
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
@@ -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
- # 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:
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raix
4
- VERSION = "2.0.3"
4
+ VERSION = "2.0.5"
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.3
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-30 00:00:00.000000000 Z
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