open_router_enhanced 1.0.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +1 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +13 -0
  5. data/.rubocop_todo.yml +130 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +41 -0
  8. data/CODE_OF_CONDUCT.md +84 -0
  9. data/CONTRIBUTING.md +384 -0
  10. data/Gemfile +22 -0
  11. data/Gemfile.lock +138 -0
  12. data/LICENSE.txt +21 -0
  13. data/MIGRATION.md +556 -0
  14. data/README.md +1660 -0
  15. data/Rakefile +334 -0
  16. data/SECURITY.md +150 -0
  17. data/VCR_CONFIGURATION.md +80 -0
  18. data/docs/model_selection.md +637 -0
  19. data/docs/observability.md +430 -0
  20. data/docs/prompt_templates.md +422 -0
  21. data/docs/streaming.md +467 -0
  22. data/docs/structured_outputs.md +466 -0
  23. data/docs/tools.md +1016 -0
  24. data/examples/basic_completion.rb +122 -0
  25. data/examples/model_selection_example.rb +141 -0
  26. data/examples/observability_example.rb +199 -0
  27. data/examples/prompt_template_example.rb +184 -0
  28. data/examples/smart_completion_example.rb +89 -0
  29. data/examples/streaming_example.rb +176 -0
  30. data/examples/structured_outputs_example.rb +191 -0
  31. data/examples/tool_calling_example.rb +149 -0
  32. data/lib/open_router/client.rb +552 -0
  33. data/lib/open_router/http.rb +118 -0
  34. data/lib/open_router/json_healer.rb +263 -0
  35. data/lib/open_router/model_registry.rb +378 -0
  36. data/lib/open_router/model_selector.rb +462 -0
  37. data/lib/open_router/prompt_template.rb +290 -0
  38. data/lib/open_router/response.rb +371 -0
  39. data/lib/open_router/schema.rb +288 -0
  40. data/lib/open_router/streaming_client.rb +210 -0
  41. data/lib/open_router/tool.rb +221 -0
  42. data/lib/open_router/tool_call.rb +180 -0
  43. data/lib/open_router/usage_tracker.rb +277 -0
  44. data/lib/open_router/version.rb +5 -0
  45. data/lib/open_router.rb +123 -0
  46. data/sig/open_router.rbs +20 -0
  47. metadata +186 -0
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenRouter
6
+ class ToolCallError < Error; end
7
+
8
+ class ToolCall
9
+ attr_reader :id, :type, :function_name, :arguments_string
10
+
11
+ def initialize(tool_call_data)
12
+ @id = tool_call_data["id"]
13
+ @type = tool_call_data["type"]
14
+
15
+ raise ToolCallError, "Invalid tool call data: missing function" unless tool_call_data["function"]
16
+
17
+ @function_name = tool_call_data["function"]["name"]
18
+ @arguments_string = tool_call_data["function"]["arguments"]
19
+ end
20
+
21
+ # Parse the arguments JSON string into a Ruby hash
22
+ def arguments
23
+ @arguments ||= begin
24
+ JSON.parse(@arguments_string)
25
+ rescue JSON::ParserError => e
26
+ raise ToolCallError, "Failed to parse tool call arguments: #{e.message}"
27
+ end
28
+ end
29
+
30
+ # Get the function name (alias for consistency)
31
+ def name
32
+ @function_name
33
+ end
34
+
35
+ # Execute the tool call with a provided block
36
+ # The block should accept (name, arguments) and return the result
37
+ def execute(&block)
38
+ raise ArgumentError, "Block required for tool execution" unless block_given?
39
+
40
+ begin
41
+ result = block.call(@function_name, arguments)
42
+ ToolResult.new(self, result)
43
+ rescue StandardError => e
44
+ ToolResult.new(self, nil, e.message)
45
+ end
46
+ end
47
+
48
+ # Convert this tool call to a message format for conversation continuation
49
+ def to_message
50
+ {
51
+ role: "assistant",
52
+ content: nil,
53
+ tool_calls: [
54
+ {
55
+ id: @id,
56
+ type: @type,
57
+ function: {
58
+ name: @function_name,
59
+ arguments: @arguments_string
60
+ }
61
+ }
62
+ ]
63
+ }
64
+ end
65
+
66
+ # Convert a tool result to a tool message for the conversation
67
+ def to_result_message(result)
68
+ content = case result
69
+ when String then result
70
+ when nil then ""
71
+ else result.to_json
72
+ end
73
+
74
+ {
75
+ role: "tool",
76
+ tool_call_id: @id,
77
+ content: content
78
+ }
79
+ end
80
+
81
+ def to_h
82
+ {
83
+ id: @id,
84
+ type: @type,
85
+ function: {
86
+ name: @function_name,
87
+ arguments: @arguments_string
88
+ }
89
+ }
90
+ end
91
+
92
+ def to_json(*args)
93
+ to_h.to_json(*args)
94
+ end
95
+
96
+ # Validate against a provided array of tools (Tool instances or hashes)
97
+ def valid?(tools:)
98
+ # tools is now a required keyword argument
99
+
100
+ schema = find_schema_for_call(tools)
101
+ return true unless schema # No validation if tool not found
102
+
103
+ return JSON::Validator.validate(schema, arguments) if validation_available?
104
+
105
+ # Fallback: shallow required check
106
+ required = Array(schema[:required]).map(&:to_s)
107
+ required.all? { |k| arguments.key?(k) }
108
+ rescue StandardError
109
+ false
110
+ end
111
+
112
+ def validation_errors(tools:)
113
+ # tools is now a required keyword argument
114
+
115
+ schema = find_schema_for_call(tools)
116
+ return [] unless schema # No errors if tool not found
117
+
118
+ return JSON::Validator.fully_validate(schema, arguments) if validation_available?
119
+
120
+ # Fallback: check required fields
121
+ required = Array(schema[:required]).map(&:to_s)
122
+ missing = required.reject { |k| arguments.key?(k) }
123
+ missing.any? ? ["Missing required keys: #{missing.join(", ")}"] : []
124
+ rescue StandardError => e
125
+ ["Validation error: #{e.message}"]
126
+ end
127
+
128
+ private
129
+
130
+ # Check if JSON schema validation is available
131
+ def validation_available?
132
+ !!defined?(JSON::Validator)
133
+ end
134
+
135
+ def find_schema_for_call(tools)
136
+ tool = Array(tools).find do |t|
137
+ t_name = t.is_a?(OpenRouter::Tool) ? t.name : t.dig(:function, :name)
138
+ t_name == @function_name
139
+ end
140
+ return nil unless tool
141
+
142
+ params = tool.is_a?(OpenRouter::Tool) ? tool.parameters : tool.dig(:function, :parameters)
143
+ params.is_a?(Hash) ? params : nil
144
+ end
145
+ end
146
+
147
+ # Represents the result of executing a tool call
148
+ class ToolResult
149
+ attr_reader :tool_call, :result, :error
150
+
151
+ def initialize(tool_call, result = nil, error = nil)
152
+ @tool_call = tool_call
153
+ @result = result
154
+ @error = error
155
+ end
156
+
157
+ def success?
158
+ @error.nil?
159
+ end
160
+
161
+ def failure?
162
+ !success?
163
+ end
164
+
165
+ # Convert to message format for conversation continuation
166
+ def to_message
167
+ @tool_call.to_result_message(@error || @result)
168
+ end
169
+
170
+ # Create a failed result
171
+ def self.failure(tool_call, error)
172
+ new(tool_call, nil, error)
173
+ end
174
+
175
+ # Create a successful result
176
+ def self.success(tool_call, result)
177
+ new(tool_call, result, nil)
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Tracks token usage and costs across API calls
5
+ class UsageTracker
6
+ attr_reader :total_prompt_tokens, :total_completion_tokens, :total_cached_tokens,
7
+ :total_cost, :request_count, :model_usage, :session_start
8
+
9
+ def initialize
10
+ reset!
11
+ end
12
+
13
+ # Reset all tracking counters
14
+ def reset!
15
+ @total_prompt_tokens = 0
16
+ @total_completion_tokens = 0
17
+ @total_cached_tokens = 0
18
+ @total_cost = 0.0
19
+ @request_count = 0
20
+ @model_usage = Hash.new { |h, k| h[k] = create_model_stats }
21
+ @session_start = Time.now
22
+ @request_history = []
23
+ end
24
+
25
+ # Track usage from a response
26
+ #
27
+ # @param response [Response] The response object to track
28
+ # @param model [String] The model used (optional, will try to get from response)
29
+ def track(response, model: nil)
30
+ return unless response
31
+
32
+ model ||= response.model
33
+ prompt_tokens = response.prompt_tokens
34
+ completion_tokens = response.completion_tokens
35
+ cached_tokens = response.cached_tokens
36
+ cost = response.cost_estimate || estimate_cost(model, prompt_tokens, completion_tokens)
37
+
38
+ # Update totals
39
+ @total_prompt_tokens += prompt_tokens
40
+ @total_completion_tokens += completion_tokens
41
+ @total_cached_tokens += cached_tokens
42
+ @total_cost += cost if cost
43
+ @request_count += 1
44
+
45
+ # Update per-model stats
46
+ if model
47
+ @model_usage[model][:prompt_tokens] += prompt_tokens
48
+ @model_usage[model][:completion_tokens] += completion_tokens
49
+ @model_usage[model][:cached_tokens] += cached_tokens
50
+ @model_usage[model][:cost] += cost if cost
51
+ @model_usage[model][:requests] += 1
52
+ end
53
+
54
+ # Store in history
55
+ @request_history << {
56
+ timestamp: Time.now,
57
+ model: model,
58
+ prompt_tokens: prompt_tokens,
59
+ completion_tokens: completion_tokens,
60
+ cached_tokens: cached_tokens,
61
+ cost: cost,
62
+ response_id: response.id
63
+ }
64
+
65
+ self
66
+ end
67
+
68
+ # Get total tokens used
69
+ def total_tokens
70
+ @total_prompt_tokens + @total_completion_tokens
71
+ end
72
+
73
+ # Get average tokens per request
74
+ def average_tokens_per_request
75
+ return 0 if @request_count.zero?
76
+
77
+ total_tokens.to_f / @request_count
78
+ end
79
+
80
+ # Get average cost per request
81
+ def average_cost_per_request
82
+ return 0 if @request_count.zero?
83
+
84
+ @total_cost / @request_count
85
+ end
86
+
87
+ # Get session duration in seconds
88
+ def session_duration
89
+ Time.now - @session_start
90
+ end
91
+
92
+ # Get tokens per second
93
+ def tokens_per_second
94
+ duration = session_duration
95
+ return 0 if duration.zero?
96
+
97
+ total_tokens.to_f / duration
98
+ end
99
+
100
+ # Get most used model
101
+ def most_used_model
102
+ return nil if @model_usage.empty?
103
+
104
+ @model_usage.max_by { |_, stats| stats[:requests] }&.first
105
+ end
106
+
107
+ # Get most expensive model
108
+ def most_expensive_model
109
+ return nil if @model_usage.empty?
110
+
111
+ @model_usage.max_by { |_, stats| stats[:cost] }&.first
112
+ end
113
+
114
+ # Get cache hit rate
115
+ def cache_hit_rate
116
+ return 0 if @total_prompt_tokens.zero?
117
+
118
+ (@total_cached_tokens.to_f / @total_prompt_tokens) * 100
119
+ end
120
+
121
+ # Get usage summary
122
+ def summary
123
+ {
124
+ session: {
125
+ start: @session_start,
126
+ duration_seconds: session_duration,
127
+ requests: @request_count
128
+ },
129
+ tokens: {
130
+ total: total_tokens,
131
+ prompt: @total_prompt_tokens,
132
+ completion: @total_completion_tokens,
133
+ cached: @total_cached_tokens,
134
+ cache_hit_rate: "#{cache_hit_rate.round(2)}%"
135
+ },
136
+ cost: {
137
+ total: @total_cost.round(4),
138
+ average_per_request: average_cost_per_request.round(4)
139
+ },
140
+ performance: {
141
+ tokens_per_second: tokens_per_second.round(2),
142
+ average_tokens_per_request: average_tokens_per_request.round(0)
143
+ },
144
+ models: {
145
+ most_used: most_used_model,
146
+ most_expensive: most_expensive_model,
147
+ breakdown: model_breakdown
148
+ }
149
+ }
150
+ end
151
+
152
+ # Get model usage breakdown
153
+ def model_breakdown
154
+ @model_usage.transform_values do |stats|
155
+ {
156
+ requests: stats[:requests],
157
+ tokens: stats[:prompt_tokens] + stats[:completion_tokens],
158
+ cost: stats[:cost].round(4),
159
+ cached_tokens: stats[:cached_tokens]
160
+ }
161
+ end
162
+ end
163
+
164
+ # Export usage history as CSV
165
+ def export_csv
166
+ require "csv"
167
+
168
+ CSV.generate do |csv|
169
+ csv << ["Timestamp", "Model", "Prompt Tokens", "Completion Tokens", "Cached Tokens", "Cost", "Response ID"]
170
+ @request_history.each do |entry|
171
+ csv << [
172
+ entry[:timestamp].iso8601,
173
+ entry[:model],
174
+ entry[:prompt_tokens],
175
+ entry[:completion_tokens],
176
+ entry[:cached_tokens],
177
+ entry[:cost]&.round(4),
178
+ entry[:response_id]
179
+ ]
180
+ end
181
+ end
182
+ end
183
+
184
+ # Get request history
185
+ def history(limit: nil)
186
+ limit ? @request_history.last(limit) : @request_history
187
+ end
188
+
189
+ # Pretty print summary to console
190
+ def print_summary
191
+ summary_data = summary
192
+
193
+ puts "\n#{"=" * 60}"
194
+ puts " OpenRouter Usage Summary"
195
+ puts "=" * 60
196
+
197
+ puts "\n📊 Session"
198
+ puts " Started: #{summary_data[:session][:start].strftime("%Y-%m-%d %H:%M:%S")}"
199
+ puts " Duration: #{format_duration(summary_data[:session][:duration_seconds])}"
200
+ puts " Requests: #{summary_data[:session][:requests]}"
201
+
202
+ puts "\n🔤 Tokens"
203
+ puts " Total: #{format_number(summary_data[:tokens][:total])}"
204
+ puts " Prompt: #{format_number(summary_data[:tokens][:prompt])}"
205
+ puts " Completion: #{format_number(summary_data[:tokens][:completion])}"
206
+ puts " Cached: #{format_number(summary_data[:tokens][:cached])} (#{summary_data[:tokens][:cache_hit_rate]})"
207
+
208
+ puts "\n💰 Cost"
209
+ puts " Total: $#{summary_data[:cost][:total]}"
210
+ puts " Average/Request: $#{summary_data[:cost][:average_per_request]}"
211
+
212
+ puts "\n⚡ Performance"
213
+ puts " Tokens/Second: #{summary_data[:performance][:tokens_per_second]}"
214
+ puts " Average Tokens/Request: #{summary_data[:performance][:average_tokens_per_request]}"
215
+
216
+ if summary_data[:models][:breakdown].any?
217
+ puts "\n🤖 Models Used"
218
+ summary_data[:models][:breakdown].each do |model, stats|
219
+ puts " #{model}:"
220
+ puts " Requests: #{stats[:requests]}"
221
+ puts " Tokens: #{format_number(stats[:tokens])}"
222
+ puts " Cost: $#{stats[:cost]}"
223
+ end
224
+ end
225
+
226
+ puts "\n#{"=" * 60}"
227
+ end
228
+
229
+ private
230
+
231
+ def create_model_stats
232
+ {
233
+ prompt_tokens: 0,
234
+ completion_tokens: 0,
235
+ cached_tokens: 0,
236
+ cost: 0.0,
237
+ requests: 0
238
+ }
239
+ end
240
+
241
+ # Estimate cost if not available from response
242
+ def estimate_cost(model, prompt_tokens, completion_tokens)
243
+ return 0 unless model
244
+
245
+ # Try to get pricing from model registry
246
+ model_data = ModelRegistry.get_model(model)
247
+ return 0 unless model_data
248
+
249
+ pricing = model_data["pricing"]
250
+ return 0 unless pricing
251
+
252
+ prompt_cost = (prompt_tokens / 1_000_000.0) * pricing["prompt"].to_f
253
+ completion_cost = (completion_tokens / 1_000_000.0) * pricing["completion"].to_f
254
+
255
+ prompt_cost + completion_cost
256
+ rescue StandardError
257
+ 0
258
+ end
259
+
260
+ def format_number(num)
261
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
262
+ end
263
+
264
+ def format_duration(seconds)
265
+ hours = seconds / 3600
266
+ minutes = (seconds % 3600) / 60
267
+ seconds %= 60
268
+
269
+ parts = []
270
+ parts << "#{hours.to_i}h" if hours >= 1
271
+ parts << "#{minutes.to_i}m" if minutes >= 1
272
+ parts << "#{seconds.to_i}s"
273
+
274
+ parts.join(" ")
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/multipart"
5
+
6
+ begin
7
+ require "faraday_middleware"
8
+ module OpenRouter; HAS_JSON_MW = true; end
9
+ rescue LoadError
10
+ module OpenRouter; HAS_JSON_MW = false; end
11
+ end
12
+
13
+ module OpenRouter
14
+ class Error < StandardError; end
15
+ class ConfigurationError < Error; end
16
+ class CapabilityError < Error; end
17
+ end
18
+
19
+ require_relative "open_router/http"
20
+ require_relative "open_router/tool"
21
+ require_relative "open_router/tool_call"
22
+ require_relative "open_router/schema"
23
+ require_relative "open_router/json_healer"
24
+ require_relative "open_router/response"
25
+ require_relative "open_router/model_registry"
26
+ require_relative "open_router/model_selector"
27
+ require_relative "open_router/prompt_template"
28
+ require_relative "open_router/usage_tracker"
29
+ require_relative "open_router/client"
30
+ require_relative "open_router/streaming_client"
31
+ require_relative "open_router/version"
32
+
33
+ module OpenRouter
34
+ class Configuration
35
+ attr_writer :access_token
36
+ attr_accessor :api_version, :extra_headers, :faraday_config, :log_errors, :request_timeout, :uri_base
37
+
38
+ # Healing configuration
39
+ attr_accessor :auto_heal_responses, :healer_model, :max_heal_attempts
40
+
41
+ # Cache configuration
42
+ attr_accessor :cache_ttl
43
+
44
+ # Model registry configuration
45
+ attr_accessor :model_registry_timeout, :model_registry_retries
46
+
47
+ # Capability validation configuration
48
+ attr_accessor :strict_mode
49
+
50
+ # Automatic forcing configuration
51
+ attr_accessor :auto_force_on_unsupported_models
52
+
53
+ # Default structured output mode configuration
54
+ attr_accessor :default_structured_output_mode
55
+
56
+ DEFAULT_API_VERSION = "v1"
57
+ DEFAULT_REQUEST_TIMEOUT = 120
58
+ DEFAULT_URI_BASE = "https://openrouter.ai/api"
59
+ DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60 # 7 days in seconds
60
+ DEFAULT_MODEL_REGISTRY_TIMEOUT = 30
61
+ DEFAULT_MODEL_REGISTRY_RETRIES = 3
62
+
63
+ def initialize
64
+ self.access_token = nil
65
+ self.api_version = DEFAULT_API_VERSION
66
+ self.extra_headers = {}
67
+ self.log_errors = false
68
+ self.request_timeout = DEFAULT_REQUEST_TIMEOUT
69
+ self.uri_base = DEFAULT_URI_BASE
70
+
71
+ # Healing defaults
72
+ self.auto_heal_responses = false
73
+ self.healer_model = "openai/gpt-4o-mini"
74
+ self.max_heal_attempts = 2
75
+
76
+ # Cache defaults
77
+ self.cache_ttl = ENV.fetch("OPENROUTER_CACHE_TTL", DEFAULT_CACHE_TTL).to_i
78
+
79
+ # Model registry defaults
80
+ self.model_registry_timeout = ENV.fetch("OPENROUTER_REGISTRY_TIMEOUT", DEFAULT_MODEL_REGISTRY_TIMEOUT).to_i
81
+ self.model_registry_retries = ENV.fetch("OPENROUTER_REGISTRY_RETRIES", DEFAULT_MODEL_REGISTRY_RETRIES).to_i
82
+
83
+ # Capability validation defaults
84
+ self.strict_mode = ENV.fetch("OPENROUTER_STRICT_MODE", "false").downcase == "true"
85
+
86
+ # Auto forcing defaults
87
+ self.auto_force_on_unsupported_models = ENV.fetch("OPENROUTER_AUTO_FORCE", "true").downcase == "true"
88
+
89
+ # Default structured output mode
90
+ self.default_structured_output_mode = ENV.fetch("OPENROUTER_DEFAULT_MODE", "strict").to_sym
91
+ end
92
+
93
+ def access_token
94
+ return @access_token if @access_token
95
+
96
+ raise ConfigurationError, "OpenRouter access token missing!"
97
+ end
98
+
99
+ def faraday(&block)
100
+ self.faraday_config = block
101
+ end
102
+
103
+ def site_name=(value)
104
+ @extra_headers["X-Title"] = value
105
+ end
106
+
107
+ def site_url=(value)
108
+ @extra_headers["HTTP-Referer"] = value
109
+ end
110
+ end
111
+
112
+ class << self
113
+ attr_writer :configuration
114
+ end
115
+
116
+ def self.configuration
117
+ @configuration ||= OpenRouter::Configuration.new
118
+ end
119
+
120
+ def self.configure
121
+ yield(configuration)
122
+ end
123
+ end
@@ -0,0 +1,20 @@
1
+ module OpenRouter
2
+ class Client
3
+ include OpenRouter::HTTP
4
+
5
+ def initialize: () { (OpenRouter::Configuration) -> void } -> void
6
+
7
+ def complete: (
8
+ messages: Array[Hash[Symbol, String]],
9
+ ?model: String,
10
+ ?providers: Array[String],
11
+ ?transforms: Array[String],
12
+ ?extras: Hash[Symbol, untyped],
13
+ ?stream: Proc
14
+ ) -> Hash[String, untyped]
15
+
16
+ def models: () -> Array[Hash[String, untyped]]
17
+
18
+ def query_generation_stats: (generation_id: String) -> Hash[String, untyped]
19
+ end
20
+ end