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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenAICompatibleErrors
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "openai_compatible_errors"
5
+
6
+ class Minitest::Test
7
+ def assert_includes_no_secret(value, secret)
8
+ refute_includes(value.to_s, secret)
9
+ end
10
+ end
@@ -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
@@ -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