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.
- checksums.yaml +4 -4
- data/lib/active_harness/agent/image.rb +56 -0
- data/lib/active_harness/agent/models.rb +3 -1
- data/lib/active_harness/agent/providers.rb +17 -0
- data/lib/active_harness/agent.rb +14 -5
- data/lib/active_harness/pricing/models_dev.rb +218 -0
- data/lib/active_harness/pricing/openrouter.rb +323 -0
- data/lib/active_harness/pricing.rb +72 -214
- data/lib/active_harness/providers/base.rb +5 -1
- data/lib/active_harness/providers/images/openai.rb +73 -0
- data/lib/active_harness/providers/images/openrouter.rb +66 -0
- data/lib/active_harness/railtie.rb +4 -0
- data/lib/active_harness.rb +5 -1
- metadata +7 -3
- data/lib/active_harness/data/models.json +0 -61458
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
32
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
62
|
-
:
|
|
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
|
-
#
|
|
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"
|
|
78
|
-
cats << "pdf"
|
|
79
|
-
cats << "audio"
|
|
80
|
-
cats << "video"
|
|
81
|
-
cats << "imggen"
|
|
82
|
-
cats << "
|
|
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
|
|
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
|
-
|
|
77
|
+
source.for_provider(name.to_s)
|
|
100
78
|
end
|
|
101
79
|
|
|
102
80
|
def list
|
|
103
|
-
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
122
|
-
#
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/active_harness.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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-
|
|
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
|