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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +78 -15
- data/lib/generators/savvy_openrouter/install/templates/savvy_openrouter.yml +31 -0
- data/lib/savvy_openrouter/api_call_logger.rb +119 -6
- data/lib/savvy_openrouter/client.rb +10 -0
- data/lib/savvy_openrouter/configuration.rb +35 -1
- data/lib/savvy_openrouter/connection.rb +40 -1
- data/lib/savvy_openrouter/connection_api_call_recording.rb +102 -0
- data/lib/savvy_openrouter/connection_api_call_recording_transport.rb +111 -0
- data/lib/savvy_openrouter/connection_instrumentation.rb +27 -92
- data/lib/savvy_openrouter/patterns/structured_output.rb +183 -0
- data/lib/savvy_openrouter/patterns.rb +9 -0
- data/lib/savvy_openrouter/request_plugins.rb +164 -0
- data/lib/savvy_openrouter/resources/base.rb +8 -0
- data/lib/savvy_openrouter/resources/chat.rb +27 -17
- data/lib/savvy_openrouter/resources/embeddings.rb +7 -2
- data/lib/savvy_openrouter/resources/generations.rb +6 -2
- data/lib/savvy_openrouter/resources/models.rb +18 -8
- data/lib/savvy_openrouter/resources/responses.rb +27 -1
- data/lib/savvy_openrouter/responses_retry_policy.rb +112 -0
- data/lib/savvy_openrouter/structured_output_error.rb +17 -0
- data/lib/savvy_openrouter/version.rb +1 -1
- data/lib/savvy_openrouter.rb +1 -0
- data/sig/savvy_openrouter.rbs +27 -0
- metadata +14 -8
- data/savvy_openrouter-0.1.0.gem +0 -0
|
@@ -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 +
|
|
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(
|
|
23
|
-
|
|
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
|
|
34
|
-
return unless @api_call_logger.enabled?
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
return unless @api_call_logger.enabled?
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
125
|
-
|
|
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
|
|
129
|
-
|
|
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"
|