llm.rb 0.3.1 → 0.3.2
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/README.md +49 -13
- data/lib/llm/error.rb +6 -1
- data/lib/llm/file.rb +7 -0
- data/lib/llm/provider.rb +14 -10
- data/lib/llm/providers/gemini/files.rb +2 -2
- data/lib/llm/providers/gemini/images.rb +2 -2
- data/lib/llm/providers/gemini.rb +5 -4
- data/lib/llm/providers/openai/audio.rb +3 -3
- data/lib/llm/providers/openai/files.rb +2 -2
- data/lib/llm/providers/openai/format.rb +42 -11
- data/lib/llm/providers/openai/images.rb +3 -3
- data/lib/llm/providers/openai/responses.rb +14 -0
- data/lib/llm/version.rb +1 -1
- data/spec/openai/completion_spec.rb +2 -2
- data/spec/openai/files_spec.rb +3 -3
- 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: 3c55653b476d2fe6fe9457c89bc430c698668312ce89660a1d69abd8adf338eb
|
4
|
+
data.tar.gz: fe7d456bbb739eb091e82351839baef4c64d1d108a2c4cd7de3eb1b478982631
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8cd55bb28eb92fea745d8b11062b2442bf4b2de88ecfb0b7dc99cfefd293bd45113088dd13ccfe7e251d2e369459da700f15725bae51c3d31d4bf68e19953138
|
7
|
+
data.tar.gz: dab47021b94d00e51e7d0ca3f92e2966170b9fd8ce7138e0728d2be7fb83da03104ff93cd7c54b760acca62dd03adf16462069db9eb5c30185743c25259105aa
|
data/README.md
CHANGED
@@ -26,6 +26,7 @@ llm = LLM.openai("yourapikey")
|
|
26
26
|
llm = LLM.gemini("yourapikey")
|
27
27
|
llm = LLM.anthropic("yourapikey")
|
28
28
|
llm = LLM.ollama(nil)
|
29
|
+
llm = LLM.voyageai("yourapikey")
|
29
30
|
```
|
30
31
|
|
31
32
|
### Conversations
|
@@ -122,8 +123,6 @@ for more information on how to use the audio generation API:
|
|
122
123
|
```ruby
|
123
124
|
#!/usr/bin/env ruby
|
124
125
|
require "llm"
|
125
|
-
require "open-uri"
|
126
|
-
require "fileutils"
|
127
126
|
|
128
127
|
llm = LLM.openai(ENV["KEY"])
|
129
128
|
res = llm.audio.create_speech(input: "Hello world")
|
@@ -151,8 +150,6 @@ examples and documentation
|
|
151
150
|
```ruby
|
152
151
|
#!/usr/bin/env ruby
|
153
152
|
require "llm"
|
154
|
-
require "open-uri"
|
155
|
-
require "fileutils"
|
156
153
|
|
157
154
|
llm = LLM.openai(ENV["KEY"])
|
158
155
|
res = llm.audio.create_transcription(
|
@@ -180,9 +177,8 @@ examples and documentation
|
|
180
177
|
|
181
178
|
|
182
179
|
```ruby
|
180
|
+
#!/usr/bin/env ruby
|
183
181
|
require "llm"
|
184
|
-
require "open-uri"
|
185
|
-
require "fileutils"
|
186
182
|
|
187
183
|
llm = LLM.openai(ENV["KEY"])
|
188
184
|
res = llm.audio.create_translation(
|
@@ -320,6 +316,48 @@ bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n"
|
|
320
316
|
# contains information about the features, installation, and usage of OpenBSD.
|
321
317
|
```
|
322
318
|
|
319
|
+
### Prompts
|
320
|
+
|
321
|
+
#### Multimodal
|
322
|
+
|
323
|
+
Generally all providers accept text prompts but some providers can
|
324
|
+
also understand URLs, and various file types (eg images, audio, video,
|
325
|
+
etc). The llm.rb approach to multimodal prompts is to let you pass `URI`
|
326
|
+
objects to describe links, `LLM::File` / `LLM::Response::File` objects
|
327
|
+
to describe files, `String` objects to describe text blobs, or an array
|
328
|
+
of the forementioned objects to describe multiple objects in a single
|
329
|
+
prompt. Each object is a first class citizen that can be passed directly
|
330
|
+
to a prompt.
|
331
|
+
|
332
|
+
For more depth and examples on how to use the multimodal API, please see
|
333
|
+
the [provider-specific documentation](https://0x1eef.github.io/x/llm.rb/)
|
334
|
+
for more provider-specific examples – there can be subtle differences
|
335
|
+
between providers and even between APIs from the same provider that are
|
336
|
+
not covered in the README:
|
337
|
+
|
338
|
+
```ruby
|
339
|
+
#!/usr/bin/env ruby
|
340
|
+
require "llm"
|
341
|
+
|
342
|
+
llm = LLM.openai(ENV["KEY"])
|
343
|
+
bot = LLM::Chat.new(llm).lazy
|
344
|
+
|
345
|
+
bot.chat URI("https://example.com/path/to/image.png")
|
346
|
+
bot.chat "Describe the above image"
|
347
|
+
bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
|
348
|
+
|
349
|
+
file = bot.files.create(file: LLM::File("/documents/openbsd_is_awesome.pdf"))
|
350
|
+
bot.chat file
|
351
|
+
bot.chat "What is this file about?"
|
352
|
+
bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
|
353
|
+
|
354
|
+
bot.chat [LLM::File("/images/puffy.png"), "What is this image about?"]
|
355
|
+
bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
|
356
|
+
|
357
|
+
bot.chat [LLM::File("/images/beastie.png"), "What is this image about?"]
|
358
|
+
bot.messages.select(&:assistant?).each { print "[#{_1.role}] ", _1.content, "\n" }
|
359
|
+
```
|
360
|
+
|
323
361
|
### Embeddings
|
324
362
|
|
325
363
|
#### Text
|
@@ -394,11 +432,11 @@ llm.rb can be installed via rubygems.org:
|
|
394
432
|
|
395
433
|
## Philosophy
|
396
434
|
|
397
|
-
llm.rb was built for developers who believe that simplicity
|
398
|
-
It provides a clean, dependency-free interface to
|
399
|
-
treating Ruby itself as the primary platform –
|
400
|
-
specific framework or library. There is no hidden
|
401
|
-
metaprogramming.
|
435
|
+
llm.rb was built for developers who believe that simplicity can be challenging
|
436
|
+
but it is always worth it. It provides a clean, dependency-free interface to
|
437
|
+
Large Language Models, treating Ruby itself as the primary platform –
|
438
|
+
not Rails or any other specific framework or library. There is no hidden
|
439
|
+
magic or complex metaprogramming.
|
402
440
|
|
403
441
|
Every part of llm.rb is designed to be explicit, composable, memory-safe,
|
404
442
|
and production-ready without compromise. No unnecessary abstractions,
|
@@ -406,8 +444,6 @@ no global configuration, and no dependencies that aren't part of standard
|
|
406
444
|
Ruby. It has been inspired in part by other languages such as Python, but
|
407
445
|
it is not a port of any other library.
|
408
446
|
|
409
|
-
Good software doesn’t need marketing. It just needs to work. :)
|
410
|
-
|
411
447
|
## License
|
412
448
|
|
413
449
|
[BSD Zero Clause](https://choosealicense.com/licenses/0bsd/)
|
data/lib/llm/error.rb
CHANGED
@@ -4,8 +4,9 @@ module LLM
|
|
4
4
|
##
|
5
5
|
# The superclass of all LLM errors
|
6
6
|
class Error < RuntimeError
|
7
|
-
def initialize
|
7
|
+
def initialize(...)
|
8
8
|
block_given? ? yield(self) : nil
|
9
|
+
super
|
9
10
|
end
|
10
11
|
|
11
12
|
##
|
@@ -17,6 +18,10 @@ module LLM
|
|
17
18
|
attr_accessor :response
|
18
19
|
end
|
19
20
|
|
21
|
+
##
|
22
|
+
# When a prompt is given an object that's not understood
|
23
|
+
PromptError = Class.new(Error)
|
24
|
+
|
20
25
|
##
|
21
26
|
# HTTPUnauthorized
|
22
27
|
Unauthorized = Class.new(ResponseError)
|
data/lib/llm/file.rb
CHANGED
@@ -42,6 +42,13 @@ class LLM::File
|
|
42
42
|
[File.binread(path)].pack("m0")
|
43
43
|
end
|
44
44
|
|
45
|
+
##
|
46
|
+
# @return [String]
|
47
|
+
# Returns the file contents in base64 URL format
|
48
|
+
def to_data_uri
|
49
|
+
"data:#{mime_type};base64,#{to_b64}"
|
50
|
+
end
|
51
|
+
|
45
52
|
##
|
46
53
|
# @return [File]
|
47
54
|
# Yields an IO object suitable to be streamed
|
data/lib/llm/provider.rb
CHANGED
@@ -4,16 +4,7 @@
|
|
4
4
|
# The Provider class represents an abstract class for
|
5
5
|
# LLM (Language Model) providers.
|
6
6
|
#
|
7
|
-
# @note
|
8
|
-
# This class is not meant to be instantiated directly.
|
9
|
-
# Instead, use one of the subclasses that implement
|
10
|
-
# the methods defined here.
|
11
|
-
#
|
12
7
|
# @abstract
|
13
|
-
# @see LLM::Provider::OpenAI
|
14
|
-
# @see LLM::Provider::Anthropic
|
15
|
-
# @see LLM::Provider::Gemini
|
16
|
-
# @see LLM::Provider::Ollama
|
17
8
|
class LLM::Provider
|
18
9
|
require "net/http"
|
19
10
|
|
@@ -78,7 +69,7 @@ class LLM::Provider
|
|
78
69
|
# @raise [NotImplementedError]
|
79
70
|
# When the method is not implemented by a subclass
|
80
71
|
# @return [LLM::Response::Completion]
|
81
|
-
def complete(prompt, role = :user, model
|
72
|
+
def complete(prompt, role = :user, model: nil, **params)
|
82
73
|
raise NotImplementedError
|
83
74
|
end
|
84
75
|
|
@@ -237,6 +228,8 @@ class LLM::Provider
|
|
237
228
|
# When the rate limit is exceeded
|
238
229
|
# @raise [LLM::Error::ResponseError]
|
239
230
|
# When any other unsuccessful status code is returned
|
231
|
+
# @raise [LLM::Error::PromptError]
|
232
|
+
# When given an object a provider does not understand
|
240
233
|
# @raise [SystemCallError]
|
241
234
|
# When there is a network error at the operating system level
|
242
235
|
def request(http, req, &b)
|
@@ -247,6 +240,17 @@ class LLM::Provider
|
|
247
240
|
end
|
248
241
|
end
|
249
242
|
|
243
|
+
##
|
244
|
+
# @param [Net::HTTPRequest] req
|
245
|
+
# The request to set the body stream for
|
246
|
+
# @param [IO] io
|
247
|
+
# The IO object to set as the body stream
|
248
|
+
# @return [void]
|
249
|
+
def set_body_stream(req, io)
|
250
|
+
req.body_stream = io
|
251
|
+
req["transfer-encoding"] = "chunked" unless req["content-length"]
|
252
|
+
end
|
253
|
+
|
250
254
|
##
|
251
255
|
# @param [String] provider
|
252
256
|
# The name of the provider
|
@@ -83,7 +83,7 @@ class LLM::Gemini
|
|
83
83
|
req["X-Goog-Upload-Offset"] = 0
|
84
84
|
req["X-Goog-Upload-Command"] = "upload, finalize"
|
85
85
|
file.with_io do |io|
|
86
|
-
req
|
86
|
+
set_body_stream(req, io)
|
87
87
|
res = request(http, req)
|
88
88
|
LLM::Response::File.new(res)
|
89
89
|
end
|
@@ -155,7 +155,7 @@ class LLM::Gemini
|
|
155
155
|
@provider.instance_variable_get(:@secret)
|
156
156
|
end
|
157
157
|
|
158
|
-
[:headers, :request].each do |m|
|
158
|
+
[:headers, :request, :set_body_stream].each do |m|
|
159
159
|
define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
|
160
160
|
end
|
161
161
|
end
|
@@ -70,7 +70,7 @@ class LLM::Gemini
|
|
70
70
|
contents: [{parts: [{text: prompt}, format_content(image)]}],
|
71
71
|
generationConfig: {responseModalities: ["TEXT", "IMAGE"]}
|
72
72
|
}.merge!(params)).b
|
73
|
-
req
|
73
|
+
set_body_stream(req, StringIO.new(body))
|
74
74
|
res = request(http, req)
|
75
75
|
LLM::Response::Image.new(res).extend(response_parser)
|
76
76
|
end
|
@@ -92,7 +92,7 @@ class LLM::Gemini
|
|
92
92
|
@provider.instance_variable_get(:@http)
|
93
93
|
end
|
94
94
|
|
95
|
-
[:response_parser, :headers, :request].each do |m|
|
95
|
+
[:response_parser, :headers, :request, :set_body_stream].each do |m|
|
96
96
|
define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
|
97
97
|
end
|
98
98
|
end
|
data/lib/llm/providers/gemini.rb
CHANGED
@@ -70,11 +70,12 @@ module LLM
|
|
70
70
|
# @raise (see LLM::Provider#request)
|
71
71
|
# @return (see LLM::Provider#complete)
|
72
72
|
def complete(prompt, role = :user, model: "gemini-1.5-flash", **params)
|
73
|
-
path
|
74
|
-
req
|
73
|
+
path = ["/v1beta/models/#{model}", "generateContent?key=#{@secret}"].join(":")
|
74
|
+
req = Net::HTTP::Post.new(path, headers)
|
75
75
|
messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
|
76
|
-
|
77
|
-
|
76
|
+
body = JSON.dump({contents: format(messages)}).b
|
77
|
+
set_body_stream(req, StringIO.new(body))
|
78
|
+
res = request(@http, req)
|
78
79
|
Response::Completion.new(res).extend(response_parser)
|
79
80
|
end
|
80
81
|
|
@@ -57,7 +57,7 @@ class LLM::OpenAI
|
|
57
57
|
multi = LLM::Multipart.new(params.merge!(file:, model:))
|
58
58
|
req = Net::HTTP::Post.new("/v1/audio/transcriptions", headers)
|
59
59
|
req["content-type"] = multi.content_type
|
60
|
-
req
|
60
|
+
set_body_stream(req, multi.body)
|
61
61
|
res = request(http, req)
|
62
62
|
LLM::Response::AudioTranscription.new(res).tap { _1.text = _1.body["text"] }
|
63
63
|
end
|
@@ -79,7 +79,7 @@ class LLM::OpenAI
|
|
79
79
|
multi = LLM::Multipart.new(params.merge!(file:, model:))
|
80
80
|
req = Net::HTTP::Post.new("/v1/audio/translations", headers)
|
81
81
|
req["content-type"] = multi.content_type
|
82
|
-
req
|
82
|
+
set_body_stream(req, multi.body)
|
83
83
|
res = request(http, req)
|
84
84
|
LLM::Response::AudioTranslation.new(res).tap { _1.text = _1.body["text"] }
|
85
85
|
end
|
@@ -90,7 +90,7 @@ class LLM::OpenAI
|
|
90
90
|
@provider.instance_variable_get(:@http)
|
91
91
|
end
|
92
92
|
|
93
|
-
[:headers, :request].each do |m|
|
93
|
+
[:headers, :request, :set_body_stream].each do |m|
|
94
94
|
define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
|
95
95
|
end
|
96
96
|
end
|
@@ -73,7 +73,7 @@ class LLM::OpenAI
|
|
73
73
|
multi = LLM::Multipart.new(params.merge!(file:, purpose:))
|
74
74
|
req = Net::HTTP::Post.new("/v1/files", headers)
|
75
75
|
req["content-type"] = multi.content_type
|
76
|
-
req
|
76
|
+
set_body_stream(req, multi.body)
|
77
77
|
res = request(http, req)
|
78
78
|
LLM::Response::File.new(res)
|
79
79
|
end
|
@@ -141,7 +141,7 @@ class LLM::OpenAI
|
|
141
141
|
@provider.instance_variable_get(:@http)
|
142
142
|
end
|
143
143
|
|
144
|
-
[:headers, :request].each do |m|
|
144
|
+
[:headers, :request, :set_body_stream].each do |m|
|
145
145
|
define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
|
146
146
|
end
|
147
147
|
end
|
@@ -29,19 +29,50 @@ class LLM::OpenAI
|
|
29
29
|
# The formatted content
|
30
30
|
def format_content(content, mode)
|
31
31
|
if mode == :complete
|
32
|
-
|
33
|
-
when Array then content.flat_map { format_content(_1, mode) }
|
34
|
-
when URI then [{type: :image_url, image_url: {url: content.to_s}}]
|
35
|
-
when LLM::Response::File then [{type: :file, file: {file_id: content.id}}]
|
36
|
-
else [{type: :text, text: content.to_s}]
|
37
|
-
end
|
32
|
+
format_complete(content)
|
38
33
|
elsif mode == :response
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
34
|
+
format_response(content)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def format_complete(content)
|
39
|
+
case content
|
40
|
+
when Array
|
41
|
+
content.flat_map { format_complete(_1) }
|
42
|
+
when URI
|
43
|
+
[{type: :image_url, image_url: {url: content.to_s}}]
|
44
|
+
when LLM::File
|
45
|
+
[{type: :image_url, image_url: {url: content.to_data_uri}}]
|
46
|
+
when LLM::Response::File
|
47
|
+
[{type: :file, file: {file_id: content.id}}]
|
48
|
+
when String
|
49
|
+
[{type: :text, text: content.to_s}]
|
50
|
+
when LLM::Message
|
51
|
+
format_complete(content.content)
|
52
|
+
else
|
53
|
+
raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
|
54
|
+
"is not supported by the OpenAI chat completions API"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def format_response(content)
|
59
|
+
case content
|
60
|
+
when Array
|
61
|
+
content.flat_map { format_response(_1) }
|
62
|
+
when LLM::Response::File
|
63
|
+
file = LLM::File(content.filename)
|
64
|
+
if file.image?
|
65
|
+
[{type: :input_image, file_id: content.id}]
|
66
|
+
else
|
67
|
+
[{type: :input_file, file_id: content.id}]
|
44
68
|
end
|
69
|
+
when String
|
70
|
+
[{type: :input_text, text: content.to_s}]
|
71
|
+
when LLM::Message
|
72
|
+
format_response(content.content)
|
73
|
+
else
|
74
|
+
raise LLM::Error::PromptError, "The given object (an instance of #{content.class}) " \
|
75
|
+
"is not supported by the OpenAI responses API"
|
45
76
|
end
|
46
77
|
end
|
47
78
|
end
|
@@ -69,7 +69,7 @@ class LLM::OpenAI
|
|
69
69
|
multi = LLM::Multipart.new(params.merge!(image:, model:))
|
70
70
|
req = Net::HTTP::Post.new("/v1/images/variations", headers)
|
71
71
|
req["content-type"] = multi.content_type
|
72
|
-
req
|
72
|
+
set_body_stream(req, multi.body)
|
73
73
|
res = request(http, req)
|
74
74
|
LLM::Response::Image.new(res).extend(response_parser)
|
75
75
|
end
|
@@ -91,7 +91,7 @@ class LLM::OpenAI
|
|
91
91
|
multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:))
|
92
92
|
req = Net::HTTP::Post.new("/v1/images/edits", headers)
|
93
93
|
req["content-type"] = multi.content_type
|
94
|
-
req
|
94
|
+
set_body_stream(req, multi.body)
|
95
95
|
res = request(http, req)
|
96
96
|
LLM::Response::Image.new(res).extend(response_parser)
|
97
97
|
end
|
@@ -102,7 +102,7 @@ class LLM::OpenAI
|
|
102
102
|
@provider.instance_variable_get(:@http)
|
103
103
|
end
|
104
104
|
|
105
|
-
[:response_parser, :headers, :request].each do |m|
|
105
|
+
[:response_parser, :headers, :request, :set_body_stream].each do |m|
|
106
106
|
define_method(m) { |*args, &b| @provider.send(m, *args, &b) }
|
107
107
|
end
|
108
108
|
end
|
@@ -16,6 +16,20 @@ class LLM::OpenAI
|
|
16
16
|
# res1 = llm.responses.create "Your task is to help me with math", :developer
|
17
17
|
# res2 = llm.responses.create "5 + 5 = ?", :user, previous_response_id: res1.id
|
18
18
|
# [res1,res2].each { llm.responses.delete(_1) }
|
19
|
+
# @example
|
20
|
+
# #!/usr/bin/env ruby
|
21
|
+
# require "llm"
|
22
|
+
#
|
23
|
+
# llm = LLM.openai(ENV["KEY"])
|
24
|
+
# file = llm.files.create file: LLM::File("/images/hat.png")
|
25
|
+
# res = llm.responses.create ["Describe the image", file]
|
26
|
+
# @example
|
27
|
+
# #!/usr/bin/env ruby
|
28
|
+
# require "llm"
|
29
|
+
#
|
30
|
+
# llm = LLM.openai(ENV["KEY"])
|
31
|
+
# file = llm.files.create file: LLM::File("/documents/freebsd.pdf")
|
32
|
+
# res = llm.responses.create ["Describe the document, file]
|
19
33
|
class Responses
|
20
34
|
include Format
|
21
35
|
|
data/lib/llm/version.rb
CHANGED
@@ -48,7 +48,7 @@ RSpec.describe "LLM::OpenAI: completions" do
|
|
48
48
|
openai.complete "What is your name? What age are you?", :user, messages: [
|
49
49
|
{role: "system", content: "Answer all of my questions"},
|
50
50
|
{role: "system", content: "Answer in the format: My name is <name> and I am <age> years old"},
|
51
|
-
{role: "system", content: "Your name is Pablo and you are 25 years old"}
|
51
|
+
{role: "system", content: "Your name is Pablo and you are 25 years old"}
|
52
52
|
]
|
53
53
|
end
|
54
54
|
|
@@ -81,7 +81,7 @@ RSpec.describe "LLM::OpenAI: completions" do
|
|
81
81
|
|
82
82
|
context "when given an unauthorized response",
|
83
83
|
vcr: {cassette_name: "openai/completions/unauthorized_response"} do
|
84
|
-
subject(:response) { openai.complete(LLM::Message.new("Hello!"
|
84
|
+
subject(:response) { openai.complete(LLM::Message.new(:user, "Hello!")) }
|
85
85
|
let(:token) { "BADTOKEN" }
|
86
86
|
|
87
87
|
it "raises an error" do
|
data/spec/openai/files_spec.rb
CHANGED
@@ -94,16 +94,16 @@ RSpec.describe "LLM::OpenAI::Files" do
|
|
94
94
|
provider.files.create(file: LLM::File("spec/fixtures/documents/haiku2.txt"))
|
95
95
|
]
|
96
96
|
end
|
97
|
-
subject(:
|
97
|
+
subject(:filelist) { provider.files.all }
|
98
98
|
|
99
99
|
it "is successful" do
|
100
|
-
expect(
|
100
|
+
expect(filelist).to be_instance_of(LLM::Response::FileList)
|
101
101
|
ensure
|
102
102
|
files.each { |file| provider.files.delete(file:) }
|
103
103
|
end
|
104
104
|
|
105
105
|
it "returns an array of file objects" do
|
106
|
-
expect(
|
106
|
+
expect(filelist.files[0..1]).to match_array(
|
107
107
|
[
|
108
108
|
have_attributes(
|
109
109
|
id: instance_of(String),
|