active_harness 0.2.11 → 0.2.12

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: 38c99c81636b8ffa378a8f753fbdc86f269d843bf3c78769cbb6600c5ef9b9af
4
- data.tar.gz: b515af2c5c13e84db218059490302bc16f0b7726d7c11b0ebc18e3e6fe883978
3
+ metadata.gz: f4f069d1d894324475b0a5395d5728fa77626698255bc3400428ef66a2228ac4
4
+ data.tar.gz: 1cbfee779b26e122d0e01e3fa1e4a80907398eec5f0895b28bc1dcb19484d8a4
5
5
  SHA512:
6
- metadata.gz: f7e1f9b8a0c0f88d300df2fc018dc5580524fd67b44c04df359a77bab67a987b20d19823d88fa7d42c9ae6dbfc140e520c0791c7260119b5787687bb6d22c8ca
7
- data.tar.gz: c7c24cb33a57a75f9edce08484573dabdd35a938543de5acf46ab670c78fc8cffe7a288ed2a9ddf703ef350519888609c80c54bc42242ff48febd7e245c9cce6
6
+ metadata.gz: c2293baad25b739468f7fb3306583f2e475409edc7b1533a03e83da9e28776ba17a27ae6a9aebf47fb18d4a1953957240d2615a277443e39781876a685d7da1e
7
+ data.tar.gz: 6e78901ee75204d853b53df08baef9215f7f225f15065da771262ee084af17243a796ab132526701b016e4d0066762338e01fd01fd36f5e316e1045599cc8c3b
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "fileutils"
7
+
8
+ module ActiveHarness
9
+ # Provides access to AI model pricing data, filtered to providers supported
10
+ # by ActiveHarness (files present in lib/active_harness/providers/).
11
+ #
12
+ # Data source priority:
13
+ # 1. {project_root}/tmp/active_harness/costs.json — fetched cache (refreshed once per day)
14
+ # 2. lib/active_harness/data/models.json — bundled fallback (ships with gem)
15
+ #
16
+ # Usage:
17
+ #
18
+ # # Fetch fresh data and save to tmp cache (also called automatically when stale)
19
+ # ActiveHarness::Costs.update
20
+ #
21
+ # # All models (auto-updates cache if missing or older than 24h)
22
+ # ActiveHarness::Costs.all
23
+ #
24
+ # # Single model by ID
25
+ # ActiveHarness::Costs.find("gpt-4o")
26
+ #
27
+ # # By provider — method or bracket syntax
28
+ # ActiveHarness::Costs.providers.openai
29
+ # ActiveHarness::Costs.providers[:anthropic]
30
+ #
31
+ # # List providers that have data
32
+ # ActiveHarness::Costs.providers.list
33
+ #
34
+ module Costs
35
+ BUNDLED_DATA_FILE = File.expand_path("data/models.json", __dir__).freeze
36
+ MODELS_DEV_URL = "https://models.dev/api.json"
37
+ CACHE_TTL = 86_400 # 24 hours in seconds
38
+
39
+ # Maps models.dev provider keys → ActiveHarness provider names.
40
+ # Only entries whose value matches a file in providers/ will be kept.
41
+ MODELS_DEV_PROVIDER_MAP = {
42
+ "openai" => "openai",
43
+ "anthropic" => "anthropic",
44
+ "google" => "gemini",
45
+ "google-vertex" => "vertexai",
46
+ "amazon-bedrock" => "bedrock",
47
+ "deepseek" => "deepseek",
48
+ "mistral" => "mistral",
49
+ "openrouter" => "openrouter",
50
+ "perplexity" => "perplexity",
51
+ "xai" => "xai",
52
+ "groq" => "groq",
53
+ "azure" => "azure"
54
+ }.freeze
55
+
56
+ # Value object representing the pricing for a single model.
57
+ ModelCost = Struct.new(
58
+ :id,
59
+ :name,
60
+ :provider,
61
+ :input_per_million,
62
+ :output_per_million,
63
+ :cache_read_input_per_million,
64
+ :cache_write_input_per_million,
65
+ keyword_init: true
66
+ ) do
67
+ def inspect
68
+ parts = ["id=#{id.inspect}", "provider=#{provider.inspect}"]
69
+ parts << "input=$#{input_per_million}/M" if input_per_million
70
+ parts << "output=$#{output_per_million}/M" if output_per_million
71
+ "#<ModelCost #{parts.join(' ')}>"
72
+ end
73
+ end
74
+
75
+ # Proxy object that exposes providers as methods and via [].
76
+ class ProvidersProxy
77
+ def [](name)
78
+ ActiveHarness::Costs.for_provider(name.to_s)
79
+ end
80
+
81
+ def list
82
+ ActiveHarness::Costs.provider_names
83
+ end
84
+
85
+ def method_missing(name, *args, &block)
86
+ provider = name.to_s
87
+ if ActiveHarness::Costs.provider_names.include?(provider)
88
+ ActiveHarness::Costs.for_provider(provider)
89
+ else
90
+ super
91
+ end
92
+ end
93
+
94
+ def respond_to_missing?(name, include_private = false)
95
+ ActiveHarness::Costs.provider_names.include?(name.to_s) || super
96
+ end
97
+ end
98
+
99
+ class << self
100
+ # Returns pricing data for all models from supported providers.
101
+ # Automatically fetches fresh data if the cache is missing or older than 24h.
102
+ def all
103
+ ensure_fresh_registry
104
+ registry.map { |raw| build_cost(raw) }
105
+ end
106
+
107
+ # Returns pricing data for a single model by ID, or nil if not found.
108
+ def find(model_id)
109
+ ensure_fresh_registry
110
+ raw = registry.find { |m| m[:id] == model_id.to_s }
111
+ raw ? build_cost(raw) : nil
112
+ end
113
+
114
+ # Returns a ProvidersProxy for provider-scoped access.
115
+ def providers
116
+ @providers_proxy ||= ProvidersProxy.new
117
+ end
118
+
119
+ # Returns pricing data for all models from the given provider.
120
+ def for_provider(name)
121
+ ensure_fresh_registry
122
+ registry
123
+ .select { |m| m[:provider] == name.to_s }
124
+ .map { |m| build_cost(m) }
125
+ end
126
+
127
+ # Returns a sorted list of provider names that have data.
128
+ def provider_names
129
+ @provider_names ||= begin
130
+ ensure_fresh_registry
131
+ registry.map { |m| m[:provider] }.uniq.sort
132
+ end
133
+ end
134
+
135
+ # Fetches fresh pricing data from models.dev, filters to supported providers,
136
+ # and writes the result to {project_root}/tmp/active_harness/costs.json.
137
+ # Returns the number of models saved, or raises on HTTP failure.
138
+ def update
139
+ raw_api = fetch_models_dev
140
+ models = extract_models(raw_api)
141
+
142
+ FileUtils.mkdir_p(File.dirname(cache_file))
143
+ File.write(cache_file, JSON.generate(models))
144
+
145
+ reload!
146
+ models.size
147
+ end
148
+
149
+ # Reloads registry from disk on next access.
150
+ def reload!
151
+ @registry = nil
152
+ @provider_names = nil
153
+ nil
154
+ end
155
+
156
+ # Path to the per-project cache file.
157
+ def cache_file
158
+ File.join(project_root, "tmp", "active_harness", "costs.json")
159
+ end
160
+
161
+ # Names of providers supported by ActiveHarness (derived from providers/ directory).
162
+ def available_providers
163
+ @available_providers ||= begin
164
+ providers_dir = File.expand_path("providers", __dir__)
165
+ Dir.glob("#{providers_dir}/*.rb")
166
+ .map { |f| File.basename(f, ".rb") }
167
+ .reject { |n| %w[base custom].include?(n) }
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def ensure_fresh_registry
174
+ return if cache_file_fresh?
175
+
176
+ update
177
+ rescue StandardError
178
+ # Network unavailable or update failed — fall back to bundled/stale cache silently
179
+ end
180
+
181
+ def cache_file_fresh?
182
+ File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < CACHE_TTL
183
+ end
184
+
185
+ def registry
186
+ @registry ||= begin
187
+ source = File.exist?(cache_file) ? cache_file : BUNDLED_DATA_FILE
188
+ JSON.parse(File.read(source), symbolize_names: true)
189
+ end
190
+ end
191
+
192
+ def fetch_models_dev
193
+ uri = URI(MODELS_DEV_URL)
194
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
195
+ http.get(uri.request_uri)
196
+ end
197
+ raise "models.dev returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
198
+
199
+ JSON.parse(response.body, symbolize_names: true)
200
+ end
201
+
202
+ def extract_models(raw_api)
203
+ allowed = available_providers.to_set
204
+
205
+ raw_api.flat_map do |provider_key, provider_data|
206
+ ah_provider = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
207
+ next [] unless ah_provider && allowed.include?(ah_provider)
208
+
209
+ models_hash = provider_data.is_a?(Hash) ? (provider_data[:models] || {}) : {}
210
+ models_hash.values.filter_map do |m|
211
+ next unless m.is_a?(Hash) && m[:id]
212
+
213
+ cost = m[:cost] || {}
214
+ standard = {
215
+ input_per_million: cost[:input],
216
+ output_per_million: cost[:output],
217
+ cache_read_input_per_million: cost[:cache_read],
218
+ cache_write_input_per_million: cost[:cache_write]
219
+ }.compact
220
+
221
+ {
222
+ id: m[:id],
223
+ name: m[:name] || m[:id],
224
+ provider: ah_provider,
225
+ pricing: standard.any? ? { text_tokens: { standard: standard } } : {}
226
+ }
227
+ end
228
+ end
229
+ end
230
+
231
+ def build_cost(raw)
232
+ standard = raw.dig(:pricing, :text_tokens, :standard) || {}
233
+ ModelCost.new(
234
+ id: raw[:id],
235
+ name: raw[:name],
236
+ provider: raw[:provider],
237
+ input_per_million: standard[:input_per_million],
238
+ output_per_million: standard[:output_per_million],
239
+ cache_read_input_per_million: standard[:cache_read_input_per_million],
240
+ cache_write_input_per_million: standard[:cache_write_input_per_million]
241
+ )
242
+ end
243
+
244
+ def project_root
245
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
246
+ Rails.root.to_s
247
+ else
248
+ Dir.pwd
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end