harnex 0.7.14 → 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.
@@ -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
@@ -0,0 +1,311 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "set"
4
+ require "time"
5
+
6
+ module Harnex
7
+ module Retention
8
+ DIRS = {
9
+ "events" => "events",
10
+ "output" => "output"
11
+ }.freeze
12
+ THROTTLE_SECONDS = 3600
13
+ MAX_REPORTED_PATHS = 100
14
+ METADATA_PATH = File.join(STATE_DIR, "retention.json").freeze
15
+ LOCK_PATH = File.join(STATE_DIR, "retention.lock").freeze
16
+
17
+ module_function
18
+
19
+ def status(repo_root:, env: ENV, now: Time.now)
20
+ limits = Config.retention_limits(repo_root, env: env)
21
+ {
22
+ ok: true,
23
+ checked_at: now.utc.iso8601,
24
+ config: limits,
25
+ directories: DIRS.to_h do |key, dirname|
26
+ files = inventory(File.join(STATE_DIR, dirname))
27
+ [key, {
28
+ path: File.join(STATE_DIR, dirname),
29
+ count: files.length,
30
+ bytes: files.sum { |file| file.fetch(:bytes) },
31
+ max_age_days: limits.fetch(key).fetch("max_age_days"),
32
+ max_bytes: limits.fetch(key).fetch("max_bytes")
33
+ }]
34
+ end,
35
+ last_prune: last_prune_metadata
36
+ }
37
+ end
38
+
39
+ def auto_prune(repo_root:, current_paths: [], env: ENV, now: Time.now)
40
+ prune(
41
+ repo_root: repo_root,
42
+ current_paths: current_paths,
43
+ env: env,
44
+ now: now,
45
+ dry_run: false,
46
+ force: false
47
+ )
48
+ rescue Config::ConfigError
49
+ raise
50
+ rescue StandardError => e
51
+ warn("harnex: retention prune failed: #{e.message}; run `harnex doctor --prune --dry-run` to inspect candidates")
52
+ { ok: false, error: e.message }
53
+ end
54
+
55
+ def prune(repo_root:, current_paths: [], env: ENV, now: Time.now, dry_run: false, force: false)
56
+ limits = Config.retention_limits(repo_root, env: env)
57
+ with_lock do
58
+ last = last_prune_metadata
59
+ if !force && !dry_run && throttled?(last, now)
60
+ return {
61
+ ok: true,
62
+ skipped: true,
63
+ reason: "throttled",
64
+ throttle_seconds: THROTTLE_SECONDS,
65
+ last_prune: last
66
+ }
67
+ end
68
+
69
+ protected = protected_paths(repo_root, current_paths)
70
+ report = {
71
+ ok: true,
72
+ skipped: false,
73
+ dry_run: dry_run,
74
+ timestamp: now.utc.iso8601,
75
+ directories: DIRS.to_h do |key, dirname|
76
+ [key.to_sym, prune_directory(
77
+ File.join(STATE_DIR, dirname),
78
+ limits.fetch(key),
79
+ protected,
80
+ now,
81
+ dry_run: dry_run
82
+ )]
83
+ end
84
+ }
85
+ persist_last_prune(report)
86
+ report
87
+ end
88
+ end
89
+
90
+ def inventory(dir)
91
+ FileUtils.mkdir_p(dir)
92
+ base = File.expand_path(dir)
93
+ Dir.children(base).sort.filter_map do |name|
94
+ path = File.expand_path(File.join(base, name))
95
+ next unless path.start_with?("#{base}#{File::SEPARATOR}")
96
+
97
+ stat = File.lstat(path)
98
+ next unless stat.file?
99
+
100
+ { path: path, bytes: stat.size, mtime: stat.mtime }
101
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR
102
+ nil
103
+ end
104
+ end
105
+
106
+ def prune_directory(dir, limits, protected, now, dry_run:)
107
+ files = inventory(dir)
108
+ before_bytes = files.sum { |file| file.fetch(:bytes) }
109
+ protected_files = files.select { |file| protected.include?(file.fetch(:path)) }
110
+ deleted = []
111
+ deleted_paths = Set.new
112
+ projected_bytes = before_bytes
113
+
114
+ cutoff = now - limits.fetch("max_age_days") * 86_400
115
+ age_candidates = files.reject { |file| protected.include?(file.fetch(:path)) }
116
+ .select { |file| file.fetch(:mtime) < cutoff }
117
+ .sort_by { |file| [file.fetch(:mtime), file.fetch(:path)] }
118
+ age_candidates.each do |file|
119
+ next if deleted_paths.include?(file.fetch(:path))
120
+
121
+ if delete_candidate(file, dry_run: dry_run)
122
+ deleted << file
123
+ deleted_paths.add(file.fetch(:path))
124
+ projected_bytes -= file.fetch(:bytes)
125
+ end
126
+ end
127
+
128
+ size_candidates = files.reject { |file| protected.include?(file.fetch(:path)) || deleted_paths.include?(file.fetch(:path)) }
129
+ .sort_by { |file| [file.fetch(:mtime), file.fetch(:path)] }
130
+ size_candidates.each do |file|
131
+ break if projected_bytes <= limits.fetch("max_bytes")
132
+
133
+ if delete_candidate(file, dry_run: dry_run)
134
+ deleted << file
135
+ deleted_paths.add(file.fetch(:path))
136
+ projected_bytes -= file.fetch(:bytes)
137
+ end
138
+ end
139
+
140
+ after_bytes = dry_run ? projected_bytes : inventory(dir).sum { |file| file.fetch(:bytes) }
141
+ {
142
+ path: dir,
143
+ before_count: files.length,
144
+ before_bytes: before_bytes,
145
+ after_count: dry_run ? files.length - deleted.length : inventory(dir).length,
146
+ after_bytes: after_bytes,
147
+ deleted_count: deleted.length,
148
+ deleted_bytes: deleted.sum { |file| file.fetch(:bytes) },
149
+ deleted_paths: deleted.first(MAX_REPORTED_PATHS).map { |file| file.fetch(:path) },
150
+ deleted_paths_truncated: deleted.length > MAX_REPORTED_PATHS,
151
+ protected_count: protected_files.length,
152
+ protected_bytes: protected_files.sum { |file| file.fetch(:bytes) },
153
+ max_age_days: limits.fetch("max_age_days"),
154
+ max_bytes: limits.fetch("max_bytes"),
155
+ over_cap: after_bytes > limits.fetch("max_bytes")
156
+ }
157
+ end
158
+
159
+ def delete_candidate(file, dry_run:)
160
+ return true if dry_run
161
+
162
+ File.delete(file.fetch(:path))
163
+ true
164
+ rescue Errno::ENOENT
165
+ true
166
+ rescue Errno::EACCES, Errno::EPERM, Errno::ENOTDIR
167
+ false
168
+ end
169
+
170
+ def protected_paths(repo_root, current_paths)
171
+ paths = Set.new
172
+ current_paths.each { |path| protect(paths, path) }
173
+ protect_live_registry_paths(paths)
174
+ protect_live_start_row_paths(paths, repo_root)
175
+ paths
176
+ end
177
+
178
+ def protect_live_registry_paths(paths)
179
+ return unless Dir.exist?(SESSIONS_DIR)
180
+
181
+ Dir.glob(File.join(SESSIONS_DIR, "*.json")).sort.each do |path|
182
+ data = JSON.parse(File.read(path))
183
+ next unless data["pid"] && Harnex.alive_pid?(data["pid"])
184
+
185
+ protect(paths, data["events_log_path"])
186
+ protect(paths, data["output_log_path"])
187
+ protect_derived_session_paths(paths, data)
188
+ rescue JSON::ParserError, ArgumentError, TypeError, Errno::ENOENT, Errno::EACCES
189
+ next
190
+ end
191
+ end
192
+
193
+ def protect_live_start_row_paths(paths, repo_root)
194
+ history_paths = [
195
+ DispatchHistory.path_for(repo_root),
196
+ DispatchHistory.global_path
197
+ ].uniq
198
+
199
+ history_paths.each do |path|
200
+ live_start_records(path).each do |record|
201
+ protect(paths, record["events_log_path"])
202
+ protect(paths, record["output_log_path"])
203
+ protect_derived_session_paths(paths, record)
204
+ end
205
+ end
206
+ end
207
+
208
+ def live_start_records(path)
209
+ return [] unless File.file?(path)
210
+
211
+ open = {}
212
+ File.foreach(path) do |line|
213
+ record = JSON.parse(line)
214
+ next unless record.is_a?(Hash)
215
+
216
+ if DispatchHistory.start_record?(record)
217
+ next unless DispatchHistory.same_host?(record)
218
+ next unless record["pid"] && Harnex.alive_pid?(record["pid"])
219
+
220
+ open[start_key(record)] = record
221
+ elsif DispatchHistory.end_record?(record)
222
+ open.delete_if { |_key, start| DispatchHistory.end_matches_start?(record, start) }
223
+ end
224
+ rescue JSON::ParserError, ArgumentError, TypeError
225
+ next
226
+ end
227
+ open.values
228
+ rescue Errno::ENOENT, Errno::EACCES
229
+ []
230
+ end
231
+
232
+ def start_key(record)
233
+ session_id = record["session_id"].to_s
234
+ return "session:#{session_id}" unless session_id.empty?
235
+
236
+ "id:#{record['id']}\0#{record['started_at']}"
237
+ end
238
+
239
+ def protect_derived_session_paths(paths, data)
240
+ repo = data["repo_root"].to_s
241
+ id = data["id"].to_s
242
+ return if repo.empty? || id.empty?
243
+
244
+ protect(paths, Harnex.events_log_path(repo, id))
245
+ protect(paths, Harnex.output_log_path(repo, id))
246
+ rescue StandardError
247
+ nil
248
+ end
249
+
250
+ def protect(paths, path)
251
+ text = path.to_s
252
+ return if text.empty?
253
+
254
+ paths.add(File.expand_path(text))
255
+ end
256
+
257
+ def with_lock
258
+ FileUtils.mkdir_p(STATE_DIR)
259
+ File.open(LOCK_PATH, File::RDWR | File::CREAT, 0o644) do |file|
260
+ file.flock(File::LOCK_EX)
261
+ yield
262
+ ensure
263
+ file.flock(File::LOCK_UN) unless file.closed?
264
+ end
265
+ end
266
+
267
+ def throttled?(last, now)
268
+ timestamp = last.is_a?(Hash) ? last["timestamp"].to_s : ""
269
+ return false if timestamp.empty?
270
+
271
+ now - Time.parse(timestamp) < THROTTLE_SECONDS
272
+ rescue ArgumentError
273
+ false
274
+ end
275
+
276
+ def last_prune_metadata
277
+ data = JSON.parse(File.read(METADATA_PATH))
278
+ data["last_prune"]
279
+ rescue Errno::ENOENT, JSON::ParserError
280
+ nil
281
+ end
282
+
283
+ def persist_last_prune(report)
284
+ payload = {
285
+ "last_prune" => {
286
+ "timestamp" => report.fetch(:timestamp),
287
+ "dry_run" => report.fetch(:dry_run),
288
+ "applied" => !report.fetch(:dry_run),
289
+ "directories" => report.fetch(:directories).transform_keys(&:to_s).transform_values do |stats|
290
+ {
291
+ "before_count" => stats.fetch(:before_count),
292
+ "before_bytes" => stats.fetch(:before_bytes),
293
+ "after_count" => stats.fetch(:after_count),
294
+ "after_bytes" => stats.fetch(:after_bytes),
295
+ "deleted_count" => stats.fetch(:deleted_count),
296
+ "deleted_bytes" => stats.fetch(:deleted_bytes),
297
+ "protected_count" => stats.fetch(:protected_count),
298
+ "protected_bytes" => stats.fetch(:protected_bytes),
299
+ "over_cap" => stats.fetch(:over_cap)
300
+ }
301
+ end
302
+ }
303
+ }
304
+ tmp = "#{METADATA_PATH}.tmp.#{Process.pid}"
305
+ File.write(tmp, JSON.pretty_generate(payload))
306
+ File.rename(tmp, METADATA_PATH)
307
+ rescue StandardError => e
308
+ warn("harnex: failed to persist retention metadata: #{e.message}")
309
+ end
310
+ end
311
+ end