active_harness 0.2.33 → 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.
@@ -1,85 +1,59 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "json"
4
- require "net/http"
5
- require "uri"
6
- require "fileutils"
7
2
 
8
3
  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/pricing.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::Pricing.update
20
- #
21
- # # All models (auto-updates cache if missing or older than 24h)
22
- # ActiveHarness::Pricing.all
23
- #
24
- # # Single model by ID
25
- # ActiveHarness::Pricing.find("gpt-4o")
26
- #
27
- # # By provider — method or bracket syntax
28
- # ActiveHarness::Pricing.providers.openai
29
- # ActiveHarness::Pricing.providers[:anthropic]
4
+ # Pricing namespace shared types and a facade over pricing source modules.
30
5
  #
31
- # # List providers that have data
32
- # ActiveHarness::Pricing.providers.list
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)
33
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
34
15
  module Pricing
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.
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.
57
20
  ModelPrice = Struct.new(
58
21
  :id,
59
22
  :name,
60
23
  :provider,
61
- :input_per_million,
62
- :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)
63
27
  :cache_read_input_per_million,
64
28
  :cache_write_input_per_million,
65
29
  :context_window,
66
30
  :max_output_tokens,
67
31
  :input_modalities,
68
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
69
39
  keyword_init: true
70
40
  ) do
71
- # Returns capability tags derived from modality data and model id/name.
72
- # Possible values: "vision", "pdf", "audio", "video", "imggen", "embed"
41
+ # Capability tags derived from modality data.
42
+ # Possible values: "vision", "pdf", "audio", "video", "imggen", "embed",
43
+ # "speech", "transcription", "rerank"
73
44
  def categories
74
- inp = input_modalities || []
75
- out = output_modalities || []
45
+ inp = Array(input_modalities)
46
+ out = Array(output_modalities)
76
47
  cats = []
77
- cats << "vision" if inp.include?("image")
78
- cats << "pdf" if inp.include?("pdf")
79
- cats << "audio" if inp.include?("audio") || out.include?("audio")
80
- cats << "video" if inp.include?("video") || out.include?("video")
81
- cats << "imggen" if out.include?("image")
82
- 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")
83
57
  cats
84
58
  end
85
59
 
@@ -93,201 +67,85 @@ module ActiveHarness
93
67
  end
94
68
  end
95
69
 
96
- # Proxy object that exposes providers as methods and via [].
70
+ # Proxy returned by Pricing.providers — exposes providers as methods and [].
97
71
  class ProvidersProxy
72
+ def initialize(source = nil)
73
+ @source = source
74
+ end
75
+
98
76
  def [](name)
99
- ActiveHarness::Pricing.for_provider(name.to_s)
77
+ source.for_provider(name.to_s)
100
78
  end
101
79
 
102
80
  def list
103
- ActiveHarness::Pricing.provider_names
81
+ source.provider_names
104
82
  end
105
83
 
106
84
  def method_missing(name, *args, &block)
107
85
  provider = name.to_s
108
- if ActiveHarness::Pricing.provider_names.include?(provider)
109
- ActiveHarness::Pricing.for_provider(provider)
86
+ if source.provider_names.include?(provider)
87
+ source.for_provider(provider)
110
88
  else
111
89
  super
112
90
  end
113
91
  end
114
92
 
115
93
  def respond_to_missing?(name, include_private = false)
116
- ActiveHarness::Pricing.provider_names.include?(name.to_s) || super
94
+ source.provider_names.include?(name.to_s) || super
95
+ end
96
+
97
+ private
98
+
99
+ def source
100
+ @source || ModelsDev
117
101
  end
118
102
  end
119
103
 
104
+ # ---------------------------------------------------------------------------
105
+ # Facade — delegates to ModelsDev (general fallback source)
106
+ # ---------------------------------------------------------------------------
120
107
  class << self
121
- # Returns pricing data for all models from supported providers.
122
- # Automatically fetches fresh data if the cache is missing or older than 24h.
123
- def all
124
- ensure_fresh_registry
125
- registry.map { |raw| build_cost(raw) }
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!
126
113
  end
127
114
 
128
- # Returns pricing data for a single model by ID, or nil if not found.
129
115
  def find(model_id)
130
- ensure_fresh_registry
131
- raw = registry.find { |m| m[:id] == model_id.to_s }
132
- raw ? build_cost(raw) : nil
116
+ ModelsDev.find(model_id)
117
+ end
118
+
119
+ def all
120
+ ModelsDev.all
133
121
  end
134
122
 
135
- # Returns a ProvidersProxy for provider-scoped access.
136
123
  def providers
137
- @providers_proxy ||= ProvidersProxy.new
124
+ ModelsDev.providers
138
125
  end
139
126
 
140
- # Returns pricing data for all models from the given provider.
141
127
  def for_provider(name)
142
- ensure_fresh_registry
143
- registry
144
- .select { |m| m[:provider] == name.to_s }
145
- .map { |m| build_cost(m) }
128
+ ModelsDev.for_provider(name)
146
129
  end
147
130
 
148
- # Returns a sorted list of provider names that have data.
149
131
  def provider_names
150
- @provider_names ||= begin
151
- ensure_fresh_registry
152
- registry.map { |m| m[:provider] }.uniq.sort
153
- end
132
+ ModelsDev.provider_names
154
133
  end
155
134
 
156
- # Fetches fresh pricing data from models.dev, filters to supported providers,
157
- # and writes the result to {project_root}/tmp/active_harness/pricing.json.
158
- # Returns the number of models saved, or raises on HTTP failure.
159
135
  def update
160
- raw_api = fetch_models_dev
161
- models = extract_models(raw_api)
162
-
163
- FileUtils.mkdir_p(File.dirname(cache_file))
164
- File.write(cache_file, JSON.generate(models))
165
-
166
- reload!
167
- models.size
136
+ ModelsDev.update
168
137
  end
169
138
 
170
- # Reloads registry from disk on next access.
171
139
  def reload!
172
- @registry = nil
173
- @provider_names = nil
174
- nil
140
+ ModelsDev.reload!
175
141
  end
176
142
 
177
- # Path to the per-project cache file.
178
143
  def cache_file
179
- File.join(project_root, "tmp", "active_harness", "pricing.json")
144
+ ModelsDev.cache_file
180
145
  end
181
146
 
182
- # Names of providers supported by ActiveHarness (derived from providers/ directory).
183
147
  def available_providers
184
- @available_providers ||= begin
185
- providers_dir = File.expand_path("providers", __dir__)
186
- Dir.glob("#{providers_dir}/*.rb")
187
- .map { |f| File.basename(f, ".rb") }
188
- .reject { |n| %w[base custom].include?(n) }
189
- end
190
- end
191
-
192
- private
193
-
194
- def ensure_fresh_registry
195
- return if cache_file_fresh?
196
-
197
- update
198
- rescue StandardError
199
- # Network unavailable or update failed — fall back to bundled/stale cache silently
200
- end
201
-
202
- def cache_file_fresh?
203
- File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < CACHE_TTL
204
- end
205
-
206
- def registry
207
- @registry ||= load_registry
208
- end
209
-
210
- def load_registry
211
- if File.exist?(cache_file)
212
- begin
213
- data = JSON.parse(File.read(cache_file), symbolize_names: true)
214
- return data if data.is_a?(Array)
215
- rescue JSON::ParserError
216
- # Cache file corrupted — fall through to bundled data
217
- end
218
- end
219
- JSON.parse(File.read(BUNDLED_DATA_FILE), symbolize_names: true)
220
- rescue JSON::ParserError, Errno::ENOENT
221
- []
222
- end
223
-
224
- def fetch_models_dev
225
- uri = URI(MODELS_DEV_URL)
226
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
227
- http.get(uri.request_uri)
228
- end
229
- raise "models.dev returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
230
-
231
- JSON.parse(response.body, symbolize_names: true)
232
- end
233
-
234
- def extract_models(raw_api)
235
- allowed = available_providers.to_set
236
-
237
- raw_api.flat_map do |provider_key, provider_data|
238
- ah_provider = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
239
- next [] unless ah_provider && allowed.include?(ah_provider)
240
-
241
- models_hash = provider_data.is_a?(Hash) ? (provider_data[:models] || {}) : {}
242
- models_hash.values.filter_map do |m|
243
- next unless m.is_a?(Hash) && m[:id]
244
-
245
- cost = m[:cost] || {}
246
- standard = {
247
- input_per_million: cost[:input],
248
- output_per_million: cost[:output],
249
- cache_read_input_per_million: cost[:cache_read],
250
- cache_write_input_per_million: cost[:cache_write]
251
- }.compact
252
-
253
- mods = m[:modalities] || {}
254
- {
255
- id: m[:id],
256
- name: m[:name] || m[:id],
257
- provider: ah_provider,
258
- context_window: m[:context_window] || m.dig(:limit, :context),
259
- max_output_tokens: m[:max_output_tokens] || m.dig(:limit, :output),
260
- input_modalities: Array(mods[:input]),
261
- output_modalities: Array(mods[:output]),
262
- pricing: standard.any? ? { text_tokens: { standard: standard } } : {}
263
- }
264
- end
265
- end
266
- end
267
-
268
- def build_cost(raw)
269
- standard = raw.dig(:pricing, :text_tokens, :standard) || {}
270
- ModelPrice.new(
271
- id: raw[:id],
272
- name: raw[:name],
273
- provider: raw[:provider],
274
- input_per_million: standard[:input_per_million],
275
- output_per_million: standard[:output_per_million],
276
- cache_read_input_per_million: standard[:cache_read_input_per_million],
277
- cache_write_input_per_million: standard[:cache_write_input_per_million],
278
- context_window: raw[:context_window],
279
- max_output_tokens: raw[:max_output_tokens],
280
- input_modalities: Array(raw[:input_modalities]),
281
- output_modalities: Array(raw[:output_modalities])
282
- )
283
- end
284
-
285
- def project_root
286
- if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
287
- Rails.root.to_s
288
- else
289
- Dir.pwd
290
- end
148
+ ModelsDev.available_providers
291
149
  end
292
150
  end
293
151
  end
@@ -22,14 +22,18 @@ module ActiveHarness
22
22
 
23
23
  # Normalize OpenAI-compatible usage object to a consistent hash.
24
24
  # Returns nil if the response contains no usage data.
25
+ # provider_cost is included when the provider returns a cost field (e.g. OpenRouter).
25
26
  def extract_usage_openai(data)
26
27
  u = data["usage"]
27
28
  return nil unless u
28
- {
29
+
30
+ result = {
29
31
  input_tokens: u["prompt_tokens"].to_i,
30
32
  output_tokens: u["completion_tokens"].to_i,
31
33
  total_tokens: u["total_tokens"].to_i
32
34
  }
35
+ result[:provider_cost] = u["cost"].to_f if u.key?("cost")
36
+ result
33
37
  end
34
38
 
35
39
  # Normalize Anthropic usage object.
@@ -0,0 +1,73 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ module Images
6
+ class OpenAI < Base
7
+ ENDPOINT = "https://api.openai.com/v1/images/generations"
8
+
9
+ # @param model [String] "dall-e-2", "dall-e-3", "gpt-image-1"
10
+ # @param prompt [String] image description
11
+ # @param size [String] e.g. "1024x1024"
12
+ # @param quality [String] "standard"/"hd" (dall-e-3), "low"/"medium"/"high"/"auto" (gpt-image-1)
13
+ def call(model:, prompt:, size: "1024x1024", quality: nil, **_)
14
+ headers = {
15
+ "Content-Type" => "application/json",
16
+ "Authorization" => "Bearer #{api_key}"
17
+ }
18
+
19
+ raw = post_json(URI(ENDPOINT), headers: headers, body: build_payload(model, prompt, size, quality), timeout: 60)
20
+ data = parse!(raw)
21
+ handle_error!(data)
22
+
23
+ b64 = data.dig("data", 0, "b64_json")
24
+ raise Errors::ProviderError, "No image data in response" unless b64
25
+
26
+ { content: b64, provider: :openai, model: model, usage: nil }
27
+ end
28
+
29
+ private
30
+
31
+ def build_payload(model, prompt, size, quality)
32
+ payload = { model: model, prompt: prompt, n: 1, size: size }
33
+ # gpt-image-* always returns b64_json; older models default to url
34
+ payload[:response_format] = "b64_json" unless model.start_with?("gpt-image")
35
+ payload[:quality] = quality if quality
36
+ payload
37
+ end
38
+
39
+ def api_key
40
+ key = config.openai_api_key.to_s
41
+ raise Errors::InvalidApiKeyError, "openai_api_key is not configured" if key.empty?
42
+ key
43
+ end
44
+
45
+ def handle_error!(data)
46
+ return unless data["error"]
47
+
48
+ msg = data.dig("error", "message").to_s
49
+ code = data.dig("error", "code").to_s
50
+ type = data.dig("error", "type").to_s
51
+ metadata = data["error"].reject { |k, _| %w[message code type].include?(k) }
52
+ metadata = nil if metadata.empty?
53
+
54
+ case code
55
+ when "invalid_api_key", "unauthorized"
56
+ raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
57
+ when "rate_limit_exceeded"
58
+ raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
59
+ when "content_filter"
60
+ raise Errors::SafetyBlockedError.new(msg, error_code: code, metadata: metadata)
61
+ else
62
+ case type
63
+ when "server_error"
64
+ raise Errors::ServerError.new(msg, error_code: code, metadata: metadata)
65
+ else
66
+ raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,66 @@
1
+ require "uri"
2
+
3
+ module ActiveHarness
4
+ module Providers
5
+ module Images
6
+ class OpenRouter < Base
7
+ # @param model [String] e.g. "openai/gpt-5-image-mini", "google/gemini-2.5-flash-image"
8
+ # @param prompt [String] image description
9
+ # @param size [String] ignored by OpenRouter (passed through for future support)
10
+ def call(model:, prompt:, size: nil, quality: nil, **_)
11
+ headers = {
12
+ "Content-Type" => "application/json",
13
+ "Authorization" => "Bearer #{api_key}"
14
+ }
15
+ referer = config.openrouter_http_referer.to_s
16
+ headers["HTTP-Referer"] = referer unless referer.empty?
17
+
18
+ messages = [{ role: "user", content: prompt }]
19
+ body = { model: model, messages: messages, modalities: ["image", "text"] }
20
+ body[:size] = size if size
21
+ body[:quality] = quality if quality
22
+
23
+ raw = post_json(URI(config.openrouter_api_url), headers: headers, body: body, timeout: 120)
24
+ data = parse!(raw)
25
+ handle_error!(data)
26
+
27
+ content = extract_image(data)
28
+ raise Errors::ProviderError, "No image data in response: #{data.dig('choices', 0, 'message')&.keys}" unless content
29
+
30
+ { content: content, provider: :openrouter, model: model, usage: extract_usage_openai(data) }
31
+ end
32
+
33
+ private
34
+
35
+ def extract_image(data)
36
+ images = data.dig("choices", 0, "message", "images")
37
+ return unless images.is_a?(Array) && images.any?
38
+
39
+ images.first&.dig("image_url", "url")
40
+ end
41
+
42
+ def api_key
43
+ key = config.openrouter_api_key.to_s
44
+ raise Errors::InvalidApiKeyError, "openrouter_api_key is not configured" if key.empty?
45
+ key
46
+ end
47
+
48
+ def handle_error!(data)
49
+ return unless data["error"]
50
+
51
+ msg = data.dig("error", "message").to_s
52
+ code = data.dig("error", "code").to_s
53
+ metadata = data.dig("error", "metadata")
54
+
55
+ case code
56
+ when "401" then raise Errors::InvalidApiKeyError.new(msg, error_code: code, metadata: metadata)
57
+ when "402", "429" then raise Errors::RateLimitError.new(msg, error_code: code, metadata: metadata)
58
+ when "500", "502",
59
+ "503", "504" then raise Errors::ProviderUnavailableError.new(msg, error_code: code, metadata: metadata)
60
+ else raise Errors::InvalidRequestError.new(msg, error_code: code, metadata: metadata)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ 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
@@ -21,7 +21,11 @@ require_relative "active_harness/providers/azure"
21
21
  require_relative "active_harness/providers/bedrock"
22
22
  require_relative "active_harness/providers/vertexai"
23
23
  require_relative "active_harness/providers/custom"
24
+ require_relative "active_harness/providers/images/openai"
25
+ require_relative "active_harness/providers/images/openrouter"
24
26
  require_relative "active_harness/pricing"
27
+ require_relative "active_harness/pricing/models_dev"
28
+ require_relative "active_harness/pricing/openrouter"
25
29
  require_relative "active_harness/memory"
26
30
  require_relative "active_harness/agent"
27
31
  require_relative "active_harness/tribunal"
@@ -30,7 +34,7 @@ require_relative "active_harness/pipeline"
30
34
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
31
35
 
32
36
  module ActiveHarness
33
- VERSION = "0.2.33"
37
+ VERSION = "0.2.35"
34
38
 
35
39
  class << self
36
40
  # Configure ActiveHarness.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.33
4
+ version: 0.2.35
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-12 00:00:00.000000000 Z
11
+ date: 2026-06-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -36,6 +36,7 @@ files:
36
36
  - lib/active_harness/agent/cost.rb
37
37
  - lib/active_harness/agent/custom_llm_backend.rb
38
38
  - lib/active_harness/agent/hooks.rb
39
+ - lib/active_harness/agent/image.rb
39
40
  - lib/active_harness/agent/models.rb
40
41
  - lib/active_harness/agent/output_parser.rb
41
42
  - lib/active_harness/agent/prompt.rb
@@ -43,7 +44,6 @@ files:
43
44
  - lib/active_harness/configuration.rb
44
45
  - lib/active_harness/core/errors.rb
45
46
  - lib/active_harness/core/hooks.rb
46
- - lib/active_harness/data/models.json
47
47
  - lib/active_harness/http/client.rb
48
48
  - lib/active_harness/http/retry_policy.rb
49
49
  - lib/active_harness/http/streaming_client.rb
@@ -57,6 +57,8 @@ files:
57
57
  - lib/active_harness/pipeline/hooks.rb
58
58
  - lib/active_harness/pipeline/step.rb
59
59
  - lib/active_harness/pricing.rb
60
+ - lib/active_harness/pricing/models_dev.rb
61
+ - lib/active_harness/pricing/openrouter.rb
60
62
  - lib/active_harness/providers/PROVIDER_CONTRACT.md
61
63
  - lib/active_harness/providers/anthropic.rb
62
64
  - lib/active_harness/providers/azure.rb
@@ -67,6 +69,8 @@ files:
67
69
  - lib/active_harness/providers/gemini.rb
68
70
  - lib/active_harness/providers/gpustack.rb
69
71
  - lib/active_harness/providers/groq.rb
72
+ - lib/active_harness/providers/images/openai.rb
73
+ - lib/active_harness/providers/images/openrouter.rb
70
74
  - lib/active_harness/providers/mistral.rb
71
75
  - lib/active_harness/providers/ollama.rb
72
76
  - lib/active_harness/providers/openai.rb