Skip to content

Commit 2d46314

Browse files
obieclaude
andauthored
fix: translate OpenAI image_url content into RubyLLM attachments (#51) (#54)
* fix: translate OpenAI image_url content into RubyLLM attachments (#51) A multimodal user message added to the transcript in OpenAI content-array form (a `text` part plus `{ type: "image_url", image_url: { url: ... } }`) was passed to RubyLLM's `add_message` verbatim. RubyLLM treats a raw array of OpenAI content hashes as plain text, so the image was silently dropped — a vision model received text only and confabulated an answer, with no error. Add `Raix::MultimodalContentAdapter`, which translates `image_url` parts into RubyLLM attachments before `add_message`: - base64 `data:` URIs are decoded into a binary StringIO, because RubyLLM's `Attachment` does not recognize `data:` URIs and would treat one as a filesystem path. - http(s) URLs are passed through as-is (Attachment fetches them). Anything that is not an array of hashes containing at least one `image_url` part is returned untouched, so text completions and the Anthropic cache_control multipart shape are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: changelog for image_url multimodal fix (#51) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1d44179 commit 2d46314

4 files changed

Lines changed: 131 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- `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.
77
- `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.
88
- 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).
910

1011
## [2.0.4] - 2026-05-19
1112

lib/raix/chat_completion.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,7 @@ def ruby_llm_request(params:, model:, messages:, openai_override: nil)
355355
chat.with_instructions(content)
356356
when "user"
357357
has_user_message = true
358-
chat.add_message(role: :user, content:)
358+
chat.add_message(role: :user, content: MultimodalContentAdapter.translate(content))
359359
when "assistant"
360360
if msg[:tool_calls] || msg["tool_calls"]
361361
chat.add_message(role: :assistant, content:, tool_calls: msg[:tool_calls] || msg["tool_calls"])
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Raix::MultimodalContentAdapter do
6+
# 2x2 solid-red PNG
7+
let(:red_png_base64) do
8+
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEUlEQVR4nGP8z8Dwn4EIwDiqEAAQOAQBjEZ1pgAAAABJRU5ErkJggg=="
9+
end
10+
let(:data_uri) { "data:image/png;base64,#{red_png_base64}" }
11+
12+
describe ".translate" do
13+
it "turns a data-URI image_url part into a RubyLLM::Content with a decoded image attachment" do
14+
content = [{ type: "image_url", image_url: { url: data_uri } }]
15+
16+
result = described_class.translate(content)
17+
18+
expect(result).to be_a(RubyLLM::Content)
19+
expect(result.attachments.size).to eq(1)
20+
attachment = result.attachments.first
21+
expect(attachment).to be_image
22+
expect(attachment.mime_type).to eq("image/png")
23+
end
24+
25+
it "turns an http image_url part into a RubyLLM::Content with a URL attachment" do
26+
content = [{ type: "image_url", image_url: { url: "https://example.com/red.png" } }]
27+
28+
result = described_class.translate(content)
29+
30+
expect(result).to be_a(RubyLLM::Content)
31+
attachment = result.attachments.first
32+
expect(attachment).to be_url
33+
expect(attachment.source.to_s).to eq("https://example.com/red.png")
34+
end
35+
36+
it "keeps the text part alongside the image attachment" do
37+
content = [
38+
{ type: "text", text: "What color is this?" },
39+
{ type: "image_url", image_url: { url: data_uri } }
40+
]
41+
42+
result = described_class.translate(content)
43+
44+
expect(result.text).to eq("What color is this?")
45+
expect(result.attachments.size).to eq(1)
46+
end
47+
48+
it "accepts string-keyed parts (OpenAI JSON shape)" do
49+
content = [{ "type" => "image_url", "image_url" => { "url" => data_uri } }]
50+
51+
result = described_class.translate(content)
52+
53+
expect(result.attachments.size).to eq(1)
54+
end
55+
56+
it "returns plain string content unchanged" do
57+
expect(described_class.translate("just text")).to eq("just text")
58+
end
59+
60+
it "leaves a text-only content array untouched" do
61+
content = [{ type: "text", text: "hello" }]
62+
63+
expect(described_class.translate(content)).to equal(content)
64+
end
65+
end
66+
end

0 commit comments

Comments
 (0)