ace-support-models 0.9.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +162 -0
  3. data/LICENSE +21 -0
  4. data/README.md +39 -0
  5. data/Rakefile +13 -0
  6. data/exe/ace-llm-providers +19 -0
  7. data/exe/ace-models +23 -0
  8. data/lib/ace/support/models/atoms/api_fetcher.rb +76 -0
  9. data/lib/ace/support/models/atoms/cache_path_resolver.rb +38 -0
  10. data/lib/ace/support/models/atoms/file_reader.rb +43 -0
  11. data/lib/ace/support/models/atoms/file_writer.rb +63 -0
  12. data/lib/ace/support/models/atoms/json_parser.rb +38 -0
  13. data/lib/ace/support/models/atoms/model_filter.rb +107 -0
  14. data/lib/ace/support/models/atoms/model_name_canonicalizer.rb +119 -0
  15. data/lib/ace/support/models/atoms/provider_config_reader.rb +218 -0
  16. data/lib/ace/support/models/atoms/provider_config_writer.rb +230 -0
  17. data/lib/ace/support/models/cli/commands/cache/clear.rb +43 -0
  18. data/lib/ace/support/models/cli/commands/cache/diff.rb +74 -0
  19. data/lib/ace/support/models/cli/commands/cache/status.rb +54 -0
  20. data/lib/ace/support/models/cli/commands/cache/sync.rb +51 -0
  21. data/lib/ace/support/models/cli/commands/info.rb +33 -0
  22. data/lib/ace/support/models/cli/commands/models/cost.rb +54 -0
  23. data/lib/ace/support/models/cli/commands/models/info.rb +136 -0
  24. data/lib/ace/support/models/cli/commands/models/search.rb +101 -0
  25. data/lib/ace/support/models/cli/commands/providers/list.rb +46 -0
  26. data/lib/ace/support/models/cli/commands/providers/show.rb +54 -0
  27. data/lib/ace/support/models/cli/commands/providers/sync.rb +66 -0
  28. data/lib/ace/support/models/cli/commands/search.rb +35 -0
  29. data/lib/ace/support/models/cli/commands/sync_shortcut.rb +32 -0
  30. data/lib/ace/support/models/cli/providers_cli.rb +72 -0
  31. data/lib/ace/support/models/cli.rb +84 -0
  32. data/lib/ace/support/models/errors.rb +55 -0
  33. data/lib/ace/support/models/models/diff_result.rb +94 -0
  34. data/lib/ace/support/models/models/model_info.rb +129 -0
  35. data/lib/ace/support/models/models/pricing_info.rb +74 -0
  36. data/lib/ace/support/models/models/provider_info.rb +81 -0
  37. data/lib/ace/support/models/models.rb +97 -0
  38. data/lib/ace/support/models/molecules/cache_manager.rb +237 -0
  39. data/lib/ace/support/models/molecules/cost_calculator.rb +135 -0
  40. data/lib/ace/support/models/molecules/diff_generator.rb +171 -0
  41. data/lib/ace/support/models/molecules/model_searcher.rb +176 -0
  42. data/lib/ace/support/models/molecules/model_validator.rb +177 -0
  43. data/lib/ace/support/models/molecules/provider_sync_diff.rb +291 -0
  44. data/lib/ace/support/models/organisms/provider_sync_orchestrator.rb +278 -0
  45. data/lib/ace/support/models/organisms/sync_orchestrator.rb +108 -0
  46. data/lib/ace/support/models/version.rb +9 -0
  47. data/lib/ace/support/models.rb +3 -0
  48. metadata +149 -0
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Models
6
+ module Molecules
7
+ # Searches for models with fuzzy matching
8
+ class ModelSearcher
9
+ # Initialize searcher
10
+ # @param cache_manager [CacheManager, nil] Cache manager instance
11
+ def initialize(cache_manager: nil)
12
+ @cache_manager = cache_manager || CacheManager.new
13
+ end
14
+
15
+ # Search for models matching query
16
+ # @param query [String, nil] Search query (nil = match all)
17
+ # @param provider [String, nil] Limit to specific provider
18
+ # @param limit [Integer] Max results
19
+ # @param filters [Hash, nil] Additional filters (key-value pairs)
20
+ # @param with_total [Boolean] Return hash with models and total count
21
+ # @return [Array<Models::ModelInfo>, Hash] Matching models or {models:, total:}
22
+ #
23
+ # Memory optimization: Defers ModelInfo instantiation until after pagination.
24
+ # This avoids materializing thousands of objects for large caches with broad queries.
25
+ def search(query = nil, provider: nil, limit: 20, filters: nil, with_total: false)
26
+ data = load_data
27
+ results = []
28
+
29
+ providers_to_search = provider ? [provider] : data.keys
30
+
31
+ # Phase 1: Collect lightweight hashes with scores (no ModelInfo instantiation)
32
+ providers_to_search.each do |provider_id|
33
+ provider_data = data[provider_id]
34
+ next unless provider_data
35
+
36
+ (provider_data["models"] || {}).each do |model_id, model_data|
37
+ # If no query provided, match all (score = 1)
38
+ score = query ? match_score(query, model_id, model_data["name"]) : 1
39
+ if score > 0
40
+ results << {
41
+ data: model_data,
42
+ provider_id: provider_id,
43
+ score: score
44
+ }
45
+ end
46
+ end
47
+ end
48
+
49
+ # Phase 2: Sort by score (still lightweight hashes)
50
+ sorted = results.sort_by { |r| -r[:score] }
51
+
52
+ # Phase 3: Apply filters if provided (requires ModelInfo for capability checks)
53
+ if filters
54
+ # Must instantiate to filter, but filter early before limit
55
+ models = sorted.map { |r| Models::ModelInfo.from_hash(r[:data], provider_id: r[:provider_id]) }
56
+ models = Atoms::ModelFilter.apply(models, filters)
57
+ total = models.size
58
+ limited = models.first(limit)
59
+ else
60
+ # No filters: instantiate only the limited set
61
+ total = sorted.size
62
+ limited = sorted.first(limit).map do |r|
63
+ Models::ModelInfo.from_hash(r[:data], provider_id: r[:provider_id])
64
+ end
65
+ end
66
+
67
+ if with_total
68
+ {models: limited, total: total}
69
+ else
70
+ limited
71
+ end
72
+ end
73
+
74
+ # List all models
75
+ # @param provider [String, nil] Limit to specific provider
76
+ # @return [Array<Models::ModelInfo>] All models
77
+ def all(provider: nil)
78
+ data = load_data
79
+ models = []
80
+
81
+ providers_to_search = provider ? [provider] : data.keys
82
+
83
+ providers_to_search.each do |provider_id|
84
+ provider_data = data[provider_id]
85
+ next unless provider_data
86
+
87
+ (provider_data["models"] || {}).each do |_model_id, model_data|
88
+ models << Models::ModelInfo.from_hash(model_data, provider_id: provider_id)
89
+ end
90
+ end
91
+
92
+ models.sort_by(&:full_id)
93
+ end
94
+
95
+ # Count models
96
+ # @param provider [String, nil] Limit to specific provider
97
+ # @return [Integer] Model count
98
+ def count(provider: nil)
99
+ data = load_data
100
+ total = 0
101
+
102
+ providers_to_search = provider ? [provider] : data.keys
103
+
104
+ providers_to_search.each do |provider_id|
105
+ provider_data = data[provider_id]
106
+ next unless provider_data
107
+
108
+ total += (provider_data["models"] || {}).size
109
+ end
110
+
111
+ total
112
+ end
113
+
114
+ # Get stats about the data
115
+ # @return [Hash] Stats
116
+ def stats
117
+ data = load_data
118
+ provider_count = data.size
119
+ model_count = 0
120
+ models_by_provider = {}
121
+
122
+ data.each do |provider_id, provider_data|
123
+ count = (provider_data["models"] || {}).size
124
+ models_by_provider[provider_id] = count
125
+ model_count += count
126
+ end
127
+
128
+ {
129
+ providers: provider_count,
130
+ models: model_count,
131
+ models_by_provider: models_by_provider.sort_by { |_, v| -v }.to_h
132
+ }
133
+ end
134
+
135
+ private
136
+
137
+ def load_data
138
+ data = @cache_manager.read
139
+ raise CacheError, "No cache data. Run 'ace-models sync' first." unless data
140
+
141
+ data
142
+ end
143
+
144
+ def match_score(query, model_id, model_name)
145
+ query_down = query.downcase
146
+ id_down = model_id.downcase
147
+ name_down = (model_name || "").downcase
148
+
149
+ score = 0
150
+
151
+ # Exact match
152
+ return 100 if id_down == query_down || name_down == query_down
153
+
154
+ # Starts with
155
+ score += 50 if id_down.start_with?(query_down) || name_down.start_with?(query_down)
156
+
157
+ # Contains
158
+ score += 25 if id_down.include?(query_down) || name_down.include?(query_down)
159
+
160
+ # Word match
161
+ query_words = query_down.split(/[-_\s]/)
162
+ id_words = id_down.split(/[-_\s]/)
163
+ name_words = name_down.split(/[-_\s]/)
164
+
165
+ query_words.each do |qw|
166
+ score += 10 if id_words.any? { |w| w.start_with?(qw) }
167
+ score += 10 if name_words.any? { |w| w.start_with?(qw) }
168
+ end
169
+
170
+ score
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Models
6
+ module Molecules
7
+ # Validates model names against cached data
8
+ class ModelValidator
9
+ MAX_SUGGESTIONS = 5
10
+ # Maximum string length for Levenshtein distance calculation.
11
+ # Prevents O(n*m) memory/time blowups on pathological inputs.
12
+ MAX_LEVENSHTEIN_LENGTH = 500
13
+
14
+ # Initialize validator
15
+ # @param cache_manager [CacheManager, nil] Cache manager instance
16
+ def initialize(cache_manager: nil)
17
+ @cache_manager = cache_manager || CacheManager.new
18
+ end
19
+
20
+ # Validate a model exists
21
+ # @param model_id [String] Model ID (provider:model or just model)
22
+ # @return [Models::ModelInfo] Model info if valid
23
+ # @raise [ProviderNotFoundError] if provider doesn't exist
24
+ # @raise [ModelNotFoundError] if model doesn't exist
25
+ # @raise [ValidationError] if model_id format is invalid
26
+ def validate(model_id)
27
+ validate_format!(model_id)
28
+ data = load_data
29
+ provider_id, model_name = parse_model_id(model_id)
30
+
31
+ # Check provider exists
32
+ provider_data = data[provider_id]
33
+ raise ProviderNotFoundError, provider_id unless provider_data
34
+
35
+ # Check model exists
36
+ model_data = provider_data.dig("models", model_name)
37
+ unless model_data
38
+ suggestions = find_suggestions(provider_data["models"]&.keys || [], model_name)
39
+ raise ModelNotFoundError.new("#{provider_id}:#{model_name}", suggestions: suggestions)
40
+ end
41
+
42
+ Models::ModelInfo.from_hash(model_data, provider_id: provider_id)
43
+ end
44
+
45
+ # Check if model is valid (no exception)
46
+ # @param model_id [String] Model ID
47
+ # @return [Boolean]
48
+ def valid?(model_id)
49
+ validate(model_id)
50
+ true
51
+ rescue ModelNotFoundError, ProviderNotFoundError
52
+ false
53
+ end
54
+
55
+ # Get model info (returns nil instead of raising)
56
+ # @param model_id [String] Model ID
57
+ # @return [Models::ModelInfo, nil]
58
+ def get(model_id)
59
+ validate(model_id)
60
+ rescue ModelNotFoundError, ProviderNotFoundError
61
+ nil
62
+ end
63
+
64
+ # List all providers
65
+ # @return [Array<String>] Provider IDs
66
+ def providers
67
+ load_data.keys.sort
68
+ end
69
+
70
+ # List models for a provider
71
+ # @param provider_id [String] Provider ID
72
+ # @return [Array<String>] Model IDs
73
+ def models_for(provider_id)
74
+ data = load_data
75
+ provider_data = data[provider_id]
76
+ return [] unless provider_data
77
+
78
+ (provider_data["models"]&.keys || []).sort
79
+ end
80
+
81
+ private
82
+
83
+ def load_data
84
+ data = @cache_manager.read
85
+ raise CacheError, "No cache data. Run 'ace-models sync' first." unless data
86
+
87
+ data
88
+ end
89
+
90
+ # Validate model_id format before cache lookup
91
+ # @raise [ValidationError] if format is invalid
92
+ def validate_format!(model_id)
93
+ raise ValidationError, "Model ID cannot be nil" if model_id.nil?
94
+ raise ValidationError, "Model ID cannot be empty" if model_id.strip.empty?
95
+ raise ValidationError, "Model ID is too short" if model_id.length < 2
96
+
97
+ # If contains colon, validate both parts are present
98
+ if model_id.include?(":")
99
+ parts = model_id.split(":", 2)
100
+ raise ValidationError, "Provider cannot be empty in 'provider:model' format" if parts[0].strip.empty?
101
+ raise ValidationError, "Model name cannot be empty in 'provider:model' format" if parts[1].nil? || parts[1].strip.empty?
102
+ end
103
+ end
104
+
105
+ def parse_model_id(model_id)
106
+ if model_id.include?(":")
107
+ parts = model_id.split(":", 2)
108
+ [parts[0], parts[1]]
109
+ else
110
+ # Try to find provider by model name
111
+ data = load_data
112
+ data.each do |provider_id, provider_data|
113
+ if provider_data.dig("models", model_id)
114
+ return [provider_id, model_id]
115
+ end
116
+ end
117
+ # Default to treating as unknown provider
118
+ raise ValidationError, "Model ID must be in format 'provider:model' (e.g., 'openai:gpt-4o')"
119
+ end
120
+ end
121
+
122
+ def find_suggestions(model_names, target)
123
+ return [] if model_names.empty?
124
+
125
+ # Pre-filter by length before expensive levenshtein
126
+ # Skip names that differ by more than 3 characters in length
127
+ candidates = model_names.select do |name|
128
+ (name.length - target.length).abs <= 3
129
+ end
130
+
131
+ # Simple prefix/substring matching for suggestions
132
+ suggestions = candidates.select do |name|
133
+ name.include?(target) || target.include?(name) ||
134
+ levenshtein_distance(name, target) <= 3
135
+ end
136
+
137
+ suggestions.first(MAX_SUGGESTIONS)
138
+ end
139
+
140
+ # Simple Levenshtein distance for fuzzy matching
141
+ # @param s1 [String] First string
142
+ # @param s2 [String] Second string
143
+ # @return [Integer] Edit distance, or Float::INFINITY if inputs exceed MAX_LEVENSHTEIN_LENGTH
144
+ def levenshtein_distance(s1, s2)
145
+ # Guard against pathological inputs that would cause O(n*m) blowups
146
+ if s1.length > MAX_LEVENSHTEIN_LENGTH || s2.length > MAX_LEVENSHTEIN_LENGTH
147
+ return Float::INFINITY
148
+ end
149
+
150
+ m = s1.length
151
+ n = s2.length
152
+ return n if m.zero?
153
+ return m if n.zero?
154
+
155
+ d = Array.new(m + 1) { Array.new(n + 1) }
156
+
157
+ (0..m).each { |i| d[i][0] = i }
158
+ (0..n).each { |j| d[0][j] = j }
159
+
160
+ (1..m).each do |i|
161
+ (1..n).each do |j|
162
+ cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1
163
+ d[i][j] = [
164
+ d[i - 1][j] + 1,
165
+ d[i][j - 1] + 1,
166
+ d[i - 1][j - 1] + cost
167
+ ].min
168
+ end
169
+ end
170
+
171
+ d[m][n]
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Ace
6
+ module Support
7
+ module Models
8
+ module Molecules
9
+ # Generates diff between current provider configs and models.dev data
10
+ # Shows added, removed, and deprecated models for each provider
11
+ class ProviderSyncDiff
12
+ # Model change types
13
+ ADDED = :added
14
+ REMOVED = :removed
15
+ UNCHANGED = :unchanged
16
+ DEPRECATED = :deprecated
17
+
18
+ attr_reader :cache_manager
19
+
20
+ # Initialize diff generator
21
+ # @param cache_manager [CacheManager, nil] Cache manager for models.dev data
22
+ def initialize(cache_manager: nil)
23
+ @cache_manager = cache_manager || CacheManager.new
24
+ end
25
+
26
+ # Generate diff for all providers
27
+ # @param current_configs [Hash<String, Hash>] Provider name => config hash
28
+ # @param provider_filter [String, nil] Limit to specific provider
29
+ # @param since_date [Date, nil] Only show models released after this date
30
+ # @param show_all [Boolean] Show all models regardless of date (ignores since_date)
31
+ # @return [Hash] Diff results by provider
32
+ def generate(current_configs, provider_filter: nil, since_date: nil, show_all: false)
33
+ models_dev_data = load_models_dev_data
34
+ results = {}
35
+
36
+ current_configs.each do |provider_name, config|
37
+ # Skip if filtering and this isn't the target provider
38
+ next if provider_filter && provider_name != provider_filter
39
+
40
+ # Determine the models.dev ID to use (may be mapped via models_dev_id field)
41
+ models_dev_id = Atoms::ProviderConfigReader.extract_models_dev_id(config)
42
+
43
+ # Find matching provider in models.dev
44
+ provider_data = find_provider(models_dev_data, models_dev_id)
45
+
46
+ if provider_data.nil?
47
+ # Generate hint for unmapped providers
48
+ hint = suggest_models_dev_id(models_dev_data, provider_name)
49
+ results[provider_name] = {
50
+ status: :not_found,
51
+ message: "Provider not found in models.dev",
52
+ hint: hint
53
+ }
54
+ next
55
+ end
56
+
57
+ # Determine the date to filter by
58
+ filter_date = determine_filter_date(config, since_date, show_all)
59
+
60
+ # Generate diff for this provider
61
+ results[provider_name] = diff_provider(config, provider_data, since_date: filter_date, provider_name: provider_name)
62
+ results[provider_name][:last_synced] = Atoms::ProviderConfigReader.extract_last_synced(config)
63
+ results[provider_name][:models_dev_id] = models_dev_id if models_dev_id != provider_name
64
+ end
65
+
66
+ results
67
+ end
68
+
69
+ # Generate diff for a single provider
70
+ # @param config [Hash] Current provider config
71
+ # @param provider_data [Hash] models.dev provider data
72
+ # @param since_date [Date, nil] Only include models released after this date
73
+ # @param provider_name [String, nil] Provider name for canonicalization (e.g., "openrouter")
74
+ # @return [Hash] Diff result
75
+ def diff_provider(config, provider_data, since_date: nil, provider_name: nil)
76
+ current_models = Set.new(Atoms::ProviderConfigReader.extract_models(config))
77
+ models_dev_models = extract_models_dev_models(provider_data)
78
+
79
+ # Build a set of canonical model names from models.dev for efficient lookup
80
+ models_dev_canonical = Set.new(models_dev_models.keys)
81
+
82
+ # Build a mapping of canonical names to original names for current models
83
+ # This handles cases like "model:nitro" -> "model"
84
+ current_canonical_to_original = {}
85
+ current_models.each do |model_id|
86
+ canonical = Atoms::ModelNameCanonicalizer.canonicalize(model_id, provider: provider_name)
87
+ current_canonical_to_original[canonical] ||= []
88
+ current_canonical_to_original[canonical] << model_id
89
+ end
90
+
91
+ added = []
92
+ added_with_dates = {}
93
+ removed = []
94
+ unchanged = []
95
+ deprecated = []
96
+
97
+ # Check models.dev models against current config
98
+ # Use canonical names for matching
99
+ models_dev_models.each do |model_id, model_data|
100
+ # Check if any current model (or its canonical form) matches this models.dev model
101
+ has_match = current_canonical_to_original.key?(model_id)
102
+
103
+ if has_match
104
+ if model_data[:status] == "deprecated"
105
+ deprecated << model_id
106
+ else
107
+ unchanged << model_id
108
+ end
109
+ else
110
+ if model_data[:status] == "deprecated"
111
+ # Don't suggest adding deprecated models
112
+ next
113
+ end
114
+
115
+ # Filter by release date if specified
116
+ if since_date && model_data[:release_date]
117
+ next if model_data[:release_date] <= since_date
118
+ end
119
+
120
+ added << model_id
121
+ added_with_dates[model_id] = model_data[:release_date]
122
+ end
123
+ end
124
+
125
+ # Check for removed models (in config but not in models.dev)
126
+ # Use canonical names to avoid false positives for suffixed models
127
+ current_models.each do |model_id|
128
+ canonical = Atoms::ModelNameCanonicalizer.canonicalize(model_id, provider: provider_name)
129
+ unless models_dev_canonical.include?(canonical)
130
+ removed << model_id
131
+ end
132
+ end
133
+
134
+ {
135
+ status: :ok,
136
+ added: added.sort,
137
+ added_with_dates: added_with_dates,
138
+ removed: removed.sort,
139
+ unchanged: unchanged.sort,
140
+ deprecated: deprecated.sort,
141
+ models_dev_count: models_dev_models.size,
142
+ current_count: current_models.size,
143
+ filtered_by_date: !since_date.nil?
144
+ }
145
+ end
146
+
147
+ # Calculate summary statistics
148
+ # @param results [Hash] Diff results by provider
149
+ # @return [Hash] Summary stats
150
+ def summary(results)
151
+ total_added = 0
152
+ total_removed = 0
153
+ total_unchanged = 0
154
+ total_deprecated = 0
155
+ providers_synced = 0
156
+ providers_skipped = 0
157
+
158
+ results.each do |_provider, result|
159
+ if result[:status] == :ok
160
+ providers_synced += 1
161
+ total_added += result[:added].size
162
+ total_removed += result[:removed].size
163
+ total_unchanged += result[:unchanged].size
164
+ total_deprecated += result[:deprecated].size
165
+ else
166
+ providers_skipped += 1
167
+ end
168
+ end
169
+
170
+ {
171
+ added: total_added,
172
+ removed: total_removed,
173
+ unchanged: total_unchanged,
174
+ deprecated: total_deprecated,
175
+ providers_synced: providers_synced,
176
+ providers_skipped: providers_skipped
177
+ }
178
+ end
179
+
180
+ # Check if there are any changes
181
+ # @param results [Hash] Diff results
182
+ # @return [Boolean] true if any changes detected
183
+ def any_changes?(results)
184
+ results.any? do |_provider, result|
185
+ next false unless result[:status] == :ok
186
+
187
+ result[:added].any? || result[:removed].any?
188
+ end
189
+ end
190
+
191
+ private
192
+
193
+ def load_models_dev_data
194
+ data = cache_manager.read
195
+ raise CacheError, "No models.dev cache found. Run 'ace-models sync' first." unless data
196
+
197
+ data
198
+ end
199
+
200
+ def find_provider(models_dev_data, provider_name)
201
+ return nil unless provider_name
202
+
203
+ # Try exact match first
204
+ return models_dev_data[provider_name] if models_dev_data.key?(provider_name)
205
+
206
+ # Try case-insensitive match
207
+ models_dev_data.each do |id, data|
208
+ return data if id.downcase == provider_name.downcase
209
+ end
210
+
211
+ # Try matching by provider name field
212
+ models_dev_data.each do |_id, data|
213
+ name = data["name"] || data["id"]
214
+ return data if name&.downcase == provider_name.downcase
215
+ end
216
+
217
+ nil
218
+ end
219
+
220
+ def extract_models_dev_models(provider_data)
221
+ models = provider_data["models"] || {}
222
+ result = {}
223
+
224
+ models.each do |model_id, model_data|
225
+ release_date = parse_date(model_data["release_date"])
226
+ result[model_id] = {
227
+ status: model_data["status"],
228
+ name: model_data["name"] || model_id,
229
+ release_date: release_date
230
+ }
231
+ end
232
+
233
+ result
234
+ end
235
+
236
+ def parse_date(value)
237
+ return nil unless value
238
+
239
+ case value
240
+ when Date
241
+ value
242
+ when String
243
+ Date.parse(value)
244
+ end
245
+ rescue ArgumentError
246
+ nil
247
+ end
248
+
249
+ def determine_filter_date(config, explicit_since_date, show_all)
250
+ return nil if show_all
251
+
252
+ # Use explicit --since date if provided
253
+ return explicit_since_date if explicit_since_date
254
+
255
+ # Otherwise use last_synced from config
256
+ Atoms::ProviderConfigReader.extract_last_synced(config)
257
+ end
258
+
259
+ def suggest_models_dev_id(models_dev_data, provider_name)
260
+ # Try to find a provider that might match
261
+ provider_lower = provider_name.downcase
262
+
263
+ # Common mappings
264
+ common_mappings = {
265
+ "claude" => "anthropic",
266
+ "gpt" => "openai",
267
+ "gemini" => "google",
268
+ "llama" => "meta"
269
+ }
270
+
271
+ # Check common mappings first
272
+ common_mappings.each do |key, value|
273
+ if provider_lower.include?(key) && models_dev_data.key?(value)
274
+ return "Add 'models_dev_id: #{value}' to provider config"
275
+ end
276
+ end
277
+
278
+ # Try fuzzy matching on provider names
279
+ models_dev_data.each do |id, _data|
280
+ if id.downcase.include?(provider_lower) || provider_lower.include?(id.downcase)
281
+ return "Add 'models_dev_id: #{id}' to provider config"
282
+ end
283
+ end
284
+
285
+ nil
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
291
+ end