active_harness_pricing 0.1.3 → 0.1.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.
@@ -0,0 +1,44 @@
1
+ module ActiveHarness
2
+ module Pricing
3
+ # Converts any model identifier or display name to a canonical lookup key.
4
+ #
5
+ # Works on both raw provider model IDs and human-readable display names,
6
+ # producing the same key for the same model regardless of source.
7
+ #
8
+ # Examples:
9
+ # Normalizer.to_key("Mistral Nemo") # => "mistral-nemo"
10
+ # Normalizer.to_key("mistralai/mistral-nemo") # => "mistral-nemo"
11
+ # Normalizer.to_key("GPT-4o") # => "gpt-4o"
12
+ # Normalizer.to_key("gpt-4o") # => "gpt-4o"
13
+ # Normalizer.to_key("claude-3-5-haiku-20241022") # => "claude-3-5-haiku"
14
+ # Normalizer.to_key("global.anthropic.claude-haiku-4-5-20251001-v1:0") # => "claude-haiku-4-5"
15
+ # Normalizer.to_key("models/gemini-2.5-flash") # => "gemini-2-5-flash"
16
+ module Normalizer
17
+ def self.to_key(str)
18
+ s = str.to_s.downcase
19
+
20
+ # Strip "author/" prefix ("mistralai/mistral-nemo" → "mistral-nemo")
21
+ s = s.split("/").last if s.include?("/")
22
+
23
+ # Strip leading "word." segments ("global.anthropic.claude-..." → "claude-...")
24
+ s = s.sub(/\A[a-z]+\./, "") while s.match?(/\A[a-z]+\.[a-z]/)
25
+
26
+ # Normalize all non-alphanumeric characters to hyphens
27
+ s = s.gsub(/[^a-z0-9]/, "-")
28
+
29
+ # Strip date suffixes and everything that follows
30
+ s = s.gsub(/-\d{8}.*/, "") # -YYYYMMDD...
31
+ s = s.gsub(/-\d{4}-\d{2}-\d{2}.*/, "") # -YYYY-MM-DD...
32
+ s = s.gsub(/-\d{2}-\d{2}$/, "") # trailing -MM-DD (Gemini preview dates)
33
+
34
+ # Strip version suffixes ("-v2:0", "-v1-0")
35
+ s = s.gsub(/-v\d+(-\d+)?$/, "")
36
+
37
+ # Strip common qualifiers
38
+ s = s.gsub(/-(latest|online|free|exp)$/, "")
39
+
40
+ s.squeeze("-").gsub(/^-|-$/, "")
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,128 @@
1
+ module ActiveHarness
2
+ module Pricing
3
+ # Queries all pricing sources for a given model and calculates costs.
4
+ #
5
+ # All cost methods accept either an agent/result object OR explicit keyword args:
6
+ #
7
+ # PriceResolver.max_cost(result) # from ActiveHarness agent result
8
+ # PriceResolver.max_cost(agent) # from ActiveHarness agent instance
9
+ # PriceResolver.max_cost(model_id: "gpt-4o", tokens_input: 1000, tokens_output: 500)
10
+ #
11
+ # Resolved keys are cached in memory (TTL: 24h) — in production only a handful
12
+ # of models are used, so the first lookup pays the search cost and every
13
+ # subsequent call returns instantly from cache.
14
+ module PriceResolver
15
+ DATA_DIR = File.expand_path("../../../data", __dir__)
16
+ CACHE_TTL = 86_400 # 24 hours
17
+
18
+ SOURCES = {
19
+ pricepertoken: Source.new(File.join(DATA_DIR, "pricepertoken.json"), :pricepertoken),
20
+ modelsdev: Source.new(File.join(DATA_DIR, "modelsdev.json"), :modelsdev),
21
+ openrouter: Source.new(File.join(DATA_DIR, "openrouter.json"), :openrouter)
22
+ }.freeze
23
+
24
+ class << self
25
+ # Returns { source_name => PricingData } for every source that has this model.
26
+ # Result is cached by canonical key for CACHE_TTL seconds.
27
+ def resolve(model_id)
28
+ key = Normalizer.to_key(model_id)
29
+
30
+ cached = resolve_cache[key]
31
+ return cached unless cached.nil?
32
+
33
+ result = SOURCES.each_with_object({}) do |(name, source), h|
34
+ hit = source.find(key)
35
+ h[name] = hit if hit&.input_per_1m && hit&.output_per_1m
36
+ end
37
+
38
+ resolve_cache[key] = result
39
+ result
40
+ end
41
+
42
+ # Returns { source_name => Float (USD) } — calculated cost per source.
43
+ # Accepts a result/agent object or keyword args.
44
+ def costs(subject = nil, model_id: nil, tokens_input: 0, tokens_output: 0)
45
+ args = extract_args(subject, model_id: model_id, tokens_input: tokens_input, tokens_output: tokens_output)
46
+ tokens_in = args[:tokens_input].to_i
47
+ tokens_out = args[:tokens_output].to_i
48
+ resolve(args[:model_id]).transform_values do |p|
49
+ (tokens_in * p.input_per_1m / 1_000_000.0) +
50
+ (tokens_out * p.output_per_1m / 1_000_000.0)
51
+ end
52
+ end
53
+
54
+ # Returns the highest cost estimate across all sources (conservative upper bound).
55
+ # Accepts a result/agent object or keyword args.
56
+ # Returns nil when no pricing data found.
57
+ # Returns { cost: Float, source: Symbol, all: Hash } otherwise.
58
+ def max_cost(subject = nil, model_id: nil, tokens_input: 0, tokens_output: 0, provider_cost: nil)
59
+ args = extract_args(subject, model_id: model_id, tokens_input: tokens_input,
60
+ tokens_output: tokens_output, provider_cost: provider_cost)
61
+
62
+ return provider_result(args[:provider_cost], args[:model_id]) if args[:provider_cost].to_f > 0
63
+
64
+ all = costs(model_id: args[:model_id], tokens_input: args[:tokens_input], tokens_output: args[:tokens_output])
65
+ return nil if all.empty?
66
+
67
+ src, cost = all.max_by { |_, v| v }
68
+ { cost: cost, source: src, all: all }
69
+ end
70
+
71
+ # Returns the lowest cost estimate across all sources (optimistic lower bound).
72
+ # Accepts a result/agent object or keyword args.
73
+ # Returns nil when no pricing data found.
74
+ # Returns { cost: Float, source: Symbol, all: Hash } otherwise.
75
+ def min_cost(subject = nil, model_id: nil, tokens_input: 0, tokens_output: 0, provider_cost: nil)
76
+ args = extract_args(subject, model_id: model_id, tokens_input: tokens_input,
77
+ tokens_output: tokens_output, provider_cost: provider_cost)
78
+
79
+ return provider_result(args[:provider_cost], args[:model_id]) if args[:provider_cost].to_f > 0
80
+
81
+ all = costs(model_id: args[:model_id], tokens_input: args[:tokens_input], tokens_output: args[:tokens_output])
82
+ return nil if all.empty?
83
+
84
+ src, cost = all.min_by { |_, v| v }
85
+ { cost: cost, source: src, all: all }
86
+ end
87
+
88
+ # Clears the resolve cache. Useful in tests or after manually refreshing data files.
89
+ def clear_cache!
90
+ @resolve_cache = nil
91
+ @cache_built_at = nil
92
+ end
93
+
94
+ private
95
+
96
+ # Normalizes arguments from either a result/agent object or explicit keyword args.
97
+ # An agent is any object that responds to :result (ActiveHarness agent instance).
98
+ # A result is any object with .model.name and .usage.tokens.{input,output}.
99
+ def extract_args(subject, model_id: nil, tokens_input: 0, tokens_output: 0, provider_cost: nil)
100
+ return { model_id: model_id, tokens_input: tokens_input,
101
+ tokens_output: tokens_output, provider_cost: provider_cost } if subject.nil?
102
+
103
+ result = subject.respond_to?(:result) ? subject.result : subject
104
+
105
+ {
106
+ model_id: result.model&.name.to_s,
107
+ tokens_input: result.usage&.tokens&.input.to_i,
108
+ tokens_output: result.usage&.tokens&.output.to_i,
109
+ provider_cost: result.usage&.cost&.total
110
+ }
111
+ end
112
+
113
+ # Cache hash, reset automatically after CACHE_TTL.
114
+ def resolve_cache
115
+ if @cache_built_at.nil? || (Time.now - @cache_built_at) >= CACHE_TTL
116
+ @resolve_cache = {}
117
+ @cache_built_at = Time.now
118
+ end
119
+ @resolve_cache
120
+ end
121
+
122
+ def provider_result(cost, model_id)
123
+ { cost: cost.to_f, source: :provider, all: { provider: cost.to_f } }
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,110 @@
1
+ require "json"
2
+
3
+ module ActiveHarness
4
+ module Pricing
5
+ # Reads a standardized pricing data file and looks up models by canonical key.
6
+ # Data is loaded lazily on first access and reloaded automatically after CACHE_TTL.
7
+ #
8
+ # Data file format (JSON hash):
9
+ # {
10
+ # "mistral-nemo": {
11
+ # "name": "Mistral Nemo",
12
+ # "input_per_1m": 0.02,
13
+ # "output_per_1m": 0.03,
14
+ # "context_window": 131072,
15
+ # "tokens_per_second": 56.87, # optional
16
+ # "time_to_first_token": 0.99 # optional
17
+ # }
18
+ # }
19
+ #
20
+ # Usage:
21
+ # src = Source.new("data/pricepertoken.json", :pricepertoken)
22
+ # src.find("mistral-nemo") # exact
23
+ # src.find("mistral-nemo-instruct") # prefix fallback
24
+ class Source
25
+ CACHE_TTL = 86_400 # 24 hours
26
+
27
+ PricingData = Struct.new(
28
+ :key,
29
+ :name,
30
+ :source,
31
+ :input_per_1m,
32
+ :output_per_1m,
33
+ :context_window,
34
+ :tokens_per_second,
35
+ :time_to_first_token,
36
+ keyword_init: true
37
+ ) do
38
+ def inspect
39
+ "#<PricingData source=#{source} key=#{key.inspect}" \
40
+ " in=$#{input_per_1m}/M out=$#{output_per_1m}/M>"
41
+ end
42
+ end
43
+
44
+ def initialize(data_file, source_name)
45
+ @data_file = data_file
46
+ @source_name = source_name.to_sym
47
+ end
48
+
49
+ # Finds a model by canonical key.
50
+ # Falls back to prefix match when exact key is not found
51
+ # (e.g. "mistral-nemo-instruct-2407" → finds "mistral-nemo").
52
+ def find(canonical_key)
53
+ key = canonical_key.to_s
54
+
55
+ raw = data[key]
56
+ return build(key, raw) if raw
57
+
58
+ # prefix fallback: find the longest stored key that is a prefix of the lookup key
59
+ match_key, match_raw = data
60
+ .select { |k, _| key.start_with?(k) && k.length >= 5 }
61
+ .max_by { |k, _| k.length }
62
+
63
+ build(match_key, match_raw) if match_raw
64
+ end
65
+
66
+ def all
67
+ data.map { |key, raw| build(key, raw) }
68
+ end
69
+
70
+ def reload!
71
+ @data = nil
72
+ @loaded_at = nil
73
+ end
74
+
75
+ private
76
+
77
+ def data
78
+ expire_if_stale
79
+ @data ||= load_data
80
+ end
81
+
82
+ def expire_if_stale
83
+ return unless @loaded_at && (Time.now - @loaded_at) >= CACHE_TTL
84
+ @data = nil
85
+ @loaded_at = nil
86
+ end
87
+
88
+ def load_data
89
+ @loaded_at = Time.now
90
+ return {} unless File.exist?(@data_file)
91
+ JSON.parse(File.read(@data_file))
92
+ rescue StandardError
93
+ {}
94
+ end
95
+
96
+ def build(key, raw)
97
+ PricingData.new(
98
+ key: key,
99
+ name: raw["name"],
100
+ source: @source_name,
101
+ input_per_1m: raw["input_per_1m"],
102
+ output_per_1m: raw["output_per_1m"],
103
+ context_window: raw["context_window"],
104
+ tokens_per_second: raw["tokens_per_second"],
105
+ time_to_first_token: raw["time_to_first_token"]
106
+ )
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,7 +1,10 @@
1
1
  require_relative "active_harness/pricing"
2
2
  require_relative "active_harness/pricing/models_dev"
3
3
  require_relative "active_harness/pricing/openrouter"
4
+ require_relative "active_harness/pricing/normalizer"
5
+ require_relative "active_harness/pricing/source"
6
+ require_relative "active_harness/pricing/price_resolver"
4
7
 
5
8
  module ActiveHarnessPricing
6
- VERSION = "0.1.3"
9
+ VERSION = "0.1.4"
7
10
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness_pricing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-14 00:00:00.000000000 Z
11
+ date: 2026-06-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -17,9 +17,16 @@ executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
+ - data/README.md
21
+ - data/modelsdev.json
22
+ - data/openrouter.json
23
+ - data/pricepertoken.json
20
24
  - lib/active_harness/pricing.rb
21
25
  - lib/active_harness/pricing/models_dev.rb
26
+ - lib/active_harness/pricing/normalizer.rb
22
27
  - lib/active_harness/pricing/openrouter.rb
28
+ - lib/active_harness/pricing/price_resolver.rb
29
+ - lib/active_harness/pricing/source.rb
23
30
  - lib/active_harness_pricing.rb
24
31
  homepage: https://github.com/the-teacher/active_harness
25
32
  licenses: