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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +162 -0
- data/LICENSE +21 -0
- data/README.md +39 -0
- data/Rakefile +13 -0
- data/exe/ace-llm-providers +19 -0
- data/exe/ace-models +23 -0
- data/lib/ace/support/models/atoms/api_fetcher.rb +76 -0
- data/lib/ace/support/models/atoms/cache_path_resolver.rb +38 -0
- data/lib/ace/support/models/atoms/file_reader.rb +43 -0
- data/lib/ace/support/models/atoms/file_writer.rb +63 -0
- data/lib/ace/support/models/atoms/json_parser.rb +38 -0
- data/lib/ace/support/models/atoms/model_filter.rb +107 -0
- data/lib/ace/support/models/atoms/model_name_canonicalizer.rb +119 -0
- data/lib/ace/support/models/atoms/provider_config_reader.rb +218 -0
- data/lib/ace/support/models/atoms/provider_config_writer.rb +230 -0
- data/lib/ace/support/models/cli/commands/cache/clear.rb +43 -0
- data/lib/ace/support/models/cli/commands/cache/diff.rb +74 -0
- data/lib/ace/support/models/cli/commands/cache/status.rb +54 -0
- data/lib/ace/support/models/cli/commands/cache/sync.rb +51 -0
- data/lib/ace/support/models/cli/commands/info.rb +33 -0
- data/lib/ace/support/models/cli/commands/models/cost.rb +54 -0
- data/lib/ace/support/models/cli/commands/models/info.rb +136 -0
- data/lib/ace/support/models/cli/commands/models/search.rb +101 -0
- data/lib/ace/support/models/cli/commands/providers/list.rb +46 -0
- data/lib/ace/support/models/cli/commands/providers/show.rb +54 -0
- data/lib/ace/support/models/cli/commands/providers/sync.rb +66 -0
- data/lib/ace/support/models/cli/commands/search.rb +35 -0
- data/lib/ace/support/models/cli/commands/sync_shortcut.rb +32 -0
- data/lib/ace/support/models/cli/providers_cli.rb +72 -0
- data/lib/ace/support/models/cli.rb +84 -0
- data/lib/ace/support/models/errors.rb +55 -0
- data/lib/ace/support/models/models/diff_result.rb +94 -0
- data/lib/ace/support/models/models/model_info.rb +129 -0
- data/lib/ace/support/models/models/pricing_info.rb +74 -0
- data/lib/ace/support/models/models/provider_info.rb +81 -0
- data/lib/ace/support/models/models.rb +97 -0
- data/lib/ace/support/models/molecules/cache_manager.rb +237 -0
- data/lib/ace/support/models/molecules/cost_calculator.rb +135 -0
- data/lib/ace/support/models/molecules/diff_generator.rb +171 -0
- data/lib/ace/support/models/molecules/model_searcher.rb +176 -0
- data/lib/ace/support/models/molecules/model_validator.rb +177 -0
- data/lib/ace/support/models/molecules/provider_sync_diff.rb +291 -0
- data/lib/ace/support/models/organisms/provider_sync_orchestrator.rb +278 -0
- data/lib/ace/support/models/organisms/sync_orchestrator.rb +108 -0
- data/lib/ace/support/models/version.rb +9 -0
- data/lib/ace/support/models.rb +3 -0
- 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
|