ace-llm 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/llm/config.yml +31 -0
  3. data/.ace-defaults/llm/presets/claude/prompt.yml +5 -0
  4. data/.ace-defaults/llm/presets/claude/ro.yml +6 -0
  5. data/.ace-defaults/llm/presets/claude/rw.yml +4 -0
  6. data/.ace-defaults/llm/presets/claude/yolo.yml +3 -0
  7. data/.ace-defaults/llm/presets/codex/ro.yml +5 -0
  8. data/.ace-defaults/llm/presets/codex/rw.yml +3 -0
  9. data/.ace-defaults/llm/presets/codex/yolo.yml +3 -0
  10. data/.ace-defaults/llm/presets/gemini/ro.yml +4 -0
  11. data/.ace-defaults/llm/presets/gemini/rw.yml +4 -0
  12. data/.ace-defaults/llm/presets/gemini/yolo.yml +4 -0
  13. data/.ace-defaults/llm/presets/opencode/ro.yml +1 -0
  14. data/.ace-defaults/llm/presets/opencode/rw.yml +1 -0
  15. data/.ace-defaults/llm/presets/opencode/yolo.yml +3 -0
  16. data/.ace-defaults/llm/presets/pi/ro.yml +1 -0
  17. data/.ace-defaults/llm/presets/pi/rw.yml +1 -0
  18. data/.ace-defaults/llm/presets/pi/yolo.yml +1 -0
  19. data/.ace-defaults/llm/providers/anthropic.yml +34 -0
  20. data/.ace-defaults/llm/providers/google.yml +36 -0
  21. data/.ace-defaults/llm/providers/groq.yml +29 -0
  22. data/.ace-defaults/llm/providers/lmstudio.yml +24 -0
  23. data/.ace-defaults/llm/providers/mistral.yml +33 -0
  24. data/.ace-defaults/llm/providers/openai.yml +33 -0
  25. data/.ace-defaults/llm/providers/openrouter.yml +45 -0
  26. data/.ace-defaults/llm/providers/togetherai.yml +26 -0
  27. data/.ace-defaults/llm/providers/xai.yml +30 -0
  28. data/.ace-defaults/llm/providers/zai.yml +18 -0
  29. data/.ace-defaults/llm/thinking/claude/high.yml +3 -0
  30. data/.ace-defaults/llm/thinking/claude/low.yml +3 -0
  31. data/.ace-defaults/llm/thinking/claude/medium.yml +3 -0
  32. data/.ace-defaults/llm/thinking/claude/xhigh.yml +3 -0
  33. data/.ace-defaults/llm/thinking/codex/high.yml +3 -0
  34. data/.ace-defaults/llm/thinking/codex/low.yml +3 -0
  35. data/.ace-defaults/llm/thinking/codex/medium.yml +3 -0
  36. data/.ace-defaults/llm/thinking/codex/xhigh.yml +3 -0
  37. data/.ace-defaults/nav/protocols/guide-sources/ace-llm.yml +10 -0
  38. data/CHANGELOG.md +641 -0
  39. data/LICENSE +21 -0
  40. data/README.md +42 -0
  41. data/Rakefile +14 -0
  42. data/exe/ace-llm +25 -0
  43. data/handbook/guides/llm-query-tool-reference.g.md +683 -0
  44. data/handbook/templates/agent/plan-mode.template.md +48 -0
  45. data/lib/ace/llm/atoms/env_reader.rb +155 -0
  46. data/lib/ace/llm/atoms/error_classifier.rb +200 -0
  47. data/lib/ace/llm/atoms/http_client.rb +162 -0
  48. data/lib/ace/llm/atoms/provider_config_validator.rb +260 -0
  49. data/lib/ace/llm/atoms/xdg_directory_resolver.rb +189 -0
  50. data/lib/ace/llm/cli/commands/query.rb +280 -0
  51. data/lib/ace/llm/cli.rb +24 -0
  52. data/lib/ace/llm/configuration.rb +180 -0
  53. data/lib/ace/llm/models/fallback_config.rb +216 -0
  54. data/lib/ace/llm/molecules/client_registry.rb +336 -0
  55. data/lib/ace/llm/molecules/config_loader.rb +39 -0
  56. data/lib/ace/llm/molecules/fallback_orchestrator.rb +218 -0
  57. data/lib/ace/llm/molecules/file_io_handler.rb +158 -0
  58. data/lib/ace/llm/molecules/format_handlers.rb +183 -0
  59. data/lib/ace/llm/molecules/llm_alias_resolver.rb +50 -0
  60. data/lib/ace/llm/molecules/openai_compatible_params.rb +21 -0
  61. data/lib/ace/llm/molecules/preset_loader.rb +99 -0
  62. data/lib/ace/llm/molecules/provider_loader.rb +198 -0
  63. data/lib/ace/llm/molecules/provider_model_parser.rb +172 -0
  64. data/lib/ace/llm/molecules/thinking_level_loader.rb +83 -0
  65. data/lib/ace/llm/organisms/anthropic_client.rb +213 -0
  66. data/lib/ace/llm/organisms/base_client.rb +264 -0
  67. data/lib/ace/llm/organisms/google_client.rb +187 -0
  68. data/lib/ace/llm/organisms/groq_client.rb +197 -0
  69. data/lib/ace/llm/organisms/lmstudio_client.rb +146 -0
  70. data/lib/ace/llm/organisms/mistral_client.rb +180 -0
  71. data/lib/ace/llm/organisms/openai_client.rb +195 -0
  72. data/lib/ace/llm/organisms/openrouter_client.rb +216 -0
  73. data/lib/ace/llm/organisms/togetherai_client.rb +184 -0
  74. data/lib/ace/llm/organisms/xai_client.rb +213 -0
  75. data/lib/ace/llm/organisms/zai_client.rb +149 -0
  76. data/lib/ace/llm/query_interface.rb +455 -0
  77. data/lib/ace/llm/version.rb +7 -0
  78. data/lib/ace/llm.rb +61 -0
  79. metadata +318 -0
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module LLM
5
+ module Atoms
6
+ # ProviderConfigValidator validates provider configuration structure and content
7
+ class ProviderConfigValidator
8
+ # Required fields for any provider configuration
9
+ REQUIRED_FIELDS = %w[name class gem].freeze
10
+
11
+ # Optional fields with expected types
12
+ OPTIONAL_FIELDS = {
13
+ "models" => Array,
14
+ "api_key" => [Hash, NilClass],
15
+ "capabilities" => Array,
16
+ "default_options" => Hash,
17
+ "endpoint" => String,
18
+ "version" => String,
19
+ "aliases" => Hash
20
+ }.freeze
21
+
22
+ # Valid capability values
23
+ VALID_CAPABILITIES = %w[
24
+ text_generation
25
+ streaming
26
+ function_calling
27
+ vision
28
+ embeddings
29
+ code_generation
30
+ chat_completion
31
+ ].freeze
32
+
33
+ # Validation result
34
+ ValidationResult = Struct.new(:valid, :errors, :warnings) do
35
+ def valid?
36
+ valid
37
+ end
38
+
39
+ def invalid?
40
+ !valid
41
+ end
42
+ end
43
+
44
+ # Validate a provider configuration
45
+ # @param config [Hash] Provider configuration to validate
46
+ # @return [ValidationResult] Validation result with errors and warnings
47
+ def validate(config)
48
+ errors = []
49
+ warnings = []
50
+
51
+ # Check that config is a Hash
52
+ unless config.is_a?(Hash)
53
+ errors << "Configuration must be a Hash, got #{config.class}"
54
+ return ValidationResult.new(false, errors, warnings)
55
+ end
56
+
57
+ # Validate required fields
58
+ REQUIRED_FIELDS.each do |field|
59
+ if config[field].nil? || config[field].to_s.strip.empty?
60
+ errors << "Missing required field: '#{field}'"
61
+ end
62
+ end
63
+
64
+ # Validate field types
65
+ validate_field_types(config, errors, warnings)
66
+
67
+ # Validate specific field content
68
+ validate_name(config["name"], errors) if config["name"]
69
+ validate_class(config["class"], errors) if config["class"]
70
+ validate_gem(config["gem"], errors) if config["gem"]
71
+ validate_models(config["models"], warnings) if config["models"]
72
+ validate_api_key(config["api_key"], errors, warnings) if config["api_key"]
73
+ validate_capabilities(config["capabilities"], warnings) if config["capabilities"]
74
+ validate_default_options(config["default_options"], warnings) if config["default_options"]
75
+ validate_aliases(config["aliases"], errors, warnings) if config["aliases"]
76
+
77
+ # Return validation result
78
+ ValidationResult.new(errors.empty?, errors, warnings)
79
+ end
80
+
81
+ # Validate a batch of configurations
82
+ # @param configs [Array<Hash>] Array of provider configurations
83
+ # @return [Hash] Map of config name to validation result
84
+ def validate_batch(configs)
85
+ results = {}
86
+
87
+ configs.each do |config|
88
+ name = config["name"] || "unnamed"
89
+ results[name] = validate(config)
90
+ end
91
+
92
+ results
93
+ end
94
+
95
+ private
96
+
97
+ # Validate field types match expected types
98
+ def validate_field_types(config, errors, warnings)
99
+ config.each do |field, value|
100
+ next if REQUIRED_FIELDS.include?(field)
101
+ next unless OPTIONAL_FIELDS.key?(field)
102
+
103
+ expected_types = Array(OPTIONAL_FIELDS[field])
104
+ unless expected_types.any? { |type| value.is_a?(type) }
105
+ errors << "Field '#{field}' must be #{expected_types.join(" or ")}, got #{value.class}"
106
+ end
107
+ end
108
+
109
+ # Warn about unknown fields
110
+ unknown_fields = config.keys - REQUIRED_FIELDS - OPTIONAL_FIELDS.keys
111
+ unless unknown_fields.empty?
112
+ warnings << "Unknown fields in configuration: #{unknown_fields.join(", ")}"
113
+ end
114
+ end
115
+
116
+ # Validate provider name format
117
+ def validate_name(name, errors)
118
+ unless name.is_a?(String) && name.match?(/\A[a-z0-9_-]+\z/i)
119
+ errors << "Provider name must contain only letters, numbers, hyphens, and underscores"
120
+ end
121
+ end
122
+
123
+ # Validate class name format
124
+ def validate_class(class_name, errors)
125
+ unless class_name.is_a?(String) && class_name.match?(/\A[A-Z][A-Za-z0-9_]*(::[A-Z][A-Za-z0-9_]*)*\z/)
126
+ errors << "Class must be a valid Ruby class name (e.g., 'Ace::LLM::Organisms::GoogleClient')"
127
+ end
128
+ end
129
+
130
+ # Validate gem name format
131
+ def validate_gem(gem_name, errors)
132
+ unless gem_name.is_a?(String) && gem_name.match?(/\A[a-z0-9][a-z0-9_-]*\z/)
133
+ errors << "Gem name must be a valid RubyGems name"
134
+ end
135
+ end
136
+
137
+ # Validate models array
138
+ def validate_models(models, warnings)
139
+ if models.empty?
140
+ warnings << "No models specified for provider"
141
+ end
142
+
143
+ models.each do |model|
144
+ unless model.is_a?(String) && !model.strip.empty?
145
+ warnings << "Invalid model entry: #{model.inspect} (must be non-empty string)"
146
+ end
147
+ end
148
+ end
149
+
150
+ # Validate API key configuration
151
+ def validate_api_key(api_key_config, errors, warnings)
152
+ if api_key_config.is_a?(Hash)
153
+ # Check for valid configuration keys
154
+ valid_keys = %w[env value required description]
155
+ unknown_keys = api_key_config.keys - valid_keys
156
+
157
+ unless unknown_keys.empty?
158
+ warnings << "Unknown API key configuration keys: #{unknown_keys.join(", ")}"
159
+ end
160
+
161
+ # Must have either env or value
162
+ unless api_key_config["env"] || api_key_config["value"]
163
+ errors << "API key configuration must specify either 'env' or 'value'"
164
+ end
165
+
166
+ # Warn if using direct value
167
+ if api_key_config["value"]
168
+ warnings << "Using direct API key value in configuration is not recommended for security"
169
+ end
170
+
171
+ # Validate env var name format
172
+ if api_key_config["env"] && !api_key_config["env"].match?(/\A[A-Z][A-Z0-9_]*\z/)
173
+ warnings << "Environment variable name should be uppercase with underscores"
174
+ end
175
+ else
176
+ errors << "API key configuration must be a Hash"
177
+ end
178
+ end
179
+
180
+ # Validate capabilities array
181
+ def validate_capabilities(capabilities, warnings)
182
+ invalid_capabilities = capabilities - VALID_CAPABILITIES
183
+
184
+ unless invalid_capabilities.empty?
185
+ warnings << "Unknown capabilities: #{invalid_capabilities.join(", ")}. " \
186
+ "Valid capabilities: #{VALID_CAPABILITIES.join(", ")}"
187
+ end
188
+ end
189
+
190
+ # Validate default options
191
+ def validate_default_options(options, warnings)
192
+ # Validate temperature range
193
+ if options["temperature"]
194
+ temp = options["temperature"]
195
+ if temp.is_a?(Numeric)
196
+ if temp < 0.0 || temp > 2.0
197
+ warnings << "Temperature should be between 0.0 and 2.0, got #{temp}"
198
+ end
199
+ else
200
+ warnings << "Temperature must be a number, got #{temp.class}"
201
+ end
202
+ end
203
+
204
+ # Validate max_tokens
205
+ if options["max_tokens"]
206
+ max_tokens = options["max_tokens"]
207
+ if max_tokens.is_a?(Integer)
208
+ if max_tokens <= 0
209
+ warnings << "max_tokens must be positive, got #{max_tokens}"
210
+ end
211
+ else
212
+ warnings << "max_tokens must be an integer, got #{max_tokens.class}"
213
+ end
214
+ end
215
+ end
216
+
217
+ # Validate aliases structure
218
+ def validate_aliases(aliases, errors, warnings)
219
+ unless aliases.is_a?(Hash)
220
+ errors << "Aliases must be a Hash, got #{aliases.class}"
221
+ return
222
+ end
223
+
224
+ # Check for valid sections
225
+ valid_sections = %w[global model]
226
+ unknown_sections = aliases.keys - valid_sections
227
+ unless unknown_sections.empty?
228
+ warnings << "Unknown alias sections: #{unknown_sections.join(", ")}. Valid sections: global, model"
229
+ end
230
+
231
+ # Validate global aliases if present
232
+ if aliases["global"]
233
+ if aliases["global"].is_a?(Hash)
234
+ aliases["global"].each do |key, value|
235
+ unless value.is_a?(String)
236
+ errors << "Global alias '#{key}' must be a String, got #{value.class}"
237
+ end
238
+ end
239
+ else
240
+ errors << "Global aliases must be a Hash, got #{aliases["global"].class}"
241
+ end
242
+ end
243
+
244
+ # Validate model aliases if present
245
+ if aliases["model"]
246
+ if aliases["model"].is_a?(Hash)
247
+ aliases["model"].each do |key, value|
248
+ unless value.is_a?(String)
249
+ errors << "Model alias '#{key}' must be a String, got #{value.class}"
250
+ end
251
+ end
252
+ else
253
+ errors << "Model aliases must be a Hash, got #{aliases["model"].class}"
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Ace
7
+ module LLM
8
+ module Atoms
9
+ # XDGDirectoryResolver provides XDG Base Directory Specification
10
+ # compliant directory resolution for cache and data storage.
11
+ # This atom has no dependencies on other parts of this gem.
12
+ class XDGDirectoryResolver
13
+ # Application name used in directory paths
14
+ APP_NAME = "ace-llm"
15
+
16
+ # Environment variable names
17
+ XDG_CACHE_HOME = "XDG_CACHE_HOME"
18
+ XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
19
+ XDG_DATA_HOME = "XDG_DATA_HOME"
20
+ HOME = "HOME"
21
+
22
+ # Default directory permissions
23
+ DEFAULT_DIR_PERMISSIONS = 0o700
24
+
25
+ # Resolve XDG-compliant cache directory path
26
+ # @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
27
+ # @return [String] Absolute path to cache directory
28
+ def self.cache_directory(env_reader = ENV)
29
+ xdg_cache_home = env_reader[XDG_CACHE_HOME]
30
+ home_dir = env_reader[HOME]
31
+
32
+ # Use XDG_CACHE_HOME if set and non-empty
33
+ cache_base = if xdg_cache_home && !xdg_cache_home.strip.empty?
34
+ File.expand_path(xdg_cache_home.strip)
35
+ elsif home_dir && !home_dir.strip.empty?
36
+ # Fall back to ~/.cache if HOME is available
37
+ File.expand_path(".cache", home_dir.strip)
38
+ else
39
+ # Last resort: use current directory
40
+ File.expand_path(".cache")
41
+ end
42
+
43
+ File.join(cache_base, APP_NAME)
44
+ end
45
+
46
+ # Resolve XDG-compliant config directory path
47
+ # @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
48
+ # @return [String] Absolute path to config directory
49
+ def self.config_directory(env_reader = ENV)
50
+ xdg_config_home = env_reader[XDG_CONFIG_HOME]
51
+ home_dir = env_reader[HOME]
52
+
53
+ # Use XDG_CONFIG_HOME if set and non-empty
54
+ config_base = if xdg_config_home && !xdg_config_home.strip.empty?
55
+ File.expand_path(xdg_config_home.strip)
56
+ elsif home_dir && !home_dir.strip.empty?
57
+ # Fall back to ~/.config if HOME is available
58
+ File.expand_path(".config", home_dir.strip)
59
+ else
60
+ # Last resort: use current directory
61
+ File.expand_path(".config")
62
+ end
63
+
64
+ File.join(config_base, APP_NAME)
65
+ end
66
+
67
+ # Resolve XDG-compliant data directory path
68
+ # @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
69
+ # @return [String] Absolute path to data directory
70
+ def self.data_directory(env_reader = ENV)
71
+ xdg_data_home = env_reader[XDG_DATA_HOME]
72
+ home_dir = env_reader[HOME]
73
+
74
+ # Use XDG_DATA_HOME if set and non-empty
75
+ data_base = if xdg_data_home && !xdg_data_home.strip.empty?
76
+ File.expand_path(xdg_data_home.strip)
77
+ elsif home_dir && !home_dir.strip.empty?
78
+ # Fall back to ~/.local/share if HOME is available
79
+ File.expand_path(".local/share", home_dir.strip)
80
+ else
81
+ # Last resort: use current directory
82
+ File.expand_path(".local/share")
83
+ end
84
+
85
+ File.join(data_base, APP_NAME)
86
+ end
87
+
88
+ # Ensure directory exists with proper permissions
89
+ # @param directory [String] Directory path
90
+ # @param permissions [Integer] Directory permissions (default: 0700)
91
+ # @return [String] The created directory path
92
+ # @raise [SystemCallError] If directory cannot be created
93
+ def self.ensure_directory(directory, permissions = DEFAULT_DIR_PERMISSIONS)
94
+ FileUtils.mkdir_p(directory, mode: permissions)
95
+ directory
96
+ end
97
+
98
+ # Get cache subdirectory path for specific cache type
99
+ # @param cache_type [String] Type of cache (e.g., 'models', 'http', 'pricing')
100
+ # @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
101
+ # @return [String] Path to cache subdirectory
102
+ def self.cache_subdirectory(cache_type, env_reader = ENV)
103
+ base_cache_dir = cache_directory(env_reader)
104
+ File.join(base_cache_dir, cache_type.to_s)
105
+ end
106
+
107
+ # Resolve and ensure cache subdirectory exists
108
+ # @param cache_type [String] Type of cache
109
+ # @param env_reader [Hash, #[]] Environment variable source (defaults to ENV)
110
+ # @param permissions [Integer] Directory permissions (default: 0700)
111
+ # @return [String] Path to existing cache subdirectory
112
+ def self.ensure_cache_subdirectory(cache_type, env_reader = ENV, permissions = DEFAULT_DIR_PERMISSIONS)
113
+ subdir_path = cache_subdirectory(cache_type, env_reader)
114
+ ensure_directory(subdir_path, permissions)
115
+ end
116
+
117
+ # Validate directory path for security
118
+ # @param path [String] Directory path to validate
119
+ # @return [Boolean] True if path is safe to use
120
+ def self.safe_directory_path?(path)
121
+ return false if path.nil? || path.empty?
122
+
123
+ # Reject paths with null bytes (security concern)
124
+ return false if path.include?("\0")
125
+
126
+ # Reject paths with parent directory traversal attempts
127
+ return false if path.include?("..")
128
+
129
+ # Must be absolute path after expansion
130
+ begin
131
+ expanded = File.expand_path(path)
132
+ pathname = Pathname.new(expanded)
133
+ return false unless pathname.absolute?
134
+ rescue
135
+ return false
136
+ end
137
+
138
+ true
139
+ end
140
+
141
+ # Instance methods for non-static usage
142
+ def initialize(env_reader = ENV)
143
+ @env_reader = env_reader
144
+ end
145
+
146
+ # Instance method to get cache directory
147
+ # @return [String] Cache directory path
148
+ def cache_directory
149
+ self.class.cache_directory(@env_reader)
150
+ end
151
+
152
+ # Instance method to get config directory
153
+ # @return [String] Config directory path
154
+ def config_directory
155
+ self.class.config_directory(@env_reader)
156
+ end
157
+
158
+ # Instance method to get data directory
159
+ # @return [String] Data directory path
160
+ def data_directory
161
+ self.class.data_directory(@env_reader)
162
+ end
163
+
164
+ # Instance method to ensure directory exists
165
+ # @param directory [String] Directory path
166
+ # @param permissions [Integer] Directory permissions (default: 0700)
167
+ # @return [String] The created directory path
168
+ def ensure_directory(directory, permissions = DEFAULT_DIR_PERMISSIONS)
169
+ self.class.ensure_directory(directory, permissions)
170
+ end
171
+
172
+ # Instance method to get cache subdirectory
173
+ # @param cache_type [String] Type of cache
174
+ # @return [String] Path to cache subdirectory
175
+ def cache_subdirectory(cache_type)
176
+ self.class.cache_subdirectory(cache_type, @env_reader)
177
+ end
178
+
179
+ # Instance method to ensure cache subdirectory exists
180
+ # @param cache_type [String] Type of cache
181
+ # @param permissions [Integer] Directory permissions (default: 0700)
182
+ # @return [String] Path to existing cache subdirectory
183
+ def ensure_cache_subdirectory(cache_type, permissions = DEFAULT_DIR_PERMISSIONS)
184
+ self.class.ensure_cache_subdirectory(cache_type, @env_reader, permissions)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end