ask-ai 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/aa +5 -0
- data/config/config.yml +1 -0
- data/files/context.jsonl +0 -0
- data/files/context_file.txt +0 -0
- data/lib/context.rb +79 -0
- data/lib/handle_args.rb +61 -0
- data/lib/help.rb +48 -0
- data/lib/main.rb +168 -0
- data/lib/prompt.rb +102 -0
- metadata +66 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: f66251ade1f9986ae077dc13cb177f5f651b5c3638115fb7b5a5ef54d9510972
|
4
|
+
data.tar.gz: ec6673e17d5cdd7e6e202ad33d87f30b4e132afdbde347c67917aee6a7483291
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c4c71fbb474ebbea5684fc849007f04af964c105ae9a8827790da443a4c45168b59ba5c49b90644bdd8d3f46ca03d5ad31535bf244eace5b00d017f43aae36e1
|
7
|
+
data.tar.gz: 69dea278d20a873ba3a91f93dd41e97d148e754cf654ac32c6ca3ad168bc57a66bd3816db23aa38362bf81a18be3453d08e2201d1ec134ed1230b5a792f70000
|
data/bin/aa
ADDED
data/config/config.yml
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
|
data/files/context.jsonl
ADDED
File without changes
|
File without changes
|
data/lib/context.rb
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
class Context
|
2
|
+
def self.load_context()
|
3
|
+
if File.exist?(CONTEXT_PATH)
|
4
|
+
conversation = File.readlines(CONTEXT_PATH).map { |line| JSON.parse(line) }
|
5
|
+
else
|
6
|
+
conversation = []
|
7
|
+
end
|
8
|
+
|
9
|
+
if conversation.length > 0
|
10
|
+
context_as_string = "This is our previous conversation:\n"
|
11
|
+
else
|
12
|
+
context_as_string = ""
|
13
|
+
end
|
14
|
+
|
15
|
+
if conversation.length > 0
|
16
|
+
conversation.each_with_index do |v, i|
|
17
|
+
context_as_string += "My #{i + 1} input was: #{v['input']}\nYour #{i + 1} response was: #{v['response']}\n"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
return context_as_string
|
22
|
+
end
|
23
|
+
|
24
|
+
## Here we save the context to a file.
|
25
|
+
## Max 10 previous Q / A to save tokens.
|
26
|
+
def self.save_context(context)
|
27
|
+
tmp_arr = []
|
28
|
+
unless File.exist?(CONTEXT_PATH)
|
29
|
+
File.open(CONTEXT_PATH, "w") {}
|
30
|
+
end
|
31
|
+
File.readlines(CONTEXT_PATH).map { |line| tmp_arr.push(JSON.parse(line)) }
|
32
|
+
if tmp_arr.length > 9
|
33
|
+
tmp_arr.shift()
|
34
|
+
end
|
35
|
+
File.truncate(CONTEXT_PATH, 0)
|
36
|
+
tmp_arr.each { |line| File.open(CONTEXT_PATH, "a") { |file| file.write("#{line.to_json}\n") } }
|
37
|
+
File.open(CONTEXT_PATH, "a") { |file| file.write("#{context.to_json}\n") }
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.delete_context()
|
41
|
+
puts "Deleting previous context."
|
42
|
+
File.truncate(CONTEXT_PATH, 0)
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.save_context_file(file_path)
|
46
|
+
unless file_path.nil?
|
47
|
+
file_in = File.open(file_path, 'r')
|
48
|
+
file_out = File.open(CONTEXT_FILE_PATH, 'w')
|
49
|
+
char_count = 0
|
50
|
+
file_in.each do |line|
|
51
|
+
char_count += line.length
|
52
|
+
file_out.write(line)
|
53
|
+
end
|
54
|
+
|
55
|
+
if char_count > 10000
|
56
|
+
puts "Warning: The file you are trying to feed to the API is #{char_count} characters long. This consumes a lot of tokens."
|
57
|
+
end
|
58
|
+
else
|
59
|
+
puts "No file path given."
|
60
|
+
end
|
61
|
+
rescue Errno::ENOENT
|
62
|
+
puts "No file at '#{file_path}' found."
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.load_context_file()
|
66
|
+
file = File.open(CONTEXT_FILE_PATH, 'r')
|
67
|
+
file_as_string = ""
|
68
|
+
file.each do |line|
|
69
|
+
file_as_string += line
|
70
|
+
end
|
71
|
+
|
72
|
+
return file_as_string
|
73
|
+
rescue Errno::ENOENT
|
74
|
+
puts "No file at '#{CONTEXT_FILE_PATH}' found."
|
75
|
+
puts "Load a file with 'aa -lf <file_path>'"
|
76
|
+
return ""
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
data/lib/handle_args.rb
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
class HandleArgs
|
2
|
+
def self.permitted_options()
|
3
|
+
## TODO: Add more options.
|
4
|
+
## -t --translate
|
5
|
+
## -temp --temperature (need to handle value after input)
|
6
|
+
|
7
|
+
[
|
8
|
+
'-h',
|
9
|
+
'--help',
|
10
|
+
'-v',
|
11
|
+
'--version',
|
12
|
+
'-f',
|
13
|
+
'--file',
|
14
|
+
'-lf',
|
15
|
+
'--loadfile',
|
16
|
+
'-d',
|
17
|
+
'--delete',
|
18
|
+
'-c',
|
19
|
+
'--conversation',
|
20
|
+
'--finetune',
|
21
|
+
'-w', '--whisper',
|
22
|
+
'-t', '--translate',
|
23
|
+
'-i', '--interactive',
|
24
|
+
'--key',
|
25
|
+
]
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.handle_args()
|
29
|
+
p_args = HandleArgs.permitted_options()
|
30
|
+
## This is the hash that will be returned.
|
31
|
+
args_hash = {}
|
32
|
+
|
33
|
+
## This handles args then called as ruby script.
|
34
|
+
ARGV.each_with_index do |arg, i|
|
35
|
+
#puts "Arg: #{arg}"
|
36
|
+
|
37
|
+
if arg.start_with?('-')
|
38
|
+
## This is an option.
|
39
|
+
if p_args.include?(arg)
|
40
|
+
## This is a permitted / available option.
|
41
|
+
#puts "Option: #{arg}"
|
42
|
+
args_hash["option_#{i}"] = arg
|
43
|
+
else
|
44
|
+
## This is an unknown option.
|
45
|
+
## TODO: Handle unknown option. display help? discard?
|
46
|
+
puts "Unknown option: #{arg}"
|
47
|
+
args_hash["option_#{i}"] = arg
|
48
|
+
end
|
49
|
+
else
|
50
|
+
## This is input.
|
51
|
+
## This if statement append all 'input' args to one string.
|
52
|
+
if args_hash["input"].nil?
|
53
|
+
args_hash["input"] = arg
|
54
|
+
else
|
55
|
+
args_hash["input"] = "#{args_hash['input']} #{arg}"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
return args_hash
|
60
|
+
end
|
61
|
+
end
|
data/lib/help.rb
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
|
3
|
+
class Help
|
4
|
+
def self.display_help()
|
5
|
+
puts "Usage: aa [options] [input]"
|
6
|
+
puts " -lf, --loadfile <path>: Load file into context"
|
7
|
+
puts " -f, --file: Read from context file"
|
8
|
+
puts " -c, --conversation: Append to conversation (max 10 Questions / Answers pairs saved)"
|
9
|
+
puts " -d, --delete: Delete conversation"
|
10
|
+
puts " -i, --interactive: Interactive mode, always a conversation. Clear context with 'clear' (exit with 'exit' or 'quit')"
|
11
|
+
puts " -w, --whisper <path>: Transcribe audio file"
|
12
|
+
puts " -t, --translate <path>: Translate audio file"
|
13
|
+
puts "\n Options:"
|
14
|
+
puts " --config: Edit config file"
|
15
|
+
puts " -v, --version: Display version"
|
16
|
+
puts " -h, --help: Display this help message"
|
17
|
+
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.display_api_key()
|
21
|
+
puts "You need to set your API key in the file: ./config/config.yml"
|
22
|
+
puts "Create the file if it doesn't exist."
|
23
|
+
puts "Add the following line to the file:"
|
24
|
+
puts " OPENAI_API_KEY: <your API key>"
|
25
|
+
puts "You can get your API key from: https://openai.com/"
|
26
|
+
end
|
27
|
+
|
28
|
+
## This don't work yet. Need to rework the way we handle args.
|
29
|
+
def self.display_help_file()
|
30
|
+
## TODO: How to work with files
|
31
|
+
end
|
32
|
+
|
33
|
+
## This don't work yet. Need to rework the way we handle args.
|
34
|
+
def self.display_help_conversation()
|
35
|
+
## TODO: How to work with conversation
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.display_version()
|
39
|
+
spec = Gem::Specification::load("aa.gemspec")
|
40
|
+
puts "Version: #{spec.version}"
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.display_usage()
|
44
|
+
puts "Usage: ./main.rb [options] [input]"
|
45
|
+
|
46
|
+
puts "There are two types of options, flags and arguments."
|
47
|
+
end
|
48
|
+
end
|
data/lib/main.rb
ADDED
@@ -0,0 +1,168 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'fileutils'
|
4
|
+
require 'yaml'
|
5
|
+
require 'readline'
|
6
|
+
require 'io/console'
|
7
|
+
[
|
8
|
+
'./prompt.rb',
|
9
|
+
'./handle_args.rb',
|
10
|
+
'./help.rb',
|
11
|
+
'./context.rb'
|
12
|
+
].each { |f| require_relative f }
|
13
|
+
|
14
|
+
CONTEXT_PATH = File.expand_path("./../files/context.jsonl", __dir__)
|
15
|
+
FILE_PATH = File.expand_path("./../files/", __dir__)
|
16
|
+
CONFIG_PATH = File.expand_path("./../config/config.yml", __dir__)
|
17
|
+
CONTEXT_FILE_PATH = File.expand_path("./../files/context_file.txt", __dir__)
|
18
|
+
class Main
|
19
|
+
|
20
|
+
def self.run()
|
21
|
+
config = load_env()
|
22
|
+
|
23
|
+
## This does not work. Need to fix this.
|
24
|
+
if config.nil? || config['OPENAI_API_KEY'].nil?
|
25
|
+
puts "No API key found."
|
26
|
+
Help.display_api_key()
|
27
|
+
puts 'If you want to set your API key now, type (y)es and press enter.'
|
28
|
+
#puts "If you have an API key you can set it here."
|
29
|
+
#puts "Enter API key: (or press enter to exit)"
|
30
|
+
|
31
|
+
while input = Readline.readline("> ", true) do
|
32
|
+
if input.empty?
|
33
|
+
puts "Exiting."
|
34
|
+
exit
|
35
|
+
elsif input == "y" || input == "yes" || input == "Y" || input == "Yes"
|
36
|
+
set_key()
|
37
|
+
exit
|
38
|
+
else
|
39
|
+
puts 'Invalid input (y)es or press enter to exit.'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
context = Context.load_context()
|
45
|
+
options_and_input = HandleArgs.handle_args()
|
46
|
+
options = options_and_input.select { |k, v| k.start_with?("option_") }
|
47
|
+
input = options_and_input["input"]
|
48
|
+
|
49
|
+
halt_options = ["-h", "--help", "-v", "--version", "--key"]
|
50
|
+
|
51
|
+
## Hack... Need to fix this.
|
52
|
+
if options.empty?
|
53
|
+
options = { "option_0" => "simple" }
|
54
|
+
end
|
55
|
+
|
56
|
+
options.each do |k, v|
|
57
|
+
if halt_options.include?(v)
|
58
|
+
## Options that halt the program.
|
59
|
+
case v
|
60
|
+
when "-h", "--help"
|
61
|
+
Help.display_help()
|
62
|
+
exit
|
63
|
+
when "-v", "--version"
|
64
|
+
Help.display_version()
|
65
|
+
exit
|
66
|
+
|
67
|
+
when "--key"
|
68
|
+
set_key(api_key: nil)
|
69
|
+
else
|
70
|
+
Help.display_help()
|
71
|
+
exit
|
72
|
+
end
|
73
|
+
else
|
74
|
+
## Options that don't halt the program.
|
75
|
+
case v
|
76
|
+
when "-f", "--file"
|
77
|
+
file_as_string = Context.load_context_file()
|
78
|
+
if file_as_string.empty?
|
79
|
+
exit
|
80
|
+
end
|
81
|
+
Prompt.stream_prompt(input, file_as_string)
|
82
|
+
when "-lf", "--loadfile"
|
83
|
+
puts "Loading File #{input}"
|
84
|
+
Context.save_context_file(input)
|
85
|
+
when "-d", "--delete"
|
86
|
+
if input.nil?
|
87
|
+
Context.delete_context()
|
88
|
+
else
|
89
|
+
Context.delete_context()
|
90
|
+
Context.save_context(Prompt.stream_prompt(input, context))
|
91
|
+
end
|
92
|
+
when "-c", "--conversation"
|
93
|
+
puts input
|
94
|
+
Context.save_context(Prompt.stream_prompt(input, context))
|
95
|
+
when "-w", "--whisper"
|
96
|
+
puts Prompt.whisper_transcribe(input)
|
97
|
+
when "-t", "--translate"
|
98
|
+
puts Prompt.whisper_translate(input)
|
99
|
+
when "-i", "--interactive"
|
100
|
+
puts "Interactive mode..."
|
101
|
+
puts "Type 'exit' or 'quit' to exit."
|
102
|
+
puts "Type 'clear' to clear context."
|
103
|
+
|
104
|
+
while input = Readline.readline("\n> ", true) do
|
105
|
+
if (input == "exit" || input == "quit")
|
106
|
+
break
|
107
|
+
end
|
108
|
+
if input == "clear"
|
109
|
+
puts "Clearing context..."
|
110
|
+
Context.delete_context()
|
111
|
+
next
|
112
|
+
end
|
113
|
+
options_and_input = HandleArgs.handle_args()
|
114
|
+
context = Context.load_context()
|
115
|
+
puts "\n"
|
116
|
+
Context.save_context(Prompt.stream_prompt(input, context))
|
117
|
+
puts "\n"
|
118
|
+
end
|
119
|
+
puts "Exiting..."
|
120
|
+
#Context.delete_context()
|
121
|
+
when "simple"
|
122
|
+
if !input.nil?
|
123
|
+
Prompt.stream_prompt(input)
|
124
|
+
else
|
125
|
+
puts "No input given."
|
126
|
+
Help.display_help()
|
127
|
+
end
|
128
|
+
else
|
129
|
+
Help.display_help()
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
private
|
136
|
+
|
137
|
+
def self.set_key(api_key: nil)
|
138
|
+
if api_key.nil?
|
139
|
+
puts "Setting API key..."
|
140
|
+
puts "Enter API key: (or press enter to exit)"
|
141
|
+
|
142
|
+
while input = Readline.readline("> ", true) do
|
143
|
+
if input.empty?
|
144
|
+
puts "Exiting."
|
145
|
+
exit
|
146
|
+
else
|
147
|
+
api_key = input.strip
|
148
|
+
break
|
149
|
+
end
|
150
|
+
end
|
151
|
+
puts "Saving API key..."
|
152
|
+
end
|
153
|
+
|
154
|
+
FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
|
155
|
+
File.open(CONFIG_PATH, "w") do |f|
|
156
|
+
f.write(YAML.dump({ "OPENAI_API_KEY" => api_key }))
|
157
|
+
end
|
158
|
+
puts "API key saved."
|
159
|
+
end
|
160
|
+
|
161
|
+
def self.load_env()
|
162
|
+
YAML.load(File.read(CONFIG_PATH))
|
163
|
+
|
164
|
+
rescue Errno::ENOENT
|
165
|
+
puts "No config.yml found."
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
data/lib/prompt.rb
ADDED
@@ -0,0 +1,102 @@
|
|
1
|
+
require "openai"
|
2
|
+
|
3
|
+
class Prompt
|
4
|
+
|
5
|
+
## Streams the response, VERY NICE
|
6
|
+
def self.stream_prompt(input, conversation = '', temp = 0.7)
|
7
|
+
if conversation.length == 0
|
8
|
+
conversation += input
|
9
|
+
else
|
10
|
+
conversation += "\n My question: #{input}"
|
11
|
+
end
|
12
|
+
|
13
|
+
response = ''
|
14
|
+
client.chat(
|
15
|
+
parameters: {
|
16
|
+
model: "gpt-3.5-turbo",
|
17
|
+
messages: [{ role: "user", content: conversation}],
|
18
|
+
temperature: temp, ## Should be a parameter
|
19
|
+
stream: proc do |chunk, _bytesize|
|
20
|
+
response += chunk.dig("choices", 0, "delta", "content") unless chunk.dig("choices", 0, "delta", "content").nil?
|
21
|
+
print chunk.dig("choices", 0, "delta", "content")
|
22
|
+
end
|
23
|
+
}
|
24
|
+
)
|
25
|
+
context = {
|
26
|
+
"input" => input,
|
27
|
+
"response" => response,
|
28
|
+
}
|
29
|
+
return context
|
30
|
+
end
|
31
|
+
|
32
|
+
## Not implemented only scaffolding
|
33
|
+
def self.file_finetune()
|
34
|
+
return
|
35
|
+
client.files.upload(parameters: { file: "./test.json", purpose: "fine-tune" })
|
36
|
+
client.files.lisr
|
37
|
+
client.files.retrieve(id: "file-123")
|
38
|
+
client.files.content(id: "file-123")
|
39
|
+
client.files.delete(id: "file-123")
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.whisper_translate(file_path)
|
43
|
+
if (file_path.nil? || !file_path.end_with?(*['.mp3', '.wav', '.m4a', '.webm', '.mpeg', '.mpga']))
|
44
|
+
puts "No file given or wrong file type"
|
45
|
+
exit
|
46
|
+
else
|
47
|
+
size = File.size(file_path).to_f / 2**20
|
48
|
+
if size > 24
|
49
|
+
warning("The file is above the maximum size of 25MB")
|
50
|
+
exit
|
51
|
+
else
|
52
|
+
response = client.audio.translate(
|
53
|
+
parameters: {
|
54
|
+
model: "whisper-1",
|
55
|
+
file: File.open(file_path, "rb"),
|
56
|
+
})
|
57
|
+
if (response["text"].nil? || response["text"].empty?)
|
58
|
+
puts "No text found"
|
59
|
+
exit
|
60
|
+
end
|
61
|
+
puts response["text"]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
rescue Errno::ENOENT => e
|
65
|
+
puts "File not found"
|
66
|
+
end
|
67
|
+
|
68
|
+
def self.whisper_transcribe(file_path)
|
69
|
+
if (file_path.nil? || !file_path.end_with?(*['.mp3', '.wav', '.m4a', '.webm', '.mpeg', '.mpga']))
|
70
|
+
puts "No file given"
|
71
|
+
exit
|
72
|
+
else
|
73
|
+
size = File.size(file_path).to_f / 2**20
|
74
|
+
if size > 24
|
75
|
+
warning("The file is above the maximum size of 25MB, this may take")
|
76
|
+
exit
|
77
|
+
else
|
78
|
+
response = client.audio.transcribe(
|
79
|
+
parameters: {
|
80
|
+
model: "whisper-1",
|
81
|
+
file: File.open(file_path, "rb"),
|
82
|
+
})
|
83
|
+
if (response["text"].nil? || response["text"].empty?)
|
84
|
+
puts "No text found"
|
85
|
+
exit
|
86
|
+
end
|
87
|
+
puts response["text"]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
rescue Errno::ENOENT => e
|
91
|
+
puts "File not found"
|
92
|
+
end
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def self.client()
|
97
|
+
conf = YAML.load(File.read(CONFIG_PATH))
|
98
|
+
key = conf["OPENAI_API_KEY"]
|
99
|
+
|
100
|
+
OpenAI::Client.new(access_token: key)
|
101
|
+
end
|
102
|
+
end
|
metadata
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: ask-ai
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Oskar Franck
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-09-22 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: ruby-openai
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 5.1.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 5.1.0
|
27
|
+
description: A CLI tool for using OpenAI's API. The idea is to make it easy to use
|
28
|
+
the API with an option to pass files for help with diffrent tasks.
|
29
|
+
email: contact.oskarfranck.se
|
30
|
+
executables:
|
31
|
+
- aa
|
32
|
+
extensions: []
|
33
|
+
extra_rdoc_files: []
|
34
|
+
files:
|
35
|
+
- bin/aa
|
36
|
+
- config/config.yml
|
37
|
+
- files/context.jsonl
|
38
|
+
- files/context_file.txt
|
39
|
+
- lib/context.rb
|
40
|
+
- lib/handle_args.rb
|
41
|
+
- lib/help.rb
|
42
|
+
- lib/main.rb
|
43
|
+
- lib/prompt.rb
|
44
|
+
homepage:
|
45
|
+
licenses: []
|
46
|
+
metadata: {}
|
47
|
+
post_install_message:
|
48
|
+
rdoc_options: []
|
49
|
+
require_paths:
|
50
|
+
- lib
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '0'
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '0'
|
61
|
+
requirements: []
|
62
|
+
rubygems_version: 3.3.7
|
63
|
+
signing_key:
|
64
|
+
specification_version: 4
|
65
|
+
summary: A simple CLI for OpenAI's GPT-3 API.
|
66
|
+
test_files: []
|