ace-llm 0.32.1 → 0.38.2
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 +4 -4
- data/.ace-defaults/llm/config.yml +1 -1
- data/.ace-defaults/llm/providers/anthropic.yml +29 -14
- data/.ace-defaults/llm/providers/google.yml +28 -14
- data/.ace-defaults/llm/providers/groq.yml +60 -5
- data/.ace-defaults/llm/providers/lmstudio.yml +1 -1
- data/.ace-defaults/llm/providers/mistral.yml +45 -14
- data/.ace-defaults/llm/providers/openai.yml +46 -14
- data/.ace-defaults/llm/providers/openrouter.yml +229 -32
- data/.ace-defaults/llm/providers/togetherai.yml +43 -10
- data/.ace-defaults/llm/providers/xai.yml +21 -8
- data/.ace-defaults/llm/providers/zai.yml +17 -6
- data/CHANGELOG.md +148 -0
- data/README.md +19 -2
- data/exe/ace-llm +2 -1
- data/handbook/guides/llm-query-tool-reference.g.md +78 -626
- data/lib/ace/llm/atoms/provider_config_validator.rb +53 -0
- data/lib/ace/llm/cli/commands/query.rb +56 -7
- data/lib/ace/llm/molecules/client_registry.rb +2 -1
- data/lib/ace/llm/molecules/fallback_orchestrator.rb +14 -8
- data/lib/ace/llm/molecules/interactive_command_builder.rb +64 -0
- data/lib/ace/llm/molecules/model_limit_resolver.rb +88 -0
- data/lib/ace/llm/molecules/role_resolver.rb +0 -1
- data/lib/ace/llm/query_interface.rb +106 -33
- data/lib/ace/llm/version.rb +1 -1
- data/lib/ace/llm.rb +2 -0
- metadata +18 -2
|
@@ -11,6 +11,7 @@ module Ace
|
|
|
11
11
|
# Optional fields with expected types
|
|
12
12
|
OPTIONAL_FIELDS = {
|
|
13
13
|
"models" => Array,
|
|
14
|
+
"limits" => Hash,
|
|
14
15
|
"api_key" => [Hash, NilClass],
|
|
15
16
|
"capabilities" => Array,
|
|
16
17
|
"default_options" => Hash,
|
|
@@ -69,6 +70,7 @@ module Ace
|
|
|
69
70
|
validate_class(config["class"], errors) if config["class"]
|
|
70
71
|
validate_gem(config["gem"], errors) if config["gem"]
|
|
71
72
|
validate_models(config["models"], warnings) if config["models"]
|
|
73
|
+
validate_limits(config["limits"], errors, warnings) if config["limits"]
|
|
72
74
|
validate_api_key(config["api_key"], errors, warnings) if config["api_key"]
|
|
73
75
|
validate_capabilities(config["capabilities"], warnings) if config["capabilities"]
|
|
74
76
|
validate_default_options(config["default_options"], warnings) if config["default_options"]
|
|
@@ -147,6 +149,57 @@ module Ace
|
|
|
147
149
|
end
|
|
148
150
|
end
|
|
149
151
|
|
|
152
|
+
def validate_limits(limits, errors, warnings)
|
|
153
|
+
unless limits.is_a?(Hash)
|
|
154
|
+
errors << "Limits must be a Hash, got #{limits.class}"
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
valid_limit_keys = %w[default models]
|
|
159
|
+
unknown_limit_keys = limits.keys - valid_limit_keys
|
|
160
|
+
unless unknown_limit_keys.empty?
|
|
161
|
+
warnings << "Unknown limits keys: #{unknown_limit_keys.join(", ")}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
validate_limit_entry(limits["default"], "limits.default", errors) if limits["default"]
|
|
165
|
+
|
|
166
|
+
return unless limits["models"]
|
|
167
|
+
|
|
168
|
+
unless limits["models"].is_a?(Hash)
|
|
169
|
+
errors << "limits.models must be a Hash, got #{limits["models"].class}"
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
limits["models"].each do |model_name, entry|
|
|
174
|
+
if model_name.to_s.strip.empty?
|
|
175
|
+
errors << "limits.models entries must have non-empty model names"
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
validate_limit_entry(entry, "limits.models.#{model_name}", errors)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_limit_entry(entry, path, errors)
|
|
184
|
+
unless entry.is_a?(Hash)
|
|
185
|
+
errors << "#{path} must be a Hash, got #{entry.class}"
|
|
186
|
+
return
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
valid_keys = %w[context output]
|
|
190
|
+
unknown_keys = entry.keys - valid_keys
|
|
191
|
+
unless unknown_keys.empty?
|
|
192
|
+
errors << "#{path} contains unknown keys: #{unknown_keys.join(", ")}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
entry.each do |field, value|
|
|
196
|
+
next unless valid_keys.include?(field)
|
|
197
|
+
next if value.is_a?(Integer) && value >= 0
|
|
198
|
+
|
|
199
|
+
errors << "#{path}.#{field} must be a non-negative Integer"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
150
203
|
# Validate API key configuration
|
|
151
204
|
def validate_api_key(api_key_config, errors, warnings)
|
|
152
205
|
if api_key_config.is_a?(Hash)
|
|
@@ -32,6 +32,9 @@ module Ace
|
|
|
32
32
|
option :model, type: :string, desc: "Model name (overrides PROVIDER[:MODEL])"
|
|
33
33
|
option :prompt, type: :string, desc: "Prompt text (overrides positional PROMPT)"
|
|
34
34
|
option :force, type: :boolean, default: false, desc: "Force overwrite existing files"
|
|
35
|
+
option :interactive, type: :boolean, default: false, desc: "Start an interactive CLI session instead of one-shot query"
|
|
36
|
+
option :fallback, type: :boolean, default: true, desc: "Use provider fallback for this query"
|
|
37
|
+
option :json, type: :boolean, default: false, desc: "Output command results as JSON"
|
|
35
38
|
|
|
36
39
|
option :version, type: :boolean, desc: "Show version information"
|
|
37
40
|
option :list_providers, type: :boolean, desc: "List available LLM providers"
|
|
@@ -54,6 +57,7 @@ module Ace
|
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
@prompt = options[:prompt] || prompt_text
|
|
60
|
+
options[:format] ||= "json" if options[:json]
|
|
57
61
|
|
|
58
62
|
return show_help if @provider_model.nil? && @prompt.nil?
|
|
59
63
|
return show_provider_help if @prompt.nil? || @prompt.empty?
|
|
@@ -88,6 +92,9 @@ module Ace
|
|
|
88
92
|
puts " --timeout SECONDS Request timeout in seconds"
|
|
89
93
|
puts " --model MODEL Model name (overrides PROVIDER[:MODEL])"
|
|
90
94
|
puts " --prompt PROMPT Prompt text (overrides positional PROMPT)"
|
|
95
|
+
puts " --interactive Start an interactive CLI session"
|
|
96
|
+
puts " --no-fallback Disable provider fallback for this query"
|
|
97
|
+
puts " --json Output command results as JSON"
|
|
91
98
|
puts " --force Force overwrite existing files"
|
|
92
99
|
puts " -q, --quiet Suppress config summary output"
|
|
93
100
|
puts " -d, --debug Enable debug output"
|
|
@@ -97,17 +104,20 @@ module Ace
|
|
|
97
104
|
puts ' ace-llm google:gemini-2.5-flash "What is Ruby?"'
|
|
98
105
|
puts ' ace-llm gflash "Quick question" # using alias'
|
|
99
106
|
puts ' ace-llm gflash@ro "Summarize this diff"'
|
|
100
|
-
puts ' ace-llm codex:gpt
|
|
107
|
+
puts ' ace-llm codex:gpt:high@ro "Review this code"'
|
|
101
108
|
puts ' ace-llm claude:sonnet "Summarize this diff" --preset rw'
|
|
102
109
|
puts ' ace-llm claude:sonnet "Hi" --cli-args "dangerously-skip-permissions"'
|
|
103
110
|
puts ' ace-llm claude:sonnet "Hi" --cli-args "--model=claude-sonnet-4-0 --verbose"'
|
|
111
|
+
puts ' ace-llm codex:gpt@yolo "/as-assign-drive abc123@010" --interactive'
|
|
112
|
+
puts ' ace-llm gemini:pro "ping" --no-fallback --timeout 15 --max-tokens 4'
|
|
104
113
|
puts ""
|
|
105
114
|
puts "Provider Aliases:"
|
|
106
115
|
puts " Short aliases for common provider:MODEL combinations:"
|
|
107
|
-
puts " gflash → google:gemini-
|
|
108
|
-
puts " glite → google:gemini-
|
|
109
|
-
puts "
|
|
110
|
-
puts "
|
|
116
|
+
puts " gflash → google:gemini-flash-latest"
|
|
117
|
+
puts " glite → google:gemini-flash-lite-latest"
|
|
118
|
+
puts " codex:gpt → codex:gpt-5.4"
|
|
119
|
+
puts " cc → claude:sonnet"
|
|
120
|
+
puts " sonnet → anthropic:claude-sonnet-4-5"
|
|
111
121
|
end
|
|
112
122
|
|
|
113
123
|
def show_provider_help
|
|
@@ -132,7 +142,7 @@ module Ace
|
|
|
132
142
|
return if quiet?(options)
|
|
133
143
|
|
|
134
144
|
require "ace/core"
|
|
135
|
-
summary_keys = %w[provider_model preset temperature max_tokens format timeout system_append cli_args]
|
|
145
|
+
summary_keys = %w[provider_model preset temperature max_tokens format timeout system_append cli_args fallback]
|
|
136
146
|
Ace::Core::Atoms::ConfigSummary.display(
|
|
137
147
|
command: "query",
|
|
138
148
|
config: options.merge(provider_model: @provider_model),
|
|
@@ -150,6 +160,23 @@ module Ace
|
|
|
150
160
|
system_append_text = options[:system_append] ? file_handler.read_content(options[:system_append]) : nil
|
|
151
161
|
normalized_timeout = normalize_timeout(options[:timeout])
|
|
152
162
|
|
|
163
|
+
if options[:interactive]
|
|
164
|
+
validate_interactive_options!(options)
|
|
165
|
+
invocation = Ace::LLM::Molecules::InteractiveCommandBuilder.new.build(
|
|
166
|
+
provider_model: @provider_model,
|
|
167
|
+
prompt: prompt_text,
|
|
168
|
+
system: system_text,
|
|
169
|
+
cli_args: options[:cli_args],
|
|
170
|
+
system_append: system_append_text,
|
|
171
|
+
preset: options[:preset],
|
|
172
|
+
model: (@model_from_option ? nil : options[:model]),
|
|
173
|
+
working_dir: Dir.pwd,
|
|
174
|
+
subprocess_env: ENV.to_h
|
|
175
|
+
)
|
|
176
|
+
execute_interactive_invocation(invocation)
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
153
180
|
resolved_model_override = @model_from_option ? nil : options[:model]
|
|
154
181
|
response = Ace::LLM::QueryInterface.query(
|
|
155
182
|
@provider_model,
|
|
@@ -162,7 +189,8 @@ module Ace
|
|
|
162
189
|
model: resolved_model_override,
|
|
163
190
|
cli_args: options[:cli_args],
|
|
164
191
|
system_append: system_append_text,
|
|
165
|
-
preset: options[:preset]
|
|
192
|
+
preset: options[:preset],
|
|
193
|
+
fallback: (options[:fallback] == false ? false : nil)
|
|
166
194
|
)
|
|
167
195
|
|
|
168
196
|
output_response(response, options)
|
|
@@ -178,6 +206,27 @@ module Ace
|
|
|
178
206
|
raise
|
|
179
207
|
end
|
|
180
208
|
|
|
209
|
+
def validate_interactive_options!(options)
|
|
210
|
+
conflicts = []
|
|
211
|
+
conflicts << "--output" if options[:output]
|
|
212
|
+
conflicts << "--force" if options[:force]
|
|
213
|
+
conflicts << "--temperature" unless options[:temperature].nil?
|
|
214
|
+
conflicts << "--max-tokens" unless options[:max_tokens].nil?
|
|
215
|
+
explicit_format = options[:format]
|
|
216
|
+
conflicts << "--format" if explicit_format && explicit_format != "text"
|
|
217
|
+
conflicts << "--no-fallback" if options[:fallback] == false
|
|
218
|
+
return if conflicts.empty?
|
|
219
|
+
|
|
220
|
+
raise Ace::Support::Cli::Error, "Interactive mode does not support #{conflicts.join(', ')}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def execute_interactive_invocation(invocation)
|
|
224
|
+
env = invocation[:env].respond_to?(:to_h) ? invocation[:env].to_h : {}
|
|
225
|
+
command = invocation[:command]
|
|
226
|
+
working_dir = invocation[:working_dir]
|
|
227
|
+
Kernel.exec(env, *command, chdir: working_dir)
|
|
228
|
+
end
|
|
229
|
+
|
|
181
230
|
def output_response(response, options)
|
|
182
231
|
format = options[:format] || "text"
|
|
183
232
|
handler = Ace::LLM::Molecules::FormatHandlers.get_handler(format)
|
|
@@ -142,7 +142,8 @@ module Ace
|
|
|
142
142
|
provider = get_provider(provider_name)
|
|
143
143
|
return false unless provider
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
normalized_name = normalize_provider_name(provider["name"] || provider_name)
|
|
146
|
+
credential_status(provider, normalized_name)[:present]
|
|
146
147
|
end
|
|
147
148
|
|
|
148
149
|
# Reload all configurations
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../atoms/error_classifier"
|
|
4
4
|
require_relative "../models/fallback_config"
|
|
5
|
+
require_relative "provider_model_parser"
|
|
5
6
|
|
|
6
7
|
module Ace
|
|
7
8
|
module LLM
|
|
@@ -35,10 +36,12 @@ module Ace
|
|
|
35
36
|
@visited_providers.clear
|
|
36
37
|
|
|
37
38
|
# If fallback disabled, just execute with primary
|
|
38
|
-
return yield
|
|
39
|
+
return yield(get_client(primary_provider, registry), primary_provider) if @config.disabled?
|
|
39
40
|
|
|
40
41
|
# Try primary provider with retries
|
|
41
|
-
result = try_provider_with_retry(primary_provider, registry)
|
|
42
|
+
result = try_provider_with_retry(primary_provider, registry) do |client, provider_name|
|
|
43
|
+
yield client, provider_name
|
|
44
|
+
end
|
|
42
45
|
return result if result
|
|
43
46
|
|
|
44
47
|
# Try fallback providers in order (per-provider chain or default)
|
|
@@ -54,7 +57,9 @@ module Ace
|
|
|
54
57
|
|
|
55
58
|
report_status("ℹ Trying fallback provider #{fallback_provider}...")
|
|
56
59
|
|
|
57
|
-
result = try_provider_with_retry(fallback_provider, registry)
|
|
60
|
+
result = try_provider_with_retry(fallback_provider, registry) do |client, provider_name|
|
|
61
|
+
yield client, provider_name
|
|
62
|
+
end
|
|
58
63
|
return result if result
|
|
59
64
|
end
|
|
60
65
|
|
|
@@ -77,7 +82,7 @@ module Ace
|
|
|
77
82
|
|
|
78
83
|
loop do
|
|
79
84
|
client = get_client(provider_name, registry)
|
|
80
|
-
return yield client
|
|
85
|
+
return yield client, provider_name
|
|
81
86
|
rescue => error
|
|
82
87
|
last_error = error
|
|
83
88
|
|
|
@@ -145,10 +150,11 @@ module Ace
|
|
|
145
150
|
opts = {}
|
|
146
151
|
opts[:timeout] = @timeout if @timeout
|
|
147
152
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
parser = ProviderModelParser.new(registry: registry)
|
|
154
|
+
parsed = parser.parse(provider_name.to_s)
|
|
155
|
+
|
|
156
|
+
if parsed.valid?
|
|
157
|
+
registry.get_client(parsed.provider, model: parsed.model, **opts)
|
|
152
158
|
else
|
|
153
159
|
registry.get_client(provider_name, **opts)
|
|
154
160
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module LLM
|
|
5
|
+
module Molecules
|
|
6
|
+
class InteractiveCommandBuilder
|
|
7
|
+
def initialize(registry: ClientRegistry.new)
|
|
8
|
+
@registry = registry
|
|
9
|
+
@parser = ProviderModelParser.new(registry: registry)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def build(provider_model:, prompt:, model: nil, preset: nil, system: nil, cli_args: nil,
|
|
13
|
+
system_append: nil, sandbox: nil, working_dir: nil, subprocess_env: nil)
|
|
14
|
+
parse_result = @parser.parse(provider_model)
|
|
15
|
+
raise Error, parse_result.error unless parse_result.valid?
|
|
16
|
+
|
|
17
|
+
resolved_preset = QueryInterface.send(:resolve_preset_name, parse_result.preset, preset)
|
|
18
|
+
final_model = model || parse_result.model
|
|
19
|
+
if final_model.nil? || final_model.empty?
|
|
20
|
+
raise Error, "No model specified and no default available for #{parse_result.provider}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
final_prompt = prompt.to_s
|
|
24
|
+
raise Error, "No prompt specified. Use positional prompt or --prompt." if final_prompt.empty?
|
|
25
|
+
|
|
26
|
+
generation_opts = QueryInterface.send(
|
|
27
|
+
:build_generation_opts,
|
|
28
|
+
provider: parse_result.provider,
|
|
29
|
+
preset: resolved_preset,
|
|
30
|
+
thinking_level: parse_result.thinking_level,
|
|
31
|
+
temperature: nil,
|
|
32
|
+
max_tokens: nil,
|
|
33
|
+
system_file: nil,
|
|
34
|
+
prompt_file: nil,
|
|
35
|
+
cli_args: cli_args,
|
|
36
|
+
system_append: system_append,
|
|
37
|
+
sandbox: sandbox,
|
|
38
|
+
working_dir: working_dir,
|
|
39
|
+
subprocess_env: subprocess_env,
|
|
40
|
+
subprocess_command_prefix: nil,
|
|
41
|
+
last_message_file: nil
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
client = @registry.get_client(parse_result.provider, model: final_model)
|
|
45
|
+
unless client.respond_to?(:interactive_supported?) && client.interactive_supported?
|
|
46
|
+
raise Error, "Provider '#{parse_result.provider}' does not support interactive mode"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
messages = []
|
|
50
|
+
messages << {role: "system", content: system} if system && !system.empty?
|
|
51
|
+
messages << {role: "user", content: final_prompt}
|
|
52
|
+
|
|
53
|
+
invocation = client.build_interactive_invocation(messages, **generation_opts)
|
|
54
|
+
invocation.merge(
|
|
55
|
+
provider: parse_result.provider,
|
|
56
|
+
model: final_model,
|
|
57
|
+
preset: resolved_preset,
|
|
58
|
+
thinking_level: parse_result.thinking_level
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/models"
|
|
4
|
+
require_relative "client_registry"
|
|
5
|
+
require_relative "provider_model_parser"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module LLM
|
|
9
|
+
module Molecules
|
|
10
|
+
# Resolves model context/output limits against the expanded concrete target.
|
|
11
|
+
class ModelLimitResolver
|
|
12
|
+
DEFAULT_CONTEXT_LIMIT = 200_000
|
|
13
|
+
|
|
14
|
+
ResolveResult = Struct.new(
|
|
15
|
+
:provider,
|
|
16
|
+
:model,
|
|
17
|
+
:context_limit,
|
|
18
|
+
:output_limit,
|
|
19
|
+
:source,
|
|
20
|
+
:original_target,
|
|
21
|
+
keyword_init: true
|
|
22
|
+
) do
|
|
23
|
+
def full_model
|
|
24
|
+
return nil if provider.to_s.empty? || model.to_s.empty?
|
|
25
|
+
|
|
26
|
+
"#{provider}:#{model}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.resolve(target, **kwargs)
|
|
31
|
+
new(**kwargs).resolve(target)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(registry: nil, parser: nil)
|
|
35
|
+
@registry = registry || ClientRegistry.new
|
|
36
|
+
@parser = parser || ProviderModelParser.new(registry: @registry)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve(target)
|
|
40
|
+
original_target = target.to_s
|
|
41
|
+
return fallback_result(original_target) if original_target.strip.empty?
|
|
42
|
+
|
|
43
|
+
parsed = @parser.parse(original_target)
|
|
44
|
+
return fallback_result(original_target) if parsed.invalid?
|
|
45
|
+
|
|
46
|
+
provider_config = @registry.get_provider(parsed.provider)
|
|
47
|
+
return fallback_result(original_target, provider: parsed.provider, model: parsed.model) unless provider_config
|
|
48
|
+
|
|
49
|
+
limits = Ace::Support::Models::Atoms::ProviderConfigReader.extract_limits(provider_config)
|
|
50
|
+
default_limits = limits["default"] || {}
|
|
51
|
+
model_limits = limits.dig("models", parsed.model) || {}
|
|
52
|
+
merged_limits = default_limits.merge(model_limits)
|
|
53
|
+
|
|
54
|
+
ResolveResult.new(
|
|
55
|
+
provider: parsed.provider,
|
|
56
|
+
model: parsed.model,
|
|
57
|
+
context_limit: merged_limits["context"] || DEFAULT_CONTEXT_LIMIT,
|
|
58
|
+
output_limit: merged_limits["output"],
|
|
59
|
+
source: limit_source(default_limits, model_limits),
|
|
60
|
+
original_target: original_target
|
|
61
|
+
)
|
|
62
|
+
rescue
|
|
63
|
+
fallback_result(original_target)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def limit_source(default_limits, model_limits)
|
|
69
|
+
return :model_override if model_limits.any?
|
|
70
|
+
return :provider_default if default_limits.any?
|
|
71
|
+
|
|
72
|
+
:fallback
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fallback_result(original_target, provider: nil, model: nil)
|
|
76
|
+
ResolveResult.new(
|
|
77
|
+
provider: provider,
|
|
78
|
+
model: model,
|
|
79
|
+
context_limit: DEFAULT_CONTEXT_LIMIT,
|
|
80
|
+
output_limit: nil,
|
|
81
|
+
source: :fallback,
|
|
82
|
+
original_target: original_target
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -60,7 +60,6 @@ module Ace
|
|
|
60
60
|
return false if provider.nil? || provider.empty?
|
|
61
61
|
return false if @configuration.provider_inactive?(provider)
|
|
62
62
|
return false unless @registry.provider_available?(provider)
|
|
63
|
-
return false if @registry.provider_api_key_required?(provider) && !@registry.provider_api_key_present?(provider)
|
|
64
63
|
|
|
65
64
|
true
|
|
66
65
|
end
|
|
@@ -35,6 +35,7 @@ module Ace
|
|
|
35
35
|
sandbox: nil,
|
|
36
36
|
working_dir: nil,
|
|
37
37
|
subprocess_env: nil,
|
|
38
|
+
subprocess_command_prefix: nil,
|
|
38
39
|
last_message_file: nil)
|
|
39
40
|
registry = Molecules::ClientRegistry.new
|
|
40
41
|
parser = Molecules::ProviderModelParser.new(registry: registry)
|
|
@@ -43,12 +44,6 @@ module Ace
|
|
|
43
44
|
raise Error, parse_result.error unless parse_result.valid?
|
|
44
45
|
|
|
45
46
|
resolved_preset = resolve_preset_name(parse_result.preset, preset)
|
|
46
|
-
execution_overrides = load_execution_overrides(
|
|
47
|
-
provider: parse_result.provider,
|
|
48
|
-
preset: resolved_preset,
|
|
49
|
-
thinking_level: parse_result.thinking_level
|
|
50
|
-
)
|
|
51
|
-
|
|
52
47
|
final_model = model || parse_result.model
|
|
53
48
|
if final_model.nil? || final_model.empty?
|
|
54
49
|
raise Error, "No model specified and no default available for #{parse_result.provider}"
|
|
@@ -63,38 +58,41 @@ module Ace
|
|
|
63
58
|
messages << {role: "system", content: system} if system && !system.empty?
|
|
64
59
|
messages << {role: "user", content: final_prompt}
|
|
65
60
|
|
|
66
|
-
generation_opts =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
61
|
+
generation_opts = build_generation_opts(
|
|
62
|
+
provider: parse_result.provider,
|
|
63
|
+
preset: resolved_preset,
|
|
64
|
+
thinking_level: parse_result.thinking_level,
|
|
65
|
+
temperature: temperature,
|
|
66
|
+
max_tokens: max_tokens,
|
|
67
|
+
system_file: system_file,
|
|
68
|
+
prompt_file: prompt_file,
|
|
69
|
+
cli_args: cli_args,
|
|
70
|
+
system_append: system_append,
|
|
71
|
+
sandbox: sandbox,
|
|
72
|
+
working_dir: working_dir,
|
|
73
|
+
subprocess_env: subprocess_env,
|
|
74
|
+
subprocess_command_prefix: subprocess_command_prefix,
|
|
75
|
+
last_message_file: last_message_file
|
|
76
|
+
)
|
|
77
|
+
execution_overrides = load_execution_overrides(
|
|
78
|
+
provider: parse_result.provider,
|
|
79
|
+
preset: resolved_preset,
|
|
80
|
+
thinking_level: parse_result.thinking_level
|
|
81
|
+
)
|
|
85
82
|
|
|
86
83
|
if debug
|
|
87
84
|
warn "Provider: #{parse_result.provider}"
|
|
88
85
|
warn "Model: #{final_model}"
|
|
89
86
|
warn "Preset: #{resolved_preset}" if resolved_preset
|
|
90
87
|
warn "Thinking level: #{parse_result.thinking_level}" if parse_result.thinking_level
|
|
91
|
-
warn "Temperature: #{
|
|
92
|
-
warn "Max tokens: #{
|
|
88
|
+
warn "Temperature: #{generation_opts[:temperature]}" unless generation_opts[:temperature].nil?
|
|
89
|
+
warn "Max tokens: #{generation_opts[:max_tokens]}" unless generation_opts[:max_tokens].nil?
|
|
93
90
|
end
|
|
94
91
|
|
|
95
92
|
fallback_config = load_fallback_config(fallback, fallback_providers, parser: parser)
|
|
96
93
|
timeout_value = first_non_nil(timeout, execution_overrides["timeout"], Molecules::ConfigLoader.get("llm.timeout"), 120)
|
|
97
94
|
resolved_timeout = normalize_timeout(timeout_value)
|
|
95
|
+
parser_for_options = Molecules::ProviderModelParser.new(registry: registry)
|
|
98
96
|
|
|
99
97
|
response = execute_with_fallback(
|
|
100
98
|
provider: parse_result.provider,
|
|
@@ -105,7 +103,39 @@ module Ace
|
|
|
105
103
|
fallback_config: fallback_config,
|
|
106
104
|
timeout: resolved_timeout,
|
|
107
105
|
debug: debug,
|
|
108
|
-
role_fallbacks: parse_result.role_fallbacks
|
|
106
|
+
role_fallbacks: parse_result.role_fallbacks,
|
|
107
|
+
preset: resolved_preset,
|
|
108
|
+
thinking_level: parse_result.thinking_level,
|
|
109
|
+
option_builder: lambda { |target_selector|
|
|
110
|
+
parsed_target = parser_for_options.parse(target_selector.to_s)
|
|
111
|
+
target_provider = parsed_target.valid? ? parsed_target.provider : parse_result.provider
|
|
112
|
+
target_preset = if parsed_target.valid? && parsed_target.preset
|
|
113
|
+
parsed_target.preset
|
|
114
|
+
else
|
|
115
|
+
resolved_preset
|
|
116
|
+
end
|
|
117
|
+
target_thinking = if parsed_target.valid?
|
|
118
|
+
parsed_target.thinking_level
|
|
119
|
+
else
|
|
120
|
+
parse_result.thinking_level
|
|
121
|
+
end
|
|
122
|
+
build_generation_opts(
|
|
123
|
+
provider: target_provider,
|
|
124
|
+
preset: target_preset,
|
|
125
|
+
thinking_level: target_thinking,
|
|
126
|
+
temperature: temperature,
|
|
127
|
+
max_tokens: max_tokens,
|
|
128
|
+
system_file: system_file,
|
|
129
|
+
prompt_file: prompt_file,
|
|
130
|
+
cli_args: cli_args,
|
|
131
|
+
system_append: system_append,
|
|
132
|
+
sandbox: sandbox,
|
|
133
|
+
working_dir: working_dir,
|
|
134
|
+
subprocess_env: subprocess_env,
|
|
135
|
+
subprocess_command_prefix: subprocess_command_prefix,
|
|
136
|
+
last_message_file: last_message_file
|
|
137
|
+
)
|
|
138
|
+
}
|
|
109
139
|
)
|
|
110
140
|
|
|
111
141
|
text_content = extract_text_content(response)
|
|
@@ -174,6 +204,43 @@ module Ace
|
|
|
174
204
|
end
|
|
175
205
|
private_class_method :load_execution_overrides
|
|
176
206
|
|
|
207
|
+
def self.build_generation_opts(provider:, preset:, thinking_level:, temperature:, max_tokens:, system_file:,
|
|
208
|
+
prompt_file:, cli_args:, system_append:, sandbox:, working_dir:, subprocess_env:, subprocess_command_prefix:,
|
|
209
|
+
last_message_file:)
|
|
210
|
+
execution_overrides = load_execution_overrides(
|
|
211
|
+
provider: provider,
|
|
212
|
+
preset: preset,
|
|
213
|
+
thinking_level: thinking_level
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
generation_opts = {}
|
|
217
|
+
resolved_temperature = first_non_nil(temperature, execution_overrides["temperature"])
|
|
218
|
+
resolved_max_tokens = first_non_nil(max_tokens, execution_overrides["max_tokens"])
|
|
219
|
+
resolved_cli_args = append_cli_args(execution_overrides["cli_args"], cli_args)
|
|
220
|
+
resolved_system_append = first_non_empty(system_append, execution_overrides["system_append"])
|
|
221
|
+
resolved_sandbox = first_non_nil(sandbox, execution_overrides["sandbox"])
|
|
222
|
+
resolved_working_dir = first_non_nil(working_dir, execution_overrides["working_dir"])
|
|
223
|
+
resolved_subprocess_env = merge_hash_values(execution_overrides["subprocess_env"], subprocess_env)
|
|
224
|
+
resolved_subprocess_command_prefix = first_non_nil(
|
|
225
|
+
subprocess_command_prefix,
|
|
226
|
+
execution_overrides["subprocess_command_prefix"]
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
generation_opts[:temperature] = resolved_temperature unless resolved_temperature.nil?
|
|
230
|
+
generation_opts[:max_tokens] = resolved_max_tokens unless resolved_max_tokens.nil?
|
|
231
|
+
generation_opts[:system_file] = system_file if system_file
|
|
232
|
+
generation_opts[:prompt_file] = prompt_file if prompt_file
|
|
233
|
+
generation_opts[:cli_args] = resolved_cli_args unless blank_value?(resolved_cli_args)
|
|
234
|
+
generation_opts[:system_append] = resolved_system_append unless blank_value?(resolved_system_append)
|
|
235
|
+
generation_opts[:sandbox] = resolved_sandbox if resolved_sandbox
|
|
236
|
+
generation_opts[:working_dir] = resolved_working_dir unless blank_value?(resolved_working_dir)
|
|
237
|
+
generation_opts[:subprocess_env] = resolved_subprocess_env unless resolved_subprocess_env.nil?
|
|
238
|
+
generation_opts[:subprocess_command_prefix] = resolved_subprocess_command_prefix unless blank_value?(resolved_subprocess_command_prefix)
|
|
239
|
+
generation_opts[:last_message_file] = last_message_file if last_message_file
|
|
240
|
+
generation_opts
|
|
241
|
+
end
|
|
242
|
+
private_class_method :build_generation_opts
|
|
243
|
+
|
|
177
244
|
def self.merge_execution_overrides(base, overlay)
|
|
178
245
|
left = base.respond_to?(:to_h) ? deep_stringify_keys(base.to_h) : deep_stringify_keys(base || {})
|
|
179
246
|
right = overlay.respond_to?(:to_h) ? deep_stringify_keys(overlay.to_h) : deep_stringify_keys(overlay || {})
|
|
@@ -291,7 +358,7 @@ module Ace
|
|
|
291
358
|
end
|
|
292
359
|
|
|
293
360
|
def self.execute_with_fallback(provider:, model:, messages:, generation_opts:,
|
|
294
|
-
registry:, fallback_config:, timeout:, debug:, role_fallbacks: nil)
|
|
361
|
+
registry:, fallback_config:, timeout:, debug:, role_fallbacks: nil, preset: nil, thinking_level: nil, option_builder:)
|
|
295
362
|
if fallback_config.disabled?
|
|
296
363
|
client = registry.get_client(provider, model: model, timeout: timeout)
|
|
297
364
|
return client.generate(messages, **generation_opts)
|
|
@@ -300,9 +367,10 @@ module Ace
|
|
|
300
367
|
primary_provider_string = model ? "#{provider}:#{model}" : provider
|
|
301
368
|
|
|
302
369
|
# Inject remaining role candidates ahead of the global fallback chain
|
|
370
|
+
normalized_role_fallbacks = normalize_fallback_providers(role_fallbacks, Molecules::ProviderModelParser.new(registry: registry))
|
|
303
371
|
if role_fallbacks&.any?
|
|
304
372
|
existing_chain = fallback_config.providers_for(primary_provider_string)
|
|
305
|
-
merged_chain =
|
|
373
|
+
merged_chain = normalized_role_fallbacks + (existing_chain - normalized_role_fallbacks)
|
|
306
374
|
fallback_config = fallback_config.merge(chains: {primary_provider_string => merged_chain})
|
|
307
375
|
end
|
|
308
376
|
|
|
@@ -314,8 +382,13 @@ module Ace
|
|
|
314
382
|
timeout: timeout
|
|
315
383
|
)
|
|
316
384
|
|
|
317
|
-
orchestrator.execute(primary_provider: primary_provider_string, registry: registry) do |client|
|
|
318
|
-
|
|
385
|
+
orchestrator.execute(primary_provider: primary_provider_string, registry: registry) do |client, target_selector|
|
|
386
|
+
opts = if target_selector.to_s == primary_provider_string
|
|
387
|
+
generation_opts
|
|
388
|
+
else
|
|
389
|
+
option_builder.call(target_selector)
|
|
390
|
+
end
|
|
391
|
+
client.generate(messages, **opts)
|
|
319
392
|
end
|
|
320
393
|
end
|
|
321
394
|
|
|
@@ -380,7 +453,7 @@ module Ace
|
|
|
380
453
|
|
|
381
454
|
parse_result = parser.parse(provider)
|
|
382
455
|
canonical_provider = if parse_result.valid?
|
|
383
|
-
|
|
456
|
+
parse_result.to_s
|
|
384
457
|
else
|
|
385
458
|
provider
|
|
386
459
|
end
|
data/lib/ace/llm/version.rb
CHANGED
data/lib/ace/llm.rb
CHANGED
|
@@ -25,9 +25,11 @@ require_relative "llm/molecules/file_io_handler"
|
|
|
25
25
|
require_relative "llm/molecules/llm_alias_resolver"
|
|
26
26
|
require_relative "llm/molecules/role_resolver"
|
|
27
27
|
require_relative "llm/molecules/provider_model_parser"
|
|
28
|
+
require_relative "llm/molecules/model_limit_resolver"
|
|
28
29
|
require_relative "llm/molecules/format_handlers"
|
|
29
30
|
require_relative "llm/molecules/client_registry"
|
|
30
31
|
require_relative "llm/molecules/fallback_orchestrator"
|
|
32
|
+
require_relative "llm/molecules/interactive_command_builder"
|
|
31
33
|
|
|
32
34
|
require_relative "llm/query_interface"
|
|
33
35
|
|