active_harness 0.2.11 → 0.2.13

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: 0c5339e68dc9f7de87fc67a4e986cd1858e86f52e95ee277ee0f12563eedf428
4
+ data.tar.gz: d563d7088e6b29c2119160a9eb0e0a39eb550d12e1bf828937a7d20fddcf7ad4
5
5
  SHA512:
6
- metadata.gz: f7e1f9b8a0c0f88d300df2fc018dc5580524fd67b44c04df359a77bab67a987b20d19823d88fa7d42c9ae6dbfc140e520c0791c7260119b5787687bb6d22c8ca
7
- data.tar.gz: c7c24cb33a57a75f9edce08484573dabdd35a938543de5acf46ab670c78fc8cffe7a288ed2a9ddf703ef350519888609c80c54bc42242ff48febd7e245c9cce6
6
+ metadata.gz: 9d710fedd64ae1bd1ee40616aacb884e698491ec223895651c1ad28a307121575f5078df54074e3218ade227cf376f20a881af15ecef640e63f74887cd5a9115
7
+ data.tar.gz: '075778aea6ae0b53624b18fd64835cdaaec6e0381ff7616faab86c2b9813d5ae80830bdcf59ce71b255a13930ad6d35f99b1953bde11e58c7a8bda03c36732df'
@@ -0,0 +1,28 @@
1
+ module ActiveHarness
2
+ class Agent
3
+ private
4
+
5
+ # Calculates the monetary cost of a single request based on token usage
6
+ # and pricing data from ActiveHarness::Costs.
7
+ #
8
+ # Returns a hash { input_cost:, output_cost:, total_cost: } in USD,
9
+ # or nil if usage is absent or the model is not found in the pricing registry.
10
+ def calculate_cost(model_id, usage)
11
+ return nil if model_id.nil? || usage.nil?
12
+
13
+ pricing = ActiveHarness::Costs.find(model_id.to_s)
14
+ return nil unless pricing&.input_per_million && pricing&.output_per_million
15
+
16
+ input_cost = (usage[:input_tokens].to_f / 1_000_000) * pricing.input_per_million
17
+ output_cost = (usage[:output_tokens].to_f / 1_000_000) * pricing.output_per_million
18
+
19
+ {
20
+ input_cost: input_cost.round(8),
21
+ output_cost: output_cost.round(8),
22
+ total_cost: (input_cost + output_cost).round(8)
23
+ }
24
+ rescue StandardError
25
+ nil
26
+ end
27
+ end
28
+ end
@@ -113,6 +113,7 @@ module ActiveHarness
113
113
  def build_result(response, entry, attempts, elapsed)
114
114
  raw = response[:content]
115
115
  parsed = parse_output(raw)
116
+ usage = response[:usage]
116
117
 
117
118
  Result.new(
118
119
  input: @input,
@@ -125,7 +126,8 @@ module ActiveHarness
125
126
  model_list: model_list,
126
127
  attempts: attempts,
127
128
  execution_time: elapsed,
128
- usage: response[:usage]
129
+ usage: usage,
130
+ cost: calculate_cost(entry[:model], usage)
129
131
  )
130
132
  end
131
133
 
@@ -152,4 +154,5 @@ require_relative "agent/models"
152
154
  require_relative "agent/providers"
153
155
  require_relative "agent/output_parser"
154
156
  require_relative "agent/ruby_llm_backend"
157
+ require_relative "agent/cost"
155
158
 
@@ -0,0 +1,264 @@
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 ||= load_registry
187
+ end
188
+
189
+ def load_registry
190
+ if File.exist?(cache_file)
191
+ begin
192
+ data = JSON.parse(File.read(cache_file), symbolize_names: true)
193
+ return data if data.is_a?(Array)
194
+ rescue JSON::ParserError
195
+ # Cache file corrupted — fall through to bundled data
196
+ end
197
+ end
198
+ JSON.parse(File.read(BUNDLED_DATA_FILE), symbolize_names: true)
199
+ rescue JSON::ParserError, Errno::ENOENT
200
+ []
201
+ end
202
+
203
+ def fetch_models_dev
204
+ uri = URI(MODELS_DEV_URL)
205
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
206
+ http.get(uri.request_uri)
207
+ end
208
+ raise "models.dev returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
209
+
210
+ JSON.parse(response.body, symbolize_names: true)
211
+ end
212
+
213
+ def extract_models(raw_api)
214
+ allowed = available_providers.to_set
215
+
216
+ raw_api.flat_map do |provider_key, provider_data|
217
+ ah_provider = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
218
+ next [] unless ah_provider && allowed.include?(ah_provider)
219
+
220
+ models_hash = provider_data.is_a?(Hash) ? (provider_data[:models] || {}) : {}
221
+ models_hash.values.filter_map do |m|
222
+ next unless m.is_a?(Hash) && m[:id]
223
+
224
+ cost = m[:cost] || {}
225
+ standard = {
226
+ input_per_million: cost[:input],
227
+ output_per_million: cost[:output],
228
+ cache_read_input_per_million: cost[:cache_read],
229
+ cache_write_input_per_million: cost[:cache_write]
230
+ }.compact
231
+
232
+ {
233
+ id: m[:id],
234
+ name: m[:name] || m[:id],
235
+ provider: ah_provider,
236
+ pricing: standard.any? ? { text_tokens: { standard: standard } } : {}
237
+ }
238
+ end
239
+ end
240
+ end
241
+
242
+ def build_cost(raw)
243
+ standard = raw.dig(:pricing, :text_tokens, :standard) || {}
244
+ ModelCost.new(
245
+ id: raw[:id],
246
+ name: raw[:name],
247
+ provider: raw[:provider],
248
+ input_per_million: standard[:input_per_million],
249
+ output_per_million: standard[:output_per_million],
250
+ cache_read_input_per_million: standard[:cache_read_input_per_million],
251
+ cache_write_input_per_million: standard[:cache_write_input_per_million]
252
+ )
253
+ end
254
+
255
+ def project_root
256
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
257
+ Rails.root.to_s
258
+ else
259
+ Dir.pwd
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end