ace-llm 0.30.2 → 0.32.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2eb129fd759362beb79ce68322986ba1378a4ba7a0fb2b9cc2964941bda4fbb9
4
- data.tar.gz: 10383bb45c7f012164d6983da558ec7fd2d5fabbbc91faa8589c078c2aef60eb
3
+ metadata.gz: 9d2199db9c575bab6a4e80d9537115bbd643cdcf3ae9925b7af6469ec9fd0b0e
4
+ data.tar.gz: e20d96efca816db66196dd4c907f9814ab8d7c0cb759e33d1d11cfb5f98e6326
5
5
  SHA512:
6
- metadata.gz: 76fb444f51fbdcd4b27cbde5ac785ca2bceffe4f350f6946b8b07aa89fe1bdfed7457cb1639a8c33b82fcc3446c05bcdd0a9d7626edab0a84ed16ef357ef4a8c
7
- data.tar.gz: 7e0fc445cf15a599c1d60ffcf4483abd199b9233b40b1ae1cbeed195d3ea8f85a1d1d8cf4a664889f84e9e777a786335d901aa32103c0ec1dd9e8ee3a81c6b03
6
+ metadata.gz: 72d45697b419523ec93028ff004cc840c641336dcfdd985d13a18f4468ad0d1b2abede096887309c02c39bbbd10f6e0909afb7c92ac7d379b805ad52ef30560e
7
+ data.tar.gz: a92cce87e5212b2d8c201aa04265e2255e6f6d7022315622f53c70f2c24cfe704d890d07379ae2b86fb4889e6fb66f30e1b3aa025b77b28ae0e756a620a1290a
@@ -25,6 +25,99 @@ llm:
25
25
  chains: {}
26
26
  providers: []
27
27
 
28
+ # Named model roles for indirection across consumer configs.
29
+ # Each role lists candidates in priority order; RoleResolver returns
30
+ # the first available one. Every role covers codex, claude, and gemini
31
+ # so resolution succeeds even when a provider is unconfigured.
32
+ # Override in .ace/llm/config.yml for project-specific role assignments.
33
+ roles:
34
+ # --- Reusable candidate lists ---
35
+ _utility-lite: &utility-lite
36
+ - google:lite
37
+ - codex:mini
38
+ - claude:haiku
39
+ _assign-worker: &assign-worker
40
+ - codex:codex@yolo
41
+ - claude:sonnet@yolo
42
+ - gemini:pro-latest@yolo
43
+
44
+ # --- Diagnostic ---
45
+ doctor:
46
+ - gemini:flash-latest@yolo
47
+ - claude:haiku@yolo
48
+ - codex:mini@yolo
49
+
50
+ # --- Planning ---
51
+ planner:
52
+ - codex:gpt@ro
53
+ - claude:sonnet@ro
54
+ - gemini:pro-latest@ro
55
+
56
+ # --- Review (single-model and synthesis) ---
57
+ review-default:
58
+ - codex:codex@ro
59
+ - claude:sonnet@ro
60
+ - gemini:pro-latest@ro
61
+ review-synthesizer:
62
+ - codex:gpt@ro
63
+ - claude:sonnet@ro
64
+ - gemini:pro-latest@ro
65
+
66
+ # --- Review (provider-specific, for parallel multi-model review) ---
67
+ review-claude:
68
+ - claude:opus@ro
69
+ - codex:gpt@ro
70
+ - gemini:pro-latest@ro
71
+ review-codex:
72
+ - codex:gpt@ro
73
+ - claude:sonnet@ro
74
+ - gemini:pro-latest@ro
75
+ review-gemini:
76
+ - gemini:pro-latest@ro
77
+ - claude:sonnet@ro
78
+ - codex:gpt@ro
79
+
80
+ # --- Assignment ---
81
+ assign-executor: *assign-worker
82
+ assign-engineer: *assign-worker
83
+ assign-orchestrator:
84
+ - claude:sonnet
85
+ - codex:gpt
86
+ - gemini:pro-latest
87
+
88
+ # --- E2E testing ---
89
+ e2e-executor:
90
+ - claude:haiku@yolo
91
+ - codex:mini@yolo
92
+ - gemini:flash-latest@yolo
93
+ e2e-reporter:
94
+ - claude:haiku
95
+ - codex:mini
96
+ - gemini:flash-latest
97
+
98
+ # --- Utility ---
99
+ prompt-enhance: *utility-lite
100
+ commit:
101
+ - codex:mini
102
+ - claude:haiku
103
+ - gemini:flash-latest
104
+ docs-analysis: *utility-lite
105
+ idea-enhance:
106
+ - gemini:flash-latest
107
+ - codex:mini
108
+ - claude:haiku
109
+ compressor: *utility-lite
110
+
111
+ # --- Simulation ---
112
+ sim-primary:
113
+ - google:flash-preview
114
+ - claude:haiku
115
+ - codex:mini
116
+ sim-synthesis:
117
+ - claude:haiku
118
+ - codex:mini
119
+ - gemini:flash-latest
120
+
28
121
  # Default context limit for unknown models (in tokens)
29
122
  # Provider-specific limits are defined in providers/*.yml
30
123
  context_limit:
data/CHANGELOG.md CHANGED
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.32.1] - 2026-04-01
11
+
12
+ ### Fixed
13
+ - Made role-based fallback chain-aware so remaining role candidates are tried before global fallback providers on query failure.
14
+
15
+ ## [0.32.0] - 2026-03-31
16
+
17
+ ### Added
18
+ - Shipped canonical role catalog with gem defaults so `role:*` selectors resolve without project-level config.
19
+
20
+ ### Fixed
21
+ - Added type guard in `RoleConfig.from_hash` to raise `ConfigurationError` for non-Hash role config input.
22
+
23
+ ## [0.31.3] - 2026-03-31
24
+
25
+ ### Technical
26
+ - Clarified provider fallback environment-key coupling in `ClientRegistry` to align credential resolution guidance with current setup behavior.
27
+
28
+ ## [0.31.2] - 2026-03-30
29
+
30
+ ### Technical
31
+ - Removed the unused `api_key_present?` helper from `ClientRegistry` to keep provider credential routing focused on active call sites.
32
+
33
+ ## [0.31.1] - 2026-03-30
34
+
35
+ ### Technical
36
+ - Added regression coverage for Google provider credential fallback when only `GOOGLE_API_KEY` is configured.
37
+
38
+ ## [0.31.0] - 2026-03-29
39
+
40
+ ### Added
41
+ - Extended `ace-llm --list-providers` output with provider credential setup hints, including required environment variable names.
42
+ - Added named model-role resolution via `role:<name>` selectors in `ProviderModelParser`, including strict runtime availability checks across candidate providers.
43
+
44
+ ### Changed
45
+ - Made provider setup failures actionable by surfacing supported provider lists, ignored-provider guidance, and an explicit recovery path via `ace-llm --list-providers`.
46
+ - Updated onboarding and usage documentation to align runtime errors with provider discovery and environment-variable setup guidance.
47
+ - Added `llm.roles` configuration support and a dedicated `RoleResolver`/`RoleConfig` pipeline so callers can centralize provider-model selection and keep caller `:thinking` and `@preset` overrides authoritative.
48
+
49
+ ### Technical
50
+ - Added focused coverage for role config validation, resolver behavior, and parser integration in `test/models/role_config_test.rb`, `test/molecules/role_resolver_test.rb`, and `test/molecules/provider_model_parser_test.rb`.
51
+
10
52
  ## [0.30.2] - 2026-03-29
11
53
 
12
54
  ### Technical
@@ -215,17 +215,27 @@ module Ace
215
215
 
216
216
  providers.each do |provider|
217
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"
218
+ credential_status = if provider[:api_key_required]
219
+ provider[:api_key_present] ? "Credentials configured" : "Credentials required"
220
220
  else
221
- "No API key needed"
221
+ "No credentials required"
222
222
  end
223
223
 
224
224
  models = provider[:models] || []
225
225
  model_count = models.empty? ? "" : " \u00b7 #{models.length} models"
226
- puts "#{status} #{provider[:name]}#{model_count} (#{api_status})"
226
+ puts "#{status} #{provider[:name]}#{model_count} (#{credential_status})"
227
227
 
228
228
  print_wrapped_list(models, indent: " ") unless models.empty?
229
+ env_keys = provider[:credential_env_keys] || []
230
+ if provider[:api_key_required]
231
+ if env_keys.empty?
232
+ puts " Setup hint: credentials are required for this provider."
233
+ else
234
+ puts " Setup hint: set #{env_keys.join(' or ')}"
235
+ end
236
+ else
237
+ puts " Setup hint: no credential environment variable required."
238
+ end
229
239
  puts " Gem required: #{provider[:gem]}" unless provider[:available]
230
240
  puts ""
231
241
  end
@@ -59,6 +59,11 @@ module Ace
59
59
  Molecules::ConfigLoader.get(path)
60
60
  end
61
61
 
62
+ # Get configured role map (llm.roles) from config cascade.
63
+ def roles
64
+ get("llm.roles") || {}
65
+ end
66
+
62
67
  # Check if configuration exists
63
68
  def configured?
64
69
  !config.empty?
@@ -122,7 +127,10 @@ module Ace
122
127
  unknown = active_allow_list - available
123
128
  return if unknown.empty?
124
129
 
125
- warn "Unknown providers in llm.providers.active: #{unknown.join(", ")} (ignored)"
130
+ warn "Unknown providers in llm.providers.active: #{unknown.join(", ")} (ignored). " \
131
+ "These names do not match configured providers and were skipped. " \
132
+ "Update llm.providers.active to use supported provider names, or run " \
133
+ "`ace-llm --list-providers` for available providers and configuration guidance."
126
134
  end
127
135
 
128
136
  def active_provider_allow_list
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module LLM
5
+ module Models
6
+ # RoleConfig represents llm.roles configuration with validation.
7
+ class RoleConfig
8
+ attr_reader :roles
9
+
10
+ def initialize(roles: {})
11
+ @roles = normalize_roles(roles).freeze
12
+ validate!
13
+ end
14
+
15
+ def self.from_hash(hash)
16
+ return new unless hash
17
+
18
+ unless hash.is_a?(Hash)
19
+ raise Ace::LLM::ConfigurationError, "llm.roles config must be a Hash, got: #{hash.class}"
20
+ end
21
+
22
+ roles = hash.fetch(:roles, hash.fetch("roles", hash))
23
+ new(roles: roles)
24
+ end
25
+
26
+ def role_names
27
+ @roles.keys.sort
28
+ end
29
+
30
+ def candidates_for(role_name)
31
+ normalized = normalize_role_name(role_name)
32
+ @roles[normalized]
33
+ end
34
+
35
+ private
36
+
37
+ def normalize_roles(roles)
38
+ return {} if roles.nil?
39
+
40
+ unless roles.is_a?(Hash)
41
+ raise Ace::LLM::ConfigurationError, "llm.roles must be a hash, got: #{roles.class}"
42
+ end
43
+
44
+ roles.each_with_object({}) do |(name, candidates), acc|
45
+ normalized_name = normalize_role_name(name)
46
+ acc[normalized_name] = Array(candidates).map { |candidate| candidate.to_s.strip }
47
+ end
48
+ end
49
+
50
+ def normalize_role_name(name)
51
+ name.to_s.strip
52
+ end
53
+
54
+ def validate!
55
+ @roles.each do |name, candidates|
56
+ if name.empty?
57
+ raise Ace::LLM::ConfigurationError, "role name cannot be empty"
58
+ end
59
+
60
+ unless candidates.is_a?(Array) && !candidates.empty?
61
+ raise Ace::LLM::ConfigurationError, "role '#{name}' must define at least one candidate"
62
+ end
63
+
64
+ candidates.each do |candidate|
65
+ if candidate.empty?
66
+ raise Ace::LLM::ConfigurationError, "role '#{name}' contains an empty candidate"
67
+ end
68
+
69
+ if candidate.start_with?("role:")
70
+ raise Ace::LLM::ConfigurationError,
71
+ "role '#{name}' cannot reference nested role candidate '#{candidate}'"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -112,17 +112,39 @@ module Ace
112
112
  # @return [Hash] Provider status information
113
113
  def list_providers_with_status
114
114
  @providers.map do |name, config|
115
+ credential = credential_status(config, name)
115
116
  {
116
117
  name: name,
117
118
  models: config["models"] || [],
118
119
  gem: config["gem"],
119
120
  available: provider_available?(name),
120
- api_key_required: config.dig("api_key", "required") || false,
121
- api_key_present: api_key_present?(config["api_key"])
121
+ api_key_required: credential[:required],
122
+ api_key_present: credential[:present],
123
+ credential_env_keys: credential[:env_keys]
122
124
  }
123
125
  end
124
126
  end
125
127
 
128
+ # Whether provider config marks API key as required.
129
+ # @param provider_name [String]
130
+ # @return [Boolean]
131
+ def provider_api_key_required?(provider_name)
132
+ provider = get_provider(provider_name)
133
+ return false unless provider
134
+
135
+ provider.dig("api_key", "required") || false
136
+ end
137
+
138
+ # Whether provider has a present API key according to its api_key config.
139
+ # @param provider_name [String]
140
+ # @return [Boolean]
141
+ def provider_api_key_present?(provider_name)
142
+ provider = get_provider(provider_name)
143
+ return false unless provider
144
+
145
+ api_key_present?(provider["api_key"])
146
+ end
147
+
126
148
  # Reload all configurations
127
149
  def reload!
128
150
  @providers.clear
@@ -299,14 +321,30 @@ module Ace
299
321
  end
300
322
  end
301
323
 
302
- # Check if API key is present
303
- # @param api_key_config [Hash, String, nil] API key configuration
304
- # @return [Boolean] True if API key is configured and present
305
- def api_key_present?(api_key_config)
306
- return false if api_key_config.nil?
324
+ # Build normalized credential status for provider output.
325
+ # @param provider_config [Hash] provider configuration hash
326
+ # @param provider_name [String] normalized provider name
327
+ # @return [Hash] credential status
328
+ def credential_status(provider_config, provider_name)
329
+ env_keys = extract_credential_env_keys(provider_config, provider_name)
330
+ explicit_required = provider_config.dig("api_key", "required")
331
+ required = explicit_required.nil? ? !env_keys.empty? : explicit_required
332
+
333
+ present = if provider_config["api_key"].is_a?(String) && !provider_config["api_key"].empty?
334
+ true
335
+ elsif provider_config["api_key"].is_a?(Hash) && provider_config["api_key"]["value"]
336
+ !provider_config["api_key"]["value"].to_s.empty?
337
+ elsif provider_config["api_key"].is_a?(Hash) && provider_config["api_key"]["env"]
338
+ !ENV[provider_config["api_key"]["env"]].to_s.empty?
339
+ else
340
+ env_keys.any? { |key| !ENV[key].to_s.empty? }
341
+ end
307
342
 
308
- key = resolve_api_key(api_key_config)
309
- !key.nil? && !key.empty?
343
+ {
344
+ required: required,
345
+ present: present,
346
+ env_keys: env_keys
347
+ }
310
348
  end
311
349
 
312
350
  # Build alias maps from provider configurations
@@ -330,6 +368,47 @@ module Ace
330
368
  end
331
369
  end
332
370
  end
371
+
372
+ def extract_credential_env_keys(provider_config, provider_name)
373
+ env_keys = []
374
+
375
+ api_key_env = provider_config.dig("api_key", "env")
376
+ env_keys << api_key_env if api_key_env
377
+
378
+ backends = provider_config["backends"]
379
+ if backends.is_a?(Hash)
380
+ backends.each_value do |backend|
381
+ next unless backend.is_a?(Hash)
382
+
383
+ env_key = backend["env_key"]
384
+ Array(env_key).each do |key|
385
+ env_keys << key if key
386
+ end
387
+ end
388
+ end
389
+
390
+ # Keep parity with BaseClient/provider.yml defaults for providers that rely on
391
+ # fallback keys. If new providers are added, update this fallback map as well.
392
+ env_keys.concat(default_env_keys_for_provider(provider_name)) if env_keys.empty?
393
+ env_keys.map(&:to_s).map(&:strip).reject(&:empty?).uniq
394
+ end
395
+
396
+ def default_env_keys_for_provider(provider_name)
397
+ case provider_name.to_s.downcase
398
+ when "google"
399
+ %w[GEMINI_API_KEY GOOGLE_API_KEY]
400
+ when "openai"
401
+ ["OPENAI_API_KEY"]
402
+ when "anthropic"
403
+ ["ANTHROPIC_API_KEY"]
404
+ when "mistral"
405
+ ["MISTRAL_API_KEY"]
406
+ when "togetherai"
407
+ %w[TOGETHER_API_KEY TOGETHERAI_API_KEY]
408
+ else
409
+ []
410
+ end
411
+ end
333
412
  end
334
413
  end
335
414
  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,101 @@
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
+ return false if @registry.provider_api_key_required?(provider) && !@registry.provider_api_key_present?(provider)
64
+
65
+ true
66
+ end
67
+
68
+ def provider_for_candidate(candidate)
69
+ candidate_target, _preset, _preset_error = split_preset_suffix(candidate.to_s)
70
+ model_target, _thinking, _thinking_error = split_thinking_suffix(candidate_target)
71
+ resolved = @registry.resolve_alias(model_target)
72
+ provider = resolved.to_s.split(":", 2).first
73
+ normalize_provider(provider)
74
+ end
75
+
76
+ def normalize_provider(name)
77
+ name.to_s.strip.downcase.gsub(/[-_]/, "")
78
+ end
79
+
80
+ def split_preset_suffix(input)
81
+ provider_target, preset_name = input.split("@", 2)
82
+ return [input, nil, nil] unless input.include?("@")
83
+
84
+ [provider_target.to_s.strip, preset_name.to_s.strip, nil]
85
+ end
86
+
87
+ def split_thinking_suffix(model_input)
88
+ trimmed = model_input.to_s.strip
89
+ base, separator, suffix = trimmed.rpartition(":")
90
+ return [trimmed, nil, nil] if separator.empty?
91
+
92
+ normalized_level = suffix.to_s.strip.downcase
93
+ return [trimmed, nil, nil] unless THINKING_LEVELS.include?(normalized_level)
94
+ return [trimmed, nil, nil] if base.to_s.strip.empty?
95
+
96
+ [base.strip, normalized_level, nil]
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -104,7 +104,8 @@ module Ace
104
104
  registry: registry,
105
105
  fallback_config: fallback_config,
106
106
  timeout: resolved_timeout,
107
- debug: debug
107
+ debug: debug,
108
+ role_fallbacks: parse_result.role_fallbacks
108
109
  )
109
110
 
110
111
  text_content = extract_text_content(response)
@@ -290,12 +291,21 @@ module Ace
290
291
  end
291
292
 
292
293
  def self.execute_with_fallback(provider:, model:, messages:, generation_opts:,
293
- registry:, fallback_config:, timeout:, debug:)
294
+ registry:, fallback_config:, timeout:, debug:, role_fallbacks: nil)
294
295
  if fallback_config.disabled?
295
296
  client = registry.get_client(provider, model: model, timeout: timeout)
296
297
  return client.generate(messages, **generation_opts)
297
298
  end
298
299
 
300
+ primary_provider_string = model ? "#{provider}:#{model}" : provider
301
+
302
+ # Inject remaining role candidates ahead of the global fallback chain
303
+ if role_fallbacks&.any?
304
+ existing_chain = fallback_config.providers_for(primary_provider_string)
305
+ merged_chain = role_fallbacks + (existing_chain - role_fallbacks)
306
+ fallback_config = fallback_config.merge(chains: {primary_provider_string => merged_chain})
307
+ end
308
+
299
309
  status_callback = ->(msg) { warn msg }
300
310
 
301
311
  orchestrator = Molecules::FallbackOrchestrator.new(
@@ -304,8 +314,6 @@ module Ace
304
314
  timeout: timeout
305
315
  )
306
316
 
307
- primary_provider_string = model ? "#{provider}:#{model}" : provider
308
-
309
317
  orchestrator.execute(primary_provider: primary_provider_string, registry: registry) do |client|
310
318
  client.generate(messages, **generation_opts)
311
319
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module LLM
5
- VERSION = '0.30.2'
5
+ VERSION = '0.32.1'
6
6
  end
7
7
  end
data/lib/ace/llm.rb CHANGED
@@ -19,9 +19,11 @@ 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"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.2
4
+ version: 0.32.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-29 00:00:00.000000000 Z
10
+ date: 2026-04-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -267,6 +267,7 @@ files:
267
267
  - lib/ace/llm/cli/commands/query.rb
268
268
  - lib/ace/llm/configuration.rb
269
269
  - lib/ace/llm/models/fallback_config.rb
270
+ - lib/ace/llm/models/role_config.rb
270
271
  - lib/ace/llm/molecules/client_registry.rb
271
272
  - lib/ace/llm/molecules/config_loader.rb
272
273
  - lib/ace/llm/molecules/fallback_orchestrator.rb
@@ -277,6 +278,7 @@ files:
277
278
  - lib/ace/llm/molecules/preset_loader.rb
278
279
  - lib/ace/llm/molecules/provider_loader.rb
279
280
  - lib/ace/llm/molecules/provider_model_parser.rb
281
+ - lib/ace/llm/molecules/role_resolver.rb
280
282
  - lib/ace/llm/molecules/thinking_level_loader.rb
281
283
  - lib/ace/llm/organisms/anthropic_client.rb
282
284
  - lib/ace/llm/organisms/base_client.rb