ask-llm-providers 0.8.7 → 0.9.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 +22 -0
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/anthropic.rb +44 -2
- data/lib/ask/provider/openai.rb +184 -15
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ceebc12e911bceb83ec1528b66716605dbdb2e838d69051751ea9455f21cb018
|
|
4
|
+
data.tar.gz: f435785c44e2cc895c8dce3280ad1220dd19445dea044fd11e0070f11605b476
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 299eb23798c50249fe2dce48981418516bf9630235eaaa25ffec01e6753aeaefe9c2867c02c697ee97084e092ed1e5ee4b7051cf1ef29b08efd095af402c9f3c
|
|
7
|
+
data.tar.gz: 196fe1897eeccd769a1ce8b097a0ce238199abb50fd4d2e0d16378a4e83a13b6599765f60a2708d3a52f6f32245f96f098247bcd31a20591e1ba4ef50dbb591f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## [0.9.0] — 2026-07-21
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
|
|
7
|
+
- **Prompt caching support for OpenAI** — `parse_response` now extracts `usage.prompt_tokens_details.cached_tokens` into response metadata as `cached_tokens`.
|
|
8
|
+
|
|
9
|
+
- **Both providers advertise `prompt_caching: true`** in their capabilities.
|
|
10
|
+
|
|
11
|
+
## [0.8.7] — 2026-07-18
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`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`.
|
|
16
|
+
|
|
17
|
+
## [0.8.6] — 2026-07-18
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`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.
|
|
22
|
+
|
|
1
23
|
## [0.8.5] — 2026-07-18
|
|
2
24
|
|
|
3
25
|
### 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
|
|
@@ -203,6 +219,32 @@ module Ask
|
|
|
203
219
|
texts.join("\n")
|
|
204
220
|
end
|
|
205
221
|
|
|
222
|
+
def format_system_with_caching(system_msgs, chat_msgs)
|
|
223
|
+
texts = system_msgs.map { |m| m[:content] || m["content"] }.compact
|
|
224
|
+
return nil if texts.empty?
|
|
225
|
+
|
|
226
|
+
combined = texts.join("\n")
|
|
227
|
+
[{ type: "text", text: combined, cache_control: { type: "ephemeral" } }]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Wrap the last user message content for caching.
|
|
231
|
+
# Plain strings become [{ type: "text", text: content, cache_control: { type: "ephemeral" } }].
|
|
232
|
+
# Already-structured content blocks get cache_control appended.
|
|
233
|
+
def wrap_content_for_caching(content)
|
|
234
|
+
case content
|
|
235
|
+
when Array
|
|
236
|
+
content.map { |c|
|
|
237
|
+
if c.is_a?(Hash)
|
|
238
|
+
c.merge(cache_control: { type: "ephemeral" })
|
|
239
|
+
else
|
|
240
|
+
{ type: "text", text: c.to_s, cache_control: { type: "ephemeral" } }
|
|
241
|
+
end
|
|
242
|
+
}
|
|
243
|
+
else
|
|
244
|
+
[{ type: "text", text: content.to_s, cache_control: { type: "ephemeral" } }]
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
206
248
|
def parse_json(str)
|
|
207
249
|
JSON.parse(str)
|
|
208
250
|
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,6 +160,21 @@ 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
|
{ role: role.to_s, content: msg[:content] || msg["content"] }.tap do |fm|
|
|
@@ -156,6 +192,139 @@ module Ask
|
|
|
156
192
|
end.compact
|
|
157
193
|
end
|
|
158
194
|
|
|
195
|
+
# Use the OpenAI Responses API, which supports provider-executed tools
|
|
196
|
+
# like web_search, file_search, and code_interpreter.
|
|
197
|
+
def responses_chat(messages, model:, regular_tools:, provider_tools:,
|
|
198
|
+
temperature: nil, stream: nil, schema: nil, **params, &block)
|
|
199
|
+
payload = {
|
|
200
|
+
model: model,
|
|
201
|
+
input: format_responses_input(messages)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
all_tools = []
|
|
205
|
+
all_tools.concat(format_tools(regular_tools)) if regular_tools&.any?
|
|
206
|
+
all_tools.concat(format_responses_tools(provider_tools)) if provider_tools&.any?
|
|
207
|
+
payload[:tools] = all_tools if all_tools.any?
|
|
208
|
+
payload[:temperature] = temperature if temperature
|
|
209
|
+
payload.merge!(params)
|
|
210
|
+
|
|
211
|
+
if stream
|
|
212
|
+
responses_chat_stream(payload, model, provider_tools, &block)
|
|
213
|
+
else
|
|
214
|
+
responses_chat_nonstream(payload, model, provider_tools)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def responses_chat_nonstream(payload, model, provider_tools)
|
|
219
|
+
response = @http.post("responses") { |r| r.body = payload }
|
|
220
|
+
raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success?
|
|
221
|
+
|
|
222
|
+
body = response.body
|
|
223
|
+
output = body["output"] || []
|
|
224
|
+
|
|
225
|
+
# Extract text content and provider-executed tool results
|
|
226
|
+
text_parts = output.select { |o| o["type"] == "message" }
|
|
227
|
+
content = text_parts.flat_map { |m| (m["content"] || []) }
|
|
228
|
+
.select { |c| c["type"] == "output_text" }
|
|
229
|
+
.map { |c| c["text"] }
|
|
230
|
+
.join
|
|
231
|
+
|
|
232
|
+
# Extract provider-executed tool results
|
|
233
|
+
provider_results = extract_responses_provider_results(output, provider_tools)
|
|
234
|
+
|
|
235
|
+
# Extract regular tool calls
|
|
236
|
+
regular_calls = extract_responses_tool_calls(output)
|
|
237
|
+
|
|
238
|
+
usage = body["usage"] || {}
|
|
239
|
+
Ask::Message.new(
|
|
240
|
+
role: :assistant,
|
|
241
|
+
content: content,
|
|
242
|
+
tool_calls: regular_calls,
|
|
243
|
+
metadata: {
|
|
244
|
+
model: body["model"] || model,
|
|
245
|
+
finish_reason: body.dig("status"),
|
|
246
|
+
input_tokens: usage["input_tokens"],
|
|
247
|
+
output_tokens: usage["output_tokens"],
|
|
248
|
+
provider_results: provider_results,
|
|
249
|
+
raw: body
|
|
250
|
+
}
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def responses_chat_stream(payload, model, provider_tools, &block)
|
|
255
|
+
# Streaming with the Responses API — for now, fall back to non-streaming
|
|
256
|
+
# and return the full result. Full streaming support can be added later.
|
|
257
|
+
responses_chat_nonstream(payload, model, provider_tools)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def format_responses_input(messages)
|
|
261
|
+
messages.map do |msg|
|
|
262
|
+
role = msg[:role] || msg["role"] || "user"
|
|
263
|
+
content = msg[:content] || msg["content"] || ""
|
|
264
|
+
|
|
265
|
+
entry = { role: role.to_s }
|
|
266
|
+
entry[:content] = [{ type: "input_text", text: content.to_s }]
|
|
267
|
+
|
|
268
|
+
# Handle tool calls in assistant messages
|
|
269
|
+
if (tc = msg[:tool_calls] || msg["tool_calls"]) && tc.respond_to?(:any?) && tc.any?
|
|
270
|
+
calls = tc.is_a?(Hash) ? tc.values : tc
|
|
271
|
+
entry[:content] = calls.map { |t|
|
|
272
|
+
id = t.respond_to?(:id) ? t.id : (t[:id] || t["id"])
|
|
273
|
+
name = t.respond_to?(:name) ? t.name : (t[:name] || t["name"] || t.dig(:function, :name))
|
|
274
|
+
raw_args = t.respond_to?(:arguments) ? t.arguments : (t[:arguments] || t["arguments"] || t.dig(:function, :arguments))
|
|
275
|
+
args = raw_args.is_a?(String) ? raw_args : JSON.generate(raw_args)
|
|
276
|
+
{ type: "function_call", id: id, name: name, arguments: args, status: "completed" }
|
|
277
|
+
}
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Handle tool results
|
|
281
|
+
if (tid = msg[:tool_call_id] || msg["tool_call_id"])
|
|
282
|
+
entry[:content] = [{ type: "function_call_output", id: tid, output: content.to_s }]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
entry
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def extract_responses_provider_results(output, provider_tools)
|
|
290
|
+
results = {}
|
|
291
|
+
provider_tool_names = provider_tools.map(&:name)
|
|
292
|
+
|
|
293
|
+
output.each do |item|
|
|
294
|
+
case item["type"]
|
|
295
|
+
when "web_search_call"
|
|
296
|
+
result_item = output.find { |o| o["type"] == "web_search_result" && o["id"] == item["id"] }
|
|
297
|
+
if result_item
|
|
298
|
+
results[item["id"]] = {
|
|
299
|
+
provider_executed: true,
|
|
300
|
+
tool_name: "web_search",
|
|
301
|
+
message: result_item.to_s,
|
|
302
|
+
status: "success"
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
when "file_search_call"
|
|
306
|
+
result_item = output.find { |o| o["type"] == "file_search_result" && o["id"] == item["id"] }
|
|
307
|
+
if result_item
|
|
308
|
+
results[item["id"]] = {
|
|
309
|
+
provider_executed: true,
|
|
310
|
+
tool_name: "file_search",
|
|
311
|
+
message: result_item.to_s,
|
|
312
|
+
status: "success"
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
when "function_call"
|
|
316
|
+
# Regular tool call — handled elsewhere
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
results
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def extract_responses_tool_calls(output)
|
|
323
|
+
output.select { |o| o["type"] == "function_call" }.map do |fc|
|
|
324
|
+
{ id: fc["id"], type: "function", name: fc["name"], arguments: fc["arguments"] }
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
159
328
|
private
|
|
160
329
|
|
|
161
330
|
def extract_provider_keys(config)
|