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,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Support
|
|
5
|
+
module Models
|
|
6
|
+
module Atoms
|
|
7
|
+
# Canonicalizes model names by stripping provider-specific suffixes
|
|
8
|
+
#
|
|
9
|
+
# OpenRouter uses dynamic and static suffixes that modify routing behavior
|
|
10
|
+
# but don't represent different models in the canonical model registry.
|
|
11
|
+
#
|
|
12
|
+
# @see https://openrouter.ai/docs/faq for suffix documentation
|
|
13
|
+
class ModelNameCanonicalizer
|
|
14
|
+
# OpenRouter dynamic suffixes (work across all models, modify routing)
|
|
15
|
+
OPENROUTER_DYNAMIC_SUFFIXES = %w[
|
|
16
|
+
nitro
|
|
17
|
+
floor
|
|
18
|
+
online
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# OpenRouter static suffixes (apply to specific models only)
|
|
22
|
+
OPENROUTER_STATIC_SUFFIXES = %w[
|
|
23
|
+
free
|
|
24
|
+
extended
|
|
25
|
+
exacto
|
|
26
|
+
thinking
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Combined list of all known OpenRouter suffixes
|
|
30
|
+
OPENROUTER_SUFFIXES = (OPENROUTER_DYNAMIC_SUFFIXES + OPENROUTER_STATIC_SUFFIXES).freeze
|
|
31
|
+
|
|
32
|
+
# Provider-specific suffix configurations
|
|
33
|
+
# Maps provider ID to array of suffixes to strip
|
|
34
|
+
PROVIDER_SUFFIXES = {
|
|
35
|
+
"openrouter" => OPENROUTER_SUFFIXES
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Extract the canonical model name by stripping provider-specific suffixes
|
|
40
|
+
#
|
|
41
|
+
# @param model_id [String] The model ID (e.g., "openai/gpt-4:nitro")
|
|
42
|
+
# @param provider [String, nil] The provider ID to determine which suffixes to strip
|
|
43
|
+
# @return [String] The canonical model name (e.g., "openai/gpt-4")
|
|
44
|
+
#
|
|
45
|
+
# @example Strip OpenRouter :nitro suffix
|
|
46
|
+
# canonicalize("openai/gpt-4:nitro", provider: "openrouter")
|
|
47
|
+
# # => "openai/gpt-4"
|
|
48
|
+
#
|
|
49
|
+
# @example Preserve model with no suffix
|
|
50
|
+
# canonicalize("openai/gpt-4", provider: "openrouter")
|
|
51
|
+
# # => "openai/gpt-4"
|
|
52
|
+
#
|
|
53
|
+
# @example Non-OpenRouter provider keeps suffix
|
|
54
|
+
# canonicalize("model:variant", provider: "other")
|
|
55
|
+
# # => "model:variant"
|
|
56
|
+
def canonicalize(model_id, provider: nil)
|
|
57
|
+
return model_id if model_id.nil? || model_id.empty?
|
|
58
|
+
|
|
59
|
+
suffixes = PROVIDER_SUFFIXES[provider]
|
|
60
|
+
return model_id unless suffixes
|
|
61
|
+
|
|
62
|
+
strip_suffixes(model_id, suffixes)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Check if a model ID has a known suffix for the given provider
|
|
66
|
+
#
|
|
67
|
+
# @param model_id [String] The model ID to check
|
|
68
|
+
# @param provider [String, nil] The provider ID
|
|
69
|
+
# @return [Boolean] true if the model has a strippable suffix
|
|
70
|
+
def has_suffix?(model_id, provider: nil)
|
|
71
|
+
return false if model_id.nil? || model_id.empty?
|
|
72
|
+
|
|
73
|
+
suffixes = PROVIDER_SUFFIXES[provider]
|
|
74
|
+
return false unless suffixes
|
|
75
|
+
|
|
76
|
+
suffix = extract_suffix(model_id)
|
|
77
|
+
suffix && suffixes.include?(suffix)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Extract the suffix from a model ID if present
|
|
81
|
+
#
|
|
82
|
+
# @param model_id [String] The model ID
|
|
83
|
+
# @return [String, nil] The suffix without the colon, or nil if no suffix
|
|
84
|
+
def extract_suffix(model_id)
|
|
85
|
+
return nil if model_id.nil? || model_id.empty?
|
|
86
|
+
|
|
87
|
+
# Match the last :suffix pattern (handles model IDs like "org/model:suffix")
|
|
88
|
+
match = model_id.match(/:([^:\/]+)$/)
|
|
89
|
+
match&.captures&.first
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get all known suffixes for a provider
|
|
93
|
+
#
|
|
94
|
+
# @param provider [String] The provider ID
|
|
95
|
+
# @return [Array<String>] Array of suffix strings (without colons)
|
|
96
|
+
def suffixes_for(provider)
|
|
97
|
+
PROVIDER_SUFFIXES[provider] || []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Strip known suffixes from a model ID
|
|
103
|
+
#
|
|
104
|
+
# @param model_id [String] The model ID
|
|
105
|
+
# @param suffixes [Array<String>] Suffixes to strip
|
|
106
|
+
# @return [String] Model ID with suffix stripped
|
|
107
|
+
def strip_suffixes(model_id, suffixes)
|
|
108
|
+
suffix = extract_suffix(model_id)
|
|
109
|
+
return model_id unless suffix && suffixes.include?(suffix)
|
|
110
|
+
|
|
111
|
+
# Remove the :suffix from the end
|
|
112
|
+
model_id.sub(/:#{Regexp.escape(suffix)}$/, "")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "date"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Support
|
|
9
|
+
module Models
|
|
10
|
+
module Atoms
|
|
11
|
+
# Reads provider configuration files with cascade support:
|
|
12
|
+
# - Project: .ace/llm/providers/
|
|
13
|
+
# - User: ~/.ace/llm/providers/
|
|
14
|
+
# - Gem: ace-llm/.ace-defaults/llm/providers/ (single source of truth)
|
|
15
|
+
class ProviderConfigReader
|
|
16
|
+
class << self
|
|
17
|
+
# Find all provider config directories in cascade order
|
|
18
|
+
# @param config_dir [String, nil] Override config directory
|
|
19
|
+
# @return [Array<String>] List of directories (project first, then user, then gem)
|
|
20
|
+
def config_directories(config_dir: nil)
|
|
21
|
+
dirs = []
|
|
22
|
+
|
|
23
|
+
if config_dir
|
|
24
|
+
dirs << config_dir if Dir.exist?(config_dir)
|
|
25
|
+
else
|
|
26
|
+
# Project-level config
|
|
27
|
+
project_dir = project_config_dir
|
|
28
|
+
dirs << project_dir if project_dir && Dir.exist?(project_dir)
|
|
29
|
+
|
|
30
|
+
# User-level config
|
|
31
|
+
user_dir = user_config_dir
|
|
32
|
+
dirs << user_dir if user_dir && Dir.exist?(user_dir)
|
|
33
|
+
|
|
34
|
+
# Gem-level config (ace-llm/providers/)
|
|
35
|
+
gem_dir = gem_config_dir
|
|
36
|
+
dirs << gem_dir if gem_dir && Dir.exist?(gem_dir)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
dirs
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Find the first writable config directory
|
|
43
|
+
# @param config_dir [String, nil] Override config directory
|
|
44
|
+
# @return [String, nil] Writable directory or nil
|
|
45
|
+
def writable_config_directory(config_dir: nil)
|
|
46
|
+
dirs = config_directories(config_dir: config_dir)
|
|
47
|
+
dirs.find { |dir| writable?(dir) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Read all provider configs from cascade
|
|
51
|
+
# @param config_dir [String, nil] Override config directory
|
|
52
|
+
# @return [Hash<String, Hash>] Provider name => config hash
|
|
53
|
+
def read_all(config_dir: nil)
|
|
54
|
+
configs = {}
|
|
55
|
+
|
|
56
|
+
# Read from all directories (later wins for same provider)
|
|
57
|
+
config_directories(config_dir: config_dir).reverse_each do |dir|
|
|
58
|
+
read_directory(dir).each do |name, config|
|
|
59
|
+
configs[name] = config
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
configs
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Read provider configs from a specific directory
|
|
67
|
+
# @param dir [String] Directory path
|
|
68
|
+
# @return [Hash<String, Hash>] Provider name => config hash
|
|
69
|
+
def read_directory(dir)
|
|
70
|
+
configs = {}
|
|
71
|
+
|
|
72
|
+
Dir.glob(File.join(dir, "*.yml")).each do |file|
|
|
73
|
+
name = File.basename(file, ".yml")
|
|
74
|
+
next if name == "template" || name.end_with?(".example")
|
|
75
|
+
|
|
76
|
+
config = read_file(file)
|
|
77
|
+
configs[name] = config.merge("_source_file" => file) if config
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
configs
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Read a single provider config file
|
|
84
|
+
# @param path [String] File path
|
|
85
|
+
# @return [Hash, nil] Parsed YAML or nil on error
|
|
86
|
+
def read_file(path)
|
|
87
|
+
return nil unless File.exist?(path)
|
|
88
|
+
|
|
89
|
+
content = File.read(path)
|
|
90
|
+
YAML.safe_load(content, permitted_classes: [Symbol, Date])
|
|
91
|
+
rescue Errno::EACCES => e
|
|
92
|
+
raise CacheError, "Permission denied reading #{path}: #{e.message}"
|
|
93
|
+
rescue Psych::SyntaxError => e
|
|
94
|
+
raise ConfigError, "Invalid YAML in #{path}: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Extract models list from a provider config
|
|
98
|
+
# @param config [Hash] Provider config
|
|
99
|
+
# @return [Array<String>] List of model IDs
|
|
100
|
+
def extract_models(config)
|
|
101
|
+
models = config["models"]
|
|
102
|
+
return [] unless models
|
|
103
|
+
|
|
104
|
+
case models
|
|
105
|
+
when Array
|
|
106
|
+
models
|
|
107
|
+
when Hash
|
|
108
|
+
models.keys
|
|
109
|
+
else
|
|
110
|
+
[]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Extract models.dev provider ID from config
|
|
115
|
+
# Falls back to provider name if not specified
|
|
116
|
+
# @param config [Hash] Provider config
|
|
117
|
+
# @return [String] models.dev provider ID
|
|
118
|
+
def extract_models_dev_id(config)
|
|
119
|
+
config["models_dev_id"] || config["name"]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Extract last_synced date from config
|
|
123
|
+
# @param config [Hash] Provider config
|
|
124
|
+
# @return [Date, nil] Last sync date or nil
|
|
125
|
+
def extract_last_synced(config)
|
|
126
|
+
value = config["last_synced"]
|
|
127
|
+
return nil unless value
|
|
128
|
+
|
|
129
|
+
case value
|
|
130
|
+
when Date
|
|
131
|
+
value
|
|
132
|
+
when String
|
|
133
|
+
Date.parse(value)
|
|
134
|
+
end
|
|
135
|
+
rescue ArgumentError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def project_config_dir
|
|
142
|
+
# Use ace-core if available and has project_root method
|
|
143
|
+
if defined?(Ace::Core) && Ace::Core.respond_to?(:project_root)
|
|
144
|
+
project_root = Ace::Core.project_root
|
|
145
|
+
return File.join(project_root, ".ace", "llm", "providers") if project_root
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Fallback: traverse up from current dir looking for .ace or .git
|
|
149
|
+
find_project_root_dir
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def user_config_dir
|
|
153
|
+
home = ENV["HOME"]
|
|
154
|
+
return nil unless home
|
|
155
|
+
|
|
156
|
+
File.join(home, ".ace", "llm", "providers")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def gem_config_dir
|
|
160
|
+
# Find ace-llm gem's providers directory (from .ace-defaults/ - single source of truth)
|
|
161
|
+
if defined?(Ace::LLM)
|
|
162
|
+
# Try to find via gem spec
|
|
163
|
+
spec = begin
|
|
164
|
+
Gem::Specification.find_by_name("ace-llm")
|
|
165
|
+
rescue
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
return File.join(spec.gem_dir, ".ace-defaults", "llm", "providers") if spec
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Fallback: look relative to this gem in mono-repo development
|
|
172
|
+
# This enables development without installing ace-llm as a gem.
|
|
173
|
+
# Production/installed gem use goes through Gem::Specification above.
|
|
174
|
+
ace_llm_path = find_ace_llm_path
|
|
175
|
+
File.join(ace_llm_path, ".ace-defaults", "llm", "providers") if ace_llm_path
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def find_project_root_dir
|
|
179
|
+
dir = Dir.pwd
|
|
180
|
+
while dir != "/"
|
|
181
|
+
if Dir.exist?(File.join(dir, ".ace")) || Dir.exist?(File.join(dir, ".git"))
|
|
182
|
+
return File.join(dir, ".ace", "llm", "providers")
|
|
183
|
+
end
|
|
184
|
+
dir = File.dirname(dir)
|
|
185
|
+
end
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def find_ace_llm_path
|
|
190
|
+
# Look in common locations relative to this gem
|
|
191
|
+
candidates = [
|
|
192
|
+
File.expand_path("../../../../../../ace-llm", __FILE__),
|
|
193
|
+
File.expand_path("../../../../../../../ace-llm", __FILE__)
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
candidates.find { |path| Dir.exist?(path) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def writable?(dir)
|
|
200
|
+
return false unless Dir.exist?(dir)
|
|
201
|
+
|
|
202
|
+
# Initialize test_file before begin block to ensure it's available in ensure
|
|
203
|
+
test_file = File.join(dir, ".write_test_#{Process.pid}_#{SecureRandom.hex(4)}")
|
|
204
|
+
begin
|
|
205
|
+
File.write(test_file, "test")
|
|
206
|
+
true
|
|
207
|
+
rescue Errno::EACCES, Errno::EROFS
|
|
208
|
+
false
|
|
209
|
+
ensure
|
|
210
|
+
File.delete(test_file) if File.exist?(test_file)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Support
|
|
9
|
+
module Models
|
|
10
|
+
module Atoms
|
|
11
|
+
# Writes provider configuration files, preserving structure
|
|
12
|
+
# Updates only the models: section while keeping other fields intact
|
|
13
|
+
class ProviderConfigWriter
|
|
14
|
+
class << self
|
|
15
|
+
# Update the models list in a provider config file
|
|
16
|
+
# @param path [String] Path to config file
|
|
17
|
+
# @param models [Array<String>] New list of model IDs
|
|
18
|
+
# @return [Boolean] true on success
|
|
19
|
+
# @raise [ConfigError] on write errors
|
|
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)
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Write a complete config file
|
|
30
|
+
# @param path [String] Path to config file
|
|
31
|
+
# @param config [Hash] Config hash
|
|
32
|
+
# @return [Boolean] true on success
|
|
33
|
+
def write(path, config)
|
|
34
|
+
ensure_directory(File.dirname(path))
|
|
35
|
+
content = YAML.dump(config)
|
|
36
|
+
write_file(path, content)
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Create a backup of a config file
|
|
41
|
+
# @param path [String] Path to config file
|
|
42
|
+
# @return [String] Path to backup file
|
|
43
|
+
def backup(path)
|
|
44
|
+
return nil unless File.exist?(path)
|
|
45
|
+
|
|
46
|
+
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
47
|
+
backup_path = "#{path}.backup.#{timestamp}"
|
|
48
|
+
FileUtils.cp(path, backup_path)
|
|
49
|
+
backup_path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Update the last_synced field in a provider config file
|
|
53
|
+
# @param path [String] Path to config file
|
|
54
|
+
# @param date [Date] Date to set (defaults to today)
|
|
55
|
+
# @return [Boolean] true on success
|
|
56
|
+
# @raise [ConfigError] on write errors
|
|
57
|
+
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)
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Update both models and last_synced in one operation
|
|
67
|
+
# @param path [String] Path to config file
|
|
68
|
+
# @param models [Array<String>] New list of model IDs
|
|
69
|
+
# @param date [Date] Date to set for last_synced
|
|
70
|
+
# @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
|
|
74
|
+
|
|
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)
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
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
|
+
def write_file(path, content)
|
|
92
|
+
# Validate YAML before writing to catch regex manipulation errors
|
|
93
|
+
YAML.safe_load(content, permitted_classes: [Symbol, Date])
|
|
94
|
+
File.write(path, content)
|
|
95
|
+
rescue Psych::SyntaxError => e
|
|
96
|
+
raise ConfigError, "Generated invalid YAML for #{path}: #{e.message}"
|
|
97
|
+
rescue Errno::EACCES => e
|
|
98
|
+
raise ConfigError, "Permission denied writing #{path}: #{e.message}"
|
|
99
|
+
rescue Errno::ENOSPC => e
|
|
100
|
+
raise ConfigError, "No space left writing #{path}: #{e.message}"
|
|
101
|
+
rescue Errno::EROFS => e
|
|
102
|
+
raise ConfigError, "Read-only filesystem: #{path}: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ensure_directory(dir)
|
|
106
|
+
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
|
|
107
|
+
rescue Errno::EACCES => e
|
|
108
|
+
raise ConfigError, "Permission denied creating directory #{dir}: #{e.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
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
|
|
122
|
+
|
|
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
|
|
128
|
+
|
|
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
|
|
147
|
+
|
|
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
|
|
176
|
+
|
|
177
|
+
result << line
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
result.join
|
|
181
|
+
end
|
|
182
|
+
|
|
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
|
|
200
|
+
end
|
|
201
|
+
|
|
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")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
result.join
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Support
|
|
7
|
+
module Models
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
module Cache
|
|
11
|
+
# Clear local cache
|
|
12
|
+
class Clear < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
|
|
15
|
+
desc "Clear local cache"
|
|
16
|
+
|
|
17
|
+
option :json, type: :boolean, desc: "Output as JSON"
|
|
18
|
+
|
|
19
|
+
def call(**options)
|
|
20
|
+
cache_manager = Molecules::CacheManager.new
|
|
21
|
+
result = cache_manager.clear
|
|
22
|
+
|
|
23
|
+
if options[:json]
|
|
24
|
+
puts JSON.pretty_generate(result)
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if result[:status] == :success
|
|
29
|
+
puts "Cache cleared successfully"
|
|
30
|
+
puts "Deleted: #{result[:deleted_files].join(", ")}" if result[:deleted_files]&.any?
|
|
31
|
+
else
|
|
32
|
+
raise Ace::Support::Cli::Error.new(result[:message])
|
|
33
|
+
end
|
|
34
|
+
rescue => e
|
|
35
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Support
|
|
7
|
+
module Models
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
module Cache
|
|
11
|
+
# Show changes since last sync
|
|
12
|
+
class Diff < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
|
|
15
|
+
desc "Show changes since last sync"
|
|
16
|
+
|
|
17
|
+
option :json, type: :boolean, desc: "Output as JSON"
|
|
18
|
+
|
|
19
|
+
def call(**options)
|
|
20
|
+
result = Molecules::DiffGenerator.new.generate
|
|
21
|
+
|
|
22
|
+
if options[:json]
|
|
23
|
+
puts JSON.pretty_generate(result.to_h)
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless result.any_changes?
|
|
28
|
+
puts "No changes since last sync"
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if result.added_providers.any?
|
|
33
|
+
puts "New providers:"
|
|
34
|
+
result.added_providers.each { |p| puts " + #{p}" }
|
|
35
|
+
puts
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if result.removed_providers.any?
|
|
39
|
+
puts "Removed providers:"
|
|
40
|
+
result.removed_providers.each { |p| puts " - #{p}" }
|
|
41
|
+
puts
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if result.added_models.any?
|
|
45
|
+
puts "New models:"
|
|
46
|
+
result.added_models.each { |m| puts " + #{m}" }
|
|
47
|
+
puts
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if result.removed_models.any?
|
|
51
|
+
puts "Removed models:"
|
|
52
|
+
result.removed_models.each { |m| puts " - #{m}" }
|
|
53
|
+
puts
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if result.updated_models.any?
|
|
57
|
+
puts "Updated models:"
|
|
58
|
+
result.updated_models.each do |update|
|
|
59
|
+
puts " ~ #{update.model_id}: #{update.summary}"
|
|
60
|
+
end
|
|
61
|
+
puts
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
puts "Summary: #{result.summary}"
|
|
65
|
+
rescue CacheError => e
|
|
66
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|