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 +4 -4
- data/CHANGELOG.md +19 -0
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/anthropic.rb +50 -3
- data/lib/ask/provider/openai.rb +67 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6670dc1a64e5b158fa6cb25b8acca8df2daad65bd866d1b67594425d6407c30
|
|
4
|
+
data.tar.gz: 195892b5274ea261223f22d221f462c1229f719c3834812cd510918a21ce1f5f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/ask/llm/version.rb
CHANGED
|
@@ -163,10 +163,11 @@ module Ask
|
|
|
163
163
|
|
|
164
164
|
def format_message(msg)
|
|
165
165
|
role = (msg[:role] || msg["role"]).to_s
|
|
166
|
-
|
|
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
|
-
|
|
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
|
data/lib/ask/provider/openai.rb
CHANGED
|
@@ -177,7 +177,16 @@ module Ask
|
|
|
177
177
|
|
|
178
178
|
def format_message(msg)
|
|
179
179
|
role = msg[:role] || msg["role"] || :user
|
|
180
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
+
version: 0.7.0
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: ask-auth
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|