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.
@@ -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 get_client(primary_provider, registry) if @config.disabled?
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) { |client| yield client }
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) { |client| yield client }
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
- # Parse provider:model format if present
149
- if provider_name.include?(":")
150
- provider, model = provider_name.split(":", 2)
151
- registry.get_client(provider, model: model, **opts)
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 supported_providers
87
- @registry.available_providers
88
- end
152
+ def build_role_fallbacks(candidates, caller_preset, caller_thinking)
153
+ return nil if candidates.nil? || candidates.empty?
89
154
 
90
- def default_model_for(provider)
91
- models = @registry.models_for_provider(provider)
92
- models&.first
93
- end
155
+ candidates.map do |candidate|
156
+ parsed = parse_standard_target(candidate)
157
+ next nil if parsed.invalid?
94
158
 
95
- def dynamic_aliases
96
- return {} unless @alias_resolver
97
-
98
- global = @alias_resolver.available_aliases[:global] || {}
99
- providers = @alias_resolver.available_aliases[:providers] || {}
100
-
101
- flattened = {}
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
- private
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
- resolved_temperature = first_non_nil(temperature, execution_overrides["temperature"])
68
- resolved_max_tokens = first_non_nil(max_tokens, execution_overrides["max_tokens"])
69
- resolved_cli_args = first_non_nil(cli_args, execution_overrides["cli_args"])
70
- resolved_system_append = first_non_empty(system_append, execution_overrides["system_append"])
71
- resolved_sandbox = first_non_nil(sandbox, execution_overrides["sandbox"])
72
- resolved_working_dir = first_non_nil(working_dir, execution_overrides["working_dir"])
73
- resolved_subprocess_env = merge_hash_values(execution_overrides["subprocess_env"], subprocess_env)
74
-
75
- generation_opts[:temperature] = resolved_temperature unless resolved_temperature.nil?
76
- generation_opts[:max_tokens] = resolved_max_tokens unless resolved_max_tokens.nil?
77
- generation_opts[:system_file] = system_file if system_file
78
- generation_opts[:prompt_file] = prompt_file if prompt_file
79
- generation_opts[:cli_args] = resolved_cli_args unless blank_value?(resolved_cli_args)
80
- generation_opts[:system_append] = resolved_system_append unless blank_value?(resolved_system_append)
81
- generation_opts[:sandbox] = resolved_sandbox if resolved_sandbox
82
- generation_opts[:working_dir] = resolved_working_dir unless blank_value?(resolved_working_dir)
83
- generation_opts[:subprocess_env] = resolved_subprocess_env unless resolved_subprocess_env.nil?
84
- generation_opts[:last_message_file] = last_message_file if last_message_file
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: #{resolved_temperature}" unless resolved_temperature.nil?
92
- warn "Max tokens: #{resolved_max_tokens}" unless resolved_max_tokens.nil?
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 = model ? "#{provider}:#{model}" : provider
308
-
309
- orchestrator.execute(primary_provider: primary_provider_string, registry: registry) do |client|
310
- client.generate(messages, **generation_opts)
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
- "#{parse_result.provider}:#{parse_result.model}"
456
+ parse_result.to_s
376
457
  else
377
458
  provider
378
459
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module LLM
5
- VERSION = '0.30.2'
5
+ VERSION = '0.36.3'
6
6
  end
7
7
  end
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