ace-llm-providers-cli 0.34.0 → 0.35.0

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: f25fb8b3c59151cc9e4e46f49cd9eae4d2d06b6fca7052422a60b1ab0129cc24
4
+ data.tar.gz: 4f37a1d94452b1f3fb06f37d47d3407c2c3e1c3e0e8cf40eb2d38756f1920b16
5
5
  SHA512:
6
- metadata.gz: df4f6c5788a4a461ce1dab3be11ba0c9644c584308f6d553d28a3c97ec4e2248fa9a936765815cf4c64407cd37ec9d11b2e6a054459a7aa924c7b391dd7d9d2a
7
- data.tar.gz: af0ccb22aee51c204e5ec9c932c41e6a1f2369e75574511175f0d939fcedf8ec0c24acfe305ddf2a4fc2622a5c41981b2a26359711538b2f8371971418ce21d3
6
+ metadata.gz: 2124492c17917caf053a5ad03014087c5c1fe75c3336cb9e44777d6858829ff6b1e02cf1552c05b1f2c9c4afed6b573f83b1df61c6bf06c444435b171731d35d
7
+ data.tar.gz: a77c73150b6f49caa994f793a6608c4be21d6c2bc46f30d0b475078568c6f0e7e35345cb974c2aa4ddeef0b17d0ad2cbdc2c372862ad4375aa401965d2f9aac3
@@ -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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.0] - 2026-09-27
11
+
12
+ ### Changed
13
+
14
+ - Pi client reports measured per-model token usage — input, output, cached (`cacheRead`) and total (`totalTokens`) — instead of character-based estimates. When the CLI supplies no usage, the session records an explicit `usage_status: unavailable` (with the pi version when known) and no fabricated token counts; measured zeros are preserved and absent fields stay absent.
15
+
16
+ ## [0.34.1] - 2026-09-24
17
+
18
+ ### Fixed
19
+
20
+ - Preserve completed Codex responses when copying the optional last-message diagnostic fails, and handle Pi retry streams without accepting partial output.
21
+ - Reject unresolved Pi model selectors before CLI execution and keep the `glm5` alias synchronized across provider registries.
22
+ - Preserve Codex JSON response text containing a preset path without mistaking it for the CLI banner.
23
+ - Reject directory destinations for Codex last-message capture and preserve the final Pi stop reason when earlier turns appear in terminal events.
24
+
25
+ ### Changed
26
+
27
+ - Restrict Pi `@ro` to read-only tools (`read`, `grep`, `find`, `ls`) and disable extensions.
28
+
10
29
  ## [0.34.0] - 2026-09-13
11
30
 
12
31
  ### 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
 
@@ -294,21 +297,48 @@ module Ace
294
297
  }
295
298
  end
296
299
 
300
+ # Measured usage only. When the pi CLI supplies usage the token counts
301
+ # are recorded as-is (cached 0 means "no cache read", distinct from
302
+ # absent); when it does not, tokens are absent entirely and the
303
+ # session records an explicit "unavailable" state — never an estimate.
297
304
  def build_metadata(response, text, prompt, options)
298
305
  usage = response["usage"] || {}
299
306
 
300
- prompt_tokens = usage["input_tokens"] || (prompt.to_s.length / 4).round
301
- output_tokens = usage["output_tokens"] || (text.length / 4).round
302
-
303
- {
307
+ metadata = {
304
308
  provider: "pi",
305
309
  model: @model || DEFAULT_MODEL,
306
- input_tokens: prompt_tokens,
307
- output_tokens: output_tokens,
308
- total_tokens: prompt_tokens + output_tokens,
309
310
  finish_reason: response["finish_reason"] || "success",
310
311
  timestamp: Time.now.utc.iso8601
311
312
  }
313
+
314
+ if usage["input_tokens"] || usage["output_tokens"]
315
+ metadata[:input_tokens] = usage["input_tokens"] unless usage["input_tokens"].nil?
316
+ metadata[:output_tokens] = usage["output_tokens"] unless usage["output_tokens"].nil?
317
+ metadata[:cached_tokens] = usage["cached_tokens"] if usage.key?("cached_tokens")
318
+ metadata[:total_tokens] = usage["total_tokens"] ||
319
+ (usage["input_tokens"].to_i + usage["output_tokens"].to_i)
320
+ metadata[:usage_status] = "measured"
321
+ else
322
+ metadata[:usage_status] = "unavailable"
323
+ version = pi_version
324
+ metadata[:pi_version] = version if version
325
+ end
326
+
327
+ metadata
328
+ end
329
+
330
+ # Best-effort pi CLI version for the usage-unavailable record. Runs
331
+ # outside SafeCapture (a bare --version probe) and is memoized per
332
+ # instance so retries don't re-spawn the process.
333
+ def pi_version
334
+ return @pi_version if defined?(@pi_version)
335
+
336
+ @pi_version = begin
337
+ stdout, _stderr, status = Open3.capture3("pi", "--version")
338
+ status.success? ? stdout.strip : nil
339
+ rescue StandardError
340
+ nil
341
+ end
312
342
  end
313
343
 
314
344
  # Parse NDJSON output from Pi CLI when --mode json is used.
@@ -320,40 +350,72 @@ module Ace
320
350
  lines = stdout.split("\n")
321
351
  text_parts = []
322
352
  usage = nil
353
+ terminal_event = nil
354
+ settled = false
355
+ message_stop_reason = nil
323
356
 
324
357
  lines.each do |line|
325
358
  next if line.strip.empty?
326
359
  event = JSON.parse(line)
360
+ raise Ace::LLM::ProviderError, "Pi JSON stream contains a non-object event" unless event.is_a?(Hash)
327
361
  case event["type"]
328
362
  when "message_end"
329
363
  # 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"
364
+ message = event["message"] || {}
365
+ if message["role"].nil? || message["role"] == "assistant"
366
+ text_parts = extract_message_text(message)
367
+ usage = message["usage"]
368
+ message_stop_reason = message["stopReason"] if message["stopReason"]
333
369
  end
334
- usage = event.dig("message", "usage")
335
370
  when "agent_end"
336
- # Fallback: extract from messages array
371
+ settled = false
372
+ if event["willRetry"]
373
+ text_parts.clear
374
+ usage = nil
375
+ terminal_event = nil
376
+ message_stop_reason = nil
377
+ next
378
+ end
379
+ terminal_event = event
337
380
  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
381
+ if (last_assistant = messages.reverse.find { |msg| (msg["role"].nil? || msg["role"] == "assistant") && extract_message_text(msg).any? })
382
+ text_parts = extract_message_text(last_assistant)
383
+ usage ||= last_assistant["usage"]
384
+ message_stop_reason ||= last_assistant["stopReason"]
343
385
  end
344
- usage = messages.dig(0, "usage") if usage.nil?
386
+ when "agent_settled"
387
+ settled = true if terminal_event
345
388
  end
346
389
  end
347
390
 
391
+ raise Ace::LLM::ProviderError, "Pi JSON stream ended without agent_end" unless terminal_event
392
+ raise Ace::LLM::ProviderError, "Pi JSON stream ended without agent_settled" unless settled
393
+
348
394
  text = text_parts.join("")
349
- [text, usage || {}]
395
+ raise Ace::LLM::ProviderError, "Pi JSON stream produced no final assistant message" if text.strip.empty?
396
+ terminal_reason = terminal_event["stopReason"] || terminal_event.dig("messages", -1, "stopReason")
397
+ successful_reasons = Ace::LLM::SUCCESSFUL_FINISH_REASONS
398
+ finish_reason = if %w[error aborted].include?(terminal_reason.to_s.downcase)
399
+ terminal_reason
400
+ elsif message_stop_reason && !successful_reasons.include?(message_stop_reason.to_s.downcase)
401
+ message_stop_reason
402
+ else
403
+ terminal_reason || message_stop_reason || "success"
404
+ end
405
+ [text, usage || {}, finish_reason]
350
406
  rescue JSON::ParserError
351
- # If parsing fails, treat as plain text
352
- [stdout.strip, {}]
407
+ raise Ace::LLM::ProviderError, "Pi JSON stream contains invalid events"
408
+ end
409
+
410
+ def extract_message_text(message)
411
+ Array(message["content"]).filter_map { |content| content["text"] if content["type"] == "text" }
353
412
  end
354
413
 
355
414
  # Normalize Pi usage field names to our standard format.
356
- # Pi uses "input"/"output", we normalize to "input_tokens"/"output_tokens".
415
+ # Pi reports input/output/cacheRead/cacheWrite/totalTokens on the
416
+ # assistant message; we map them to *_tokens keys. A measured zero
417
+ # cacheRead is kept (distinguishing "no cache" from "unknown"); a
418
+ # missing key stays missing.
357
419
  #
358
420
  # @param usage [Hash] Raw usage hash from Pi response
359
421
  # @return [Hash] Normalized usage hash
@@ -361,7 +423,9 @@ module Ace
361
423
  return {} unless usage
362
424
  {
363
425
  "input_tokens" => usage["input"] || usage["input_tokens"],
364
- "output_tokens" => usage["output"] || usage["output_tokens"]
426
+ "output_tokens" => usage["output"] || usage["output_tokens"],
427
+ "cached_tokens" => (usage["cacheRead"] if usage.key?("cacheRead")),
428
+ "total_tokens" => (usage["totalTokens"] if usage.key?("totalTokens"))
365
429
  }.compact
366
430
  end
367
431
 
@@ -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.35.0"
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.35.0
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-27 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.41'
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.41'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rake
28
28
  requirement: !ruby/object:Gem::Requirement