openai-compatible-errors 0.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 +7 -0
- data/CHANGELOG.md +21 -0
- data/CONTRIBUTING.md +26 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +213 -0
- data/RELEASING.md +34 -0
- data/Rakefile +13 -0
- data/SECURITY.md +18 -0
- data/examples/net_http.rb +32 -0
- data/lib/openai_compatible_errors/error.rb +159 -0
- data/lib/openai_compatible_errors/headers.rb +111 -0
- data/lib/openai_compatible_errors/normalize.rb +254 -0
- data/lib/openai_compatible_errors/redaction.rb +115 -0
- data/lib/openai_compatible_errors/retry.rb +182 -0
- data/lib/openai_compatible_errors/sse.rb +284 -0
- data/lib/openai_compatible_errors/version.rb +5 -0
- data/lib/openai_compatible_errors.rb +21 -0
- data/test/test_helper.rb +10 -0
- data/test/test_normalize.rb +120 -0
- data/test/test_redaction.rb +46 -0
- data/test/test_retry.rb +92 -0
- data/test/test_sse.rb +80 -0
- metadata +107 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module OpenAICompatibleErrors
|
|
6
|
+
StreamState = Struct.new(:events_seen, :malformed_events, :has_output,
|
|
7
|
+
:protocol, :termination, :error, keyword_init: true) do
|
|
8
|
+
def initialize(events_seen: 0, malformed_events: 0, has_output: false,
|
|
9
|
+
protocol: :unknown, termination: :open, error: nil)
|
|
10
|
+
super
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def done?
|
|
15
|
+
termination == :done
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def incomplete?
|
|
19
|
+
termination == :incomplete
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unexpected_eof?
|
|
23
|
+
termination == :unexpected_eof
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class SSEInspector
|
|
28
|
+
MAX_EVENT_BYTES = 65_536
|
|
29
|
+
MAX_BUFFER_BYTES = 131_072
|
|
30
|
+
RESPONSE_TERMINALS = %w[response.completed response.incomplete response.failed].freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :state
|
|
33
|
+
|
|
34
|
+
def initialize(max_event_bytes: MAX_EVENT_BYTES, max_buffer_bytes: MAX_BUFFER_BYTES,
|
|
35
|
+
include_provider_message: false)
|
|
36
|
+
@max_event_bytes = bounded_limit(max_event_bytes, MAX_EVENT_BYTES, 1, 1_048_576)
|
|
37
|
+
@max_buffer_bytes = bounded_limit(max_buffer_bytes, MAX_BUFFER_BYTES, 1, 2_097_152)
|
|
38
|
+
@include_provider_message = (include_provider_message == true)
|
|
39
|
+
@buffer = +"".b
|
|
40
|
+
@at_start = true
|
|
41
|
+
@state = StreamState.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def feed(chunk)
|
|
45
|
+
return @state unless open?
|
|
46
|
+
raise TypeError, "SSE chunks must be String instances" unless chunk.is_a?(String)
|
|
47
|
+
|
|
48
|
+
@buffer << chunk.b
|
|
49
|
+
return protocol_error! if @buffer.bytesize > @max_buffer_bytes
|
|
50
|
+
|
|
51
|
+
loop do
|
|
52
|
+
boundary = event_boundary(@buffer)
|
|
53
|
+
break unless boundary
|
|
54
|
+
|
|
55
|
+
raw, offset = boundary
|
|
56
|
+
@buffer = @buffer.byteslice(offset, @buffer.bytesize - offset) || +"".b
|
|
57
|
+
consume_event(raw)
|
|
58
|
+
break unless open?
|
|
59
|
+
end
|
|
60
|
+
@state
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def close
|
|
64
|
+
return @state unless open?
|
|
65
|
+
|
|
66
|
+
consume_event(@buffer) unless @buffer.empty?
|
|
67
|
+
@buffer = +"".b
|
|
68
|
+
finish(:unexpected_eof, stream_error) if open?
|
|
69
|
+
@state
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fail
|
|
73
|
+
finish(:error, stream_error) if open?
|
|
74
|
+
@state
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.inspect_each(enum, inspector: new)
|
|
78
|
+
return enum_for(__method__, enum, inspector: inspector) unless block_given?
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
enum.each do |chunk|
|
|
82
|
+
inspector.feed(chunk)
|
|
83
|
+
yield chunk
|
|
84
|
+
end
|
|
85
|
+
inspector.close
|
|
86
|
+
rescue StandardError
|
|
87
|
+
inspector.fail
|
|
88
|
+
raise
|
|
89
|
+
end
|
|
90
|
+
inspector.state
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def open?
|
|
96
|
+
@state.termination == :open
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def bounded_limit(value, fallback, minimum, maximum)
|
|
100
|
+
return fallback unless value.is_a?(Integer)
|
|
101
|
+
|
|
102
|
+
[[value, minimum].max, maximum].min
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def event_boundary(bytes)
|
|
106
|
+
indexes = []
|
|
107
|
+
index = bytes.index("\n\n".b)
|
|
108
|
+
indexes << [index, 2] if index
|
|
109
|
+
index = bytes.index("\r\r".b)
|
|
110
|
+
indexes << [index, 2] if index
|
|
111
|
+
index = bytes.index("\r\n\r\n".b)
|
|
112
|
+
indexes << [index, 4] if index
|
|
113
|
+
index = bytes.index("\n\r\n".b)
|
|
114
|
+
indexes << [index, 3] if index
|
|
115
|
+
return nil if indexes.empty?
|
|
116
|
+
|
|
117
|
+
start, length = indexes.min_by(&:first)
|
|
118
|
+
[bytes.byteslice(0, start), start + length]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def consume_event(raw)
|
|
122
|
+
return if raw.empty?
|
|
123
|
+
return protocol_error! if raw.bytesize > @max_event_bytes
|
|
124
|
+
|
|
125
|
+
text =
|
|
126
|
+
begin
|
|
127
|
+
candidate = raw.dup.force_encoding(Encoding::UTF_8)
|
|
128
|
+
raise EncodingError, "invalid UTF-8" unless candidate.valid_encoding?
|
|
129
|
+
|
|
130
|
+
candidate
|
|
131
|
+
rescue EncodingError, ArgumentError
|
|
132
|
+
return protocol_error!
|
|
133
|
+
end
|
|
134
|
+
if @at_start && text.start_with?("\uFEFF")
|
|
135
|
+
text = text.byteslice(3..)
|
|
136
|
+
end
|
|
137
|
+
@at_start = false
|
|
138
|
+
|
|
139
|
+
event_name = nil
|
|
140
|
+
data_lines = []
|
|
141
|
+
text.split(/\r\n|\n|\r/, -1).each do |line|
|
|
142
|
+
next if line.empty? || line.start_with?(":")
|
|
143
|
+
|
|
144
|
+
field, value = line.split(":", 2)
|
|
145
|
+
value = value.to_s.sub(/\A /, "")
|
|
146
|
+
case field
|
|
147
|
+
when "event"
|
|
148
|
+
event_name = value.byteslice(0, 128)
|
|
149
|
+
when "data"
|
|
150
|
+
data_lines << value
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
return if data_lines.empty?
|
|
154
|
+
|
|
155
|
+
data = data_lines.join("\n")
|
|
156
|
+
@state = with_state(events_seen: @state.events_seen + 1)
|
|
157
|
+
return finish(:done, nil) if data == "[DONE]"
|
|
158
|
+
|
|
159
|
+
payload =
|
|
160
|
+
begin
|
|
161
|
+
JSON.parse(data, allow_nan: false, max_nesting: 100)
|
|
162
|
+
rescue JSON::ParserError, ArgumentError, SystemStackError
|
|
163
|
+
return protocol_error!
|
|
164
|
+
end
|
|
165
|
+
update_protocol(payload, event_name)
|
|
166
|
+
|
|
167
|
+
if error_event?(payload, event_name)
|
|
168
|
+
return finish(:error, Normalizer.normalize(payload,
|
|
169
|
+
source: :sse, include_provider_message: @include_provider_message))
|
|
170
|
+
end
|
|
171
|
+
return finish(:done, nil) if terminal_completed?(payload, event_name)
|
|
172
|
+
return finish(:incomplete, nil) if terminal_incomplete?(payload, event_name)
|
|
173
|
+
|
|
174
|
+
@state = with_state(has_output: true) if output_event?(payload, event_name)
|
|
175
|
+
@state
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def update_protocol(payload, event_name)
|
|
179
|
+
type = hash_value(payload, "type")
|
|
180
|
+
object = hash_value(payload, "object")
|
|
181
|
+
protocol =
|
|
182
|
+
if (type.is_a?(String) && type.start_with?("response.")) ||
|
|
183
|
+
(event_name.is_a?(String) && event_name.start_with?("response."))
|
|
184
|
+
:responses
|
|
185
|
+
elsif hash_value(payload, "choices").is_a?(Array) ||
|
|
186
|
+
(object.is_a?(String) && object.start_with?("chat.completion"))
|
|
187
|
+
:chat_completions
|
|
188
|
+
else
|
|
189
|
+
:unknown
|
|
190
|
+
end
|
|
191
|
+
@state = with_state(protocol: protocol) unless protocol == :unknown
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def error_event?(payload, event_name)
|
|
195
|
+
return true if %w[error response.failed].include?(event_name.to_s.downcase)
|
|
196
|
+
return true if hash_value(payload, "error")
|
|
197
|
+
return true if hash_value(payload, "type").to_s == "error"
|
|
198
|
+
|
|
199
|
+
response = hash_value(payload, "response")
|
|
200
|
+
response.is_a?(Hash) &&
|
|
201
|
+
(response["status"] == "failed" || !response["error"].nil?)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def terminal_completed?(payload, event_name)
|
|
205
|
+
event_name == "response.completed" ||
|
|
206
|
+
hash_value(payload, "type") == "response.completed"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def terminal_incomplete?(payload, event_name)
|
|
210
|
+
event_name == "response.incomplete" ||
|
|
211
|
+
hash_value(payload, "type") == "response.incomplete"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def output_event?(payload, event_name)
|
|
215
|
+
signal = hash_value(payload, "type")
|
|
216
|
+
signal = event_name unless signal.is_a?(String)
|
|
217
|
+
if signal.is_a?(String) &&
|
|
218
|
+
(signal.end_with?(".delta") || signal.end_with?("-delta") ||
|
|
219
|
+
signal.start_with?("response.output_") ||
|
|
220
|
+
signal.start_with?("response.content_part.") ||
|
|
221
|
+
signal.start_with?("response.refusal.") ||
|
|
222
|
+
signal.start_with?("response.reasoning_") ||
|
|
223
|
+
signal == "response.image_generation_call.partial_image")
|
|
224
|
+
return true
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
choices = hash_value(payload, "choices")
|
|
228
|
+
if choices.is_a?(Array)
|
|
229
|
+
choices.first(128).each do |choice|
|
|
230
|
+
next unless choice.is_a?(Hash)
|
|
231
|
+
|
|
232
|
+
delta = choice["delta"]
|
|
233
|
+
if delta.is_a?(Hash) && %w[content refusal reasoning reasoning_content
|
|
234
|
+
audio tool_calls function_call].any? do |key|
|
|
235
|
+
value = delta[key]
|
|
236
|
+
!value.nil? && value != "" && value != [] && value != {}
|
|
237
|
+
end
|
|
238
|
+
return true
|
|
239
|
+
end
|
|
240
|
+
return true if choice["message"] || choice["text"]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
delta = hash_value(payload, "delta")
|
|
244
|
+
return true if delta.is_a?(String) && !delta.empty?
|
|
245
|
+
|
|
246
|
+
response = hash_value(payload, "response")
|
|
247
|
+
return true if response.is_a?(Hash) && response["output"] && response["output"] != []
|
|
248
|
+
|
|
249
|
+
# Unknown data events are treated as potentially visible output. A false
|
|
250
|
+
# positive blocks an unsafe replay; a false negative could duplicate it.
|
|
251
|
+
!%w[response.created response.in_progress response.queued response.completed
|
|
252
|
+
response.incomplete response.failed error].include?(signal.to_s)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def hash_value(value, key)
|
|
256
|
+
value.is_a?(Hash) ? (value[key] || value[key.to_sym]) : nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def with_state(**changes)
|
|
260
|
+
values = {
|
|
261
|
+
events_seen: @state.events_seen,
|
|
262
|
+
malformed_events: @state.malformed_events,
|
|
263
|
+
has_output: @state.has_output,
|
|
264
|
+
protocol: @state.protocol,
|
|
265
|
+
termination: @state.termination,
|
|
266
|
+
error: @state.error
|
|
267
|
+
}.merge(changes)
|
|
268
|
+
StreamState.new(**values)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def stream_error
|
|
272
|
+
ApiError.new(category: :stream, source: :sse)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def protocol_error!
|
|
276
|
+
@state = with_state(malformed_events: @state.malformed_events + 1)
|
|
277
|
+
finish(:error, stream_error)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def finish(termination, error)
|
|
281
|
+
@state = with_state(termination: termination, error: error)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai_compatible_errors/version"
|
|
4
|
+
require_relative "openai_compatible_errors/error"
|
|
5
|
+
require_relative "openai_compatible_errors/headers"
|
|
6
|
+
require_relative "openai_compatible_errors/redaction"
|
|
7
|
+
require_relative "openai_compatible_errors/normalize"
|
|
8
|
+
require_relative "openai_compatible_errors/retry"
|
|
9
|
+
require_relative "openai_compatible_errors/sse"
|
|
10
|
+
|
|
11
|
+
module OpenAICompatibleErrors
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def redact_sensitive_text(value, **options)
|
|
15
|
+
Redaction.redact_sensitive_text(value, **options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def sanitize_for_log(value, **options)
|
|
19
|
+
Redaction.sanitize_for_log(value, **options)
|
|
20
|
+
end
|
|
21
|
+
end
|
data/test/test_helper.rb
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
class NormalizeTest < Minitest::Test
|
|
7
|
+
FakeResponse = Struct.new(:status, :headers, :body, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def test_normalizes_http_rate_limit_without_retaining_provider_message
|
|
10
|
+
error = OpenAICompatibleErrors.normalize_error(
|
|
11
|
+
status: 429,
|
|
12
|
+
headers: { "Retry-After" => "2", "X-Request-Id" => "req_01" },
|
|
13
|
+
body: {
|
|
14
|
+
error: {
|
|
15
|
+
code: "rate_limit_exceeded",
|
|
16
|
+
type: "requests",
|
|
17
|
+
message: "Bearer very-secret-token"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
assert_equal :rate_limit, error.category
|
|
23
|
+
assert_equal :http, error.source
|
|
24
|
+
assert_equal 429, error.status
|
|
25
|
+
assert_equal "rate_limit_exceeded", error.code
|
|
26
|
+
assert_equal "requests", error.type
|
|
27
|
+
assert_equal "req_01", error.request_id
|
|
28
|
+
assert_equal 2_000, error.retry_after_ms
|
|
29
|
+
assert_nil error.provider_message
|
|
30
|
+
assert_equal "The API rate limit was reached.", error.message
|
|
31
|
+
assert_includes_no_secret(error.to_h, "very-secret-token")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_provider_message_requires_explicit_opt_in_and_is_redacted
|
|
35
|
+
error = OpenAICompatibleErrors.normalize_error(
|
|
36
|
+
{
|
|
37
|
+
status: 401,
|
|
38
|
+
body: {
|
|
39
|
+
error: { message: "authorization=never-log-this Bearer also-never-log" }
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
include_provider_message: true
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
assert_equal :authentication, error.category
|
|
46
|
+
assert_includes error.provider_message, "[REDACTED]"
|
|
47
|
+
assert_includes_no_secret(error.provider_message, "never-log-this")
|
|
48
|
+
assert_includes_no_secret(error.provider_message, "also-never-log")
|
|
49
|
+
assert_nil error.to_h[:provider_message]
|
|
50
|
+
assert_includes error.to_h(include_provider_message: true), :provider_message
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_response_shape_and_http_date_retry_after_are_supported
|
|
54
|
+
now = Time.utc(2026, 8, 9, 0, 0, 0)
|
|
55
|
+
response = FakeResponse.new(
|
|
56
|
+
status: "503",
|
|
57
|
+
headers: { "retry-after" => (now + 5).httpdate, "x-request-id" => "req_503" },
|
|
58
|
+
body: '{"error":{"code":"server_error","type":"internal"}}'
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
error = OpenAICompatibleErrors.normalize_error(response, now: now)
|
|
62
|
+
|
|
63
|
+
assert_equal :upstream, error.category
|
|
64
|
+
assert_equal :http, error.source
|
|
65
|
+
assert_equal 5_000, error.retry_after_ms
|
|
66
|
+
assert_equal "req_503", error.request_id
|
|
67
|
+
assert_equal "server_error", error.code
|
|
68
|
+
assert_equal "internal", error.type
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_status_and_structured_code_precede_exception_text
|
|
72
|
+
error = OpenAICompatibleErrors.normalize_error(
|
|
73
|
+
Timeout::Error.new("connection reset"),
|
|
74
|
+
status: 400,
|
|
75
|
+
body: { error: { code: "invalid_request_error" } }
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert_equal :validation, error.category
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_timeout_exception_is_classified_without_logging_message
|
|
82
|
+
error = OpenAICompatibleErrors.normalize_error(Timeout::Error.new("secret prompt"))
|
|
83
|
+
|
|
84
|
+
assert_equal :timeout, error.category
|
|
85
|
+
assert_nil error.provider_message
|
|
86
|
+
assert_includes_no_secret(error.to_h, "secret prompt")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def test_malformed_retry_hint_saturates_to_conservative_sentinel
|
|
90
|
+
error = OpenAICompatibleErrors.normalize_error(
|
|
91
|
+
status: 429,
|
|
92
|
+
headers: { "retry-after" => "soon" }
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
assert_equal OpenAICompatibleErrors::Headers::MAX_RETRY_AFTER_MS, error.retry_after_ms
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_invalid_identifiers_and_unbounded_body_are_dropped
|
|
99
|
+
error = OpenAICompatibleErrors.normalize_error(
|
|
100
|
+
status: 500,
|
|
101
|
+
headers: { "x-request-id" => "Bearer should-not-pass" },
|
|
102
|
+
body: "{\"error\":{\"code\":\"x\"}}" + (" " * 70_000)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
assert_equal :server, error.category
|
|
106
|
+
assert_nil error.request_id
|
|
107
|
+
assert_nil error.code
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_api_error_is_immutable_and_hides_provider_message_from_inspect
|
|
111
|
+
error = OpenAICompatibleErrors::ApiError.new(
|
|
112
|
+
category: :network,
|
|
113
|
+
source: :unknown,
|
|
114
|
+
provider_message: "do-not-print"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert_predicate error, :frozen?
|
|
118
|
+
refute_includes error.inspect, "do-not-print"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class RedactionTest < Minitest::Test
|
|
6
|
+
def test_redacts_common_credentials
|
|
7
|
+
result = OpenAICompatibleErrors.redact_sensitive_text(
|
|
8
|
+
"Bearer abcdefghijklmnop sk-secret-token-123 api_key=also-secret"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
assert_includes result, "[REDACTED]"
|
|
12
|
+
assert_includes_no_secret(result, "abcdefghijklmnop")
|
|
13
|
+
assert_includes_no_secret(result, "secret-token-123")
|
|
14
|
+
assert_includes_no_secret(result, "also-secret")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_sanitizer_redacts_sensitive_keys_without_reading_exception_message
|
|
18
|
+
error = RuntimeError.new("customer prompt should never become diagnostic context")
|
|
19
|
+
value = {
|
|
20
|
+
"api_key" => "key-to-hide",
|
|
21
|
+
"nested" => { "authorization" => "Bearer key-to-hide", "ok" => "fine" },
|
|
22
|
+
"exception" => error
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
sanitized = OpenAICompatibleErrors.sanitize_for_log(value)
|
|
26
|
+
|
|
27
|
+
assert_equal "[REDACTED]", sanitized["api_key"]
|
|
28
|
+
assert_equal "[REDACTED]", sanitized["nested"]["authorization"]
|
|
29
|
+
assert_equal "fine", sanitized["nested"]["ok"]
|
|
30
|
+
assert_equal "[RuntimeError]", sanitized["exception"]
|
|
31
|
+
assert_includes_no_secret(sanitized, "customer prompt")
|
|
32
|
+
assert_includes_no_secret(sanitized, "key-to-hide")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_sanitizer_breaks_cycles_and_bounds_collections
|
|
36
|
+
looped = []
|
|
37
|
+
looped << looped
|
|
38
|
+
sanitized = OpenAICompatibleErrors.sanitize_for_log(
|
|
39
|
+
{ "items" => (1..40).to_a, "loop" => looped },
|
|
40
|
+
limits: OpenAICompatibleErrors::Redaction::Limits.new(max_items: 3, max_keys: 3)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert_equal [1, 2, 3, "[TRUNCATED]"], sanitized["items"]
|
|
44
|
+
assert_equal ["[REDACTED]"], sanitized["loop"]
|
|
45
|
+
end
|
|
46
|
+
end
|
data/test/test_retry.rb
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class RetryTest < Minitest::Test
|
|
6
|
+
def error(category: :rate_limit, retry_after_ms: nil)
|
|
7
|
+
OpenAICompatibleErrors::ApiError.new(
|
|
8
|
+
category: category,
|
|
9
|
+
source: :http,
|
|
10
|
+
retry_after_ms: retry_after_ms
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def context(**overrides)
|
|
15
|
+
OpenAICompatibleErrors::RetryContext.new(
|
|
16
|
+
method: "POST",
|
|
17
|
+
phase: :http_error,
|
|
18
|
+
replay_safety: :safe,
|
|
19
|
+
attempt: 1,
|
|
20
|
+
elapsed_ms: 100,
|
|
21
|
+
**overrides
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_uses_server_retry_after_when_evidence_is_safe
|
|
26
|
+
plan = OpenAICompatibleErrors.decide_retry(error(retry_after_ms: 2_000), context)
|
|
27
|
+
|
|
28
|
+
assert_equal :retry, plan.action
|
|
29
|
+
assert_equal :transient_and_replay_safe, plan.reason
|
|
30
|
+
assert_equal 2_000, plan.delay_ms
|
|
31
|
+
assert_equal :server, plan.delay_source
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_never_retries_after_partial_stream_output
|
|
35
|
+
plan = OpenAICompatibleErrors.decide_retry(error, context(has_stream_output: true))
|
|
36
|
+
|
|
37
|
+
assert_equal :do_not_retry, plan.action
|
|
38
|
+
assert_equal :partial_stream_output, plan.reason
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_treats_quota_as_permanent
|
|
42
|
+
plan = OpenAICompatibleErrors.decide_retry(error(category: :quota), context)
|
|
43
|
+
|
|
44
|
+
assert_equal :do_not_retry, plan.action
|
|
45
|
+
assert_equal :permanent_error, plan.reason
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_requires_known_phase_and_replay_safety
|
|
49
|
+
phase_plan = OpenAICompatibleErrors.decide_retry(error, context(phase: :unknown))
|
|
50
|
+
safety_plan = OpenAICompatibleErrors.decide_retry(
|
|
51
|
+
error,
|
|
52
|
+
context(replay_safety: :unknown)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
assert_equal :manual_decision, phase_plan.action
|
|
56
|
+
assert_equal :unknown_phase, phase_plan.reason
|
|
57
|
+
assert_equal :manual_decision, safety_plan.action
|
|
58
|
+
assert_equal :unknown_replay_safety, safety_plan.reason
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_honors_attempt_and_time_budgets
|
|
62
|
+
attempt_plan = OpenAICompatibleErrors.decide_retry(
|
|
63
|
+
error,
|
|
64
|
+
context(attempt: 3),
|
|
65
|
+
policy: OpenAICompatibleErrors::RetryPolicy.new(max_attempts: 3)
|
|
66
|
+
)
|
|
67
|
+
time_plan = OpenAICompatibleErrors.decide_retry(
|
|
68
|
+
error,
|
|
69
|
+
context(elapsed_ms: 30_000)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
assert_equal :attempt_budget_exhausted, attempt_plan.reason
|
|
73
|
+
assert_equal :time_budget_exhausted, time_plan.reason
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def test_invalid_random_source_fails_closed
|
|
77
|
+
plan = OpenAICompatibleErrors.decide_retry(error, context, random: -> { 2 })
|
|
78
|
+
|
|
79
|
+
assert_equal :manual_decision, plan.action
|
|
80
|
+
assert_equal :invalid_context, plan.reason
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_malformed_header_sentinel_does_not_fit_default_delay_budget
|
|
84
|
+
plan = OpenAICompatibleErrors.decide_retry(
|
|
85
|
+
error(retry_after_ms: OpenAICompatibleErrors::Headers::MAX_RETRY_AFTER_MS),
|
|
86
|
+
context
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
assert_equal :do_not_retry, plan.action
|
|
90
|
+
assert_equal :retry_after_exceeds_budget, plan.reason
|
|
91
|
+
end
|
|
92
|
+
end
|
data/test/test_sse.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class SSETest < Minitest::Test
|
|
6
|
+
def test_inspects_chat_chunks_across_byte_boundaries
|
|
7
|
+
inspector = OpenAICompatibleErrors::SSEInspector.new
|
|
8
|
+
event = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n"
|
|
9
|
+
event.bytes.each_slice(3) { |bytes| inspector.feed(bytes.pack("C*")) }
|
|
10
|
+
inspector.feed("data: [DONE]\n\n")
|
|
11
|
+
|
|
12
|
+
assert_equal :chat_completions, inspector.state.protocol
|
|
13
|
+
assert inspector.state.has_output
|
|
14
|
+
assert inspector.state.done?
|
|
15
|
+
assert_equal 2, inspector.state.events_seen
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_responses_metadata_does_not_claim_output_but_delta_does
|
|
19
|
+
inspector = OpenAICompatibleErrors::SSEInspector.new
|
|
20
|
+
inspector.feed("event: response.created\ndata: {\"type\":\"response.created\"}\n\n")
|
|
21
|
+
refute inspector.state.has_output
|
|
22
|
+
|
|
23
|
+
inspector.feed(
|
|
24
|
+
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n"
|
|
25
|
+
)
|
|
26
|
+
inspector.feed("event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n")
|
|
27
|
+
|
|
28
|
+
assert_equal :responses, inspector.state.protocol
|
|
29
|
+
assert inspector.state.has_output
|
|
30
|
+
assert inspector.state.done?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_error_event_normalizes_without_provider_message_by_default
|
|
34
|
+
inspector = OpenAICompatibleErrors::SSEInspector.new
|
|
35
|
+
inspector.feed(
|
|
36
|
+
"event: error\ndata: {\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"Bearer never-log\"}}\n\n"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
assert_equal :error, inspector.state.termination
|
|
40
|
+
assert_equal :sse, inspector.state.error.source
|
|
41
|
+
assert_equal :rate_limit, inspector.state.error.category
|
|
42
|
+
assert_nil inspector.state.error.provider_message
|
|
43
|
+
assert_includes_no_secret(inspector.state.error.to_h, "never-log")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_malformed_utf8_and_oversized_events_become_stream_errors
|
|
47
|
+
malformed = OpenAICompatibleErrors::SSEInspector.new
|
|
48
|
+
malformed.feed("data: ".b + [0xFF].pack("C") + "\n\n".b)
|
|
49
|
+
assert_equal :error, malformed.state.termination
|
|
50
|
+
assert_equal 1, malformed.state.malformed_events
|
|
51
|
+
|
|
52
|
+
oversized = OpenAICompatibleErrors::SSEInspector.new(max_event_bytes: 10)
|
|
53
|
+
oversized.feed("data: {\"x\":\"too long\"}\n\n")
|
|
54
|
+
assert_equal :error, oversized.state.termination
|
|
55
|
+
assert_equal 1, oversized.state.malformed_events
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_close_records_unexpected_eof_after_output
|
|
59
|
+
inspector = OpenAICompatibleErrors::SSEInspector.new
|
|
60
|
+
inspector.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n")
|
|
61
|
+
inspector.close
|
|
62
|
+
|
|
63
|
+
assert inspector.state.has_output
|
|
64
|
+
assert inspector.state.unexpected_eof?
|
|
65
|
+
assert_equal :stream, inspector.state.error.category
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_adapter_preserves_chunks_and_marks_source_failure
|
|
69
|
+
inspector = OpenAICompatibleErrors::SSEInspector.new
|
|
70
|
+
chunks = ["data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n", "data: [DONE]\n\n"]
|
|
71
|
+
received = []
|
|
72
|
+
|
|
73
|
+
state = OpenAICompatibleErrors::SSEInspector.inspect_each(chunks, inspector: inspector) do |chunk|
|
|
74
|
+
received << chunk
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
assert_equal chunks, received
|
|
78
|
+
assert state.done?
|
|
79
|
+
end
|
|
80
|
+
end
|