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 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
@@ -0,0 +1,7 @@
1
+ require_relative "active_harness/pricing"
2
+ require_relative "active_harness/pricing/models_dev"
3
+ require_relative "active_harness/pricing/openrouter"
4
+
5
+ module ActiveHarnessPricing
6
+ VERSION = "0.1.0"
7
+ 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: []