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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2eb129fd759362beb79ce68322986ba1378a4ba7a0fb2b9cc2964941bda4fbb9
4
- data.tar.gz: 10383bb45c7f012164d6983da558ec7fd2d5fabbbc91faa8589c078c2aef60eb
3
+ metadata.gz: 1d92ed4d8cab8d260ef4235ecf94f612af751abbf4d1f4a4cdd30951d8c33388
4
+ data.tar.gz: dd6ff444bed65aa46801197857f39c008087a41da8e01c6cafcfc3c88d781a43
5
5
  SHA512:
6
- metadata.gz: 76fb444f51fbdcd4b27cbde5ac785ca2bceffe4f350f6946b8b07aa89fe1bdfed7457cb1639a8c33b82fcc3446c05bcdd0a9d7626edab0a84ed16ef357ef4a8c
7
- data.tar.gz: 7e0fc445cf15a599c1d60ffcf4483abd199b9233b40b1ae1cbeed195d3ea8f85a1d1d8cf4a664889f84e9e777a786335d901aa32103c0ec1dd9e8ee3a81c6b03
6
+ metadata.gz: 66a5720b86973458a565c63fb36a37061d1e201b1f0be9b43cc3d6f4ecedad6785117d96ec8a53819ab35aacc70246c68780af5d82c24f8c52067a94b1714b69
7
+ data.tar.gz: b107b41c5221dcfda405a2073996184edf6f52b075a97f90c753b36eb283ce07403942c96e8f3116dfc2cbab3d58faa61c2630b378718c5a31aaf1c5ec8db304
@@ -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
+ - google:lite
102
+ - codex:mini
103
+ - claude:haiku
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,153 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.36.3] - 2026-04-19
11
+
12
+ ### Fixed
13
+ - Fixed `ace-llm --interactive` startup for Codex aliases so the `/as-onboard` and related interactive alias prompts are launched with a valid subprocess command shape instead of aborting with a missing keyword.
14
+
15
+ ## [0.36.2] - 2026-04-19
16
+
17
+ ### Fixed
18
+ - Refreshed Codex model examples in README, usage, getting-started, demo, and help output from stale `codex:gpt-5` forms to the current stable `codex:gpt` alias and updated current provider alias guidance.
19
+
20
+ ## [0.36.1] - 2026-04-16
21
+
22
+ ### Fixed
23
+ - Switched the fresh-default `commit` role to prefer `google:lite` before CLI fallbacks so new `ace-config init` projects keep generated commit messages working when stale CLI aliases or unavailable local CLIs would otherwise break message generation.
24
+
25
+ ## [0.36.0] - 2026-04-15
26
+
27
+ ### Changed
28
+ - Reworked `TS-LLM-001` to public-surface goal style by tightening model-routing evidence checks, adding `TC-003` output-file-contract coverage, and prioritizing end-state/output artifacts over helper-only signals.
29
+ - Added `TS-LLM-002` provider-discovery coverage for `ace-llm --list-providers` so public setup-hint and provider-list output behavior is verified as a first-class E2E journey.
30
+
31
+ ### Technical
32
+ - Updated runtime dependency constraint to `ace-support-models ~> 0.11` after the coordinated support-models minor release.
33
+
34
+ ## [0.35.1] - 2026-04-15
35
+
36
+ ### Fixed
37
+ - Updated the `cli_args` threading feature tests to match the normalized array contract and stub provider preset loading directly, so `ace-test ace-llm` no longer fails on stale scalar expectations or package-local preset lookup assumptions.
38
+
39
+ ## [0.35.0] - 2026-04-15
40
+
41
+ ### Fixed
42
+ - Merged preset CLI args with explicit `--cli-args` so interactive launches keep provider autonomy presets such as `@yolo` while still accepting additional flags like `--no-alt-screen`.
43
+
44
+ ### Technical
45
+ - Threaded the current working directory and subprocess environment explicitly through `ace-llm --interactive` so provider-side startup policy hooks can make launch decisions from the real execution context.
46
+
47
+ ## [0.34.0] - 2026-04-15
48
+
49
+ ### Added
50
+ - Added `ace-llm --interactive` for supported CLI-backed providers so alias resolution, presets, thinking levels, and canonical skill handoff translation can launch the provider's native interactive terminal UI in one call.
51
+
52
+ ### Changed
53
+ - Documented interactive launch usage in the README, getting-started guide, and usage guide, including the tmux fork handoff pattern built around `/as-assign-drive <assignment>@<root>`.
54
+
55
+ ## [0.33.6] - 2026-04-13
56
+
57
+ ### Fixed
58
+ - Switched `TS-LLM-001` basic-query coverage to the explicit `--prompt` form so the scenario executes with canonical prompt input and no longer misclassifies dropped positional input as provider behavior.
59
+
60
+ ## [0.33.5] - 2026-04-13
61
+
62
+ ### Fixed
63
+ - Hardened `TS-LLM-001` basic-query execution by persisting the exact prompt and command artifacts so empty-input/provider-contract failures are distinguished from valid model responses.
64
+
65
+ ## [0.33.4] - 2026-04-13
66
+
67
+ ### Fixed
68
+ - Added the missing `ace-support-models` runtime dependency so `ace-llm --list-providers` no longer fails on fresh setups with `cannot load such file -- ace/support/models`.
69
+
70
+ ### Changed
71
+ - Updated quick-start onboarding guidance to include `ace-llm` in the base install command used before provider verification.
72
+
73
+ ## [0.33.3] - 2026-04-13
74
+
75
+ ### Fixed
76
+ - Added `codex:mini` as a fallback model for the `commit` role after `glite`, so role-based commit-message generation tries a secondary model instead of failing when `glite` is unavailable.
77
+
78
+
79
+ ### Technical
80
+ - Tightened the package version contract test to require a full semantic-version match instead of accepting partial matches.
81
+ ## [0.33.2] - 2026-04-13
82
+
83
+ ### Fixed
84
+ - Fixed provider credential availability checks so role-based model selection no longer crashes when a provider relies on fallback environment keys.
85
+
86
+ ### Technical
87
+ - Added regression coverage for `ClientRegistry#provider_api_key_present?` using Google fallback API key resolution.
88
+
89
+ ## [0.33.1] - 2026-04-13
90
+
91
+ ### Changed
92
+ - Completed the batch i05 migration follow-through for this package and aligned it with the restarted `fast` / `feat` / `e2e` verification model.
93
+
94
+ ### Technical
95
+ - Included in the coordinated assignment-driven patch release for batch i05 package updates.
96
+
97
+
98
+ ## [0.33.0] - 2026-04-12
99
+
100
+ ### Changed
101
+ - Migrated package tests to the restarted `fast` / `feat` / `e2e` contract:
102
+ - moved deterministic test suites from legacy top-level folders into `test/fast/`
103
+ - moved legacy `test/integration/` coverage into `test/feat/`
104
+ - reduced `TS-LLM-001` E2E scenario from 3 to 2 TCs and added an E2E decision record
105
+ - Updated README and package docs to teach only `ace-test ace-llm`, `ace-test ace-llm feat`, `ace-test ace-llm all`, and `ace-test-e2e ace-llm`.
106
+
107
+ ### Fixed
108
+ - Preserved full fallback target selectors (including provider/model + suffix context) across fallback attempts and rebuilt generation options per target instead of reusing stale primary-provider options.
109
+
110
+ ## [0.32.2] - 2026-04-10
111
+
112
+ ### Changed
113
+ - Updated the shared role catalog for restarted E2E execution with explicit `e2e-runner`, `e2e-verifier`, and `e2e-reporter` roles in the project-owned LLM configuration surface.
114
+
115
+ ## [0.32.1] - 2026-04-01
116
+
117
+ ### Fixed
118
+ - Made role-based fallback chain-aware so remaining role candidates are tried before global fallback providers on query failure.
119
+
120
+ ## [0.32.0] - 2026-03-31
121
+
122
+ ### Added
123
+ - Shipped canonical role catalog with gem defaults so `role:*` selectors resolve without project-level config.
124
+
125
+ ### Fixed
126
+ - Added type guard in `RoleConfig.from_hash` to raise `ConfigurationError` for non-Hash role config input.
127
+
128
+ ## [0.31.3] - 2026-03-31
129
+
130
+ ### Technical
131
+ - Clarified provider fallback environment-key coupling in `ClientRegistry` to align credential resolution guidance with current setup behavior.
132
+
133
+ ## [0.31.2] - 2026-03-30
134
+
135
+ ### Technical
136
+ - Removed the unused `api_key_present?` helper from `ClientRegistry` to keep provider credential routing focused on active call sites.
137
+
138
+ ## [0.31.1] - 2026-03-30
139
+
140
+ ### Technical
141
+ - Added regression coverage for Google provider credential fallback when only `GOOGLE_API_KEY` is configured.
142
+
143
+ ## [0.31.0] - 2026-03-29
144
+
145
+ ### Added
146
+ - Extended `ace-llm --list-providers` output with provider credential setup hints, including required environment variable names.
147
+ - Added named model-role resolution via `role:<name>` selectors in `ProviderModelParser`, including strict runtime availability checks across candidate providers.
148
+
149
+ ### Changed
150
+ - Made provider setup failures actionable by surfacing supported provider lists, ignored-provider guidance, and an explicit recovery path via `ace-llm --list-providers`.
151
+ - Updated onboarding and usage documentation to align runtime errors with provider discovery and environment-variable setup guidance.
152
+ - 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.
153
+
154
+ ### Technical
155
+ - 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`.
156
+
10
157
  ## [0.30.2] - 2026-03-29
11
158
 
12
159
  ### Technical
data/README.md CHANGED
@@ -18,7 +18,20 @@
18
18
 
19
19
  ![ace-llm demo](docs/demo/ace-llm-getting-started.gif)
20
20
 
21
- `ace-llm` gives developers and coding agents one command surface for querying any LLM provider. Address models by alias (`gflash`, `sonnet`), explicit `provider:model` notation, or with thinking levels (`codex:gpt-5:high`) and execution presets (`cc@ro`). Pass prompts and system instructions inline or as file paths. Fallback routing and retry behavior keep prompt workflows resilient.
21
+ `ace-llm` gives developers and coding agents one command surface for querying any LLM provider. Address models by alias (`gflash`, `sonnet`), explicit `provider:model` notation, or with thinking levels (`codex:gpt:high`) and execution presets (`cc@ro`). Pass prompts and system instructions inline or as file paths, or start supported CLI providers in native interactive mode with `--interactive`. Fallback routing and retry behavior keep prompt workflows resilient.
22
+
23
+ ## Testing
24
+
25
+ Package-local verification contract:
26
+
27
+ ```bash
28
+ ace-test ace-llm
29
+ ace-test ace-llm feat
30
+ ace-test ace-llm all
31
+ ace-test-e2e ace-llm
32
+ ```
33
+
34
+ Deterministic coverage lives in `test/fast/` and `test/feat/`. Scenario assets stay in `test/e2e/`.
22
35
 
23
36
  ## How It Works
24
37
 
@@ -30,10 +43,12 @@
30
43
 
31
44
  **Switch providers with aliases** - use short names like `gflash`, `sonnet`, `opus` instead of full `provider:model` notation. Aliases resolve through versioned YAML in [`.ace-defaults/`](docs/usage.md).
32
45
 
33
- **Control reasoning depth** - append a thinking level (`codex:gpt-5:high`, `claude:sonnet:low`) to tune reasoning budgets. Supported CLI providers: `claude`, `codex` (levels: `low`, `medium`, `high`, `xhigh`).
46
+ **Control reasoning depth** - append a thinking level (`codex:gpt:high`, `claude:sonnet:low`) to tune reasoning budgets. Supported CLI providers: `claude`, `codex` (levels: `low`, `medium`, `high`, `xhigh`).
34
47
 
35
48
  **Run preset-driven prompts** - apply execution profiles with `@preset` or `--preset`. Built-in presets for CLI providers: `@ro` (read-only), `@rw` (read-write), `@yolo` (full autonomy). Supported by: `claude`, `codex`, `gemini`, `opencode`, `pi`.
36
49
 
50
+ **Launch real interactive agents** - use `--interactive` for supported CLI providers so alias resolution, presets, and skill translation still flow through `ace-llm`, but the provider starts its native terminal UI instead of one-shot print mode.
51
+
37
52
  **Build resilient prompt workflows** - configure fallback chains and retry behavior through the [config cascade](.ace-defaults/llm/config.yml) so transient provider issues do not block work.
38
53
 
39
54
  **Power LLM-enhanced flows in sibling packages** - serve as the execution backend for [ace-git-commit](../ace-git-commit), [ace-idea](../ace-idea), [ace-review](../ace-review), [ace-sim](../ace-sim), [ace-prompt-prep](../ace-prompt-prep), and more.
@@ -32,6 +32,7 @@ module Ace
32
32
  option :model, type: :string, desc: "Model name (overrides PROVIDER[:MODEL])"
33
33
  option :prompt, type: :string, desc: "Prompt text (overrides positional PROMPT)"
34
34
  option :force, type: :boolean, default: false, desc: "Force overwrite existing files"
35
+ option :interactive, type: :boolean, default: false, desc: "Start an interactive CLI session instead of one-shot query"
35
36
 
36
37
  option :version, type: :boolean, desc: "Show version information"
37
38
  option :list_providers, type: :boolean, desc: "List available LLM providers"
@@ -88,6 +89,7 @@ module Ace
88
89
  puts " --timeout SECONDS Request timeout in seconds"
89
90
  puts " --model MODEL Model name (overrides PROVIDER[:MODEL])"
90
91
  puts " --prompt PROMPT Prompt text (overrides positional PROMPT)"
92
+ puts " --interactive Start an interactive CLI session"
91
93
  puts " --force Force overwrite existing files"
92
94
  puts " -q, --quiet Suppress config summary output"
93
95
  puts " -d, --debug Enable debug output"
@@ -97,17 +99,19 @@ module Ace
97
99
  puts ' ace-llm google:gemini-2.5-flash "What is Ruby?"'
98
100
  puts ' ace-llm gflash "Quick question" # using alias'
99
101
  puts ' ace-llm gflash@ro "Summarize this diff"'
100
- puts ' ace-llm codex:gpt-5:high@ro "Review this code"'
102
+ puts ' ace-llm codex:gpt:high@ro "Review this code"'
101
103
  puts ' ace-llm claude:sonnet "Summarize this diff" --preset rw'
102
104
  puts ' ace-llm claude:sonnet "Hi" --cli-args "dangerously-skip-permissions"'
103
105
  puts ' ace-llm claude:sonnet "Hi" --cli-args "--model=claude-sonnet-4-0 --verbose"'
106
+ puts ' ace-llm codex:gpt@yolo "/as-assign-drive abc123@010" --interactive'
104
107
  puts ""
105
108
  puts "Provider Aliases:"
106
109
  puts " Short aliases for common provider:MODEL combinations:"
107
- puts " gflash → google:gemini-2.5-flash"
108
- puts " glite → google:gemini-2.0-flash-lite"
109
- puts " gpt4 openai:gpt-4"
110
- puts " claude anthropic:claude-3-5-sonnet"
110
+ puts " gflash → google:gemini-flash-latest"
111
+ puts " glite → google:gemini-flash-lite-latest"
112
+ puts " codex:gpt codex:gpt-5.4"
113
+ puts " cc claude:sonnet"
114
+ puts " sonnet → anthropic:claude-sonnet-4-5"
111
115
  end
112
116
 
113
117
  def show_provider_help
@@ -150,6 +154,23 @@ module Ace
150
154
  system_append_text = options[:system_append] ? file_handler.read_content(options[:system_append]) : nil
151
155
  normalized_timeout = normalize_timeout(options[:timeout])
152
156
 
157
+ if options[:interactive]
158
+ validate_interactive_options!(options)
159
+ invocation = Ace::LLM::Molecules::InteractiveCommandBuilder.new.build(
160
+ provider_model: @provider_model,
161
+ prompt: prompt_text,
162
+ system: system_text,
163
+ cli_args: options[:cli_args],
164
+ system_append: system_append_text,
165
+ preset: options[:preset],
166
+ model: (@model_from_option ? nil : options[:model]),
167
+ working_dir: Dir.pwd,
168
+ subprocess_env: ENV.to_h
169
+ )
170
+ execute_interactive_invocation(invocation)
171
+ return
172
+ end
173
+
153
174
  resolved_model_override = @model_from_option ? nil : options[:model]
154
175
  response = Ace::LLM::QueryInterface.query(
155
176
  @provider_model,
@@ -178,6 +199,26 @@ module Ace
178
199
  raise
179
200
  end
180
201
 
202
+ def validate_interactive_options!(options)
203
+ conflicts = []
204
+ conflicts << "--output" if options[:output]
205
+ conflicts << "--force" if options[:force]
206
+ conflicts << "--temperature" unless options[:temperature].nil?
207
+ conflicts << "--max-tokens" unless options[:max_tokens].nil?
208
+ explicit_format = options[:format]
209
+ conflicts << "--format" if explicit_format && explicit_format != "text"
210
+ return if conflicts.empty?
211
+
212
+ raise Ace::Support::Cli::Error, "Interactive mode does not support #{conflicts.join(', ')}"
213
+ end
214
+
215
+ def execute_interactive_invocation(invocation)
216
+ env = invocation[:env].respond_to?(:to_h) ? invocation[:env].to_h : {}
217
+ command = invocation[:command]
218
+ working_dir = invocation[:working_dir]
219
+ Kernel.exec(env, *command, chdir: working_dir)
220
+ end
221
+
181
222
  def output_response(response, options)
182
223
  format = options[:format] || "text"
183
224
  handler = Ace::LLM::Molecules::FormatHandlers.get_handler(format)
@@ -215,17 +256,27 @@ module Ace
215
256
 
216
257
  providers.each do |provider|
217
258
  status = provider[:available] ? "\u2713" : "\u2717"
218
- api_status = if provider[:api_key_required]
219
- provider[:api_key_present] ? "API key configured" : "API key required"
259
+ credential_status = if provider[:api_key_required]
260
+ provider[:api_key_present] ? "Credentials configured" : "Credentials required"
220
261
  else
221
- "No API key needed"
262
+ "No credentials required"
222
263
  end
223
264
 
224
265
  models = provider[:models] || []
225
266
  model_count = models.empty? ? "" : " \u00b7 #{models.length} models"
226
- puts "#{status} #{provider[:name]}#{model_count} (#{api_status})"
267
+ puts "#{status} #{provider[:name]}#{model_count} (#{credential_status})"
227
268
 
228
269
  print_wrapped_list(models, indent: " ") unless models.empty?
270
+ env_keys = provider[:credential_env_keys] || []
271
+ if provider[:api_key_required]
272
+ if env_keys.empty?
273
+ puts " Setup hint: credentials are required for this provider."
274
+ else
275
+ puts " Setup hint: set #{env_keys.join(' or ')}"
276
+ end
277
+ else
278
+ puts " Setup hint: no credential environment variable required."
279
+ end
229
280
  puts " Gem required: #{provider[:gem]}" unless provider[:available]
230
281
  puts ""
231
282
  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,40 @@ 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
+ normalized_name = normalize_provider_name(provider["name"] || provider_name)
146
+ credential_status(provider, normalized_name)[:present]
147
+ end
148
+
126
149
  # Reload all configurations
127
150
  def reload!
128
151
  @providers.clear
@@ -299,14 +322,30 @@ module Ace
299
322
  end
300
323
  end
301
324
 
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?
325
+ # Build normalized credential status for provider output.
326
+ # @param provider_config [Hash] provider configuration hash
327
+ # @param provider_name [String] normalized provider name
328
+ # @return [Hash] credential status
329
+ def credential_status(provider_config, provider_name)
330
+ env_keys = extract_credential_env_keys(provider_config, provider_name)
331
+ explicit_required = provider_config.dig("api_key", "required")
332
+ required = explicit_required.nil? ? !env_keys.empty? : explicit_required
333
+
334
+ present = if provider_config["api_key"].is_a?(String) && !provider_config["api_key"].empty?
335
+ true
336
+ elsif provider_config["api_key"].is_a?(Hash) && provider_config["api_key"]["value"]
337
+ !provider_config["api_key"]["value"].to_s.empty?
338
+ elsif provider_config["api_key"].is_a?(Hash) && provider_config["api_key"]["env"]
339
+ !ENV[provider_config["api_key"]["env"]].to_s.empty?
340
+ else
341
+ env_keys.any? { |key| !ENV[key].to_s.empty? }
342
+ end
307
343
 
308
- key = resolve_api_key(api_key_config)
309
- !key.nil? && !key.empty?
344
+ {
345
+ required: required,
346
+ present: present,
347
+ env_keys: env_keys
348
+ }
310
349
  end
311
350
 
312
351
  # Build alias maps from provider configurations
@@ -330,6 +369,47 @@ module Ace
330
369
  end
331
370
  end
332
371
  end
372
+
373
+ def extract_credential_env_keys(provider_config, provider_name)
374
+ env_keys = []
375
+
376
+ api_key_env = provider_config.dig("api_key", "env")
377
+ env_keys << api_key_env if api_key_env
378
+
379
+ backends = provider_config["backends"]
380
+ if backends.is_a?(Hash)
381
+ backends.each_value do |backend|
382
+ next unless backend.is_a?(Hash)
383
+
384
+ env_key = backend["env_key"]
385
+ Array(env_key).each do |key|
386
+ env_keys << key if key
387
+ end
388
+ end
389
+ end
390
+
391
+ # Keep parity with BaseClient/provider.yml defaults for providers that rely on
392
+ # fallback keys. If new providers are added, update this fallback map as well.
393
+ env_keys.concat(default_env_keys_for_provider(provider_name)) if env_keys.empty?
394
+ env_keys.map(&:to_s).map(&:strip).reject(&:empty?).uniq
395
+ end
396
+
397
+ def default_env_keys_for_provider(provider_name)
398
+ case provider_name.to_s.downcase
399
+ when "google"
400
+ %w[GEMINI_API_KEY GOOGLE_API_KEY]
401
+ when "openai"
402
+ ["OPENAI_API_KEY"]
403
+ when "anthropic"
404
+ ["ANTHROPIC_API_KEY"]
405
+ when "mistral"
406
+ ["MISTRAL_API_KEY"]
407
+ when "togetherai"
408
+ %w[TOGETHER_API_KEY TOGETHERAI_API_KEY]
409
+ else
410
+ []
411
+ end
412
+ end
333
413
  end
334
414
  end
335
415
  end