open_router_enhanced 2.0.0 → 2.1.0

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: 925d55d16a222d6a954c449307c480ab85bf3092138b172cd8a2561f40aeeba4
4
- data.tar.gz: 7f427cb96bdd2a98c355096db04babb861a8bb2a13ab3f7f940c5397a9d4f651
3
+ metadata.gz: 1f08650282b1232abfa779330c8f8ec9aad7bf6a984fd31e1a74ce1f3cad6962
4
+ data.tar.gz: e41ce6204abde21a34dd6c2976801666b8f4b9339cf81c845861f3837c15097c
5
5
  SHA512:
6
- metadata.gz: c4cea4727bfa5df23b123e695cd95fe6106f6a57a5184e8b3af9f7c043e3198702ea433fc7d553220c3def45d1adc899b4e53ff4b7fa3ff4b005cf76a580c681
7
- data.tar.gz: 9544496653c0716f7c704d10f7376e21e20bc4f98fe784114822640ffc957142e3fed056bcdc6d135e333010e2489910c44f7a445aa50ac3dc18eb9fe52bb4ee
6
+ metadata.gz: 1d15f1e25db721734f0f9d2e43d01f6546af170096e65253c1c8438c3362484d6bad28f36669e2b8c34856ca297bb07dc1ffb18c7dc4442e2fe8c93ad79474b1
7
+ data.tar.gz: 2db9c96b66dcb0eae4b03f463db956192b38ef7444c879f7fe72a767e31c378d1ed702ee38a4c3b76e187945e6d942ac5dcbf7e7733fe8621315bee729e74940
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- open_router_enhanced (2.0.0)
4
+ open_router_enhanced (2.0.1)
5
5
  activesupport (>= 6.0, < 9.0)
6
6
  dotenv (>= 2.0, < 4.0)
7
7
  faraday (>= 1.0, < 3.0)
@@ -11,7 +11,7 @@ PATH
11
11
  GEM
12
12
  remote: https://rubygems.org/
13
13
  specs:
14
- activesupport (8.1.1)
14
+ activesupport (8.1.3)
15
15
  base64
16
16
  bigdecimal
17
17
  concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -24,11 +24,11 @@ GEM
24
24
  securerandom (>= 0.3)
25
25
  tzinfo (~> 2.0, >= 2.0.5)
26
26
  uri (>= 0.13.1)
27
- addressable (2.8.8)
27
+ addressable (2.9.0)
28
28
  public_suffix (>= 2.0.2, < 8.0)
29
29
  ast (2.4.3)
30
30
  base64 (0.3.0)
31
- bigdecimal (4.0.1)
31
+ bigdecimal (4.1.1)
32
32
  coderay (1.1.3)
33
33
  concurrent-ruby (1.3.6)
34
34
  connection_pool (3.0.2)
@@ -38,42 +38,47 @@ GEM
38
38
  diff-lcs (1.6.2)
39
39
  dotenv (3.2.0)
40
40
  drb (2.2.3)
41
- faraday (2.14.0)
41
+ faraday (2.14.1)
42
42
  faraday-net_http (>= 2.0, < 3.5)
43
43
  json
44
44
  logger
45
- faraday-multipart (1.1.1)
45
+ faraday-multipart (1.2.0)
46
46
  multipart-post (~> 2.0)
47
47
  faraday-net_http (3.4.2)
48
48
  net-http (~> 0.5)
49
49
  hashdiff (1.2.1)
50
50
  i18n (1.14.8)
51
51
  concurrent-ruby (~> 1.0)
52
- json (2.18.0)
52
+ io-console (0.8.2)
53
+ json (2.19.3)
53
54
  json-schema (4.3.1)
54
55
  addressable (>= 2.8)
55
56
  language_server-protocol (3.17.0.5)
56
57
  lint_roller (1.1.0)
57
58
  logger (1.7.0)
58
59
  method_source (1.1.0)
59
- minitest (6.0.0)
60
+ minitest (6.0.4)
61
+ drb (~> 2.0)
60
62
  prism (~> 1.5)
61
63
  multipart-post (2.4.1)
62
64
  net-http (0.9.1)
63
65
  uri (>= 0.11.1)
64
- parallel (1.27.0)
65
- parser (3.3.10.0)
66
+ parallel (2.0.1)
67
+ parser (3.3.11.1)
66
68
  ast (~> 2.4.1)
67
69
  racc
68
- prism (1.7.0)
69
- pry (0.15.2)
70
+ prism (1.9.0)
71
+ pry (0.16.0)
70
72
  coderay (~> 1.1)
71
73
  method_source (~> 1.0)
72
- public_suffix (7.0.0)
74
+ reline (>= 0.6.0)
75
+ public_suffix (7.0.5)
73
76
  racc (1.8.1)
74
77
  rainbow (3.1.1)
75
- rake (13.3.1)
76
- regexp_parser (2.11.3)
78
+ rake (13.4.2)
79
+ regexp_parser (2.12.0)
80
+ reline (0.6.3)
81
+ io-console (~> 0.5)
77
82
  rexml (3.4.4)
78
83
  rspec (3.13.2)
79
84
  rspec-core (~> 3.13.0)
@@ -84,24 +89,24 @@ GEM
84
89
  rspec-expectations (3.13.5)
85
90
  diff-lcs (>= 1.2.0, < 2.0)
86
91
  rspec-support (~> 3.13.0)
87
- rspec-mocks (3.13.7)
92
+ rspec-mocks (3.13.8)
88
93
  diff-lcs (>= 1.2.0, < 2.0)
89
94
  rspec-support (~> 3.13.0)
90
- rspec-support (3.13.6)
91
- rubocop (1.82.1)
95
+ rspec-support (3.13.7)
96
+ rubocop (1.86.1)
92
97
  json (~> 2.3)
93
98
  language_server-protocol (~> 3.17.0.2)
94
99
  lint_roller (~> 1.1.0)
95
- parallel (~> 1.10)
100
+ parallel (>= 1.10)
96
101
  parser (>= 3.3.0.2)
97
102
  rainbow (>= 2.2.2, < 4.0)
98
103
  regexp_parser (>= 2.9.3, < 3.0)
99
- rubocop-ast (>= 1.48.0, < 2.0)
104
+ rubocop-ast (>= 1.49.0, < 2.0)
100
105
  ruby-progressbar (~> 1.7)
101
106
  unicode-display_width (>= 2.4.0, < 4.0)
102
- rubocop-ast (1.48.0)
107
+ rubocop-ast (1.49.1)
103
108
  parser (>= 3.3.7.2)
104
- prism (~> 1.4)
109
+ prism (~> 1.7)
105
110
  ruby-progressbar (1.13.0)
106
111
  securerandom (0.4.1)
107
112
  tzinfo (2.0.6)
@@ -111,7 +116,7 @@ GEM
111
116
  unicode-emoji (4.2.0)
112
117
  uri (1.1.1)
113
118
  vcr (6.4.0)
114
- webmock (3.26.1)
119
+ webmock (3.26.2)
115
120
  addressable (>= 2.8.0)
116
121
  crack (>= 0.3.2)
117
122
  hashdiff (>= 0.4.0, < 2.0.0)
data/Rakefile CHANGED
@@ -30,6 +30,17 @@ task ci: %i[spec_all rubocop]
30
30
 
31
31
  # Model exploration tasks
32
32
  namespace :models do
33
+ desc "Fetch fresh model data from OpenRouter API and update local cache"
34
+ task :update do
35
+ require_relative "lib/open_router"
36
+
37
+ print "Fetching models from OpenRouter API..."
38
+ OpenRouter::ModelRegistry.refresh!
39
+ count = OpenRouter::ModelRegistry.all_models.size
40
+ puts " done. #{count} models cached."
41
+ end
42
+
43
+
33
44
  desc "Display summary of available models"
34
45
  task :summary do
35
46
  require_relative "lib/open_router"
@@ -59,18 +70,18 @@ namespace :models do
59
70
  end
60
71
 
61
72
  # Cost analysis
62
- input_costs = models.values.map { |spec| spec[:cost_per_1k_tokens][:input] }.compact.sort
63
- output_costs = models.values.map { |spec| spec[:cost_per_1k_tokens][:output] }.compact.sort
73
+ input_costs = models.values.map { |spec| spec[:cost_per_token][:input] }.compact.sort
74
+ output_costs = models.values.map { |spec| spec[:cost_per_token][:output] }.compact.sort
64
75
 
65
- puts "\n💰 Cost Analysis (per 1k tokens):"
76
+ puts "\n💰 Cost Analysis (per million tokens):"
66
77
  puts " Input tokens:"
67
- puts " Min: $#{format("%.6f", input_costs.min)}"
68
- puts " Max: $#{format("%.6f", input_costs.max)}"
69
- puts " Median: $#{format("%.6f", input_costs[input_costs.size / 2])}"
78
+ puts " Min: $#{format("%.4f", input_costs.min * 1_000_000)}"
79
+ puts " Max: $#{format("%.4f", input_costs.max * 1_000_000)}"
80
+ puts " Median: $#{format("%.4f", input_costs[input_costs.size / 2] * 1_000_000)}"
70
81
  puts " Output tokens:"
71
- puts " Min: $#{format("%.6f", output_costs.min)}"
72
- puts " Max: $#{format("%.6f", output_costs.max)}"
73
- puts " Median: $#{format("%.6f", output_costs[output_costs.size / 2])}"
82
+ puts " Min: $#{format("%.4f", output_costs.min * 1_000_000)}"
83
+ puts " Max: $#{format("%.4f", output_costs.max * 1_000_000)}"
84
+ puts " Median: $#{format("%.4f", output_costs[output_costs.size / 2] * 1_000_000)}"
74
85
 
75
86
  # Context length analysis
76
87
  context_lengths = models.values.map { |spec| spec[:context_length] }.compact.sort
@@ -269,8 +280,8 @@ namespace :models do
269
280
  def self.display_model_info(model_id, specs, index)
270
281
  puts "#{(index + 1).to_s.rjust(3)}. #{model_id}"
271
282
  puts " Name: #{specs[:name]}" if specs[:name]
272
- puts " Cost: $#{format("%.6f", specs[:cost_per_1k_tokens][:input])}/1k input, " \
273
- "$#{format("%.6f", specs[:cost_per_1k_tokens][:output])}/1k output"
283
+ cpm = OpenRouter::ModelRegistry.cost_per_million(model_id)
284
+ puts " Cost: $#{format("%.4f", cpm[:input])}/M input, $#{format("%.4f", cpm[:output])}/M output"
274
285
  puts " Context: #{format_number_with_commas(specs[:context_length])} tokens"
275
286
  puts " Capabilities: #{specs[:capabilities].join(", ")}"
276
287
  puts " Tier: #{specs[:performance_tier]}"
@@ -318,17 +329,17 @@ namespace :models do
318
329
  def self.sort_by_strategy(candidates, strategy)
319
330
  case strategy
320
331
  when :cost
321
- candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
332
+ candidates.sort_by { |_, specs| specs[:cost_per_token][:input] }
322
333
  when :performance
323
334
  candidates.sort_by do |_, specs|
324
- [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_1k_tokens][:input]]
335
+ [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_token][:input]]
325
336
  end
326
337
  when :latest
327
338
  candidates.sort_by { |_, specs| -(specs[:created_at] || 0).to_i }
328
339
  when :context
329
340
  candidates.sort_by { |_, specs| -(specs[:context_length] || 0).to_i }
330
341
  else
331
- candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
342
+ candidates.sort_by { |_, specs| specs[:cost_per_token][:input] }
332
343
  end
333
344
  end
334
345
  end
@@ -12,15 +12,19 @@ module OpenRouter
12
12
  class Client
13
13
  include OpenRouter::HTTP
14
14
 
15
- attr_reader :callbacks, :usage_tracker
15
+ attr_reader :callbacks, :usage_tracker, :configuration
16
16
 
17
17
  # Initializes the client with optional configurations.
18
18
  def initialize(access_token: nil, request_timeout: nil, uri_base: nil, extra_headers: {}, track_usage: true)
19
- OpenRouter.configuration.access_token = access_token if access_token
20
- OpenRouter.configuration.request_timeout = request_timeout if request_timeout
21
- OpenRouter.configuration.uri_base = uri_base if uri_base
22
- OpenRouter.configuration.extra_headers = extra_headers if extra_headers.any?
23
- yield(OpenRouter.configuration) if block_given?
19
+ # Build a per-instance configuration to avoid mutating the global singleton,
20
+ # which would cause credential leakage across Client instances in concurrent use.
21
+ @configuration = OpenRouter.configuration.dup
22
+ @configuration.extra_headers = OpenRouter.configuration.extra_headers.dup
23
+ @configuration.access_token = access_token if access_token
24
+ @configuration.request_timeout = request_timeout if request_timeout
25
+ @configuration.uri_base = uri_base if uri_base
26
+ @configuration.extra_headers = @configuration.extra_headers.merge(extra_headers) if extra_headers.any?
27
+ yield(@configuration) if block_given?
24
28
 
25
29
  # Instance-level tracking of capability warnings to avoid memory leaks
26
30
  @capability_warnings_shown = Set.new
@@ -40,10 +44,6 @@ module OpenRouter
40
44
  @usage_tracker = UsageTracker.new if @track_usage
41
45
  end
42
46
 
43
- def configuration
44
- OpenRouter.configuration
45
- end
46
-
47
47
  # Register a callback for a specific event
48
48
  #
49
49
  # @param event [Symbol] The event to register for (:before_request, :after_response, :on_tool_call, :on_error, :on_stream_chunk, :on_healing)
@@ -185,7 +185,7 @@ module OpenRouter
185
185
 
186
186
  parameters = { model: opts.model, input: input }
187
187
  parameters[:reasoning] = opts.reasoning if opts.reasoning
188
- parameters[:tools] = serialize_tools_for_responses(opts.tools) if opts.has_tools?
188
+ parameters[:tools] = serialize_tools_for_responses(opts.tools) if opts.tools?
189
189
  parameters[:tool_choice] = opts.tool_choice if opts.tool_choice
190
190
  # Prefer max_completion_tokens over max_tokens (consistent with complete() method)
191
191
  parameters[:max_output_tokens] = opts.max_completion_tokens || opts.max_tokens if opts.max_completion_tokens || opts.max_tokens
@@ -503,7 +503,7 @@ module OpenRouter
503
503
  # @param parameters [Hash] Request parameters hash
504
504
  # @param opts [CompletionOptions] Options object
505
505
  def configure_tool_calling!(parameters, opts)
506
- return unless opts.has_tools?
506
+ return unless opts.tools?
507
507
 
508
508
  warn_if_unsupported(opts.model, :function_calling, "tool calling")
509
509
  parameters[:tools] = serialize_tools(opts.tools)
@@ -516,7 +516,7 @@ module OpenRouter
516
516
  # @param opts [CompletionOptions] Options object
517
517
  # @return [Boolean] Whether forced extraction mode is being used
518
518
  def configure_structured_outputs!(parameters, opts)
519
- return false unless opts.has_response_format?
519
+ return false unless opts.response_format?
520
520
 
521
521
  force_extraction = determine_forced_extraction_mode(opts.model, opts.force_structured_output)
522
522
 
@@ -244,14 +244,14 @@ module OpenRouter
244
244
  # Check if this options object has any tools defined
245
245
  #
246
246
  # @return [Boolean]
247
- def has_tools?
247
+ def tools?
248
248
  tools.is_a?(Array) && !tools.empty?
249
249
  end
250
250
 
251
251
  # Check if response format is configured
252
252
  #
253
253
  # @return [Boolean]
254
- def has_response_format?
254
+ def response_format?
255
255
  !response_format.nil?
256
256
  end
257
257
 
@@ -78,30 +78,30 @@ module OpenRouter
78
78
 
79
79
  def conn(multipart: false)
80
80
  Faraday.new do |f|
81
- f.options[:timeout] = OpenRouter.configuration.request_timeout
81
+ f.options[:timeout] = configuration.request_timeout
82
82
  f.request(:multipart) if multipart
83
83
  # NOTE: Removed MiddlewareErrors reference - was undefined and @log_errors was never set
84
84
  f.response :raise_error
85
85
  f.response :json if OpenRouter::HAS_JSON_MW
86
86
 
87
- OpenRouter.configuration.faraday_config&.call(f)
87
+ configuration.faraday_config&.call(f)
88
88
  end
89
89
  end
90
90
 
91
91
  def uri(path:)
92
- base = OpenRouter.configuration.uri_base.sub(%r{/\z}, "")
93
- ver = OpenRouter.configuration.api_version.to_s.sub(%r{^/}, "").sub(%r{/\z}, "")
92
+ base = configuration.uri_base.sub(%r{/\z}, "")
93
+ ver = configuration.api_version.to_s.sub(%r{^/}, "").sub(%r{/\z}, "")
94
94
  p = path.to_s.sub(%r{^/}, "")
95
- "#{base}/#{ver}/#{p}"
95
+ [base, ver, p].reject(&:empty?).join("/")
96
96
  end
97
97
 
98
98
  def headers
99
99
  {
100
- "Authorization" => "Bearer #{OpenRouter.configuration.access_token}",
100
+ "Authorization" => "Bearer #{configuration.access_token}",
101
101
  "Content-Type" => "application/json",
102
102
  "X-Title" => "OpenRouter Ruby Client",
103
103
  "HTTP-Referer" => "https://github.com/OlympiaAI/open_router"
104
- }.merge(OpenRouter.configuration.extra_headers)
104
+ }.merge(configuration.extra_headers)
105
105
  end
106
106
 
107
107
  def multipart_parameters(parameters)
@@ -22,6 +22,11 @@ module OpenRouter
22
22
 
23
23
  # Enhanced heal method that supports different healing contexts
24
24
  def heal(raw_text, schema, context: :generic)
25
+ # Guard against re-entrant healing triggered by on_healing callbacks
26
+ raise StructuredOutputError, "Recursive healing detected — cannot heal from within a healing callback" if (Thread.current[:openrouter_heal_depth] || 0).positive?
27
+
28
+ Thread.current[:openrouter_heal_depth] = 1
29
+
25
30
  candidate_json = extract_json_candidate(raw_text)
26
31
  raise StructuredOutputError, "No JSON-like content found in the response." if candidate_json.nil?
27
32
 
@@ -53,6 +58,8 @@ module OpenRouter
53
58
  # Escalate to LLM-based healing with proper context
54
59
  candidate_json = fix_with_healer_model(candidate_json, schema, e.message, e.class, original_content, context)
55
60
  end
61
+ ensure
62
+ Thread.current[:openrouter_heal_depth] = 0
56
63
  end
57
64
 
58
65
  private
@@ -15,6 +15,8 @@ module OpenRouter
15
15
  CACHE_METADATA_FILE = File.join(CACHE_DIR, "cache_metadata.json")
16
16
  MAX_CACHE_SIZE_MB = 50 # Maximum cache size in megabytes
17
17
 
18
+ REGISTRY_MUTEX = Mutex.new
19
+
18
20
  class << self
19
21
  # Fetch models from OpenRouter API using Faraday for consistent SSL handling
20
22
  def fetch_models_from_api
@@ -83,9 +85,11 @@ module OpenRouter
83
85
 
84
86
  # Clear local cache (both files and memory)
85
87
  def clear_cache!
86
- FileUtils.rm_rf(CACHE_DIR) if Dir.exist?(CACHE_DIR)
87
- @processed_models = nil
88
- @all_models = nil
88
+ REGISTRY_MUTEX.synchronize do
89
+ FileUtils.rm_rf(CACHE_DIR) if Dir.exist?(CACHE_DIR)
90
+ @processed_models = nil
91
+ @all_models = nil
92
+ end
89
93
  end
90
94
 
91
95
  # Refresh models data from API
@@ -135,7 +139,7 @@ module OpenRouter
135
139
 
136
140
  models[model_id] = {
137
141
  name: model_data["name"],
138
- cost_per_1k_tokens: {
142
+ cost_per_token: {
139
143
  input: model_data.dig("pricing", "prompt").to_f,
140
144
  output: model_data.dig("pricing", "completion").to_f
141
145
  },
@@ -246,7 +250,11 @@ module OpenRouter
246
250
 
247
251
  # Get all registered models (fetch from API if needed)
248
252
  def all_models
249
- @all_models ||= fetch_and_cache_models
253
+ return @all_models if @all_models # fast path without lock
254
+
255
+ REGISTRY_MUTEX.synchronize do
256
+ @all_models ||= fetch_and_cache_models
257
+ end
250
258
  end
251
259
 
252
260
  # Calculate estimated cost for a request
@@ -254,12 +262,30 @@ module OpenRouter
254
262
  model_info = get_model_info(model)
255
263
  return 0 unless model_info
256
264
 
257
- input_cost = (input_tokens / 1000.0) * model_info[:cost_per_1k_tokens][:input]
258
- output_cost = (output_tokens / 1000.0) * model_info[:cost_per_1k_tokens][:output]
265
+ input_cost = input_tokens * model_info[:cost_per_token][:input]
266
+ output_cost = output_tokens * model_info[:cost_per_token][:output]
259
267
 
260
268
  input_cost + output_cost
261
269
  end
262
270
 
271
+ # Cost per 1,000 tokens — { input: Float, output: Float } or nil
272
+ def cost_per_thousand(model)
273
+ info = get_model_info(model)
274
+ return nil unless info
275
+
276
+ { input: info[:cost_per_token][:input] * 1_000,
277
+ output: info[:cost_per_token][:output] * 1_000 }
278
+ end
279
+
280
+ # Cost per 1,000,000 tokens — { input: Float, output: Float } or nil
281
+ def cost_per_million(model)
282
+ info = get_model_info(model)
283
+ return nil unless info
284
+
285
+ { input: info[:cost_per_token][:input] * 1_000_000,
286
+ output: info[:cost_per_token][:output] * 1_000_000 }
287
+ end
288
+
263
289
  private
264
290
 
265
291
  # Check if model specs meet the given requirements
@@ -271,11 +297,11 @@ module OpenRouter
271
297
  end
272
298
 
273
299
  # Check cost requirements
274
- if requirements[:max_input_cost] && (specs[:cost_per_1k_tokens][:input] > requirements[:max_input_cost])
300
+ if requirements[:max_input_cost] && (specs[:cost_per_token][:input] > requirements[:max_input_cost])
275
301
  return false
276
302
  end
277
303
 
278
- if requirements[:max_output_cost] && (specs[:cost_per_1k_tokens][:output] > requirements[:max_output_cost])
304
+ if requirements[:max_output_cost] && (specs[:cost_per_token][:output] > requirements[:max_output_cost])
279
305
  return false
280
306
  end
281
307
 
@@ -326,7 +352,7 @@ module OpenRouter
326
352
  def calculate_model_cost(specs, _requirements)
327
353
  # Simple cost calculation for sorting - could be made more sophisticated
328
354
  # For now, just use input token cost as the primary metric
329
- specs[:cost_per_1k_tokens][:input]
355
+ specs[:cost_per_token][:input]
330
356
  end
331
357
 
332
358
  # Set up cleanup hook to manage cache size
@@ -355,9 +381,9 @@ module OpenRouter
355
381
  total_size = Dir.glob(File.join(CACHE_DIR, "**/*"))
356
382
  .select { |f| File.file?(f) }
357
383
  .sum do |f|
358
- File.size(f)
359
- rescue StandardError
360
- 0
384
+ File.size(f)
385
+ rescue StandardError
386
+ 0
361
387
  end
362
388
  total_size / (1024.0 * 1024.0)
363
389
  end
@@ -343,7 +343,7 @@ module OpenRouter
343
343
  all_candidates = filter_by_providers(ModelRegistry.all_models)
344
344
  return nil if all_candidates.empty?
345
345
 
346
- all_candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }&.first
346
+ all_candidates.min_by { |_, specs| specs[:cost_per_token][:input] }&.first
347
347
  end
348
348
 
349
349
  # Get detailed information about the current selection criteria
@@ -426,18 +426,18 @@ module OpenRouter
426
426
  def apply_strategy_sorting(candidates)
427
427
  case @strategy
428
428
  when :cost
429
- candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
429
+ candidates.min_by { |_, specs| specs[:cost_per_token][:input] }
430
430
  when :performance
431
431
  # Prefer premium tier, then by cost within tier
432
432
  candidates.min_by do |_, specs|
433
- [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_1k_tokens][:input]]
433
+ [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_token][:input]]
434
434
  end
435
435
  when :latest
436
436
  candidates.max_by { |_, specs| (specs[:created_at] || 0).to_i }
437
437
  when :context
438
438
  candidates.max_by { |_, specs| (specs[:context_length] || 0).to_i }
439
439
  else
440
- candidates.min_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
440
+ candidates.min_by { |_, specs| specs[:cost_per_token][:input] }
441
441
  end
442
442
  end
443
443
 
@@ -445,17 +445,17 @@ module OpenRouter
445
445
  def apply_strategy_sorting_all(candidates)
446
446
  case @strategy
447
447
  when :cost
448
- candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
448
+ candidates.sort_by { |_, specs| specs[:cost_per_token][:input] }
449
449
  when :performance
450
450
  candidates.sort_by do |_, specs|
451
- [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_1k_tokens][:input]]
451
+ [specs[:performance_tier] == :premium ? 0 : 1, specs[:cost_per_token][:input]]
452
452
  end
453
453
  when :latest
454
454
  candidates.sort_by { |_, specs| -(specs[:created_at] || 0).to_i }
455
455
  when :context
456
456
  candidates.sort_by { |_, specs| -(specs[:context_length] || 0).to_i }
457
457
  else
458
- candidates.sort_by { |_, specs| specs[:cost_per_1k_tokens][:input] }
458
+ candidates.sort_by { |_, specs| specs[:cost_per_token][:input] }
459
459
  end
460
460
  end
461
461
  end
@@ -110,7 +110,12 @@ module OpenRouter
110
110
  end
111
111
  end
112
112
 
113
- @structured_output ||= result
113
+ # Use a flag rather than ||= so nil results don't trigger re-parsing on every call
114
+ unless @structured_output_computed
115
+ @structured_output = result
116
+ @structured_output_computed = true
117
+ end
118
+ @structured_output
114
119
  when :gentle
115
120
  # New gentle mode: best-effort parsing, no healing, no validation
116
121
  content_to_parse = @forced_extraction ? extract_json_from_text(content) : content
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpenRouter
4
- VERSION = "2.0.0"
4
+ VERSION = "2.1.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: open_router_enhanced
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Stiens
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-12-28 00:00:00.000000000 Z
11
+ date: 2026-04-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport