ask-llm-providers 0.8.7 → 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 +41 -0
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/anthropic.rb +94 -5
- data/lib/ask/provider/openai.rb +251 -16
- 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,44 @@
|
|
|
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
|
+
|
|
20
|
+
## [0.9.0] — 2026-07-21
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Prompt caching support for Anthropic** — `build_request` now accepts `prompt_caching: true`. When enabled, the system prompt is sent as an array with `cache_control: { type: "ephemeral" }` and the last user message content is wrapped to include `cache_control`. Response metadata includes `cache_creation_input_tokens` and `cache_read_input_tokens` from the provider.
|
|
25
|
+
|
|
26
|
+
- **Prompt caching support for OpenAI** — `parse_response` now extracts `usage.prompt_tokens_details.cached_tokens` into response metadata as `cached_tokens`.
|
|
27
|
+
|
|
28
|
+
- **Both providers advertise `prompt_caching: true`** in their capabilities.
|
|
29
|
+
|
|
30
|
+
## [0.8.7] — 2026-07-18
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **`OpenAI#format_message` no longer sends empty `tool_calls: []` on assistant messages** — When a model response has no tool calls, the formatted message previously included `tool_calls: []` in the output. Some providers (notably opencode_go) reject messages with an empty `tool_calls` array. Now the field is only added when there are actual tool calls to report. Fixes multi-turn conversations breaking on all providers that reject empty `tool_calls`.
|
|
35
|
+
|
|
36
|
+
## [0.8.6] — 2026-07-18
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **`OpenAICompatible#resolve_credential_from_env_name` now tries both flat key and path segments** — The credential fallback resolves the `api_key_env` name (e.g., `OPENCODE_API_KEY`) both as a flat key (`:opencode_api_key`) and as a nested path (`[:opencode, :api_key]`). This works with the new `Ask::Auth.resolve` multi-name and path segment support from ask-auth 0.2.3.
|
|
41
|
+
|
|
1
42
|
## [0.8.5] — 2026-07-18
|
|
2
43
|
|
|
3
44
|
### Fixed
|
data/lib/ask/llm/version.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Ask
|
|
|
69
69
|
|
|
70
70
|
def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
|
|
71
71
|
system_msgs, chat_msgs = messages.partition { |m| (m[:role] || m["role"]).to_s == "system" }
|
|
72
|
-
|
|
72
|
+
prompt_caching = params.delete(:prompt_caching) || false
|
|
73
73
|
|
|
74
74
|
payload = {
|
|
75
75
|
model:,
|
|
@@ -78,7 +78,21 @@ module Ask
|
|
|
78
78
|
stream: stream || false
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
if prompt_caching
|
|
82
|
+
payload[:system] = format_system_with_caching(system_msgs, chat_msgs)
|
|
83
|
+
# Mark the last user message for caching (required by Anthropic for conversation caching)
|
|
84
|
+
if payload[:messages].any?
|
|
85
|
+
last_user_idx = payload[:messages].rindex { |m| m[:role] == "user" }
|
|
86
|
+
if last_user_idx
|
|
87
|
+
content = payload[:messages][last_user_idx][:content]
|
|
88
|
+
payload[:messages][last_user_idx][:content] = wrap_content_for_caching(content)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
else
|
|
92
|
+
system_content = format_system_content(system_msgs)
|
|
93
|
+
payload[:system] = system_content if system_content
|
|
94
|
+
end
|
|
95
|
+
|
|
82
96
|
tool_defs = format_tools(tools) if tools&.any?
|
|
83
97
|
payload[:tools] = tool_defs if tool_defs
|
|
84
98
|
payload[:temperature] = temperature if temperature
|
|
@@ -102,6 +116,8 @@ module Ask
|
|
|
102
116
|
stop_sequence: body["stop_sequence"],
|
|
103
117
|
input_tokens: usage["input_tokens"],
|
|
104
118
|
output_tokens: usage["output_tokens"],
|
|
119
|
+
cache_creation_input_tokens: usage["cache_creation_input_tokens"],
|
|
120
|
+
cache_read_input_tokens: usage["cache_read_input_tokens"],
|
|
105
121
|
thinking: thinking_blocks.map { |b| b["thinking"] || b["text"] }.compact.join("\n"),
|
|
106
122
|
raw: body
|
|
107
123
|
}.compact
|
|
@@ -147,10 +163,11 @@ module Ask
|
|
|
147
163
|
|
|
148
164
|
def format_message(msg)
|
|
149
165
|
role = (msg[:role] || msg["role"]).to_s
|
|
150
|
-
|
|
166
|
+
raw_content = msg[:content] || msg["content"]
|
|
151
167
|
|
|
152
168
|
if msg[:tool_calls] || msg["tool_calls"]
|
|
153
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
|
|
154
171
|
return {
|
|
155
172
|
role:,
|
|
156
173
|
content:,
|
|
@@ -166,17 +183,63 @@ module Ask
|
|
|
166
183
|
end
|
|
167
184
|
|
|
168
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
|
|
169
191
|
return {
|
|
170
192
|
role: "user",
|
|
171
193
|
content: [{
|
|
172
194
|
type: "tool_result",
|
|
173
195
|
tool_use_id: msg[:tool_call_id] || msg["tool_call_id"],
|
|
174
|
-
content: content
|
|
196
|
+
content: content
|
|
175
197
|
}]
|
|
176
198
|
}
|
|
177
199
|
end
|
|
178
200
|
|
|
179
|
-
|
|
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
|
|
180
243
|
end
|
|
181
244
|
|
|
182
245
|
private
|
|
@@ -203,6 +266,32 @@ module Ask
|
|
|
203
266
|
texts.join("\n")
|
|
204
267
|
end
|
|
205
268
|
|
|
269
|
+
def format_system_with_caching(system_msgs, chat_msgs)
|
|
270
|
+
texts = system_msgs.map { |m| m[:content] || m["content"] }.compact
|
|
271
|
+
return nil if texts.empty?
|
|
272
|
+
|
|
273
|
+
combined = texts.join("\n")
|
|
274
|
+
[{ type: "text", text: combined, cache_control: { type: "ephemeral" } }]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Wrap the last user message content for caching.
|
|
278
|
+
# Plain strings become [{ type: "text", text: content, cache_control: { type: "ephemeral" } }].
|
|
279
|
+
# Already-structured content blocks get cache_control appended.
|
|
280
|
+
def wrap_content_for_caching(content)
|
|
281
|
+
case content
|
|
282
|
+
when Array
|
|
283
|
+
content.map { |c|
|
|
284
|
+
if c.is_a?(Hash)
|
|
285
|
+
c.merge(cache_control: { type: "ephemeral" })
|
|
286
|
+
else
|
|
287
|
+
{ type: "text", text: c.to_s, cache_control: { type: "ephemeral" } }
|
|
288
|
+
end
|
|
289
|
+
}
|
|
290
|
+
else
|
|
291
|
+
[{ type: "text", text: content.to_s, cache_control: { type: "ephemeral" } }]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
206
295
|
def parse_json(str)
|
|
207
296
|
JSON.parse(str)
|
|
208
297
|
rescue JSON::ParserError
|
data/lib/ask/provider/openai.rb
CHANGED
|
@@ -31,8 +31,19 @@ module Ask
|
|
|
31
31
|
|
|
32
32
|
def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
|
|
33
33
|
msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
|
|
35
|
+
# Separate provider tools from regular tools
|
|
36
|
+
regular_tools, provider_tools = split_tools(tools)
|
|
37
|
+
|
|
38
|
+
if provider_tools.any?
|
|
39
|
+
# Use the Responses API when provider tools are involved
|
|
40
|
+
responses_chat(msgs, model:, regular_tools:, provider_tools:,
|
|
41
|
+
temperature:, stream:, schema:, **params, &block)
|
|
42
|
+
else
|
|
43
|
+
payload = build_request(msgs, model:, tools: regular_tools,
|
|
44
|
+
temperature:, stream:, schema:, **params)
|
|
45
|
+
stream ? chat_stream(payload, model, &block) : chat_nonstream(payload, model)
|
|
46
|
+
end
|
|
36
47
|
end
|
|
37
48
|
|
|
38
49
|
def embed(texts, model:)
|
|
@@ -65,7 +76,8 @@ module Ask
|
|
|
65
76
|
{
|
|
66
77
|
chat: true, streaming: true, tool_calls: true, vision: true,
|
|
67
78
|
thinking: true, structured_output: true, embed: true,
|
|
68
|
-
transcribe: true, paint: true, moderate: true
|
|
79
|
+
transcribe: true, paint: true, moderate: true,
|
|
80
|
+
prompt_caching: true
|
|
69
81
|
}
|
|
70
82
|
end
|
|
71
83
|
|
|
@@ -94,18 +106,19 @@ module Ask
|
|
|
94
106
|
|
|
95
107
|
msg = choice["message"]
|
|
96
108
|
usage = body["usage"] || {}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
Ask::Message.new(
|
|
110
|
+
role: :assistant,
|
|
111
|
+
content: msg["content"],
|
|
112
|
+
tool_calls: parse_tool_calls(msg["tool_calls"]),
|
|
113
|
+
metadata: {
|
|
114
|
+
model: body["model"] || model,
|
|
115
|
+
finish_reason: choice["finish_reason"],
|
|
116
|
+
input_tokens: usage["prompt_tokens"],
|
|
117
|
+
output_tokens: usage["completion_tokens"],
|
|
118
|
+
cached_tokens: usage.dig("prompt_tokens_details", "cached_tokens"),
|
|
119
|
+
raw: body
|
|
120
|
+
}
|
|
121
|
+
)
|
|
109
122
|
end
|
|
110
123
|
|
|
111
124
|
def parse_stream(raw, stream, model, &block)
|
|
@@ -126,7 +139,15 @@ module Ask
|
|
|
126
139
|
end
|
|
127
140
|
end
|
|
128
141
|
|
|
142
|
+
def split_tools(tools)
|
|
143
|
+
return [[], []] unless tools&.any?
|
|
144
|
+
|
|
145
|
+
tools.partition { |t| !t.respond_to?(:provider_tool?) || !t.provider_tool? }
|
|
146
|
+
end
|
|
147
|
+
|
|
129
148
|
def format_tools(tools)
|
|
149
|
+
return [] unless tools&.any?
|
|
150
|
+
|
|
130
151
|
tools.map do |t|
|
|
131
152
|
{
|
|
132
153
|
type: "function",
|
|
@@ -139,9 +160,33 @@ module Ask
|
|
|
139
160
|
end
|
|
140
161
|
end
|
|
141
162
|
|
|
163
|
+
def format_responses_tools(provider_tools)
|
|
164
|
+
provider_tools.map do |pt|
|
|
165
|
+
case pt.name
|
|
166
|
+
when "web_search"
|
|
167
|
+
{ type: "web_search" }.merge(pt.args)
|
|
168
|
+
when "file_search"
|
|
169
|
+
{ type: "file_search" }.merge(pt.args)
|
|
170
|
+
when "code_interpreter"
|
|
171
|
+
{ type: "code_interpreter" }.merge(pt.args)
|
|
172
|
+
else
|
|
173
|
+
{ type: pt.name }.merge(pt.args)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
142
178
|
def format_message(msg)
|
|
143
179
|
role = msg[:role] || msg["role"] || :user
|
|
144
|
-
|
|
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|
|
|
145
190
|
if (tc = msg[:tool_calls] || msg["tool_calls"]) && tc.respond_to?(:any?) && tc.any?
|
|
146
191
|
calls = tc.is_a?(Hash) ? tc.values : tc
|
|
147
192
|
fm[:tool_calls] = calls.map { |t|
|
|
@@ -156,6 +201,139 @@ module Ask
|
|
|
156
201
|
end.compact
|
|
157
202
|
end
|
|
158
203
|
|
|
204
|
+
# Use the OpenAI Responses API, which supports provider-executed tools
|
|
205
|
+
# like web_search, file_search, and code_interpreter.
|
|
206
|
+
def responses_chat(messages, model:, regular_tools:, provider_tools:,
|
|
207
|
+
temperature: nil, stream: nil, schema: nil, **params, &block)
|
|
208
|
+
payload = {
|
|
209
|
+
model: model,
|
|
210
|
+
input: format_responses_input(messages)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
all_tools = []
|
|
214
|
+
all_tools.concat(format_tools(regular_tools)) if regular_tools&.any?
|
|
215
|
+
all_tools.concat(format_responses_tools(provider_tools)) if provider_tools&.any?
|
|
216
|
+
payload[:tools] = all_tools if all_tools.any?
|
|
217
|
+
payload[:temperature] = temperature if temperature
|
|
218
|
+
payload.merge!(params)
|
|
219
|
+
|
|
220
|
+
if stream
|
|
221
|
+
responses_chat_stream(payload, model, provider_tools, &block)
|
|
222
|
+
else
|
|
223
|
+
responses_chat_nonstream(payload, model, provider_tools)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def responses_chat_nonstream(payload, model, provider_tools)
|
|
228
|
+
response = @http.post("responses") { |r| r.body = payload }
|
|
229
|
+
raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success?
|
|
230
|
+
|
|
231
|
+
body = response.body
|
|
232
|
+
output = body["output"] || []
|
|
233
|
+
|
|
234
|
+
# Extract text content and provider-executed tool results
|
|
235
|
+
text_parts = output.select { |o| o["type"] == "message" }
|
|
236
|
+
content = text_parts.flat_map { |m| (m["content"] || []) }
|
|
237
|
+
.select { |c| c["type"] == "output_text" }
|
|
238
|
+
.map { |c| c["text"] }
|
|
239
|
+
.join
|
|
240
|
+
|
|
241
|
+
# Extract provider-executed tool results
|
|
242
|
+
provider_results = extract_responses_provider_results(output, provider_tools)
|
|
243
|
+
|
|
244
|
+
# Extract regular tool calls
|
|
245
|
+
regular_calls = extract_responses_tool_calls(output)
|
|
246
|
+
|
|
247
|
+
usage = body["usage"] || {}
|
|
248
|
+
Ask::Message.new(
|
|
249
|
+
role: :assistant,
|
|
250
|
+
content: content,
|
|
251
|
+
tool_calls: regular_calls,
|
|
252
|
+
metadata: {
|
|
253
|
+
model: body["model"] || model,
|
|
254
|
+
finish_reason: body.dig("status"),
|
|
255
|
+
input_tokens: usage["input_tokens"],
|
|
256
|
+
output_tokens: usage["output_tokens"],
|
|
257
|
+
provider_results: provider_results,
|
|
258
|
+
raw: body
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def responses_chat_stream(payload, model, provider_tools, &block)
|
|
264
|
+
# Streaming with the Responses API — for now, fall back to non-streaming
|
|
265
|
+
# and return the full result. Full streaming support can be added later.
|
|
266
|
+
responses_chat_nonstream(payload, model, provider_tools)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def format_responses_input(messages)
|
|
270
|
+
messages.map do |msg|
|
|
271
|
+
role = msg[:role] || msg["role"] || "user"
|
|
272
|
+
content = msg[:content] || msg["content"] || ""
|
|
273
|
+
|
|
274
|
+
entry = { role: role.to_s }
|
|
275
|
+
entry[:content] = [{ type: "input_text", text: content.to_s }]
|
|
276
|
+
|
|
277
|
+
# Handle tool calls in assistant messages
|
|
278
|
+
if (tc = msg[:tool_calls] || msg["tool_calls"]) && tc.respond_to?(:any?) && tc.any?
|
|
279
|
+
calls = tc.is_a?(Hash) ? tc.values : tc
|
|
280
|
+
entry[:content] = calls.map { |t|
|
|
281
|
+
id = t.respond_to?(:id) ? t.id : (t[:id] || t["id"])
|
|
282
|
+
name = t.respond_to?(:name) ? t.name : (t[:name] || t["name"] || t.dig(:function, :name))
|
|
283
|
+
raw_args = t.respond_to?(:arguments) ? t.arguments : (t[:arguments] || t["arguments"] || t.dig(:function, :arguments))
|
|
284
|
+
args = raw_args.is_a?(String) ? raw_args : JSON.generate(raw_args)
|
|
285
|
+
{ type: "function_call", id: id, name: name, arguments: args, status: "completed" }
|
|
286
|
+
}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Handle tool results
|
|
290
|
+
if (tid = msg[:tool_call_id] || msg["tool_call_id"])
|
|
291
|
+
entry[:content] = [{ type: "function_call_output", id: tid, output: content.to_s }]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
entry
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def extract_responses_provider_results(output, provider_tools)
|
|
299
|
+
results = {}
|
|
300
|
+
provider_tool_names = provider_tools.map(&:name)
|
|
301
|
+
|
|
302
|
+
output.each do |item|
|
|
303
|
+
case item["type"]
|
|
304
|
+
when "web_search_call"
|
|
305
|
+
result_item = output.find { |o| o["type"] == "web_search_result" && o["id"] == item["id"] }
|
|
306
|
+
if result_item
|
|
307
|
+
results[item["id"]] = {
|
|
308
|
+
provider_executed: true,
|
|
309
|
+
tool_name: "web_search",
|
|
310
|
+
message: result_item.to_s,
|
|
311
|
+
status: "success"
|
|
312
|
+
}
|
|
313
|
+
end
|
|
314
|
+
when "file_search_call"
|
|
315
|
+
result_item = output.find { |o| o["type"] == "file_search_result" && o["id"] == item["id"] }
|
|
316
|
+
if result_item
|
|
317
|
+
results[item["id"]] = {
|
|
318
|
+
provider_executed: true,
|
|
319
|
+
tool_name: "file_search",
|
|
320
|
+
message: result_item.to_s,
|
|
321
|
+
status: "success"
|
|
322
|
+
}
|
|
323
|
+
end
|
|
324
|
+
when "function_call"
|
|
325
|
+
# Regular tool call — handled elsewhere
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
results
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def extract_responses_tool_calls(output)
|
|
332
|
+
output.select { |o| o["type"] == "function_call" }.map do |fc|
|
|
333
|
+
{ id: fc["id"], type: "function", name: fc["name"], arguments: fc["arguments"] }
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
159
337
|
private
|
|
160
338
|
|
|
161
339
|
def extract_provider_keys(config)
|
|
@@ -193,6 +371,63 @@ module Ask
|
|
|
193
371
|
messages.map { |msg| format_message(msg) }
|
|
194
372
|
end
|
|
195
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
|
+
|
|
196
431
|
def chat_nonstream(payload, model)
|
|
197
432
|
response = @http.post("chat/completions") { |r| r.body = payload }
|
|
198
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
|