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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d0b181ef0d5094ee52f234c097cbce2fecad3dcad729c91e6923036483a47cf
4
- data.tar.gz: 8afd2d428cbe66ffa17bacdfe65e459acdef03ec8052ab764f7a742f7f453be4
3
+ metadata.gz: ceebc12e911bceb83ec1528b66716605dbdb2e838d69051751ea9455f21cb018
4
+ data.tar.gz: f435785c44e2cc895c8dce3280ad1220dd19445dea044fd11e0070f11605b476
5
5
  SHA512:
6
- metadata.gz: 4a4c3741d5d530624c8acde90a60cc01b9c0b5742f219f2a237d8d9de45291b1da98d7cb43d34678c7597865dac54574b0dd36c6f31eae4ec31eeb5f281de57c
7
- data.tar.gz: 2577b18aaa21cd12ce02788dbfd53d1a05196bace3e6443127deca449ba477ef9342fa0bd11de43fe3c411cab2784794efbb4707fa60d183a19cbdf11f30bcfc
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.8.7"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
@@ -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
- system_content = format_system_content(system_msgs)
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
- payload[:system] = system_content if system_content
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
@@ -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
- payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
35
- stream ? chat_stream(payload, model, &block) : chat_nonstream(payload, model)
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
- Ask::Message.new(
98
- role: :assistant,
99
- content: msg["content"],
100
- tool_calls: parse_tool_calls(msg["tool_calls"]),
101
- metadata: {
102
- model: body["model"] || model,
103
- finish_reason: choice["finish_reason"],
104
- input_tokens: usage["prompt_tokens"],
105
- output_tokens: usage["completion_tokens"],
106
- raw: body
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)
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.8.7
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto