active_harness 0.2.34 → 0.2.35
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/lib/active_harness/pricing/models_dev.rb +35 -11
- data/lib/active_harness/pricing/openrouter.rb +230 -83
- data/lib/active_harness/pricing.rb +33 -12
- data/lib/active_harness/railtie.rb +4 -0
- data/lib/active_harness.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f08d4dd9d2254cb4895919e690fd485cab05d2b89c53786383b0da353aeb5f82
|
|
4
|
+
data.tar.gz: 1aa9a3e3a7cd8a2e83179b88a9f3623f39b4fa59e7386d43094de2739f44d50a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1aa38c722c75fbc04d6d389ebe6d260322eddd1a4d6c5bb9a43117b2b341447bf1883f90beacdcf5c2bc99adc6867a2dc7ee820f3dcd85d44538c5d148953a92
|
|
7
|
+
data.tar.gz: 146bf3d9516d70dcc45c2b092855b2b6b01543dd3b76e45de250b2b91fecce700cbabf7f492ebbf2e7417dfca22a48c21b2247a361b83eb28a81b001a3ce5999
|
|
@@ -18,7 +18,7 @@ module ActiveHarness
|
|
|
18
18
|
# Pricing::ModelsDev.update
|
|
19
19
|
module ModelsDev
|
|
20
20
|
MODELS_DEV_URL = "https://models.dev/api.json"
|
|
21
|
-
|
|
21
|
+
MEMORY_TTL = 3 * 86_400 # 3 days
|
|
22
22
|
|
|
23
23
|
MODELS_DEV_PROVIDER_MAP = {
|
|
24
24
|
"openai" => "openai",
|
|
@@ -65,25 +65,36 @@ module ActiveHarness
|
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
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
|
+
|
|
68
80
|
def update
|
|
69
81
|
raw_api = fetch_models_dev
|
|
70
82
|
models = extract_models(raw_api)
|
|
71
83
|
|
|
72
84
|
FileUtils.mkdir_p(File.dirname(cache_file))
|
|
73
85
|
File.write(cache_file, JSON.generate(models))
|
|
74
|
-
|
|
75
|
-
reload!
|
|
76
86
|
models.size
|
|
77
87
|
end
|
|
78
88
|
|
|
79
89
|
def reload!
|
|
80
90
|
@registry = nil
|
|
91
|
+
@loaded_at = nil
|
|
81
92
|
@provider_names = nil
|
|
82
93
|
nil
|
|
83
94
|
end
|
|
84
95
|
|
|
85
96
|
def cache_file
|
|
86
|
-
File.join(project_root, "tmp", "active_harness", "
|
|
97
|
+
File.join(project_root, "tmp", "active_harness", "models_dev_pricing.json")
|
|
87
98
|
end
|
|
88
99
|
|
|
89
100
|
def available_providers
|
|
@@ -98,18 +109,31 @@ module ActiveHarness
|
|
|
98
109
|
private
|
|
99
110
|
|
|
100
111
|
def ensure_fresh_registry
|
|
101
|
-
return if
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
return if memory_fresh?
|
|
113
|
+
|
|
114
|
+
unless file_fresh?
|
|
115
|
+
begin
|
|
116
|
+
update
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
@registry = load_registry
|
|
123
|
+
@loaded_at = @registry.empty? ? nil : Time.now
|
|
124
|
+
@provider_names = nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def memory_fresh?
|
|
128
|
+
@loaded_at && (Time.now - @loaded_at) < MEMORY_TTL
|
|
105
129
|
end
|
|
106
130
|
|
|
107
|
-
def
|
|
108
|
-
File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) <
|
|
131
|
+
def file_fresh?
|
|
132
|
+
File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < MEMORY_TTL
|
|
109
133
|
end
|
|
110
134
|
|
|
111
135
|
def registry
|
|
112
|
-
@registry ||=
|
|
136
|
+
@registry ||= []
|
|
113
137
|
end
|
|
114
138
|
|
|
115
139
|
def load_registry
|
|
@@ -5,162 +5,309 @@ require "fileutils"
|
|
|
5
5
|
|
|
6
6
|
module ActiveHarness
|
|
7
7
|
module Pricing
|
|
8
|
-
# Fetches
|
|
8
|
+
# Fetches complete pricing for all OpenRouter models across all modalities.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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)
|
|
13
18
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
19
30
|
#
|
|
20
31
|
# Usage:
|
|
21
32
|
# Pricing::OpenRouter.find("openai/gpt-5-image-mini") # → ModelPrice or nil
|
|
33
|
+
# Pricing::OpenRouter.all # → Array<ModelPrice>
|
|
22
34
|
# Pricing::OpenRouter.update # force refresh
|
|
23
35
|
module OpenRouter
|
|
24
|
-
API_BASE
|
|
25
|
-
|
|
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
|
|
26
41
|
|
|
27
42
|
class << self
|
|
28
|
-
# Returns a ModelPrice for the given OpenRouter model id, or nil.
|
|
29
|
-
# Automatically refreshes the cache if missing or stale.
|
|
30
43
|
def find(model_id)
|
|
31
44
|
ensure_fresh_registry
|
|
32
45
|
raw = registry.find { |m| m[:id] == model_id.to_s }
|
|
33
46
|
raw ? build_price(raw) : nil
|
|
34
47
|
end
|
|
35
48
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
enriched = image_models.map { |m| enrich_with_endpoint(m) }
|
|
49
|
+
def all
|
|
50
|
+
ensure_fresh_registry
|
|
51
|
+
registry.filter_map { |raw| build_price(raw) }
|
|
52
|
+
end
|
|
41
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
|
|
42
65
|
FileUtils.mkdir_p(File.dirname(cache_file))
|
|
43
|
-
File.write(cache_file, JSON.generate(
|
|
44
|
-
|
|
45
|
-
enriched.size
|
|
66
|
+
File.write(cache_file, JSON.generate(entries))
|
|
67
|
+
entries.size
|
|
46
68
|
end
|
|
47
69
|
|
|
48
70
|
def reload!
|
|
49
|
-
@registry
|
|
71
|
+
@registry = nil
|
|
72
|
+
@loaded_at = nil
|
|
50
73
|
end
|
|
51
74
|
|
|
52
75
|
def cache_file
|
|
53
|
-
File.join(project_root, "tmp", "active_harness", "
|
|
76
|
+
File.join(project_root, "tmp", "active_harness", "openrouter_pricing.json")
|
|
54
77
|
end
|
|
55
78
|
|
|
56
79
|
private
|
|
57
80
|
|
|
81
|
+
# ── Freshness ────────────────────────────────────────────────────
|
|
82
|
+
|
|
58
83
|
def ensure_fresh_registry
|
|
59
|
-
return if
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
63
98
|
end
|
|
64
99
|
|
|
65
|
-
def
|
|
66
|
-
File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) <
|
|
100
|
+
def file_fresh?
|
|
101
|
+
File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < MEMORY_TTL
|
|
67
102
|
end
|
|
68
103
|
|
|
69
104
|
def registry
|
|
70
|
-
@registry ||=
|
|
71
|
-
return [] unless File.exist?(cache_file)
|
|
72
|
-
JSON.parse(File.read(cache_file), symbolize_names: true)
|
|
73
|
-
rescue JSON::ParserError
|
|
74
|
-
[]
|
|
75
|
-
end
|
|
105
|
+
@registry ||= []
|
|
76
106
|
end
|
|
77
107
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
[]
|
|
84
114
|
end
|
|
85
115
|
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
90
138
|
|
|
91
|
-
|
|
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
|
|
92
148
|
|
|
149
|
+
# Normalize a raw API model hash into our cache entry format.
|
|
150
|
+
def normalize(m)
|
|
151
|
+
p = m[:pricing] || {}
|
|
93
152
|
{
|
|
94
|
-
id:
|
|
95
|
-
name:
|
|
96
|
-
input_modalities:
|
|
97
|
-
output_modalities:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
103
166
|
}
|
|
104
167
|
end
|
|
105
168
|
|
|
106
|
-
#
|
|
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
|
+
|
|
107
201
|
def fetch_endpoint_pricing(model_id)
|
|
108
202
|
uri = URI("#{API_BASE}/#{model_id}/endpoints")
|
|
109
|
-
|
|
110
|
-
data = JSON.parse(
|
|
203
|
+
resp = http_get(uri)
|
|
204
|
+
data = JSON.parse(resp.body, symbolize_names: true)
|
|
111
205
|
endpoints = data.dig(:data, :endpoints) || []
|
|
112
|
-
|
|
113
|
-
# Prefer the first endpoint with status == 0 (online), else first available.
|
|
114
206
|
ep = endpoints.find { |e| e[:status] == 0 } || endpoints.first
|
|
115
207
|
ep&.dig(:pricing)
|
|
116
208
|
rescue StandardError
|
|
117
209
|
nil
|
|
118
210
|
end
|
|
119
211
|
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
|
|
122
220
|
def build_price(raw)
|
|
123
|
-
|
|
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")
|
|
124
228
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
129
269
|
|
|
130
|
-
|
|
270
|
+
# Primary input for cost calculation and sorting
|
|
271
|
+
primary_input = is_transcription ? audio_in_pm : text_in_pm
|
|
131
272
|
|
|
132
|
-
|
|
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]
|
|
133
276
|
|
|
134
277
|
Pricing::ModelPrice.new(
|
|
135
278
|
id: raw[:id],
|
|
136
279
|
name: raw[:name],
|
|
137
280
|
provider: "openrouter",
|
|
138
|
-
input_per_million:
|
|
139
|
-
output_per_million:
|
|
140
|
-
cache_read_input_per_million:
|
|
141
|
-
cache_write_input_per_million:
|
|
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,
|
|
142
285
|
context_window: nil,
|
|
143
286
|
max_output_tokens: nil,
|
|
144
|
-
input_modalities:
|
|
145
|
-
output_modalities:
|
|
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
|
|
146
294
|
)
|
|
147
295
|
end
|
|
148
296
|
|
|
149
|
-
#
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return nil if value.nil? || value.to_s.empty?
|
|
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?
|
|
153
300
|
f = value.to_f
|
|
154
301
|
return nil if f <= 0
|
|
155
302
|
(f * 1_000_000).round(6)
|
|
156
303
|
end
|
|
157
304
|
|
|
158
305
|
def http_get(uri)
|
|
159
|
-
|
|
160
|
-
|
|
306
|
+
resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 15) do |h|
|
|
307
|
+
h.get(uri.request_uri)
|
|
161
308
|
end
|
|
162
|
-
raise "OpenRouter API
|
|
163
|
-
|
|
309
|
+
raise "OpenRouter API #{resp.code} for #{uri}" unless resp.is_a?(Net::HTTPSuccess)
|
|
310
|
+
resp
|
|
164
311
|
end
|
|
165
312
|
|
|
166
313
|
def project_root
|
|
@@ -13,33 +13,47 @@ module ActiveHarness
|
|
|
13
13
|
# Pricing.providers.openai → Array<ModelPrice>
|
|
14
14
|
# Pricing.update → refreshes ModelsDev cache
|
|
15
15
|
module Pricing
|
|
16
|
-
# Pricing rates for a single model
|
|
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.
|
|
17
20
|
ModelPrice = Struct.new(
|
|
18
21
|
:id,
|
|
19
22
|
:name,
|
|
20
23
|
:provider,
|
|
21
|
-
|
|
22
|
-
:
|
|
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)
|
|
23
27
|
:cache_read_input_per_million,
|
|
24
28
|
:cache_write_input_per_million,
|
|
25
29
|
:context_window,
|
|
26
30
|
:max_output_tokens,
|
|
27
31
|
:input_modalities,
|
|
28
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
|
|
29
39
|
keyword_init: true
|
|
30
40
|
) do
|
|
31
41
|
# Capability tags derived from modality data.
|
|
32
|
-
# Possible values: "vision", "pdf", "audio", "video", "imggen", "embed"
|
|
42
|
+
# Possible values: "vision", "pdf", "audio", "video", "imggen", "embed",
|
|
43
|
+
# "speech", "transcription", "rerank"
|
|
33
44
|
def categories
|
|
34
|
-
inp = input_modalities
|
|
35
|
-
out = output_modalities
|
|
45
|
+
inp = Array(input_modalities)
|
|
46
|
+
out = Array(output_modalities)
|
|
36
47
|
cats = []
|
|
37
|
-
cats << "vision"
|
|
38
|
-
cats << "pdf"
|
|
39
|
-
cats << "audio"
|
|
40
|
-
cats << "video"
|
|
41
|
-
cats << "imggen"
|
|
42
|
-
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")
|
|
43
57
|
cats
|
|
44
58
|
end
|
|
45
59
|
|
|
@@ -91,6 +105,13 @@ module ActiveHarness
|
|
|
91
105
|
# Facade — delegates to ModelsDev (general fallback source)
|
|
92
106
|
# ---------------------------------------------------------------------------
|
|
93
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
|
+
|
|
94
115
|
def find(model_id)
|
|
95
116
|
ModelsDev.find(model_id)
|
|
96
117
|
end
|
data/lib/active_harness.rb
CHANGED