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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +160 -0
  4. data/bin/llms-chat +6 -0
  5. data/bin/llms-test-model-access +4 -0
  6. data/bin/llms-test-model-image-support +4 -0
  7. data/bin/llms-test-model-prompt-caching +4 -0
  8. data/bin/llms-test-model-tool-use +5 -0
  9. data/lib/llms/adapters/anthropic_message_adapter.rb +73 -0
  10. data/lib/llms/adapters/anthropic_tool_call_adapter.rb +20 -0
  11. data/lib/llms/adapters/base_message_adapter.rb +60 -0
  12. data/lib/llms/adapters/google_gemini_message_adapter.rb +72 -0
  13. data/lib/llms/adapters/google_gemini_tool_call_adapter.rb +20 -0
  14. data/lib/llms/adapters/open_ai_compatible_message_adapter.rb +88 -0
  15. data/lib/llms/adapters/open_ai_compatible_tool_call_adapter.rb +67 -0
  16. data/lib/llms/adapters.rb +12 -0
  17. data/lib/llms/apis/google_gemini_api.rb +45 -0
  18. data/lib/llms/apis/open_ai_compatible_api.rb +54 -0
  19. data/lib/llms/cli/base.rb +186 -0
  20. data/lib/llms/cli/chat.rb +92 -0
  21. data/lib/llms/cli/test_access.rb +79 -0
  22. data/lib/llms/cli/test_image_support.rb +92 -0
  23. data/lib/llms/cli/test_prompt_caching.rb +275 -0
  24. data/lib/llms/cli/test_tool_use.rb +108 -0
  25. data/lib/llms/cli.rb +12 -0
  26. data/lib/llms/conversation.rb +100 -0
  27. data/lib/llms/conversation_message.rb +60 -0
  28. data/lib/llms/conversation_tool_call.rb +14 -0
  29. data/lib/llms/conversation_tool_result.rb +15 -0
  30. data/lib/llms/exceptions.rb +33 -0
  31. data/lib/llms/executors/anthropic_executor.rb +247 -0
  32. data/lib/llms/executors/base_executor.rb +144 -0
  33. data/lib/llms/executors/google_gemini_executor.rb +212 -0
  34. data/lib/llms/executors/hugging_face_executor.rb +17 -0
  35. data/lib/llms/executors/open_ai_compatible_executor.rb +209 -0
  36. data/lib/llms/executors.rb +52 -0
  37. data/lib/llms/models/model.rb +86 -0
  38. data/lib/llms/models/provider.rb +48 -0
  39. data/lib/llms/models.rb +187 -0
  40. data/lib/llms/parsers/anthropic_chat_response_stream_parser.rb +184 -0
  41. data/lib/llms/parsers/google_gemini_chat_response_stream_parser.rb +128 -0
  42. data/lib/llms/parsers/open_ai_compatible_chat_response_stream_parser.rb +170 -0
  43. data/lib/llms/parsers/partial_json_parser.rb +77 -0
  44. data/lib/llms/parsers/sse_chat_response_stream_parser.rb +72 -0
  45. data/lib/llms/public_models.json +607 -0
  46. data/lib/llms/stream/event_emitter.rb +48 -0
  47. data/lib/llms/stream/events.rb +104 -0
  48. data/lib/llms/usage/cost_calculator.rb +75 -0
  49. data/lib/llms/usage/usage_data.rb +46 -0
  50. data/lib/llms.rb +16 -0
  51. 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