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 +4 -4
- data/.ace-defaults/llm/providers/pi.yml +1 -0
- data/CHANGELOG.md +19 -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 +111 -47
- 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: f25fb8b3c59151cc9e4e46f49cd9eae4d2d06b6fca7052422a60b1ab0129cc24
|
|
4
|
+
data.tar.gz: 4f37a1d94452b1f3fb06f37d47d3407c2c3e1c3e0e8cf40eb2d38756f1920b16
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2124492c17917caf053a5ad03014087c5c1fe75c3336cb9e44777d6858829ff6b1e02cf1552c05b1f2c9c4afed6b573f83b1df61c6bf06c444435b171731d35d
|
|
7
|
+
data.tar.gz: a77c73150b6f49caa994f793a6608c4be21d6c2bc46f30d0b475078568c6f0e7e35345cb974c2aa4ddeef0b17d0ad2cbdc2c372862ad4375aa401965d2f9aac3
|
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}
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
text_parts
|
|
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
|
-
|
|
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.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
|
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
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
25
|
+
version: '0.41'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: rake
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|