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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5d0b181ef0d5094ee52f234c097cbce2fecad3dcad729c91e6923036483a47cf
4
- data.tar.gz: 8afd2d428cbe66ffa17bacdfe65e459acdef03ec8052ab764f7a742f7f453be4
3
+ metadata.gz: d6670dc1a64e5b158fa6cb25b8acca8df2daad65bd866d1b67594425d6407c30
4
+ data.tar.gz: 195892b5274ea261223f22d221f462c1229f719c3834812cd510918a21ce1f5f
5
5
  SHA512:
6
- metadata.gz: 4a4c3741d5d530624c8acde90a60cc01b9c0b5742f219f2a237d8d9de45291b1da98d7cb43d34678c7597865dac54574b0dd36c6f31eae4ec31eeb5f281de57c
7
- data.tar.gz: 2577b18aaa21cd12ce02788dbfd53d1a05196bace3e6443127deca449ba477ef9342fa0bd11de43fe3c411cab2784794efbb4707fa60d183a19cbdf11f30bcfc
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.8.7"
5
+ VERSION = "0.10.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
@@ -147,10 +163,11 @@ module Ask
147
163
 
148
164
  def format_message(msg)
149
165
  role = (msg[:role] || msg["role"]).to_s
150
- content = msg[:content] || msg["content"]
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
- { 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
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
@@ -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,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
- { 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|
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.8.7
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