harnex 0.8.0 → 0.10.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/CHANGELOG.md +188 -0
- data/GUIDE.md +17 -10
- data/README.md +61 -54
- data/TECHNICAL.md +38 -15
- data/docs/codex-appserver.md +175 -0
- data/docs/configuration.md +118 -0
- data/docs/dispatch-telemetry.md +502 -0
- data/docs/events.md +132 -0
- data/guides/01_dispatch.md +31 -28
- data/guides/04_monitoring.md +10 -9
- data/guides/05_naming.md +18 -8
- data/lib/harnex/adapters/base.rb +10 -0
- data/lib/harnex/adapters/codex_appserver.rb +25 -0
- data/lib/harnex/artifact_report.rb +455 -6
- data/lib/harnex/cli.rb +3 -3
- data/lib/harnex/commands/artifact_report.rb +8 -7
- data/lib/harnex/commands/doctor.rb +38 -4
- data/lib/harnex/commands/history.rb +7 -3
- data/lib/harnex/commands/run.rb +55 -43
- data/lib/harnex/commands/status.rb +0 -2
- data/lib/harnex/commands/wait.rb +0 -1
- data/lib/harnex/config.rb +170 -0
- data/lib/harnex/core.rb +200 -21
- data/lib/harnex/dispatch_history.rb +16 -7
- data/lib/harnex/pricing.rb +135 -0
- data/lib/harnex/retention.rb +320 -0
- data/lib/harnex/runtime/session.rb +451 -166
- data/lib/harnex/terminal_status.rb +18 -21
- data/lib/harnex/version.rb +2 -2
- data/lib/harnex.rb +3 -0
- metadata +9 -2
data/lib/harnex/core.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "open3"
|
|
3
4
|
require "optparse"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
require "set"
|
|
@@ -19,6 +20,8 @@ module Harnex
|
|
|
19
20
|
DEFAULT_PORT_SPAN = Integer(env_value("HARNEX_PORT_SPAN", default: "4000"))
|
|
20
21
|
DEFAULT_ID = "default"
|
|
21
22
|
WATCH_DEBOUNCE_SECONDS = 1.0
|
|
23
|
+
GIT_FINGERPRINT_FULL_BYTES = 4 * 1024 * 1024
|
|
24
|
+
GIT_FINGERPRINT_SAMPLE_BYTES = 64 * 1024
|
|
22
25
|
STATE_DIR = File.expand_path(env_value("HARNEX_STATE_DIR", default: "~/.local/state/harnex"))
|
|
23
26
|
SESSIONS_DIR = File.join(STATE_DIR, "sessions")
|
|
24
27
|
WatchConfig = Struct.new(:absolute_path, :display_path, :hook_message, :debounce_seconds, keyword_init: true)
|
|
@@ -114,49 +117,204 @@ module Harnex
|
|
|
114
117
|
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
115
118
|
end
|
|
116
119
|
|
|
117
|
-
def
|
|
118
|
-
root = repo_root.to_s
|
|
119
|
-
return nil if root.empty?
|
|
120
|
-
|
|
121
|
-
File.join(root, ".harnex", "dispatch.jsonl")
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def git_capture_start(repo_root)
|
|
120
|
+
def git_capture_start(repo_root, exclude_paths: [])
|
|
125
121
|
sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
126
122
|
branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
127
123
|
return {} if sha.empty? || branch.empty?
|
|
128
124
|
|
|
125
|
+
excluded_paths = normalize_git_excluded_paths(exclude_paths)
|
|
126
|
+
worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths)
|
|
129
127
|
{
|
|
130
128
|
sha: sha,
|
|
131
|
-
branch: branch
|
|
129
|
+
branch: branch,
|
|
130
|
+
worktree: worktree,
|
|
131
|
+
excluded_paths: excluded_paths
|
|
132
132
|
}
|
|
133
133
|
rescue StandardError
|
|
134
134
|
{}
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
# Captures committed and in-progress work relative to the state observed at
|
|
138
|
+
# session start. Passing the full git_capture_start hash avoids treating an
|
|
139
|
+
# unchanged pre-existing dirty tree as worker activity; a SHA string remains
|
|
140
|
+
# supported for older callers.
|
|
141
|
+
def git_capture_end(repo_root, start, exclude_paths: nil)
|
|
142
|
+
start_capture = start.is_a?(Hash) ? start : {}
|
|
143
|
+
start_sha = (start_capture[:sha] || start_capture["sha"] || start).to_s.strip
|
|
139
144
|
return {} if start_sha.empty?
|
|
140
145
|
|
|
146
|
+
excluded_paths ||= start_capture[:excluded_paths] || start_capture["excluded_paths"] || []
|
|
147
|
+
excluded_paths = normalize_git_excluded_paths(excluded_paths)
|
|
141
148
|
end_sha = git_output(repo_root, "rev-parse", "HEAD")
|
|
142
149
|
range = "#{start_sha}..#{end_sha}"
|
|
143
|
-
shortstat = git_output(repo_root, "diff", "--shortstat", range)
|
|
144
|
-
changed_paths = git_output(repo_root, "diff", "--name-only", range).lines.map(&:strip).reject(&:empty?).first(200)
|
|
145
150
|
commits = Integer(git_output(repo_root, "rev-list", "--count", range))
|
|
146
|
-
|
|
151
|
+
committed_paths = git_null_paths(repo_root, "diff", "--name-only", "-z", range)
|
|
152
|
+
.reject { |path| excluded_paths.include?(path) }
|
|
153
|
+
committed_stats = if committed_paths.empty?
|
|
154
|
+
{ loc_added: 0, loc_removed: 0 }
|
|
155
|
+
else
|
|
156
|
+
parse_git_numstat(
|
|
157
|
+
git_output(repo_root, "diff", "--numstat", range, "--", *committed_paths.first(200))
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
start_worktree = start_capture[:worktree] || start_capture["worktree"]
|
|
162
|
+
end_worktree = git_worktree_snapshot(repo_root, exclude_paths: excluded_paths)
|
|
163
|
+
worktree_paths = changed_worktree_paths(start_worktree, end_worktree)
|
|
164
|
+
tracked_worktree_paths = worktree_paths.select do |path|
|
|
165
|
+
entry = end_worktree.fetch(:entries).fetch(path, nil)
|
|
166
|
+
entry && entry.fetch(:tracked)
|
|
167
|
+
end
|
|
168
|
+
worktree_stats = if tracked_worktree_paths.empty?
|
|
169
|
+
{ loc_added: 0, loc_removed: 0 }
|
|
170
|
+
else
|
|
171
|
+
parse_git_numstat(
|
|
172
|
+
git_output(repo_root, "diff", "--numstat", end_sha, "--", *tracked_worktree_paths.first(200))
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
untracked_stats = untracked_worktree_stats(start_worktree, end_worktree, worktree_paths)
|
|
176
|
+
all_paths = (committed_paths + worktree_paths).uniq
|
|
147
177
|
|
|
148
178
|
{
|
|
149
179
|
sha: end_sha,
|
|
150
|
-
loc_added:
|
|
151
|
-
loc_removed:
|
|
152
|
-
files_changed:
|
|
153
|
-
changed_paths:
|
|
154
|
-
commits: commits
|
|
180
|
+
loc_added: committed_stats.fetch(:loc_added) + worktree_stats.fetch(:loc_added) + untracked_stats.fetch(:loc_added),
|
|
181
|
+
loc_removed: committed_stats.fetch(:loc_removed) + worktree_stats.fetch(:loc_removed) + untracked_stats.fetch(:loc_removed),
|
|
182
|
+
files_changed: all_paths.length,
|
|
183
|
+
changed_paths: all_paths.first(200),
|
|
184
|
+
commits: commits,
|
|
185
|
+
start_dirty: start_worktree ? !start_worktree.fetch(:entries).empty? : nil,
|
|
186
|
+
end_dirty: !end_worktree.fetch(:entries).empty?,
|
|
187
|
+
worktree_changed: !worktree_paths.empty?
|
|
155
188
|
}
|
|
156
189
|
rescue StandardError
|
|
157
190
|
{}
|
|
158
191
|
end
|
|
159
192
|
|
|
193
|
+
def git_worktree_snapshot(repo_root, exclude_paths: [])
|
|
194
|
+
excluded_paths = normalize_git_excluded_paths(exclude_paths)
|
|
195
|
+
tracked_paths = (
|
|
196
|
+
git_null_paths(repo_root, "diff", "--name-only", "-z") +
|
|
197
|
+
git_null_paths(repo_root, "diff", "--cached", "--name-only", "-z")
|
|
198
|
+
).uniq.reject { |path| excluded_paths.include?(path) }
|
|
199
|
+
untracked_paths = git_null_paths(repo_root, "ls-files", "--others", "--exclude-standard", "-z")
|
|
200
|
+
.reject { |path| excluded_paths.include?(path) }
|
|
201
|
+
entries = {}
|
|
202
|
+
tracked_paths.each do |path|
|
|
203
|
+
entries[path] = git_worktree_entry(repo_root, path, tracked: true)
|
|
204
|
+
end
|
|
205
|
+
untracked_paths.each do |path|
|
|
206
|
+
entries[path] = git_worktree_entry(repo_root, path, tracked: false)
|
|
207
|
+
end
|
|
208
|
+
{ entries: entries }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def normalize_git_excluded_paths(paths)
|
|
212
|
+
Array(paths).filter_map do |path|
|
|
213
|
+
text = path.to_s.sub(%r{\A\./}, "")
|
|
214
|
+
text unless text.empty?
|
|
215
|
+
end.uniq
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def git_worktree_entry(repo_root, relative_path, tracked:)
|
|
219
|
+
path = File.join(repo_root.to_s, relative_path)
|
|
220
|
+
stat = File.lstat(path)
|
|
221
|
+
if stat.symlink?
|
|
222
|
+
content = File.readlink(path)
|
|
223
|
+
return {
|
|
224
|
+
tracked: tracked,
|
|
225
|
+
fingerprint: "symlink:#{Digest::SHA256.hexdigest(content)}",
|
|
226
|
+
lines: 0,
|
|
227
|
+
binary: false
|
|
228
|
+
}
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
return { tracked: tracked, fingerprint: "missing", lines: 0, binary: false } unless stat.file?
|
|
232
|
+
|
|
233
|
+
if stat.size > GIT_FINGERPRINT_FULL_BYTES
|
|
234
|
+
first = File.binread(path, GIT_FINGERPRINT_SAMPLE_BYTES)
|
|
235
|
+
last = File.open(path, "rb") do |file|
|
|
236
|
+
file.seek(-[stat.size, GIT_FINGERPRINT_SAMPLE_BYTES].min, IO::SEEK_END)
|
|
237
|
+
file.read(GIT_FINGERPRINT_SAMPLE_BYTES).to_s
|
|
238
|
+
end
|
|
239
|
+
digest = Digest::SHA256.new
|
|
240
|
+
digest.update("#{stat.mode}:#{stat.size}:#{(stat.mtime.to_r * 1_000_000_000).to_i}")
|
|
241
|
+
digest.update(first)
|
|
242
|
+
digest.update(last)
|
|
243
|
+
return {
|
|
244
|
+
tracked: tracked,
|
|
245
|
+
fingerprint: "large:#{digest.hexdigest}",
|
|
246
|
+
lines: 0,
|
|
247
|
+
binary: first.include?("\0") || last.include?("\0")
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
digest = Digest::SHA256.new
|
|
252
|
+
lines = 0
|
|
253
|
+
binary = false
|
|
254
|
+
last_byte = nil
|
|
255
|
+
File.open(path, "rb") do |file|
|
|
256
|
+
buffer = +""
|
|
257
|
+
while file.read(16 * 1024, buffer)
|
|
258
|
+
digest.update(buffer)
|
|
259
|
+
binary ||= buffer.include?("\0")
|
|
260
|
+
lines += buffer.count("\n")
|
|
261
|
+
last_byte = buffer.byteslice(-1)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
lines += 1 if stat.size.positive? && last_byte != "\n"
|
|
265
|
+
{
|
|
266
|
+
tracked: tracked,
|
|
267
|
+
fingerprint: "file:#{stat.mode}:#{digest.hexdigest}",
|
|
268
|
+
lines: binary ? 0 : lines,
|
|
269
|
+
binary: binary
|
|
270
|
+
}
|
|
271
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
|
|
272
|
+
{ tracked: tracked, fingerprint: "missing", lines: 0, binary: false }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def changed_worktree_paths(start_worktree, end_worktree)
|
|
276
|
+
end_entries = end_worktree.fetch(:entries)
|
|
277
|
+
return end_entries.keys.sort unless start_worktree.is_a?(Hash)
|
|
278
|
+
|
|
279
|
+
start_entries = start_worktree[:entries] || start_worktree["entries"] || {}
|
|
280
|
+
(start_entries.keys + end_entries.keys).uniq.select do |path|
|
|
281
|
+
start_entries[path] != end_entries[path]
|
|
282
|
+
end.sort
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def untracked_worktree_stats(start_worktree, end_worktree, changed_paths)
|
|
286
|
+
start_entries = if start_worktree.is_a?(Hash)
|
|
287
|
+
start_worktree[:entries] || start_worktree["entries"] || {}
|
|
288
|
+
else
|
|
289
|
+
{}
|
|
290
|
+
end
|
|
291
|
+
end_entries = end_worktree.fetch(:entries)
|
|
292
|
+
changed_paths.each_with_object({ loc_added: 0, loc_removed: 0 }) do |path, stats|
|
|
293
|
+
before = start_entries[path]
|
|
294
|
+
after = end_entries[path]
|
|
295
|
+
next if after && after.fetch(:tracked)
|
|
296
|
+
next if before && before.fetch(:tracked)
|
|
297
|
+
|
|
298
|
+
stats[:loc_removed] += before.fetch(:lines) if before
|
|
299
|
+
stats[:loc_added] += after.fetch(:lines) if after
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def git_null_paths(repo_root, *args)
|
|
304
|
+
output, _stderr, status = Open3.capture3("git", "-C", repo_root.to_s, *args)
|
|
305
|
+
raise "git #{args.join(' ')} failed" unless status.success?
|
|
306
|
+
|
|
307
|
+
output.split("\0").reject(&:empty?)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def parse_git_numstat(text)
|
|
311
|
+
text.to_s.lines.each_with_object({ loc_added: 0, loc_removed: 0 }) do |line, stats|
|
|
312
|
+
added, removed, = line.split("\t", 3)
|
|
313
|
+
stats[:loc_added] += Integer(added, exception: false).to_i
|
|
314
|
+
stats[:loc_removed] += Integer(removed, exception: false).to_i
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
160
318
|
# Canonical path resolution shared by every registry/exit/events writer and
|
|
161
319
|
# reader. realpath collapses symlinked prefixes so a session registered from
|
|
162
320
|
# a symlinked cwd stays visible to checkers resolving the physical path.
|
|
@@ -292,6 +450,12 @@ module Harnex
|
|
|
292
450
|
false
|
|
293
451
|
rescue Errno::EPERM
|
|
294
452
|
true
|
|
453
|
+
rescue ArgumentError, TypeError
|
|
454
|
+
# A non-numeric pid means a truncated, hand-edited, or foreign-written
|
|
455
|
+
# registry file. Report it dead so active_sessions prunes it, the same
|
|
456
|
+
# self-healing it already applies to unparseable JSON. Raising here would
|
|
457
|
+
# take down every command that scans sessions -- status, send, pane.
|
|
458
|
+
false
|
|
295
459
|
end
|
|
296
460
|
|
|
297
461
|
def read_registry(repo_root, id = DEFAULT_ID, cli: nil)
|
|
@@ -359,10 +523,25 @@ module Harnex
|
|
|
359
523
|
nil
|
|
360
524
|
end
|
|
361
525
|
|
|
362
|
-
|
|
363
|
-
|
|
526
|
+
# Atomically replace a JSON state file.
|
|
527
|
+
#
|
|
528
|
+
# The temp name must be unique per write, not just per process. Several
|
|
529
|
+
# threads in one session write the same registry path — the startup persist,
|
|
530
|
+
# the inbox delivery thread, and one thread per API client — and a shared
|
|
531
|
+
# temp name lets one thread rename the file another is still writing, which
|
|
532
|
+
# surfaces as ENOENT on the loser's rename. mkdir_p covers a state directory
|
|
533
|
+
# that was reaped (tmpfs, operator cleanup) while the process was live.
|
|
534
|
+
def atomic_write_json(path, payload)
|
|
535
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
536
|
+
tmp = "#{path}.tmp.#{Process.pid}.#{SecureRandom.hex(6)}"
|
|
364
537
|
File.write(tmp, JSON.pretty_generate(payload))
|
|
365
538
|
File.rename(tmp, path)
|
|
539
|
+
ensure
|
|
540
|
+
FileUtils.rm_f(tmp) if tmp
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def write_registry(path, payload)
|
|
544
|
+
atomic_write_json(path, payload)
|
|
366
545
|
end
|
|
367
546
|
|
|
368
547
|
def allocate_port(repo_root, id, requested_port = nil, host: DEFAULT_HOST)
|
|
@@ -9,6 +9,12 @@ module Harnex
|
|
|
9
9
|
|
|
10
10
|
MAX_REPO_WALK_LEVELS = 10
|
|
11
11
|
|
|
12
|
+
# v2 marks the unified era: one rich dispatch_end row per dispatch
|
|
13
|
+
# carrying both the thin envelope and the summary sections. Readers
|
|
14
|
+
# key on record_type, not this stamp; legacy clauses keep accepting
|
|
15
|
+
# v1 and envelope-less rows mixed in the same file.
|
|
16
|
+
SCHEMA_VERSION = 2
|
|
17
|
+
|
|
12
18
|
def global_path
|
|
13
19
|
File.join(STATE_DIR, "dispatch.jsonl")
|
|
14
20
|
end
|
|
@@ -138,7 +144,7 @@ module Harnex
|
|
|
138
144
|
# trace; the dispatch_end row written in finalize_session! completes it.
|
|
139
145
|
def build_start_record(session)
|
|
140
146
|
{
|
|
141
|
-
schema_version:
|
|
147
|
+
schema_version: SCHEMA_VERSION,
|
|
142
148
|
record_type: "dispatch_start",
|
|
143
149
|
id: session.id,
|
|
144
150
|
session_id: session.session_id,
|
|
@@ -150,16 +156,21 @@ module Harnex
|
|
|
150
156
|
repo_root: session.repo_root,
|
|
151
157
|
tier: session.__send__(:meta_hash)["tier"],
|
|
152
158
|
meta: session.__send__(:meta_hash),
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
events_log_path: session.events_log_path,
|
|
160
|
+
artifact_report_path: session.artifact_report_path,
|
|
161
|
+
artifact_claims_path: session.artifact_claims_path
|
|
155
162
|
}
|
|
156
163
|
end
|
|
157
164
|
|
|
165
|
+
# The v2 end row: the thin envelope merged with the rich summary
|
|
166
|
+
# sections. The envelope carries no raw meta passthrough — the summary's
|
|
167
|
+
# meta section (a superset with provenance) rides in its place; top-level
|
|
168
|
+
# tier stays for the history renderer.
|
|
158
169
|
def build_record(session)
|
|
159
170
|
ended_at = session.ended_at || Time.now
|
|
160
171
|
status, terminal_event = classify(session)
|
|
161
172
|
{
|
|
162
|
-
schema_version:
|
|
173
|
+
schema_version: SCHEMA_VERSION,
|
|
163
174
|
record_type: "dispatch_end",
|
|
164
175
|
id: session.id,
|
|
165
176
|
session_id: session.session_id,
|
|
@@ -172,11 +183,9 @@ module Harnex
|
|
|
172
183
|
terminal_event: terminal_event,
|
|
173
184
|
commit_sha: commit_sha(session.git_start, session.git_end),
|
|
174
185
|
tier: session.__send__(:meta_hash)["tier"],
|
|
175
|
-
meta: session.__send__(:meta_hash),
|
|
176
|
-
summary_out_path: session.summary_out,
|
|
177
186
|
events_log_path: session.events_log_path,
|
|
178
187
|
tmux_state: tmux_state(session.__send__(:summary_tmux_session))
|
|
179
|
-
}
|
|
188
|
+
}.merge(session.__send__(:build_summary_record))
|
|
180
189
|
end
|
|
181
190
|
|
|
182
191
|
def classify(session)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module Harnex
|
|
2
|
+
# Static price table for computing usage.cost_usd when an adapter reports
|
|
3
|
+
# tokens but no cost (plan 33 Phase 2, locked decision 6). Cost is computed,
|
|
4
|
+
# never guessed: an unknown provider/model/service tier or a missing token
|
|
5
|
+
# component leaves cost_usd null.
|
|
6
|
+
#
|
|
7
|
+
# ## Update procedure
|
|
8
|
+
#
|
|
9
|
+
# Rates are USD per 1M tokens, copied by hand from the provider pricing
|
|
10
|
+
# pages at the URLs below. To update:
|
|
11
|
+
#
|
|
12
|
+
# 1. Fetch the provider's pricing page and read the per-1M-token rates for
|
|
13
|
+
# input, cached input (cache read), and output.
|
|
14
|
+
# 2. Replace the entry's rates and set `as_of` to the date you read them.
|
|
15
|
+
# Add new models as new entries; never delete an entry solely because a
|
|
16
|
+
# model left the pricing page (old rows in dispatch.jsonl were priced
|
|
17
|
+
# against it — the `as_of` on each row records which table vintage
|
|
18
|
+
# applied).
|
|
19
|
+
# 3. Update the expected costs in test/harnex/pricing_test.rb — rate
|
|
20
|
+
# changes are meant to be conscious, test-visible edits.
|
|
21
|
+
# 4. Never backfill: rows written before a table change keep the cost they
|
|
22
|
+
# were written with.
|
|
23
|
+
#
|
|
24
|
+
# Models whose pricing varies by service tier use a nested `service_tiers`
|
|
25
|
+
# table. For those models a nil or unknown service tier is unpriceable. A
|
|
26
|
+
# `max_context_tokens_exclusive` entry also requires an observed context
|
|
27
|
+
# high-water below that boundary; Harnex leaves cost null when context is
|
|
28
|
+
# missing or entered a differently-priced long-context tier. Do not infer
|
|
29
|
+
# standard/flex/fast or context tier outside the recorded measurements.
|
|
30
|
+
# OpenAI documents `priority` as the fast alias for gpt-5.5 short-context
|
|
31
|
+
# pricing; Harnex maps that alias only where the source supports it.
|
|
32
|
+
#
|
|
33
|
+
# Known scheduled change: Anthropic's claude-sonnet-5 entry below carries
|
|
34
|
+
# introductory pricing ($2/$10) that ends 2026-08-31; standard pricing is
|
|
35
|
+
# $3/$15 (cache read $0.30) from 2026-09-01. Refresh the entry then.
|
|
36
|
+
#
|
|
37
|
+
# ## Token semantics (verified 2026-08-02, plan 33 Phase 2)
|
|
38
|
+
#
|
|
39
|
+
# What input_tokens contains depends on the CAPTURE PATH, not just the
|
|
40
|
+
# provider, so the caller passes `input_includes_cached:` (sourced from
|
|
41
|
+
# `Adapters::Base#usage_input_includes_cached?`):
|
|
42
|
+
#
|
|
43
|
+
# - codex app-server (input_includes_cached: true): the JSON
|
|
44
|
+
# TokenUsageBreakdown reports cachedInputTokens as a subset of
|
|
45
|
+
# inputTokens and reasoningOutputTokens as a subset of outputTokens
|
|
46
|
+
# (verified against test/fixtures/codex_schema/v2 and a live captured
|
|
47
|
+
# row: input 3,084,697 + output 17,705 == total 3,102,402 exactly).
|
|
48
|
+
# Billable input = input - cached.
|
|
49
|
+
# - codex PTY (false): the scraped TUI line `input=X (+ Y cached)` shows
|
|
50
|
+
# NON-cached input with cached as a separate additive count (fixture:
|
|
51
|
+
# total 106,867 == input 104,158 + output 2,709, cached 250,880 > input).
|
|
52
|
+
# Billable input = input; cached prices additively at the cached rate.
|
|
53
|
+
# - anthropic (false): the Messages API reports input_tokens EXCLUSIVE of
|
|
54
|
+
# cache reads (cache_read_input_tokens is a separate field). Cache
|
|
55
|
+
# WRITES (cache_creation_input_tokens, billed at 1.25x input) have no
|
|
56
|
+
# harnex usage field yet — the Phase 3 Claude usage producer must either
|
|
57
|
+
# fold them into input_tokens or extend this formula before pricing rows
|
|
58
|
+
# that include cache writes.
|
|
59
|
+
#
|
|
60
|
+
# Reasoning tokens are never priced separately on any path — they are
|
|
61
|
+
# already inside output_tokens.
|
|
62
|
+
module Pricing
|
|
63
|
+
PRICES = {
|
|
64
|
+
"openai" => {
|
|
65
|
+
# Source: https://developers.openai.com/api/docs/pricing
|
|
66
|
+
"gpt-5.3-codex" => { input: 1.75, cached_input: 0.175, output: 14.00, as_of: "2026-08-02" },
|
|
67
|
+
# Short-context service-tier rates read 2026-08-03. Long-context
|
|
68
|
+
# rates are deliberately absent until exact source pricing is known.
|
|
69
|
+
"gpt-5.5" => {
|
|
70
|
+
max_context_tokens_exclusive: 272_000,
|
|
71
|
+
service_tier_aliases: { "priority" => "fast" }.freeze,
|
|
72
|
+
service_tiers: {
|
|
73
|
+
"standard" => { input: 5.00, cached_input: 0.50, output: 30.00, as_of: "2026-08-03" },
|
|
74
|
+
"flex" => { input: 2.50, cached_input: 0.25, output: 15.00, as_of: "2026-08-03" },
|
|
75
|
+
"fast" => { input: 12.50, cached_input: 1.25, output: 75.00, as_of: "2026-08-03" }
|
|
76
|
+
}.freeze
|
|
77
|
+
}.freeze,
|
|
78
|
+
"gpt-5.2" => { input: 1.75, cached_input: 0.175, output: 14.00, as_of: "2026-08-02" },
|
|
79
|
+
"gpt-5.1" => { input: 1.25, cached_input: 0.125, output: 10.00, as_of: "2026-08-02" },
|
|
80
|
+
"gpt-5" => { input: 1.25, cached_input: 0.125, output: 10.00, as_of: "2026-08-02" },
|
|
81
|
+
"gpt-5-mini" => { input: 0.25, cached_input: 0.025, output: 2.00, as_of: "2026-08-02" }
|
|
82
|
+
}.freeze,
|
|
83
|
+
"anthropic" => {
|
|
84
|
+
# Source: https://claude.com/platform/api (cache read = 0.1x input)
|
|
85
|
+
"claude-fable-5" => { input: 10.00, cached_input: 1.00, output: 50.00, as_of: "2026-08-02" },
|
|
86
|
+
"claude-opus-5" => { input: 5.00, cached_input: 0.50, output: 25.00, as_of: "2026-08-02" },
|
|
87
|
+
"claude-sonnet-5" => { input: 2.00, cached_input: 0.20, output: 10.00, as_of: "2026-08-02" },
|
|
88
|
+
"claude-haiku-4-5" => { input: 1.00, cached_input: 0.10, output: 5.00, as_of: "2026-08-02" }
|
|
89
|
+
}.freeze
|
|
90
|
+
}.freeze
|
|
91
|
+
|
|
92
|
+
module_function
|
|
93
|
+
|
|
94
|
+
# Returns { cost_usd:, as_of: } or nil when the cost cannot be computed
|
|
95
|
+
# (unknown provider/model/service tier, or input/output token counts missing).
|
|
96
|
+
def compute(provider:, model:, input_tokens:, output_tokens:, cached_tokens: nil,
|
|
97
|
+
service_tier: nil, context_tokens: nil,
|
|
98
|
+
input_includes_cached: false)
|
|
99
|
+
model_entry = PRICES.dig(provider.to_s, model.to_s)
|
|
100
|
+
return nil unless model_entry
|
|
101
|
+
return nil unless context_priceable?(model_entry, context_tokens)
|
|
102
|
+
|
|
103
|
+
entry = rates_for_service_tier(model_entry, service_tier)
|
|
104
|
+
return nil unless entry
|
|
105
|
+
return nil unless input_tokens.is_a?(Numeric) && output_tokens.is_a?(Numeric)
|
|
106
|
+
|
|
107
|
+
cached = cached_tokens.is_a?(Numeric) ? cached_tokens : 0
|
|
108
|
+
billable_input = input_includes_cached ? input_tokens - cached : input_tokens
|
|
109
|
+
billable_input = 0 if billable_input.negative?
|
|
110
|
+
|
|
111
|
+
cost = (billable_input * entry[:input] +
|
|
112
|
+
cached * entry[:cached_input] +
|
|
113
|
+
output_tokens * entry[:output]) / 1_000_000.0
|
|
114
|
+
{ cost_usd: cost.round(6), as_of: entry[:as_of] }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def context_priceable?(entry, context_tokens)
|
|
118
|
+
limit = entry[:max_context_tokens_exclusive]
|
|
119
|
+
return true unless limit
|
|
120
|
+
|
|
121
|
+
context_tokens.is_a?(Numeric) && context_tokens >= 0 && context_tokens < limit
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def rates_for_service_tier(entry, service_tier)
|
|
125
|
+
tiers = entry[:service_tiers]
|
|
126
|
+
return entry unless tiers
|
|
127
|
+
|
|
128
|
+
tier = service_tier.to_s
|
|
129
|
+
return nil if tier.empty?
|
|
130
|
+
|
|
131
|
+
aliases = entry[:service_tier_aliases] || {}
|
|
132
|
+
tiers[aliases.fetch(tier, tier)]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|