savvy_openrouter 0.2.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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +50 -0
  4. data/CHANGELOG.md +23 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +234 -0
  8. data/Rakefile +12 -0
  9. data/exe/savvy_openrouter +32 -0
  10. data/lib/generators/savvy_openrouter/install/install_generator.rb +17 -0
  11. data/lib/generators/savvy_openrouter/install/templates/savvy_openrouter.yml +53 -0
  12. data/lib/savvy_openrouter/api_call_logger.rb +93 -0
  13. data/lib/savvy_openrouter/client.rb +105 -0
  14. data/lib/savvy_openrouter/completion_retry_policy.rb +135 -0
  15. data/lib/savvy_openrouter/configuration.rb +156 -0
  16. data/lib/savvy_openrouter/connection.rb +316 -0
  17. data/lib/savvy_openrouter/connection_instrumentation.rb +133 -0
  18. data/lib/savvy_openrouter/errors.rb +35 -0
  19. data/lib/savvy_openrouter/resources/analytics.rb +13 -0
  20. data/lib/savvy_openrouter/resources/anthropic_messages.rb +14 -0
  21. data/lib/savvy_openrouter/resources/api_keys.rb +33 -0
  22. data/lib/savvy_openrouter/resources/audio.rb +19 -0
  23. data/lib/savvy_openrouter/resources/base.rb +23 -0
  24. data/lib/savvy_openrouter/resources/chat.rb +45 -0
  25. data/lib/savvy_openrouter/resources/credits.rb +13 -0
  26. data/lib/savvy_openrouter/resources/embeddings.rb +18 -0
  27. data/lib/savvy_openrouter/resources/endpoints.rb +13 -0
  28. data/lib/savvy_openrouter/resources/generations.rb +17 -0
  29. data/lib/savvy_openrouter/resources/guardrails.rb +61 -0
  30. data/lib/savvy_openrouter/resources/models.rb +57 -0
  31. data/lib/savvy_openrouter/resources/oauth.rb +17 -0
  32. data/lib/savvy_openrouter/resources/organization.rb +13 -0
  33. data/lib/savvy_openrouter/resources/providers.rb +13 -0
  34. data/lib/savvy_openrouter/resources/rerank.rb +14 -0
  35. data/lib/savvy_openrouter/resources/responses.rb +14 -0
  36. data/lib/savvy_openrouter/resources/videos.rb +53 -0
  37. data/lib/savvy_openrouter/resources/workspaces.rb +37 -0
  38. data/lib/savvy_openrouter/streaming.rb +32 -0
  39. data/lib/savvy_openrouter/version.rb +5 -0
  40. data/lib/savvy_openrouter.rb +13 -0
  41. data/savvy_openrouter-0.1.0.gem +0 -0
  42. data/sig/savvy_openrouter.rbs +150 -0
  43. metadata +165 -0
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module SavvyOpenrouter
6
+ # Persists OpenRouter HTTP exchanges when configured via +api_call_log+ (YAML or Client kwargs).
7
+ # Failures while saving never raise into application code.
8
+ class ApiCallLogger
9
+ DEFAULT_MAX_BODY_BYTES = 65_536
10
+
11
+ CANONICAL_KEYS = %w[
12
+ method path status duration_ms request_body response_body error_class error_message streaming
13
+ ].freeze
14
+
15
+ class << self
16
+ def format_body_for_log(obj, max_bytes: DEFAULT_MAX_BODY_BYTES)
17
+ str =
18
+ case obj
19
+ when nil then +""
20
+ when String then obj.b
21
+ else
22
+ JSON.generate(obj)
23
+ end
24
+ str = redact_secrets(str)
25
+ truncate_bytes(str, max_bytes)
26
+ end
27
+
28
+ private
29
+
30
+ def redact_secrets(str)
31
+ s = str.dup
32
+ s.gsub!(/sk-or-v1-[A-Za-z0-9_-]+/, "sk-or-v1-[REDACTED]")
33
+ s.gsub!(/Bearer\s+[A-Za-z0-9._-]+/i, "Bearer [REDACTED]")
34
+ s
35
+ end
36
+
37
+ def truncate_bytes(str, max_bytes)
38
+ return str if str.bytesize <= max_bytes
39
+
40
+ "#{str.byteslice(0, max_bytes)}…(truncated)"
41
+ end
42
+ end
43
+
44
+ def initialize(config)
45
+ @config = config.is_a?(Hash) ? Configuration.stringify_keys_static(config) : {}
46
+ end
47
+
48
+ def enabled?
49
+ m = @config["model"]
50
+ !m.nil? && !m.to_s.strip.empty? &&
51
+ @config["columns"].is_a?(Hash) && !@config["columns"].empty?
52
+ end
53
+
54
+ def max_body_limit
55
+ n = @config["max_body_bytes"]
56
+ n.is_a?(Integer) && n.positive? ? n : DEFAULT_MAX_BODY_BYTES
57
+ end
58
+
59
+ # +attrs+ uses canonical string keys (see CANONICAL_KEYS). Column mapping is applied before create.
60
+ def record(attrs)
61
+ return unless enabled?
62
+
63
+ row = build_row(attrs)
64
+ return if row.empty?
65
+
66
+ constantize_model(@config["model"].to_s.strip).create!(row)
67
+ rescue StandardError => e
68
+ warn "[savvy_openrouter] api_call_log skipped: #{e.class}: #{e.message}" if $VERBOSE
69
+ nil
70
+ end
71
+
72
+ private
73
+
74
+ def build_row(attrs)
75
+ cols = @config["columns"] || {}
76
+ attrs = Configuration.stringify_keys_static(attrs)
77
+ cols.each_with_object({}) do |(canonical, column_name), acc|
78
+ next unless CANONICAL_KEYS.include?(canonical.to_s)
79
+ next unless attrs.key?(canonical.to_s)
80
+
81
+ acc[column_name.to_s] = attrs[canonical.to_s]
82
+ end
83
+ end
84
+
85
+ def constantize_model(name)
86
+ raise NameError, "blank model" if name.empty?
87
+ raise NameError, "invalid model name #{name.inspect}" if name.include?("..") || /[^A-Za-z0-9_:]/.match?(name)
88
+
89
+ parts = name.delete_prefix("::").split("::")
90
+ parts.reduce(Object) { |mod, piece| mod.const_get(piece) }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "connection"
5
+ require_relative "resources/chat"
6
+ require_relative "resources/responses"
7
+ require_relative "resources/anthropic_messages"
8
+ require_relative "resources/embeddings"
9
+ require_relative "resources/rerank"
10
+ require_relative "resources/models"
11
+ require_relative "resources/credits"
12
+ require_relative "resources/providers"
13
+ require_relative "resources/generations"
14
+ require_relative "resources/endpoints"
15
+ require_relative "resources/analytics"
16
+ require_relative "resources/audio"
17
+ require_relative "resources/videos"
18
+ require_relative "resources/oauth"
19
+ require_relative "resources/api_keys"
20
+ require_relative "resources/organization"
21
+ require_relative "resources/guardrails"
22
+ require_relative "resources/workspaces"
23
+
24
+ module SavvyOpenrouter
25
+ class Client
26
+ attr_reader :config, :connection
27
+
28
+ def initialize(config_path: nil, **options)
29
+ @config = Configuration.new(config_path: config_path, **options)
30
+ @connection = Connection.new(@config)
31
+ end
32
+
33
+ def chat
34
+ @chat ||= Resources::Chat.new(self)
35
+ end
36
+
37
+ def responses
38
+ @responses ||= Resources::Responses.new(self)
39
+ end
40
+
41
+ def anthropic_messages
42
+ @anthropic_messages ||= Resources::AnthropicMessages.new(self)
43
+ end
44
+
45
+ def embeddings
46
+ @embeddings ||= Resources::Embeddings.new(self)
47
+ end
48
+
49
+ def rerank
50
+ @rerank ||= Resources::Rerank.new(self)
51
+ end
52
+
53
+ def models
54
+ @models ||= Resources::Models.new(self)
55
+ end
56
+
57
+ def credits
58
+ @credits ||= Resources::Credits.new(self)
59
+ end
60
+
61
+ def providers
62
+ @providers ||= Resources::Providers.new(self)
63
+ end
64
+
65
+ def generations
66
+ @generations ||= Resources::Generations.new(self)
67
+ end
68
+
69
+ def endpoints
70
+ @endpoints ||= Resources::Endpoints.new(self)
71
+ end
72
+
73
+ def analytics
74
+ @analytics ||= Resources::Analytics.new(self)
75
+ end
76
+
77
+ def audio
78
+ @audio ||= Resources::Audio.new(self)
79
+ end
80
+
81
+ def videos
82
+ @videos ||= Resources::Videos.new(self)
83
+ end
84
+
85
+ def oauth
86
+ @oauth ||= Resources::OAuth.new(self)
87
+ end
88
+
89
+ def api_keys
90
+ @api_keys ||= Resources::ApiKeys.new(self)
91
+ end
92
+
93
+ def organization
94
+ @organization ||= Resources::Organization.new(self)
95
+ end
96
+
97
+ def guardrails
98
+ @guardrails ||= Resources::Guardrails.new(self)
99
+ end
100
+
101
+ def workspaces
102
+ @workspaces ||= Resources::Workspaces.new(self)
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SavvyOpenrouter
4
+ # Controls optional retries for +Chat#completions+ (non-streaming): empty/zero-token
5
+ # completions and selected HTTP errors from OpenRouter.
6
+ class CompletionRetryPolicy
7
+ DEFAULT_ON = {
8
+ "zero_completion_tokens" => true,
9
+ "empty_assistant_content" => true,
10
+ "rate_limit" => true,
11
+ "bad_gateway" => true,
12
+ "internal_server_error" => true,
13
+ "service_unavailable" => true
14
+ }.freeze
15
+
16
+ def initialize(raw)
17
+ @raw =
18
+ case raw
19
+ when false, nil then {}
20
+ when Hash then Configuration.stringify_keys_static(raw)
21
+ else
22
+ raise ArgumentError, "chat_retries must be a Hash or false"
23
+ end
24
+ end
25
+
26
+ def max_attempts
27
+ n = @raw["max_attempts"]
28
+ i = Integer(n, exception: false)
29
+ i&.positive? ? i : 1
30
+ end
31
+
32
+ def enabled?
33
+ max_attempts > 1
34
+ end
35
+
36
+ def retry_http_error?(error)
37
+ return false unless enabled?
38
+ return false unless error.is_a?(SavvyOpenrouter::ApiError)
39
+
40
+ code = error.status_code
41
+ return false unless code
42
+
43
+ flag =
44
+ case code
45
+ when 429 then "rate_limit"
46
+ when 502 then "bad_gateway"
47
+ when 500, 501 then "internal_server_error"
48
+ when 503 then "service_unavailable"
49
+ end
50
+ flag ? on?(flag) : false
51
+ end
52
+
53
+ def retry_response?(response)
54
+ return false unless enabled?
55
+
56
+ (on?("zero_completion_tokens") && zero_completion_tokens?(response)) ||
57
+ (on?("empty_assistant_content") && empty_assistant_content?(response))
58
+ end
59
+
60
+ def wait_after_attempt(attempt_number)
61
+ base = integer_opt(@raw["base_delay_ms"], 400)
62
+ max_d = integer_opt(@raw["max_delay_ms"], 10_000)
63
+ exponential = @raw["exponential_backoff"] != false
64
+ jitter_ratio = float_opt(@raw["jitter_ratio"], 0.15)
65
+
66
+ delay_ms =
67
+ if exponential
68
+ base * (2**(attempt_number - 1))
69
+ else
70
+ base
71
+ end
72
+ delay_ms = [delay_ms, max_d].min
73
+ jitter = jitter_ratio.clamp(0.0, 1.0) * delay_ms * rand
74
+ sleep((delay_ms + jitter) / 1000.0)
75
+ end
76
+
77
+ private
78
+
79
+ def on?(key)
80
+ merged_on[key.to_s] == true
81
+ end
82
+
83
+ def merged_on
84
+ @merged_on ||= DEFAULT_ON.merge(explicit_on)
85
+ end
86
+
87
+ def explicit_on
88
+ h = @raw["on"]
89
+ h.is_a?(Hash) ? Configuration.stringify_keys_static(h) : {}
90
+ end
91
+
92
+ def zero_completion_tokens?(response)
93
+ usage = dig_usage(response)
94
+ return false unless usage
95
+
96
+ ct = usage[:completion_tokens] || usage["completion_tokens"]
97
+ ct.nil? ? false : ct.to_i.zero?
98
+ end
99
+
100
+ def empty_assistant_content?(response)
101
+ msg = first_assistant_message(response)
102
+ return false unless msg
103
+
104
+ tool_calls = msg[:tool_calls] || msg["tool_calls"]
105
+ return false if tool_calls.is_a?(Array) && !tool_calls.empty?
106
+
107
+ content = msg[:content] || msg["content"]
108
+ return true if content.nil?
109
+
110
+ !content.is_a?(String) || content.strip.empty?
111
+ end
112
+
113
+ def dig_usage(response)
114
+ response[:usage] || response["usage"]
115
+ end
116
+
117
+ def first_assistant_message(response)
118
+ choices = response[:choices] || response["choices"]
119
+ return nil unless choices.is_a?(Array) && choices.first
120
+
121
+ choice = choices.first
122
+ choice[:message] || choice["message"]
123
+ end
124
+
125
+ def integer_opt(val, default)
126
+ i = Integer(val, exception: false)
127
+ i&.positive? ? i : default
128
+ end
129
+
130
+ def float_opt(val, default)
131
+ f = Float(val, exception: false)
132
+ f.nil? || f.negative? ? default : f
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module SavvyOpenrouter
6
+ class Configuration
7
+ attr_accessor :api_key, :base_url, :default_model, :http_referer, :app_title
8
+ attr_reader :defaults, :video_defaults, :responses_defaults, :api_call_log, :chat_retries
9
+
10
+ alias llm_model default_model
11
+ alias llm_model= default_model=
12
+
13
+ def self.load_file(path)
14
+ return {} unless path && File.file?(path)
15
+
16
+ data = YAML.safe_load(File.read(path), permitted_classes: [], permitted_symbols: [], aliases: true)
17
+ data.is_a?(Hash) ? stringify_keys_static(data) : {}
18
+ end
19
+
20
+ def self.stringify_keys_static(hash)
21
+ hash.each_with_object({}) do |(k, v), acc|
22
+ acc[k.to_s] = v.is_a?(Hash) ? stringify_keys_static(v) : v
23
+ end
24
+ end
25
+
26
+ # Precedence: explicit keyword args (highest) > YAML file > ENV (lowest).
27
+ def initialize(config_path: nil, **options)
28
+ @defaults = {}
29
+ @video_defaults = {}
30
+ @responses_defaults = {}
31
+ @api_call_log = {}
32
+ @chat_retries = {}
33
+ load_from_env!
34
+ yaml_path = config_path || self.class.discover_config_file
35
+ merge_hash!(self.class.load_file(yaml_path)) if yaml_path
36
+ apply_options!(options)
37
+ end
38
+
39
+ def self.discover_config_file(cwd = Dir.pwd)
40
+ %w[config/savvy_openrouter.yml .savvy_openrouter.yml].each do |rel|
41
+ path = File.join(cwd, rel)
42
+ return path if File.file?(path)
43
+ end
44
+ nil
45
+ end
46
+
47
+ def merge_hash!(hash)
48
+ return unless hash.is_a?(Hash)
49
+
50
+ hash = self.class.stringify_keys_static(hash)
51
+ @api_key = hash["api_key"] if hash.key?("api_key")
52
+ @base_url = hash["base_url"] if hash.key?("base_url")
53
+ @default_model = hash["default_model"] || hash["llm_model"] if hash.key?("default_model") || hash.key?("llm_model")
54
+ @http_referer = hash["http_referer"] if hash.key?("http_referer")
55
+ @app_title = hash["app_title"] || hash["x_title"] if hash.key?("app_title") || hash.key?("x_title")
56
+ @defaults = @defaults.merge(self.class.stringify_keys_static(hash["defaults"] || {}))
57
+ vd = hash["video_defaults"] || hash["defaults_video"]
58
+ @video_defaults = @video_defaults.merge(self.class.stringify_keys_static(vd || {}))
59
+ rd = hash["responses_defaults"] || hash["defaults_responses"]
60
+ @responses_defaults = @responses_defaults.merge(self.class.stringify_keys_static(rd || {}))
61
+ assign_api_call_log(hash["api_call_log"]) if hash.key?("api_call_log")
62
+ assign_chat_retries(hash["chat_retries"]) if hash.key?("chat_retries")
63
+ assign_chat_retries(hash["completion_retries"]) if hash.key?("completion_retries")
64
+ end
65
+
66
+ def merge_chat_body(body)
67
+ body = stringify_keys(body)
68
+ merged = @defaults.merge(body)
69
+ merged["model"] ||= @default_model if @default_model && merged["model"].nil?
70
+ merged
71
+ end
72
+
73
+ def merge_video_body(body)
74
+ body = stringify_keys(body)
75
+ merged = @video_defaults.merge(body)
76
+ merged["model"] ||= @default_model if @default_model && merged["model"].nil?
77
+ merged
78
+ end
79
+
80
+ # Defaults only for `POST /responses` (Responses API beta): `plugins`, `tools`,
81
+ # `max_output_tokens`, `x_search_filter`, etc. Keeps web-search settings off chat/embeddings bodies.
82
+ def merge_responses_body(body)
83
+ body = stringify_keys(body)
84
+ merged = @responses_defaults.merge(body)
85
+ merged["model"] ||= @default_model if @default_model && merged["model"].nil?
86
+ merged
87
+ end
88
+
89
+ private
90
+
91
+ def load_from_env!
92
+ @api_key = ENV.fetch("OPENROUTER_API_KEY", nil)
93
+ @base_url = ENV.fetch("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
94
+ @default_model = ENV.fetch("OPENROUTER_DEFAULT_MODEL", nil)
95
+ @http_referer = ENV.fetch("OPENROUTER_HTTP_REFERER", nil)
96
+ @app_title = ENV.fetch("OPENROUTER_APP_TITLE", nil)
97
+ end
98
+
99
+ def apply_options!(options)
100
+ opts = options.dup
101
+ @defaults = @defaults.merge(stringify_keys(opts.delete(:defaults) || {}))
102
+ vd = opts.delete(:video_defaults) || opts.delete(:defaults_video)
103
+ @video_defaults = @video_defaults.merge(stringify_keys(vd || {}))
104
+ rd = opts.delete(:responses_defaults) || opts.delete(:defaults_responses)
105
+ @responses_defaults = @responses_defaults.merge(stringify_keys(rd || {}))
106
+
107
+ @api_key = opts.delete(:api_key) if opts.key?(:api_key)
108
+ @base_url = opts.delete(:base_url) if opts.key?(:base_url)
109
+ if opts.key?(:default_model) || opts.key?(:llm_model)
110
+ @default_model = opts.delete(:default_model) || opts.delete(:llm_model)
111
+ end
112
+ @http_referer = opts.delete(:http_referer) if opts.key?(:http_referer)
113
+ @app_title = opts.delete(:app_title) if opts.key?(:app_title)
114
+ assign_api_call_log(opts.delete(:api_call_log)) if opts.key?(:api_call_log)
115
+ if opts.key?(:chat_retries)
116
+ assign_chat_retries(opts.delete(:chat_retries))
117
+ elsif opts.key?(:completion_retries)
118
+ assign_chat_retries(opts.delete(:completion_retries))
119
+ end
120
+
121
+ return if opts.empty?
122
+
123
+ raise ArgumentError, "Unknown keywords: #{opts.keys.join(", ")}"
124
+ end
125
+
126
+ def stringify_keys(hash)
127
+ self.class.stringify_keys_static(hash || {})
128
+ end
129
+
130
+ def assign_api_call_log(value)
131
+ case value
132
+ when false, nil
133
+ @api_call_log = {}
134
+ when Hash
135
+ h = self.class.stringify_keys_static(value)
136
+ h["columns"] = self.class.stringify_keys_static(h["columns"]) if h["columns"].is_a?(Hash)
137
+ @api_call_log = h
138
+ else
139
+ raise ArgumentError, "api_call_log must be a Hash or false"
140
+ end
141
+ end
142
+
143
+ def assign_chat_retries(value)
144
+ case value
145
+ when false, nil
146
+ @chat_retries = {}
147
+ when Hash
148
+ h = self.class.stringify_keys_static(value)
149
+ h["on"] = self.class.stringify_keys_static(h["on"]) if h["on"].is_a?(Hash)
150
+ @chat_retries = h
151
+ else
152
+ raise ArgumentError, "chat_retries must be a Hash or false"
153
+ end
154
+ end
155
+ end
156
+ end