ask-llm-providers 0.9.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ceebc12e911bceb83ec1528b66716605dbdb2e838d69051751ea9455f21cb018
4
- data.tar.gz: f435785c44e2cc895c8dce3280ad1220dd19445dea044fd11e0070f11605b476
3
+ metadata.gz: d6670dc1a64e5b158fa6cb25b8acca8df2daad65bd866d1b67594425d6407c30
4
+ data.tar.gz: 195892b5274ea261223f22d221f462c1229f719c3834812cd510918a21ce1f5f
5
5
  SHA512:
6
- metadata.gz: 299eb23798c50249fe2dce48981418516bf9630235eaaa25ffec01e6753aeaefe9c2867c02c697ee97084e092ed1e5ee4b7051cf1ef29b08efd095af402c9f3c
7
- data.tar.gz: 196fe1897eeccd769a1ce8b097a0ce238199abb50fd4d2e0d16378a4e83a13b6599765f60a2708d3a52f6f32245f96f098247bcd31a20591e1ba4ef50dbb591f
6
+ metadata.gz: f8bcd6ca9421703ec7a075dcc5431e487d420c610fa03c93ddf9c65918b798455334b92aa545e69da71831eca98eb993c10b0cf0b49e5fb4862d7b3810783980
7
+ data.tar.gz: 4272c5ccce88ee16f61097556838f40d25d64436f2c3d2e3d3db6cd08434b344944180c56e9ae66df98341ce66aff3b6379d0a563ad3b6913efea9eacab09b22
data/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## [0.10.0] — 2026-07-26
2
+
3
+ ### Added
4
+
5
+ - **Multi-modal content block support** — OpenAI and Anthropic providers now detect content block arrays in messages and serialize them to their native wire formats.
6
+
7
+ **OpenAI**: `image` (URL/base64/file_id) → `image_url`, `audio` (URL/base64) → `input_audio`, `video` → `image_url`, `file` → text.
8
+
9
+ **Anthropic**: `image` (base64/URL) → native `source` blocks, `image` with `file_id` → URL, `audio`/`video` → text fallback, `file` → text.
10
+
11
+ ### Changed
12
+
13
+ - `format_message` in both providers now handles Array `content` (from `Ask::Content` blocks in `Message#to_h`). Plain string content is unchanged — fully backward compatible.
14
+
15
+ ### Tested
16
+
17
+ - 20 new tests for provider-specific content block formatting
18
+ - 503 total tests, 0 failures
19
+
1
20
  ## [0.9.0] — 2026-07-21
2
21
 
3
22
  ### Added
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.9.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
@@ -163,10 +163,11 @@ module Ask
163
163
 
164
164
  def format_message(msg)
165
165
  role = (msg[:role] || msg["role"]).to_s
166
- content = msg[:content] || msg["content"]
166
+ raw_content = msg[:content] || msg["content"]
167
167
 
168
168
  if msg[:tool_calls] || msg["tool_calls"]
169
169
  tc = msg[:tool_calls] || msg["tool_calls"]
170
+ content = raw_content.is_a?(Array) ? raw_content.map { |b| format_anthropic_content_block(b) } : raw_content
170
171
  return {
171
172
  role:,
172
173
  content:,
@@ -182,17 +183,63 @@ module Ask
182
183
  end
183
184
 
184
185
  if msg[:tool_call_id] || msg["tool_call_id"]
186
+ content = if raw_content.is_a?(Array)
187
+ raw_content.map { |b| format_anthropic_content_block(b) }
188
+ else
189
+ raw_content || ""
190
+ end
185
191
  return {
186
192
  role: "user",
187
193
  content: [{
188
194
  type: "tool_result",
189
195
  tool_use_id: msg[:tool_call_id] || msg["tool_call_id"],
190
- content: content || ""
196
+ content: content
191
197
  }]
192
198
  }
193
199
  end
194
200
 
195
- { role:, content: }.compact
201
+ # Multi-modal content blocks
202
+ if raw_content.is_a?(Array)
203
+ content = raw_content.map { |b| format_anthropic_content_block(b) }
204
+ return { role:, content: }
205
+ end
206
+
207
+ { role:, content: raw_content }.compact
208
+ end
209
+
210
+ # Transform a generic content block hash into Anthropic's wire format.
211
+ # https://docs.anthropic.com/en/docs/build-with-claude/vision
212
+ def format_anthropic_content_block(block)
213
+ block = block.transform_keys(&:to_sym) if block.respond_to?(:transform_keys)
214
+ type = block[:type] || block["type"]
215
+
216
+ case type
217
+ when "text"
218
+ { type: "text", text: block[:text] || block["text"] }
219
+ when "image"
220
+ if block[:base64] || block["base64"]
221
+ mime = block[:mime_type] || block["mime_type"] || "image/png"
222
+ { type: "image", source: { type: "base64", media_type: mime, data: block[:base64] || block["base64"] } }
223
+ elsif block[:url] || block["url"]
224
+ mime = block[:mime_type] || block["mime_type"] || "image/jpeg"
225
+ { type: "image", source: { type: "url", url: block[:url] || block["url"] } }
226
+ elsif block[:file_id] || block["file_id"]
227
+ # Anthropic doesn't support file_id for images; pass as URL
228
+ { type: "image", source: { type: "url", url: block[:file_id] || block["file_id"] } }
229
+ else
230
+ block
231
+ end
232
+ when "audio", "video"
233
+ # Anthropic doesn't support audio/video content blocks in messages
234
+ # Fall back to text description
235
+ { type: "text", text: "[#{type} content not supported by Anthropic]" }
236
+ when "file"
237
+ data = block[:data] || block["data"] || ""
238
+ filename = block[:filename] ? "[#{block[:filename]}] " : ""
239
+ { type: "text", text: "#{filename}#{data}" }
240
+ else
241
+ block
242
+ end
196
243
  end
197
244
 
198
245
  private
@@ -177,7 +177,16 @@ module Ask
177
177
 
178
178
  def format_message(msg)
179
179
  role = msg[:role] || msg["role"] || :user
180
- { role: role.to_s, content: msg[:content] || msg["content"] }.tap do |fm|
180
+ raw_content = msg[:content] || msg["content"]
181
+
182
+ # Multi-modal content blocks — transform to OpenAI's format
183
+ if raw_content.is_a?(Array)
184
+ content = raw_content.map { |block| format_openai_content_block(block) }
185
+ else
186
+ content = raw_content
187
+ end
188
+
189
+ { role: role.to_s, content: content }.tap do |fm|
181
190
  if (tc = msg[:tool_calls] || msg["tool_calls"]) && tc.respond_to?(:any?) && tc.any?
182
191
  calls = tc.is_a?(Hash) ? tc.values : tc
183
192
  fm[:tool_calls] = calls.map { |t|
@@ -362,6 +371,63 @@ module Ask
362
371
  messages.map { |msg| format_message(msg) }
363
372
  end
364
373
 
374
+ # Transform a generic content block hash into OpenAI's wire format.
375
+ # https://platform.openai.com/docs/guides/vision
376
+ def format_openai_content_block(block)
377
+ block = block.transform_keys(&:to_sym) if block.respond_to?(:transform_keys)
378
+ type = block[:type] || block["type"]
379
+
380
+ case type
381
+ when "text"
382
+ { type: "text", text: block[:text] || block["text"] }
383
+ when "image"
384
+ if block[:url] || block["url"]
385
+ { type: "image_url", image_url: { url: block[:url] || block["url"] } }
386
+ elsif block[:base64] || block["base64"]
387
+ mime = block[:mime_type] || block["mime_type"] || "image/png"
388
+ data = block[:base64] || block["base64"]
389
+ { type: "image_url", image_url: { url: "data:#{mime};base64,#{data}" } }
390
+ elsif block[:file_id] || block["file_id"]
391
+ { type: "image_url", image_url: { url: block[:file_id] || block["file_id"] } }
392
+ else
393
+ block
394
+ end
395
+ when "audio"
396
+ if block[:url] || block["url"]
397
+ { type: "input_audio", input_audio: { data: block[:url] || block["url"], format: detect_audio_format(block) } }
398
+ elsif block[:base64] || block["base64"]
399
+ mime = block[:mime_type] || block["mime_type"] || "audio/wav"
400
+ { type: "input_audio", input_audio: { data: block[:base64] || block["base64"], format: mime.split("/").last } }
401
+ else
402
+ block
403
+ end
404
+ when "file"
405
+ # OpenAI doesn't have a generic file content block; send as text
406
+ data = block[:data] || block["data"] || ""
407
+ filename = block[:filename] ? "[#{block[:filename]}] " : ""
408
+ { type: "text", text: "#{filename}#{data}" }
409
+ when "video"
410
+ # OpenAI supports video via URLs (same as images)
411
+ if block[:url] || block["url"]
412
+ { type: "image_url", image_url: { url: block[:url] || block["url"] } }
413
+ else
414
+ block
415
+ end
416
+ else
417
+ block
418
+ end
419
+ end
420
+
421
+ def detect_audio_format(block)
422
+ mime = block[:mime_type] || block["mime_type"] || ""
423
+ case mime
424
+ when /mpeg|mp3/ then "mp3"
425
+ when /wav/ then "wav"
426
+ when /opus/ then "opus"
427
+ else mime.split("/").last || "wav"
428
+ end
429
+ end
430
+
365
431
  def chat_nonstream(payload, model)
366
432
  response = @http.post("chat/completions") { |r| r.body = payload }
367
433
  raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-llm-providers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.2.0
18
+ version: 0.7.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 0.2.0
25
+ version: 0.7.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ask-auth
28
28
  requirement: !ruby/object:Gem::Requirement