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 +4 -4
- data/.ace-defaults/llm/config.yml +93 -0
- data/CHANGELOG.md +42 -0
- data/lib/ace/llm/cli/commands/query.rb +14 -4
- 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 +88 -9
- data/lib/ace/llm/molecules/provider_model_parser.rb +85 -24
- data/lib/ace/llm/molecules/role_resolver.rb +101 -0
- data/lib/ace/llm/query_interface.rb +12 -4
- data/lib/ace/llm/version.rb +1 -1
- data/lib/ace/llm.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d2199db9c575bab6a4e80d9537115bbd643cdcf3ae9925b7af6469ec9fd0b0e
|
|
4
|
+
data.tar.gz: e20d96efca816db66196dd4c907f9814ab8d7c0cb759e33d1d11cfb5f98e6326
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
219
|
-
provider[:api_key_present] ? "
|
|
218
|
+
credential_status = if provider[:api_key_required]
|
|
219
|
+
provider[:api_key_present] ? "Credentials configured" : "Credentials required"
|
|
220
220
|
else
|
|
221
|
-
"No
|
|
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} (#{
|
|
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:
|
|
121
|
-
api_key_present:
|
|
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
|
-
#
|
|
303
|
-
# @param
|
|
304
|
-
# @
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
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,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
|
data/lib/ace/llm/version.rb
CHANGED
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.
|
|
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-
|
|
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
|