agent-cli-runtime 0.1.1 → 0.2.4
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/CHANGELOG.md +87 -0
- data/README.md +177 -9
- data/agent-cli-runtime.gemspec +7 -5
- data/lib/agent_cli_runtime/error_extractors.rb +95 -0
- data/lib/agent_cli_runtime/errors.rb +7 -0
- data/lib/agent_cli_runtime/opencode/inspection.rb +32 -0
- data/lib/agent_cli_runtime/opencode/overlay.rb +488 -0
- data/lib/agent_cli_runtime/opencode/permissions.rb +144 -0
- data/lib/agent_cli_runtime/opencode/probe.rb +304 -0
- data/lib/agent_cli_runtime/opencode/result_parser.rb +416 -0
- data/lib/agent_cli_runtime/profile.rb +60 -11
- data/lib/agent_cli_runtime/profiles.rb +98 -3
- data/lib/agent_cli_runtime/runtime.rb +118 -13
- data/lib/agent_cli_runtime/usage_extractors.rb +119 -32
- data/lib/agent_cli_runtime/values.rb +478 -0
- data/lib/agent_cli_runtime/version.rb +1 -1
- data/lib/agent_cli_runtime.rb +29 -3
- metadata +13 -6
|
@@ -103,11 +103,44 @@ module AgentCliRuntime
|
|
|
103
103
|
home_path(home, ".grok", "auth.json")
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
def opencode_auth_path(home:, env:)
|
|
107
|
+
data_home = env["XDG_DATA_HOME"]
|
|
108
|
+
if !data_home.to_s.empty?
|
|
109
|
+
unless File.absolute_path?(data_home)
|
|
110
|
+
raise ArgumentError, "XDG_DATA_HOME must be absolute"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return File.join(data_home, "opencode", "auth.json")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
home_path(home, ".local", "share", "opencode", "auth.json")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def opencode_model_arguments(model)
|
|
120
|
+
route = Route.parse(model)
|
|
121
|
+
[ "--model", route.to_s ]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
OPENCODE_VARIANTS = %w[minimal low medium high xhigh max].freeze
|
|
125
|
+
|
|
126
|
+
def opencode_variant_arguments(variant)
|
|
127
|
+
normalized = variant.to_s
|
|
128
|
+
unless OPENCODE_VARIANTS.include?(normalized)
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"unsupported OpenCode variant #{variant.inspect}; expected one of " \
|
|
131
|
+
"#{OPENCODE_VARIANTS.join(', ')}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
[ "--variant", normalized ]
|
|
135
|
+
end
|
|
136
|
+
|
|
106
137
|
CLAUDE = Profile.new(
|
|
107
138
|
name: :claude,
|
|
108
139
|
bin_default: "claude",
|
|
109
140
|
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_CLAUDE_BIN HIVE_CLAUDE_BIN],
|
|
110
141
|
headless_flag: "-p",
|
|
142
|
+
# Print mode reads stdin; keep large review prompts out of exec arguments.
|
|
143
|
+
prompt_style: :piped_stdin,
|
|
111
144
|
permission_skip_flag: "--dangerously-skip-permissions",
|
|
112
145
|
add_dir_flag: "--add-dir",
|
|
113
146
|
tool_scope_flags: {
|
|
@@ -125,6 +158,7 @@ module AgentCliRuntime
|
|
|
125
158
|
effort_argument_builder: ->(effort) { [ "--effort", effort ] },
|
|
126
159
|
launcher_identity: "claude-code/v1",
|
|
127
160
|
usage_extractor: UsageExtractors::CLAUDE,
|
|
161
|
+
error_extractor: ErrorExtractors::CLAUDE,
|
|
128
162
|
credential_environment_keys: %w[
|
|
129
163
|
ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_API_KEY
|
|
130
164
|
],
|
|
@@ -189,12 +223,17 @@ module AgentCliRuntime
|
|
|
189
223
|
bin_default: "pi",
|
|
190
224
|
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_PI_BIN HIVE_PI_BIN],
|
|
191
225
|
headless_flag: "-p",
|
|
226
|
+
# Pi reads a non-TTY stdin stream into its initial message without an
|
|
227
|
+
# argv placeholder. Keeping the prompt out of argv also avoids Linux's
|
|
228
|
+
# per-argument size limit on real implementation plans.
|
|
229
|
+
prompt_style: :piped_stdin,
|
|
192
230
|
output_format_flags: [ "--mode", "json", "--no-session" ],
|
|
193
231
|
version_flag: "--version",
|
|
194
232
|
min_version: "0.70.2",
|
|
195
233
|
model_argument_builder: ->(model) { [ "--model", model ] },
|
|
196
234
|
launcher_identity: "pi-coding-agent/v1",
|
|
197
235
|
usage_extractor: UsageExtractors::PI,
|
|
236
|
+
error_extractor: ErrorExtractors::PI,
|
|
198
237
|
credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
|
|
199
238
|
configuration_environment_key: "PI_CODING_AGENT_DIR",
|
|
200
239
|
default_configuration_directory: ".pi/agent",
|
|
@@ -216,9 +255,18 @@ module AgentCliRuntime
|
|
|
216
255
|
name: :grok,
|
|
217
256
|
bin_default: "grok",
|
|
218
257
|
env_bin_override_keys: %w[AGENT_CLI_RUNTIME_GROK_BIN HIVE_GROK_BIN],
|
|
219
|
-
|
|
220
|
-
|
|
258
|
+
# Grok's Unix CLI reads a prompt file; stdin already has a managed lifetime.
|
|
259
|
+
headless_flag: "--prompt-file=/dev/stdin",
|
|
260
|
+
prompt_style: :piped_stdin,
|
|
221
261
|
permission_skip_flag: "--always-approve",
|
|
262
|
+
# Grok confines the filesystem natively, the same shape codex uses.
|
|
263
|
+
# `workspace` limits writes to the working directory, `read-only`
|
|
264
|
+
# forbids them entirely; both are built-in profiles (custom ones extend
|
|
265
|
+
# them from ~/.grok/sandbox.toml). `--always-approve` suppresses the
|
|
266
|
+
# interactive approval prompt, which a headless reviewer can never answer
|
|
267
|
+
# — the sandbox, not the prompt, is what actually bounds the agent.
|
|
268
|
+
workspace_write_flags: [ "--sandbox", "workspace", "--always-approve" ],
|
|
269
|
+
read_only_flags: [ "--sandbox", "read-only", "--always-approve" ],
|
|
222
270
|
output_format_flags: [ "--output-format", "streaming-json" ],
|
|
223
271
|
version_flag: "--version",
|
|
224
272
|
min_version: "0.2.90",
|
|
@@ -242,7 +290,54 @@ module AgentCliRuntime
|
|
|
242
290
|
end
|
|
243
291
|
)
|
|
244
292
|
|
|
245
|
-
|
|
293
|
+
OPENCODE = Profile.new(
|
|
294
|
+
name: :opencode,
|
|
295
|
+
bin_default: "opencode",
|
|
296
|
+
env_bin_override_keys: %w[
|
|
297
|
+
AGENT_CLI_RUNTIME_OPENCODE_BIN HIVE_OPENCODE_BIN
|
|
298
|
+
],
|
|
299
|
+
headless_flag: "run",
|
|
300
|
+
output_format_flags: [ "--format", "json" ],
|
|
301
|
+
version_flag: "--version",
|
|
302
|
+
# `opencode run` reads a non-TTY stdin stream as the initial message.
|
|
303
|
+
# Keep implementation-sized prompts out of one argv element: Linux
|
|
304
|
+
# rejects a single argument around 128 KiB with E2BIG even when the
|
|
305
|
+
# complete argv remains far below ARG_MAX.
|
|
306
|
+
prompt_style: :piped_stdin,
|
|
307
|
+
# Bun-backed OpenCode startup can exceed the generic 10-second bound
|
|
308
|
+
# under sustained host I/O even though the executable is healthy.
|
|
309
|
+
version_check_timeout_sec: 30,
|
|
310
|
+
min_version: "1.18.16",
|
|
311
|
+
model_argument_builder: ->(model) { opencode_model_arguments(model) },
|
|
312
|
+
effort_argument_builder:
|
|
313
|
+
->(variant) { opencode_variant_arguments(variant) },
|
|
314
|
+
launcher_identity: "opencode-cli/v1",
|
|
315
|
+
credential_environment_keys: PI_CREDENTIAL_ENVIRONMENT_KEYS,
|
|
316
|
+
configuration_environment_key: "OPENCODE_CONFIG_DIR",
|
|
317
|
+
default_configuration_directory: ".config/opencode",
|
|
318
|
+
permission_policy_required: true,
|
|
319
|
+
error_extractor: ErrorExtractors::OPENCODE,
|
|
320
|
+
result_parser: OpenCode::ResultParser,
|
|
321
|
+
cli_capabilities: {
|
|
322
|
+
json_events: [ "run", "--format" ],
|
|
323
|
+
working_directory: [ "run", "--dir" ],
|
|
324
|
+
model_variant: [ "run", "--variant" ],
|
|
325
|
+
pure: [ "run", "--pure" ],
|
|
326
|
+
sanitized_export: [ "export", "--sanitize" ]
|
|
327
|
+
},
|
|
328
|
+
auth_configuration_probe: lambda do |home:, env:|
|
|
329
|
+
if env_configured?(env, *PI_CREDENTIAL_ENVIRONMENT_KEYS)
|
|
330
|
+
AuthConfiguration.new(status: :configured, source: "environment")
|
|
331
|
+
else
|
|
332
|
+
auth_from_file(
|
|
333
|
+
opencode_auth_path(home:, env:),
|
|
334
|
+
source: "opencode auth.json"
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
PROFILES = [ CLAUDE, CODEX, PI, GROK, OPENCODE ].to_h do |profile|
|
|
246
341
|
[ profile.name, profile ]
|
|
247
342
|
end.freeze
|
|
248
343
|
PROVIDER_ORDER = PROFILES.keys.freeze
|
|
@@ -46,12 +46,23 @@ module AgentCliRuntime
|
|
|
46
46
|
end
|
|
47
47
|
argv.concat(request.trusted_cli_arguments)
|
|
48
48
|
argv.concat(profile.output_format_flags) if request.include_output_format
|
|
49
|
-
|
|
50
|
-
prompt_style
|
|
49
|
+
stdin_data =
|
|
50
|
+
case prompt_style
|
|
51
|
+
when :positional
|
|
52
|
+
argv << request.prompt
|
|
53
|
+
nil
|
|
54
|
+
when :headless_flag_value
|
|
55
|
+
nil
|
|
56
|
+
when :stdin
|
|
57
|
+
argv << "-"
|
|
58
|
+
request.prompt
|
|
59
|
+
when :piped_stdin
|
|
60
|
+
request.prompt
|
|
61
|
+
end
|
|
51
62
|
|
|
52
63
|
CompiledInvocation.new(
|
|
53
64
|
argv: request.command_prefix + argv,
|
|
54
|
-
stdin_data
|
|
65
|
+
stdin_data:,
|
|
55
66
|
provider: profile.name,
|
|
56
67
|
launcher_identity: profile.launcher_identity,
|
|
57
68
|
capability_evidence: evidence
|
|
@@ -63,9 +74,13 @@ module AgentCliRuntime
|
|
|
63
74
|
compilation_error!(profile, e)
|
|
64
75
|
end
|
|
65
76
|
|
|
66
|
-
def prepare!(profile)
|
|
77
|
+
def prepare!(profile, env: ENV)
|
|
78
|
+
if profile.is_a?(OpenCodePreparationRequest)
|
|
79
|
+
return OpenCode::Overlay.prepare!(profile, env:)
|
|
80
|
+
end
|
|
81
|
+
|
|
67
82
|
resolved = Profiles.resolve(profile)
|
|
68
|
-
result = Probe.call(resolved)
|
|
83
|
+
result = Probe.call(resolved, env:)
|
|
69
84
|
return result if result.ready
|
|
70
85
|
|
|
71
86
|
evidence = unsupported_evidence(
|
|
@@ -103,6 +118,51 @@ module AgentCliRuntime
|
|
|
103
118
|
end
|
|
104
119
|
end
|
|
105
120
|
|
|
121
|
+
# A provider failure seen mid-stream, normalized to
|
|
122
|
+
# {kind:, provider:, status_code:, message:}, or nil when the event is
|
|
123
|
+
# clean. Callers use this to tell a provider-side stop (quota, credit
|
|
124
|
+
# ceiling, rate limit, model output truncation) apart from an agent that
|
|
125
|
+
# genuinely produced nothing: several CLIs report the former on the stream
|
|
126
|
+
# and still exit zero.
|
|
127
|
+
def extract_provider_error(profile, event)
|
|
128
|
+
resolved = Profiles.resolve(profile)
|
|
129
|
+
extracted = resolved.extract_error_event(event)
|
|
130
|
+
return nil if extracted.nil?
|
|
131
|
+
|
|
132
|
+
kind, text = normalize_extracted_error(extracted)
|
|
133
|
+
return nil if text.nil? || text.strip.empty?
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
kind: kind,
|
|
137
|
+
provider: resolved.name,
|
|
138
|
+
status_code: status_code_from(text),
|
|
139
|
+
message: Redactor.diagnostic(text)
|
|
140
|
+
}.freeze
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalize_extracted_error(extracted)
|
|
144
|
+
return [ extracted.kind, extracted.message ] if extracted.is_a?(ExtractedFailure)
|
|
145
|
+
return [ failure_kind_from(extracted), extracted ] if extracted.is_a?(String)
|
|
146
|
+
|
|
147
|
+
[ nil, nil ]
|
|
148
|
+
end
|
|
149
|
+
private_class_method :normalize_extracted_error
|
|
150
|
+
|
|
151
|
+
def failure_kind_from(text)
|
|
152
|
+
status = status_code_from(text)
|
|
153
|
+
return :provider_limit if status == 402
|
|
154
|
+
return :rate_limited if status == 429
|
|
155
|
+
|
|
156
|
+
normalized = text.to_s.downcase
|
|
157
|
+
return :rate_limited if normalized.match?(/\brate[\s_-]*limit(?:ed|s)?\b/)
|
|
158
|
+
return :provider_limit if normalized.match?(
|
|
159
|
+
/\b(?:quota|billing|credits?|tokens?[\s_-]*limit|usage[\s_-]*limit)\b/
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
:provider_error
|
|
163
|
+
end
|
|
164
|
+
private_class_method :failure_kind_from
|
|
165
|
+
|
|
106
166
|
def observe(profile, result)
|
|
107
167
|
resolved = Profiles.resolve(profile)
|
|
108
168
|
raw = result.is_a?(Hash) ? result : {}
|
|
@@ -121,6 +181,16 @@ module AgentCliRuntime
|
|
|
121
181
|
)
|
|
122
182
|
end
|
|
123
183
|
|
|
184
|
+
def parse_run(profile, stdout:)
|
|
185
|
+
Profiles.resolve(profile).parse_run(stdout)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def normalize(profile, captured, requested_route:)
|
|
189
|
+
Profiles.resolve(profile).normalize_captured_result(
|
|
190
|
+
captured, requested_route:
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
124
194
|
def supported_evidence(profile, capability, arguments = [])
|
|
125
195
|
CapabilityEvidence.new(
|
|
126
196
|
capability: capability,
|
|
@@ -253,22 +323,57 @@ module AgentCliRuntime
|
|
|
253
323
|
end
|
|
254
324
|
private_class_method :tool_csv
|
|
255
325
|
|
|
326
|
+
# Providers prefix the status onto the text ("402: {...}") or carry it in
|
|
327
|
+
# the embedded payload. Either is enough to classify without matching on
|
|
328
|
+
# human-readable wording, which differs per provider and changes freely.
|
|
329
|
+
def status_code_from(text)
|
|
330
|
+
value = text[/\A\s*(\d{3})\s*:/, 1] ||
|
|
331
|
+
text[/"(?:code|http_status)"\s*:\s*(\d{3})\b/, 1]
|
|
332
|
+
code = value.to_i
|
|
333
|
+
code.between?(100, 599) ? code : nil
|
|
334
|
+
end
|
|
335
|
+
private_class_method :status_code_from
|
|
336
|
+
|
|
256
337
|
def normalize_usage(usage)
|
|
257
338
|
return nil unless usage.is_a?(Hash)
|
|
258
339
|
|
|
259
|
-
|
|
260
|
-
output = usage.key?(:output) ? usage[:output] : usage["output"]
|
|
261
|
-
cached = usage.key?(:cached) ? usage[:cached] : usage["cached"]
|
|
262
|
-
model = usage.key?(:model) ? usage[:model] : usage["model"]
|
|
340
|
+
value = ->(key) { usage.key?(key) ? usage[key] : usage[key.to_s] }
|
|
263
341
|
{
|
|
264
|
-
input:
|
|
265
|
-
output:
|
|
266
|
-
cached:
|
|
267
|
-
|
|
342
|
+
input: normalized_count(value.call(:input)),
|
|
343
|
+
output: normalized_count(value.call(:output)),
|
|
344
|
+
cached: normalized_count(value.call(:cached)),
|
|
345
|
+
cache_read: normalized_count(value.call(:cache_read)),
|
|
346
|
+
cache_write: normalized_count(value.call(:cache_write)),
|
|
347
|
+
reasoning: normalized_count(value.call(:reasoning)),
|
|
348
|
+
input_includes_cache_read: normalized_boolean(value.call(:input_includes_cache_read)),
|
|
349
|
+
input_includes_cache_write: normalized_boolean(value.call(:input_includes_cache_write)),
|
|
350
|
+
output_includes_reasoning: normalized_boolean(value.call(:output_includes_reasoning)),
|
|
351
|
+
model: value.call(:model)&.to_s&.dup&.freeze,
|
|
352
|
+
# Carried as a float: this is money, and normalized_count would round
|
|
353
|
+
# a fraction-of-a-cent charge to zero.
|
|
354
|
+
provider_reported_cost: normalized_cost(value.call(:provider_reported_cost))
|
|
268
355
|
}.freeze
|
|
269
356
|
end
|
|
270
357
|
private_class_method :normalize_usage
|
|
271
358
|
|
|
359
|
+
def normalized_cost(value)
|
|
360
|
+
return nil unless value.is_a?(Numeric)
|
|
361
|
+
|
|
362
|
+
cost = value.to_f
|
|
363
|
+
cost if cost >= 0 && cost.finite?
|
|
364
|
+
end
|
|
365
|
+
private_class_method :normalized_cost
|
|
366
|
+
|
|
367
|
+
def normalized_count(value)
|
|
368
|
+
value.nil? ? nil : [ value.to_i, 0 ].max
|
|
369
|
+
end
|
|
370
|
+
private_class_method :normalized_count
|
|
371
|
+
|
|
372
|
+
def normalized_boolean(value)
|
|
373
|
+
value if value == true || value == false
|
|
374
|
+
end
|
|
375
|
+
private_class_method :normalized_boolean
|
|
376
|
+
|
|
272
377
|
def unsupported!(profile, capability, diagnostic)
|
|
273
378
|
evidence = unsupported_evidence(profile, capability, diagnostic)
|
|
274
379
|
raise UnsupportedCapability.new(
|
|
@@ -7,9 +7,9 @@ module AgentCliRuntime
|
|
|
7
7
|
|
|
8
8
|
usage = usage_hash(event)
|
|
9
9
|
if event["type"] == "result" && usage
|
|
10
|
-
usage_result(event, usage, model_from(event))
|
|
10
|
+
usage_result(event, usage, model_from(event), provider: :claude)
|
|
11
11
|
elsif event["type"] == "stream_event" && usage
|
|
12
|
-
usage_result(event, usage, model_from(event))
|
|
12
|
+
usage_result(event, usage, model_from(event), provider: :claude)
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -17,21 +17,21 @@ module AgentCliRuntime
|
|
|
17
17
|
next nil unless event.is_a?(Hash)
|
|
18
18
|
|
|
19
19
|
usage = usage_hash(event)
|
|
20
|
-
usage_result(event, usage, model_from(event)) if usage
|
|
20
|
+
usage_result(event, usage, model_from(event), provider: :codex) if usage
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
PI = lambda do |event|
|
|
24
24
|
next nil unless event.is_a?(Hash)
|
|
25
25
|
|
|
26
26
|
usage = usage_hash(event)
|
|
27
|
-
usage_result(event, usage, model_from(event)) if usage
|
|
27
|
+
usage_result(event, usage, model_from(event), provider: :pi) if usage
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
GROK = lambda do |event|
|
|
31
31
|
next nil unless event.is_a?(Hash)
|
|
32
32
|
|
|
33
33
|
usage = usage_hash(event)
|
|
34
|
-
usage_result(event, usage, model_from(event)) if usage
|
|
34
|
+
usage_result(event, usage, model_from(event), provider: :grok) if usage
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def usage_hash(event)
|
|
@@ -40,6 +40,9 @@ module AgentCliRuntime
|
|
|
40
40
|
event["token_usage"],
|
|
41
41
|
event.dig("event", "usage"),
|
|
42
42
|
event.dig("event", "message", "usage"),
|
|
43
|
+
# pi reports usage on the assistant message itself, not under an
|
|
44
|
+
# "event" envelope. Without this every pi run is unmetered.
|
|
45
|
+
event.dig("message", "usage"),
|
|
43
46
|
event.dig("info", "usage"),
|
|
44
47
|
event.dig("info", "total_token_usage"),
|
|
45
48
|
event.dig("response", "usage"),
|
|
@@ -48,42 +51,126 @@ module AgentCliRuntime
|
|
|
48
51
|
candidates.find { |value| value.is_a?(Hash) }
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
def usage_result(event, usage, model)
|
|
54
|
+
def usage_result(event, usage, model, provider:)
|
|
55
|
+
cache_read = cache_read_tokens(usage)
|
|
56
|
+
cache_write = cache_write_tokens(usage)
|
|
57
|
+
aggregate_cached = aggregate_cached_tokens(usage)
|
|
58
|
+
reasoning = optional_token_count(
|
|
59
|
+
usage, "reasoning_tokens", "reasoningTokens"
|
|
60
|
+
)
|
|
61
|
+
reasoning ||= optional_token_count(
|
|
62
|
+
nested_hash(usage, "completion_tokens_details"),
|
|
63
|
+
"reasoning_tokens", "reasoningTokens"
|
|
64
|
+
)
|
|
52
65
|
{
|
|
53
|
-
input
|
|
54
|
-
|
|
66
|
+
# The bare "input"/"output" spellings are pi's and are matched last,
|
|
67
|
+
# so a provider using an explicit *_tokens key keeps its own reading.
|
|
68
|
+
input: optional_token_count(
|
|
69
|
+
usage, "input_tokens", "inputTokens", "prompt_tokens", "promptTokens",
|
|
70
|
+
"input"
|
|
55
71
|
),
|
|
56
|
-
output:
|
|
72
|
+
output: optional_token_count(
|
|
57
73
|
usage, "output_tokens", "outputTokens", "completion_tokens",
|
|
58
|
-
"completionTokens"
|
|
74
|
+
"completionTokens", "output"
|
|
59
75
|
),
|
|
60
|
-
cached:
|
|
61
|
-
|
|
76
|
+
cached: aggregate_cached || complete_cached(cache_read, cache_write),
|
|
77
|
+
cache_read: cache_read,
|
|
78
|
+
cache_write: cache_write,
|
|
79
|
+
reasoning: reasoning,
|
|
80
|
+
input_includes_cache_read:
|
|
81
|
+
inclusion_or_inferred(
|
|
82
|
+
usage, "input_includes_cache_read",
|
|
83
|
+
inferred_input_cache_inclusion(provider, cache_read)
|
|
84
|
+
),
|
|
85
|
+
input_includes_cache_write:
|
|
86
|
+
inclusion_or_inferred(
|
|
87
|
+
usage, "input_includes_cache_write",
|
|
88
|
+
inferred_input_cache_inclusion(provider, cache_write)
|
|
89
|
+
),
|
|
90
|
+
output_includes_reasoning:
|
|
91
|
+
inclusion_or_inferred(
|
|
92
|
+
usage, "output_includes_reasoning",
|
|
93
|
+
inferred_reasoning_inclusion(provider, reasoning)
|
|
94
|
+
),
|
|
95
|
+
model: model || model_from_usage(usage) || model_from(event),
|
|
96
|
+
provider_reported_cost: reported_cost(usage)
|
|
62
97
|
}
|
|
63
98
|
end
|
|
64
99
|
|
|
65
|
-
|
|
66
|
-
|
|
100
|
+
# A charge the provider itself reported for this turn, in USD. This is an
|
|
101
|
+
# observed amount, not an estimate from the pricing catalog, and it is the
|
|
102
|
+
# only cost available for a model the catalog does not carry — an
|
|
103
|
+
# OpenRouter-routed model, for one. OpenRouter sends a per-category
|
|
104
|
+
# breakdown carrying a total; other providers may send a scalar.
|
|
105
|
+
def reported_cost(usage)
|
|
106
|
+
value = usage["cost"] || usage["total_cost"] || usage["totalCost"]
|
|
107
|
+
value = value["total"] || value["total_cost"] if value.is_a?(Hash)
|
|
108
|
+
return nil unless value.is_a?(Numeric) || value.is_a?(String)
|
|
109
|
+
|
|
110
|
+
cost = begin
|
|
111
|
+
Float(value)
|
|
112
|
+
rescue ArgumentError, TypeError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
cost if cost && cost >= 0 && cost.finite?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def aggregate_cached_tokens(usage)
|
|
119
|
+
optional_token_count(
|
|
67
120
|
usage, "cached", "cached_tokens", "cachedTokens",
|
|
68
121
|
"cached_input_tokens", "cachedInputTokens"
|
|
69
122
|
)
|
|
70
|
-
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cache_read_tokens(usage)
|
|
126
|
+
optional_token_count(
|
|
127
|
+
usage, "cache_read_input_tokens", "cacheReadInputTokens"
|
|
128
|
+
) || optional_token_count(
|
|
129
|
+
nested_hash(usage, "prompt_tokens_details"), "cached_tokens", "cachedTokens"
|
|
130
|
+
) || optional_token_count(
|
|
131
|
+
nested_hash(usage, "input_tokens_details"), "cached_tokens", "cachedTokens"
|
|
132
|
+
) || optional_token_count(usage, "cacheRead")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def cache_write_tokens(usage)
|
|
136
|
+
optional_token_count(
|
|
137
|
+
usage, "cache_creation_input_tokens", "cacheCreationInputTokens",
|
|
138
|
+
"cache_write_input_tokens", "cacheWriteInputTokens", "cacheWrite"
|
|
139
|
+
)
|
|
140
|
+
end
|
|
71
141
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
) +
|
|
77
|
-
token_count(
|
|
78
|
-
nested_hash(usage, "input_tokens_details"), "cached_tokens", "cachedTokens"
|
|
79
|
-
)
|
|
142
|
+
def complete_cached(cache_read, cache_write)
|
|
143
|
+
return nil if cache_read.nil? || cache_write.nil?
|
|
144
|
+
|
|
145
|
+
cache_read + cache_write
|
|
80
146
|
end
|
|
81
147
|
|
|
82
|
-
def
|
|
83
|
-
return
|
|
148
|
+
def inferred_input_cache_inclusion(provider, value)
|
|
149
|
+
return nil if value.nil?
|
|
150
|
+
return false if provider == :claude
|
|
151
|
+
return true if provider == :codex
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def inferred_reasoning_inclusion(provider, value)
|
|
157
|
+
return nil if value.nil?
|
|
158
|
+
|
|
159
|
+
provider == :codex ? true : nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def inclusion_or_inferred(hash, key, inferred)
|
|
163
|
+
return inferred unless hash.is_a?(Hash) && hash.key?(key)
|
|
164
|
+
|
|
165
|
+
value = hash[key]
|
|
166
|
+
value if value == true || value == false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def optional_token_count(hash, *keys)
|
|
170
|
+
return nil unless hash.is_a?(Hash)
|
|
84
171
|
|
|
85
172
|
key = keys.find { |candidate| hash.key?(candidate) }
|
|
86
|
-
key ? hash[key].to_i :
|
|
173
|
+
key ? hash[key].to_i : nil
|
|
87
174
|
end
|
|
88
175
|
|
|
89
176
|
def nested_hash(hash, key)
|
|
@@ -92,11 +179,6 @@ module AgentCliRuntime
|
|
|
92
179
|
end
|
|
93
180
|
|
|
94
181
|
def model_from(event)
|
|
95
|
-
model_usage = event["modelUsage"]
|
|
96
|
-
if model_usage.is_a?(Hash) && !model_usage.empty?
|
|
97
|
-
return model_usage.keys.first.to_s
|
|
98
|
-
end
|
|
99
|
-
|
|
100
182
|
candidates = [
|
|
101
183
|
event["model"],
|
|
102
184
|
event.dig("message", "model"),
|
|
@@ -104,7 +186,12 @@ module AgentCliRuntime
|
|
|
104
186
|
event.dig("response", "model"),
|
|
105
187
|
event.dig("item", "model")
|
|
106
188
|
]
|
|
107
|
-
candidates.find { |value| !value.to_s.empty? }
|
|
189
|
+
explicit = candidates.find { |value| !value.to_s.empty? }
|
|
190
|
+
return explicit.to_s if explicit
|
|
191
|
+
|
|
192
|
+
# Run totals may include helper models; their order is not agent identity.
|
|
193
|
+
model_usage = event["modelUsage"]
|
|
194
|
+
model_usage.keys.first.to_s if model_usage.is_a?(Hash) && model_usage.size == 1
|
|
108
195
|
end
|
|
109
196
|
|
|
110
197
|
def model_from_usage(usage)
|