ace-llm 0.32.1 → 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: 9d2199db9c575bab6a4e80d9537115bbd643cdcf3ae9925b7af6469ec9fd0b0e
4
- data.tar.gz: e20d96efca816db66196dd4c907f9814ab8d7c0cb759e33d1d11cfb5f98e6326
3
+ metadata.gz: 1d92ed4d8cab8d260ef4235ecf94f612af751abbf4d1f4a4cdd30951d8c33388
4
+ data.tar.gz: dd6ff444bed65aa46801197857f39c008087a41da8e01c6cafcfc3c88d781a43
5
5
  SHA512:
6
- metadata.gz: 72d45697b419523ec93028ff004cc840c641336dcfdd985d13a18f4468ad0d1b2abede096887309c02c39bbbd10f6e0909afb7c92ac7d379b805ad52ef30560e
7
- data.tar.gz: a92cce87e5212b2d8c201aa04265e2255e6f6d7022315622f53c70f2c24cfe704d890d07379ae2b86fb4889e6fb66f30e1b3aa025b77b28ae0e756a620a1290a
6
+ metadata.gz: 66a5720b86973458a565c63fb36a37061d1e201b1f0be9b43cc3d6f4ecedad6785117d96ec8a53819ab35aacc70246c68780af5d82c24f8c52067a94b1714b69
7
+ data.tar.gz: b107b41c5221dcfda405a2073996184edf6f52b075a97f90c753b36eb283ce07403942c96e8f3116dfc2cbab3d58faa61c2630b378718c5a31aaf1c5ec8db304
@@ -98,9 +98,9 @@ llm:
98
98
  # --- Utility ---
99
99
  prompt-enhance: *utility-lite
100
100
  commit:
101
+ - google:lite
101
102
  - codex:mini
102
103
  - claude:haiku
103
- - gemini:flash-latest
104
104
  docs-analysis: *utility-lite
105
105
  idea-enhance:
106
106
  - gemini:flash-latest
data/CHANGELOG.md CHANGED
@@ -7,6 +7,111 @@ 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
+
10
115
  ## [0.32.1] - 2026-04-01
11
116
 
12
117
  ### Fixed
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)
@@ -142,7 +142,8 @@ module Ace
142
142
  provider = get_provider(provider_name)
143
143
  return false unless provider
144
144
 
145
- api_key_present?(provider["api_key"])
145
+ normalized_name = normalize_provider_name(provider["name"] || provider_name)
146
+ credential_status(provider, normalized_name)[:present]
146
147
  end
147
148
 
148
149
  # Reload all configurations
@@ -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
@@ -60,7 +60,6 @@ module Ace
60
60
  return false if provider.nil? || provider.empty?
61
61
  return false if @configuration.provider_inactive?(provider)
62
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
63
 
65
64
  true
66
65
  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,
@@ -105,7 +103,39 @@ module Ace
105
103
  fallback_config: fallback_config,
106
104
  timeout: resolved_timeout,
107
105
  debug: debug,
108
- role_fallbacks: parse_result.role_fallbacks
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
+ }
109
139
  )
110
140
 
111
141
  text_content = extract_text_content(response)
@@ -174,6 +204,43 @@ module Ace
174
204
  end
175
205
  private_class_method :load_execution_overrides
176
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
+
177
244
  def self.merge_execution_overrides(base, overlay)
178
245
  left = base.respond_to?(:to_h) ? deep_stringify_keys(base.to_h) : deep_stringify_keys(base || {})
179
246
  right = overlay.respond_to?(:to_h) ? deep_stringify_keys(overlay.to_h) : deep_stringify_keys(overlay || {})
@@ -291,7 +358,7 @@ module Ace
291
358
  end
292
359
 
293
360
  def self.execute_with_fallback(provider:, model:, messages:, generation_opts:,
294
- registry:, fallback_config:, timeout:, debug:, role_fallbacks: nil)
361
+ registry:, fallback_config:, timeout:, debug:, role_fallbacks: nil, preset: nil, thinking_level: nil, option_builder:)
295
362
  if fallback_config.disabled?
296
363
  client = registry.get_client(provider, model: model, timeout: timeout)
297
364
  return client.generate(messages, **generation_opts)
@@ -300,9 +367,10 @@ module Ace
300
367
  primary_provider_string = model ? "#{provider}:#{model}" : provider
301
368
 
302
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))
303
371
  if role_fallbacks&.any?
304
372
  existing_chain = fallback_config.providers_for(primary_provider_string)
305
- merged_chain = role_fallbacks + (existing_chain - role_fallbacks)
373
+ merged_chain = normalized_role_fallbacks + (existing_chain - normalized_role_fallbacks)
306
374
  fallback_config = fallback_config.merge(chains: {primary_provider_string => merged_chain})
307
375
  end
308
376
 
@@ -314,8 +382,13 @@ module Ace
314
382
  timeout: timeout
315
383
  )
316
384
 
317
- orchestrator.execute(primary_provider: primary_provider_string, registry: registry) do |client|
318
- 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)
319
392
  end
320
393
  end
321
394
 
@@ -380,7 +453,7 @@ module Ace
380
453
 
381
454
  parse_result = parser.parse(provider)
382
455
  canonical_provider = if parse_result.valid?
383
- "#{parse_result.provider}:#{parse_result.model}"
456
+ parse_result.to_s
384
457
  else
385
458
  provider
386
459
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module LLM
5
- VERSION = '0.32.1'
5
+ VERSION = '0.36.3'
6
6
  end
7
7
  end
data/lib/ace/llm.rb CHANGED
@@ -28,6 +28,7 @@ require_relative "llm/molecules/provider_model_parser"
28
28
  require_relative "llm/molecules/format_handlers"
29
29
  require_relative "llm/molecules/client_registry"
30
30
  require_relative "llm/molecules/fallback_orchestrator"
31
+ require_relative "llm/molecules/interactive_command_builder"
31
32
 
32
33
  require_relative "llm/query_interface"
33
34
 
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.32.1
4
+ version: 0.36.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-04-01 00:00:00.000000000 Z
10
+ date: 2026-04-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.29'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ace-support-models
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.11'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.11'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: faraday
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -273,6 +287,7 @@ files:
273
287
  - lib/ace/llm/molecules/fallback_orchestrator.rb
274
288
  - lib/ace/llm/molecules/file_io_handler.rb
275
289
  - lib/ace/llm/molecules/format_handlers.rb
290
+ - lib/ace/llm/molecules/interactive_command_builder.rb
276
291
  - lib/ace/llm/molecules/llm_alias_resolver.rb
277
292
  - lib/ace/llm/molecules/openai_compatible_params.rb
278
293
  - lib/ace/llm/molecules/preset_loader.rb