llms 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +160 -0
- data/bin/llms-chat +6 -0
- data/bin/llms-test-model-access +4 -0
- data/bin/llms-test-model-image-support +4 -0
- data/bin/llms-test-model-prompt-caching +4 -0
- data/bin/llms-test-model-tool-use +5 -0
- data/lib/llms/adapters/anthropic_message_adapter.rb +73 -0
- data/lib/llms/adapters/anthropic_tool_call_adapter.rb +20 -0
- data/lib/llms/adapters/base_message_adapter.rb +60 -0
- data/lib/llms/adapters/google_gemini_message_adapter.rb +72 -0
- data/lib/llms/adapters/google_gemini_tool_call_adapter.rb +20 -0
- data/lib/llms/adapters/open_ai_compatible_message_adapter.rb +88 -0
- data/lib/llms/adapters/open_ai_compatible_tool_call_adapter.rb +67 -0
- data/lib/llms/adapters.rb +12 -0
- data/lib/llms/apis/google_gemini_api.rb +45 -0
- data/lib/llms/apis/open_ai_compatible_api.rb +54 -0
- data/lib/llms/cli/base.rb +186 -0
- data/lib/llms/cli/chat.rb +92 -0
- data/lib/llms/cli/test_access.rb +79 -0
- data/lib/llms/cli/test_image_support.rb +92 -0
- data/lib/llms/cli/test_prompt_caching.rb +275 -0
- data/lib/llms/cli/test_tool_use.rb +108 -0
- data/lib/llms/cli.rb +12 -0
- data/lib/llms/conversation.rb +100 -0
- data/lib/llms/conversation_message.rb +60 -0
- data/lib/llms/conversation_tool_call.rb +14 -0
- data/lib/llms/conversation_tool_result.rb +15 -0
- data/lib/llms/exceptions.rb +33 -0
- data/lib/llms/executors/anthropic_executor.rb +247 -0
- data/lib/llms/executors/base_executor.rb +144 -0
- data/lib/llms/executors/google_gemini_executor.rb +212 -0
- data/lib/llms/executors/hugging_face_executor.rb +17 -0
- data/lib/llms/executors/open_ai_compatible_executor.rb +209 -0
- data/lib/llms/executors.rb +52 -0
- data/lib/llms/models/model.rb +86 -0
- data/lib/llms/models/provider.rb +48 -0
- data/lib/llms/models.rb +187 -0
- data/lib/llms/parsers/anthropic_chat_response_stream_parser.rb +184 -0
- data/lib/llms/parsers/google_gemini_chat_response_stream_parser.rb +128 -0
- data/lib/llms/parsers/open_ai_compatible_chat_response_stream_parser.rb +170 -0
- data/lib/llms/parsers/partial_json_parser.rb +77 -0
- data/lib/llms/parsers/sse_chat_response_stream_parser.rb +72 -0
- data/lib/llms/public_models.json +607 -0
- data/lib/llms/stream/event_emitter.rb +48 -0
- data/lib/llms/stream/events.rb +104 -0
- data/lib/llms/usage/cost_calculator.rb +75 -0
- data/lib/llms/usage/usage_data.rb +46 -0
- data/lib/llms.rb +16 -0
- metadata +243 -0
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module LLMs
|
5
|
+
module APIs
|
6
|
+
class OpenAICompatibleAPI
|
7
|
+
def initialize(api_key, base_url)
|
8
|
+
@api_key = api_key
|
9
|
+
@base_url = base_url
|
10
|
+
end
|
11
|
+
|
12
|
+
def chat_completion(model_name, messages, params = {})
|
13
|
+
uri = URI("#{@base_url}/chat/completions")
|
14
|
+
|
15
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
16
|
+
http.use_ssl = @base_url.start_with?('https://')
|
17
|
+
http.read_timeout = 1000
|
18
|
+
|
19
|
+
stream = params.delete(:stream)
|
20
|
+
params[:stream] = !!stream
|
21
|
+
|
22
|
+
request = Net::HTTP::Post.new(uri).tap do |req|
|
23
|
+
req['Content-Type'] = 'application/json'
|
24
|
+
req['Authorization'] = "Bearer #{@api_key}"
|
25
|
+
req.body = params.merge({
|
26
|
+
model: model_name,
|
27
|
+
messages: messages,
|
28
|
+
}).to_json
|
29
|
+
end
|
30
|
+
|
31
|
+
if !!stream
|
32
|
+
http.request(request) do |response|
|
33
|
+
if response.code.to_s == '200'
|
34
|
+
response.read_body do |data|
|
35
|
+
stream.call(data)
|
36
|
+
end
|
37
|
+
return nil ## return nil to indicate that there's no separate api response other than the stream
|
38
|
+
else
|
39
|
+
return JSON.parse(response.body)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
else
|
43
|
+
response = http.request(request)
|
44
|
+
if response.code.to_s == '200'
|
45
|
+
JSON.parse(response.body)
|
46
|
+
else
|
47
|
+
{'error' => JSON.parse(response.body)} ## TODO add status code?
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
|
3
|
+
module LLMs
|
4
|
+
module CLI
|
5
|
+
class Base
|
6
|
+
attr_reader :options, :executor
|
7
|
+
|
8
|
+
def initialize(command_name, override_default_options = {})
|
9
|
+
@command_name = command_name
|
10
|
+
@options = default_options.merge(override_default_options)
|
11
|
+
parse_options
|
12
|
+
end
|
13
|
+
|
14
|
+
def run
|
15
|
+
setup
|
16
|
+
execute
|
17
|
+
cleanup
|
18
|
+
rescue LLMs::Error => e
|
19
|
+
$stderr.puts "Error: #{e.message}"
|
20
|
+
exit 1
|
21
|
+
rescue StandardError => e
|
22
|
+
$stderr.puts "Unexpected error: #{e.message}"
|
23
|
+
if @options[:debug]
|
24
|
+
$stderr.puts e.backtrace
|
25
|
+
end
|
26
|
+
exit 1
|
27
|
+
end
|
28
|
+
|
29
|
+
protected
|
30
|
+
|
31
|
+
def default_options
|
32
|
+
{
|
33
|
+
stream: false,
|
34
|
+
usage: false,
|
35
|
+
quiet: false,
|
36
|
+
debug: false,
|
37
|
+
model_name: nil,
|
38
|
+
list_models: false,
|
39
|
+
max_completion_tokens: nil,
|
40
|
+
max_thinking_tokens: nil,
|
41
|
+
thinking_effort: nil,
|
42
|
+
thinking_mode: false,
|
43
|
+
temperature: 0.0,
|
44
|
+
oac_base_url: nil,
|
45
|
+
oac_api_key: nil,
|
46
|
+
oac_api_key_env_var: nil
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
def parse_options
|
51
|
+
OptionParser.new do |opts|
|
52
|
+
opts.banner = "Usage: #{@command_name} [options]"
|
53
|
+
|
54
|
+
opts.on("--system PROMPT", "Set custom system prompt") do |prompt|
|
55
|
+
@options[:system_prompt] = prompt
|
56
|
+
end
|
57
|
+
|
58
|
+
opts.on("--stream", "Stream the output") do
|
59
|
+
@options[:stream] = true
|
60
|
+
end
|
61
|
+
|
62
|
+
opts.on("--usage", "Show usage information") do
|
63
|
+
@options[:usage] = true
|
64
|
+
end
|
65
|
+
|
66
|
+
opts.on("-q", "--quiet", "Suppress non-essential output") do
|
67
|
+
@options[:quiet] = true
|
68
|
+
end
|
69
|
+
|
70
|
+
opts.on("-d", "--debug", "Enable debug output") do
|
71
|
+
@options[:debug] = true
|
72
|
+
end
|
73
|
+
|
74
|
+
opts.on("-m", "--model MODEL", "Specify model name") do |model|
|
75
|
+
@options[:model_name] = model
|
76
|
+
end
|
77
|
+
|
78
|
+
opts.on("-l", "--list-models", "List available models") do
|
79
|
+
@options[:list_models] = true
|
80
|
+
end
|
81
|
+
|
82
|
+
opts.on("--max-completion-tokens N", Integer, "Set max completion tokens") do |n|
|
83
|
+
@options[:max_completion_tokens] = n
|
84
|
+
end
|
85
|
+
|
86
|
+
opts.on("--max-thinking-tokens N", Integer, "Set max thinking tokens") do |n|
|
87
|
+
@options[:max_thinking_tokens] = n
|
88
|
+
end
|
89
|
+
|
90
|
+
opts.on("--thinking-effort L", "Set thinking effort (low, medium, high)") do |level|
|
91
|
+
@options[:thinking_effort] = level
|
92
|
+
end
|
93
|
+
|
94
|
+
opts.on("-t", "--thinking", "Enable thinking mode") do
|
95
|
+
@options[:thinking_mode] = true
|
96
|
+
end
|
97
|
+
|
98
|
+
opts.on("--temperature T", Float, "Set temperature") do |t|
|
99
|
+
@options[:temperature] = t
|
100
|
+
end
|
101
|
+
|
102
|
+
opts.on("--oac-base-url URL", "OpenAI Compatible base URL to use") do |url|
|
103
|
+
@options[:oac_base_url] = url
|
104
|
+
end
|
105
|
+
|
106
|
+
opts.on("--oac-api-key KEY", "OpenAI Compatible API key to use") do |key|
|
107
|
+
@options[:oac_api_key] = key
|
108
|
+
end
|
109
|
+
|
110
|
+
opts.on("--oac-api-key-env-var VAR", "OpenAI Compatible API key environment variable to use") do |var|
|
111
|
+
@options[:oac_api_key_env_var] = var
|
112
|
+
end
|
113
|
+
|
114
|
+
add_custom_options(opts)
|
115
|
+
end.parse!
|
116
|
+
end
|
117
|
+
|
118
|
+
def add_custom_options(opts)
|
119
|
+
# Override in subclasses to add custom options
|
120
|
+
end
|
121
|
+
|
122
|
+
def setup
|
123
|
+
validate_options
|
124
|
+
set_executor unless @options[:list_models]
|
125
|
+
end
|
126
|
+
|
127
|
+
def execute
|
128
|
+
if @options[:list_models]
|
129
|
+
list_models
|
130
|
+
else
|
131
|
+
perform_execution
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def cleanup
|
136
|
+
# Override in subclasses if needed
|
137
|
+
end
|
138
|
+
|
139
|
+
def validate_options
|
140
|
+
raise LLMs::ConfigurationError, "Model name is required" if @options[:model_name].nil? && !@options[:list_models]
|
141
|
+
end
|
142
|
+
|
143
|
+
def set_executor
|
144
|
+
@executor = create_executor
|
145
|
+
end
|
146
|
+
|
147
|
+
def create_executor(options_override = {})
|
148
|
+
executor = LLMs::Executors.instance(**@options.merge(options_override))
|
149
|
+
|
150
|
+
unless @options.merge(options_override)[:quiet]
|
151
|
+
puts "Connected to: #{executor.model_name} (#{executor.class.name}#{executor.base_url ? " #{executor.base_url}" : ""})"
|
152
|
+
end
|
153
|
+
|
154
|
+
executor
|
155
|
+
end
|
156
|
+
|
157
|
+
def list_models
|
158
|
+
models = LLMs::Models.list_model_names(full: true)
|
159
|
+
if @options[:model]
|
160
|
+
models = models.select { |name| name.include?(@options[:model]) }
|
161
|
+
end
|
162
|
+
puts models
|
163
|
+
end
|
164
|
+
|
165
|
+
def perform_execution
|
166
|
+
raise NotImplementedError, "Subclasses must implement perform_execution"
|
167
|
+
end
|
168
|
+
|
169
|
+
def report_usage(executor)
|
170
|
+
return if @options[:quiet] || !@options[:usage]
|
171
|
+
|
172
|
+
if usage = executor.last_usage_data
|
173
|
+
puts usage.inspect
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def report_error(executor)
|
178
|
+
return if @options[:quiet]
|
179
|
+
|
180
|
+
if error = executor.last_error
|
181
|
+
$stderr.puts "Error: #{error.inspect}"
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|
186
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require_relative 'base'
|
2
|
+
require 'readline'
|
3
|
+
|
4
|
+
module LLMs
|
5
|
+
module CLI
|
6
|
+
class Chat < Base
|
7
|
+
protected
|
8
|
+
|
9
|
+
def default_options
|
10
|
+
super.merge({
|
11
|
+
stream: true,
|
12
|
+
system_prompt: "You are a helpful assistant",
|
13
|
+
model_name: LLMs::Models::DEFAULT_MODEL,
|
14
|
+
max_completion_tokens: 2048,
|
15
|
+
thinking_mode: false,
|
16
|
+
thinking_effort: nil,
|
17
|
+
max_thinking_tokens: 1024,
|
18
|
+
quiet: false
|
19
|
+
})
|
20
|
+
end
|
21
|
+
|
22
|
+
def add_custom_options(opts)
|
23
|
+
opts.on("--no-stream", "Disable streaming output") do
|
24
|
+
@options[:stream] = false
|
25
|
+
end
|
26
|
+
opts.on("--system PROMPT", "Set custom system prompt") do |prompt|
|
27
|
+
@options[:system_prompt] = prompt
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def perform_execution
|
32
|
+
if ARGV.empty?
|
33
|
+
run_chat
|
34
|
+
else
|
35
|
+
run_prompt(ARGV.join(' '))
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def run_chat
|
42
|
+
conversation = LLMs::Conversation.new
|
43
|
+
conversation.set_system_message(@options[:system_prompt])
|
44
|
+
|
45
|
+
Readline.completion_append_character = " "
|
46
|
+
Readline.completion_proc = proc { |s| [] }
|
47
|
+
|
48
|
+
loop do
|
49
|
+
prompt = Readline.readline("> ", true)
|
50
|
+
break if prompt.nil? || prompt.empty? || prompt == "exit"
|
51
|
+
|
52
|
+
conversation.add_user_message(prompt)
|
53
|
+
|
54
|
+
if @options[:stream]
|
55
|
+
@executor.execute_conversation(conversation) { |chunk| print chunk }
|
56
|
+
puts
|
57
|
+
else
|
58
|
+
response = @executor.execute_conversation(conversation)
|
59
|
+
puts response.text
|
60
|
+
end
|
61
|
+
|
62
|
+
if error = @executor.last_error
|
63
|
+
$stderr.puts "Error: #{error.inspect}"
|
64
|
+
end
|
65
|
+
|
66
|
+
unless @options[:quiet]
|
67
|
+
usage = @executor.last_usage_data
|
68
|
+
puts "Usage: #{usage}"
|
69
|
+
end
|
70
|
+
|
71
|
+
conversation.add_assistant_message(@executor.last_received_message)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def run_prompt(prompt)
|
76
|
+
if @options[:stream]
|
77
|
+
@executor.execute_prompt(prompt.strip) { |chunk| print chunk }
|
78
|
+
puts
|
79
|
+
else
|
80
|
+
response = @executor.execute_prompt(prompt.strip)
|
81
|
+
puts response
|
82
|
+
end
|
83
|
+
|
84
|
+
if error = @executor.last_error
|
85
|
+
$stderr.puts "Error: #{error.inspect}"
|
86
|
+
end
|
87
|
+
|
88
|
+
report_usage(executor)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require_relative 'base'
|
2
|
+
|
3
|
+
module LLMs
|
4
|
+
module CLI
|
5
|
+
class TestAccess < Base
|
6
|
+
protected
|
7
|
+
|
8
|
+
def default_options
|
9
|
+
super.merge({
|
10
|
+
max_completion_tokens: 250,
|
11
|
+
prompt: "2+2=",
|
12
|
+
system_prompt: "Always reply with numbers as WORDS not digits."
|
13
|
+
})
|
14
|
+
end
|
15
|
+
|
16
|
+
def add_custom_options(opts)
|
17
|
+
opts.on("--prompt PROMPT", "Test prompt to use") do |prompt|
|
18
|
+
@options[:prompt] = prompt
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def setup
|
23
|
+
# No model name required for this command
|
24
|
+
true
|
25
|
+
end
|
26
|
+
|
27
|
+
def perform_execution
|
28
|
+
if @options[:model_name]
|
29
|
+
test_single_model(create_executor)
|
30
|
+
else
|
31
|
+
test_all_models
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def test_single_model(executor)
|
38
|
+
begin
|
39
|
+
if @options[:stream]
|
40
|
+
response = executor.execute_prompt(@options[:prompt], system_prompt: @options[:system_prompt]) do |chunk|
|
41
|
+
print chunk
|
42
|
+
end
|
43
|
+
puts
|
44
|
+
else
|
45
|
+
response = executor.execute_prompt(@options[:prompt], system_prompt: @options[:system_prompt])
|
46
|
+
puts response
|
47
|
+
end
|
48
|
+
|
49
|
+
report_error(executor)
|
50
|
+
report_usage(executor)
|
51
|
+
|
52
|
+
rescue StandardError => e
|
53
|
+
puts "#{executor.model_name}: ERROR - #{e.message}"
|
54
|
+
puts e.backtrace if @options[:debug]
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def test_all_models
|
59
|
+
models = get_models_to_test
|
60
|
+
|
61
|
+
models.each do |model_name|
|
62
|
+
test_single_model(create_executor({model_name: model_name}))
|
63
|
+
puts "-" * 80
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def get_models_to_test
|
68
|
+
models = LLMs::Models.list_model_names(full: true)
|
69
|
+
|
70
|
+
# Filter by model name if provided
|
71
|
+
if ARGV[0]
|
72
|
+
models = models.select { |name| name.include?(ARGV[0]) }
|
73
|
+
end
|
74
|
+
|
75
|
+
models
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
require_relative 'base'
|
2
|
+
require 'open-uri'
|
3
|
+
require 'base64'
|
4
|
+
require_relative '../conversation'
|
5
|
+
|
6
|
+
module LLMs
|
7
|
+
module CLI
|
8
|
+
class TestImageSupport < Base
|
9
|
+
protected
|
10
|
+
|
11
|
+
def default_options
|
12
|
+
super.merge({
|
13
|
+
max_completion_tokens: 2000,
|
14
|
+
prompt: "What is in this picture?",
|
15
|
+
system_prompt: "Always reply in Latin"
|
16
|
+
})
|
17
|
+
end
|
18
|
+
|
19
|
+
def add_custom_options(opts)
|
20
|
+
opts.on("--prompt PROMPT", "Test prompt to use") do |prompt|
|
21
|
+
@options[:prompt] = prompt
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def setup
|
26
|
+
# No model name required for this command
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
def perform_execution
|
31
|
+
if @options[:model_name]
|
32
|
+
test_single_model(create_executor)
|
33
|
+
else
|
34
|
+
test_all_models
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def test_single_model(executor)
|
41
|
+
begin
|
42
|
+
image_data = fetch_image_data
|
43
|
+
|
44
|
+
cm = LLMs::Conversation.new
|
45
|
+
cm.set_system_message(@options[:system_prompt])
|
46
|
+
cm.add_user_message([{ text: @options[:prompt], image: image_data, media_type: 'image/png' }])
|
47
|
+
|
48
|
+
if @options[:stream]
|
49
|
+
executor.execute_conversation(cm) do |chunk|
|
50
|
+
print chunk
|
51
|
+
end
|
52
|
+
puts
|
53
|
+
else
|
54
|
+
response_message = executor.execute_conversation(cm)
|
55
|
+
puts response_message&.text
|
56
|
+
end
|
57
|
+
|
58
|
+
report_error(executor)
|
59
|
+
report_usage(executor)
|
60
|
+
|
61
|
+
rescue StandardError => e
|
62
|
+
puts "#{executor.model_name}: ERROR - #{e.message}"
|
63
|
+
puts e.backtrace if @options[:debug]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def test_all_models
|
68
|
+
models = get_models_to_test
|
69
|
+
|
70
|
+
models.each do |model_name|
|
71
|
+
test_single_model(create_executor({model_name: model_name}))
|
72
|
+
puts "-" * 80
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def get_models_to_test
|
77
|
+
models = LLMs::Models.list_model_names(full: true, require_vision: true)
|
78
|
+
|
79
|
+
# Filter by model name if provided
|
80
|
+
if ARGV[0]
|
81
|
+
models = models.select { |name| name.include?(ARGV[0]) }
|
82
|
+
end
|
83
|
+
|
84
|
+
models
|
85
|
+
end
|
86
|
+
|
87
|
+
def fetch_image_data
|
88
|
+
Base64.strict_encode64(URI.open('https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png').read)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|