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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39b817ef529f0158a20634ba42a61ae0078404238038b22d63c2c8cc50a1ff3a
4
- data.tar.gz: bcf8d21cb68449e3962f2bb9b515c39237c7774a6ca300a803775ae09c275567
3
+ metadata.gz: f08d4dd9d2254cb4895919e690fd485cab05d2b89c53786383b0da353aeb5f82
4
+ data.tar.gz: 1aa9a3e3a7cd8a2e83179b88a9f3623f39b4fa59e7386d43094de2739f44d50a
5
5
  SHA512:
6
- metadata.gz: e09428cecc83c7fe3caed3c45c4caf000ee07a5b783acbfe3b591de02615af98498e7b490f0b6e5d7a34b5dcbdf12a4afd252a197c4fa71e6a1eabfd466d4b92
7
- data.tar.gz: e80805c251e3a48ce0ab032a9c8904dae3401c6d02bedb9d1372df32ada7b9609880096c2fb39c62d575aecdace7266d068f6e0ed165ece989038b29e1b74885
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
- CACHE_TTL = 86_400
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", "pricing_models_dev.json")
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 cache_file_fresh?
102
- update
103
- rescue StandardError
104
- # Network unavailable — fall back to bundled/stale cache silently
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 cache_file_fresh?
108
- File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < CACHE_TTL
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 ||= load_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 image-model pricing directly from the OpenRouter API.
8
+ # Fetches complete pricing for all OpenRouter models across all modalities.
9
9
  #
10
- # models.dev only has generic prompt/completion rates for OpenRouter models.
11
- # OpenRouter's own /endpoints API exposes a separate `image_output` rate
12
- # that reflects the real cost of image generation tokens.
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
- # Data flow:
15
- # 1. GET /api/v1/models?output_modalities=image → list of image models
16
- # 2. GET /api/v1/models/{id}/endpoints → per-model, picks first
17
- # active endpoint to get image_output rate
18
- # 3. Result cached to tmp/active_harness/pricing_openrouter.json for 24h
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 = "https://openrouter.ai/api/v1/models"
25
- CACHE_TTL = 86_400
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
- # Fetches fresh data from OpenRouter and writes the cache.
37
- # Returns the number of models saved.
38
- def update
39
- image_models = fetch_image_models
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(enriched))
44
- reload!
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 = nil
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", "pricing_openrouter.json")
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 cache_fresh?
60
- update
61
- rescue StandardError
62
- # network unavailable — fall back to stale cache silently
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 cache_fresh?
66
- File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < CACHE_TTL
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 ||= begin
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
- # Fetch all models with image output from OpenRouter.
79
- def fetch_image_models
80
- uri = URI("#{API_BASE}?output_modalities=image")
81
- response = http_get(uri)
82
- data = JSON.parse(response.body, symbolize_names: true)
83
- data[:data] || []
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
- # Fetch /endpoints for the model and merge image_output pricing.
87
- def enrich_with_endpoint(model)
88
- model_id = model[:id]
89
- base_pricing = model[:pricing] || {}
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
- endpoint_pricing = fetch_endpoint_pricing(model_id)
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: model_id,
95
- name: model[:name],
96
- input_modalities: model.dig(:architecture, :input_modalities) || [],
97
- output_modalities: model.dig(:architecture, :output_modalities) || [],
98
- prompt: base_pricing[:prompt].to_s,
99
- completion: base_pricing[:completion].to_s,
100
- image_output: endpoint_pricing&.dig(:image_output).to_s,
101
- image: endpoint_pricing&.dig(:image).to_s,
102
- cache_read: (endpoint_pricing&.dig(:input_cache_read) || base_pricing[:input_cache_read]).to_s
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
- # Returns the pricing hash from the first active endpoint, or nil.
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
- response = http_get(uri)
110
- data = JSON.parse(response.body, symbolize_names: true)
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
- # Build a ModelPrice compatible with the rest of the Pricing system.
121
- # For image-output models, uses image_output rate for output_per_million.
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
- is_image_output = Array(raw[:output_modalities]).include?("image")
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
- input_pm = to_per_million(raw[:prompt])
126
- completion_pm = to_per_million(raw[:completion])
127
- image_out_pm = to_per_million(raw[:image_output])
128
- cache_pm = to_per_million(raw[:cache_read])
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
- output_pm = (is_image_output && image_out_pm) ? image_out_pm : completion_pm
270
+ # Primary input for cost calculation and sorting
271
+ primary_input = is_transcription ? audio_in_pm : text_in_pm
131
272
 
132
- return nil unless input_pm || output_pm
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: input_pm,
139
- output_per_million: output_pm,
140
- cache_read_input_per_million: cache_pm,
141
- cache_write_input_per_million: nil,
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: Array(raw[:input_modalities]),
145
- output_modalities: Array(raw[: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
- # OpenRouter pricing fields are per-token strings (e.g. "0.000008").
150
- # Convert to per-million float. Returns nil for zero/blank values.
151
- def to_per_million(value)
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
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 10) do |http|
160
- http.get(uri.request_uri)
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 returned HTTP #{response.code} for #{uri}" unless response.is_a?(Net::HTTPSuccess)
163
- response
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 (per-million USD).
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
- :input_per_million,
22
- :output_per_million,
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" if inp.include?("image")
38
- cats << "pdf" if inp.include?("pdf")
39
- cats << "audio" if inp.include?("audio") || out.include?("audio")
40
- cats << "video" if inp.include?("video") || out.include?("video")
41
- cats << "imggen" if out.include?("image")
42
- cats << "embed" if id.to_s.match?(/embed/i) || name.to_s.match?(/embed/i)
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
@@ -8,5 +8,9 @@ module ActiveHarness
8
8
  app.config.autoload_paths << path.to_s if path.exist?
9
9
  end
10
10
  end
11
+
12
+ config.after_initialize do
13
+ ActiveHarness::Pricing.preload!
14
+ end
11
15
  end
12
16
  end
@@ -34,7 +34,7 @@ require_relative "active_harness/pipeline"
34
34
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
35
35
 
36
36
  module ActiveHarness
37
- VERSION = "0.2.34"
37
+ VERSION = "0.2.35"
38
38
 
39
39
  class << self
40
40
  # Configure ActiveHarness.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.34
4
+ version: 0.2.35
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher