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,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+ require "pathname"
6
+ require "ace/support/config"
7
+ require_relative "../atoms/env_reader"
8
+
9
+ module Ace
10
+ module LLM
11
+ module Molecules
12
+ # ClientRegistry manages provider configurations and instantiation
13
+ # Loads provider definitions from YAML files and creates client instances dynamically
14
+ class ClientRegistry
15
+ attr_reader :providers, :global_aliases, :model_aliases
16
+
17
+ # Initialize the client registry
18
+ def initialize
19
+ @providers = {}
20
+ @loaded_gems = {}
21
+ @global_aliases = {}
22
+ @model_aliases = {}
23
+ load_all_configurations
24
+ build_alias_maps
25
+ end
26
+
27
+ # Get a client instance for a provider and model
28
+ # @param provider_name [String] Provider name
29
+ # @param model [String, nil] Model to use (uses default if nil)
30
+ # @param options [Hash] Additional options for client initialization
31
+ # @return [BaseClient] Client instance
32
+ # @raise [ProviderError] If provider not found or cannot be loaded
33
+ def get_client(provider_name, model: nil, **options)
34
+ provider_config = get_provider(provider_name)
35
+
36
+ unless provider_config
37
+ raise ProviderError, "Unknown provider: #{provider_name}. Available providers: #{available_providers.join(", ")}"
38
+ end
39
+
40
+ # Load the provider's gem if needed
41
+ load_provider_gem(provider_config)
42
+
43
+ # Get the class and instantiate
44
+ client_class = resolve_class(provider_config["class"])
45
+
46
+ # Merge options with defaults from config
47
+ merged_options = (provider_config["default_options"] || {}).merge(options)
48
+
49
+ # Use specified model or default from config
50
+ model ||= provider_config["models"]&.first || "default"
51
+
52
+ # Add API key configuration if specified
53
+ if provider_config["api_key"]
54
+ merged_options[:api_key] = resolve_api_key(provider_config["api_key"])
55
+ end
56
+
57
+ # Pass backend configurations (base_url, env_key, model_tiers) to client
58
+ if provider_config["backends"]
59
+ merged_options[:backends] = provider_config["backends"]
60
+ end
61
+
62
+ client_class.new(model: model, **merged_options)
63
+ end
64
+
65
+ # Get provider configuration
66
+ # @param provider_name [String] Provider name
67
+ # @return [Hash, nil] Provider configuration or nil if not found
68
+ def get_provider(provider_name)
69
+ normalized_name = normalize_provider_name(provider_name)
70
+ @providers[normalized_name]
71
+ end
72
+
73
+ # Get list of available provider names
74
+ # @return [Array<String>] Provider names
75
+ def available_providers
76
+ @providers.keys.sort
77
+ end
78
+
79
+ # Get list of models for a provider
80
+ # @param provider_name [String] Provider name
81
+ # @return [Array<String>, nil] List of models or nil if provider not found
82
+ def models_for_provider(provider_name)
83
+ provider = get_provider(provider_name)
84
+ provider&.fetch("models", [])
85
+ end
86
+
87
+ # Check if a provider is available
88
+ # @param provider_name [String] Provider name
89
+ # @return [Boolean] True if provider is registered
90
+ def provider_exists?(provider_name)
91
+ normalized_name = normalize_provider_name(provider_name)
92
+ @providers.key?(normalized_name)
93
+ end
94
+
95
+ # Check if a provider's gem is available
96
+ # @param provider_name [String] Provider name
97
+ # @return [Boolean] True if provider gem can be loaded
98
+ def provider_available?(provider_name)
99
+ provider_config = get_provider(provider_name)
100
+ return false unless provider_config
101
+
102
+ # Try to load the gem
103
+ begin
104
+ load_provider_gem(provider_config)
105
+ true
106
+ rescue LoadError, NameError
107
+ false
108
+ end
109
+ end
110
+
111
+ # List all providers with their status
112
+ # @return [Hash] Provider status information
113
+ def list_providers_with_status
114
+ @providers.map do |name, config|
115
+ {
116
+ name: name,
117
+ models: config["models"] || [],
118
+ gem: config["gem"],
119
+ available: provider_available?(name),
120
+ api_key_required: config.dig("api_key", "required") || false,
121
+ api_key_present: api_key_present?(config["api_key"])
122
+ }
123
+ end
124
+ end
125
+
126
+ # Reload all configurations
127
+ def reload!
128
+ @providers.clear
129
+ @loaded_gems.clear
130
+ @global_aliases.clear
131
+ @model_aliases.clear
132
+ load_all_configurations
133
+ build_alias_maps
134
+ end
135
+
136
+ # Resolve an alias to provider:model format
137
+ # @param input [String] The alias or model to resolve
138
+ # @return [String] The resolved provider:model or original input
139
+ def resolve_alias(input)
140
+ input = input.to_s.strip
141
+
142
+ # Check if it's already in provider:model format
143
+ if input.include?(":")
144
+ provider_name, model_alias = input.split(":", 2)
145
+ normalized_provider = normalize_provider_name(provider_name)
146
+
147
+ # Try to resolve model alias for this provider
148
+ if @model_aliases[normalized_provider] && @model_aliases[normalized_provider][model_alias]
149
+ return "#{provider_name}:#{@model_aliases[normalized_provider][model_alias]}"
150
+ end
151
+
152
+ # Auto-resolve: provider:provider → provider:default_model
153
+ normalized_alias = normalize_provider_name(model_alias)
154
+ if normalized_alias == normalized_provider
155
+ default_model = @providers[normalized_provider]&.dig("models")&.first
156
+ return "#{provider_name}:#{default_model}" if default_model
157
+ end
158
+
159
+ # Return as-is if no model alias found
160
+ return input
161
+ end
162
+
163
+ # Check global aliases
164
+ if @global_aliases[input]
165
+ resolved = @global_aliases[input]
166
+
167
+ # If global alias points to provider:model_alias, resolve the model alias too
168
+ if resolved.include?(":")
169
+ provider_name, model_part = resolved.split(":", 2)
170
+ normalized_provider = normalize_provider_name(provider_name)
171
+
172
+ # Check if model_part is itself an alias
173
+ if @model_aliases[normalized_provider] && @model_aliases[normalized_provider][model_part]
174
+ return "#{provider_name}:#{@model_aliases[normalized_provider][model_part]}"
175
+ end
176
+ end
177
+
178
+ return resolved
179
+ end
180
+
181
+ # Return original if no alias found
182
+ input
183
+ end
184
+
185
+ # Get all available aliases
186
+ # @return [Hash] Hash with global and model aliases by provider
187
+ def available_aliases
188
+ {
189
+ global: @global_aliases.dup,
190
+ model: @model_aliases.dup
191
+ }
192
+ end
193
+
194
+ private
195
+
196
+ # Get gem root for config resolution
197
+ # @return [String] Path to gem root directory
198
+ def gem_root
199
+ Gem.loaded_specs["ace-llm"]&.gem_dir ||
200
+ File.expand_path("../../../..", __dir__)
201
+ end
202
+
203
+ # Load all provider configurations using Configuration class
204
+ # Uses ProviderConfigReader which handles project -> home -> gem cascade
205
+ def load_all_configurations
206
+ require_relative "../configuration"
207
+ providers = Ace::LLM.providers
208
+
209
+ providers.each do |provider_name, config|
210
+ # Validate required fields
211
+ unless config["name"] && config["class"]
212
+ warn "Invalid provider configuration for #{provider_name}: missing 'name' or 'class'"
213
+ next
214
+ end
215
+
216
+ normalized_name = normalize_provider_name(config["name"])
217
+ @providers[normalized_name] = config
218
+ end
219
+ rescue => e
220
+ warn "Error loading provider configurations: #{e.message}"
221
+ end
222
+
223
+ # Load a single provider with cascade (deep merge)
224
+ # @param resolver [Ace::Support::Config::Organisms::ConfigResolver] Config resolver
225
+ # @param filename [String] Provider config filename (e.g., "anthropic.yml")
226
+ def load_provider_with_cascade(resolver, filename)
227
+ config = resolver.resolve_file(["llm/providers/#{filename}"]).data
228
+
229
+ # Validate required fields
230
+ unless config["name"] && config["class"]
231
+ warn "Invalid provider configuration in #{filename}: missing 'name' or 'class'"
232
+ return
233
+ end
234
+
235
+ provider_name = normalize_provider_name(config["name"])
236
+ @providers[provider_name] = config
237
+ rescue Ace::Support::Config::YamlParseError => e
238
+ warn "Error parsing provider config #{filename}: #{e.message}"
239
+ rescue => e
240
+ warn "Error loading provider #{filename}: #{e.message}"
241
+ end
242
+
243
+ # Normalize provider name for consistency
244
+ # @param name [String] Provider name
245
+ # @return [String] Normalized name
246
+ def normalize_provider_name(name)
247
+ name.to_s.strip.downcase.gsub(/[-_]/, "")
248
+ end
249
+
250
+ # Load provider gem if not already loaded
251
+ # @param provider_config [Hash] Provider configuration
252
+ # @raise [LoadError] If gem cannot be loaded
253
+ def load_provider_gem(provider_config)
254
+ gem_name = provider_config["gem"]
255
+ return unless gem_name
256
+
257
+ # Check if already loaded
258
+ return if @loaded_gems[gem_name]
259
+
260
+ # Try to require the gem
261
+ begin
262
+ require gem_name.tr("-", "/")
263
+ @loaded_gems[gem_name] = true
264
+ rescue LoadError => e
265
+ raise LoadError, "Cannot load provider gem '#{gem_name}': #{e.message}"
266
+ end
267
+ end
268
+
269
+ # Resolve class constant from string
270
+ # @param class_name [String] Full class name (e.g., "Ace::LLM::Organisms::GoogleClient")
271
+ # @return [Class] The resolved class
272
+ # @raise [NameError] If class cannot be resolved
273
+ def resolve_class(class_name)
274
+ class_name.split("::").inject(Object) do |mod, name|
275
+ mod.const_get(name)
276
+ end
277
+ rescue NameError => e
278
+ raise NameError, "Cannot resolve class '#{class_name}': #{e.message}"
279
+ end
280
+
281
+ # Resolve API key from configuration
282
+ # @param api_key_config [Hash, String, nil] API key configuration
283
+ # @return [String, nil] Resolved API key
284
+ def resolve_api_key(api_key_config)
285
+ return nil if api_key_config.nil?
286
+
287
+ # Simple string means direct key
288
+ return api_key_config if api_key_config.is_a?(String)
289
+
290
+ # Hash configuration
291
+ if api_key_config.is_a?(Hash)
292
+ if api_key_config["env"]
293
+ # Read from environment variable
294
+ ENV[api_key_config["env"]]
295
+ elsif api_key_config["value"]
296
+ # Direct value (not recommended)
297
+ api_key_config["value"]
298
+ end
299
+ end
300
+ end
301
+
302
+ # Check if API key is present
303
+ # @param api_key_config [Hash, String, nil] API key configuration
304
+ # @return [Boolean] True if API key is configured and present
305
+ def api_key_present?(api_key_config)
306
+ return false if api_key_config.nil?
307
+
308
+ key = resolve_api_key(api_key_config)
309
+ !key.nil? && !key.empty?
310
+ end
311
+
312
+ # Build alias maps from provider configurations
313
+ def build_alias_maps
314
+ @providers.each do |provider_name, config|
315
+ next unless config["aliases"]
316
+
317
+ # Process global aliases
318
+ if config["aliases"]["global"]
319
+ config["aliases"]["global"].each do |alias_name, target|
320
+ @global_aliases[alias_name] = target
321
+ end
322
+ end
323
+
324
+ # Process model aliases
325
+ if config["aliases"]["model"]
326
+ @model_aliases[provider_name] ||= {}
327
+ config["aliases"]["model"].each do |alias_name, model|
328
+ @model_aliases[provider_name][alias_name] = model
329
+ end
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/config"
4
+
5
+ module Ace
6
+ module LLM
7
+ module Molecules
8
+ # Config loader for ace-llm using Ace::Support::Config cascade
9
+ class ConfigLoader
10
+ class << self
11
+ # Load configuration from cascade (project → home → gem)
12
+ # Uses resolve_namespace("llm") to load from llm/ subfolder
13
+ def load
14
+ Ace::Support::Config.create(
15
+ config_dir: ".ace",
16
+ defaults_dir: ".ace-defaults",
17
+ gem_path: gem_root
18
+ ).resolve_namespace("llm")
19
+ end
20
+
21
+ # Get configuration value by path
22
+ # @param path [String] Dot-separated path like "llm.timeout"
23
+ # @return [Object] Value at path or nil
24
+ def get(path)
25
+ config = load
26
+ keys = path.split(".")
27
+ config.get(*keys)
28
+ end
29
+
30
+ # Find gem root directory
31
+ # From lib/ace/llm/molecules/config_loader.rb, go 4 levels up to ace-llm/
32
+ def gem_root
33
+ @gem_root ||= File.expand_path("../../../..", __dir__)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/error_classifier"
4
+ require_relative "../models/fallback_config"
5
+
6
+ module Ace
7
+ module LLM
8
+ module Molecules
9
+ # FallbackOrchestrator manages provider fallback chain execution
10
+ # This is a molecule - it coordinates between atoms and handles complex logic
11
+ class FallbackOrchestrator
12
+ attr_reader :config, :status_callback
13
+
14
+ # @param config [Models::FallbackConfig] Fallback configuration
15
+ # @param status_callback [Proc, nil] Optional callback for status messages
16
+ # @param timeout [Integer, nil] Request timeout in seconds to forward to each client
17
+ def initialize(config:, status_callback: nil, timeout: nil)
18
+ @config = config
19
+ @status_callback = status_callback
20
+ @timeout = timeout
21
+ @visited_providers = Set.new
22
+ @start_time = nil
23
+ @last_failure_terminal = false
24
+ end
25
+
26
+ # Execute a block with fallback support
27
+ # @param primary_provider [String] Primary provider name
28
+ # @param registry [Molecules::ClientRegistry] Client registry for getting fallback providers
29
+ # @yield Block to execute with current provider
30
+ # @yieldparam client [Object] Provider client
31
+ # @return [Object] Result from successful execution
32
+ # @raise [Error] If all providers and retries exhausted
33
+ def execute(primary_provider:, registry:)
34
+ @start_time = Time.now
35
+ @visited_providers.clear
36
+
37
+ # If fallback disabled, just execute with primary
38
+ return yield get_client(primary_provider, registry) if @config.disabled?
39
+
40
+ # Try primary provider with retries
41
+ result = try_provider_with_retry(primary_provider, registry) { |client| yield client }
42
+ return result if result
43
+
44
+ # Try fallback providers in order (per-provider chain or default)
45
+ @config.providers_for(primary_provider).each do |fallback_provider|
46
+ # Skip if we've already tried this provider
47
+ next if @visited_providers.include?(fallback_provider)
48
+
49
+ # Check total timeout
50
+ if timeout_exceeded?
51
+ report_status("⚠ Total timeout exceeded (#{@config.max_total_timeout}s)") unless @last_failure_terminal
52
+ break
53
+ end
54
+
55
+ report_status("ℹ Trying fallback provider #{fallback_provider}...")
56
+
57
+ result = try_provider_with_retry(fallback_provider, registry) { |client| yield client }
58
+ return result if result
59
+ end
60
+
61
+ # All providers exhausted
62
+ raise Ace::LLM::ProviderError, build_exhaustion_error_message
63
+ end
64
+
65
+ private
66
+
67
+ # Try a provider with retry logic
68
+ # @param provider_name [String] Provider name
69
+ # @param registry [Molecules::ClientRegistry] Client registry
70
+ # @yield Block to execute with client
71
+ # @return [Object, nil] Result if successful, nil if all retries failed
72
+ def try_provider_with_retry(provider_name, registry)
73
+ @visited_providers << provider_name
74
+ attempts = 0
75
+ last_error = nil
76
+ @last_failure_terminal = false
77
+
78
+ loop do
79
+ client = get_client(provider_name, registry)
80
+ return yield client
81
+ rescue => error
82
+ last_error = error
83
+
84
+ # Handle the error - returns :retry or :stop_and_fallback
85
+ action = handle_error(error, provider_name, attempts)
86
+
87
+ if action == :retry
88
+ attempts += 1
89
+ next
90
+ else # :stop_and_fallback
91
+ return nil
92
+ end
93
+ end
94
+ end
95
+
96
+ # Handle error and determine retry/fallback strategy
97
+ # @param error [Exception] The error that occurred
98
+ # @param provider_name [String] Provider name
99
+ # @param attempts [Integer] Current attempt number
100
+ # @return [Symbol] :retry to retry, :stop_and_fallback to move to next provider
101
+ def handle_error(error, provider_name, attempts)
102
+ classification = Atoms::ErrorClassifier.classify(error)
103
+
104
+ case classification
105
+ when Atoms::ErrorClassifier::SKIP_TO_NEXT
106
+ @last_failure_terminal = false
107
+ report_status("⚠ #{provider_name} authentication failed, skipping...")
108
+ :stop_and_fallback
109
+ when Atoms::ErrorClassifier::FALLBACK_IMMEDIATELY
110
+ @last_failure_terminal = false
111
+ reason = if Atoms::ErrorClassifier.quota_or_credit_limited?(error)
112
+ "quota/credit/window limit reached"
113
+ else
114
+ "timeout"
115
+ end
116
+ report_status("⚠ #{provider_name} #{reason}, trying next provider...")
117
+ :stop_and_fallback
118
+ when Atoms::ErrorClassifier::RETRYABLE_WITH_BACKOFF
119
+ @last_failure_terminal = false
120
+ if attempts < @config.retry_count && !timeout_exceeded?
121
+ delay = Atoms::ErrorClassifier.retry_delay(
122
+ error,
123
+ attempt: attempts + 1,
124
+ base_delay: @config.retry_delay
125
+ )
126
+ report_status("⚠ #{provider_name} unavailable (#{extract_error_code(error)}), retrying... (attempt #{attempts + 2}/#{@config.retry_count + 1})")
127
+ wait(delay)
128
+ :retry
129
+ else
130
+ report_status("⚠ #{provider_name} unavailable after #{attempts + 1} retries")
131
+ :stop_and_fallback
132
+ end
133
+ when Atoms::ErrorClassifier::TERMINAL
134
+ @last_failure_terminal = true
135
+ report_status("⚠ #{provider_name} error: #{error.message}")
136
+ :stop_and_fallback
137
+ end
138
+ end
139
+
140
+ # Get a client from the registry
141
+ # @param provider_name [String] Provider name
142
+ # @param registry [Molecules::ClientRegistry] Client registry
143
+ # @return [Object] Provider client
144
+ def get_client(provider_name, registry)
145
+ opts = {}
146
+ opts[:timeout] = @timeout if @timeout
147
+
148
+ # Parse provider:model format if present
149
+ if provider_name.include?(":")
150
+ provider, model = provider_name.split(":", 2)
151
+ registry.get_client(provider, model: model, **opts)
152
+ else
153
+ registry.get_client(provider_name, **opts)
154
+ end
155
+ end
156
+
157
+ # Check if total timeout exceeded
158
+ # @return [Boolean]
159
+ def timeout_exceeded?
160
+ return false unless @start_time
161
+
162
+ elapsed = Time.now - @start_time
163
+ elapsed >= @config.max_total_timeout
164
+ end
165
+
166
+ # Extract error code from error message
167
+ # @param error [Exception] Error
168
+ # @return [String] Error code or type
169
+ def extract_error_code(error)
170
+ status = Atoms::ErrorClassifier.extract_status_code(error)
171
+ return status.to_s if status
172
+
173
+ # Try to identify error type
174
+ case error
175
+ when Faraday::TimeoutError
176
+ "timeout"
177
+ when Faraday::ConnectionFailed
178
+ "connection failed"
179
+ when Ace::LLM::ProviderError
180
+ "provider error"
181
+ else
182
+ "error"
183
+ end
184
+ end
185
+
186
+ # Build error message for when all providers are exhausted
187
+ # @return [String] Error message
188
+ def build_exhaustion_error_message
189
+ providers_tried = @visited_providers.to_a.join(", ")
190
+
191
+ msg = "All configured providers unavailable. "
192
+ msg += "Tried: #{providers_tried}. "
193
+ msg += "\nTry:\n"
194
+ msg += " - Check provider status pages\n"
195
+ msg += " - Configure additional providers\n"
196
+ msg += " - Retry in a few minutes\n"
197
+ msg += " - Run with --debug for detailed errors"
198
+ msg
199
+ end
200
+
201
+ # Report status message via callback
202
+ # @param message [String] Status message
203
+ def report_status(message)
204
+ return unless @status_callback
205
+
206
+ @status_callback.call(message)
207
+ end
208
+
209
+ # Wait for specified duration
210
+ # Extracted to protected method for easier testing without actual sleep
211
+ # @param duration [Float] Duration in seconds
212
+ def wait(duration)
213
+ sleep(duration)
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end