ace-llm 0.32.1 → 0.38.2

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.
@@ -11,6 +11,7 @@ module Ace
11
11
  # Optional fields with expected types
12
12
  OPTIONAL_FIELDS = {
13
13
  "models" => Array,
14
+ "limits" => Hash,
14
15
  "api_key" => [Hash, NilClass],
15
16
  "capabilities" => Array,
16
17
  "default_options" => Hash,
@@ -69,6 +70,7 @@ module Ace
69
70
  validate_class(config["class"], errors) if config["class"]
70
71
  validate_gem(config["gem"], errors) if config["gem"]
71
72
  validate_models(config["models"], warnings) if config["models"]
73
+ validate_limits(config["limits"], errors, warnings) if config["limits"]
72
74
  validate_api_key(config["api_key"], errors, warnings) if config["api_key"]
73
75
  validate_capabilities(config["capabilities"], warnings) if config["capabilities"]
74
76
  validate_default_options(config["default_options"], warnings) if config["default_options"]
@@ -147,6 +149,57 @@ module Ace
147
149
  end
148
150
  end
149
151
 
152
+ def validate_limits(limits, errors, warnings)
153
+ unless limits.is_a?(Hash)
154
+ errors << "Limits must be a Hash, got #{limits.class}"
155
+ return
156
+ end
157
+
158
+ valid_limit_keys = %w[default models]
159
+ unknown_limit_keys = limits.keys - valid_limit_keys
160
+ unless unknown_limit_keys.empty?
161
+ warnings << "Unknown limits keys: #{unknown_limit_keys.join(", ")}"
162
+ end
163
+
164
+ validate_limit_entry(limits["default"], "limits.default", errors) if limits["default"]
165
+
166
+ return unless limits["models"]
167
+
168
+ unless limits["models"].is_a?(Hash)
169
+ errors << "limits.models must be a Hash, got #{limits["models"].class}"
170
+ return
171
+ end
172
+
173
+ limits["models"].each do |model_name, entry|
174
+ if model_name.to_s.strip.empty?
175
+ errors << "limits.models entries must have non-empty model names"
176
+ next
177
+ end
178
+
179
+ validate_limit_entry(entry, "limits.models.#{model_name}", errors)
180
+ end
181
+ end
182
+
183
+ def validate_limit_entry(entry, path, errors)
184
+ unless entry.is_a?(Hash)
185
+ errors << "#{path} must be a Hash, got #{entry.class}"
186
+ return
187
+ end
188
+
189
+ valid_keys = %w[context output]
190
+ unknown_keys = entry.keys - valid_keys
191
+ unless unknown_keys.empty?
192
+ errors << "#{path} contains unknown keys: #{unknown_keys.join(", ")}"
193
+ end
194
+
195
+ entry.each do |field, value|
196
+ next unless valid_keys.include?(field)
197
+ next if value.is_a?(Integer) && value >= 0
198
+
199
+ errors << "#{path}.#{field} must be a non-negative Integer"
200
+ end
201
+ end
202
+
150
203
  # Validate API key configuration
151
204
  def validate_api_key(api_key_config, errors, warnings)
152
205
  if api_key_config.is_a?(Hash)
@@ -32,6 +32,9 @@ 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"
36
+ option :fallback, type: :boolean, default: true, desc: "Use provider fallback for this query"
37
+ option :json, type: :boolean, default: false, desc: "Output command results as JSON"
35
38
 
36
39
  option :version, type: :boolean, desc: "Show version information"
37
40
  option :list_providers, type: :boolean, desc: "List available LLM providers"
@@ -54,6 +57,7 @@ module Ace
54
57
  end
55
58
 
56
59
  @prompt = options[:prompt] || prompt_text
60
+ options[:format] ||= "json" if options[:json]
57
61
 
58
62
  return show_help if @provider_model.nil? && @prompt.nil?
59
63
  return show_provider_help if @prompt.nil? || @prompt.empty?
@@ -88,6 +92,9 @@ module Ace
88
92
  puts " --timeout SECONDS Request timeout in seconds"
89
93
  puts " --model MODEL Model name (overrides PROVIDER[:MODEL])"
90
94
  puts " --prompt PROMPT Prompt text (overrides positional PROMPT)"
95
+ puts " --interactive Start an interactive CLI session"
96
+ puts " --no-fallback Disable provider fallback for this query"
97
+ puts " --json Output command results as JSON"
91
98
  puts " --force Force overwrite existing files"
92
99
  puts " -q, --quiet Suppress config summary output"
93
100
  puts " -d, --debug Enable debug output"
@@ -97,17 +104,20 @@ module Ace
97
104
  puts ' ace-llm google:gemini-2.5-flash "What is Ruby?"'
98
105
  puts ' ace-llm gflash "Quick question" # using alias'
99
106
  puts ' ace-llm gflash@ro "Summarize this diff"'
100
- puts ' ace-llm codex:gpt-5:high@ro "Review this code"'
107
+ puts ' ace-llm codex:gpt:high@ro "Review this code"'
101
108
  puts ' ace-llm claude:sonnet "Summarize this diff" --preset rw'
102
109
  puts ' ace-llm claude:sonnet "Hi" --cli-args "dangerously-skip-permissions"'
103
110
  puts ' ace-llm claude:sonnet "Hi" --cli-args "--model=claude-sonnet-4-0 --verbose"'
111
+ puts ' ace-llm codex:gpt@yolo "/as-assign-drive abc123@010" --interactive'
112
+ puts ' ace-llm gemini:pro "ping" --no-fallback --timeout 15 --max-tokens 4'
104
113
  puts ""
105
114
  puts "Provider Aliases:"
106
115
  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"
116
+ puts " gflash → google:gemini-flash-latest"
117
+ puts " glite → google:gemini-flash-lite-latest"
118
+ puts " codex:gpt → codex:gpt-5.4"
119
+ puts " cc → claude:sonnet"
120
+ puts " sonnet → anthropic:claude-sonnet-4-5"
111
121
  end
112
122
 
113
123
  def show_provider_help
@@ -132,7 +142,7 @@ module Ace
132
142
  return if quiet?(options)
133
143
 
134
144
  require "ace/core"
135
- summary_keys = %w[provider_model preset temperature max_tokens format timeout system_append cli_args]
145
+ summary_keys = %w[provider_model preset temperature max_tokens format timeout system_append cli_args fallback]
136
146
  Ace::Core::Atoms::ConfigSummary.display(
137
147
  command: "query",
138
148
  config: options.merge(provider_model: @provider_model),
@@ -150,6 +160,23 @@ module Ace
150
160
  system_append_text = options[:system_append] ? file_handler.read_content(options[:system_append]) : nil
151
161
  normalized_timeout = normalize_timeout(options[:timeout])
152
162
 
163
+ if options[:interactive]
164
+ validate_interactive_options!(options)
165
+ invocation = Ace::LLM::Molecules::InteractiveCommandBuilder.new.build(
166
+ provider_model: @provider_model,
167
+ prompt: prompt_text,
168
+ system: system_text,
169
+ cli_args: options[:cli_args],
170
+ system_append: system_append_text,
171
+ preset: options[:preset],
172
+ model: (@model_from_option ? nil : options[:model]),
173
+ working_dir: Dir.pwd,
174
+ subprocess_env: ENV.to_h
175
+ )
176
+ execute_interactive_invocation(invocation)
177
+ return
178
+ end
179
+
153
180
  resolved_model_override = @model_from_option ? nil : options[:model]
154
181
  response = Ace::LLM::QueryInterface.query(
155
182
  @provider_model,
@@ -162,7 +189,8 @@ module Ace
162
189
  model: resolved_model_override,
163
190
  cli_args: options[:cli_args],
164
191
  system_append: system_append_text,
165
- preset: options[:preset]
192
+ preset: options[:preset],
193
+ fallback: (options[:fallback] == false ? false : nil)
166
194
  )
167
195
 
168
196
  output_response(response, options)
@@ -178,6 +206,27 @@ module Ace
178
206
  raise
179
207
  end
180
208
 
209
+ def validate_interactive_options!(options)
210
+ conflicts = []
211
+ conflicts << "--output" if options[:output]
212
+ conflicts << "--force" if options[:force]
213
+ conflicts << "--temperature" unless options[:temperature].nil?
214
+ conflicts << "--max-tokens" unless options[:max_tokens].nil?
215
+ explicit_format = options[:format]
216
+ conflicts << "--format" if explicit_format && explicit_format != "text"
217
+ conflicts << "--no-fallback" if options[:fallback] == false
218
+ return if conflicts.empty?
219
+
220
+ raise Ace::Support::Cli::Error, "Interactive mode does not support #{conflicts.join(', ')}"
221
+ end
222
+
223
+ def execute_interactive_invocation(invocation)
224
+ env = invocation[:env].respond_to?(:to_h) ? invocation[:env].to_h : {}
225
+ command = invocation[:command]
226
+ working_dir = invocation[:working_dir]
227
+ Kernel.exec(env, *command, chdir: working_dir)
228
+ end
229
+
181
230
  def output_response(response, options)
182
231
  format = options[:format] || "text"
183
232
  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
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/models"
4
+ require_relative "client_registry"
5
+ require_relative "provider_model_parser"
6
+
7
+ module Ace
8
+ module LLM
9
+ module Molecules
10
+ # Resolves model context/output limits against the expanded concrete target.
11
+ class ModelLimitResolver
12
+ DEFAULT_CONTEXT_LIMIT = 200_000
13
+
14
+ ResolveResult = Struct.new(
15
+ :provider,
16
+ :model,
17
+ :context_limit,
18
+ :output_limit,
19
+ :source,
20
+ :original_target,
21
+ keyword_init: true
22
+ ) do
23
+ def full_model
24
+ return nil if provider.to_s.empty? || model.to_s.empty?
25
+
26
+ "#{provider}:#{model}"
27
+ end
28
+ end
29
+
30
+ def self.resolve(target, **kwargs)
31
+ new(**kwargs).resolve(target)
32
+ end
33
+
34
+ def initialize(registry: nil, parser: nil)
35
+ @registry = registry || ClientRegistry.new
36
+ @parser = parser || ProviderModelParser.new(registry: @registry)
37
+ end
38
+
39
+ def resolve(target)
40
+ original_target = target.to_s
41
+ return fallback_result(original_target) if original_target.strip.empty?
42
+
43
+ parsed = @parser.parse(original_target)
44
+ return fallback_result(original_target) if parsed.invalid?
45
+
46
+ provider_config = @registry.get_provider(parsed.provider)
47
+ return fallback_result(original_target, provider: parsed.provider, model: parsed.model) unless provider_config
48
+
49
+ limits = Ace::Support::Models::Atoms::ProviderConfigReader.extract_limits(provider_config)
50
+ default_limits = limits["default"] || {}
51
+ model_limits = limits.dig("models", parsed.model) || {}
52
+ merged_limits = default_limits.merge(model_limits)
53
+
54
+ ResolveResult.new(
55
+ provider: parsed.provider,
56
+ model: parsed.model,
57
+ context_limit: merged_limits["context"] || DEFAULT_CONTEXT_LIMIT,
58
+ output_limit: merged_limits["output"],
59
+ source: limit_source(default_limits, model_limits),
60
+ original_target: original_target
61
+ )
62
+ rescue
63
+ fallback_result(original_target)
64
+ end
65
+
66
+ private
67
+
68
+ def limit_source(default_limits, model_limits)
69
+ return :model_override if model_limits.any?
70
+ return :provider_default if default_limits.any?
71
+
72
+ :fallback
73
+ end
74
+
75
+ def fallback_result(original_target, provider: nil, model: nil)
76
+ ResolveResult.new(
77
+ provider: provider,
78
+ model: model,
79
+ context_limit: DEFAULT_CONTEXT_LIMIT,
80
+ output_limit: nil,
81
+ source: :fallback,
82
+ original_target: original_target
83
+ )
84
+ end
85
+ end
86
+ end
87
+ end
88
+ 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.38.2'
6
6
  end
7
7
  end
data/lib/ace/llm.rb CHANGED
@@ -25,9 +25,11 @@ require_relative "llm/molecules/file_io_handler"
25
25
  require_relative "llm/molecules/llm_alias_resolver"
26
26
  require_relative "llm/molecules/role_resolver"
27
27
  require_relative "llm/molecules/provider_model_parser"
28
+ require_relative "llm/molecules/model_limit_resolver"
28
29
  require_relative "llm/molecules/format_handlers"
29
30
  require_relative "llm/molecules/client_registry"
30
31
  require_relative "llm/molecules/fallback_orchestrator"
32
+ require_relative "llm/molecules/interactive_command_builder"
31
33
 
32
34
  require_relative "llm/query_interface"
33
35