active_harness_pricing 0.1.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 +7 -0
- data/lib/active_harness/pricing/models_dev.rb +220 -0
- data/lib/active_harness/pricing/openrouter.rb +323 -0
- data/lib/active_harness/pricing.rb +152 -0
- data/lib/active_harness_pricing.rb +7 -0
- metadata +47 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ac53afc65a92dfd05822664fcb797fb9d785eba4be2148295e5ee898f1e2236b
|
|
4
|
+
data.tar.gz: fa65d9618561a7f6d346713b7ec2000688e504cf6aa120db7b9282f54e5c79a4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b1fd5891bee21ca2c575b7046b0dee73ce90791d58fb231ebc68633f96b2d7efac48bfb1ae6db1b0c5668e699cff36cf31cba592de4bf7a6ddd402a5baefd711
|
|
7
|
+
data.tar.gz: 963a2238509aa0c4acf2017ed871e23a02aa5187c62275b787732e9141977b8606c11430f7202da87d9c0725b51ee6e4e4e66df8ba932c959bb269aaa19c6cc1
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module ActiveHarness
|
|
8
|
+
module Pricing
|
|
9
|
+
# Fallback pricing source — fetches model data from models.dev.
|
|
10
|
+
#
|
|
11
|
+
# Data source:
|
|
12
|
+
# {project_root}/tmp/active_harness/pricing_models_dev.json — fetched cache (24h TTL)
|
|
13
|
+
# Returns nil/empty if cache is missing and network is unavailable.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# Pricing::ModelsDev.find("gpt-4o")
|
|
17
|
+
# Pricing::ModelsDev.all
|
|
18
|
+
# Pricing::ModelsDev.update
|
|
19
|
+
module ModelsDev
|
|
20
|
+
MODELS_DEV_URL = "https://models.dev/api.json"
|
|
21
|
+
MEMORY_TTL = 3 * 86_400 # 3 days
|
|
22
|
+
|
|
23
|
+
MODELS_DEV_PROVIDER_MAP = {
|
|
24
|
+
"openai" => "openai",
|
|
25
|
+
"anthropic" => "anthropic",
|
|
26
|
+
"google" => "gemini",
|
|
27
|
+
"google-vertex" => "vertexai",
|
|
28
|
+
"amazon-bedrock" => "bedrock",
|
|
29
|
+
"deepseek" => "deepseek",
|
|
30
|
+
"mistral" => "mistral",
|
|
31
|
+
"openrouter" => "openrouter",
|
|
32
|
+
"perplexity" => "perplexity",
|
|
33
|
+
"xai" => "xai",
|
|
34
|
+
"groq" => "groq",
|
|
35
|
+
"azure" => "azure"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
def all
|
|
40
|
+
ensure_fresh_registry
|
|
41
|
+
registry.map { |raw| build_cost(raw) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find(model_id)
|
|
45
|
+
ensure_fresh_registry
|
|
46
|
+
raw = registry.find { |m| m[:id] == model_id.to_s }
|
|
47
|
+
raw ? build_cost(raw) : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def providers
|
|
51
|
+
@providers_proxy ||= Pricing::ProvidersProxy.new(self)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def for_provider(name)
|
|
55
|
+
ensure_fresh_registry
|
|
56
|
+
registry
|
|
57
|
+
.select { |m| m[:provider] == name.to_s }
|
|
58
|
+
.map { |m| build_cost(m) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def provider_names
|
|
62
|
+
@provider_names ||= begin
|
|
63
|
+
ensure_fresh_registry
|
|
64
|
+
registry.map { |m| m[:provider] }.uniq.sort
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Fetches fresh data from models.dev, writes to cache file, loads into memory.
|
|
69
|
+
# Called automatically when memory is stale. Can also be called explicitly.
|
|
70
|
+
def preload!
|
|
71
|
+
update
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
ensure
|
|
75
|
+
@registry = load_registry
|
|
76
|
+
@loaded_at = @registry.empty? ? nil : Time.now
|
|
77
|
+
@provider_names = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def update
|
|
81
|
+
raw_api = fetch_models_dev
|
|
82
|
+
models = extract_models(raw_api)
|
|
83
|
+
|
|
84
|
+
FileUtils.mkdir_p(File.dirname(cache_file))
|
|
85
|
+
File.write(cache_file, JSON.generate(models))
|
|
86
|
+
models.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def reload!
|
|
90
|
+
@registry = nil
|
|
91
|
+
@loaded_at = nil
|
|
92
|
+
@provider_names = nil
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def cache_file
|
|
97
|
+
File.join(project_root, "tmp", "active_harness", "models_dev_pricing.json")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns all providers known to this gem.
|
|
101
|
+
# Can be overridden by assigning an explicit list:
|
|
102
|
+
# ActiveHarness::Pricing::ModelsDev.available_providers = %w[openai anthropic]
|
|
103
|
+
def available_providers
|
|
104
|
+
@available_providers ||= MODELS_DEV_PROVIDER_MAP.values.uniq
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def available_providers=(list)
|
|
108
|
+
@available_providers = list
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def ensure_fresh_registry
|
|
114
|
+
return if memory_fresh?
|
|
115
|
+
|
|
116
|
+
unless file_fresh?
|
|
117
|
+
begin
|
|
118
|
+
update
|
|
119
|
+
rescue StandardError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
@registry = load_registry
|
|
125
|
+
@loaded_at = @registry.empty? ? nil : Time.now
|
|
126
|
+
@provider_names = nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def memory_fresh?
|
|
130
|
+
@loaded_at && (Time.now - @loaded_at) < MEMORY_TTL
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def file_fresh?
|
|
134
|
+
File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < MEMORY_TTL
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def registry
|
|
138
|
+
@registry ||= []
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def load_registry
|
|
142
|
+
return [] unless File.exist?(cache_file)
|
|
143
|
+
data = JSON.parse(File.read(cache_file), symbolize_names: true)
|
|
144
|
+
data.is_a?(Array) ? data : []
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
[]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def fetch_models_dev
|
|
150
|
+
uri = URI(MODELS_DEV_URL)
|
|
151
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
152
|
+
http.get(uri.request_uri)
|
|
153
|
+
end
|
|
154
|
+
raise "models.dev returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
155
|
+
|
|
156
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def extract_models(raw_api)
|
|
160
|
+
allowed = available_providers.to_set
|
|
161
|
+
|
|
162
|
+
raw_api.flat_map do |provider_key, provider_data|
|
|
163
|
+
ah_provider = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
|
|
164
|
+
next [] unless ah_provider && allowed.include?(ah_provider)
|
|
165
|
+
|
|
166
|
+
models_hash = provider_data.is_a?(Hash) ? (provider_data[:models] || {}) : {}
|
|
167
|
+
models_hash.values.filter_map do |m|
|
|
168
|
+
next unless m.is_a?(Hash) && m[:id]
|
|
169
|
+
|
|
170
|
+
cost = m[:cost] || {}
|
|
171
|
+
standard = {
|
|
172
|
+
input_per_million: cost[:input],
|
|
173
|
+
output_per_million: cost[:output],
|
|
174
|
+
cache_read_input_per_million: cost[:cache_read],
|
|
175
|
+
cache_write_input_per_million: cost[:cache_write]
|
|
176
|
+
}.compact
|
|
177
|
+
|
|
178
|
+
mods = m[:modalities] || {}
|
|
179
|
+
{
|
|
180
|
+
id: m[:id],
|
|
181
|
+
name: m[:name] || m[:id],
|
|
182
|
+
provider: ah_provider,
|
|
183
|
+
context_window: m[:context_window] || m.dig(:limit, :context),
|
|
184
|
+
max_output_tokens: m[:max_output_tokens] || m.dig(:limit, :output),
|
|
185
|
+
input_modalities: Array(mods[:input]),
|
|
186
|
+
output_modalities: Array(mods[:output]),
|
|
187
|
+
pricing: standard.any? ? { text_tokens: { standard: standard } } : {}
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def build_cost(raw)
|
|
194
|
+
standard = raw.dig(:pricing, :text_tokens, :standard) || {}
|
|
195
|
+
Pricing::ModelPrice.new(
|
|
196
|
+
id: raw[:id],
|
|
197
|
+
name: raw[:name],
|
|
198
|
+
provider: raw[:provider],
|
|
199
|
+
input_per_million: standard[:input_per_million],
|
|
200
|
+
output_per_million: standard[:output_per_million],
|
|
201
|
+
cache_read_input_per_million: standard[:cache_read_input_per_million],
|
|
202
|
+
cache_write_input_per_million: standard[:cache_write_input_per_million],
|
|
203
|
+
context_window: raw[:context_window],
|
|
204
|
+
max_output_tokens: raw[:max_output_tokens],
|
|
205
|
+
input_modalities: Array(raw[:input_modalities]),
|
|
206
|
+
output_modalities: Array(raw[:output_modalities])
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def project_root
|
|
211
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
212
|
+
Rails.root.to_s
|
|
213
|
+
else
|
|
214
|
+
Dir.pwd
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module ActiveHarness
|
|
7
|
+
module Pricing
|
|
8
|
+
# Fetches complete pricing for all OpenRouter models across all modalities.
|
|
9
|
+
#
|
|
10
|
+
# OpenRouter exposes models via several endpoints:
|
|
11
|
+
# GET /api/v1/models → 337 text models (base)
|
|
12
|
+
# GET /api/v1/models?output_modalities=image → 32 image-gen models (25 extra)
|
|
13
|
+
# GET /api/v1/models?output_modalities=embeddings → 26 models (all extra)
|
|
14
|
+
# GET /api/v1/models?output_modalities=speech → 9 models (all extra)
|
|
15
|
+
# GET /api/v1/models?output_modalities=transcription → 10 models (all extra)
|
|
16
|
+
# GET /api/v1/models?output_modalities=video → 14 models (all zero pricing)
|
|
17
|
+
# GET /api/v1/models?output_modalities=rerank → 4 models (all zero pricing)
|
|
18
|
+
#
|
|
19
|
+
# For image-output models, /api/v1/models/{id}/endpoints is also fetched
|
|
20
|
+
# to get the accurate `image_output` per-token rate.
|
|
21
|
+
#
|
|
22
|
+
# All models are merged by id; pricing fields are populated per-modality:
|
|
23
|
+
# text_input / text_output — text tokens
|
|
24
|
+
# image_input — image tokens accepted as input (vision)
|
|
25
|
+
# image_output — image generation tokens (from /endpoints)
|
|
26
|
+
# audio_input — audio tokens as input
|
|
27
|
+
# audio_output — audio tokens as output (TTS)
|
|
28
|
+
# cache_read / cache_write — cache tokens
|
|
29
|
+
# web_search — per web-search request
|
|
30
|
+
#
|
|
31
|
+
# Usage:
|
|
32
|
+
# Pricing::OpenRouter.find("openai/gpt-5-image-mini") # → ModelPrice or nil
|
|
33
|
+
# Pricing::OpenRouter.all # → Array<ModelPrice>
|
|
34
|
+
# Pricing::OpenRouter.update # force refresh
|
|
35
|
+
module OpenRouter
|
|
36
|
+
API_BASE = "https://openrouter.ai/api/v1/models"
|
|
37
|
+
MEMORY_TTL = 3 * 86_400 # 3 days
|
|
38
|
+
|
|
39
|
+
# Modalities that have models outside the base text-337 set.
|
|
40
|
+
EXTRA_MODALITIES = %w[image embeddings speech transcription video rerank].freeze
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def find(model_id)
|
|
44
|
+
ensure_fresh_registry
|
|
45
|
+
raw = registry.find { |m| m[:id] == model_id.to_s }
|
|
46
|
+
raw ? build_price(raw) : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def all
|
|
50
|
+
ensure_fresh_registry
|
|
51
|
+
registry.filter_map { |raw| build_price(raw) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def preload!
|
|
55
|
+
update
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
ensure
|
|
59
|
+
@registry = load_registry
|
|
60
|
+
@loaded_at = @registry.empty? ? nil : Time.now
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def update
|
|
64
|
+
entries = collect_all_models
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(cache_file))
|
|
66
|
+
File.write(cache_file, JSON.generate(entries))
|
|
67
|
+
entries.size
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reload!
|
|
71
|
+
@registry = nil
|
|
72
|
+
@loaded_at = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cache_file
|
|
76
|
+
File.join(project_root, "tmp", "active_harness", "openrouter_pricing.json")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# ── Freshness ────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def ensure_fresh_registry
|
|
84
|
+
return if memory_fresh?
|
|
85
|
+
unless file_fresh?
|
|
86
|
+
begin
|
|
87
|
+
update
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
@registry = load_registry
|
|
93
|
+
@loaded_at = @registry.empty? ? nil : Time.now
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def memory_fresh?
|
|
97
|
+
@loaded_at && (Time.now - @loaded_at) < MEMORY_TTL
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def file_fresh?
|
|
101
|
+
File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < MEMORY_TTL
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def registry
|
|
105
|
+
@registry ||= []
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def load_registry
|
|
109
|
+
return [] unless File.exist?(cache_file)
|
|
110
|
+
data = JSON.parse(File.read(cache_file), symbolize_names: true)
|
|
111
|
+
data.is_a?(Array) ? data : []
|
|
112
|
+
rescue JSON::ParserError
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ── Data collection ──────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
# Fetches all modality endpoints, merges by id, enriches image models.
|
|
119
|
+
def collect_all_models
|
|
120
|
+
models = {}
|
|
121
|
+
|
|
122
|
+
# Base text models
|
|
123
|
+
fetch_models(API_BASE).each do |m|
|
|
124
|
+
models[m[:id]] = normalize(m)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Specialized modalities — add extra models and merge pricing
|
|
128
|
+
EXTRA_MODALITIES.each do |mod|
|
|
129
|
+
fetch_models("#{API_BASE}?output_modalities=#{mod}").each do |m|
|
|
130
|
+
id = m[:id]
|
|
131
|
+
if models[id]
|
|
132
|
+
merge_pricing!(models[id], m)
|
|
133
|
+
else
|
|
134
|
+
models[id] = normalize(m)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Enrich image-output models with /endpoints for accurate image_output rate
|
|
140
|
+
models.values.map do |entry|
|
|
141
|
+
if Array(entry[:output_modalities]).include?("image")
|
|
142
|
+
enrich_with_endpoint(entry)
|
|
143
|
+
else
|
|
144
|
+
entry
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Normalize a raw API model hash into our cache entry format.
|
|
150
|
+
def normalize(m)
|
|
151
|
+
p = m[:pricing] || {}
|
|
152
|
+
{
|
|
153
|
+
id: m[:id],
|
|
154
|
+
name: m[:name],
|
|
155
|
+
input_modalities: m.dig(:architecture, :input_modalities) || [],
|
|
156
|
+
output_modalities: m.dig(:architecture, :output_modalities) || [],
|
|
157
|
+
text_input: p[:prompt].to_s,
|
|
158
|
+
text_output: p[:completion].to_s,
|
|
159
|
+
image_input: p[:image].to_s,
|
|
160
|
+
audio_input: p[:audio].to_s,
|
|
161
|
+
image_output: "",
|
|
162
|
+
audio_output: "",
|
|
163
|
+
cache_read: p[:input_cache_read].to_s,
|
|
164
|
+
cache_write: p[:input_cache_write].to_s,
|
|
165
|
+
web_search: p[:web_search].to_s
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Merge non-zero pricing fields from a new API response into existing entry.
|
|
170
|
+
def merge_pricing!(entry, raw_model)
|
|
171
|
+
p = raw_model[:pricing] || {}
|
|
172
|
+
[
|
|
173
|
+
[:text_input, p[:prompt]],
|
|
174
|
+
[:text_output, p[:completion]],
|
|
175
|
+
[:image_input, p[:image]],
|
|
176
|
+
[:audio_input, p[:audio]],
|
|
177
|
+
[:cache_read, p[:input_cache_read]],
|
|
178
|
+
[:cache_write, p[:input_cache_write]],
|
|
179
|
+
[:web_search, p[:web_search]]
|
|
180
|
+
].each do |key, val|
|
|
181
|
+
entry[key] = val.to_s if val.to_f > 0 && entry[key].to_f == 0
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Merge modalities (union)
|
|
185
|
+
new_out = raw_model.dig(:architecture, :output_modalities) || []
|
|
186
|
+
entry[:output_modalities] = (Array(entry[:output_modalities]) | new_out).uniq
|
|
187
|
+
new_in = raw_model.dig(:architecture, :input_modalities) || []
|
|
188
|
+
entry[:input_modalities] = (Array(entry[:input_modalities]) | new_in).uniq
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Fetch /endpoints and add image_output rate to the entry.
|
|
192
|
+
def enrich_with_endpoint(entry)
|
|
193
|
+
pricing = fetch_endpoint_pricing(entry[:id])
|
|
194
|
+
entry[:image_output] = pricing&.dig(:image_output).to_s
|
|
195
|
+
entry[:audio_output] = pricing&.dig(:audio_output).to_s
|
|
196
|
+
entry
|
|
197
|
+
rescue StandardError
|
|
198
|
+
entry
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def fetch_endpoint_pricing(model_id)
|
|
202
|
+
uri = URI("#{API_BASE}/#{model_id}/endpoints")
|
|
203
|
+
resp = http_get(uri)
|
|
204
|
+
data = JSON.parse(resp.body, symbolize_names: true)
|
|
205
|
+
endpoints = data.dig(:data, :endpoints) || []
|
|
206
|
+
ep = endpoints.find { |e| e[:status] == 0 } || endpoints.first
|
|
207
|
+
ep&.dig(:pricing)
|
|
208
|
+
rescue StandardError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def fetch_models(url)
|
|
213
|
+
resp = http_get(URI(url))
|
|
214
|
+
data = JSON.parse(resp.body, symbolize_names: true)
|
|
215
|
+
data[:data] || []
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# ── Build ModelPrice ─────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def build_price(raw)
|
|
221
|
+
out_mods = Array(raw[:output_modalities])
|
|
222
|
+
inp_mods = Array(raw[:input_modalities])
|
|
223
|
+
|
|
224
|
+
is_imggen = out_mods.include?("image")
|
|
225
|
+
is_embed = out_mods.include?("embeddings")
|
|
226
|
+
is_speech = out_mods.include?("speech")
|
|
227
|
+
is_transcription = out_mods.include?("transcription")
|
|
228
|
+
|
|
229
|
+
text_in_pm = to_pm(raw[:text_input])
|
|
230
|
+
text_out_pm = to_pm(raw[:text_output])
|
|
231
|
+
img_in_pm = to_pm(raw[:image_input])
|
|
232
|
+
img_out_pm = to_pm(raw[:image_output])
|
|
233
|
+
# p[:audio] field — audio input tokens (multimodal/embedding models like Gemini)
|
|
234
|
+
audio_in_pm = to_pm(raw[:audio_input])
|
|
235
|
+
aud_out_pm = to_pm(raw[:audio_output])
|
|
236
|
+
cache_r_pm = to_pm(raw[:cache_read])
|
|
237
|
+
cache_w_pm = to_pm(raw[:cache_write])
|
|
238
|
+
# web_search is a flat per-request fee in USD, not a per-token rate
|
|
239
|
+
ws_raw = raw[:web_search].to_s
|
|
240
|
+
web_search_usd = ws_raw.empty? ? nil : (ws_raw.to_f > 0 ? ws_raw.to_f : nil)
|
|
241
|
+
|
|
242
|
+
# Transcription pricing is stored in `prompt` but the unit differs by model:
|
|
243
|
+
# prompt < 0.0001 → per-audio-token (e.g. gpt-4o-transcribe $2.5/M) → use to_pm
|
|
244
|
+
# prompt >= 0.0001 → per-minute of audio (e.g. Whisper $0.006/min) → raw USD
|
|
245
|
+
if is_transcription
|
|
246
|
+
raw_rate = raw[:text_input].to_s.to_f
|
|
247
|
+
audio_in_pm = if raw_rate > 0 && raw_rate < 0.0001
|
|
248
|
+
to_pm(raw[:text_input]) # per-token → convert to per-million
|
|
249
|
+
elsif raw_rate > 0
|
|
250
|
+
raw_rate # per-minute → keep raw USD value
|
|
251
|
+
end
|
|
252
|
+
text_in_pm = nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Primary output for cost calculation and sorting:
|
|
256
|
+
# imggen → image_output rate (from /endpoints)
|
|
257
|
+
# speech → audio_output rate (completion is audio)
|
|
258
|
+
# embed / transcription → no output cost
|
|
259
|
+
# text → text_output rate
|
|
260
|
+
primary_output = if is_imggen
|
|
261
|
+
img_out_pm || text_out_pm
|
|
262
|
+
elsif is_speech
|
|
263
|
+
aud_out_pm || text_out_pm
|
|
264
|
+
elsif is_embed || is_transcription
|
|
265
|
+
nil
|
|
266
|
+
else
|
|
267
|
+
text_out_pm
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Primary input for cost calculation and sorting
|
|
271
|
+
primary_input = is_transcription ? audio_in_pm : text_in_pm
|
|
272
|
+
|
|
273
|
+
# Skip models with no id/name; keep zero-priced models (rerank, video) —
|
|
274
|
+
# they are real models, just have $0 rates in the OpenRouter API.
|
|
275
|
+
return nil unless raw[:id] && raw[:name]
|
|
276
|
+
|
|
277
|
+
Pricing::ModelPrice.new(
|
|
278
|
+
id: raw[:id],
|
|
279
|
+
name: raw[:name],
|
|
280
|
+
provider: "openrouter",
|
|
281
|
+
input_per_million: primary_input,
|
|
282
|
+
output_per_million: primary_output,
|
|
283
|
+
cache_read_input_per_million: cache_r_pm,
|
|
284
|
+
cache_write_input_per_million: cache_w_pm,
|
|
285
|
+
context_window: nil,
|
|
286
|
+
max_output_tokens: nil,
|
|
287
|
+
input_modalities: inp_mods,
|
|
288
|
+
output_modalities: out_mods,
|
|
289
|
+
image_input_per_million: img_in_pm,
|
|
290
|
+
image_output_per_million: img_out_pm,
|
|
291
|
+
audio_input_per_million: audio_in_pm,
|
|
292
|
+
audio_output_per_million: aud_out_pm,
|
|
293
|
+
web_search_per_request: web_search_usd
|
|
294
|
+
)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Per-token string → per-million float. Returns nil for zero/blank.
|
|
298
|
+
def to_pm(value)
|
|
299
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
300
|
+
f = value.to_f
|
|
301
|
+
return nil if f <= 0
|
|
302
|
+
(f * 1_000_000).round(6)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def http_get(uri)
|
|
306
|
+
resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 15) do |h|
|
|
307
|
+
h.get(uri.request_uri)
|
|
308
|
+
end
|
|
309
|
+
raise "OpenRouter API #{resp.code} for #{uri}" unless resp.is_a?(Net::HTTPSuccess)
|
|
310
|
+
resp
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def project_root
|
|
314
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
315
|
+
Rails.root.to_s
|
|
316
|
+
else
|
|
317
|
+
Dir.pwd
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveHarness
|
|
4
|
+
# Pricing namespace — shared types and a facade over pricing source modules.
|
|
5
|
+
#
|
|
6
|
+
# Sources (in priority order):
|
|
7
|
+
# Pricing::OpenRouter — live data from OpenRouter API (image models, 24h cache)
|
|
8
|
+
# Pricing::ModelsDev — live data from models.dev API (all providers, 24h cache)
|
|
9
|
+
#
|
|
10
|
+
# Public facade delegates to ModelsDev (used as the general fallback):
|
|
11
|
+
# Pricing.find("gpt-4o") → ModelPrice or nil
|
|
12
|
+
# Pricing.all → Array<ModelPrice>
|
|
13
|
+
# Pricing.providers.openai → Array<ModelPrice>
|
|
14
|
+
# Pricing.update → refreshes ModelsDev cache
|
|
15
|
+
module Pricing
|
|
16
|
+
# Pricing rates for a single model.
|
|
17
|
+
# All *_per_million fields are in USD per 1M tokens.
|
|
18
|
+
# audio_input_per_million / audio_output_per_million may represent
|
|
19
|
+
# per-million audio tokens or per-unit (second/char) depending on provider.
|
|
20
|
+
ModelPrice = Struct.new(
|
|
21
|
+
:id,
|
|
22
|
+
:name,
|
|
23
|
+
:provider,
|
|
24
|
+
# Primary fields (used for cost calculation, backward-compatible)
|
|
25
|
+
:input_per_million, # text tokens input
|
|
26
|
+
:output_per_million, # primary output (text or image_output for imggen)
|
|
27
|
+
:cache_read_input_per_million,
|
|
28
|
+
:cache_write_input_per_million,
|
|
29
|
+
:context_window,
|
|
30
|
+
:max_output_tokens,
|
|
31
|
+
:input_modalities,
|
|
32
|
+
:output_modalities,
|
|
33
|
+
# Extended modality-specific pricing
|
|
34
|
+
:image_input_per_million, # image tokens accepted as input (vision models)
|
|
35
|
+
:image_output_per_million, # image generation output tokens (imggen models)
|
|
36
|
+
:audio_input_per_million, # audio tokens accepted as input
|
|
37
|
+
:audio_output_per_million, # audio output tokens (TTS models)
|
|
38
|
+
:web_search_per_request, # per web-search call in USD
|
|
39
|
+
keyword_init: true
|
|
40
|
+
) do
|
|
41
|
+
# Capability tags derived from modality data.
|
|
42
|
+
# Possible values: "vision", "pdf", "audio", "video", "imggen", "embed",
|
|
43
|
+
# "speech", "transcription", "rerank"
|
|
44
|
+
def categories
|
|
45
|
+
inp = Array(input_modalities)
|
|
46
|
+
out = Array(output_modalities)
|
|
47
|
+
cats = []
|
|
48
|
+
cats << "vision" if inp.include?("image")
|
|
49
|
+
cats << "pdf" if inp.include?("pdf")
|
|
50
|
+
cats << "audio" if inp.include?("audio")
|
|
51
|
+
cats << "video" if inp.include?("video") || out.include?("video")
|
|
52
|
+
cats << "imggen" if out.include?("image")
|
|
53
|
+
cats << "speech" if out.include?("speech")
|
|
54
|
+
cats << "transcription" if out.include?("transcription")
|
|
55
|
+
cats << "rerank" if out.include?("rerank")
|
|
56
|
+
cats << "embed" if out.include?("embeddings")
|
|
57
|
+
cats
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inspect
|
|
61
|
+
parts = ["id=#{id.inspect}", "provider=#{provider.inspect}"]
|
|
62
|
+
parts << "input=$#{input_per_million}/M" if input_per_million
|
|
63
|
+
parts << "output=$#{output_per_million}/M" if output_per_million
|
|
64
|
+
parts << "ctx=#{context_window}" if context_window
|
|
65
|
+
parts << "cats=#{categories.join(',')}" if categories.any?
|
|
66
|
+
"#<ModelPrice #{parts.join(' ')}>"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Proxy returned by Pricing.providers — exposes providers as methods and [].
|
|
71
|
+
class ProvidersProxy
|
|
72
|
+
def initialize(source = nil)
|
|
73
|
+
@source = source
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def [](name)
|
|
77
|
+
source.for_provider(name.to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def list
|
|
81
|
+
source.provider_names
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def method_missing(name, *args, &block)
|
|
85
|
+
provider = name.to_s
|
|
86
|
+
if source.provider_names.include?(provider)
|
|
87
|
+
source.for_provider(provider)
|
|
88
|
+
else
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def respond_to_missing?(name, include_private = false)
|
|
94
|
+
source.provider_names.include?(name.to_s) || super
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def source
|
|
100
|
+
@source || ModelsDev
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Facade — delegates to ModelsDev (general fallback source)
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
class << self
|
|
108
|
+
# Eagerly fetch all pricing sources and load them into memory.
|
|
109
|
+
# Called at Rails startup. Network failures are silently ignored.
|
|
110
|
+
def preload!
|
|
111
|
+
ModelsDev.preload!
|
|
112
|
+
OpenRouter.preload!
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def find(model_id)
|
|
116
|
+
ModelsDev.find(model_id)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def all
|
|
120
|
+
ModelsDev.all
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def providers
|
|
124
|
+
ModelsDev.providers
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def for_provider(name)
|
|
128
|
+
ModelsDev.for_provider(name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def provider_names
|
|
132
|
+
ModelsDev.provider_names
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def update
|
|
136
|
+
ModelsDev.update
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def reload!
|
|
140
|
+
ModelsDev.reload!
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def cache_file
|
|
144
|
+
ModelsDev.cache_file
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def available_providers
|
|
148
|
+
ModelsDev.available_providers
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_harness_pricing
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- the-teacher
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
- the-teacher@github.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- lib/active_harness/pricing.rb
|
|
21
|
+
- lib/active_harness/pricing/models_dev.rb
|
|
22
|
+
- lib/active_harness/pricing/openrouter.rb
|
|
23
|
+
- lib/active_harness_pricing.rb
|
|
24
|
+
homepage: https://github.com/the-teacher/active_harness
|
|
25
|
+
licenses:
|
|
26
|
+
- MIT
|
|
27
|
+
metadata: {}
|
|
28
|
+
post_install_message:
|
|
29
|
+
rdoc_options: []
|
|
30
|
+
require_paths:
|
|
31
|
+
- lib
|
|
32
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
33
|
+
requirements:
|
|
34
|
+
- - ">="
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '2.6'
|
|
37
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
38
|
+
requirements:
|
|
39
|
+
- - ">="
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '0'
|
|
42
|
+
requirements: []
|
|
43
|
+
rubygems_version: 3.0.3.1
|
|
44
|
+
signing_key:
|
|
45
|
+
specification_version: 4
|
|
46
|
+
summary: LLM model pricing data — models.dev and OpenRouter sources
|
|
47
|
+
test_files: []
|