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 +4 -4
- data/.ace-defaults/llm/providers/pi.yml +1 -0
- data/CHANGELOG.md +13 -0
- data/lib/ace/llm/providers/cli/cli_args_support.rb +1 -1
- data/lib/ace/llm/providers/cli/codex_client.rb +74 -16
- data/lib/ace/llm/providers/cli/pi_client.rb +70 -38
- data/lib/ace/llm/providers/cli/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9aee773ea6aac47cb8aeaa00ca6926a81f5d9eaac29f9b968ba098d81da0117d
|
|
4
|
+
data.tar.gz: ac05f84f681e6316ac4662750e923e06a03692ee88201456f56da5afae904079
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 707c07914d35d16e3e868b4891a0be562930edd9ce5d79a41eb0a99f6628bc90b3cc5eff2f9ad7adc122b3e674431e4825ae2a2602354e0386bd1c0cf737e0d7
|
|
7
|
+
data.tar.gz: d10514be9fbb31940a1cd96c7ba35807eddedaa047aacf8fd09d35085d82c4e742db8efdf6261889236839056493305ec6f781836f38180a69fc3c9af2332c18
|
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}
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
288
|
-
response_start = lines.find_index { |line| line.
|
|
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)
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
cmd << "--model" << model_id
|
|
200
|
-
end
|
|
201
|
+
cmd << "--provider" << provider_name
|
|
202
|
+
cmd << "--model" << model_id
|
|
201
203
|
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
+
colon = model_string.index(":")
|
|
247
|
+
slash = model_string.index("/")
|
|
248
|
+
if colon && (!slash || colon < slash)
|
|
241
249
|
parts = model_string.split(":", 2)
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
text_parts
|
|
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
|
-
|
|
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.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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.
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
25
|
+
version: '0.40'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: rake
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|