harnex 0.8.0 → 0.9.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.
@@ -37,6 +37,16 @@ module Harnex
37
37
  nil
38
38
  end
39
39
 
40
+ # Whether the usage this adapter captures reports input_tokens
41
+ # INCLUSIVE of cached_tokens. Depends on the capture path, not just
42
+ # the provider: codex app-server JSON reports cached as a subset of
43
+ # input, while the codex TUI line the PTY adapter scrapes shows
44
+ # non-cached input with cached as a separate additive count. Feeds
45
+ # Pricing.compute (plan 33 Phase 2).
46
+ def usage_input_includes_cached?
47
+ false
48
+ end
49
+
40
50
  # Probes `<base_command.first> --version` with a short timeout and
41
51
  # memoizes the result for the adapter's lifetime. Returns nil when
42
52
  # the binary is missing, exits non-zero, or stalls past the timeout.
@@ -59,6 +59,7 @@ module Harnex
59
59
  @initial_prompt = extract_initial_prompt(extra_args)
60
60
  @client = nil
61
61
  @thread_id = nil
62
+ @current_model = nil
62
63
  @current_turn_id = nil
63
64
  @state = :disconnected
64
65
  @last_completed_at = nil
@@ -78,6 +79,21 @@ module Harnex
78
79
  true
79
80
  end
80
81
 
82
+ # thread/tokenUsage/updated totals report cachedInputTokens as a
83
+ # subset of inputTokens (verified against a live captured row:
84
+ # input + output == total exactly, cached < input).
85
+ def usage_input_includes_cached?
86
+ true
87
+ end
88
+
89
+ # Effective model reported by the app-server's thread/start or
90
+ # thread/resume response (schema-required field). Feeds
91
+ # Session#summary_model so price-table cost can resolve without the
92
+ # caller passing `--meta '{"model": ...}'`.
93
+ def current_model
94
+ @current_model
95
+ end
96
+
81
97
  def context_telemetry_supported?
82
98
  true
83
99
  end
@@ -202,6 +218,7 @@ module Harnex
202
218
  ensure_open!
203
219
  result = @client.request("thread/resume", { threadId: thread_id })
204
220
  @thread_id = thread_id
221
+ @current_model = extract_model(result) || @current_model
205
222
  @state = :prompt
206
223
  result
207
224
  end
@@ -279,6 +296,7 @@ module Harnex
279
296
 
280
297
  result = @client.request("thread/start", {})
281
298
  @thread_id = extract_thread_id(result)
299
+ @current_model = extract_model(result) || @current_model
282
300
  end
283
301
 
284
302
  def extract_thread_id(payload)
@@ -287,6 +305,13 @@ module Harnex
287
305
  payload.dig("thread", "id")
288
306
  end
289
307
 
308
+ def extract_model(payload)
309
+ return nil unless payload.is_a?(Hash)
310
+
311
+ model = payload["model"]
312
+ model.is_a?(String) && !model.empty? ? model : nil
313
+ end
314
+
290
315
  def extract_initial_prompt(extra_args)
291
316
  return nil unless extra_args.is_a?(Array)
292
317
 
@@ -7,7 +7,7 @@ module Harnex
7
7
 
8
8
  def self.usage
9
9
  <<~TEXT
10
- Usage: harnex doctor [--sweep]
10
+ Usage: harnex doctor [--sweep] [--prune [--dry-run]]
11
11
 
12
12
  Runs preflight checks for harnex's adapter dependencies.
13
13
  Currently verifies that Codex CLI is installed and at version
@@ -16,16 +16,21 @@ module Harnex
16
16
 
17
17
  Options:
18
18
  --sweep Include a read-only report of harnex/tmux session drift
19
+ --prune Apply bounded harnex events/output retention pruning
20
+ --dry-run Preview --prune candidates without deleting
19
21
  -h, --help Show this help
20
22
 
21
23
  Common patterns:
22
24
  harnex doctor
23
25
  harnex doctor --sweep
26
+ harnex doctor --prune --dry-run
27
+ harnex doctor --prune
24
28
  harnex doctor --help
25
29
 
26
30
  Gotchas:
27
31
  doctor validates local adapter prerequisites; it does not start sessions.
28
32
  --sweep is diagnostic only; it does not stop sessions or remove files.
33
+ --dry-run must be paired with --prune.
29
34
  Run it after installing or upgrading Codex CLI.
30
35
  TEXT
31
36
  end
@@ -34,21 +39,26 @@ module Harnex
34
39
  @argv = argv.dup
35
40
  @options = {
36
41
  sweep: false,
42
+ prune: false,
43
+ dry_run: false,
37
44
  help: false
38
45
  }
39
46
  end
40
47
 
41
48
  def run
42
49
  parser.parse!(@argv)
50
+ validate_options!
43
51
  if @options[:help]
44
52
  puts self.class.usage
45
53
  return 0
46
54
  end
47
55
 
48
56
  checks = [check_codex]
57
+ retention = retention_payload
49
58
  summary = {
50
- ok: checks.all? { |c| c[:ok] },
51
- checks: checks
59
+ ok: checks.all? { |c| c[:ok] } && retention.fetch(:ok, true),
60
+ checks: checks,
61
+ retention: retention
52
62
  }
53
63
  summary[:sweep] = sweep_payload if @options[:sweep]
54
64
  puts JSON.generate(summary)
@@ -59,12 +69,36 @@ module Harnex
59
69
 
60
70
  def parser
61
71
  @parser ||= OptionParser.new do |opts|
62
- opts.banner = "Usage: harnex doctor [--sweep]"
72
+ opts.banner = "Usage: harnex doctor [--sweep] [--prune [--dry-run]]"
63
73
  opts.on("--sweep", "Include read-only session drift diagnostics") { @options[:sweep] = true }
74
+ opts.on("--prune", "Apply retention pruning") { @options[:prune] = true }
75
+ opts.on("--dry-run", "Preview --prune candidates without deleting") { @options[:dry_run] = true }
64
76
  opts.on("-h", "--help", "Show help") { @options[:help] = true }
65
77
  end
66
78
  end
67
79
 
80
+ def validate_options!
81
+ return if @options[:help]
82
+ return unless @options[:dry_run] && !@options[:prune]
83
+
84
+ raise OptionParser::InvalidOption, "--dry-run requires --prune"
85
+ end
86
+
87
+ def retention_payload
88
+ repo_root = Harnex.resolve_repo_root(Dir.pwd)
89
+ if @options[:prune]
90
+ Harnex::Retention.prune(
91
+ repo_root: repo_root,
92
+ dry_run: @options[:dry_run],
93
+ force: true
94
+ )
95
+ else
96
+ Harnex::Retention.status(repo_root: repo_root)
97
+ end
98
+ rescue Harnex::Config::ConfigError => e
99
+ { ok: false, error: e.message }
100
+ end
101
+
68
102
  def check_codex
69
103
  result = { name: "codex", required: ">= #{MIN_CODEX_VERSION}" }
70
104
 
@@ -85,6 +85,7 @@ module Harnex
85
85
  # One row per dispatch: start rows completed by an end row are dropped
86
86
  # (the end row carries the outcome); uncompleted start rows surface as
87
87
  # running (pid alive on this host) or interrupted (no end row, pid gone).
88
+ # Rows in neither shape (pre-0.7.3 schemas) are skipped entirely.
88
89
  def derived_records
89
90
  raw = load_records
90
91
  ended = Set.new
@@ -97,10 +98,13 @@ module Harnex
97
98
  end
98
99
 
99
100
  raw.filter_map do |record|
100
- next record unless DispatchHistory.start_record?(record)
101
- next nil if start_completed?(record, ended)
101
+ if DispatchHistory.start_record?(record)
102
+ next nil if start_completed?(record, ended)
102
103
 
103
- derive_live_record(record)
104
+ derive_live_record(record)
105
+ elsif DispatchHistory.end_record?(record)
106
+ record
107
+ end
104
108
  end
105
109
  end
106
110
 
@@ -39,7 +39,7 @@ module Harnex
39
39
 
40
40
  # Attempt kinds that redo the parent's work; dispatching one while the
41
41
  # parent is still running duplicates work in the same checkout.
42
- LIVE_PARENT_GUARDED_KINDS = %w[retry fix superseding].freeze
42
+ LIVE_PARENT_GUARDED_KINDS = %w[retry fix fallback superseding].freeze
43
43
  VALUE_FLAGS = %w[
44
44
  --id --description --host --port --watch --watch-file --stall-after
45
45
  --max-resumes --preset --context --meta --summary-out --artifact-report
@@ -67,7 +67,7 @@ module Harnex
67
67
  --fast (codex only) Use Codex service_tier="fast".
68
68
  Default Codex runs force service_tier="flex".
69
69
  --meta JSON Attach parsed JSON metadata to the started event
70
- --summary-out PATH Append dispatch telemetry summary JSONL to PATH
70
+ --summary-out PATH Also mirror the dispatch end record JSONL to PATH
71
71
  --artifact-report PATH
72
72
  Worker-written harnex.artifact_report.v1 JSON sidecar to ingest at exit
73
73
  --validation-report PATH
@@ -87,12 +87,12 @@ module Harnex
87
87
  --model NAME Requested model metadata (also used for structured dispatch)
88
88
  --effort LEVEL Requested reasoning effort metadata (structured dispatch)
89
89
  --parent-dispatch-id ID
90
- Parent dispatch id for retry/fix/review joins
90
+ Parent dispatch id for retry/fix/review/fallback joins
91
91
  --parent-attempt-id ID
92
- Parent attempt id for retry/fix/review joins
92
+ Parent attempt id for retry/fix/review/fallback joins
93
93
  --attempt-kind KIND
94
- initial, retry, fix, review, or superseding (default: initial).
95
- retry requires --parent-dispatch-id so the
94
+ initial, retry, fix, review, fallback, or superseding (default: initial).
95
+ retry/fallback requires --parent-dispatch-id so the
96
96
  duplicate-dispatch guard can verify the parent
97
97
  --allow-live-parent
98
98
  Dispatch even though --parent-dispatch-id names a
@@ -203,6 +203,7 @@ module Harnex
203
203
  validate_required_attribution!
204
204
 
205
205
  repo_root = resolve_run_root(cli_name, child_args)
206
+ validate_repo_phase_policy!(repo_root)
206
207
  @options[:summary_out] = resolve_summary_out(repo_root)
207
208
  @options[:artifact_report] = resolve_artifact_report(repo_root)
208
209
  @options[:id] ||= Harnex.generate_id(repo_root)
@@ -371,7 +372,7 @@ module Harnex
371
372
  "Use a different --id or stop the existing session first."
372
373
  end
373
374
 
374
- # Duplicate-dispatch guard (issue #62): a retry/fix/superseding attempt
375
+ # Duplicate-dispatch guard (issue #62): a retry/fix/fallback/superseding attempt
375
376
  # whose parent dispatch is still running would duplicate work in the same
376
377
  # checkout. Explicit --parent-dispatch-id only — the implicit HARNEX_ID
377
378
  # lineage of a live spawner must not trip this.
@@ -380,8 +381,8 @@ module Harnex
380
381
  kind = metadata["attempt_kind"].to_s
381
382
  parent_id = metadata["parent_dispatch_id"].to_s.strip
382
383
 
383
- if kind == "retry" && parent_id.empty?
384
- raise "harnex run: --attempt-kind retry requires --parent-dispatch-id " \
384
+ if %w[retry fallback].include?(kind) && parent_id.empty?
385
+ raise "harnex run: --attempt-kind #{kind} requires --parent-dispatch-id " \
385
386
  "so the duplicate-dispatch guard can verify the parent is not still running."
386
387
  end
387
388
 
@@ -398,6 +399,30 @@ module Harnex
398
399
  "or pass --allow-live-parent for intentional parallelism."
399
400
  end
400
401
 
402
+ def validate_repo_phase_policy!(repo_root)
403
+ config = Harnex::Config.load_repo(repo_root)
404
+ Harnex::Config.retention_limits(config: config)
405
+ phase_config = config.phase
406
+ return unless phase_config
407
+
408
+ metadata = @options[:meta].is_a?(Hash) ? @options[:meta] : {}
409
+ phase = metadata["phase"].to_s
410
+ allowlist = phase_config.fetch("allowlist")
411
+ return if allowlist.include?(phase)
412
+
413
+ policy = phase_config.fetch("policy")
414
+ message = "harnex run: meta.phase #{phase.empty? ? '(none)' : phase.inspect} " \
415
+ "is not allowlisted by #{config.path} " \
416
+ "(allowed: #{allowlist.join(', ')}; policy: #{policy})"
417
+ if policy == "warn"
418
+ warn("harnex: warning: #{message}")
419
+ else
420
+ raise OptionParser::InvalidOption, message
421
+ end
422
+ rescue Harnex::Config::ConfigError => e
423
+ raise OptionParser::InvalidOption, "harnex run: invalid config #{e.message}"
424
+ end
425
+
401
426
  def build_session(adapter, repo_root)
402
427
  watch = Harnex.build_watch_config(@options[:watch], repo_root)
403
428
  Session.new(
@@ -842,9 +867,11 @@ module Harnex
842
867
  raise OptionParser::InvalidOption, "--meta must be valid JSON: #{e.message}"
843
868
  end
844
869
 
870
+ # Explicit-only mirror: the tracked dispatch stream is the canonical
871
+ # destination; --summary-out just duplicates the end record elsewhere.
845
872
  def resolve_summary_out(repo_root)
846
873
  configured = @options[:summary_out]
847
- return Harnex.default_summary_out_path(repo_root) if configured.nil?
874
+ return nil if configured.nil?
848
875
 
849
876
  File.expand_path(configured, repo_root)
850
877
  end
@@ -0,0 +1,166 @@
1
+ require "json"
2
+
3
+ module Harnex
4
+ module Config
5
+ CONFIG_RELATIVE_PATH = File.join(".harnex", "config.json").freeze
6
+ PHASE_POLICIES = %w[warn reject].freeze
7
+ RETENTION_DIRS = %w[events output].freeze
8
+ RETENTION_FIELDS = %w[max_age_days max_bytes].freeze
9
+ RETENTION_ENV = {
10
+ "events" => {
11
+ "max_age_days" => "HARNEX_EVENTS_MAX_AGE_DAYS",
12
+ "max_bytes" => "HARNEX_EVENTS_MAX_BYTES"
13
+ },
14
+ "output" => {
15
+ "max_age_days" => "HARNEX_OUTPUT_MAX_AGE_DAYS",
16
+ "max_bytes" => "HARNEX_OUTPUT_MAX_BYTES"
17
+ }
18
+ }.freeze
19
+ DEFAULT_RETENTION_LIMITS = RETENTION_DIRS.to_h do |dir|
20
+ [dir, { "max_age_days" => 45, "max_bytes" => 1_073_741_824 }]
21
+ end.freeze
22
+
23
+ ConfigError = Class.new(StandardError)
24
+
25
+ RepoConfig = Struct.new(:root, :path, :data, keyword_init: true) do
26
+ def present?
27
+ !path.nil? && File.file?(path)
28
+ end
29
+
30
+ def phase
31
+ data.is_a?(Hash) ? data["phase"] : nil
32
+ end
33
+
34
+ def retention
35
+ data.is_a?(Hash) ? data["retention"] : nil
36
+ end
37
+ end
38
+
39
+ module_function
40
+
41
+ def load_repo(start_path)
42
+ root = DispatchHistory.find_git_root(start_path)
43
+ return RepoConfig.new(root: nil, path: nil, data: {}) unless root
44
+
45
+ path = File.join(root, CONFIG_RELATIVE_PATH)
46
+ return RepoConfig.new(root: root, path: path, data: {}) unless File.file?(path)
47
+
48
+ parsed = JSON.parse(File.read(path))
49
+ validate_document!(parsed, path)
50
+ RepoConfig.new(root: root, path: path, data: parsed)
51
+ rescue JSON::ParserError => e
52
+ raise ConfigError, "#{path}: malformed JSON: #{e.message}"
53
+ end
54
+
55
+ def validate_document!(document, path)
56
+ unless document.is_a?(Hash)
57
+ raise ConfigError, "#{path}: config must be a JSON object"
58
+ end
59
+
60
+ validate_phase!(document["phase"], path) if document.key?("phase")
61
+ validate_retention!(document["retention"], path) if document.key?("retention")
62
+ end
63
+
64
+ def validate_phase!(phase, path)
65
+ unless phase.is_a?(Hash)
66
+ raise ConfigError, "#{path}: $.phase must be an object"
67
+ end
68
+
69
+ allowlist = phase["allowlist"]
70
+ unless allowlist.is_a?(Array)
71
+ raise ConfigError, "#{path}: $.phase.allowlist must be an array"
72
+ end
73
+
74
+ allowlist.each_with_index do |entry, index|
75
+ next if entry.is_a?(String) && !entry.strip.empty?
76
+
77
+ raise ConfigError,
78
+ "#{path}: $.phase.allowlist[#{index}] must be a non-empty string"
79
+ end
80
+
81
+ policy = phase["policy"]
82
+ unless PHASE_POLICIES.include?(policy)
83
+ raise ConfigError,
84
+ "#{path}: $.phase.policy must be one of #{PHASE_POLICIES.join(', ')}"
85
+ end
86
+ end
87
+
88
+ def retention_limits(start_path = Dir.pwd, env: ENV, config: nil)
89
+ config ||= load_repo(start_path)
90
+ limits = deep_dup_retention_defaults
91
+ retention = config.retention
92
+
93
+ if retention
94
+ RETENTION_DIRS.each do |dir|
95
+ next unless retention.key?(dir)
96
+
97
+ RETENTION_FIELDS.each do |field|
98
+ next unless retention.fetch(dir).key?(field)
99
+
100
+ limits.fetch(dir)[field] = parse_positive_integer!(
101
+ retention.fetch(dir).fetch(field),
102
+ "#{config.path}: $.retention.#{dir}.#{field}"
103
+ )
104
+ end
105
+ end
106
+ end
107
+
108
+ RETENTION_ENV.each do |dir, fields|
109
+ fields.each do |field, name|
110
+ next unless env.key?(name)
111
+
112
+ limits.fetch(dir)[field] = parse_positive_integer!(
113
+ env.fetch(name),
114
+ name
115
+ )
116
+ end
117
+ end
118
+
119
+ limits
120
+ end
121
+
122
+ def validate_retention!(retention, path)
123
+ unless retention.is_a?(Hash)
124
+ raise ConfigError, "#{path}: $.retention must be an object"
125
+ end
126
+
127
+ retention.each do |dir, value|
128
+ unless RETENTION_DIRS.include?(dir)
129
+ raise ConfigError,
130
+ "#{path}: $.retention.#{dir} is not supported (expected events or output)"
131
+ end
132
+ unless value.is_a?(Hash)
133
+ raise ConfigError, "#{path}: $.retention.#{dir} must be an object"
134
+ end
135
+
136
+ value.each do |field, limit|
137
+ unless RETENTION_FIELDS.include?(field)
138
+ raise ConfigError,
139
+ "#{path}: $.retention.#{dir}.#{field} is not supported"
140
+ end
141
+
142
+ parse_positive_integer!(
143
+ limit,
144
+ "#{path}: $.retention.#{dir}.#{field}"
145
+ )
146
+ end
147
+ end
148
+ end
149
+
150
+ def parse_positive_integer!(value, label)
151
+ text = value.to_s.strip
152
+ unless text.match?(/\A[0-9]+\z/)
153
+ raise ConfigError, "#{label} must be a positive integer"
154
+ end
155
+
156
+ integer = Integer(text)
157
+ raise ConfigError, "#{label} must be a positive integer" if integer <= 0
158
+
159
+ integer
160
+ end
161
+
162
+ def deep_dup_retention_defaults
163
+ DEFAULT_RETENTION_LIMITS.transform_values(&:dup)
164
+ end
165
+ end
166
+ end
data/lib/harnex/core.rb CHANGED
@@ -114,13 +114,6 @@ module Harnex
114
114
  text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
115
115
  end
116
116
 
117
- def default_summary_out_path(repo_root)
118
- root = repo_root.to_s
119
- return nil if root.empty?
120
-
121
- File.join(root, ".harnex", "dispatch.jsonl")
122
- end
123
-
124
117
  def git_capture_start(repo_root)
125
118
  sha = git_output(repo_root, "rev-parse", "HEAD")
126
119
  branch = git_output(repo_root, "rev-parse", "--abbrev-ref", "HEAD")
@@ -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: 1,
147
+ schema_version: SCHEMA_VERSION,
142
148
  record_type: "dispatch_start",
143
149
  id: session.id,
144
150
  session_id: session.session_id,
@@ -155,11 +161,15 @@ module Harnex
155
161
  }
156
162
  end
157
163
 
164
+ # The v2 end row: the thin envelope merged with the rich summary
165
+ # sections. The envelope carries no raw meta passthrough — the summary's
166
+ # meta section (a superset with provenance) rides in its place; top-level
167
+ # tier stays for the history renderer.
158
168
  def build_record(session)
159
169
  ended_at = session.ended_at || Time.now
160
170
  status, terminal_event = classify(session)
161
171
  {
162
- schema_version: 1,
172
+ schema_version: SCHEMA_VERSION,
163
173
  record_type: "dispatch_end",
164
174
  id: session.id,
165
175
  session_id: session.session_id,
@@ -172,11 +182,10 @@ module Harnex
172
182
  terminal_event: terminal_event,
173
183
  commit_sha: commit_sha(session.git_start, session.git_end),
174
184
  tier: session.__send__(:meta_hash)["tier"],
175
- meta: session.__send__(:meta_hash),
176
185
  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