savvy_openrouter 0.2.0 → 0.4.2

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.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SavvyOpenrouter
4
+ class Connection
5
+ # JSON Faraday exchanges + timing helpers for {api_call_log}.
6
+ module ApiCallRecording
7
+ private
8
+
9
+ def record_faraday_json(method:, rel_path:, params:, request_body:, response:, duration_ms:)
10
+ return if suppress_api_call_log?
11
+ return unless @api_call_logger.enabled?
12
+
13
+ attrs = merge_call_log_context(
14
+ faraday_json_attrs(method: method, rel_path: rel_path, params: params, request_body: request_body,
15
+ response: response, duration_ms: duration_ms)
16
+ )
17
+
18
+ if defer_chat_completions_log?(method: method, rel_path: rel_path)
19
+ @pending_deferred_chat_log = attrs
20
+ return
21
+ end
22
+
23
+ if defer_responses_log?(method: method, rel_path: rel_path)
24
+ @pending_deferred_responses_log = attrs
25
+ return
26
+ end
27
+
28
+ @api_call_logger.record(attrs)
29
+ end
30
+
31
+ def faraday_json_attrs(method:, rel_path:, params:, request_body:, response:, duration_ms:)
32
+ lim = @api_call_logger.max_body_limit
33
+ body = response.body
34
+ usage = usage_hash_from_body(body)
35
+ status = response.status
36
+ success = status.is_a?(Integer) && (200..299).include?(status)
37
+ gid = ApiCallLogger.generation_id_from(response: response, parsed_body: body)
38
+ cost = usage ? ApiCallLogger.cost_from_usage(usage) : nil
39
+ lm = extract_logical_model_from_request(request_body)
40
+ error_message =
41
+ if success
42
+ nil
43
+ else
44
+ ApiCallLogger.error_message_from_response_body(body)
45
+ end
46
+
47
+ {
48
+ "method" => method,
49
+ "path" => full_url(rel_path, params),
50
+ "status" => status,
51
+ "http_status" => status,
52
+ "duration_ms" => duration_ms.round(3),
53
+ "request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
54
+ "response_body" => ApiCallLogger.format_body_for_log(body, max_bytes: lim),
55
+ "request_json" => request_body,
56
+ "response_json" => body,
57
+ "error_class" => nil,
58
+ "error_message" => error_message,
59
+ "streaming" => false,
60
+ "success" => success,
61
+ "generation_id" => gid,
62
+ "usage" => usage,
63
+ "cost" => cost,
64
+ "logical_model" => lm
65
+ }
66
+ end
67
+
68
+ def usage_hash_from_body(body)
69
+ return unless body.is_a?(Hash)
70
+
71
+ u = body[:usage] || body["usage"]
72
+ u.is_a?(Hash) ? u : nil
73
+ end
74
+
75
+ def extract_logical_model_from_request(request_body)
76
+ case request_body
77
+ when Hash
78
+ m = request_body[:model] || request_body["model"]
79
+ s = m.to_s.strip
80
+ s.empty? ? nil : s
81
+ end
82
+ end
83
+
84
+ def full_url(rel_path, params)
85
+ u = join_uri(rel_path)
86
+ if params && !params.empty?
87
+ flat = params.transform_keys(&:to_s)
88
+ u.query = URI.encode_www_form(flat)
89
+ end
90
+ u.to_s
91
+ end
92
+
93
+ def monotonic_ms
94
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
95
+ end
96
+
97
+ def elapsed_ms(started)
98
+ monotonic_ms - started
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SavvyOpenrouter
4
+ class Connection
5
+ # Raw, streaming, and transport-error rows for {api_call_log}.
6
+ module ApiCallRecordingTransport
7
+ private
8
+
9
+ def record_faraday_raw(method:, rel_path:, params:, request_body:, response:, duration_ms:)
10
+ return if suppress_api_call_log?
11
+ return unless @api_call_logger.enabled?
12
+
13
+ lim = @api_call_logger.max_body_limit
14
+ status = response.status
15
+ success = status.is_a?(Integer) && (200..299).include?(status)
16
+ body_for_resp =
17
+ if response.body.is_a?(String) && response.body.encoding == Encoding::ASCII_8BIT
18
+ "[binary #{response.body.bytesize} bytes]"
19
+ else
20
+ response.body
21
+ end
22
+ error_message = error_message_for_raw_response(success, body_for_resp)
23
+ attrs = merge_call_log_context(
24
+ {
25
+ "method" => method,
26
+ "path" => full_url(rel_path, params),
27
+ "status" => status,
28
+ "http_status" => status,
29
+ "duration_ms" => duration_ms.round(3),
30
+ "request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
31
+ "response_body" => ApiCallLogger.format_body_for_log(body_for_resp, max_bytes: lim),
32
+ "request_json" => request_body,
33
+ "response_json" => (body_for_resp.is_a?(Hash) ? body_for_resp : nil),
34
+ "error_class" => nil,
35
+ "error_message" => error_message,
36
+ "streaming" => false,
37
+ "success" => success
38
+ }
39
+ )
40
+ @api_call_logger.record(attrs)
41
+ end
42
+
43
+ def record_stream(method:, rel_path:, params:, request_body:, status:, response_body:, duration_ms:)
44
+ return if suppress_api_call_log?
45
+ return unless @api_call_logger.enabled?
46
+
47
+ lim = @api_call_logger.max_body_limit
48
+ preview =
49
+ if status == 200
50
+ "[stream completed]"
51
+ else
52
+ response_body
53
+ end
54
+ ok = status.is_a?(Integer) && (200..299).include?(status)
55
+ error_message = error_message_for_raw_response(ok, preview)
56
+ attrs = merge_call_log_context(
57
+ {
58
+ "method" => method,
59
+ "path" => full_url(rel_path, params),
60
+ "status" => status,
61
+ "http_status" => status,
62
+ "duration_ms" => duration_ms.round(3),
63
+ "request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
64
+ "response_body" => ApiCallLogger.format_body_for_log(preview, max_bytes: lim),
65
+ "error_class" => nil,
66
+ "error_message" => error_message,
67
+ "streaming" => true,
68
+ "success" => ok
69
+ }
70
+ )
71
+ @api_call_logger.record(attrs)
72
+ end
73
+
74
+ def record_transport_error(method:, rel_path:, params:, request_body:, duration_ms:, error:)
75
+ return if suppress_api_call_log?
76
+ return unless @api_call_logger.enabled?
77
+
78
+ lim = @api_call_logger.max_body_limit
79
+ msg = ApiCallLogger.format_body_for_log(error.message, max_bytes: lim)
80
+
81
+ attrs = merge_call_log_context(
82
+ {
83
+ "method" => method,
84
+ "path" => full_url(rel_path, params),
85
+ "status" => nil,
86
+ "http_status" => nil,
87
+ "duration_ms" => duration_ms.round(3),
88
+ "request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
89
+ "response_body" => "",
90
+ "error_class" => error.class.name,
91
+ "error_message" => msg,
92
+ "streaming" => false,
93
+ "success" => false
94
+ }
95
+ )
96
+ @api_call_logger.record(attrs)
97
+ end
98
+
99
+ def error_message_for_raw_response(success, payload)
100
+ return if success
101
+
102
+ case payload
103
+ when Hash
104
+ ApiCallLogger.error_message_from_response_body(payload)
105
+ when String
106
+ ApiCallLogger.error_message_from_json_string(payload)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module SavvyOpenrouter
4
4
  class Connection
5
- # Timing + persistence hooks for optional request logging ({api_call_log} config).
5
+ # Timing + orchestration hooks for optional request logging ({api_call_log} config).
6
6
  module Instrumentation
7
7
  private
8
8
 
@@ -19,8 +19,14 @@ module SavvyOpenrouter
19
19
  else raise ArgumentError, "unsupported #{method_sym}"
20
20
  end
21
21
  duration_ms = elapsed_ms(started)
22
- record_faraday_json(method: method_sym.to_s.upcase, rel_path: rel, params: params, request_body: body, response: response,
23
- duration_ms: duration_ms)
22
+ record_faraday_json(
23
+ method: method_sym.to_s.upcase,
24
+ rel_path: rel,
25
+ params: params,
26
+ request_body: body,
27
+ response: response,
28
+ duration_ms: duration_ms
29
+ )
24
30
  parse_json_response(response, success: success)
25
31
  rescue SavvyOpenrouter::ApiError
26
32
  raise
@@ -30,103 +36,32 @@ module SavvyOpenrouter
30
36
  raise
31
37
  end
32
38
 
33
- def record_faraday_json(method:, rel_path:, params:, request_body:, response:, duration_ms:)
34
- return unless @api_call_logger.enabled?
35
-
36
- lim = @api_call_logger.max_body_limit
37
- @api_call_logger.record(
38
- "method" => method,
39
- "path" => full_url(rel_path, params),
40
- "status" => response.status,
41
- "duration_ms" => duration_ms.round(3),
42
- "request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
43
- "response_body" => ApiCallLogger.format_body_for_log(response.body, max_bytes: lim),
44
- "error_class" => nil,
45
- "error_message" => nil,
46
- "streaming" => false
47
- )
48
- end
49
-
50
- def record_faraday_raw(method:, rel_path:, params:, request_body:, response:, duration_ms:)
51
- return unless @api_call_logger.enabled?
52
-
53
- lim = @api_call_logger.max_body_limit
54
- body_for_resp =
55
- if response.body.is_a?(String) && response.body.encoding == Encoding::ASCII_8BIT
56
- "[binary #{response.body.bytesize} bytes]"
57
- else
58
- response.body
59
- end
60
- @api_call_logger.record(
61
- "method" => method,
62
- "path" => full_url(rel_path, params),
63
- "status" => response.status,
64
- "duration_ms" => duration_ms.round(3),
65
- "request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
66
- "response_body" => ApiCallLogger.format_body_for_log(body_for_resp, max_bytes: lim),
67
- "error_class" => nil,
68
- "error_message" => nil,
69
- "streaming" => false
70
- )
71
- end
72
-
73
- def record_stream(method:, rel_path:, params:, request_body:, status:, response_body:, duration_ms:)
74
- return unless @api_call_logger.enabled?
39
+ def defer_chat_completions_log?(method:, rel_path:)
40
+ return false unless @api_call_logger.enabled?
41
+ return false unless @api_call_logger.chat_attempts_final?
42
+ return false unless method.to_s.upcase == "POST"
43
+ return false unless rel_path.to_s.include?("chat/completions")
75
44
 
76
- lim = @api_call_logger.max_body_limit
77
- preview =
78
- if status == 200
79
- "[stream completed]"
80
- else
81
- response_body
82
- end
83
- @api_call_logger.record(
84
- "method" => method,
85
- "path" => full_url(rel_path, params),
86
- "status" => status,
87
- "duration_ms" => duration_ms.round(3),
88
- "request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
89
- "response_body" => ApiCallLogger.format_body_for_log(preview, max_bytes: lim),
90
- "error_class" => nil,
91
- "error_message" => nil,
92
- "streaming" => true
93
- )
45
+ true
94
46
  end
95
47
 
96
- def record_transport_error(method:, rel_path:, params:, request_body:, duration_ms:, error:)
97
- return unless @api_call_logger.enabled?
98
-
99
- lim = @api_call_logger.max_body_limit
100
- msg = ApiCallLogger.format_body_for_log(error.message, max_bytes: lim)
101
-
102
- @api_call_logger.record(
103
- "method" => method,
104
- "path" => full_url(rel_path, params),
105
- "status" => nil,
106
- "duration_ms" => duration_ms.round(3),
107
- "request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
108
- "response_body" => "",
109
- "error_class" => error.class.name,
110
- "error_message" => msg,
111
- "streaming" => false
112
- )
113
- end
48
+ def defer_responses_log?(method:, rel_path:)
49
+ return false unless @api_call_logger.enabled?
50
+ return false unless @api_call_logger.responses_attempts_final?
51
+ return false unless method.to_s.upcase == "POST"
114
52
 
115
- def full_url(rel_path, params)
116
- u = join_uri(rel_path)
117
- if params && !params.empty?
118
- flat = params.transform_keys(&:to_s)
119
- u.query = URI.encode_www_form(flat)
120
- end
121
- u.to_s
53
+ rel = rel_path.to_s
54
+ rel == "responses" || rel.end_with?("/responses")
122
55
  end
123
56
 
124
- def monotonic_ms
125
- Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
57
+ def merge_call_log_context(base)
58
+ ctx = Configuration.stringify_keys_static(@call_context_stack.last || {})
59
+ Configuration.stringify_keys_static(base).merge(ctx)
126
60
  end
127
61
 
128
- def elapsed_ms(started)
129
- monotonic_ms - started
62
+ def suppress_api_call_log?
63
+ ctx = @call_context_stack.last || {}
64
+ ctx["suppress_api_call_log"] == true || ctx[:suppress_api_call_log] == true
130
65
  end
131
66
  end
132
67
  end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../structured_output_error"
5
+
6
+ module SavvyOpenrouter
7
+ # Post-success validators for structured JSON (chat + Responses). Pure Ruby; use via +require "savvy_openrouter/patterns"+.
8
+ # rubocop:disable Metrics/ModuleLength -- single cohesive module for optional require path
9
+ module Patterns
10
+ module_function
11
+
12
+ def validate_after_success!(endpoint:, request:, response:)
13
+ return unless response.is_a?(Hash)
14
+
15
+ json = deep_stringify_keys(response)
16
+
17
+ case endpoint.to_s
18
+ when "chat_completions"
19
+ validate_chat!(request, json)
20
+ when "responses"
21
+ validate_responses!(request, json)
22
+ end
23
+ end
24
+
25
+ def validate_chat!(request_hash, json)
26
+ return unless chat_structured_requested?(request_hash)
27
+
28
+ choices = json["choices"]
29
+ unless choices.is_a?(Array) && !choices.empty?
30
+ raise StructuredOutputError.new("No choices in chat completion", reason: :no_choices, response_body: json)
31
+ end
32
+
33
+ choice = choices.first
34
+ msg = choice["message"]
35
+ unless msg.is_a?(Hash)
36
+ raise StructuredOutputError.new("Missing assistant message", reason: :no_choices, response_body: json)
37
+ end
38
+
39
+ return if tool_calls_present?(msg)
40
+
41
+ text = extract_chat_assistant_text(msg)
42
+ if text.strip.empty?
43
+ raise StructuredOutputError.new(
44
+ "Structured output was requested but assistant message content is empty",
45
+ reason: :empty_content,
46
+ response_body: json
47
+ )
48
+ end
49
+
50
+ assert_parseable_json!(text, json)
51
+ end
52
+
53
+ def validate_responses!(request_hash, json)
54
+ return unless responses_structured_requested?(request_hash)
55
+
56
+ text = extract_responses_output_text(json)
57
+ if text.strip.empty?
58
+ raise StructuredOutputError.new(
59
+ "Structured output was requested but Responses API returned no parseable text output",
60
+ reason: :empty_content,
61
+ response_body: json
62
+ )
63
+ end
64
+
65
+ assert_parseable_json!(text, json)
66
+ end
67
+
68
+ def chat_structured_requested?(body)
69
+ return false unless body.is_a?(Hash)
70
+
71
+ rf = body[:response_format] || body["response_format"]
72
+ return false unless rf.is_a?(Hash)
73
+
74
+ t = (rf[:type] || rf["type"]).to_s
75
+ %w[json_schema json_object].include?(t)
76
+ end
77
+
78
+ def responses_structured_requested?(body)
79
+ return false unless body.is_a?(Hash)
80
+
81
+ text = body[:text] || body["text"]
82
+ return false unless text.is_a?(Hash)
83
+
84
+ fmt = text[:format] || text["format"]
85
+ return false unless fmt.is_a?(Hash)
86
+
87
+ t = (fmt[:type] || fmt["type"]).to_s
88
+ %w[json_schema json_object].include?(t)
89
+ end
90
+
91
+ def extract_chat_assistant_text(msg)
92
+ msg = deep_stringify_keys(msg) if msg.is_a?(Hash)
93
+ c = msg["content"]
94
+ case c
95
+ when String
96
+ c
97
+ when Array
98
+ c.filter_map do |p|
99
+ next unless p.is_a?(Hash)
100
+
101
+ txt = p["text"]
102
+ p["type"].to_s == "text" && txt.is_a?(String) && !txt.strip.empty? ? txt : nil
103
+ end.join
104
+ else
105
+ ""
106
+ end
107
+ end
108
+
109
+ def extract_responses_output_text(payload)
110
+ payload = deep_stringify_keys(payload) if payload.is_a?(Hash)
111
+ return "" unless payload.is_a?(Hash)
112
+
113
+ ot = payload["output_text"]
114
+ return ot.to_s if ot.is_a?(String) && !ot.strip.empty?
115
+
116
+ outputs = payload["output"]
117
+ return "" unless outputs.is_a?(Array)
118
+
119
+ texts = []
120
+ outputs.each do |item|
121
+ next unless item.is_a?(Hash)
122
+
123
+ content = item["content"]
124
+ next unless content.is_a?(Array)
125
+
126
+ content.each do |part|
127
+ next unless part.is_a?(Hash)
128
+ next unless part["type"].to_s == "output_text"
129
+
130
+ texts << part["text"].to_s
131
+ end
132
+ end
133
+ texts.join
134
+ end
135
+
136
+ def assert_parseable_json!(text, response_json)
137
+ stripped = text.strip
138
+ begin
139
+ JSON.parse(stripped)
140
+ rescue JSON::ParserError => e
141
+ fenced = stripped[/```(?:json)?\s*([\s\S]*?)```/mi, 1]
142
+ if fenced && !fenced.strip.empty?
143
+ begin
144
+ return JSON.parse(fenced.strip)
145
+ rescue JSON::ParserError => e2
146
+ err = StructuredOutputError.new(
147
+ "Structured output was requested but message content is not valid JSON (#{e2.message})",
148
+ reason: :invalid_json,
149
+ response_body: response_json
150
+ )
151
+ raise err, cause: e2
152
+ end
153
+ end
154
+
155
+ err = StructuredOutputError.new(
156
+ "Structured output was requested but message content is not valid JSON (#{e.message})",
157
+ reason: :invalid_json,
158
+ response_body: response_json
159
+ )
160
+ raise err, cause: e
161
+ end
162
+ end
163
+
164
+ def deep_stringify_keys(obj)
165
+ case obj
166
+ when Hash
167
+ obj.each_with_object({}) do |(k, v), h|
168
+ h[k.to_s] = deep_stringify_keys(v)
169
+ end
170
+ when Array
171
+ obj.map { |v| deep_stringify_keys(v) }
172
+ else
173
+ obj
174
+ end
175
+ end
176
+
177
+ def tool_calls_present?(msg)
178
+ tc = msg["tool_calls"]
179
+ tc.is_a?(Array) && !tc.empty?
180
+ end
181
+ end
182
+ # rubocop:enable Metrics/ModuleLength
183
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Opt-in helpers for structured JSON validation after successful HTTP responses.
4
+ #
5
+ # require "savvy_openrouter/patterns"
6
+ # SavvyOpenrouter::Patterns.validate_after_success!(endpoint: "chat_completions", request: body, response: json)
7
+ #
8
+ # Raises {SavvyOpenrouter::StructuredOutputError} (+reason+, +response_body+).
9
+ require_relative "patterns/structured_output"