ace-llm-providers-cli 0.34.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a2381e3344c0e1e229d1a2594761f92eec1f8af7bee3950bbccd6edd06edd34
4
- data.tar.gz: 60e39f3cc0468a797a75affc1254703c99f6b49dd41cba8c1f8f141fe2654b01
3
+ metadata.gz: 9aee773ea6aac47cb8aeaa00ca6926a81f5d9eaac29f9b968ba098d81da0117d
4
+ data.tar.gz: ac05f84f681e6316ac4662750e923e06a03692ee88201456f56da5afae904079
5
5
  SHA512:
6
- metadata.gz: df4f6c5788a4a461ce1dab3be11ba0c9644c584308f6d553d28a3c97ec4e2248fa9a936765815cf4c64407cd37ec9d11b2e6a054459a7aa924c7b391dd7d9d2a
7
- data.tar.gz: af0ccb22aee51c204e5ec9c932c41e6a1f2369e75574511175f0d939fcedf8ec0c24acfe305ddf2a4fc2622a5c41981b2a26359711538b2f8371971418ce21d3
6
+ metadata.gz: 707c07914d35d16e3e868b4891a0be562930edd9ce5d79a41eb0a99f6628bc90b3cc5eff2f9ad7adc122b3e674431e4825ae2a2602354e0386bd1c0cf737e0d7
7
+ data.tar.gz: d10514be9fbb31940a1cd96c7ba35807eddedaa047aacf8fd09d35085d82c4e742db8efdf6261889236839056493305ec6f781836f38180a69fc3c9af2332c18
@@ -27,6 +27,7 @@ aliases:
27
27
  pi: pi:zai/glm-5.3-flash
28
28
  model:
29
29
  glm: zai/glm-5.3
30
+ glm5: zai/glm-5.3
30
31
  glmflash: zai/glm-5.3-flash
31
32
  glmhighspeed: zai/glm-5.3-highspeed
32
33
  gpt: openai-codex/gpt-5.4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.34.1] - 2026-09-24
11
+
12
+ ### Fixed
13
+
14
+ - Preserve completed Codex responses when copying the optional last-message diagnostic fails, and handle Pi retry streams without accepting partial output.
15
+ - Reject unresolved Pi model selectors before CLI execution and keep the `glm5` alias synchronized across provider registries.
16
+ - Preserve Codex JSON response text containing a preset path without mistaking it for the CLI banner.
17
+ - Reject directory destinations for Codex last-message capture and preserve the final Pi stop reason when earlier turns appear in terminal events.
18
+
19
+ ### Changed
20
+
21
+ - Restrict Pi `@ro` to read-only tools (`read`, `grep`, `find`, `ls`) and disable extensions.
22
+
10
23
  ## [0.34.0] - 2026-09-13
11
24
 
12
25
  ### Changed
@@ -18,7 +18,7 @@ module Ace
18
18
  conflict = find_conflicting_cli_arg(args, forbidden_flags)
19
19
  return args unless conflict
20
20
 
21
- raise Ace::LLM::ProviderError, "#{label} interactive mode does not support cli arg #{conflict}"
21
+ raise Ace::LLM::ProviderError, "#{label} does not support cli arg #{conflict} in this invocation"
22
22
  end
23
23
 
24
24
  def find_conflicting_cli_arg(args, forbidden_flags)
@@ -3,6 +3,8 @@
3
3
  require "json"
4
4
  require "open3"
5
5
  require "shellwords"
6
+ require "tempfile"
7
+ require "fileutils"
6
8
 
7
9
  require_relative "cli_args_support"
8
10
  require_relative "atoms/execution_context"
@@ -59,11 +61,42 @@ module Ace
59
61
  subprocess_env: subprocess_env
60
62
  )
61
63
  prompt = rewrite_skill_commands(prompt, working_dir: working_dir)
64
+ requested_last_message = File.expand_path(options[:last_message_file], working_dir) if options[:last_message_file]
65
+ if requested_last_message && File.directory?(requested_last_message)
66
+ raise Ace::LLM::ProviderError, "last_message_file must name a file, not a directory: #{requested_last_message}"
67
+ end
62
68
 
63
- cmd = build_codex_command(prompt, options, working_dir: working_dir)
64
- stdout, stderr, status = execute_codex_command(cmd, prompt, options)
65
-
66
- parse_codex_response(stdout, stderr, status, prompt, options)
69
+ Tempfile.create(["ace-codex-last-", ".md"]) do |last_message|
70
+ last_message.close
71
+ direct_capture = requested_last_message && File.writable?(File.dirname(requested_last_message)) && !File.directory?(requested_last_message)
72
+ execution_options = options.merge(last_message_file: direct_capture ? requested_last_message : last_message.path)
73
+ cmd = build_codex_command(prompt, execution_options, working_dir: working_dir)
74
+ if direct_capture && File.exist?(requested_last_message)
75
+ begin
76
+ File.delete(requested_last_message)
77
+ rescue SystemCallError
78
+ direct_capture = false
79
+ execution_options = options.merge(last_message_file: last_message.path)
80
+ cmd = build_codex_command(prompt, execution_options, working_dir: working_dir)
81
+ end
82
+ end
83
+ completed = false
84
+ result = nil
85
+ begin
86
+ stdout, stderr, status = execute_codex_command(cmd, prompt, execution_options)
87
+ result = parse_codex_response(stdout, stderr, status, prompt, execution_options)
88
+ completed = true
89
+ result
90
+ ensure
91
+ if requested_last_message && !direct_capture && (completed || File.size?(last_message.path))
92
+ begin
93
+ FileUtils.cp(last_message.path, requested_last_message)
94
+ rescue => copy_error
95
+ result[:metadata][:last_message_copy_error] = copy_error.message if completed
96
+ end
97
+ end
98
+ end
99
+ end
67
100
  rescue => e
68
101
  handle_codex_error(e)
69
102
  end
@@ -200,8 +233,15 @@ module Ace
200
233
  cmd << "--output-last-message" << options[:last_message_file]
201
234
  end
202
235
 
203
- # User CLI args last so they take precedence (last-wins in most CLIs)
204
- cmd.concat(normalized_cli_args(options))
236
+ cli_args = normalized_cli_args_without_conflicts(
237
+ options,
238
+ forbidden_flags: ["--model", "-m", "--profile", "--output-last-message"],
239
+ label: "Codex"
240
+ )
241
+ if codex_cli_args_override_model?(cli_args)
242
+ raise Ace::LLM::ProviderError, "Codex cli args cannot override the resolved model"
243
+ end
244
+ cmd.concat(cli_args)
205
245
 
206
246
  cmd
207
247
  end
@@ -233,17 +273,23 @@ module Ace
233
273
  cmd << "-c" << trust_override
234
274
  end
235
275
 
236
- cmd.concat(
237
- normalized_cli_args_without_conflicts(
238
- options,
239
- forbidden_flags: ["exec", "review", "-C", "--cd", "--output-last-message"],
240
- label: "Codex"
241
- )
276
+ cli_args = normalized_cli_args_without_conflicts(
277
+ options,
278
+ forbidden_flags: ["exec", "review", "-C", "--cd", "--output-last-message", "--model", "-m", "--profile"],
279
+ label: "Codex"
242
280
  )
281
+ raise Ace::LLM::ProviderError, "Codex cli args cannot override the resolved model" if codex_cli_args_override_model?(cli_args)
282
+ cmd.concat(cli_args)
243
283
  cmd << prompt.to_s unless prompt.to_s.empty?
244
284
  cmd
245
285
  end
246
286
 
287
+ def codex_cli_args_override_model?(args)
288
+ args.any? { |arg| arg.match?(/\A-m.+/) } ||
289
+ args.each_cons(2).any? { |flag, value| %w[-c --config].include?(flag) && value.match?(/\A(?:model|model_provider|profiles\.[^.]+\.(?:model|model_provider))\s*=/) } ||
290
+ args.any? { |arg| arg.match?(/\A(?:-c|--config)=?(?:model|model_provider|profiles\.[^.]+\.(?:model|model_provider))\s*=/) }
291
+ end
292
+
247
293
  def execute_codex_command(cmd, prompt, options)
248
294
  # Prepare the input - combine system prompt with user prompt if needed
249
295
  input = prompt.to_s
@@ -280,16 +326,28 @@ module Ace
280
326
  raise Ace::LLM::ProviderError, "Codex CLI failed: #{error_msg}"
281
327
  end
282
328
 
283
- # Parse Codex output format to extract the actual response
329
+ # The native last-message file contains only the final response,
330
+ # unlike stdout's transcript of tools and status messages.
331
+ last_message_path = options[:last_message_file]
332
+ if last_message_path
333
+ text = File.file?(last_message_path) ? File.read(last_message_path).strip : ""
334
+ raise Ace::LLM::ProviderError, "Codex CLI produced no final message" if text.empty?
335
+
336
+ return {text: text, metadata: build_synthetic_metadata(text, prompt)}
337
+ end
338
+
339
+ # Older CLI wrappers may supply stdout only. Parse its transcript
340
+ # only when a real CLI banner is present, not when report prose
341
+ # happens to contain a standalone `codex` line.
284
342
  # Codex output includes metadata lines and the actual response
285
343
  lines = stdout.split("\n")
286
344
 
287
- # Find where the actual response starts (after "codex" header)
288
- response_start = lines.find_index { |line| line.include?("codex") }
345
+ has_cli_banner = lines.first(20).any? { |line| line.start_with?("OpenAI Codex v") }
346
+ response_start = lines.find_index { |line| line.strip == "codex" } if has_cli_banner
289
347
 
290
348
  if response_start && response_start < lines.length - 1
291
349
  # Extract text after the "codex" line, skipping empty lines
292
- response_lines = lines[(response_start + 1)..-1]
350
+ response_lines = lines[(response_start + 1)..]
293
351
  # Remove token usage lines at the end
294
352
  response_lines = response_lines.reject { |line| line.include?("tokens used") }
295
353
  text = response_lines.join("\n").strip
@@ -183,6 +183,10 @@ module Ace
183
183
  # No session (stateless)
184
184
  cmd << "--no-session"
185
185
 
186
+ # Keep the terminal stop reason so truncated responses cannot pass
187
+ # as successful plain-text output.
188
+ cmd << "--mode" << "json"
189
+
186
190
  # No skills (we handle skill content ourselves in one-shot mode)
187
191
  cmd << "--no-skills"
188
192
 
@@ -194,13 +198,16 @@ module Ace
194
198
  # Provider/model from the model string (format: "provider/model")
195
199
  model_to_use = @model || @generation_config[:model] || DEFAULT_MODEL
196
200
  provider_name, model_id = split_provider_model(model_to_use)
197
- if provider_name && model_id
198
- cmd << "--provider" << provider_name
199
- cmd << "--model" << model_id
200
- end
201
+ cmd << "--provider" << provider_name
202
+ cmd << "--model" << model_id
201
203
 
202
- # User CLI args after generated flags (last-wins precedence)
203
- cmd.concat(normalized_cli_args(options))
204
+ cmd.concat(
205
+ normalized_cli_args_without_conflicts(
206
+ options,
207
+ forbidden_flags: ["--mode", "--provider", "--model", "--models"],
208
+ label: "Pi"
209
+ )
210
+ )
204
211
 
205
212
  cmd
206
213
  end
@@ -214,15 +221,13 @@ module Ace
214
221
 
215
222
  model_to_use = @model || @generation_config[:model] || DEFAULT_MODEL
216
223
  provider_name, model_id = split_provider_model(model_to_use)
217
- if provider_name && model_id
218
- cmd << "--provider" << provider_name
219
- cmd << "--model" << model_id
220
- end
224
+ cmd << "--provider" << provider_name
225
+ cmd << "--model" << model_id
221
226
 
222
227
  cmd.concat(
223
228
  normalized_cli_args_without_conflicts(
224
229
  options,
225
- forbidden_flags: ["-p", "--print", "--no-session", "--no-skills", "--mode"],
230
+ forbidden_flags: ["-p", "--print", "--no-session", "--no-skills", "--mode", "--provider", "--model", "--models"],
226
231
  label: "Pi"
227
232
  )
228
233
  )
@@ -234,20 +239,25 @@ module Ace
234
239
  # Handles multi-segment providers like "google-gemini-cli/gemini-2.5-pro"
235
240
  # Also handles nested providers like "openrouter:openai/gpt-oss-120b"
236
241
  def split_provider_model(model_string)
237
- return [nil, nil] unless model_string
242
+ raise Ace::LLM::ProviderError, "Pi model must resolve to provider/model" unless model_string
243
+ raise Ace::LLM::ProviderError, "Pi model must resolve to provider/model: #{model_string}" if model_string.start_with?(":")
238
244
 
239
245
  # Check for nested provider pattern (e.g., "openrouter:openai/model")
240
- if model_string.count(":") > 0
246
+ colon = model_string.index(":")
247
+ slash = model_string.index("/")
248
+ if colon && (!slash || colon < slash)
241
249
  parts = model_string.split(":", 2)
242
- if parts.length == 2 && parts[1].include?("/")
250
+ nested_parts = parts[1]&.split("/", 2)
251
+ if parts.length == 2 && !parts[0].empty? && nested_parts&.length == 2 && nested_parts.all? { |part| !part.empty? }
243
252
  # Nested provider: "openrouter:openai/model" -> ["openrouter", "openai/model"]
244
253
  return [parts[0], parts[1]]
245
254
  end
255
+ raise Ace::LLM::ProviderError, "Pi model must resolve to provider/model: #{model_string}"
246
256
  end
247
257
 
248
258
  # Standard provider/model format
249
259
  parts = model_string.split("/", 2)
250
- return [nil, nil] unless parts.length == 2
260
+ raise Ace::LLM::ProviderError, "Pi model must resolve to provider/model: #{model_string}" unless parts.length == 2 && parts.all? { |part| !part.empty? }
251
261
 
252
262
  [parts[0], parts[1]]
253
263
  end
@@ -276,15 +286,8 @@ module Ace
276
286
  raise Ace::LLM::ProviderError, "Pi CLI failed: #{error_msg}"
277
287
  end
278
288
 
279
- # Detect NDJSON: starts with {"type":"
280
- if stdout.strip.start_with?('{"type":"')
281
- text, usage = parse_ndjson(stdout)
282
- response = {"usage" => normalize_usage(usage)}
283
- else
284
- # Plain text output
285
- text = stdout.strip
286
- response = {}
287
- end
289
+ text, usage, finish_reason = parse_ndjson(stdout)
290
+ response = {"usage" => normalize_usage(usage), "finish_reason" => finish_reason}
288
291
 
289
292
  metadata = build_metadata(response, text, prompt, options)
290
293
 
@@ -320,36 +323,65 @@ module Ace
320
323
  lines = stdout.split("\n")
321
324
  text_parts = []
322
325
  usage = nil
326
+ terminal_event = nil
327
+ settled = false
328
+ message_stop_reason = nil
323
329
 
324
330
  lines.each do |line|
325
331
  next if line.strip.empty?
326
332
  event = JSON.parse(line)
333
+ raise Ace::LLM::ProviderError, "Pi JSON stream contains a non-object event" unless event.is_a?(Hash)
327
334
  case event["type"]
328
335
  when "message_end"
329
336
  # Extract text from content array
330
- content = event.dig("message", "content") || []
331
- content.each do |c|
332
- text_parts << c["text"] if c["type"] == "text"
337
+ message = event["message"] || {}
338
+ if message["role"].nil? || message["role"] == "assistant"
339
+ text_parts = extract_message_text(message)
340
+ usage = message["usage"]
341
+ message_stop_reason = message["stopReason"] if message["stopReason"]
333
342
  end
334
- usage = event.dig("message", "usage")
335
343
  when "agent_end"
336
- # Fallback: extract from messages array
344
+ settled = false
345
+ if event["willRetry"]
346
+ text_parts.clear
347
+ usage = nil
348
+ terminal_event = nil
349
+ message_stop_reason = nil
350
+ next
351
+ end
352
+ terminal_event = event
337
353
  messages = event["messages"] || []
338
- messages.each do |msg|
339
- content = msg["content"] || []
340
- content.each do |c|
341
- text_parts << c["text"] if c["type"] == "text"
342
- end
354
+ if (last_assistant = messages.reverse.find { |msg| (msg["role"].nil? || msg["role"] == "assistant") && extract_message_text(msg).any? })
355
+ text_parts = extract_message_text(last_assistant)
356
+ usage ||= last_assistant["usage"]
357
+ message_stop_reason ||= last_assistant["stopReason"]
343
358
  end
344
- usage = messages.dig(0, "usage") if usage.nil?
359
+ when "agent_settled"
360
+ settled = true if terminal_event
345
361
  end
346
362
  end
347
363
 
364
+ raise Ace::LLM::ProviderError, "Pi JSON stream ended without agent_end" unless terminal_event
365
+ raise Ace::LLM::ProviderError, "Pi JSON stream ended without agent_settled" unless settled
366
+
348
367
  text = text_parts.join("")
349
- [text, usage || {}]
368
+ raise Ace::LLM::ProviderError, "Pi JSON stream produced no final assistant message" if text.strip.empty?
369
+ terminal_reason = terminal_event["stopReason"] || terminal_event.dig("messages", -1, "stopReason")
370
+ successful_reasons = Ace::LLM::SUCCESSFUL_FINISH_REASONS
371
+ finish_reason = if %w[error aborted].include?(terminal_reason.to_s.downcase)
372
+ terminal_reason
373
+ elsif message_stop_reason && !successful_reasons.include?(message_stop_reason.to_s.downcase)
374
+ message_stop_reason
375
+ else
376
+ terminal_reason || message_stop_reason || "success"
377
+ end
378
+ [text, usage || {}, finish_reason]
350
379
  rescue JSON::ParserError
351
- # If parsing fails, treat as plain text
352
- [stdout.strip, {}]
380
+ raise Ace::LLM::ProviderError, "Pi JSON stream contains invalid events"
381
+ end
382
+
383
+ def extract_message_text(message)
384
+ Array(message["content"]).filter_map { |content| content["text"] if content["type"] == "text" }
353
385
  end
354
386
 
355
387
  # Normalize Pi usage field names to our standard format.
@@ -4,7 +4,7 @@ module Ace
4
4
  module LLM
5
5
  module Providers
6
6
  module CLI
7
- VERSION = "0.34.0"
7
+ VERSION = "0.34.1"
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-llm-providers-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.34.0
4
+ version: 0.34.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-13 00:00:00.000000000 Z
10
+ date: 2026-09-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-llm
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.39'
18
+ version: '0.40'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.39'
25
+ version: '0.40'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rake
28
28
  requirement: !ruby/object:Gem::Requirement