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 +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 +194 -0
- data/lib/active_harness/pricing/openrouter.rb +176 -0
- data/lib/active_harness/pricing.rb +41 -204
- 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.rb +5 -1
- metadata +7 -3
- data/lib/active_harness/data/models.json +0 -61458
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39b817ef529f0158a20634ba42a61ae0078404238038b22d63c2c8cc50a1ff3a
|
|
4
|
+
data.tar.gz: bcf8d21cb68449e3962f2bb9b515c39237c7774a6ca300a803775ae09c275567
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/active_harness/agent.rb
CHANGED
|
@@ -188,15 +188,23 @@ module ActiveHarness
|
|
|
188
188
|
total: raw_usage[:total_tokens]
|
|
189
189
|
)
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|