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.
- checksums.yaml +4 -4
- data/data/README.md +127 -0
- data/data/modelsdev.json +10299 -0
- data/data/openrouter.json +1866 -0
- data/data/pricepertoken.json +3226 -0
- data/lib/active_harness/pricing/normalizer.rb +44 -0
- data/lib/active_harness/pricing/price_resolver.rb +128 -0
- data/lib/active_harness/pricing/source.rb +110 -0
- data/lib/active_harness_pricing.rb +4 -1
- metadata +9 -2
|
@@ -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.
|
|
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.
|
|
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-
|
|
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:
|