active_harness 0.2.33 → 0.2.34

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,59 +1,19 @@
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 (per-million USD).
57
17
  ModelPrice = Struct.new(
58
18
  :id,
59
19
  :name,
@@ -68,7 +28,7 @@ module ActiveHarness
68
28
  :output_modalities,
69
29
  keyword_init: true
70
30
  ) do
71
- # Returns capability tags derived from modality data and model id/name.
31
+ # Capability tags derived from modality data.
72
32
  # Possible values: "vision", "pdf", "audio", "video", "imggen", "embed"
73
33
  def categories
74
34
  inp = input_modalities || []
@@ -93,201 +53,78 @@ module ActiveHarness
93
53
  end
94
54
  end
95
55
 
96
- # Proxy object that exposes providers as methods and via [].
56
+ # Proxy returned by Pricing.providers — exposes providers as methods and [].
97
57
  class ProvidersProxy
58
+ def initialize(source = nil)
59
+ @source = source
60
+ end
61
+
98
62
  def [](name)
99
- ActiveHarness::Pricing.for_provider(name.to_s)
63
+ source.for_provider(name.to_s)
100
64
  end
101
65
 
102
66
  def list
103
- ActiveHarness::Pricing.provider_names
67
+ source.provider_names
104
68
  end
105
69
 
106
70
  def method_missing(name, *args, &block)
107
71
  provider = name.to_s
108
- if ActiveHarness::Pricing.provider_names.include?(provider)
109
- ActiveHarness::Pricing.for_provider(provider)
72
+ if source.provider_names.include?(provider)
73
+ source.for_provider(provider)
110
74
  else
111
75
  super
112
76
  end
113
77
  end
114
78
 
115
79
  def respond_to_missing?(name, include_private = false)
116
- ActiveHarness::Pricing.provider_names.include?(name.to_s) || super
80
+ source.provider_names.include?(name.to_s) || super
81
+ end
82
+
83
+ private
84
+
85
+ def source
86
+ @source || ModelsDev
117
87
  end
118
88
  end
119
89
 
90
+ # ---------------------------------------------------------------------------
91
+ # Facade — delegates to ModelsDev (general fallback source)
92
+ # ---------------------------------------------------------------------------
120
93
  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) }
94
+ def find(model_id)
95
+ ModelsDev.find(model_id)
126
96
  end
127
97
 
128
- # Returns pricing data for a single model by ID, or nil if not found.
129
- 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
98
+ def all
99
+ ModelsDev.all
133
100
  end
134
101
 
135
- # Returns a ProvidersProxy for provider-scoped access.
136
102
  def providers
137
- @providers_proxy ||= ProvidersProxy.new
103
+ ModelsDev.providers
138
104
  end
139
105
 
140
- # Returns pricing data for all models from the given provider.
141
106
  def for_provider(name)
142
- ensure_fresh_registry
143
- registry
144
- .select { |m| m[:provider] == name.to_s }
145
- .map { |m| build_cost(m) }
107
+ ModelsDev.for_provider(name)
146
108
  end
147
109
 
148
- # Returns a sorted list of provider names that have data.
149
110
  def provider_names
150
- @provider_names ||= begin
151
- ensure_fresh_registry
152
- registry.map { |m| m[:provider] }.uniq.sort
153
- end
111
+ ModelsDev.provider_names
154
112
  end
155
113
 
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
114
  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
115
+ ModelsDev.update
168
116
  end
169
117
 
170
- # Reloads registry from disk on next access.
171
118
  def reload!
172
- @registry = nil
173
- @provider_names = nil
174
- nil
119
+ ModelsDev.reload!
175
120
  end
176
121
 
177
- # Path to the per-project cache file.
178
122
  def cache_file
179
- File.join(project_root, "tmp", "active_harness", "pricing.json")
123
+ ModelsDev.cache_file
180
124
  end
181
125
 
182
- # Names of providers supported by ActiveHarness (derived from providers/ directory).
183
126
  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
127
+ ModelsDev.available_providers
291
128
  end
292
129
  end
293
130
  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
@@ -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.34"
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.34
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