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,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/support/cli"
5
+
6
+ module Ace
7
+ module LLM
8
+ module CLI
9
+ module Commands
10
+ # Query command for ace-llm
11
+ class Query < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+
14
+ desc "Query an LLM provider"
15
+
16
+ argument :provider_model, required: false, desc: "PROVIDER[:MODEL] or alias (e.g., gflash, google:gemini-2.5-flash)"
17
+ argument :prompt_text, required: false, desc: "Prompt text (can also use --prompt flag)"
18
+
19
+ option :quiet, type: :boolean, default: false, desc: "Suppress non-essential output"
20
+ option :verbose, type: :boolean, default: false, desc: "Show verbose output"
21
+ option :debug, type: :boolean, default: false, desc: "Show debug output"
22
+
23
+ option :output, type: :string, aliases: %w[o], desc: "Output file path"
24
+ option :format, type: :string, aliases: %w[f], desc: "Output format (text, json, markdown)"
25
+ option :temperature, type: :float, aliases: %w[t], desc: "Temperature (0.0-2.0)"
26
+ option :max_tokens, type: :integer, aliases: %w[m], desc: "Maximum output tokens"
27
+ option :system, type: :string, aliases: %w[s], desc: "System instruction/prompt"
28
+ option :system_append, type: :string, desc: "Append to system prompt"
29
+ option :preset, type: :string, desc: "Execution preset name (or use model@preset)"
30
+ option :cli_args, type: :string, desc: "Extra args for CLI providers (auto-prefixed with --; use --flag value or flag=value for values)"
31
+ option :timeout, type: :integer, desc: "Request timeout in seconds"
32
+ option :model, type: :string, desc: "Model name (overrides PROVIDER[:MODEL])"
33
+ option :prompt, type: :string, desc: "Prompt text (overrides positional PROMPT)"
34
+ option :force, type: :boolean, default: false, desc: "Force overwrite existing files"
35
+
36
+ option :version, type: :boolean, desc: "Show version information"
37
+ option :list_providers, type: :boolean, desc: "List available LLM providers"
38
+
39
+ def call(provider_model: nil, prompt_text: nil, **options)
40
+ if options[:version]
41
+ puts "ace-llm #{Ace::LLM::VERSION}"
42
+ return
43
+ end
44
+
45
+ if options[:list_providers]
46
+ list_providers
47
+ return
48
+ end
49
+
50
+ @provider_model = provider_model
51
+ if @provider_model.nil? && options[:model]
52
+ @provider_model = options[:model]
53
+ @model_from_option = true
54
+ end
55
+
56
+ @prompt = options[:prompt] || prompt_text
57
+
58
+ return show_help if @provider_model.nil? && @prompt.nil?
59
+ return show_provider_help if @prompt.nil? || @prompt.empty?
60
+ return show_help if @provider_model.nil? || @provider_model.empty?
61
+
62
+ display_config_summary(options)
63
+ execute_query(options)
64
+ rescue Ace::LLM::Error => e
65
+ raise Ace::Support::Cli::Error.new(e.message)
66
+ rescue ArgumentError, EncodingError => e
67
+ raise Ace::Support::Cli::Error.new(e.message)
68
+ end
69
+
70
+ private
71
+
72
+ def show_help
73
+ puts "Usage: ace-llm PROVIDER[:MODEL] [PROMPT] [options]"
74
+ puts " ace-llm PROVIDER --prompt PROMPT [options]"
75
+ puts " ace-llm PROVIDER PROMPT --model MODEL [options]"
76
+ puts ""
77
+ puts "Query any LLM provider through a unified interface"
78
+ puts ""
79
+ puts "Options:"
80
+ puts " -o, --output FILE Output file path"
81
+ puts " -f, --format FORMAT Output format (text, json, markdown)"
82
+ puts " -t, --temperature FLOAT Temperature (0.0-2.0)"
83
+ puts " -m, --max-tokens INT Maximum output tokens"
84
+ puts " -s, --system TEXT System instruction/prompt"
85
+ puts " --system-append TEXT Append to system prompt"
86
+ puts " --preset NAME Execution preset name (or use model@preset)"
87
+ puts " --cli-args TEXT Extra args for CLI providers (auto-prefixed with --; use --flag value or flag=value)"
88
+ puts " --timeout SECONDS Request timeout in seconds"
89
+ puts " --model MODEL Model name (overrides PROVIDER[:MODEL])"
90
+ puts " --prompt PROMPT Prompt text (overrides positional PROMPT)"
91
+ puts " --force Force overwrite existing files"
92
+ puts " -q, --quiet Suppress config summary output"
93
+ puts " -d, --debug Enable debug output"
94
+ puts " -h, --help Show this help message"
95
+ puts ""
96
+ puts "Examples:"
97
+ puts ' ace-llm google:gemini-2.5-flash "What is Ruby?"'
98
+ puts ' ace-llm gflash "Quick question" # using alias'
99
+ puts ' ace-llm gflash@ro "Summarize this diff"'
100
+ puts ' ace-llm codex:gpt-5:high@ro "Review this code"'
101
+ puts ' ace-llm claude:sonnet "Summarize this diff" --preset rw'
102
+ puts ' ace-llm claude:sonnet "Hi" --cli-args "dangerously-skip-permissions"'
103
+ puts ' ace-llm claude:sonnet "Hi" --cli-args "--model=claude-sonnet-4-0 --verbose"'
104
+ puts ""
105
+ puts "Provider Aliases:"
106
+ puts " Short aliases for common provider:MODEL combinations:"
107
+ puts " gflash → google:gemini-2.5-flash"
108
+ puts " glite → google:gemini-2.0-flash-lite"
109
+ puts " gpt4 → openai:gpt-4"
110
+ puts " claude → anthropic:claude-3-5-sonnet"
111
+ end
112
+
113
+ def show_provider_help
114
+ puts "Available aliases for '#{@provider_model}':"
115
+ puts ""
116
+
117
+ resolver = Ace::LLM::Molecules::LlmAliasResolver.new
118
+ aliases = resolver.available_aliases
119
+
120
+ if aliases[:global] && !aliases[:global].empty?
121
+ puts "Global aliases:"
122
+ aliases[:global].each do |alias_name, target|
123
+ puts " #{alias_name} → #{target}"
124
+ end
125
+ end
126
+
127
+ puts ""
128
+ puts "Use: ace-llm #{@provider_model} \"your prompt here\""
129
+ end
130
+
131
+ def display_config_summary(options)
132
+ return if quiet?(options)
133
+
134
+ require "ace/core"
135
+ summary_keys = %w[provider_model preset temperature max_tokens format timeout system_append cli_args]
136
+ Ace::Core::Atoms::ConfigSummary.display(
137
+ command: "query",
138
+ config: options.merge(provider_model: @provider_model),
139
+ defaults: {},
140
+ options: options,
141
+ quiet: false,
142
+ summary_keys: summary_keys
143
+ )
144
+ end
145
+
146
+ def execute_query(options)
147
+ file_handler = Ace::LLM::Molecules::FileIoHandler.new
148
+ prompt_text = file_handler.read_content(@prompt)
149
+ system_text = options[:system] ? file_handler.read_content(options[:system]) : nil
150
+ system_append_text = options[:system_append] ? file_handler.read_content(options[:system_append]) : nil
151
+ normalized_timeout = normalize_timeout(options[:timeout])
152
+
153
+ resolved_model_override = @model_from_option ? nil : options[:model]
154
+ response = Ace::LLM::QueryInterface.query(
155
+ @provider_model,
156
+ prompt_text,
157
+ temperature: options[:temperature],
158
+ max_tokens: options[:max_tokens],
159
+ system: system_text,
160
+ timeout: normalized_timeout,
161
+ debug: options[:debug],
162
+ model: resolved_model_override,
163
+ cli_args: options[:cli_args],
164
+ system_append: system_append_text,
165
+ preset: options[:preset]
166
+ )
167
+
168
+ output_response(response, options)
169
+ rescue Ace::LLM::ProviderError => e
170
+ if e.message.include?("not found") || e.message.include?("not registered")
171
+ available = begin
172
+ Ace::LLM::ClientRegistry.available_providers
173
+ rescue
174
+ []
175
+ end
176
+ raise Ace::Support::Cli::Error, "#{e.message}\nAvailable providers: #{available.join(", ")}"
177
+ end
178
+ raise
179
+ end
180
+
181
+ def output_response(response, options)
182
+ format = options[:format] || "text"
183
+ handler = Ace::LLM::Molecules::FormatHandlers.get_handler(format)
184
+ formatted_output = handler.format(response)
185
+
186
+ if options[:output]
187
+ file_handler = Ace::LLM::Molecules::FileIoHandler.new
188
+ file_handler.write_content(
189
+ formatted_output,
190
+ options[:output],
191
+ format: format,
192
+ force: options[:force]
193
+ )
194
+
195
+ puts handler.generate_summary(response, options[:output])
196
+ else
197
+ puts formatted_output
198
+ end
199
+ end
200
+
201
+ def list_providers
202
+ require "ace/llm/molecules/client_registry"
203
+
204
+ registry = Ace::LLM::Molecules::ClientRegistry.new
205
+ providers = registry.list_providers_with_status
206
+ configuration = Ace::LLM.configuration
207
+
208
+ if configuration.provider_filter_applied?
209
+ total = configuration.configured_provider_names.length
210
+ puts "Available LLM Providers (filtered - #{providers.length} of #{total} active):"
211
+ else
212
+ puts "Available LLM Providers:"
213
+ end
214
+ puts ""
215
+
216
+ providers.each do |provider|
217
+ status = provider[:available] ? "\u2713" : "\u2717"
218
+ api_status = if provider[:api_key_required]
219
+ provider[:api_key_present] ? "API key configured" : "API key required"
220
+ else
221
+ "No API key needed"
222
+ end
223
+
224
+ models = provider[:models] || []
225
+ model_count = models.empty? ? "" : " \u00b7 #{models.length} models"
226
+ puts "#{status} #{provider[:name]}#{model_count} (#{api_status})"
227
+
228
+ print_wrapped_list(models, indent: " ") unless models.empty?
229
+ puts " Gem required: #{provider[:gem]}" unless provider[:available]
230
+ puts ""
231
+ end
232
+
233
+ return unless configuration.provider_filter_applied?
234
+
235
+ inactive = configuration.inactive_provider_names
236
+ return if inactive.empty?
237
+
238
+ puts "Inactive providers (#{inactive.length}):"
239
+ print_wrapped_list(inactive, indent: " ")
240
+ puts ""
241
+ end
242
+
243
+ def print_wrapped_list(items, indent: " ", max_width: 78)
244
+ current_line = indent.dup
245
+
246
+ items.each_with_index do |item, i|
247
+ is_last = i == items.length - 1
248
+ entry = is_last ? item.to_s : "#{item},"
249
+
250
+ if current_line == indent
251
+ current_line << entry
252
+ elsif current_line.length + 1 + entry.length > max_width
253
+ puts current_line
254
+ current_line = "#{indent}#{entry}"
255
+ else
256
+ current_line << " #{entry}"
257
+ end
258
+ end
259
+
260
+ puts current_line unless current_line.strip.empty?
261
+ end
262
+
263
+ def error_output(message)
264
+ warn "Error: #{message}"
265
+ end
266
+
267
+ def normalize_timeout(value)
268
+ return nil if value.nil?
269
+ return value if value.is_a?(Numeric)
270
+
271
+ normalized = value.to_s.strip
272
+ Float(normalized)
273
+ rescue ArgumentError
274
+ raise ArgumentError, "timeout must be numeric, got #{value.inspect}"
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../llm"
6
+ # Commands
7
+ require_relative "cli/commands/query"
8
+
9
+ module Ace
10
+ module LLM
11
+ # CLI namespace for ace-llm command loading.
12
+ #
13
+ # ace-llm uses a single-command ace-support-cli entrypoint that calls
14
+ # CLI::Commands::Query directly from the executable.
15
+ module CLI
16
+ # Entry point for CLI invocation (used by tests via cli_helpers)
17
+ #
18
+ # @param args [Array<String>] Command-line arguments
19
+ def self.start(args)
20
+ Ace::Support::Cli::Runner.new(Commands::Query).call(args: args)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "molecules/config_loader"
4
+
5
+ module Ace
6
+ module LLM
7
+ # Central configuration management for ace-llm
8
+ class Configuration
9
+ attr_reader :config
10
+
11
+ def initialize
12
+ @config = Molecules::ConfigLoader.load
13
+ end
14
+
15
+ # Get all provider configurations from the cascade
16
+ # Uses ProviderConfigReader which handles project → home → gem discovery
17
+ def providers
18
+ @providers ||= filter_active_providers(all_providers)
19
+ end
20
+
21
+ # Get all configured provider configurations before allow-list filtering
22
+ def all_providers
23
+ @all_providers ||= begin
24
+ require "ace/support/models"
25
+ Ace::Support::Models::Atoms::ProviderConfigReader.read_all
26
+ end
27
+ end
28
+
29
+ # Get provider config by name
30
+ def provider(name)
31
+ normalized = normalize_provider_name(name)
32
+ _name, config = providers.find do |provider_name, provider_config|
33
+ normalize_provider_name(provider_name) == normalized ||
34
+ normalize_provider_name(provider_config["name"]) == normalized
35
+ end
36
+ config
37
+ end
38
+
39
+ # Check if provider exists
40
+ def provider?(name)
41
+ !provider(name).nil?
42
+ end
43
+
44
+ # Get all provider names
45
+ def provider_names
46
+ providers.keys
47
+ end
48
+
49
+ # Reload configuration
50
+ def reload!
51
+ @config = Molecules::ConfigLoader.load
52
+ @all_providers = nil
53
+ @providers = nil
54
+ self
55
+ end
56
+
57
+ # Get configuration value by path
58
+ def get(path)
59
+ Molecules::ConfigLoader.get(path)
60
+ end
61
+
62
+ # Check if configuration exists
63
+ def configured?
64
+ !config.empty?
65
+ end
66
+
67
+ # Returns true when allow-list filtering is active.
68
+ def provider_filter_applied?
69
+ !active_provider_allow_list.nil?
70
+ end
71
+
72
+ # Normalized names of all configured providers.
73
+ def configured_provider_names
74
+ all_providers.map do |provider_name, provider_config|
75
+ normalize_provider_name(provider_config["name"] || provider_name)
76
+ end.uniq.sort
77
+ end
78
+
79
+ # Normalized names of active providers after filtering.
80
+ def active_provider_names
81
+ providers.map do |provider_name, provider_config|
82
+ normalize_provider_name(provider_config["name"] || provider_name)
83
+ end.uniq.sort
84
+ end
85
+
86
+ # Normalized names that are configured but inactive under active filter.
87
+ def inactive_provider_names
88
+ return [] unless provider_filter_applied?
89
+
90
+ configured_provider_names - active_provider_names
91
+ end
92
+
93
+ # Returns true when provider exists in config but is excluded by allow-list.
94
+ def provider_inactive?(name)
95
+ normalized = normalize_provider_name(name)
96
+ return false if normalized.empty?
97
+ return false unless provider_filter_applied?
98
+
99
+ configured_provider_names.include?(normalized) && !active_provider_names.include?(normalized)
100
+ end
101
+
102
+ private
103
+
104
+ def filter_active_providers(provider_configs)
105
+ active_allow_list = active_provider_allow_list
106
+ return provider_configs if active_allow_list.nil?
107
+
108
+ filtered = provider_configs.select do |provider_name, provider_config|
109
+ normalized = normalize_provider_name(provider_config["name"] || provider_name)
110
+ active_allow_list.include?(normalized)
111
+ end
112
+
113
+ warn_on_unknown_active_entries(provider_configs, active_allow_list)
114
+ filtered
115
+ end
116
+
117
+ def warn_on_unknown_active_entries(provider_configs, active_allow_list)
118
+ available = provider_configs.map do |provider_name, provider_config|
119
+ normalize_provider_name(provider_config["name"] || provider_name)
120
+ end.uniq
121
+
122
+ unknown = active_allow_list - available
123
+ return if unknown.empty?
124
+
125
+ warn "Unknown providers in llm.providers.active: #{unknown.join(", ")} (ignored)"
126
+ end
127
+
128
+ def active_provider_allow_list
129
+ env_present, env_active = active_provider_allow_list_from_env
130
+ return env_active if env_present
131
+
132
+ normalize_provider_allow_list(get("llm.providers.active"))
133
+ end
134
+
135
+ def active_provider_allow_list_from_env
136
+ return [false, nil] unless ENV.key?("ACE_LLM_PROVIDERS_ACTIVE")
137
+
138
+ raw = ENV["ACE_LLM_PROVIDERS_ACTIVE"].to_s
139
+ parsed = normalize_provider_allow_list(raw.split(","))
140
+
141
+ # Empty env explicitly disables filtering (same as no active list).
142
+ [true, parsed]
143
+ end
144
+
145
+ def normalize_provider_allow_list(value)
146
+ normalized = Array(value).map { |entry| normalize_provider_name(entry) }.reject(&:empty?).uniq
147
+ normalized.empty? ? nil : normalized
148
+ end
149
+
150
+ def normalize_provider_name(name)
151
+ name.to_s.strip.downcase.gsub(/[-_]/, "")
152
+ end
153
+ end
154
+
155
+ # Module-level configuration accessor
156
+ def self.configuration
157
+ @configuration ||= Configuration.new
158
+ end
159
+
160
+ # Configure block
161
+ def self.configure
162
+ yield(configuration)
163
+ end
164
+
165
+ # Reset configuration
166
+ def self.reset_configuration!
167
+ @configuration = Configuration.new
168
+ end
169
+
170
+ # Get all providers (convenience method)
171
+ def self.providers
172
+ configuration.providers
173
+ end
174
+
175
+ # Get provider config by name (convenience method)
176
+ def self.provider(name)
177
+ configuration.provider(name)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module LLM
5
+ module Models
6
+ # FallbackConfig represents fallback configuration settings
7
+ # This is a model - pure data structure with validation
8
+ class FallbackConfig
9
+ attr_reader :enabled, :retry_count, :retry_delay, :providers, :chains, :max_total_timeout
10
+
11
+ # Default configuration values
12
+ DEFAULT_ENABLED = true
13
+ DEFAULT_RETRY_COUNT = 3
14
+ DEFAULT_RETRY_DELAY = 1.0
15
+ DEFAULT_PROVIDERS = [].freeze
16
+ DEFAULT_CHAINS = {}.freeze
17
+ DEFAULT_MAX_TOTAL_TIMEOUT = 30.0
18
+
19
+ # @param enabled [Boolean] Whether fallback is enabled
20
+ # @param retry_count [Integer] Number of retries before fallback
21
+ # @param retry_delay [Float] Initial retry delay in seconds
22
+ # @param providers [Array<String>] Default fallback provider chain
23
+ # @param chains [Hash{String => Array<String>}] Per-provider fallback chains
24
+ # @param max_total_timeout [Float] Maximum total time for all retries and fallbacks
25
+ def initialize(enabled: DEFAULT_ENABLED,
26
+ retry_count: DEFAULT_RETRY_COUNT,
27
+ retry_delay: DEFAULT_RETRY_DELAY,
28
+ providers: DEFAULT_PROVIDERS,
29
+ chains: DEFAULT_CHAINS,
30
+ max_total_timeout: DEFAULT_MAX_TOTAL_TIMEOUT)
31
+ @enabled = enabled
32
+ @retry_count = retry_count
33
+ @retry_delay = retry_delay
34
+ @providers = providers.freeze
35
+ @chains = chains
36
+ @max_total_timeout = max_total_timeout
37
+
38
+ validate!
39
+
40
+ @chains = normalize_chains(@chains).freeze
41
+ end
42
+
43
+ # Create FallbackConfig from a hash (e.g., from YAML)
44
+ # @param hash [Hash] Configuration hash
45
+ # @return [FallbackConfig] New instance
46
+ def self.from_hash(hash)
47
+ return new unless hash
48
+
49
+ new(
50
+ enabled: fetch_key(hash, :enabled, DEFAULT_ENABLED),
51
+ retry_count: fetch_key(hash, :retry_count, DEFAULT_RETRY_COUNT),
52
+ retry_delay: fetch_key(hash, :retry_delay, DEFAULT_RETRY_DELAY),
53
+ providers: fetch_key(hash, :providers, DEFAULT_PROVIDERS),
54
+ chains: fetch_key(hash, :chains, DEFAULT_CHAINS),
55
+ max_total_timeout: fetch_key(hash, :max_total_timeout, DEFAULT_MAX_TOTAL_TIMEOUT)
56
+ )
57
+ end
58
+
59
+ # Return fallback providers for a specific primary provider
60
+ # Uses per-provider chain if configured, otherwise falls back to default providers
61
+ # @param primary [String] Primary provider name
62
+ # @return [Array<String>] Ordered fallback provider list
63
+ def providers_for(primary)
64
+ key = primary.to_s
65
+ @chains[key] || @providers
66
+ end
67
+
68
+ # Convert to hash representation
69
+ # @return [Hash] Configuration as hash
70
+ def to_h
71
+ {
72
+ enabled: @enabled,
73
+ retry_count: @retry_count,
74
+ retry_delay: @retry_delay,
75
+ providers: @providers.dup,
76
+ chains: @chains.transform_values(&:dup),
77
+ max_total_timeout: @max_total_timeout
78
+ }
79
+ end
80
+
81
+ # Check if fallback is enabled
82
+ # @return [Boolean]
83
+ def enabled?
84
+ @enabled == true
85
+ end
86
+
87
+ # Check if fallback is disabled
88
+ # @return [Boolean]
89
+ def disabled?
90
+ !enabled?
91
+ end
92
+
93
+ # Check if there are any fallback providers configured
94
+ # @return [Boolean]
95
+ def has_providers?
96
+ @providers && !@providers.empty?
97
+ end
98
+
99
+ # Merge with another config (other takes precedence)
100
+ # @param other [FallbackConfig, Hash] Config to merge
101
+ # @return [FallbackConfig] New merged config
102
+ def merge(other)
103
+ other_hash = other.is_a?(Hash) ? other : other.to_h
104
+
105
+ merged_chains = @chains.dup
106
+ if other_hash.key?(:chains)
107
+ other_hash[:chains].each { |k, v| merged_chains[k.to_s] = v }
108
+ end
109
+
110
+ self.class.new(
111
+ enabled: other_hash.fetch(:enabled, @enabled),
112
+ retry_count: other_hash.fetch(:retry_count, @retry_count),
113
+ retry_delay: other_hash.fetch(:retry_delay, @retry_delay),
114
+ providers: other_hash.fetch(:providers, @providers),
115
+ chains: merged_chains,
116
+ max_total_timeout: other_hash.fetch(:max_total_timeout, @max_total_timeout)
117
+ )
118
+ end
119
+
120
+ private
121
+
122
+ # Helper to fetch key from hash supporting both symbol and string keys
123
+ # @param hash [Hash] Hash to fetch from
124
+ # @param key [Symbol] Key to fetch
125
+ # @param default [Object] Default value if key not found
126
+ # @return [Object] Value from hash or default
127
+ def self.fetch_key(hash, key, default)
128
+ hash.fetch(key, hash.fetch(key.to_s, default))
129
+ end
130
+
131
+ # Normalize chains hash keys to strings
132
+ # @param chains [Hash] Raw chains hash
133
+ # @return [Hash{String => Array<String>}] Normalized chains
134
+ def normalize_chains(chains)
135
+ chains.each_with_object({}) do |(key, value), result|
136
+ result[key.to_s] = value
137
+ end
138
+ end
139
+
140
+ # Validate configuration values
141
+ # @raise [ConfigurationError] If configuration is invalid
142
+ def validate!
143
+ validate_retry_count!
144
+ validate_retry_delay!
145
+ validate_providers!
146
+ validate_chains!
147
+ validate_max_total_timeout!
148
+ end
149
+
150
+ def validate_retry_count!
151
+ unless @retry_count.is_a?(Integer) && @retry_count >= 0
152
+ raise Ace::LLM::ConfigurationError,
153
+ "retry_count must be a non-negative integer, got: #{@retry_count.inspect}"
154
+ end
155
+ end
156
+
157
+ def validate_retry_delay!
158
+ unless @retry_delay.is_a?(Numeric) && @retry_delay > 0
159
+ raise Ace::LLM::ConfigurationError,
160
+ "retry_delay must be a positive number, got: #{@retry_delay.inspect}"
161
+ end
162
+ end
163
+
164
+ def validate_providers!
165
+ unless @providers.is_a?(Array)
166
+ raise Ace::LLM::ConfigurationError,
167
+ "providers must be an array, got: #{@providers.class}"
168
+ end
169
+
170
+ # Check for duplicates
171
+ duplicates = @providers.group_by { |p| p }.select { |_, v| v.size > 1 }.keys
172
+ unless duplicates.empty?
173
+ raise Ace::LLM::ConfigurationError,
174
+ "providers contains duplicates: #{duplicates.join(", ")}"
175
+ end
176
+
177
+ # Validate each provider string format
178
+ @providers.each do |provider|
179
+ unless provider.is_a?(String) && !provider.empty?
180
+ raise Ace::LLM::ConfigurationError,
181
+ "each provider must be a non-empty string, got: #{provider.inspect}"
182
+ end
183
+ end
184
+ end
185
+
186
+ def validate_chains!
187
+ unless @chains.is_a?(Hash)
188
+ raise Ace::LLM::ConfigurationError,
189
+ "chains must be a hash, got: #{@chains.class}"
190
+ end
191
+
192
+ @chains.each do |key, chain|
193
+ unless chain.is_a?(Array)
194
+ raise Ace::LLM::ConfigurationError,
195
+ "chains value for '#{key}' must be an array, got: #{chain.class}"
196
+ end
197
+
198
+ chain.each do |provider|
199
+ unless provider.is_a?(String) && !provider.empty?
200
+ raise Ace::LLM::ConfigurationError,
201
+ "each provider in chains['#{key}'] must be a non-empty string, got: #{provider.inspect}"
202
+ end
203
+ end
204
+ end
205
+ end
206
+
207
+ def validate_max_total_timeout!
208
+ unless @max_total_timeout.is_a?(Numeric) && @max_total_timeout > 0
209
+ raise Ace::LLM::ConfigurationError,
210
+ "max_total_timeout must be a positive number, got: #{@max_total_timeout.inspect}"
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end