ruby_llm 1.0.1 → 1.1.0rc1

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -12
  3. data/lib/ruby_llm/active_record/acts_as.rb +46 -7
  4. data/lib/ruby_llm/aliases.json +65 -0
  5. data/lib/ruby_llm/aliases.rb +56 -0
  6. data/lib/ruby_llm/chat.rb +10 -9
  7. data/lib/ruby_llm/configuration.rb +4 -0
  8. data/lib/ruby_llm/error.rb +15 -4
  9. data/lib/ruby_llm/models.json +1163 -303
  10. data/lib/ruby_llm/models.rb +40 -11
  11. data/lib/ruby_llm/provider.rb +32 -39
  12. data/lib/ruby_llm/providers/anthropic/capabilities.rb +8 -9
  13. data/lib/ruby_llm/providers/anthropic/chat.rb +31 -4
  14. data/lib/ruby_llm/providers/anthropic/streaming.rb +12 -6
  15. data/lib/ruby_llm/providers/anthropic.rb +4 -0
  16. data/lib/ruby_llm/providers/bedrock/capabilities.rb +168 -0
  17. data/lib/ruby_llm/providers/bedrock/chat.rb +108 -0
  18. data/lib/ruby_llm/providers/bedrock/models.rb +84 -0
  19. data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
  20. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +46 -0
  21. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +63 -0
  22. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +79 -0
  23. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +90 -0
  24. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +91 -0
  25. data/lib/ruby_llm/providers/bedrock/streaming.rb +36 -0
  26. data/lib/ruby_llm/providers/bedrock.rb +83 -0
  27. data/lib/ruby_llm/providers/deepseek/chat.rb +17 -0
  28. data/lib/ruby_llm/providers/deepseek.rb +5 -0
  29. data/lib/ruby_llm/providers/gemini/capabilities.rb +50 -34
  30. data/lib/ruby_llm/providers/gemini/chat.rb +8 -15
  31. data/lib/ruby_llm/providers/gemini/images.rb +5 -10
  32. data/lib/ruby_llm/providers/gemini/streaming.rb +35 -76
  33. data/lib/ruby_llm/providers/gemini/tools.rb +12 -12
  34. data/lib/ruby_llm/providers/gemini.rb +4 -0
  35. data/lib/ruby_llm/providers/openai/capabilities.rb +146 -206
  36. data/lib/ruby_llm/providers/openai/streaming.rb +9 -13
  37. data/lib/ruby_llm/providers/openai.rb +4 -0
  38. data/lib/ruby_llm/streaming.rb +96 -0
  39. data/lib/ruby_llm/version.rb +1 -1
  40. data/lib/ruby_llm.rb +6 -3
  41. data/lib/tasks/browser_helper.rb +97 -0
  42. data/lib/tasks/capability_generator.rb +123 -0
  43. data/lib/tasks/capability_scraper.rb +224 -0
  44. data/lib/tasks/cli_helper.rb +22 -0
  45. data/lib/tasks/code_validator.rb +29 -0
  46. data/lib/tasks/model_updater.rb +66 -0
  47. data/lib/tasks/models.rake +28 -193
  48. data/lib/tasks/vcr.rake +13 -30
  49. metadata +27 -19
  50. data/.github/workflows/cicd.yml +0 -158
  51. data/.github/workflows/docs.yml +0 -53
  52. data/.gitignore +0 -59
  53. data/.overcommit.yml +0 -26
  54. data/.rspec +0 -3
  55. data/.rubocop.yml +0 -10
  56. data/.yardopts +0 -12
  57. data/CONTRIBUTING.md +0 -207
  58. data/Gemfile +0 -33
  59. data/Rakefile +0 -9
  60. data/bin/console +0 -17
  61. data/bin/setup +0 -6
  62. data/ruby_llm.gemspec +0 -44
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ferrum'
4
+ require_relative 'cli_helper'
5
+
6
+ class BrowserHelper # rubocop:disable Style/Documentation
7
+ REALISTIC_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36' # rubocop:disable Layout/LineLength
8
+
9
+ def initialize
10
+ @browser = create_browser
11
+ end
12
+
13
+ def goto(url)
14
+ @browser.goto(url)
15
+ end
16
+
17
+ def current_url
18
+ @browser.page.url
19
+ rescue StandardError
20
+ 'N/A'
21
+ end
22
+
23
+ def get_page_content(context = 'current page') # rubocop:disable Metrics/MethodLength
24
+ puts " Extracting HTML for #{context}..."
25
+
26
+ begin
27
+ sleep(1.0) # Small delay for page stability
28
+ html = @browser.body
29
+
30
+ if html && !html.empty?
31
+ puts " Extracted ~#{html.length} chars of HTML"
32
+ puts ' WARNING: Challenge page detected' if html.match?(/challenge-platform|Checking site/)
33
+ html
34
+ else
35
+ puts ' Warning: Empty content returned'
36
+ ''
37
+ end
38
+ rescue StandardError => e
39
+ puts " Error getting HTML: #{e.class} - #{e.message}"
40
+ ''
41
+ end
42
+ end
43
+
44
+ def wait_for_page_load
45
+ handle_cloudflare_challenge
46
+ end
47
+
48
+ def close
49
+ puts "\nClosing browser..."
50
+ @browser.quit
51
+ rescue StandardError => e
52
+ puts " Warning: Error closing browser: #{e.message}"
53
+ end
54
+
55
+ private
56
+
57
+ def create_browser
58
+ puts ' Initializing browser for manual interaction...'
59
+
60
+ Ferrum::Browser.new(
61
+ window_size: [1366, 768],
62
+ headless: false,
63
+ browser_options: browser_options,
64
+ timeout: 120,
65
+ process_timeout: 120,
66
+ pending_connection_errors: false
67
+ )
68
+ end
69
+
70
+ def browser_options
71
+ {
72
+ 'user-agent' => REALISTIC_USER_AGENT,
73
+ 'disable-gpu' => nil,
74
+ 'no-sandbox' => nil,
75
+ 'disable-blink-features' => 'AutomationControlled',
76
+ 'disable-infobars' => nil,
77
+ 'start-maximized' => nil
78
+ }
79
+ end
80
+
81
+ def handle_cloudflare_challenge # rubocop:disable Metrics/MethodLength
82
+ puts "\nWaiting for Cloudflare challenge resolution..."
83
+ puts 'c: Challenge solved'
84
+ puts 'q: Quit/Skip'
85
+
86
+ choice = CliHelper.get_user_choice('Confirm when ready', %w[c q])
87
+ return false if choice == 'q'
88
+
89
+ begin
90
+ @browser.page.target_id
91
+ true
92
+ rescue StandardError
93
+ puts 'Browser check failed after challenge'
94
+ false
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'json'
5
+ require_relative 'code_validator'
6
+
7
+ class CapabilityGenerator # rubocop:disable Style/Documentation
8
+ def initialize(provider_name, docs_html)
9
+ @provider_name = provider_name
10
+ @docs_html = docs_html
11
+ @processed_html = process_html(docs_html)
12
+ end
13
+
14
+ def generate_and_save # rubocop:disable Metrics/MethodLength
15
+ puts " Starting code generation for #{@provider_name}..."
16
+
17
+ existing_path = File.expand_path("../../lib/ruby_llm/providers/#{@provider_name}/capabilities.rb", __dir__)
18
+ unless File.exist?(existing_path)
19
+ puts " Skipping: No file at #{existing_path}"
20
+ return
21
+ end
22
+
23
+ existing_code = File.read(existing_path)
24
+ puts ' Read existing code'
25
+
26
+ generated_code = generate_capabilities(existing_code)
27
+
28
+ if generated_code
29
+ puts " Writing updated code to #{existing_path}..."
30
+ File.write(existing_path, generated_code)
31
+ puts " Updated #{@provider_name}"
32
+
33
+ verify_code_with_models_update
34
+ else
35
+ puts " Failed to generate valid code for #{@provider_name}"
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def process_html(docs_html) # rubocop:disable Metrics/MethodLength
42
+ docs_html.transform_values do |html|
43
+ next '' if html.nil? || html.empty?
44
+
45
+ # Extract just the main content areas, skip scripts, styles, etc
46
+ main_content = html.scan(%r{<main.*?>.*?</main>}m).first ||
47
+ html.scan(%r{<article.*?>.*?</article>}m).first ||
48
+ html.scan(%r{<div class="content.*?>.*?</div>}m).first
49
+
50
+ if main_content
51
+ # Further clean up the content
52
+ main_content.gsub(%r{<script.*?>.*?</script>}m, '')
53
+ .gsub(%r{<style.*?>.*?</style>}m, '')
54
+ .gsub(/<!--.*?-->/m, '')
55
+ .gsub(/\s+/, ' ')
56
+ .strip
57
+ else
58
+ ''
59
+ end
60
+ end
61
+ end
62
+
63
+ def generate_capabilities(existing_code) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
64
+ max_retries = 3
65
+ retries = 0
66
+
67
+ loop do # rubocop:disable Metrics/BlockLength
68
+ puts " Attempt #{retries + 1}/#{max_retries}..."
69
+
70
+ begin
71
+ gemini = RubyLLM.chat(model: 'gemini-1.5-pro-latest')
72
+ .with_temperature(0.1)
73
+
74
+ docs_json = JSON.pretty_generate(@processed_html)
75
+
76
+ prompt = <<~PROMPT
77
+ Update RubyLLM::Providers::#{@provider_name.capitalize}::Capabilities module.
78
+ Only use the provided HTML content and existing code structure.
79
+ Focus on updating values while preserving the module structure.
80
+
81
+ Existing code to maintain structure:
82
+ ```ruby
83
+ #{existing_code}
84
+ ```
85
+
86
+ HTML content to extract new values from:
87
+ ```json
88
+ #{docs_json}
89
+ ```
90
+
91
+ Return ONLY the complete Ruby code within ```ruby ``` tags.
92
+ PROMPT
93
+
94
+ response = gemini.ask(prompt)
95
+ generated_code = CodeValidator.extract_code_from_response(response.content)
96
+
97
+ return generated_code if generated_code && CodeValidator.validate_syntax(generated_code)
98
+ rescue RubyLLM::BadRequestError => e
99
+ puts " Error: #{e.message}"
100
+ # Try with even less content if we hit token limits
101
+ @processed_html = @processed_html.transform_values { |html| html[0..10_000] }
102
+ rescue StandardError => e
103
+ puts " Error: #{e.class} - #{e.message}"
104
+ end
105
+
106
+ retries += 1
107
+ break if retries >= max_retries
108
+ end
109
+
110
+ nil
111
+ end
112
+
113
+ def verify_code_with_models_update
114
+ puts ' Verifying with models:update...'
115
+ begin
116
+ Rake::Task['models:update'].reenable
117
+ Rake::Task['models:update'].invoke
118
+ puts ' Verification successful'
119
+ rescue StandardError => e
120
+ puts " Verification failed: #{e.message}"
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'uri'
5
+ require_relative 'browser_helper'
6
+ require_relative 'cli_helper'
7
+
8
+ class CapabilityScraper # rubocop:disable Metrics/ClassLength,Style/Documentation
9
+ PROVIDER_DOCS = {
10
+ openai: {
11
+ url: 'https://platform.openai.com/docs/models'
12
+ },
13
+ anthropic: {
14
+ url: 'https://docs.anthropic.com/claude/docs/models-overview'
15
+ },
16
+ gemini: {
17
+ url: 'https://ai.google.dev/models/gemini',
18
+ pricing_url: 'https://ai.google.dev/pricing'
19
+ },
20
+ deepseek: {
21
+ url: 'https://api-docs.deepseek.com/quick_start/pricing'
22
+ }
23
+ }.freeze
24
+
25
+ def self.parse_providers(providers_arg) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
26
+ provider_input = (providers_arg || '').downcase.split(',').map(&:strip).reject(&:empty?)
27
+
28
+ target_providers = if provider_input.empty? || provider_input.include?('all')
29
+ PROVIDER_DOCS.keys
30
+ else
31
+ provider_input.map(&:to_sym).select { |p| PROVIDER_DOCS.key?(p) }
32
+ end
33
+
34
+ if target_providers.empty?
35
+ puts "No valid providers. Available: all, #{PROVIDER_DOCS.keys.join(', ')}"
36
+ exit(1)
37
+ end
38
+
39
+ puts "Targeting providers: #{target_providers.join(', ')}"
40
+ target_providers
41
+ end
42
+
43
+ def initialize(providers)
44
+ @providers = providers
45
+ @browser_helper = BrowserHelper.new
46
+ end
47
+
48
+ def run # rubocop:disable Metrics/MethodLength
49
+ @providers.each do |provider_sym|
50
+ provider_name = provider_sym.to_s
51
+ puts "\n======== Processing Provider: #{provider_name.upcase} =========="
52
+
53
+ docs_info = PROVIDER_DOCS[provider_sym]
54
+ docs_html = scrape_provider(provider_sym, docs_info)
55
+
56
+ next unless docs_html.any? { |_k, v| v && !v.strip.empty? }
57
+
58
+ summarize_html_data(docs_html)
59
+ yield(provider_name, docs_html) if block_given?
60
+ end
61
+ ensure
62
+ @browser_helper&.close
63
+ end
64
+
65
+ private
66
+
67
+ def scrape_provider(provider_sym, docs_info)
68
+ if provider_sym == :openai
69
+ handle_openai_scraping(docs_info[:url])
70
+ else
71
+ handle_standard_scraping(provider_sym, docs_info)
72
+ end
73
+ end
74
+
75
+ def handle_openai_scraping(main_url) # rubocop:disable Metrics/MethodLength
76
+ main_url_path = extract_path_from_url(main_url)
77
+ overview_key = generate_key_from_url(main_url, main_url_path)
78
+ docs_html = {}
79
+
80
+ puts "\n--- Interactive Scraping for OpenAI ---"
81
+ puts " Navigating to main OpenAI models page: #{main_url}..."
82
+
83
+ begin
84
+ @browser_helper.goto(main_url)
85
+ return docs_html unless @browser_helper.wait_for_page_load
86
+
87
+ puts ' Scraping overview page HTML...'
88
+ overview_html = @browser_helper.get_page_content('OpenAI Overview')
89
+ docs_html[overview_key] = overview_html if overview_html && !overview_html.empty?
90
+
91
+ # Interactive scraping loop
92
+ interactive_scraping_loop(docs_html, main_url, main_url_path, overview_key)
93
+ rescue StandardError => e
94
+ puts "FATAL: Navigation failed: #{e}"
95
+ end
96
+
97
+ docs_html
98
+ end
99
+
100
+ def interactive_scraping_loop(docs_html, main_url, main_url_path, overview_key) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
101
+ loop do # rubocop:disable Metrics/BlockLength
102
+ current_url = begin
103
+ @browser_helper.current_url
104
+ rescue StandardError
105
+ 'N/A'
106
+ end
107
+ puts "\n--- OpenAI Subpage Scraping ---"
108
+ puts "Current URL: #{current_url}"
109
+ puts "Collected Keys: #{docs_html.keys.sort.join(', ')}"
110
+ puts '---------------------------------'
111
+
112
+ puts 'Ready to scrape current page. Options:'
113
+ puts 's: Scrape current page'
114
+ puts 'b: Back to overview'
115
+ puts 'q: Quit scraping'
116
+
117
+ choice = CliHelper.get_user_choice('Choose action', %w[s b q])
118
+
119
+ case choice
120
+ when 's'
121
+ scrape_current_page(docs_html, main_url_path, overview_key)
122
+ when 'b'
123
+ puts " Navigating back to overview: #{main_url}..."
124
+ begin
125
+ @browser_helper.goto(main_url)
126
+ @browser_helper.wait_for_page_load
127
+ rescue StandardError => e
128
+ puts " Error navigating back: #{e.message}. Navigate manually."
129
+ end
130
+ when 'q'
131
+ puts ' Finished OpenAI scraping.'
132
+ break
133
+ end
134
+ end
135
+ end
136
+
137
+ def scrape_current_page(docs_html, main_url_path, overview_key) # rubocop:disable Metrics/MethodLength
138
+ current_url = begin
139
+ @browser_helper.current_url
140
+ rescue StandardError
141
+ 'N/A'
142
+ end
143
+ puts " Scraping content from: #{current_url}"
144
+ page_html = @browser_helper.get_page_content(current_url)
145
+
146
+ if page_html && !page_html.empty?
147
+ page_key = generate_key_from_url(current_url, main_url_path)
148
+ if page_key != overview_key || !docs_html.key?(overview_key)
149
+ docs_html[page_key] = page_html
150
+ puts " Stored HTML under key: #{page_key}"
151
+ else
152
+ puts ' Note: Back on overview page, skipping.'
153
+ end
154
+ end
155
+
156
+ next_choice = CliHelper.get_user_choice('Continue? (y: scrape another, n: quit)', %w[y n])
157
+ false if next_choice == 'n'
158
+ end
159
+
160
+ def handle_standard_scraping(provider, info)
161
+ provider_name = provider.to_s
162
+ docs_html = {}
163
+
164
+ puts "\n--- Standard Scraping for #{provider_name.upcase} ---"
165
+
166
+ # Main page scraping
167
+ scrape_url(provider_name, info[:url], "#{provider_name}_main_html", docs_html)
168
+
169
+ # Pricing page scraping (if available)
170
+ if info[:pricing_url] && info[:pricing_url] != info[:url]
171
+ scrape_url(provider_name, info[:pricing_url], "#{provider_name}_pricing_html", docs_html)
172
+ end
173
+
174
+ docs_html
175
+ end
176
+
177
+ def scrape_url(provider_name, url, key_name, docs_html)
178
+ puts " Scraping #{key_name}: #{url}..."
179
+
180
+ begin
181
+ @browser_helper.goto(url)
182
+ return unless @browser_helper.wait_for_page_load
183
+
184
+ html_content = @browser_helper.get_page_content("#{provider_name} #{key_name}")
185
+ docs_html[key_name] = html_content if html_content && !html_content.empty?
186
+ rescue StandardError => e
187
+ puts " Error on #{key_name}: #{e.message}"
188
+ end
189
+ end
190
+
191
+ def extract_path_from_url(url_string)
192
+ URI(url_string).path
193
+ rescue StandardError
194
+ '/docs/models'
195
+ end
196
+
197
+ def generate_key_from_url(url_string, base_url_path = '/docs/models')
198
+ uri = URI(url_string)
199
+ path = uri.path.chomp('/')
200
+ return 'models_overview_html' if path == base_url_path
201
+
202
+ key_part = path.split('/').reject(&:empty?).last || 'unknown'
203
+ "model_#{key_part.gsub(/[^a-z0-9_\-]/i, '_')}_html"
204
+ rescue StandardError => e
205
+ puts " Warning: URL parsing failed for: #{url_string} (#{e.message})"
206
+ "scrape_#{Time.now.to_i}_html"
207
+ end
208
+
209
+ def summarize_html_data(docs_html) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
210
+ docs_summary = docs_html.map do |k, v|
211
+ desc = if v.nil? || v.strip.empty?
212
+ '(Empty)'
213
+ elsif v.include?('challenge') || v.include?('Checking')
214
+ '(Challenge?)'
215
+ else
216
+ '(OK)'
217
+ end
218
+ "#{k}: ~#{v&.length || 0} chars #{desc}"
219
+ end.join(', ')
220
+
221
+ puts "\n HTML Summary: #{docs_summary}"
222
+ puts ' Warning: LLM results may be inaccurate.' if docs_summary.include?('(Challenge?)')
223
+ end
224
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ class CliHelper # rubocop:disable Style/Documentation
6
+ def self.get_user_choice(prompt, valid_choices) # rubocop:disable Metrics/MethodLength
7
+ loop do
8
+ print "\n#{prompt} [#{valid_choices.join('/')}]: "
9
+ begin
10
+ choice = $stdin.getch.downcase
11
+ puts choice # Echo the character
12
+
13
+ return choice if valid_choices.include?(choice)
14
+
15
+ puts " Invalid choice '#{choice}'. Valid options: #{valid_choices.join(', ')}"
16
+ rescue Interrupt
17
+ puts "\n*** Input interrupted."
18
+ return 'q'
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ripper'
4
+
5
+ class CodeValidator # rubocop:disable Style/Documentation
6
+ # Use Ruby's built-in Ripper to validate Ruby syntax instead of eval
7
+ def self.validate_syntax(code)
8
+ # Ripper.sexp returns nil if there's a syntax error
9
+ result = Ripper.sexp(code)
10
+
11
+ if result.nil?
12
+ puts 'Syntax error in generated code'
13
+ false
14
+ else
15
+ # Additional validation could go here
16
+ true
17
+ end
18
+ end
19
+
20
+ def self.extract_code_from_response(content)
21
+ match = content.match(/```ruby\s*(.*?)\s*```/m)
22
+ if match
23
+ match[1].strip
24
+ else
25
+ puts 'No Ruby code block found in response'
26
+ nil
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+
5
+ class ModelUpdater # rubocop:disable Style/Documentation
6
+ def run
7
+ puts 'Configuring RubyLLM...'
8
+ configure_from_env
9
+
10
+ refresh_models
11
+ display_model_stats
12
+ end
13
+
14
+ private
15
+
16
+ def configure_from_env
17
+ RubyLLM.configure do |config|
18
+ config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
19
+ config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
20
+ config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil)
21
+ config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil)
22
+ configure_bedrock(config)
23
+ config.request_timeout = 30
24
+ end
25
+ end
26
+
27
+ def configure_bedrock(config)
28
+ config.bedrock_api_key = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
29
+ config.bedrock_secret_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
30
+ config.bedrock_region = ENV.fetch('AWS_REGION', nil)
31
+ config.bedrock_session_token = ENV.fetch('AWS_SESSION_TOKEN', nil)
32
+ end
33
+
34
+ def refresh_models # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
35
+ initial_count = RubyLLM.models.all.size
36
+ puts "Refreshing models (#{initial_count} cached)..."
37
+
38
+ models = RubyLLM.models.refresh!
39
+
40
+ if models.all.empty? && initial_count.zero?
41
+ puts 'Error: Failed to fetch models.'
42
+ exit(1)
43
+ elsif models.all.size == initial_count && initial_count.positive?
44
+ puts 'Warning: Model list unchanged.'
45
+ else
46
+ puts "Saving models.json (#{models.all.size} models)"
47
+ models.save_models
48
+ end
49
+
50
+ @models = models
51
+ end
52
+
53
+ def display_model_stats
54
+ puts "\nModel count:"
55
+ provider_counts = @models.all.group_by(&:provider).transform_values(&:count)
56
+
57
+ RubyLLM::Provider.providers.each_key do |sym|
58
+ name = sym.to_s.capitalize
59
+ count = provider_counts[sym.to_s] || 0
60
+ status = RubyLLM::Provider.providers[sym].configured? ? '(OK)' : '(SKIP)'
61
+ puts " #{name}: #{count} models #{status}"
62
+ end
63
+
64
+ puts 'Refresh complete.'
65
+ end
66
+ end