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.
- checksums.yaml +7 -0
- data/.ace-defaults/llm/config.yml +31 -0
- data/.ace-defaults/llm/presets/claude/prompt.yml +5 -0
- data/.ace-defaults/llm/presets/claude/ro.yml +6 -0
- data/.ace-defaults/llm/presets/claude/rw.yml +4 -0
- data/.ace-defaults/llm/presets/claude/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/codex/ro.yml +5 -0
- data/.ace-defaults/llm/presets/codex/rw.yml +3 -0
- data/.ace-defaults/llm/presets/codex/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/gemini/ro.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/rw.yml +4 -0
- data/.ace-defaults/llm/presets/gemini/yolo.yml +4 -0
- data/.ace-defaults/llm/presets/opencode/ro.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/rw.yml +1 -0
- data/.ace-defaults/llm/presets/opencode/yolo.yml +3 -0
- data/.ace-defaults/llm/presets/pi/ro.yml +1 -0
- data/.ace-defaults/llm/presets/pi/rw.yml +1 -0
- data/.ace-defaults/llm/presets/pi/yolo.yml +1 -0
- data/.ace-defaults/llm/providers/anthropic.yml +34 -0
- data/.ace-defaults/llm/providers/google.yml +36 -0
- data/.ace-defaults/llm/providers/groq.yml +29 -0
- data/.ace-defaults/llm/providers/lmstudio.yml +24 -0
- data/.ace-defaults/llm/providers/mistral.yml +33 -0
- data/.ace-defaults/llm/providers/openai.yml +33 -0
- data/.ace-defaults/llm/providers/openrouter.yml +45 -0
- data/.ace-defaults/llm/providers/togetherai.yml +26 -0
- data/.ace-defaults/llm/providers/xai.yml +30 -0
- data/.ace-defaults/llm/providers/zai.yml +18 -0
- data/.ace-defaults/llm/thinking/claude/high.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/low.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/claude/xhigh.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/high.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/low.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/medium.yml +3 -0
- data/.ace-defaults/llm/thinking/codex/xhigh.yml +3 -0
- data/.ace-defaults/nav/protocols/guide-sources/ace-llm.yml +10 -0
- data/CHANGELOG.md +641 -0
- data/LICENSE +21 -0
- data/README.md +42 -0
- data/Rakefile +14 -0
- data/exe/ace-llm +25 -0
- data/handbook/guides/llm-query-tool-reference.g.md +683 -0
- data/handbook/templates/agent/plan-mode.template.md +48 -0
- data/lib/ace/llm/atoms/env_reader.rb +155 -0
- data/lib/ace/llm/atoms/error_classifier.rb +200 -0
- data/lib/ace/llm/atoms/http_client.rb +162 -0
- data/lib/ace/llm/atoms/provider_config_validator.rb +260 -0
- data/lib/ace/llm/atoms/xdg_directory_resolver.rb +189 -0
- data/lib/ace/llm/cli/commands/query.rb +280 -0
- data/lib/ace/llm/cli.rb +24 -0
- data/lib/ace/llm/configuration.rb +180 -0
- data/lib/ace/llm/models/fallback_config.rb +216 -0
- data/lib/ace/llm/molecules/client_registry.rb +336 -0
- data/lib/ace/llm/molecules/config_loader.rb +39 -0
- data/lib/ace/llm/molecules/fallback_orchestrator.rb +218 -0
- data/lib/ace/llm/molecules/file_io_handler.rb +158 -0
- data/lib/ace/llm/molecules/format_handlers.rb +183 -0
- data/lib/ace/llm/molecules/llm_alias_resolver.rb +50 -0
- data/lib/ace/llm/molecules/openai_compatible_params.rb +21 -0
- data/lib/ace/llm/molecules/preset_loader.rb +99 -0
- data/lib/ace/llm/molecules/provider_loader.rb +198 -0
- data/lib/ace/llm/molecules/provider_model_parser.rb +172 -0
- data/lib/ace/llm/molecules/thinking_level_loader.rb +83 -0
- data/lib/ace/llm/organisms/anthropic_client.rb +213 -0
- data/lib/ace/llm/organisms/base_client.rb +264 -0
- data/lib/ace/llm/organisms/google_client.rb +187 -0
- data/lib/ace/llm/organisms/groq_client.rb +197 -0
- data/lib/ace/llm/organisms/lmstudio_client.rb +146 -0
- data/lib/ace/llm/organisms/mistral_client.rb +180 -0
- data/lib/ace/llm/organisms/openai_client.rb +195 -0
- data/lib/ace/llm/organisms/openrouter_client.rb +216 -0
- data/lib/ace/llm/organisms/togetherai_client.rb +184 -0
- data/lib/ace/llm/organisms/xai_client.rb +213 -0
- data/lib/ace/llm/organisms/zai_client.rb +149 -0
- data/lib/ace/llm/query_interface.rb +455 -0
- data/lib/ace/llm/version.rb +7 -0
- data/lib/ace/llm.rb +61 -0
- 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
|