aidp 0.28.0 → 0.30.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.
data/lib/aidp/cli.rb CHANGED
@@ -256,7 +256,7 @@ module Aidp
256
256
  # Determine if the invocation is a subcommand style call
257
257
  def subcommand?(args)
258
258
  return false if args.nil? || args.empty?
259
- %w[status jobs kb harness providers checkpoint mcp issue config init watch ws work skill settings models].include?(args.first)
259
+ %w[status jobs kb harness providers checkpoint mcp issue config init watch ws work skill settings models tools].include?(args.first)
260
260
  end
261
261
 
262
262
  def run_subcommand(args)
@@ -279,6 +279,7 @@ module Aidp
279
279
  when "skill" then run_skill_command(args)
280
280
  when "settings" then run_settings_command(args)
281
281
  when "models" then run_models_command(args)
282
+ when "tools" then run_tools_command(args)
282
283
  else
283
284
  display_message("Unknown command: #{cmd}", type: :info)
284
285
  return 1
@@ -625,6 +626,12 @@ module Aidp
625
626
  models_cmd.run(args)
626
627
  end
627
628
 
629
+ def run_tools_command(args)
630
+ require_relative "cli/tools_command"
631
+ tools_cmd = Aidp::CLI::ToolsCommand.new(project_dir: Dir.pwd, prompt: create_prompt)
632
+ tools_cmd.run(args)
633
+ end
634
+
628
635
  def run_issue_command(args)
629
636
  require_relative "cli/issue_importer"
630
637
 
data/lib/aidp/config.rb CHANGED
@@ -285,6 +285,15 @@ module Aidp
285
285
  symbolize_keys(agile_section)
286
286
  end
287
287
 
288
+ # Get tool metadata configuration
289
+ def self.tool_metadata_config(project_dir = Dir.pwd)
290
+ config = load_harness_config(project_dir)
291
+ tool_metadata_section = config[:tool_metadata] || config["tool_metadata"] || {}
292
+
293
+ # Convert string keys to symbols for consistency
294
+ symbolize_keys(tool_metadata_section)
295
+ end
296
+
288
297
  # Check if configuration file exists
289
298
  def self.config_exists?(project_dir = Dir.pwd)
290
299
  ConfigPaths.config_exists?(project_dir)
@@ -308,7 +308,8 @@ module Aidp
308
308
  iteration: @iteration_count,
309
309
  project_dir: @project_dir,
310
310
  mode: :decide_whats_next,
311
- model: model_name
311
+ model: model_name,
312
+ tier: @thinking_depth_manager.current_tier
312
313
  }
313
314
  )
314
315
 
@@ -340,7 +341,8 @@ module Aidp
340
341
  iteration: @iteration_count,
341
342
  project_dir: @project_dir,
342
343
  mode: :diagnose_failures,
343
- model: model_name
344
+ model: model_name,
345
+ tier: @thinking_depth_manager.current_tier
344
346
  }
345
347
  )
346
348
 
@@ -761,7 +763,8 @@ module Aidp
761
763
  step_name: @step_name,
762
764
  iteration: @iteration_count,
763
765
  project_dir: @project_dir,
764
- model: model_name
766
+ model: model_name,
767
+ tier: @thinking_depth_manager.current_tier
765
768
  }
766
769
  )
767
770
  end
@@ -87,7 +87,7 @@ module Aidp
87
87
  providers_to_search.each do |provider_name|
88
88
  matching_models = []
89
89
  models_for_provider(provider_name).each do |model_name, model_data|
90
- matching_models << model_name if model_data["tier"] == tier
90
+ matching_models << model_name if model_data["tier"] == tier.to_s
91
91
  end
92
92
  results[provider_name] = matching_models unless matching_models.empty?
93
93
  end
@@ -114,12 +114,12 @@ module Aidp
114
114
 
115
115
  # Check if a tier is valid
116
116
  def valid_tier?(tier)
117
- VALID_TIERS.include?(tier)
117
+ VALID_TIERS.include?(tier.to_s)
118
118
  end
119
119
 
120
120
  # Get tier priority (0 = lowest, 4 = highest)
121
121
  def tier_priority(tier)
122
- TIER_PRIORITY[tier]
122
+ TIER_PRIORITY[tier.to_s]
123
123
  end
124
124
 
125
125
  # Compare two tiers (returns -1, 0, 1 like <=>)
@@ -154,7 +154,7 @@ module Aidp
154
154
  models = models_for_provider(provider_name)
155
155
 
156
156
  # Find all models matching tier
157
- tier_models = models.select { |_name, data| data["tier"] == tier }
157
+ tier_models = models.select { |_name, data| data["tier"] == tier.to_s }
158
158
  return nil if tier_models.empty?
159
159
 
160
160
  # Prefer newer models (higher in the list)
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Aidp
7
+ module Harness
8
+ # Manages a dynamic cache of deprecated models detected at runtime
9
+ # When deprecation errors are detected from provider APIs, models are
10
+ # added to this cache with metadata (replacement, detected date, etc.)
11
+ class DeprecationCache
12
+ class CacheError < StandardError; end
13
+
14
+ attr_reader :cache_path
15
+
16
+ def initialize(cache_path: nil, root_dir: nil)
17
+ @root_dir = root_dir || safe_root_dir
18
+ @cache_path = cache_path || default_cache_path
19
+ @cache_data = nil
20
+ ensure_cache_directory
21
+ end
22
+
23
+ # Add a deprecated model to the cache
24
+ # @param provider [String] Provider name (e.g., "anthropic")
25
+ # @param model_id [String] Deprecated model ID
26
+ # @param replacement [String, nil] Replacement model ID (if known)
27
+ # @param reason [String, nil] Deprecation reason/message
28
+ def add_deprecated_model(provider:, model_id:, replacement: nil, reason: nil)
29
+ load_cache unless @cache_data
30
+
31
+ @cache_data["providers"][provider] ||= {}
32
+ @cache_data["providers"][provider][model_id] = {
33
+ "deprecated_at" => Time.now.iso8601,
34
+ "replacement" => replacement,
35
+ "reason" => reason
36
+ }.compact
37
+
38
+ save_cache
39
+ Aidp.log_info("deprecation_cache", "Added deprecated model",
40
+ provider: provider, model: model_id, replacement: replacement)
41
+ end
42
+
43
+ # Check if a model is deprecated
44
+ # @param provider [String] Provider name
45
+ # @param model_id [String] Model ID to check
46
+ # @return [Boolean]
47
+ def deprecated?(provider:, model_id:)
48
+ load_cache unless @cache_data
49
+ @cache_data.dig("providers", provider, model_id) != nil
50
+ end
51
+
52
+ # Get replacement model for a deprecated model
53
+ # @param provider [String] Provider name
54
+ # @param model_id [String] Deprecated model ID
55
+ # @return [String, nil] Replacement model ID or nil
56
+ def replacement_for(provider:, model_id:)
57
+ load_cache unless @cache_data
58
+ @cache_data.dig("providers", provider, model_id, "replacement")
59
+ end
60
+
61
+ # Get all deprecated models for a provider
62
+ # @param provider [String] Provider name
63
+ # @return [Array<String>] List of deprecated model IDs
64
+ def deprecated_models(provider:)
65
+ load_cache unless @cache_data
66
+ (@cache_data.dig("providers", provider) || {}).keys
67
+ end
68
+
69
+ # Remove a model from the deprecated cache
70
+ # Useful if a model comes back or was incorrectly marked
71
+ # @param provider [String] Provider name
72
+ # @param model_id [String] Model ID to remove
73
+ def remove_deprecated_model(provider:, model_id:)
74
+ load_cache unless @cache_data
75
+ return unless @cache_data.dig("providers", provider, model_id)
76
+
77
+ @cache_data["providers"][provider].delete(model_id)
78
+ @cache_data["providers"].delete(provider) if @cache_data["providers"][provider].empty?
79
+
80
+ save_cache
81
+ Aidp.log_info("deprecation_cache", "Removed deprecated model",
82
+ provider: provider, model: model_id)
83
+ end
84
+
85
+ # Get full deprecation info for a model
86
+ # @param provider [String] Provider name
87
+ # @param model_id [String] Model ID
88
+ # @return [Hash, nil] Deprecation metadata or nil
89
+ def info(provider:, model_id:)
90
+ load_cache unless @cache_data
91
+ @cache_data.dig("providers", provider, model_id)
92
+ end
93
+
94
+ # Clear all cached deprecations
95
+ def clear!
96
+ @cache_data = default_cache_structure
97
+ save_cache
98
+ Aidp.log_info("deprecation_cache", "Cleared all deprecations")
99
+ end
100
+
101
+ # Get cache statistics
102
+ # @return [Hash] Statistics about cached deprecations
103
+ def stats
104
+ load_cache unless @cache_data
105
+ {
106
+ providers: @cache_data["providers"].keys.sort,
107
+ total_deprecated: @cache_data["providers"].sum { |_, models| models.size },
108
+ by_provider: @cache_data["providers"].transform_values(&:size)
109
+ }
110
+ end
111
+
112
+ private
113
+
114
+ # Get a safe root directory for the cache
115
+ # Uses Dir.pwd if writable, otherwise falls back to tmpdir
116
+ def safe_root_dir
117
+ pwd = Dir.pwd
118
+ aidp_dir = File.join(pwd, ".aidp")
119
+
120
+ # Try to create the directory to test writability
121
+ begin
122
+ FileUtils.mkdir_p(aidp_dir) unless File.exist?(aidp_dir)
123
+ pwd
124
+ rescue Errno::EACCES, Errno::EROFS, Errno::EPERM
125
+ # Permission denied or read-only filesystem - use temp directory
126
+ require "tmpdir"
127
+ Dir.tmpdir
128
+ end
129
+ end
130
+
131
+ def default_cache_path
132
+ File.join(@root_dir, ".aidp", "deprecated_models.json")
133
+ end
134
+
135
+ def ensure_cache_directory
136
+ dir = File.dirname(@cache_path)
137
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
138
+ end
139
+
140
+ def load_cache
141
+ if File.exist?(@cache_path)
142
+ @cache_data = JSON.parse(File.read(@cache_path))
143
+ validate_cache_structure
144
+ else
145
+ @cache_data = default_cache_structure
146
+ end
147
+ rescue JSON::ParserError => e
148
+ Aidp.log_warn("deprecation_cache", "Invalid cache file, resetting",
149
+ error: e.message, path: @cache_path)
150
+ @cache_data = default_cache_structure
151
+ end
152
+
153
+ def save_cache
154
+ File.write(@cache_path, JSON.pretty_generate(@cache_data))
155
+ rescue => e
156
+ Aidp.log_error("deprecation_cache", "Failed to save cache",
157
+ error: e.message, path: @cache_path)
158
+ raise CacheError, "Failed to save deprecation cache: #{e.message}"
159
+ end
160
+
161
+ def default_cache_structure
162
+ {
163
+ "version" => "1.0",
164
+ "updated_at" => Time.now.iso8601,
165
+ "providers" => {}
166
+ }
167
+ end
168
+
169
+ def validate_cache_structure
170
+ unless @cache_data.is_a?(Hash) && @cache_data["providers"].is_a?(Hash)
171
+ Aidp.log_warn("deprecation_cache", "Invalid cache structure, resetting")
172
+ @cache_data = default_cache_structure
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -1394,8 +1394,9 @@ module Aidp
1394
1394
 
1395
1395
  # Execute a prompt with a specific provider
1396
1396
  def execute_with_provider(provider_type, prompt, options = {})
1397
- # Extract model from options if provided
1397
+ # Extract model and tier from options if provided
1398
1398
  model_name = options.delete(:model)
1399
+ tier = options[:tier] # Keep tier in options for provider
1399
1400
 
1400
1401
  # Create provider factory instance
1401
1402
  provider_factory = ProviderFactory.new
@@ -1414,10 +1415,11 @@ module Aidp
1414
1415
  Aidp.logger.debug("provider_manager", "Executing with provider",
1415
1416
  provider: provider_type,
1416
1417
  model: model_name,
1418
+ tier: tier,
1417
1419
  prompt_length: prompt.length)
1418
1420
 
1419
- # Execute the prompt with the provider
1420
- result = provider.send_message(prompt: prompt, session: nil)
1421
+ # Execute the prompt with the provider (pass options including tier)
1422
+ result = provider.send_message(prompt: prompt, session: nil, options: options)
1421
1423
 
1422
1424
  # Return structured result
1423
1425
  {
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require_relative "deprecation_cache"
5
+
6
+ module Aidp
7
+ module Harness
8
+ # RubyLLMRegistry wraps the ruby_llm gem's model registry
9
+ # to provide AIDP-specific functionality while leveraging
10
+ # ruby_llm's comprehensive and actively maintained model database
11
+ class RubyLLMRegistry
12
+ class RegistryError < StandardError; end
13
+ class ModelNotFound < RegistryError; end
14
+
15
+ # Map AIDP provider names to RubyLLM provider names
16
+ # Some AIDP providers use different names than the upstream APIs
17
+ PROVIDER_NAME_MAPPING = {
18
+ "codex" => "openai", # Codex is AIDP's OpenAI adapter
19
+ "anthropic" => "anthropic",
20
+ "gemini" => "gemini", # Gemini provider name matches
21
+ "aider" => nil, # Aider aggregates multiple providers
22
+ "cursor" => nil, # Cursor has its own models
23
+ "openai" => "openai",
24
+ "google" => "gemini", # Google's API uses gemini provider name
25
+ "azure" => "bedrock", # Azure OpenAI uses bedrock in registry
26
+ "bedrock" => "bedrock",
27
+ "openrouter" => "openrouter"
28
+ }.freeze
29
+
30
+ # Get deprecation cache instance (lazy loaded)
31
+ def deprecation_cache
32
+ @deprecation_cache ||= Aidp::Harness::DeprecationCache.new
33
+ end
34
+
35
+ # Tier classification based on model characteristics
36
+ # These are heuristics since ruby_llm doesn't classify tiers
37
+ TIER_CLASSIFICATION = {
38
+ # Mini tier: fast, cost-effective models
39
+ mini: ->(model) {
40
+ return true if model.id.to_s.match?(/haiku|mini|flash|small/i)
41
+
42
+ # Check pricing if available
43
+ if model.pricing
44
+ pricing_hash = model.pricing.to_h
45
+ input_cost = pricing_hash.dig(:text_tokens, :standard, :input_per_million)
46
+ return true if input_cost && input_cost < 1.0
47
+ end
48
+ false
49
+ },
50
+
51
+ # Advanced tier: high-capability, expensive models
52
+ advanced: ->(model) {
53
+ return true if model.id.to_s.match?(/opus|turbo|pro|preview|o1/i)
54
+
55
+ # Check pricing if available
56
+ if model.pricing
57
+ pricing_hash = model.pricing.to_h
58
+ input_cost = pricing_hash.dig(:text_tokens, :standard, :input_per_million)
59
+ return true if input_cost && input_cost > 10.0
60
+ end
61
+ false
62
+ },
63
+
64
+ # Standard tier: everything else (default)
65
+ standard: ->(model) { true }
66
+ }.freeze
67
+
68
+ def initialize(deprecation_cache: nil)
69
+ @deprecation_cache = deprecation_cache
70
+ @models = RubyLLM::Models.instance.instance_variable_get(:@models)
71
+ @index_by_id = @models.to_h { |m| [m.id, m] }
72
+
73
+ # Build family index for mapping versioned names to families
74
+ @family_index = build_family_index
75
+
76
+ Aidp.log_info("ruby_llm_registry", "initialized", models: @models.size)
77
+ end
78
+
79
+ # Resolve a model name (family or versioned) to the canonical API model
80
+ #
81
+ # @param model_name [String] Model name (e.g., "claude-3-5-haiku" or "claude-3-5-haiku-20241022")
82
+ # @param provider [String, nil] Optional AIDP provider filter
83
+ # @param skip_deprecated [Boolean] Skip deprecated models (default: true)
84
+ # @return [String, nil] Canonical model ID for API calls, or nil if not found
85
+ def resolve_model(model_name, provider: nil, skip_deprecated: true)
86
+ # Map AIDP provider to registry provider if filtering
87
+ registry_provider = provider ? PROVIDER_NAME_MAPPING[provider] : nil
88
+
89
+ # Check if model is deprecated
90
+ if skip_deprecated && model_deprecated?(model_name, registry_provider)
91
+ Aidp.log_warn("ruby_llm_registry", "skipping deprecated model", model: model_name, provider: provider)
92
+ return nil
93
+ end
94
+
95
+ # Try exact match first
96
+ model = @index_by_id[model_name]
97
+ return model.id if model && (registry_provider.nil? || model.provider.to_s == registry_provider)
98
+
99
+ # Try family mapping
100
+ family_models = @family_index[model_name]
101
+ if family_models
102
+ # Filter by provider if specified
103
+ family_models = family_models.select { |m| m.provider.to_s == registry_provider } if registry_provider
104
+
105
+ # Filter out deprecated models if requested
106
+ if skip_deprecated
107
+ family_models = family_models.reject do |m|
108
+ deprecation_cache.deprecated?(provider: registry_provider, model_id: m.id.to_s)
109
+ end
110
+ end
111
+
112
+ # Return the latest version (first non-"latest" model, or the latest one)
113
+ model = family_models.reject { |m| m.id.to_s.include?("-latest") }.first || family_models.first
114
+ return model.id if model
115
+ end
116
+
117
+ # Try fuzzy matching for common patterns
118
+ fuzzy_match = find_fuzzy_match(model_name, registry_provider, skip_deprecated: skip_deprecated)
119
+ return fuzzy_match.id if fuzzy_match
120
+
121
+ Aidp.log_warn("ruby_llm_registry", "model not found", model: model_name, provider: provider)
122
+ nil
123
+ end
124
+
125
+ # Get model information
126
+ #
127
+ # @param model_id [String] The model ID
128
+ # @return [Hash, nil] Model information hash or nil if not found
129
+ def get_model_info(model_id)
130
+ model = @index_by_id[model_id]
131
+ return nil unless model
132
+
133
+ {
134
+ id: model.id,
135
+ name: model.name || model.display_name,
136
+ provider: model.provider.to_s,
137
+ tier: classify_tier(model),
138
+ context_window: model.context_window,
139
+ capabilities: extract_capabilities(model),
140
+ pricing: model.pricing
141
+ }
142
+ end
143
+
144
+ # Get all models for a specific tier
145
+ #
146
+ # @param tier [String, Symbol] The tier name (mini, standard, advanced)
147
+ # @param provider [String, nil] Optional AIDP provider filter
148
+ # @param skip_deprecated [Boolean] Skip deprecated models (default: true)
149
+ # @return [Array<String>] List of model IDs for the tier
150
+ def models_for_tier(tier, provider: nil, skip_deprecated: true)
151
+ tier_sym = tier.to_sym
152
+ classifier = TIER_CLASSIFICATION[tier_sym]
153
+
154
+ unless classifier
155
+ Aidp.log_warn("ruby_llm_registry", "invalid tier", tier: tier)
156
+ return []
157
+ end
158
+
159
+ # Map AIDP provider to registry provider if filtering
160
+ registry_provider = provider ? PROVIDER_NAME_MAPPING[provider] : nil
161
+ return [] if provider && registry_provider.nil?
162
+
163
+ models = @models.select do |model|
164
+ (registry_provider.nil? || model.provider.to_s == registry_provider) &&
165
+ classifier.call(model)
166
+ end
167
+
168
+ # For mini and standard tiers, exclude if advanced classification matches
169
+ if tier_sym == :mini
170
+ models.reject! { |m| TIER_CLASSIFICATION[:advanced].call(m) }
171
+ elsif tier_sym == :standard
172
+ models.reject! do |m|
173
+ TIER_CLASSIFICATION[:mini].call(m) || TIER_CLASSIFICATION[:advanced].call(m)
174
+ end
175
+ end
176
+
177
+ # Filter out deprecated models if requested
178
+ if skip_deprecated
179
+ models.reject! { |m| deprecation_cache.deprecated?(provider: registry_provider, model_id: m.id.to_s) }
180
+ end
181
+
182
+ model_ids = models.map(&:id).uniq
183
+ Aidp.log_debug("ruby_llm_registry", "found models for tier",
184
+ tier: tier, provider: provider, count: model_ids.size)
185
+ model_ids
186
+ end
187
+
188
+ # Get all models for a provider
189
+ #
190
+ # @param provider [String] The AIDP provider name
191
+ # @return [Array<String>] List of model IDs
192
+ def models_for_provider(provider)
193
+ # Map AIDP provider name to RubyLLM provider name
194
+ registry_provider = PROVIDER_NAME_MAPPING[provider]
195
+
196
+ # Return empty if provider doesn't map to a registry provider
197
+ return [] if registry_provider.nil?
198
+
199
+ @models.select { |m| m.provider.to_s == registry_provider }.map(&:id)
200
+ end
201
+
202
+ # Classify a model's tier
203
+ #
204
+ # @param model [RubyLLM::Model::Info] The model info object
205
+ # @return [String] The tier name (mini, standard, advanced)
206
+ def classify_tier(model)
207
+ return "advanced" if TIER_CLASSIFICATION[:advanced].call(model)
208
+ return "mini" if TIER_CLASSIFICATION[:mini].call(model)
209
+ "standard"
210
+ end
211
+
212
+ # Refresh the model registry from ruby_llm
213
+ def refresh!
214
+ RubyLLM::Models.refresh!
215
+ @models = RubyLLM::Models.instance.instance_variable_get(:@models)
216
+ @index_by_id = @models.to_h { |m| [m.id, m] }
217
+ @family_index = build_family_index
218
+ Aidp.log_info("ruby_llm_registry", "refreshed", models: @models.size)
219
+ end
220
+
221
+ # Check if a model is deprecated
222
+ # @param model_id [String] The model ID to check
223
+ # @param provider [String, nil] The provider name (registry format)
224
+ # @return [Boolean] True if model is deprecated
225
+ def model_deprecated?(model_id, provider = nil)
226
+ return false unless provider
227
+
228
+ deprecation_cache.deprecated?(provider: provider, model_id: model_id.to_s)
229
+ end
230
+
231
+ # Find replacement for a deprecated model
232
+ # Returns the latest non-deprecated model in the same family/tier
233
+ # @param deprecated_model [String] The deprecated model ID
234
+ # @param provider [String, nil] The provider name (AIDP format)
235
+ # @return [String, nil] Replacement model ID or nil
236
+ def find_replacement_model(deprecated_model, provider: nil)
237
+ registry_provider = provider ? PROVIDER_NAME_MAPPING[provider] : nil
238
+ return nil unless registry_provider
239
+
240
+ # Determine tier of deprecated model
241
+ deprecated_info = @index_by_id[deprecated_model]
242
+ return nil unless deprecated_info
243
+
244
+ tier = classify_tier(deprecated_info)
245
+
246
+ # Get all non-deprecated models for this tier and provider
247
+ candidates = models_for_tier(tier, provider: provider, skip_deprecated: true)
248
+
249
+ # Prefer models in the same family (e.g., both "sonnet")
250
+ family_keyword = extract_family_keyword(deprecated_model)
251
+ same_family = candidates.select { |m| m.to_s.include?(family_keyword) } if family_keyword
252
+
253
+ # Return first match from same family, or first candidate overall
254
+ replacement = same_family&.first || candidates.first
255
+
256
+ if replacement
257
+ Aidp.log_info("ruby_llm_registry", "found replacement",
258
+ deprecated: deprecated_model,
259
+ replacement: replacement,
260
+ tier: tier)
261
+ end
262
+
263
+ replacement
264
+ end
265
+
266
+ private
267
+
268
+ # Build an index mapping family names to model objects
269
+ # Family name is model ID with version suffix removed
270
+ def build_family_index
271
+ index = Hash.new { |h, k| h[k] = [] }
272
+
273
+ @models.each do |model|
274
+ # Remove date suffix (e.g., "claude-3-5-haiku-20241022" -> "claude-3-5-haiku")
275
+ family = model.id.to_s.sub(/-\d{8}$/, "").sub(/-latest$/, "")
276
+ index[family] << model unless family == model.id.to_s
277
+ end
278
+
279
+ index
280
+ end
281
+
282
+ # Find a model by fuzzy matching
283
+ def find_fuzzy_match(model_name, provider, skip_deprecated: true)
284
+ # Normalize the search term
285
+ normalized = model_name.downcase.gsub(/[^a-z0-9]/, "")
286
+
287
+ candidates = @models.select do |m|
288
+ next false if provider && m.provider.to_s != provider
289
+
290
+ # Skip deprecated if requested
291
+ if skip_deprecated
292
+ next false if deprecation_cache.deprecated?(provider: provider, model_id: m.id.to_s)
293
+ end
294
+
295
+ # Check if model ID contains the search term
296
+ m.id.to_s.downcase.gsub(/[^a-z0-9]/, "").include?(normalized) ||
297
+ m.name.to_s.downcase.gsub(/[^a-z0-9]/, "").include?(normalized)
298
+ end
299
+
300
+ # Prefer shorter matches (more specific)
301
+ candidates.min_by { |m| m.id.to_s.length }
302
+ end
303
+
304
+ # Extract family keyword from model ID (e.g., "sonnet", "haiku", "opus")
305
+ def extract_family_keyword(model_id)
306
+ case model_id.to_s
307
+ when /sonnet/i then "sonnet"
308
+ when /haiku/i then "haiku"
309
+ when /opus/i then "opus"
310
+ when /gpt-4/i then "gpt-4"
311
+ when /gpt-3/i then "gpt-3"
312
+ end
313
+ end
314
+
315
+ # Extract capabilities from model info
316
+ def extract_capabilities(model)
317
+ caps = []
318
+ caps << "chat" if model.capabilities.include?(:chat)
319
+ caps << "code" if model.capabilities.include?(:code) || model.id.to_s.include?("code")
320
+ caps << "vision" if model.capabilities.include?(:vision)
321
+ caps << "tool_use" if model.capabilities.include?(:function_calling) || model.capabilities.include?(:tools)
322
+ caps << "streaming" if model.capabilities.include?(:streaming)
323
+ caps
324
+ end
325
+ end
326
+ end
327
+ end