ace-llm 0.30.2 → 0.36.3
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 +93 -0
- data/CHANGELOG.md +147 -0
- data/README.md +17 -2
- data/lib/ace/llm/cli/commands/query.rb +60 -9
- data/lib/ace/llm/configuration.rb +9 -1
- data/lib/ace/llm/models/role_config.rb +79 -0
- data/lib/ace/llm/molecules/client_registry.rb +89 -9
- 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/provider_model_parser.rb +85 -24
- data/lib/ace/llm/molecules/role_resolver.rb +100 -0
- data/lib/ace/llm/query_interface.rb +115 -34
- data/lib/ace/llm/version.rb +1 -1
- data/lib/ace/llm.rb +3 -0
- metadata +19 -2
|
@@ -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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "client_registry"
|
|
4
4
|
require_relative "llm_alias_resolver"
|
|
5
|
+
require_relative "role_resolver"
|
|
5
6
|
|
|
6
7
|
module Ace
|
|
7
8
|
module LLM
|
|
@@ -12,7 +13,7 @@ module Ace
|
|
|
12
13
|
THINKING_LEVELS = %w[low medium high xhigh].freeze
|
|
13
14
|
|
|
14
15
|
# Result object for parsed provider:model combinations.
|
|
15
|
-
ParseResult = Struct.new(:provider, :model, :preset, :thinking_level, :valid, :error, :original_input) do
|
|
16
|
+
ParseResult = Struct.new(:provider, :model, :preset, :thinking_level, :valid, :error, :original_input, :role_fallbacks) do
|
|
16
17
|
def valid?
|
|
17
18
|
valid
|
|
18
19
|
end
|
|
@@ -33,6 +34,7 @@ module Ace
|
|
|
33
34
|
def initialize(alias_resolver: nil, registry: nil)
|
|
34
35
|
@alias_resolver = alias_resolver || LlmAliasResolver.new
|
|
35
36
|
@registry = registry || ClientRegistry.new
|
|
37
|
+
@role_resolver = RoleResolver.new(registry: @registry)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
# Parse a provider:model string or alias.
|
|
@@ -40,6 +42,70 @@ module Ace
|
|
|
40
42
|
return create_error_result(input, "Input cannot be nil or empty") if input.nil? || input.strip.empty?
|
|
41
43
|
|
|
42
44
|
original_input = input.strip
|
|
45
|
+
if role_reference?(original_input)
|
|
46
|
+
return parse_role_reference(original_input)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
parse_standard_target(original_input)
|
|
50
|
+
rescue Ace::LLM::ConfigurationError => e
|
|
51
|
+
create_error_result(original_input, e.message)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def supported_providers
|
|
55
|
+
@registry.available_providers
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def default_model_for(provider)
|
|
59
|
+
models = @registry.models_for_provider(provider)
|
|
60
|
+
models&.first
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dynamic_aliases
|
|
64
|
+
return {} unless @alias_resolver
|
|
65
|
+
|
|
66
|
+
global = @alias_resolver.available_aliases[:global] || {}
|
|
67
|
+
providers = @alias_resolver.available_aliases[:providers] || {}
|
|
68
|
+
|
|
69
|
+
flattened = {}
|
|
70
|
+
providers.each do |provider, aliases|
|
|
71
|
+
aliases.each do |alias_name, model|
|
|
72
|
+
flattened["#{provider}:#{alias_name}"] = "#{provider}:#{model}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
global.merge(flattened)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def parse_role_reference(original_input)
|
|
82
|
+
role_target, caller_preset, preset_error = split_preset_suffix(original_input)
|
|
83
|
+
return create_error_result(original_input, preset_error) if preset_error
|
|
84
|
+
|
|
85
|
+
role_value = role_target.sub(/\Arole:/, "")
|
|
86
|
+
role_name, caller_thinking, thinking_error = split_thinking_suffix(role_value)
|
|
87
|
+
return create_error_result(original_input, thinking_error) if thinking_error
|
|
88
|
+
return create_error_result(original_input, "Invalid target: role name cannot be empty") if role_name.to_s.strip.empty?
|
|
89
|
+
|
|
90
|
+
resolved_selector, remaining_candidates = @role_resolver.resolve_with_candidates(role_name)
|
|
91
|
+
resolved_parse = parse_standard_target(resolved_selector)
|
|
92
|
+
return resolved_parse if resolved_parse.invalid?
|
|
93
|
+
|
|
94
|
+
role_fallbacks = build_role_fallbacks(remaining_candidates, caller_preset, caller_thinking)
|
|
95
|
+
|
|
96
|
+
ParseResult.new(
|
|
97
|
+
resolved_parse.provider,
|
|
98
|
+
resolved_parse.model,
|
|
99
|
+
caller_preset || resolved_parse.preset,
|
|
100
|
+
caller_thinking || resolved_parse.thinking_level,
|
|
101
|
+
true,
|
|
102
|
+
nil,
|
|
103
|
+
original_input,
|
|
104
|
+
role_fallbacks
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_standard_target(original_input)
|
|
43
109
|
provider_target, preset_name, preset_error = split_preset_suffix(original_input)
|
|
44
110
|
return create_error_result(original_input, preset_error) if preset_error
|
|
45
111
|
|
|
@@ -83,32 +149,25 @@ module Ace
|
|
|
83
149
|
ParseResult.new(resolved_provider, model, preset_name, thinking_level, true, nil, original_input)
|
|
84
150
|
end
|
|
85
151
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
end
|
|
152
|
+
def build_role_fallbacks(candidates, caller_preset, caller_thinking)
|
|
153
|
+
return nil if candidates.nil? || candidates.empty?
|
|
89
154
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
end
|
|
155
|
+
candidates.map do |candidate|
|
|
156
|
+
parsed = parse_standard_target(candidate)
|
|
157
|
+
next nil if parsed.invalid?
|
|
94
158
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
providers.each do |provider, aliases|
|
|
103
|
-
aliases.each do |alias_name, model|
|
|
104
|
-
flattened["#{provider}:#{alias_name}"] = "#{provider}:#{model}"
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
global.merge(flattened)
|
|
159
|
+
preset = caller_preset || parsed.preset
|
|
160
|
+
thinking = caller_thinking || parsed.thinking_level
|
|
161
|
+
base = "#{parsed.provider}:#{parsed.model}"
|
|
162
|
+
base += ":#{thinking}" if thinking
|
|
163
|
+
base += "@#{preset}" if preset
|
|
164
|
+
base
|
|
165
|
+
end.compact
|
|
109
166
|
end
|
|
110
167
|
|
|
111
|
-
|
|
168
|
+
def role_reference?(input)
|
|
169
|
+
input.to_s.strip.start_with?("role:")
|
|
170
|
+
end
|
|
112
171
|
|
|
113
172
|
def normalize_provider(provider)
|
|
114
173
|
provider.strip.downcase.gsub(/[-_]/, "")
|
|
@@ -154,11 +213,13 @@ module Ace
|
|
|
154
213
|
Provider '#{provider}' is inactive. It exists but is not in llm.providers.active.
|
|
155
214
|
To enable it, add '#{provider}' to llm.providers.active in your config.
|
|
156
215
|
Active providers: #{active_display}
|
|
216
|
+
Run `ace-llm --list-providers` for available providers and configuration guidance.
|
|
157
217
|
MSG
|
|
158
218
|
end
|
|
159
219
|
|
|
160
220
|
def unknown_provider_error(provider, supported_providers)
|
|
161
|
-
"Unknown provider: #{provider}. Supported providers: #{supported_providers.join(", ")}"
|
|
221
|
+
"Unknown provider: #{provider}. Supported providers: #{supported_providers.join(", ")}. " \
|
|
222
|
+
"Run `ace-llm --list-providers` for available providers and configuration guidance."
|
|
162
223
|
end
|
|
163
224
|
|
|
164
225
|
def inactive_provider?(provider)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../models/role_config"
|
|
4
|
+
require_relative "client_registry"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module LLM
|
|
8
|
+
module Molecules
|
|
9
|
+
# RoleResolver maps role names to an available concrete selector.
|
|
10
|
+
class RoleResolver
|
|
11
|
+
THINKING_LEVELS = %w[low medium high xhigh].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(registry: nil, configuration: nil)
|
|
14
|
+
@registry = registry || ClientRegistry.new
|
|
15
|
+
@configuration = configuration || Ace::LLM.configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Resolve role name to first available candidate selector string.
|
|
19
|
+
# @param role_name [String]
|
|
20
|
+
# @return [String]
|
|
21
|
+
def resolve(role_name)
|
|
22
|
+
selector, _remaining = resolve_with_candidates(role_name)
|
|
23
|
+
selector
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Resolve role name and return both the selected candidate and remaining candidates.
|
|
27
|
+
# Remaining candidates can be used as a fallback chain at query time.
|
|
28
|
+
# @param role_name [String]
|
|
29
|
+
# @return [Array(String, Array<String>)] [resolved_selector, remaining_candidates]
|
|
30
|
+
def resolve_with_candidates(role_name)
|
|
31
|
+
normalized_role_name = role_name.to_s.strip
|
|
32
|
+
if normalized_role_name.empty?
|
|
33
|
+
raise Ace::LLM::ConfigurationError, "Invalid target: role name cannot be empty"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
role_config = Models::RoleConfig.from_hash(@configuration.get("llm.roles"))
|
|
37
|
+
candidates = role_config.candidates_for(normalized_role_name)
|
|
38
|
+
unless candidates
|
|
39
|
+
available = role_config.role_names
|
|
40
|
+
available_display = available.empty? ? "(none)" : available.join(", ")
|
|
41
|
+
raise Ace::LLM::ConfigurationError,
|
|
42
|
+
"Unknown role: #{normalized_role_name}. Defined roles: #{available_display}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
candidates.each_with_index do |candidate, index|
|
|
46
|
+
if candidate_available?(candidate)
|
|
47
|
+
remaining = candidates[(index + 1)..]
|
|
48
|
+
return [candidate, remaining]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
raise Ace::LLM::ConfigurationError,
|
|
53
|
+
"No available models for role '#{normalized_role_name}'. Tried: #{candidates.join(", ")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def candidate_available?(candidate)
|
|
59
|
+
provider = provider_for_candidate(candidate)
|
|
60
|
+
return false if provider.nil? || provider.empty?
|
|
61
|
+
return false if @configuration.provider_inactive?(provider)
|
|
62
|
+
return false unless @registry.provider_available?(provider)
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def provider_for_candidate(candidate)
|
|
68
|
+
candidate_target, _preset, _preset_error = split_preset_suffix(candidate.to_s)
|
|
69
|
+
model_target, _thinking, _thinking_error = split_thinking_suffix(candidate_target)
|
|
70
|
+
resolved = @registry.resolve_alias(model_target)
|
|
71
|
+
provider = resolved.to_s.split(":", 2).first
|
|
72
|
+
normalize_provider(provider)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize_provider(name)
|
|
76
|
+
name.to_s.strip.downcase.gsub(/[-_]/, "")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def split_preset_suffix(input)
|
|
80
|
+
provider_target, preset_name = input.split("@", 2)
|
|
81
|
+
return [input, nil, nil] unless input.include?("@")
|
|
82
|
+
|
|
83
|
+
[provider_target.to_s.strip, preset_name.to_s.strip, nil]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def split_thinking_suffix(model_input)
|
|
87
|
+
trimmed = model_input.to_s.strip
|
|
88
|
+
base, separator, suffix = trimmed.rpartition(":")
|
|
89
|
+
return [trimmed, nil, nil] if separator.empty?
|
|
90
|
+
|
|
91
|
+
normalized_level = suffix.to_s.strip.downcase
|
|
92
|
+
return [trimmed, nil, nil] unless THINKING_LEVELS.include?(normalized_level)
|
|
93
|
+
return [trimmed, nil, nil] if base.to_s.strip.empty?
|
|
94
|
+
|
|
95
|
+
[base.strip, normalized_level, nil]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
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,
|
|
@@ -104,7 +102,40 @@ module Ace
|
|
|
104
102
|
registry: registry,
|
|
105
103
|
fallback_config: fallback_config,
|
|
106
104
|
timeout: resolved_timeout,
|
|
107
|
-
debug: debug
|
|
105
|
+
debug: debug,
|
|
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
|
+
}
|
|
108
139
|
)
|
|
109
140
|
|
|
110
141
|
text_content = extract_text_content(response)
|
|
@@ -173,6 +204,43 @@ module Ace
|
|
|
173
204
|
end
|
|
174
205
|
private_class_method :load_execution_overrides
|
|
175
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
|
+
|
|
176
244
|
def self.merge_execution_overrides(base, overlay)
|
|
177
245
|
left = base.respond_to?(:to_h) ? deep_stringify_keys(base.to_h) : deep_stringify_keys(base || {})
|
|
178
246
|
right = overlay.respond_to?(:to_h) ? deep_stringify_keys(overlay.to_h) : deep_stringify_keys(overlay || {})
|
|
@@ -290,12 +358,22 @@ module Ace
|
|
|
290
358
|
end
|
|
291
359
|
|
|
292
360
|
def self.execute_with_fallback(provider:, model:, messages:, generation_opts:,
|
|
293
|
-
registry:, fallback_config:, timeout:, debug:)
|
|
361
|
+
registry:, fallback_config:, timeout:, debug:, role_fallbacks: nil, preset: nil, thinking_level: nil, option_builder:)
|
|
294
362
|
if fallback_config.disabled?
|
|
295
363
|
client = registry.get_client(provider, model: model, timeout: timeout)
|
|
296
364
|
return client.generate(messages, **generation_opts)
|
|
297
365
|
end
|
|
298
366
|
|
|
367
|
+
primary_provider_string = model ? "#{provider}:#{model}" : provider
|
|
368
|
+
|
|
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))
|
|
371
|
+
if role_fallbacks&.any?
|
|
372
|
+
existing_chain = fallback_config.providers_for(primary_provider_string)
|
|
373
|
+
merged_chain = normalized_role_fallbacks + (existing_chain - normalized_role_fallbacks)
|
|
374
|
+
fallback_config = fallback_config.merge(chains: {primary_provider_string => merged_chain})
|
|
375
|
+
end
|
|
376
|
+
|
|
299
377
|
status_callback = ->(msg) { warn msg }
|
|
300
378
|
|
|
301
379
|
orchestrator = Molecules::FallbackOrchestrator.new(
|
|
@@ -304,10 +382,13 @@ module Ace
|
|
|
304
382
|
timeout: timeout
|
|
305
383
|
)
|
|
306
384
|
|
|
307
|
-
primary_provider_string
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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)
|
|
311
392
|
end
|
|
312
393
|
end
|
|
313
394
|
|
|
@@ -372,7 +453,7 @@ module Ace
|
|
|
372
453
|
|
|
373
454
|
parse_result = parser.parse(provider)
|
|
374
455
|
canonical_provider = if parse_result.valid?
|
|
375
|
-
|
|
456
|
+
parse_result.to_s
|
|
376
457
|
else
|
|
377
458
|
provider
|
|
378
459
|
end
|
data/lib/ace/llm/version.rb
CHANGED
data/lib/ace/llm.rb
CHANGED
|
@@ -19,13 +19,16 @@ require_relative "llm/atoms/xdg_directory_resolver"
|
|
|
19
19
|
require_relative "llm/atoms/error_classifier"
|
|
20
20
|
|
|
21
21
|
require_relative "llm/models/fallback_config"
|
|
22
|
+
require_relative "llm/models/role_config"
|
|
22
23
|
|
|
23
24
|
require_relative "llm/molecules/file_io_handler"
|
|
24
25
|
require_relative "llm/molecules/llm_alias_resolver"
|
|
26
|
+
require_relative "llm/molecules/role_resolver"
|
|
25
27
|
require_relative "llm/molecules/provider_model_parser"
|
|
26
28
|
require_relative "llm/molecules/format_handlers"
|
|
27
29
|
require_relative "llm/molecules/client_registry"
|
|
28
30
|
require_relative "llm/molecules/fallback_orchestrator"
|
|
31
|
+
require_relative "llm/molecules/interactive_command_builder"
|
|
29
32
|
|
|
30
33
|
require_relative "llm/query_interface"
|
|
31
34
|
|