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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f53c6eb7b468113f17ded0ff474515db8dbb640dca9cda8e7c632f05eba3469b
4
- data.tar.gz: 8ec97a06740588236a2ba6d38271aa62a92c5e91389678e73c94d4042da6d104
3
+ metadata.gz: 39b817ef529f0158a20634ba42a61ae0078404238038b22d63c2c8cc50a1ff3a
4
+ data.tar.gz: bcf8d21cb68449e3962f2bb9b515c39237c7774a6ca300a803775ae09c275567
5
5
  SHA512:
6
- metadata.gz: 32ddb50bbe337cfd32613b878936db4d303e1e49b3648b18d1ff95b5a8cc27e657372b0bea5039d0fb785b169bb8492f468fc472e0ace153c1b7009090dce81c
7
- data.tar.gz: cb45df7677c6f1d946d83722c9e5027f8a2803060116fe2cdb391771acd0bb3750dabfe2321dcb6a5e73841e6d7f7afb666ae10d88e506444d7a586a18257690
6
+ metadata.gz: e09428cecc83c7fe3caed3c45c4caf000ee07a5b783acbfe3b591de02615af98498e7b490f0b6e5d7a34b5dcbdf12a4afd252a197c4fa71e6a1eabfd466d4b92
7
+ data.tar.gz: e80805c251e3a48ce0ab032a9c8904dae3401c6d02bedb9d1372df32ada7b9609880096c2fb39c62d575aecdace7266d068f6e0ed165ece989038b29e1b74885
@@ -0,0 +1,56 @@
1
+ module ActiveHarness
2
+ class Agent
3
+ class << self
4
+ # Mark this agent as an image generation agent.
5
+ #
6
+ # class ImageAgent < ActiveHarness::Agent
7
+ # image true
8
+ # size "1024x1024"
9
+ #
10
+ # model do
11
+ # use provider: :openrouter, model: "openai/gpt-5-image-mini"
12
+ # fallback provider: :openai, model: "gpt-image-1"
13
+ # end
14
+ # end
15
+ #
16
+ # result.output — base64 string or data-URI (provider-dependent)
17
+ # result.processed — same as output (format :text default)
18
+ def image(value = true)
19
+ agent_config[:image] = value
20
+ end
21
+
22
+ # Default image size for all models in this agent's chain.
23
+ # Can be overridden per-model via: use provider: :openai, model: "...", size: "1024x1792"
24
+ def size(default_size)
25
+ agent_config[:image_size] = default_size
26
+ end
27
+ end
28
+
29
+ alias_method :_model_list_base, :model_list
30
+
31
+ def model_list
32
+ list = _model_list_base
33
+ validate_image_models!(list) if @config[:image]
34
+ list
35
+ end
36
+
37
+ private
38
+
39
+ # Models absent from the Pricing registry are silently skipped — unknown
40
+ # models are assumed valid to avoid false negatives on new/private models.
41
+ def validate_image_models!(list)
42
+ list.each do |entry|
43
+ info = Pricing.find(entry[:model].to_s)
44
+ next unless info
45
+
46
+ unless info.categories.include?("imggen")
47
+ raise ArgumentError,
48
+ "#{self.class.name}: model #{entry[:model].inspect} (provider: #{entry[:provider]}) " \
49
+ "does not support image generation " \
50
+ "(output_modalities: #{info.output_modalities.inspect}). " \
51
+ "Use a model that has 'image' in output_modalities."
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -134,12 +134,14 @@ module ActiveHarness
134
134
  @models = []
135
135
  end
136
136
 
137
- def use(provider:, model:, temperature: nil, name: nil, retry_attempts: nil, retry_delay: nil)
137
+ def use(provider:, model:, temperature: nil, name: nil, size: nil, quality: nil, retry_attempts: nil, retry_delay: nil)
138
138
  @models << {
139
139
  provider: provider,
140
140
  model: model,
141
141
  temperature: temperature,
142
142
  name: name,
143
+ size: size,
144
+ quality: quality,
143
145
  retry_attempts: retry_attempts,
144
146
  retry_delay: retry_delay
145
147
  }.compact
@@ -38,10 +38,16 @@ module ActiveHarness
38
38
  custom: -> { Providers::Custom.new }
39
39
  }.freeze
40
40
 
41
+ IMAGE_PROVIDERS = {
42
+ openai: -> { Providers::Images::OpenAI.new },
43
+ openrouter: -> { Providers::Images::OpenRouter.new }
44
+ }.freeze
45
+
41
46
  private
42
47
 
43
48
  def attempt_model(entry, system_prompt)
44
49
  return attempt_via_custom_llm(entry, system_prompt) if @config[:custom_llm_backend]
50
+ return attempt_image_model(entry) if @config[:image]
45
51
 
46
52
  provider = resolve_provider(entry[:provider])
47
53
  messages = build_messages(system_prompt, @input)
@@ -52,6 +58,17 @@ module ActiveHarness
52
58
  provider.call(**opts)
53
59
  end
54
60
 
61
+ def attempt_image_model(entry)
62
+ factory = IMAGE_PROVIDERS[entry[:provider].to_sym]
63
+ raise ArgumentError, "Provider #{entry[:provider].inspect} does not support image generation. " \
64
+ "Supported image providers: #{IMAGE_PROVIDERS.keys.join(', ')}" unless factory
65
+
66
+ size = entry[:size] || @config[:image_size] || "1024x1024"
67
+ opts = { model: entry[:model], prompt: @input.to_s, size: size }
68
+ opts[:quality] = entry[:quality] if entry[:quality]
69
+ factory.call.call(**opts)
70
+ end
71
+
55
72
  def resolve_provider(name)
56
73
  factory = PROVIDERS[name.to_sym]
57
74
  raise ArgumentError, "Unknown provider: #{name.inspect}. Supported: #{PROVIDERS.keys.join(', ')}" unless factory
@@ -188,15 +188,23 @@ module ActiveHarness
188
188
  total: raw_usage[:total_tokens]
189
189
  )
190
190
 
191
- UsageInfo.new(
192
- tokens: tokens,
193
- cost: calculate_cost(model_cost, tokens)
194
- )
191
+ cost = if raw_usage.key?(:provider_cost)
192
+ CostBreakdown.new(total: raw_usage[:provider_cost].round(8))
193
+ else
194
+ calculate_cost(model_cost, tokens)
195
+ end
196
+
197
+ UsageInfo.new(tokens: tokens, cost: cost)
195
198
  end
196
199
 
197
200
  def lookup_model_cost(entry)
198
201
  return nil unless entry
199
- Pricing.find(entry[:model].to_s)
202
+
203
+ if entry[:provider].to_sym == :openrouter
204
+ Pricing::OpenRouter.find(entry[:model].to_s) || Pricing.find(entry[:model].to_s)
205
+ else
206
+ Pricing.find(entry[:model].to_s)
207
+ end
200
208
  rescue StandardError
201
209
  nil
202
210
  end
@@ -215,4 +223,5 @@ require_relative "agent/providers"
215
223
  require_relative "agent/output_parser"
216
224
  require_relative "agent/custom_llm_backend"
217
225
  require_relative "agent/cost"
226
+ require_relative "agent/image"
218
227
 
@@ -0,0 +1,194 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "fileutils"
5
+ require "set"
6
+
7
+ module ActiveHarness
8
+ module Pricing
9
+ # Fallback pricing source — fetches model data from models.dev.
10
+ #
11
+ # Data source:
12
+ # {project_root}/tmp/active_harness/pricing_models_dev.json — fetched cache (24h TTL)
13
+ # Returns nil/empty if cache is missing and network is unavailable.
14
+ #
15
+ # Usage:
16
+ # Pricing::ModelsDev.find("gpt-4o")
17
+ # Pricing::ModelsDev.all
18
+ # Pricing::ModelsDev.update
19
+ module ModelsDev
20
+ MODELS_DEV_URL = "https://models.dev/api.json"
21
+ CACHE_TTL = 86_400
22
+
23
+ MODELS_DEV_PROVIDER_MAP = {
24
+ "openai" => "openai",
25
+ "anthropic" => "anthropic",
26
+ "google" => "gemini",
27
+ "google-vertex" => "vertexai",
28
+ "amazon-bedrock" => "bedrock",
29
+ "deepseek" => "deepseek",
30
+ "mistral" => "mistral",
31
+ "openrouter" => "openrouter",
32
+ "perplexity" => "perplexity",
33
+ "xai" => "xai",
34
+ "groq" => "groq",
35
+ "azure" => "azure"
36
+ }.freeze
37
+
38
+ class << self
39
+ def all
40
+ ensure_fresh_registry
41
+ registry.map { |raw| build_cost(raw) }
42
+ end
43
+
44
+ def find(model_id)
45
+ ensure_fresh_registry
46
+ raw = registry.find { |m| m[:id] == model_id.to_s }
47
+ raw ? build_cost(raw) : nil
48
+ end
49
+
50
+ def providers
51
+ @providers_proxy ||= Pricing::ProvidersProxy.new(self)
52
+ end
53
+
54
+ def for_provider(name)
55
+ ensure_fresh_registry
56
+ registry
57
+ .select { |m| m[:provider] == name.to_s }
58
+ .map { |m| build_cost(m) }
59
+ end
60
+
61
+ def provider_names
62
+ @provider_names ||= begin
63
+ ensure_fresh_registry
64
+ registry.map { |m| m[:provider] }.uniq.sort
65
+ end
66
+ end
67
+
68
+ def update
69
+ raw_api = fetch_models_dev
70
+ models = extract_models(raw_api)
71
+
72
+ FileUtils.mkdir_p(File.dirname(cache_file))
73
+ File.write(cache_file, JSON.generate(models))
74
+
75
+ reload!
76
+ models.size
77
+ end
78
+
79
+ def reload!
80
+ @registry = nil
81
+ @provider_names = nil
82
+ nil
83
+ end
84
+
85
+ def cache_file
86
+ File.join(project_root, "tmp", "active_harness", "pricing_models_dev.json")
87
+ end
88
+
89
+ def available_providers
90
+ @available_providers ||= begin
91
+ providers_dir = File.expand_path("../providers", __dir__)
92
+ Dir.glob("#{providers_dir}/*.rb")
93
+ .map { |f| File.basename(f, ".rb") }
94
+ .reject { |n| %w[base custom].include?(n) }
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def ensure_fresh_registry
101
+ return if cache_file_fresh?
102
+ update
103
+ rescue StandardError
104
+ # Network unavailable — fall back to bundled/stale cache silently
105
+ end
106
+
107
+ def cache_file_fresh?
108
+ File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < CACHE_TTL
109
+ end
110
+
111
+ def registry
112
+ @registry ||= load_registry
113
+ end
114
+
115
+ def load_registry
116
+ return [] unless File.exist?(cache_file)
117
+ data = JSON.parse(File.read(cache_file), symbolize_names: true)
118
+ data.is_a?(Array) ? data : []
119
+ rescue JSON::ParserError
120
+ []
121
+ end
122
+
123
+ def fetch_models_dev
124
+ uri = URI(MODELS_DEV_URL)
125
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
126
+ http.get(uri.request_uri)
127
+ end
128
+ raise "models.dev returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
129
+
130
+ JSON.parse(response.body, symbolize_names: true)
131
+ end
132
+
133
+ def extract_models(raw_api)
134
+ allowed = available_providers.to_set
135
+
136
+ raw_api.flat_map do |provider_key, provider_data|
137
+ ah_provider = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
138
+ next [] unless ah_provider && allowed.include?(ah_provider)
139
+
140
+ models_hash = provider_data.is_a?(Hash) ? (provider_data[:models] || {}) : {}
141
+ models_hash.values.filter_map do |m|
142
+ next unless m.is_a?(Hash) && m[:id]
143
+
144
+ cost = m[:cost] || {}
145
+ standard = {
146
+ input_per_million: cost[:input],
147
+ output_per_million: cost[:output],
148
+ cache_read_input_per_million: cost[:cache_read],
149
+ cache_write_input_per_million: cost[:cache_write]
150
+ }.compact
151
+
152
+ mods = m[:modalities] || {}
153
+ {
154
+ id: m[:id],
155
+ name: m[:name] || m[:id],
156
+ provider: ah_provider,
157
+ context_window: m[:context_window] || m.dig(:limit, :context),
158
+ max_output_tokens: m[:max_output_tokens] || m.dig(:limit, :output),
159
+ input_modalities: Array(mods[:input]),
160
+ output_modalities: Array(mods[:output]),
161
+ pricing: standard.any? ? { text_tokens: { standard: standard } } : {}
162
+ }
163
+ end
164
+ end
165
+ end
166
+
167
+ def build_cost(raw)
168
+ standard = raw.dig(:pricing, :text_tokens, :standard) || {}
169
+ Pricing::ModelPrice.new(
170
+ id: raw[:id],
171
+ name: raw[:name],
172
+ provider: raw[:provider],
173
+ input_per_million: standard[:input_per_million],
174
+ output_per_million: standard[:output_per_million],
175
+ cache_read_input_per_million: standard[:cache_read_input_per_million],
176
+ cache_write_input_per_million: standard[:cache_write_input_per_million],
177
+ context_window: raw[:context_window],
178
+ max_output_tokens: raw[:max_output_tokens],
179
+ input_modalities: Array(raw[:input_modalities]),
180
+ output_modalities: Array(raw[:output_modalities])
181
+ )
182
+ end
183
+
184
+ def project_root
185
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
186
+ Rails.root.to_s
187
+ else
188
+ Dir.pwd
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,176 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "fileutils"
5
+
6
+ module ActiveHarness
7
+ module Pricing
8
+ # Fetches image-model pricing directly from the OpenRouter API.
9
+ #
10
+ # models.dev only has generic prompt/completion rates for OpenRouter models.
11
+ # OpenRouter's own /endpoints API exposes a separate `image_output` rate
12
+ # that reflects the real cost of image generation tokens.
13
+ #
14
+ # Data flow:
15
+ # 1. GET /api/v1/models?output_modalities=image → list of image models
16
+ # 2. GET /api/v1/models/{id}/endpoints → per-model, picks first
17
+ # active endpoint to get image_output rate
18
+ # 3. Result cached to tmp/active_harness/pricing_openrouter.json for 24h
19
+ #
20
+ # Usage:
21
+ # Pricing::OpenRouter.find("openai/gpt-5-image-mini") # → ModelPrice or nil
22
+ # Pricing::OpenRouter.update # force refresh
23
+ module OpenRouter
24
+ API_BASE = "https://openrouter.ai/api/v1/models"
25
+ CACHE_TTL = 86_400
26
+
27
+ class << self
28
+ # Returns a ModelPrice for the given OpenRouter model id, or nil.
29
+ # Automatically refreshes the cache if missing or stale.
30
+ def find(model_id)
31
+ ensure_fresh_registry
32
+ raw = registry.find { |m| m[:id] == model_id.to_s }
33
+ raw ? build_price(raw) : nil
34
+ end
35
+
36
+ # Fetches fresh data from OpenRouter and writes the cache.
37
+ # Returns the number of models saved.
38
+ def update
39
+ image_models = fetch_image_models
40
+ enriched = image_models.map { |m| enrich_with_endpoint(m) }
41
+
42
+ FileUtils.mkdir_p(File.dirname(cache_file))
43
+ File.write(cache_file, JSON.generate(enriched))
44
+ reload!
45
+ enriched.size
46
+ end
47
+
48
+ def reload!
49
+ @registry = nil
50
+ end
51
+
52
+ def cache_file
53
+ File.join(project_root, "tmp", "active_harness", "pricing_openrouter.json")
54
+ end
55
+
56
+ private
57
+
58
+ def ensure_fresh_registry
59
+ return if cache_fresh?
60
+ update
61
+ rescue StandardError
62
+ # network unavailable — fall back to stale cache silently
63
+ end
64
+
65
+ def cache_fresh?
66
+ File.exist?(cache_file) && (Time.now - File.mtime(cache_file)) < CACHE_TTL
67
+ end
68
+
69
+ def registry
70
+ @registry ||= begin
71
+ return [] unless File.exist?(cache_file)
72
+ JSON.parse(File.read(cache_file), symbolize_names: true)
73
+ rescue JSON::ParserError
74
+ []
75
+ end
76
+ end
77
+
78
+ # Fetch all models with image output from OpenRouter.
79
+ def fetch_image_models
80
+ uri = URI("#{API_BASE}?output_modalities=image")
81
+ response = http_get(uri)
82
+ data = JSON.parse(response.body, symbolize_names: true)
83
+ data[:data] || []
84
+ end
85
+
86
+ # Fetch /endpoints for the model and merge image_output pricing.
87
+ def enrich_with_endpoint(model)
88
+ model_id = model[:id]
89
+ base_pricing = model[:pricing] || {}
90
+
91
+ endpoint_pricing = fetch_endpoint_pricing(model_id)
92
+
93
+ {
94
+ id: model_id,
95
+ name: model[:name],
96
+ input_modalities: model.dig(:architecture, :input_modalities) || [],
97
+ output_modalities: model.dig(:architecture, :output_modalities) || [],
98
+ prompt: base_pricing[:prompt].to_s,
99
+ completion: base_pricing[:completion].to_s,
100
+ image_output: endpoint_pricing&.dig(:image_output).to_s,
101
+ image: endpoint_pricing&.dig(:image).to_s,
102
+ cache_read: (endpoint_pricing&.dig(:input_cache_read) || base_pricing[:input_cache_read]).to_s
103
+ }
104
+ end
105
+
106
+ # Returns the pricing hash from the first active endpoint, or nil.
107
+ def fetch_endpoint_pricing(model_id)
108
+ uri = URI("#{API_BASE}/#{model_id}/endpoints")
109
+ response = http_get(uri)
110
+ data = JSON.parse(response.body, symbolize_names: true)
111
+ endpoints = data.dig(:data, :endpoints) || []
112
+
113
+ # Prefer the first endpoint with status == 0 (online), else first available.
114
+ ep = endpoints.find { |e| e[:status] == 0 } || endpoints.first
115
+ ep&.dig(:pricing)
116
+ rescue StandardError
117
+ nil
118
+ end
119
+
120
+ # Build a ModelPrice compatible with the rest of the Pricing system.
121
+ # For image-output models, uses image_output rate for output_per_million.
122
+ def build_price(raw)
123
+ is_image_output = Array(raw[:output_modalities]).include?("image")
124
+
125
+ input_pm = to_per_million(raw[:prompt])
126
+ completion_pm = to_per_million(raw[:completion])
127
+ image_out_pm = to_per_million(raw[:image_output])
128
+ cache_pm = to_per_million(raw[:cache_read])
129
+
130
+ output_pm = (is_image_output && image_out_pm) ? image_out_pm : completion_pm
131
+
132
+ return nil unless input_pm || output_pm
133
+
134
+ Pricing::ModelPrice.new(
135
+ id: raw[:id],
136
+ name: raw[:name],
137
+ provider: "openrouter",
138
+ input_per_million: input_pm,
139
+ output_per_million: output_pm,
140
+ cache_read_input_per_million: cache_pm,
141
+ cache_write_input_per_million: nil,
142
+ context_window: nil,
143
+ max_output_tokens: nil,
144
+ input_modalities: Array(raw[:input_modalities]),
145
+ output_modalities: Array(raw[:output_modalities])
146
+ )
147
+ end
148
+
149
+ # OpenRouter pricing fields are per-token strings (e.g. "0.000008").
150
+ # Convert to per-million float. Returns nil for zero/blank values.
151
+ def to_per_million(value)
152
+ return nil if value.nil? || value.to_s.empty?
153
+ f = value.to_f
154
+ return nil if f <= 0
155
+ (f * 1_000_000).round(6)
156
+ end
157
+
158
+ def http_get(uri)
159
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 10) do |http|
160
+ http.get(uri.request_uri)
161
+ end
162
+ raise "OpenRouter API returned HTTP #{response.code} for #{uri}" unless response.is_a?(Net::HTTPSuccess)
163
+ response
164
+ end
165
+
166
+ def project_root
167
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
168
+ Rails.root.to_s
169
+ else
170
+ Dir.pwd
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end