ace-support-models 0.9.3 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff2cc8a12292d741e29e06dcc42ff22ded276d09e4f4cbb49e653f59870b1e31
4
- data.tar.gz: c712f8e10724dff4d283f19da754fd4ae4dbc935a4b3fd19adc243556343f575
3
+ metadata.gz: 90738d0888e9e24b0a22f0febc4cd012472e31598e63f2f6dedcb59111966571
4
+ data.tar.gz: 391d5d2e24473b8e9377603c9ab33f42500397de98616d02ba530117a991787e
5
5
  SHA512:
6
- metadata.gz: 747e10e8bd1dc636c7e52074cba9d34ada6e9222322f0b1106e093c6d077002ea148112199328e24d03dc088a51b21592905f3a972b275a64c5be71922ac4884
7
- data.tar.gz: a5574cafd0bc4e33d5fec6434f4fa9b302147515b59a9599743989edd6092dd4a823d92d395c52d8e2865d18ddaa69ef8946aad1bac58d2cf9e84111604d71a2
6
+ metadata.gz: b0c49eaebca896bf7f5ec8a522b952e187a4c9031efeb839036d01fb3505857d8522aef74ebb9bd20596b526547c967750ccfeaa5bec01b39e598e570feb2a04
7
+ data.tar.gz: 80a8dc203bfa7f0159b9d3056a9c7d0d6ac412a3b57a85f9b361cd5bf4dee161f014f2ff5878d73485018ab235557d0d36d9d6b8e563eb03e933b66ce3f1ce63
data/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-04-24
11
+
12
+ ### Added
13
+ - Synced provider context and output token limits from the models catalog into managed provider YAML via `limits.default` plus per-model overrides.
14
+
15
+ ### Fixed
16
+ - Kept normal provider sync constrained to the tracked model set while still updating limit metadata, and fixed exact-suffix model matching so repeated sync runs stay idempotent.
17
+
18
+ ## [0.11.3] - 2026-04-23
19
+
20
+ ### Technical
21
+ - Kept retained provider smoke coverage on one explicit `XDG_CACHE_HOME` across `sync`, `list`, and `show` so the scenario reads a single deterministic cache root.
22
+
23
+ ## [0.11.2] - 2026-04-16
24
+
25
+ ### Fixed
26
+ - Normalized sync stats calculation for partial and string-keyed provider payloads so `ace-models sync` and `ace-models status` no longer raise when cache/provider entries omit structured model hashes.
27
+
28
+ ## [0.11.1] - 2026-04-16
29
+
30
+ ### Fixed
31
+ - Updated smoke scenario setup to source `mise.toml` from `ACE_E2E_SOURCE_ROOT` inside sandboxed runs and relaxed diff verification to accept the current public diff summary vocabulary.
32
+
33
+ ## [0.11.0] - 2026-04-15
34
+
35
+ ### Changed
36
+ - Reworked `TS-MODELS-001` E2E to public-surface goal contracts by replacing cache hand-seeding with public `ace-models sync` setup, simplifying invalid-filter coverage, and adding cache status/diff goal cases.
37
+ - Added deterministic fixture-driven sync support via `ACE_MODELS_FIXTURE_JSON` / `ACE_MODELS_API_URL` to keep cache-dependent provider E2E flows reproducible without hidden cache-shape recipes.
38
+
39
+ ### Fixed
40
+ - Aligned cache-missing guidance to the flat command surface (`ace-models sync`) across provider/cache CLI errors and executable migration hints.
41
+
42
+ ## [0.10.2] - 2026-04-13
43
+
44
+ ### Fixed
45
+ - **ace-support-models v0.10.2**: Normalized API cache provider and model payloads at read time (including string-array model lists), fixing `ace-llm-providers list`/`show` output from cached responses.
46
+
47
+ ## [0.10.1] - 2026-04-13
48
+
49
+ ### Changed
50
+ - Completed the batch i05 migration follow-through for this package and aligned it with the restarted `fast` / `feat` / `e2e` verification model.
51
+
52
+ ### Technical
53
+ - Included in the coordinated assignment-driven patch release for batch i05 package updates.
54
+
55
+
56
+ ### Changed
57
+ - Migrated package tests to the restarted `fast` / `feat` / `e2e` model by moving deterministic coverage to `test/fast`, relocating legacy integration coverage to `test/feat`, and retaining workflow-value E2E scenario coverage.
58
+ - Updated package README testing guidance and E2E scenario decision metadata to reflect the `fast` / `feat` / `e2e` contract.
59
+
10
60
  ## [0.9.3] - 2026-03-29
11
61
 
12
62
  ### Technical
data/README.md CHANGED
@@ -34,6 +34,27 @@ ace-models sync # Update cache from models.dev
34
34
  ace-llm-providers list # List known providers
35
35
  ```
36
36
 
37
+ ## Testing
38
+
39
+ Use the package test lanes directly:
40
+
41
+ ```bash
42
+ ace-test ace-support-models # fast lane default
43
+ ace-test ace-support-models feat # feature-contract lane
44
+ ace-test ace-support-models all # all deterministic lanes
45
+ ace-test-e2e ace-support-models # retained workflow scenarios
46
+ ```
47
+
48
+ For deterministic E2E setup of cache-dependent provider commands, use the public
49
+ `ace-models sync` path with a fixture payload:
50
+
51
+ ```bash
52
+ export ACE_MODELS_FIXTURE_JSON='{"anthropic":{"models":{"claude-sonnet-4":{"id":"claude-sonnet-4"}}}}'
53
+ ace-models sync
54
+ ace-llm-providers list
55
+ unset ACE_MODELS_FIXTURE_JSON
56
+ ```
57
+
37
58
  ---
38
59
 
39
60
  Part of [ACE](https://github.com/cs3b/ace)
data/exe/ace-models CHANGED
@@ -8,7 +8,7 @@ require_relative "../lib/ace/support/models/cli"
8
8
  old_cache_path = File.join(Dir.home, ".cache", "ace-llm-models-dev")
9
9
  if Dir.exist?(old_cache_path)
10
10
  warn "Note: Old cache detected at ~/.cache/ace-llm-models-dev"
11
- warn "Run: ace-models sync && rm -rf ~/.cache/ace-llm-models-dev"
11
+ warn "Run: ace-models sync then rm -rf ~/.cache/ace-llm-models-dev"
12
12
  end
13
13
 
14
14
  # No args → show help
@@ -10,6 +10,8 @@ module Ace
10
10
  # Fetches data from the models.dev API using Faraday (ADR-010 compliant)
11
11
  class ApiFetcher
12
12
  API_URL = "https://models.dev/api.json"
13
+ FIXTURE_JSON_ENV = "ACE_MODELS_FIXTURE_JSON"
14
+ API_URL_ENV = "ACE_MODELS_API_URL"
13
15
  TIMEOUT = 30
14
16
  OPEN_TIMEOUT = 10
15
17
  MAX_RETRIES = 2
@@ -21,8 +23,12 @@ module Ace
21
23
  # @return [String] Raw JSON response
22
24
  # @raise [NetworkError] on network failures
23
25
  # @raise [ApiError] on non-200 responses
24
- def fetch(url = API_URL)
25
- response = connection.get(url)
26
+ def fetch(url = nil)
27
+ fixture_json = ENV[FIXTURE_JSON_ENV]
28
+ return fixture_json unless fixture_json.to_s.strip.empty?
29
+
30
+ resolved_url = url || ENV[API_URL_ENV] || API_URL
31
+ response = connection.get(resolved_url)
26
32
 
27
33
  unless response.success?
28
34
  raise ApiError.new(
@@ -111,6 +111,24 @@ module Ace
111
111
  end
112
112
  end
113
113
 
114
+ # Extract normalized limit configuration from a provider config.
115
+ # @param config [Hash]
116
+ # @return [Hash] normalized {"default"=>{"context"=>..., "output"=>...}, "models"=>{...}}
117
+ def extract_limits(config)
118
+ limits = config["limits"]
119
+ return {} unless limits.is_a?(Hash)
120
+
121
+ normalized = {}
122
+
123
+ default_limits = normalize_limit_entry(limits["default"])
124
+ normalized["default"] = default_limits if default_limits.any?
125
+
126
+ model_limits = normalize_model_limits(limits["models"])
127
+ normalized["models"] = model_limits if model_limits.any?
128
+
129
+ normalized
130
+ end
131
+
114
132
  # Extract models.dev provider ID from config
115
133
  # Falls back to provider name if not specified
116
134
  # @param config [Hash] Provider config
@@ -138,6 +156,38 @@ module Ace
138
156
 
139
157
  private
140
158
 
159
+ def normalize_model_limits(model_limits)
160
+ return {} unless model_limits.is_a?(Hash)
161
+
162
+ model_limits.each_with_object({}) do |(model, entry), normalized|
163
+ next if model.to_s.strip.empty?
164
+
165
+ normalized_entry = normalize_limit_entry(entry)
166
+ normalized[model.to_s] = normalized_entry if normalized_entry.any?
167
+ end
168
+ end
169
+
170
+ def normalize_limit_entry(entry)
171
+ return {} unless entry.is_a?(Hash)
172
+
173
+ normalized = {}
174
+
175
+ context = normalize_limit_value(entry["context"] || entry[:context])
176
+ output = normalize_limit_value(entry["output"] || entry[:output])
177
+
178
+ normalized["context"] = context unless context.nil?
179
+ normalized["output"] = output unless output.nil?
180
+ normalized
181
+ end
182
+
183
+ def normalize_limit_value(value)
184
+ return nil if value.nil?
185
+
186
+ Integer(value)
187
+ rescue ArgumentError, TypeError
188
+ nil
189
+ end
190
+
141
191
  def project_config_dir
142
192
  # Use ace-core if available and has project_root method
143
193
  if defined?(Ace::Core) && Ace::Core.respond_to?(:project_root)
@@ -18,11 +18,9 @@ module Ace
18
18
  # @return [Boolean] true on success
19
19
  # @raise [ConfigError] on write errors
20
20
  def update_models(path, models)
21
- content = read_file_content(path)
22
- raise ConfigError, "Config file not found: #{path}" unless content
23
-
24
- updated_content = replace_models_section(content, models)
25
- write_file(path, updated_content)
21
+ config = read_config(path)
22
+ config["models"] = Array(models)
23
+ write(path, config)
26
24
  true
27
25
  end
28
26
 
@@ -32,7 +30,7 @@ module Ace
32
30
  # @return [Boolean] true on success
33
31
  def write(path, config)
34
32
  ensure_directory(File.dirname(path))
35
- content = YAML.dump(config)
33
+ content = format_config(config)
36
34
  write_file(path, content)
37
35
  true
38
36
  end
@@ -55,11 +53,9 @@ module Ace
55
53
  # @return [Boolean] true on success
56
54
  # @raise [ConfigError] on write errors
57
55
  def update_last_synced(path, date = Date.today)
58
- content = read_file_content(path)
59
- raise ConfigError, "Config file not found: #{path}" unless content
60
-
61
- updated_content = replace_or_add_field(content, "last_synced", date.to_s)
62
- write_file(path, updated_content)
56
+ config = read_config(path)
57
+ config["last_synced"] = date
58
+ write(path, config)
63
59
  true
64
60
  end
65
61
 
@@ -68,29 +64,30 @@ module Ace
68
64
  # @param models [Array<String>] New list of model IDs
69
65
  # @param date [Date] Date to set for last_synced
70
66
  # @return [Boolean] true on success
71
- def update_models_and_sync_date(path, models, date = Date.today)
72
- content = read_file_content(path)
73
- raise ConfigError, "Config file not found: #{path}" unless content
67
+ def update_models_and_sync_date(path, models, date = Date.today, limits: nil)
68
+ config = read_config(path)
69
+ config["models"] = Array(models)
70
+ config["last_synced"] = date
71
+
72
+ unless limits.nil?
73
+ normalized_limits = normalize_limits(limits)
74
+ if normalized_limits.empty?
75
+ config.delete("limits")
76
+ else
77
+ config["limits"] = normalized_limits
78
+ end
79
+ config.delete("context_limit")
80
+ end
74
81
 
75
- updated_content = replace_models_section(content, models)
76
- updated_content = replace_or_add_field(updated_content, "last_synced", date.to_s)
77
- write_file(path, updated_content)
82
+ write(path, config)
78
83
  true
79
84
  end
80
85
 
81
86
  private
82
87
 
83
- def read_file_content(path)
84
- return nil unless File.exist?(path)
85
-
86
- File.read(path)
87
- rescue Errno::EACCES => e
88
- raise ConfigError, "Permission denied reading #{path}: #{e.message}"
89
- end
90
-
91
88
  def write_file(path, content)
92
89
  # Validate YAML before writing to catch regex manipulation errors
93
- YAML.safe_load(content, permitted_classes: [Symbol, Date])
90
+ YAML.safe_load(content, permitted_classes: [Symbol, Date], aliases: true)
94
91
  File.write(path, content)
95
92
  rescue Psych::SyntaxError => e
96
93
  raise ConfigError, "Generated invalid YAML for #{path}: #{e.message}"
@@ -108,119 +105,98 @@ module Ace
108
105
  raise ConfigError, "Permission denied creating directory #{dir}: #{e.message}"
109
106
  end
110
107
 
111
- # Replace the models section in YAML content while preserving structure
112
- # @param content [String] Original YAML content
113
- # @param models [Array<String>] New models list
114
- # @return [String] Updated content
115
- # @raise [ConfigError] if unsupported YAML styles are detected
116
- def replace_models_section(content, models)
117
- # Check for flow-style arrays which are not supported
118
- if /^\s*models:\s*\[/m.match?(content)
119
- raise ConfigError, "Flow-style arrays (models: [...]) are not supported for auto-update. " \
120
- "Please convert to block style (models: followed by list items)."
121
- end
108
+ def read_config(path)
109
+ raise ConfigError, "Config file not found: #{path}" unless File.exist?(path)
122
110
 
123
- # Check for inline comments on models: line which are not preserved
124
- if /^\s*models:\s*#/m.match?(content)
125
- raise ConfigError, "Inline comments on 'models:' line (e.g., 'models: # comment') are not supported. " \
126
- "Please move the comment to a separate line above 'models:'."
127
- end
111
+ YAML.safe_load(File.read(path), permitted_classes: [Symbol, Date], aliases: true) || {}
112
+ rescue Errno::EACCES => e
113
+ raise ConfigError, "Permission denied reading #{path}: #{e.message}"
114
+ rescue Psych::SyntaxError => e
115
+ raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
116
+ end
128
117
 
129
- lines = content.lines
130
- result = []
131
- in_models_section = false
132
- models_base_indent = 0
133
-
134
- lines.each do |line|
135
- # Detect start of models section (with or without items on same line)
136
- if line =~ /^(\s*)models:\s*$/
137
- in_models_section = true
138
- models_base_indent = $1.length
139
- result << line
140
-
141
- # Add new models with standard YAML indent
142
- models.each do |model|
143
- result << "#{" " * (models_base_indent + 2)}- #{model}\n"
144
- end
145
- next
146
- end
118
+ def normalize_limits(limits)
119
+ return {} unless limits.is_a?(Hash)
147
120
 
148
- # If in models section, skip old model items
149
- if in_models_section
150
- # Check if this line is a list item (model entry)
151
- if line =~ /^(\s*)-\s+/
152
- item_indent = $1.length
153
- # Skip if it's at the expected indent for models (base + 0 or base + 2)
154
- if item_indent == models_base_indent || item_indent == models_base_indent + 2
155
- next
156
- end
157
- end
158
-
159
- # Empty line - keep but stay in models section
160
- if line.strip.empty?
161
- result << line
162
- next
163
- end
164
-
165
- # A new key at base level (not indented more) ends models section
166
- if line =~ /^(\s*)\S/
167
- current_indent = $1.length
168
- if current_indent <= models_base_indent
169
- in_models_section = false
170
- result << line
171
- end
172
- # Otherwise skip (shouldn't happen for well-formed YAML)
173
- end
174
- next
175
- end
121
+ normalized = {}
176
122
 
177
- result << line
178
- end
123
+ default_limits = normalize_limit_entry(limits["default"] || limits[:default])
124
+ normalized["default"] = default_limits if default_limits.any?
179
125
 
180
- result.join
126
+ models = limits["models"] || limits[:models]
127
+ normalized_models = normalize_model_limits(models)
128
+ normalized["models"] = normalized_models if normalized_models.any?
129
+
130
+ normalized
181
131
  end
182
132
 
183
- # Replace or add a field in YAML content
184
- # @param content [String] Original YAML content
185
- # @param field_name [String] Field name to replace or add
186
- # @param value [String] New value
187
- # @return [String] Updated content
188
- def replace_or_add_field(content, field_name, value)
189
- lines = content.lines
190
-
191
- # Try to find and replace existing field
192
- field_found = false
193
- result = lines.map do |line|
194
- if line =~ /^(\s*)#{Regexp.escape(field_name)}:\s*(.*)$/
195
- field_found = true
196
- "#{$1}#{field_name}: #{value}\n"
197
- else
198
- line
199
- end
133
+ def normalize_model_limits(models)
134
+ return {} unless models.is_a?(Hash)
135
+
136
+ models.each_with_object({}) do |(model, entry), normalized|
137
+ next if model.to_s.strip.empty?
138
+
139
+ normalized_entry = normalize_limit_entry(entry)
140
+ normalized[model.to_s] = normalized_entry if normalized_entry.any?
141
+ end.sort.to_h
142
+ end
143
+
144
+ def normalize_limit_entry(entry)
145
+ return {} unless entry.is_a?(Hash)
146
+
147
+ normalized = {}
148
+
149
+ context = normalize_limit_value(entry["context"] || entry[:context])
150
+ output = normalize_limit_value(entry["output"] || entry[:output])
151
+
152
+ normalized["context"] = context unless context.nil?
153
+ normalized["output"] = output unless output.nil?
154
+ normalized
155
+ end
156
+
157
+ def normalize_limit_value(value)
158
+ return nil if value.nil?
159
+
160
+ Integer(value)
161
+ rescue ArgumentError, TypeError
162
+ nil
163
+ end
164
+
165
+ def format_config(config)
166
+ YAML.dump(canonicalize_config(config)).sub(/\A---\n/, "")
167
+ end
168
+
169
+ def canonicalize_config(config)
170
+ config = config.dup
171
+ config.delete("_source_file")
172
+
173
+ ordered = {}
174
+ preferred_order = %w[
175
+ name
176
+ last_synced
177
+ class
178
+ gem
179
+ models_dev_id
180
+ models
181
+ limits
182
+ aliases
183
+ api_key
184
+ capabilities
185
+ default_options
186
+ backends
187
+ endpoint
188
+ version
189
+ ]
190
+
191
+ preferred_order.each do |key|
192
+ ordered[key] = config.delete(key) if config.key?(key)
200
193
  end
201
194
 
202
- # If field not found, add it after the name or first line
203
- unless field_found
204
- insert_index = 0
205
- result.each_with_index do |line, idx|
206
- if /^name:/.match?(line)
207
- insert_index = idx + 1
208
- break
209
- end
210
- end
211
- # If no name field, add after first non-comment, non-blank line
212
- if insert_index == 0
213
- result.each_with_index do |line, idx|
214
- next if line.strip.empty? || line.strip.start_with?("#") || line.strip.start_with?("---")
215
-
216
- insert_index = idx + 1
217
- break
218
- end
219
- end
220
- result.insert(insert_index, "#{field_name}: #{value}\n")
195
+ config.each do |key, value|
196
+ ordered[key] = value
221
197
  end
222
198
 
223
- result.join
199
+ ordered
224
200
  end
225
201
  end
226
202
  end
@@ -25,7 +25,7 @@ module Ace
25
25
  end
26
26
 
27
27
  unless status_data[:cached]
28
- raise Ace::Support::Cli::Error.new("No cache data. Run 'ace-models cache sync' first.")
28
+ raise Ace::Support::Cli::Error.new("No cache data. Run 'ace-models sync' first.")
29
29
  end
30
30
 
31
31
  puts "Cache Status:"
@@ -20,7 +20,7 @@ module Ace
20
20
  cache_manager = Molecules::CacheManager.new
21
21
 
22
22
  unless cache_manager.cached?
23
- raise Ace::Support::Cli::Error.new("No cache data. Run 'ace-models cache sync' first.")
23
+ raise Ace::Support::Cli::Error.new("No cache data. Run 'ace-models sync' first.")
24
24
  end
25
25
 
26
26
  providers = cache_manager.list_providers
@@ -21,7 +21,7 @@ module Ace
21
21
  cache_manager = Molecules::CacheManager.new
22
22
 
23
23
  unless cache_manager.cached?
24
- raise Ace::Support::Cli::Error.new("No cache data. Run 'ace-models cache sync' first.")
24
+ raise Ace::Support::Cli::Error.new("No cache data. Run 'ace-models sync' first.")
25
25
  end
26
26
 
27
27
  provider_data = cache_manager.get_provider(provider_id)
@@ -53,7 +53,7 @@ module Ace
53
53
  puts orchestrator.format_result(result)
54
54
  end
55
55
  rescue CacheError => e
56
- raise Ace::Support::Cli::Error.new("#{e.message}. Run 'ace-models cache sync' first to download model data.")
56
+ raise Ace::Support::Cli::Error.new("#{e.message}. Run 'ace-models sync' first to download model data.")
57
57
  rescue ConfigError => e
58
58
  raise Ace::Support::Cli::Error.new("Config error: #{e.message}")
59
59
  end
@@ -26,7 +26,8 @@ module Ace
26
26
  content = Atoms::FileReader.read(api_cache_path)
27
27
  return nil unless content
28
28
 
29
- Atoms::JsonParser.parse(content)
29
+ data = Atoms::JsonParser.parse(content)
30
+ normalize_cache_data(data)
30
31
  end
31
32
 
32
33
  # Read previous API cache (for diff)
@@ -35,7 +36,8 @@ module Ace
35
36
  content = Atoms::FileReader.read(previous_cache_path)
36
37
  return nil unless content
37
38
 
38
- Atoms::JsonParser.parse(content)
39
+ data = Atoms::JsonParser.parse(content)
40
+ normalize_cache_data(data)
39
41
  end
40
42
 
41
43
  # Write API data to cache
@@ -197,6 +199,17 @@ module Ace
197
199
  data
198
200
  end
199
201
 
202
+ def normalize_cache_data(data)
203
+ normalized = normalize_providers(data)
204
+ normalized.each_value do |provider_data|
205
+ next unless provider_data.is_a?(Hash)
206
+
207
+ provider_data["models"] = normalize_models(provider_data)
208
+ end
209
+
210
+ normalized
211
+ end
212
+
200
213
  def normalize_provider_collection(providers)
201
214
  case providers
202
215
  when Hash
@@ -205,7 +218,7 @@ module Ace
205
218
  providers.each_with_object({}) do |provider, acc|
206
219
  next unless provider.is_a?(Hash)
207
220
 
208
- provider_id = provider["id"]
221
+ provider_id = provider["id"] || provider[:id] || provider["provider_id"]
209
222
  acc[provider_id] = provider if provider_id
210
223
  end
211
224
  else
@@ -221,10 +234,18 @@ module Ace
221
234
  models
222
235
  when Array
223
236
  models.each_with_object({}) do |model, acc|
224
- next unless model.is_a?(Hash)
225
-
226
- model_id = model["id"]
227
- acc[model_id] = model if model_id
237
+ next unless model
238
+
239
+ case model
240
+ when Hash
241
+ model_id = model["id"] || model[:id]
242
+ acc[model_id] = model if model_id
243
+ when String
244
+ acc[model] = {"id" => model, "name" => model}
245
+ when Symbol
246
+ model_id = model.to_s
247
+ acc[model_id] = {"id" => model_id, "name" => model_id}
248
+ end
228
249
  end
229
250
  else
230
251
  {}
@@ -81,11 +81,11 @@ module Ace
81
81
 
82
82
  # Build a mapping of canonical names to original names for current models
83
83
  # This handles cases like "model:nitro" -> "model"
84
- current_canonical_to_original = {}
84
+ current_lookup = Set.new
85
85
  current_models.each do |model_id|
86
+ current_lookup << model_id
86
87
  canonical = Atoms::ModelNameCanonicalizer.canonicalize(model_id, provider: provider_name)
87
- current_canonical_to_original[canonical] ||= []
88
- current_canonical_to_original[canonical] << model_id
88
+ current_lookup << canonical
89
89
  end
90
90
 
91
91
  added = []
@@ -98,7 +98,7 @@ module Ace
98
98
  # Use canonical names for matching
99
99
  models_dev_models.each do |model_id, model_data|
100
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)
101
+ has_match = current_lookup.include?(model_id)
102
102
 
103
103
  if has_match
104
104
  if model_data[:status] == "deprecated"
@@ -126,11 +126,16 @@ module Ace
126
126
  # Use canonical names to avoid false positives for suffixed models
127
127
  current_models.each do |model_id|
128
128
  canonical = Atoms::ModelNameCanonicalizer.canonicalize(model_id, provider: provider_name)
129
- unless models_dev_canonical.include?(canonical)
129
+ unless models_dev_canonical.include?(model_id) || models_dev_canonical.include?(canonical)
130
130
  removed << model_id
131
131
  end
132
132
  end
133
133
 
134
+ desired_models = (current_models.to_a + added - removed).uniq.sort
135
+ desired_limits = build_desired_limits(desired_models, models_dev_models, provider_name: provider_name)
136
+ current_limits = Atoms::ProviderConfigReader.extract_limits(config)
137
+ limits_changed = current_limits != desired_limits
138
+
134
139
  {
135
140
  status: :ok,
136
141
  added: added.sort,
@@ -138,6 +143,8 @@ module Ace
138
143
  removed: removed.sort,
139
144
  unchanged: unchanged.sort,
140
145
  deprecated: deprecated.sort,
146
+ desired_limits: desired_limits,
147
+ limits_changed: limits_changed,
141
148
  models_dev_count: models_dev_models.size,
142
149
  current_count: current_models.size,
143
150
  filtered_by_date: !since_date.nil?
@@ -152,6 +159,7 @@ module Ace
152
159
  total_removed = 0
153
160
  total_unchanged = 0
154
161
  total_deprecated = 0
162
+ total_limit_updates = 0
155
163
  providers_synced = 0
156
164
  providers_skipped = 0
157
165
 
@@ -162,6 +170,7 @@ module Ace
162
170
  total_removed += result[:removed].size
163
171
  total_unchanged += result[:unchanged].size
164
172
  total_deprecated += result[:deprecated].size
173
+ total_limit_updates += 1 if result[:limits_changed]
165
174
  else
166
175
  providers_skipped += 1
167
176
  end
@@ -172,6 +181,7 @@ module Ace
172
181
  removed: total_removed,
173
182
  unchanged: total_unchanged,
174
183
  deprecated: total_deprecated,
184
+ limit_updates: total_limit_updates,
175
185
  providers_synced: providers_synced,
176
186
  providers_skipped: providers_skipped
177
187
  }
@@ -184,7 +194,7 @@ module Ace
184
194
  results.any? do |_provider, result|
185
195
  next false unless result[:status] == :ok
186
196
 
187
- result[:added].any? || result[:removed].any?
197
+ result[:added].any? || result[:removed].any? || result[:limits_changed]
188
198
  end
189
199
  end
190
200
 
@@ -223,10 +233,13 @@ module Ace
223
233
 
224
234
  models.each do |model_id, model_data|
225
235
  release_date = parse_date(model_data["release_date"])
236
+ limit = model_data["limit"] || {}
226
237
  result[model_id] = {
227
238
  status: model_data["status"],
228
239
  name: model_data["name"] || model_id,
229
- release_date: release_date
240
+ release_date: release_date,
241
+ context_limit: limit["context"],
242
+ output_limit: limit["output"]
230
243
  }
231
244
  end
232
245
 
@@ -256,6 +269,63 @@ module Ace
256
269
  Atoms::ProviderConfigReader.extract_last_synced(config)
257
270
  end
258
271
 
272
+ def build_desired_limits(models, models_dev_models, provider_name:)
273
+ limit_entries = models.each_with_object({}) do |model_id, acc|
274
+ entry = extract_limit_entry(model_id, models_dev_models, provider_name: provider_name)
275
+ acc[model_id] = entry if entry.any?
276
+ end
277
+
278
+ return {} if limit_entries.empty?
279
+
280
+ default_pair = select_default_limit_pair(limit_entries.values)
281
+ default_limits = {}
282
+ unless default_pair.nil?
283
+ default_limits["context"] = default_pair[0]
284
+ default_limits["output"] = default_pair[1]
285
+ end
286
+
287
+ overrides = limit_entries.each_with_object({}) do |(model_id, entry), acc|
288
+ override = entry.dup
289
+ override.delete("context") if default_limits["context"] && override["context"] == default_limits["context"]
290
+ override.delete("output") if default_limits["output"] && override["output"] == default_limits["output"]
291
+ acc[model_id] = override if override.any?
292
+ end
293
+
294
+ desired = {}
295
+ desired["default"] = default_limits if default_limits.any?
296
+ desired["models"] = overrides.sort.to_h if overrides.any?
297
+ desired
298
+ end
299
+
300
+ def select_default_limit_pair(entries)
301
+ counts = Hash.new(0)
302
+
303
+ entries.each do |entry|
304
+ context = entry["context"]
305
+ output = entry["output"]
306
+ next if context.nil? || output.nil?
307
+
308
+ counts[[context, output]] += 1
309
+ end
310
+
311
+ counts.max_by { |(context, output), count| [count, context, output] }&.first
312
+ end
313
+
314
+ def extract_limit_entry(model_id, models_dev_models, provider_name:)
315
+ model_data = lookup_model_data(model_id, models_dev_models, provider_name: provider_name)
316
+ return {} unless model_data
317
+
318
+ entry = {}
319
+ entry["context"] = model_data[:context_limit] unless model_data[:context_limit].nil?
320
+ entry["output"] = model_data[:output_limit] unless model_data[:output_limit].nil?
321
+ entry
322
+ end
323
+
324
+ def lookup_model_data(model_id, models_dev_models, provider_name:)
325
+ models_dev_models[model_id] ||
326
+ models_dev_models[Atoms::ModelNameCanonicalizer.canonicalize(model_id, provider: provider_name)]
327
+ end
328
+
259
329
  def suggest_models_dev_id(models_dev_data, provider_name)
260
330
  # Try to find a provider that might match
261
331
  provider_lower = provider_name.downcase
@@ -108,11 +108,15 @@ module Ace
108
108
  lines << "Summary: #{summary[:added]} added, #{summary[:removed]} removed, " \
109
109
  "#{summary[:unchanged]} unchanged across #{summary[:providers_synced]} providers"
110
110
 
111
- if summary[:deprecated] > 0
111
+ if summary[:deprecated].to_i > 0
112
112
  lines << " (#{summary[:deprecated]} deprecated models flagged)"
113
113
  end
114
114
 
115
- if summary[:providers_skipped] > 0
115
+ if summary[:limit_updates].to_i > 0
116
+ lines << " (#{summary[:limit_updates]} provider limit configs need updates)"
117
+ end
118
+
119
+ if summary[:providers_skipped].to_i > 0
116
120
  lines << " (#{summary[:providers_skipped]} providers not found in models.dev)"
117
121
  end
118
122
 
@@ -171,7 +175,7 @@ module Ace
171
175
 
172
176
  diff_results.each do |provider_name, diff|
173
177
  next unless diff[:status] == :ok
174
- next if diff[:added].empty? && diff[:removed].empty?
178
+ next if diff[:added].empty? && diff[:removed].empty? && !diff[:limits_changed]
175
179
 
176
180
  begin
177
181
  config = current_configs[provider_name]
@@ -191,7 +195,11 @@ module Ace
191
195
  Atoms::ProviderConfigWriter.backup(source_file)
192
196
 
193
197
  # Update file with models and last_synced date
194
- Atoms::ProviderConfigWriter.update_models_and_sync_date(source_file, new_models)
198
+ Atoms::ProviderConfigWriter.update_models_and_sync_date(
199
+ source_file,
200
+ new_models,
201
+ limits: diff[:desired_limits]
202
+ )
195
203
  output.puts "Updated: #{source_file}"
196
204
  rescue ConfigError => e
197
205
  errors << "#{provider_name}: #{e.message}"
@@ -238,7 +246,7 @@ module Ace
238
246
  return lines.join("\n")
239
247
  end
240
248
 
241
- if diff[:added].empty? && diff[:removed].empty? && diff[:deprecated].empty?
249
+ if diff[:added].empty? && diff[:removed].empty? && diff[:deprecated].empty? && !diff[:limits_changed]
242
250
  lines << " = (no changes)"
243
251
  if diff[:last_synced]
244
252
  lines << " Last synced: #{diff[:last_synced]}"
@@ -259,6 +267,21 @@ module Ace
259
267
  lines << " - #{model.ljust(35)} (removed)"
260
268
  end
261
269
 
270
+ if diff[:limits_changed]
271
+ default_limits = diff.dig(:desired_limits, "default") || {}
272
+ default_parts = []
273
+ default_parts << "context=#{format_number(default_limits["context"])}" if default_limits["context"]
274
+ default_parts << "output=#{format_number(default_limits["output"])}" if default_limits["output"]
275
+ unless default_parts.empty?
276
+ lines << " ~ default limits -> #{default_parts.join(', ')}"
277
+ end
278
+
279
+ override_models = diff.dig(:desired_limits, "models")&.keys || []
280
+ unless override_models.empty?
281
+ lines << " ~ per-model limit overrides: #{override_models.join(', ')}"
282
+ end
283
+ end
284
+
262
285
  diff[:deprecated].each do |model|
263
286
  lines << " ! #{model.ljust(35)} (deprecated)"
264
287
  end
@@ -271,6 +294,10 @@ module Ace
271
294
 
272
295
  lines.join("\n")
273
296
  end
297
+
298
+ def format_number(value)
299
+ value.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')
300
+ end
274
301
  end
275
302
  end
276
303
  end
@@ -90,7 +90,8 @@ module Ace
90
90
  models_by_provider = {}
91
91
 
92
92
  data.each do |provider_id, provider_data|
93
- count = (provider_data["models"] || {}).size
93
+ models = provider_models(provider_data)
94
+ count = models.size
94
95
  models_by_provider[provider_id] = count
95
96
  model_count += count
96
97
  end
@@ -101,6 +102,13 @@ module Ace
101
102
  top_providers: models_by_provider.sort_by { |_, v| -v }.first(10).to_h
102
103
  }
103
104
  end
105
+
106
+ def provider_models(provider_data)
107
+ return {} unless provider_data.is_a?(Hash)
108
+
109
+ models = provider_data["models"] || provider_data[:models]
110
+ models.is_a?(Hash) ? models : {}
111
+ end
104
112
  end
105
113
  end
106
114
  end
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Support
5
5
  module Models
6
- VERSION = '0.9.3'
6
+ VERSION = "0.12.0"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-support-models
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.3
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-29 00:00:00.000000000 Z
10
+ date: 2026-04-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-core