ace-support-models 0.12.3 → 0.12.4

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: 18ccfa94547ed745190987b958199c2f5fa781aa49099d4bd548a219b71f1843
4
- data.tar.gz: 8154d0a3c1757220d9f3f4ee42e46e2286db77d2b7ec7fd1ca912237ba1c3364
3
+ metadata.gz: 0336e5432d5309f300b08f03ff530b23f70a7e5c865aa2eb863d86e7ef87ed99
4
+ data.tar.gz: 75183c42ed84226a56f99e48c5e93accb2a8d4f29fa97c1970aa8374c14fe35d
5
5
  SHA512:
6
- metadata.gz: e7b84db686146f09f5b8e1208d5f74c6202fb56829df9a8cf560810a4a100ebb865945a309884c9d20a1eaaa7b155d0920cc4e88cfc6f6cf87b54135990d1e5b
7
- data.tar.gz: b6cb44444b31971ee881e4efe0cd90b99f06ef2da7313dff34f995563cde547e68b1e097f9d26112dc9b1f07bdf46c54f986b2443322418b5af3fdd022b4f736
6
+ metadata.gz: 8509d9953bcae94e7b556fabb82704a5c356a3996c1c8b8eaf63f2433c84514abfe19812244b496b8ef960e9d8221a2f4dc19c0aaf9dad5d354cffd0146df85d
7
+ data.tar.gz: 4c8a078145ceb0f650ae3268ac641f48aa57f2cdf411c18c9db92d505174f2eda9a3b27a4dafccd3403d8a27ea7d2b943e9532ca80feff3b201d54f781571e53
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.4] - 2026-09-13
11
+
12
+ ### Fixed
13
+
14
+ - Preserve Codex local selections, removals, aliases, limits and default order during provider sync; use the bundled native catalog instead of treating models.dev absence as retirement.
15
+ - Discover provider defaults from their owning gems and prevent sync writes into bundled defaults.
16
+ - Persist observed Codex catalog and selection history atomically, keep unknown offers unapplied, and scope sync commits to changed provider files.
17
+
10
18
 
11
19
  ## [0.12.3] - 2026-09-02
12
20
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Support
8
+ module Models
9
+ module Atoms
10
+ # Reconciles an observed local selection with a bundled native catalog.
11
+ # Missing entries on first observation carry no evidence of consent.
12
+ class ProviderCatalogReconciler
13
+ def self.reconcile(config, catalog)
14
+ local = ProviderConfigReader.extract_models(config)
15
+ offered = ProviderConfigReader.extract_models(catalog)
16
+ previous = config["sync_state"]
17
+ previous = nil unless valid_state?(previous)
18
+ removed = previous ? (previous["removed"] + previous["accepted"] - local).uniq : []
19
+ unknown = previous ? previous["unknown"] - local : offered - local
20
+ added = previous ? offered - previous["catalog"] - local - removed - unknown : []
21
+ models = local + added
22
+ state = {
23
+ "catalog" => offered,
24
+ "catalog_digest" => Digest::SHA256.hexdigest(JSON.generate(catalog)),
25
+ "accepted" => models.dup,
26
+ "unknown" => unknown,
27
+ "removed" => removed - local
28
+ }
29
+ desired = config.reject { |key, _| key.start_with?("_source_") }
30
+ # Preserve hash-form local model metadata, as well as order/default.
31
+ desired["models"] = if config["models"].is_a?(Hash)
32
+ config["models"].merge(added.to_h { |id| [id, {}] })
33
+ else
34
+ models
35
+ end
36
+ desired["sync_state"] = state
37
+ {
38
+ config: desired, added: added, offered: (unknown & offered),
39
+ removed: state["removed"], changed: desired != config.reject { |key, _| key.start_with?("_source_") }
40
+ }
41
+ end
42
+
43
+ def self.valid_state?(state)
44
+ state.is_a?(Hash) && state["catalog_digest"].is_a?(String) &&
45
+ %w[catalog accepted unknown removed].all? do |key|
46
+ state[key].is_a?(Array) && state[key].all? { |id| id.is_a?(String) }
47
+ end
48
+ end
49
+ private_class_method :valid_state?
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -11,7 +11,7 @@ module Ace
11
11
  # Reads provider configuration files with cascade support:
12
12
  # - Project: .ace/llm/providers/
13
13
  # - User: ~/.ace/llm/providers/
14
- # - Gem: ace-llm/.ace-defaults/llm/providers/ (single source of truth)
14
+ # - Gem: owning packages' .ace-defaults/llm/providers/
15
15
  class ProviderConfigReader
16
16
  class << self
17
17
  # Find all provider config directories in cascade order
@@ -32,8 +32,7 @@ module Ace
32
32
  dirs << user_dir if user_dir && Dir.exist?(user_dir)
33
33
 
34
34
  # Gem-level config (ace-llm/providers/)
35
- gem_dir = gem_config_dir
36
- dirs << gem_dir if gem_dir && Dir.exist?(gem_dir)
35
+ dirs.concat(bundled_config_directories)
37
36
  end
38
37
 
39
38
  dirs
@@ -43,7 +42,7 @@ module Ace
43
42
  # @param config_dir [String, nil] Override config directory
44
43
  # @return [String, nil] Writable directory or nil
45
44
  def writable_config_directory(config_dir: nil)
46
- dirs = config_directories(config_dir: config_dir)
45
+ dirs = config_directories(config_dir: config_dir) - bundled_config_directories
47
46
  dirs.find { |dir| writable?(dir) }
48
47
  end
49
48
 
@@ -63,6 +62,25 @@ module Ace
63
62
  configs
64
63
  end
65
64
 
65
+ # Package defaults are sources, never sync destinations. Discover via
66
+ # gem metadata without loading provider clients or reversing dependencies.
67
+ def bundled_config_directories
68
+ Gem::Specification.latest_specs.filter_map do |spec|
69
+ next unless spec.name.start_with?("ace-")
70
+
71
+ path = File.join(spec.full_gem_path, ".ace-defaults", "llm", "providers")
72
+ path if Dir.exist?(path)
73
+ end.sort
74
+ end
75
+
76
+ def bundled_config(name)
77
+ bundled_config_directories.each do |dir|
78
+ config = read_file(File.join(dir, "#{name}.yml"))
79
+ return config if config
80
+ end
81
+ nil
82
+ end
83
+
66
84
  # Read provider configs from a specific directory
67
85
  # @param dir [String] Directory path
68
86
  # @return [Hash<String, Hash>] Provider name => config hash
@@ -206,25 +224,6 @@ module Ace
206
224
  File.join(home, ".ace", "llm", "providers")
207
225
  end
208
226
 
209
- def gem_config_dir
210
- # Find ace-llm gem's providers directory (from .ace-defaults/ - single source of truth)
211
- if defined?(Ace::LLM)
212
- # Try to find via gem spec
213
- spec = begin
214
- Gem::Specification.find_by_name("ace-llm")
215
- rescue
216
- nil
217
- end
218
- return File.join(spec.gem_dir, ".ace-defaults", "llm", "providers") if spec
219
- end
220
-
221
- # Fallback: look relative to this gem in mono-repo development
222
- # This enables development without installing ace-llm as a gem.
223
- # Production/installed gem use goes through Gem::Specification above.
224
- ace_llm_path = find_ace_llm_path
225
- File.join(ace_llm_path, ".ace-defaults", "llm", "providers") if ace_llm_path
226
- end
227
-
228
227
  def find_project_root_dir
229
228
  dir = Dir.pwd
230
229
  while dir != "/"
@@ -236,16 +235,6 @@ module Ace
236
235
  nil
237
236
  end
238
237
 
239
- def find_ace_llm_path
240
- # Look in common locations relative to this gem
241
- candidates = [
242
- File.expand_path("../../../../../../ace-llm", __FILE__),
243
- File.expand_path("../../../../../../../ace-llm", __FILE__)
244
- ]
245
-
246
- candidates.find { |path| Dir.exist?(path) }
247
- end
248
-
249
238
  def writable?(dir)
250
239
  return false unless Dir.exist?(dir)
251
240
 
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
  require "fileutils"
5
5
  require "date"
6
+ require "tempfile"
6
7
 
7
8
  module Ace
8
9
  module Support
@@ -12,6 +13,24 @@ module Ace
12
13
  # Updates only the models: section while keeping other fields intact
13
14
  class ProviderConfigWriter
14
15
  class << self
16
+ # Replace config and provenance together; reject edits made after preview.
17
+ def replace(path, config, expected:)
18
+ raise ConfigError, "Config changed during sync: #{path}" unless read_config(path) == expected
19
+
20
+ content = format_config(config)
21
+ Tempfile.create([".provider-sync-", ".yml"], File.dirname(path)) do |file|
22
+ file.chmod(File.stat(path).mode & 0o777)
23
+ file.write(content)
24
+ file.flush
25
+ file.fsync
26
+ raise ConfigError, "Config changed during sync: #{path}" unless read_config(path) == expected
27
+
28
+ backup(path)
29
+ File.rename(file.path, path)
30
+ end
31
+ true
32
+ end
33
+
15
34
  # Update the models list in a provider config file
16
35
  # @param path [String] Path to config file
17
36
  # @param models [Array<String>] New list of model IDs
@@ -8,13 +8,13 @@ module Ace
8
8
  module CLI
9
9
  module Commands
10
10
  module Providers
11
- # Sync provider YAML configs with models.dev
11
+ # Sync provider YAML configs with native catalogs or models.dev.
12
12
  class Sync < Ace::Support::Cli::Command
13
13
  include Ace::Support::Cli::Base
14
14
 
15
- desc "Sync provider YAML configs with models.dev"
15
+ desc "Sync provider YAML configs with bundled catalogs and models.dev"
16
16
 
17
- option :apply, type: :boolean, desc: "Apply changes to config files (default: dry-run)"
17
+ option :apply, type: :boolean, desc: "Apply eligible changes; unresolved Codex offers require explicit config"
18
18
  option :commit, type: :boolean, desc: "Commit changes via ace-git-commit"
19
19
  option :provider, type: :string, aliases: ["-p"], desc: "Sync specific provider only"
20
20
  option :config_dir, type: :string, desc: "Target config directory"
@@ -47,6 +47,10 @@ module Ace
47
47
  raise Ace::Support::Cli::Error.new(result[:message])
48
48
  end
49
49
 
50
+ if result[:apply_errors]&.any?
51
+ raise Ace::Support::Cli::Error.new(result[:apply_errors].join("; "))
52
+ end
53
+
50
54
  if options[:json]
51
55
  puts JSON.pretty_generate(result)
52
56
  else
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "date"
4
+ require_relative "../atoms/provider_catalog_reconciler"
4
5
 
5
6
  module Ace
6
7
  module Support
@@ -15,12 +16,13 @@ module Ace
15
16
  UNCHANGED = :unchanged
16
17
  DEPRECATED = :deprecated
17
18
 
18
- attr_reader :cache_manager
19
+ attr_reader :cache_manager, :desired_configs
19
20
 
20
21
  # Initialize diff generator
21
22
  # @param cache_manager [CacheManager, nil] Cache manager for models.dev data
22
23
  def initialize(cache_manager: nil)
23
24
  @cache_manager = cache_manager || CacheManager.new
25
+ @desired_configs = {}
24
26
  end
25
27
 
26
28
  # Generate diff for all providers
@@ -30,13 +32,20 @@ module Ace
30
32
  # @param show_all [Boolean] Show all models regardless of date (ignores since_date)
31
33
  # @return [Hash] Diff results by provider
32
34
  def generate(current_configs, provider_filter: nil, since_date: nil, show_all: false)
33
- models_dev_data = load_models_dev_data
35
+ selected = current_configs.select { |name, _| !provider_filter || name == provider_filter }
36
+ models_dev_data = (selected.keys.all? { |name| name == "codex" }) ? {} : load_models_dev_data
37
+ @desired_configs = {}
34
38
  results = {}
35
39
 
36
40
  current_configs.each do |provider_name, config|
37
41
  # Skip if filtering and this isn't the target provider
38
42
  next if provider_filter && provider_name != provider_filter
39
43
 
44
+ if provider_name == "codex"
45
+ results[provider_name] = diff_provider(config, nil, provider_name: provider_name)
46
+ next
47
+ end
48
+
40
49
  # Determine the models.dev ID to use (may be mapped via models_dev_id field)
41
50
  models_dev_id = Atoms::ProviderConfigReader.extract_models_dev_id(config)
42
51
 
@@ -73,6 +82,8 @@ module Ace
73
82
  # @param provider_name [String, nil] Provider name for canonicalization (e.g., "openrouter")
74
83
  # @return [Hash] Diff result
75
84
  def diff_provider(config, provider_data, since_date: nil, provider_name: nil)
85
+ return diff_bundled_codex(config) if provider_name == "codex" || config["name"] == "codex"
86
+
76
87
  current_models = Set.new(Atoms::ProviderConfigReader.extract_models(config))
77
88
  models_dev_models = extract_models_dev_models(provider_data)
78
89
 
@@ -162,10 +173,15 @@ module Ace
162
173
  total_limit_updates = 0
163
174
  providers_synced = 0
164
175
  providers_skipped = 0
176
+ providers_unresolved = 0
165
177
 
166
178
  results.each do |_provider, result|
167
179
  if result[:status] == :ok
168
- providers_synced += 1
180
+ if Array(result[:offered]).any?
181
+ providers_unresolved += 1
182
+ else
183
+ providers_synced += 1
184
+ end
169
185
  total_added += result[:added].size
170
186
  total_removed += result[:removed].size
171
187
  total_unchanged += result[:unchanged].size
@@ -183,6 +199,7 @@ module Ace
183
199
  deprecated: total_deprecated,
184
200
  limit_updates: total_limit_updates,
185
201
  providers_synced: providers_synced,
202
+ providers_unresolved: providers_unresolved,
186
203
  providers_skipped: providers_skipped
187
204
  }
188
205
  end
@@ -194,12 +211,26 @@ module Ace
194
211
  results.any? do |_provider, result|
195
212
  next false unless result[:status] == :ok
196
213
 
197
- result[:added].any? || result[:removed].any? || result[:limits_changed]
214
+ result[:added].any? || result[:removed].any? || result[:limits_changed] || result[:config_changed]
198
215
  end
199
216
  end
200
217
 
201
218
  private
202
219
 
220
+ def diff_bundled_codex(config)
221
+ catalog = Atoms::ProviderConfigReader.bundled_config("codex")
222
+ raise ConfigError, "Bundled Codex catalog not found" unless catalog
223
+
224
+ reconciliation = Atoms::ProviderCatalogReconciler.reconcile(config, catalog)
225
+ @desired_configs["codex"] = reconciliation[:config]
226
+ {
227
+ status: :ok, source: :bundled, added: reconciliation[:added], removed: [], deprecated: [],
228
+ unchanged: Atoms::ProviderConfigReader.extract_models(config),
229
+ offered: reconciliation[:offered], preserved_removals: reconciliation[:removed],
230
+ config_changed: reconciliation[:changed], limits_changed: false
231
+ }
232
+ end
233
+
203
234
  def load_models_dev_data
204
235
  data = cache_manager.read
205
236
  raise CacheError, "No models.dev cache found. Run 'ace-models sync' first." unless data
@@ -31,11 +31,10 @@ module Ace
31
31
  # @param since [String, Date, nil] Only show models released after this date
32
32
  # @return [Hash] Result with status and details
33
33
  def sync(config_dir: nil, provider: nil, apply: false, commit: false, show_all: false, since: nil)
34
- # Ensure cache is fresh
35
- ensure_cache_fresh
36
-
37
34
  # Read current provider configs
38
35
  current_configs = Atoms::ProviderConfigReader.read_all(config_dir: config_dir)
36
+ selected_names = current_configs.keys.select { |name| !provider || name == provider }
37
+ ensure_cache_fresh unless selected_names == ["codex"] || provider == "codex"
39
38
 
40
39
  if current_configs.empty?
41
40
  return {
@@ -75,7 +74,7 @@ module Ace
75
74
 
76
75
  # Commit if requested and apply succeeded
77
76
  if commit && result[:applied]
78
- result[:committed] = commit_changes(result[:summary])
77
+ result[:committed] = commit_changes(result[:summary], apply_result[:paths])
79
78
  end
80
79
  end
81
80
 
@@ -87,7 +86,7 @@ module Ace
87
86
  # @return [String] Formatted output
88
87
  def format_result(result)
89
88
  lines = []
90
- lines << "Syncing provider configs with models.dev..."
89
+ lines << "Syncing provider configs with bundled catalogs and models.dev..."
91
90
 
92
91
  # Show date filter info
93
92
  if result[:show_all]
@@ -120,6 +119,10 @@ module Ace
120
119
  lines << " (#{summary[:providers_skipped]} providers not found in models.dev)"
121
120
  end
122
121
 
122
+ if summary[:providers_unresolved].to_i > 0
123
+ lines << " (#{summary[:providers_unresolved]} providers have unresolved catalog offers)"
124
+ end
125
+
123
126
  # Add action hints
124
127
  lines << ""
125
128
  if result[:changes_detected]
@@ -133,6 +136,8 @@ module Ace
133
136
  else
134
137
  lines << "Run with --apply to update config files."
135
138
  end
139
+ elsif result[:diff].values.any? { |diff| Array(diff[:offered]).any? }
140
+ lines << "Unresolved catalog offers remain; add desired models explicitly to your provider configuration."
136
141
  else
137
142
  lines << "All providers are up to date."
138
143
  end
@@ -172,10 +177,11 @@ module Ace
172
177
  def apply_changes(diff_results, current_configs)
173
178
  errors = []
174
179
  success = true
180
+ paths = []
175
181
 
176
182
  diff_results.each do |provider_name, diff|
177
183
  next unless diff[:status] == :ok
178
- next if diff[:added].empty? && diff[:removed].empty? && !diff[:limits_changed]
184
+ next if diff[:added].empty? && diff[:removed].empty? && !diff[:limits_changed] && !diff[:config_changed]
179
185
 
180
186
  begin
181
187
  config = current_configs[provider_name]
@@ -187,6 +193,20 @@ module Ace
187
193
  next
188
194
  end
189
195
 
196
+ if Atoms::ProviderConfigReader.bundled_config_directories.include?(File.dirname(source_file))
197
+ raise ConfigError, "Cannot write bundled provider defaults: #{source_file}"
198
+ end
199
+
200
+ if diff[:source] == :bundled
201
+ expected = config.reject { |key, _| key.start_with?("_source_") }
202
+ Atoms::ProviderConfigWriter.replace(
203
+ source_file, @diff_generator.desired_configs.fetch(provider_name), expected: expected
204
+ )
205
+ paths << source_file
206
+ output.puts "Updated: #{source_file}"
207
+ next
208
+ end
209
+
190
210
  # Calculate new model list
191
211
  current_models = Atoms::ProviderConfigReader.extract_models(config)
192
212
  new_models = (current_models + diff[:added] - diff[:removed]).uniq.sort
@@ -201,6 +221,7 @@ module Ace
201
221
  limits: diff[:desired_limits]
202
222
  )
203
223
  output.puts "Updated: #{source_file}"
224
+ paths << source_file
204
225
  rescue ConfigError => e
205
226
  errors << "#{provider_name}: #{e.message}"
206
227
  success = false
@@ -210,17 +231,17 @@ module Ace
210
231
  end
211
232
  end
212
233
 
213
- {success: success, errors: errors}
234
+ {success: success && paths.any?, errors: errors, paths: paths}
214
235
  end
215
236
 
216
- def commit_changes(summary)
237
+ def commit_changes(summary, paths)
217
238
  message = "chore(providers): Sync model lists with models.dev\n\n" \
218
239
  "Added: #{summary[:added]} models\n" \
219
240
  "Removed: #{summary[:removed]} models"
220
241
 
221
242
  # Try to use ace-git-commit if available
222
243
  begin
223
- system("ace-git-commit", "-m", message)
244
+ system("ace-git-commit", *paths, "-m", message)
224
245
  $?.success?
225
246
  rescue Errno::ENOENT
226
247
  output.puts "Warning: ace-git-commit not found. Please commit changes manually."
@@ -230,6 +251,14 @@ module Ace
230
251
 
231
252
  def format_provider_diff(provider_name, diff)
232
253
  lines = []
254
+ if diff[:source] == :bundled
255
+ lines << "#{provider_name}: (bundled catalog)"
256
+ diff[:added].each { |model| lines << " + #{model} (eligible catalog addition)" }
257
+ diff[:offered].each { |model| lines << " ? #{model} (offer only; explicit configuration required)" }
258
+ diff[:preserved_removals].each { |model| lines << " = #{model} (local removal preserved)" }
259
+ lines << " ~ record observed catalog and selection" if diff[:config_changed]
260
+ return lines.join("\n")
261
+ end
233
262
 
234
263
  # Show models_dev_id mapping if different from provider name
235
264
  lines << if diff[:models_dev_id]
@@ -3,7 +3,7 @@
3
3
  module Ace
4
4
  module Support
5
5
  module Models
6
- VERSION = "0.12.3"
6
+ VERSION = "0.12.4"
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.12.3
4
+ version: 0.12.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-05 00:00:00.000000000 Z
10
+ date: 2026-09-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-core
@@ -90,6 +90,7 @@ files:
90
90
  - lib/ace/support/models/atoms/json_parser.rb
91
91
  - lib/ace/support/models/atoms/model_filter.rb
92
92
  - lib/ace/support/models/atoms/model_name_canonicalizer.rb
93
+ - lib/ace/support/models/atoms/provider_catalog_reconciler.rb
93
94
  - lib/ace/support/models/atoms/provider_config_reader.rb
94
95
  - lib/ace/support/models/atoms/provider_config_writer.rb
95
96
  - lib/ace/support/models/cli.rb