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 +4 -4
- data/lib/active_harness/agent/cost.rb +28 -0
- data/lib/active_harness/agent.rb +4 -1
- data/lib/active_harness/costs.rb +264 -0
- data/lib/active_harness/data/models.json +61458 -0
- data/lib/active_harness/result.rb +2 -1
- data/lib/active_harness.rb +1 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c5339e68dc9f7de87fc67a4e986cd1858e86f52e95ee277ee0f12563eedf428
|
|
4
|
+
data.tar.gz: d563d7088e6b29c2119160a9eb0e0a39eb550d12e1bf828937a7d20fddcf7ad4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/active_harness/agent.rb
CHANGED
|
@@ -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:
|
|
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
|