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,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
|
data/lib/ace/llm/cli.rb
ADDED
|
@@ -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
|