simple_inference 0.1.0 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 324b5bd8986fee7b2130229b1bb7ad277b1c476c71ed07c5a350845f91ae23a5
4
- data.tar.gz: b746716fe0e9364bf1085acc4ac2a09dfe6ca5703831562869a96fdf43ea29a2
3
+ metadata.gz: c062a86e32995fae41fc56269622856569f8bf3e77a1a25be45697ca008332e8
4
+ data.tar.gz: 1988b5edc14d9c3b83963cb34a9aa75a9bea5595511fb00b8668187826aa1d74
5
5
  SHA512:
6
- metadata.gz: 25baf84b1d22f57ed1ccb4b0413acf79cad258efee5e2a31b33d6a0247a87b175492cf293a67c6c7c2503c7d3c28e32283e21dba1aeb1bcbbe621203975e7972
7
- data.tar.gz: ba78fc2c974118d4046119cc378202556e3d50611514df0cb4a9affa41412c5bb1a77aeccaef34071de15792a63b17bb5f484cafec8f506f7372660ba09fc5fa
6
+ metadata.gz: 0fb7c8c961caba0f69765d06d51f528decfbc5ec8c4cd42cbdf3a32ef109b4ff43ea155e75ec60712638f01d43589ff933a14baba80d08ed807f0d701a818f26
7
+ data.tar.gz: cedbee5acc803c8aa8e915aad28857a7d369bba100aa00c73f9843b53f0c41d90ccf199380a1af768831125fbce59f0d991f86b44d3d1c8ac6f184d45edf6716
data/README.md CHANGED
@@ -224,6 +224,8 @@ The default HTTP adapter uses Ruby's `Net::HTTP` and is safe to use under Puma's
224
224
  - Per-client configuration only
225
225
  - Blocking IO that integrates with Ruby 3 Fiber scheduler
226
226
 
227
+ If you don't pass an adapter, `SimpleInference::Client` uses `SimpleInference::HTTPAdapters::Default` (Net::HTTP).
228
+
227
229
  For Falcon / async environments, you can keep the default adapter, or use the optional HTTPX adapter (requires the `httpx` gem):
228
230
 
229
231
  ```ruby
@@ -233,7 +235,7 @@ gem "httpx" # optional, only required when using the HTTPX adapter
233
235
  You can then use the optional HTTPX adapter shipped with this gem:
234
236
 
235
237
  ```ruby
236
- adapter = SimpleInference::HTTPAdapter::HTTPX.new(timeout: 30.0)
238
+ adapter = SimpleInference::HTTPAdapters::HTTPX.new(timeout: 30.0)
237
239
 
238
240
  SIMPLE_INFERENCE_CLIENT =
239
241
  SimpleInference::Client.new(
@@ -12,7 +12,12 @@ module SimpleInference
12
12
 
13
13
  def initialize(options = {})
14
14
  @config = Config.new(options || {})
15
- @adapter = @config.adapter || HTTPAdapter::Default.new
15
+ @adapter = @config.adapter || HTTPAdapters::Default.new
16
+
17
+ unless @adapter.is_a?(HTTPAdapter)
18
+ raise Errors::ConfigurationError,
19
+ "adapter must be an instance of SimpleInference::HTTPAdapter (got #{@adapter.class})"
20
+ end
16
21
  end
17
22
 
18
23
  # POST /v1/chat/completions
@@ -168,32 +173,15 @@ module SimpleInference
168
173
  def handle_stream_response(request_env, raise_on_http_error:, &on_event)
169
174
  sse_buffer = +""
170
175
  sse_done = false
171
- used_streaming_adapter = false
176
+ streamed = false
172
177
 
173
178
  raw_response =
174
- if @adapter.respond_to?(:call_stream)
175
- used_streaming_adapter = true
176
- @adapter.call_stream(request_env) do |chunk|
177
- next if sse_done
178
-
179
- sse_buffer << chunk.to_s
180
- extract_sse_blocks!(sse_buffer).each do |block|
181
- data = sse_data_from_block(block)
182
- next if data.nil?
183
-
184
- payload = data.strip
185
- next if payload.empty?
186
- if payload == "[DONE]"
187
- sse_done = true
188
- sse_buffer.clear
189
- break
190
- end
191
-
192
- on_event&.call(parse_json_event(payload))
193
- end
194
- end
195
- else
196
- @adapter.call(request_env)
179
+ @adapter.call_stream(request_env) do |chunk|
180
+ streamed = true
181
+ next if sse_done
182
+
183
+ sse_buffer << chunk.to_s
184
+ sse_done = consume_sse_buffer!(sse_buffer, &on_event) || sse_done
197
185
  end
198
186
 
199
187
  status = raw_response[:status]
@@ -206,18 +194,9 @@ module SimpleInference
206
194
  # Streaming case.
207
195
  if status >= 200 && status < 300 && content_type.include?("text/event-stream")
208
196
  # If we couldn't stream incrementally, best-effort parse the full SSE body.
209
- unless used_streaming_adapter
197
+ unless streamed
210
198
  buffer = body_str.dup
211
- extract_sse_blocks!(buffer).each do |block|
212
- data = sse_data_from_block(block)
213
- next if data.nil?
214
-
215
- payload = data.strip
216
- next if payload.empty?
217
- break if payload == "[DONE]"
218
-
219
- on_event&.call(parse_json_event(payload))
220
- end
199
+ consume_sse_buffer!(buffer, &on_event)
221
200
  end
222
201
 
223
202
  return {
@@ -231,39 +210,14 @@ module SimpleInference
231
210
  should_parse_json = content_type.include?("json")
232
211
  parsed_body = should_parse_json ? parse_json(body_str) : body_str
233
212
 
234
- raise_on =
235
- if raise_on_http_error.nil?
236
- config.raise_on_error
237
- else
238
- !!raise_on_http_error
239
- end
240
-
241
- if raise_on && (status < 200 || status >= 300)
242
- # Do not raise for the known "streaming unsupported" case; the caller will
243
- # perform a non-streaming retry fallback.
244
- unless streaming_unsupported_error?(status, parsed_body)
245
- message = "HTTP #{status}"
246
- begin
247
- error_body = JSON.parse(body_str)
248
- error_field = error_body["error"]
249
- message =
250
- if error_field.is_a?(Hash)
251
- error_field["message"] || error_body["message"] || message
252
- else
253
- error_field || error_body["message"] || message
254
- end
255
- rescue JSON::ParserError
256
- # fall back to generic message
257
- end
258
-
259
- raise Errors::HTTPError.new(
260
- message,
261
- status: status,
262
- headers: headers,
263
- body: body_str
264
- )
265
- end
266
- end
213
+ maybe_raise_http_error(
214
+ status: status,
215
+ headers: headers,
216
+ body_str: body_str,
217
+ raise_on_http_error: raise_on_http_error,
218
+ ignore_streaming_unsupported: true,
219
+ parsed_body: parsed_body
220
+ )
267
221
 
268
222
  {
269
223
  status: status,
@@ -294,6 +248,27 @@ module SimpleInference
294
248
  blocks
295
249
  end
296
250
 
251
+ def consume_sse_buffer!(buffer, &on_event)
252
+ done = false
253
+
254
+ extract_sse_blocks!(buffer).each do |block|
255
+ data = sse_data_from_block(block)
256
+ next if data.nil?
257
+
258
+ payload = data.strip
259
+ next if payload.empty?
260
+ if payload == "[DONE]"
261
+ done = true
262
+ buffer.clear
263
+ break
264
+ end
265
+
266
+ on_event&.call(parse_json_event(payload))
267
+ end
268
+
269
+ done
270
+ end
271
+
297
272
  def sse_data_from_block(block)
298
273
  return nil if block.nil? || block.empty?
299
274
 
@@ -426,6 +401,7 @@ module SimpleInference
426
401
  end
427
402
 
428
403
  body, headers = build_multipart_body(io, filename, form_fields)
404
+ headers["Transfer-Encoding"] = "chunked" if body.respond_to?(:size) && body.size.nil?
429
405
 
430
406
  request_env = {
431
407
  method: :post,
@@ -481,60 +457,130 @@ module SimpleInference
481
457
  "Content-Type" => "multipart/form-data; boundary=#{boundary}",
482
458
  }
483
459
 
484
- body = +""
485
-
460
+ parts = []
486
461
  fields.each do |name, value|
487
- body << "--#{boundary}\r\n"
488
- body << %(Content-Disposition: form-data; name="#{name}"\r\n\r\n)
489
- body << value.to_s
490
- body << "\r\n"
462
+ parts << "--#{boundary}\r\n".b
463
+ parts << %(Content-Disposition: form-data; name="#{name}"\r\n\r\n).b
464
+ parts << value.to_s.b
465
+ parts << "\r\n".b
491
466
  end
492
467
 
493
- body << "--#{boundary}\r\n"
494
- body << %(Content-Disposition: form-data; name="file"; filename="#{filename}"\r\n)
495
- body << "Content-Type: application/octet-stream\r\n\r\n"
468
+ parts << "--#{boundary}\r\n".b
469
+ parts << %(Content-Disposition: form-data; name="file"; filename="#{filename}"\r\n).b
470
+ parts << "Content-Type: application/octet-stream\r\n\r\n".b
471
+ parts << io
472
+ parts << "\r\n--#{boundary}--\r\n".b
496
473
 
497
- while (chunk = io.read(16_384))
498
- body << chunk
474
+ [MultipartStream.new(parts), headers]
475
+ end
476
+
477
+ class MultipartStream
478
+ def initialize(parts)
479
+ @parts = parts
480
+ @part_index = 0
481
+ @string_offset = 0
482
+ @size = nil
499
483
  end
500
484
 
501
- body << "\r\n--#{boundary}--\r\n"
485
+ def read(length = nil, outbuf = nil)
486
+ return "".b if length.nil? && eof?
487
+ return nil if eof?
502
488
 
503
- [body, headers]
504
- end
489
+ out = outbuf ? outbuf.replace("".b) : +"".b
505
490
 
506
- def handle_response(request_env, expect_json:, raise_on_http_error:)
507
- response = @adapter.call(request_env)
491
+ if length.nil?
492
+ while (chunk = read(16_384))
493
+ out << chunk
494
+ end
495
+ return out
496
+ end
508
497
 
509
- status = response[:status]
510
- headers = (response[:headers] || {}).transform_keys { |k| k.to_s.downcase }
511
- body = response[:body].to_s
498
+ while out.bytesize < length && !eof?
499
+ part = @parts.fetch(@part_index)
512
500
 
513
- # Decide whether to raise on HTTP errors
514
- raise_on =
515
- if raise_on_http_error.nil?
516
- config.raise_on_error
517
- else
518
- !!raise_on_http_error
501
+ if part.is_a?(String)
502
+ remaining = part.bytesize - @string_offset
503
+ if remaining <= 0
504
+ advance_part!
505
+ next
506
+ end
507
+
508
+ take = [length - out.bytesize, remaining].min
509
+ out << part.byteslice(@string_offset, take)
510
+ @string_offset += take
511
+
512
+ advance_part! if @string_offset >= part.bytesize
513
+ else
514
+ chunk = part.read(length - out.bytesize)
515
+ if chunk.nil? || chunk.empty?
516
+ advance_part!
517
+ next
518
+ end
519
+
520
+ out << chunk
521
+ end
519
522
  end
520
523
 
521
- if raise_on && (status < 200 || status >= 300)
522
- message = "HTTP #{status}"
524
+ return nil if out.empty? && eof?
523
525
 
524
- begin
525
- error_body = JSON.parse(body)
526
- message = error_body["error"] || error_body["message"] || message
527
- rescue JSON::ParserError
528
- # fall back to generic message
526
+ out
527
+ end
528
+
529
+ def size
530
+ @size ||= compute_size
531
+ end
532
+
533
+ private
534
+
535
+ def eof?
536
+ @part_index >= @parts.length
537
+ end
538
+
539
+ def advance_part!
540
+ @part_index += 1
541
+ @string_offset = 0
542
+ end
543
+
544
+ def compute_size
545
+ total = 0
546
+
547
+ @parts.each do |part|
548
+ if part.is_a?(String)
549
+ total += part.bytesize
550
+ next
551
+ end
552
+
553
+ return nil unless part.respond_to?(:size)
554
+
555
+ part_size = part.size
556
+ if part.respond_to?(:pos)
557
+ begin
558
+ part_size -= part.pos
559
+ rescue StandardError
560
+ # ignore pos errors
561
+ end
562
+ end
563
+
564
+ total += part_size
529
565
  end
530
566
 
531
- raise Errors::HTTPError.new(
532
- message,
533
- status: status,
534
- headers: headers,
535
- body: body
536
- )
567
+ total
537
568
  end
569
+ end
570
+
571
+ def handle_response(request_env, expect_json:, raise_on_http_error:)
572
+ response = @adapter.call(request_env)
573
+
574
+ status = response[:status]
575
+ headers = (response[:headers] || {}).transform_keys { |k| k.to_s.downcase }
576
+ body = response[:body].to_s
577
+
578
+ maybe_raise_http_error(
579
+ status: status,
580
+ headers: headers,
581
+ body_str: body,
582
+ raise_on_http_error: raise_on_http_error
583
+ )
538
584
 
539
585
  should_parse_json =
540
586
  if expect_json.nil?
@@ -569,5 +615,56 @@ module SimpleInference
569
615
  rescue JSON::ParserError => e
570
616
  raise Errors::DecodeError, "Failed to parse JSON response: #{e.message}"
571
617
  end
618
+
619
+ def raise_on_http_error?(raise_on_http_error)
620
+ raise_on_http_error.nil? ? config.raise_on_error : !!raise_on_http_error
621
+ end
622
+
623
+ def http_error_message(status, body_str, parsed_body: nil)
624
+ message = "HTTP #{status}"
625
+
626
+ error_body =
627
+ if parsed_body.is_a?(Hash)
628
+ parsed_body
629
+ else
630
+ begin
631
+ JSON.parse(body_str)
632
+ rescue JSON::ParserError
633
+ nil
634
+ end
635
+ end
636
+
637
+ return message unless error_body.is_a?(Hash)
638
+
639
+ error_field = error_body["error"]
640
+ if error_field.is_a?(Hash)
641
+ error_field["message"] || error_body["message"] || message
642
+ else
643
+ error_field || error_body["message"] || message
644
+ end
645
+ end
646
+
647
+ def maybe_raise_http_error(
648
+ status:,
649
+ headers:,
650
+ body_str:,
651
+ raise_on_http_error:,
652
+ ignore_streaming_unsupported: false,
653
+ parsed_body: nil
654
+ )
655
+ return unless raise_on_http_error?(raise_on_http_error)
656
+ return unless status < 200 || status >= 300
657
+
658
+ # Do not raise for the known "streaming unsupported" case; the caller will
659
+ # perform a non-streaming retry fallback.
660
+ return if ignore_streaming_unsupported && streaming_unsupported_error?(status, parsed_body)
661
+
662
+ raise Errors::HTTPError.new(
663
+ http_error_message(status, body_str, parsed_body: parsed_body),
664
+ status: status,
665
+ headers: headers,
666
+ body: body_str
667
+ )
668
+ end
572
669
  end
573
670
  end
@@ -1,123 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "uri"
5
-
6
3
  module SimpleInference
7
- module HTTPAdapter
8
- # Optional adapters are lazily loaded so the SDK has no hard runtime deps.
9
- autoload :HTTPX, "simple_inference/http_adapter/httpx"
10
-
11
- # Default synchronous HTTP adapter built on Net::HTTP.
12
- # It is compatible with Ruby 3 Fiber scheduler and keeps the interface
13
- # minimal so it can be swapped out for custom adapters (HTTPX, async-http, etc.).
14
- class Default
15
- def call(request)
16
- uri = URI.parse(request.fetch(:url))
17
-
18
- http = Net::HTTP.new(uri.host, uri.port)
19
- http.use_ssl = uri.scheme == "https"
20
-
21
- timeout = request[:timeout]
22
- open_timeout = request[:open_timeout] || timeout
23
- read_timeout = request[:read_timeout] || timeout
24
-
25
- http.open_timeout = open_timeout if open_timeout
26
- http.read_timeout = read_timeout if read_timeout
27
-
28
- klass = http_class_for(request[:method])
29
- req = klass.new(uri.request_uri)
30
-
31
- headers = request[:headers] || {}
32
- headers.each do |key, value|
33
- req[key.to_s] = value
34
- end
35
-
36
- body = request[:body]
37
- req.body = body if body
38
-
39
- response = http.request(req)
40
-
41
- {
42
- status: Integer(response.code),
43
- headers: response.each_header.to_h,
44
- body: response.body.to_s,
45
- }
46
- end
47
-
48
- # Streaming-capable request helper.
49
- #
50
- # When the response is `text/event-stream` (and 2xx), it yields raw body chunks
51
- # as they arrive via the given block, and returns a response hash with `body: nil`.
52
- #
53
- # For non-streaming responses, it behaves like `#call` and returns the full body.
54
- def call_stream(request)
55
- return call(request) unless block_given?
56
-
57
- uri = URI.parse(request.fetch(:url))
58
-
59
- http = Net::HTTP.new(uri.host, uri.port)
60
- http.use_ssl = uri.scheme == "https"
61
-
62
- timeout = request[:timeout]
63
- open_timeout = request[:open_timeout] || timeout
64
- read_timeout = request[:read_timeout] || timeout
65
-
66
- http.open_timeout = open_timeout if open_timeout
67
- http.read_timeout = read_timeout if read_timeout
68
-
69
- klass = http_class_for(request[:method])
70
- req = klass.new(uri.request_uri)
71
-
72
- headers = request[:headers] || {}
73
- headers.each do |key, value|
74
- req[key.to_s] = value
75
- end
76
-
77
- body = request[:body]
78
- req.body = body if body
79
-
80
- status = nil
81
- response_headers = {}
82
- response_body = +""
83
-
84
- http.request(req) do |response|
85
- status = Integer(response.code)
86
- response_headers = response.each_header.to_h
87
-
88
- headers_lc = response_headers.transform_keys { |k| k.to_s.downcase }
89
- content_type = headers_lc["content-type"]
90
-
91
- if status >= 200 && status < 300 && content_type&.include?("text/event-stream")
92
- response.read_body do |chunk|
93
- yield chunk
94
- end
95
- response_body = nil
96
- else
97
- response_body = response.body.to_s
98
- end
99
- end
4
+ # Base class for HTTP adapters.
5
+ #
6
+ # Concrete adapters must implement `#call` and may override `#call_stream`
7
+ # for incremental streaming.
8
+ class HTTPAdapter
9
+ def call(_request)
10
+ raise NotImplementedError, "#{self.class} must implement #call"
11
+ end
100
12
 
101
- {
102
- status: Integer(status),
103
- headers: response_headers,
104
- body: response_body,
105
- }
13
+ # Streaming-capable request helper.
14
+ #
15
+ # When the response is `text/event-stream` (and 2xx), it yields raw body chunks
16
+ # as they arrive via the given block, and returns a response hash with `body: nil`.
17
+ #
18
+ # For non-streaming responses, it behaves like `#call` and returns the full body.
19
+ def call_stream(request)
20
+ return call(request) unless block_given?
21
+
22
+ response = call(request)
23
+
24
+ status = response[:status].to_i
25
+ headers = response[:headers] || {}
26
+ content_type =
27
+ headers.each_with_object({}) do |(k, v), out|
28
+ out[k.to_s.downcase] = v
29
+ end["content-type"].to_s
30
+
31
+ if status >= 200 && status < 300 && content_type.include?("text/event-stream")
32
+ yield response[:body].to_s
33
+ return { status: status, headers: headers, body: nil }
106
34
  end
107
35
 
108
- private
109
-
110
- def http_class_for(method)
111
- case method.to_s.upcase
112
- when "GET" then Net::HTTP::Get
113
- when "POST" then Net::HTTP::Post
114
- when "PUT" then Net::HTTP::Put
115
- when "PATCH" then Net::HTTP::Patch
116
- when "DELETE" then Net::HTTP::Delete
117
- else
118
- raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
119
- end
120
- end
36
+ response
121
37
  end
122
38
  end
39
+
40
+ module HTTPAdapters
41
+ autoload :Default, "simple_inference/http_adapters/default"
42
+ autoload :HTTPX, "simple_inference/http_adapters/httpx"
43
+ end
123
44
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module SimpleInference
7
+ module HTTPAdapters
8
+ # Default synchronous HTTP adapter built on Net::HTTP.
9
+ # It is compatible with Ruby 3 Fiber scheduler and keeps the interface
10
+ # minimal so it can be swapped out for custom adapters (HTTPX, async-http, etc.).
11
+ class Default < HTTPAdapter
12
+ def call(request)
13
+ http, req = build_request(request)
14
+
15
+ response = http.request(req)
16
+
17
+ {
18
+ status: Integer(response.code),
19
+ headers: response.each_header.to_h,
20
+ body: response.body.to_s,
21
+ }
22
+ end
23
+
24
+ # Streaming-capable request helper.
25
+ #
26
+ # When the response is `text/event-stream` (and 2xx), it yields raw body chunks
27
+ # as they arrive via the given block, and returns a response hash with `body: nil`.
28
+ #
29
+ # For non-streaming responses, it behaves like `#call` and returns the full body.
30
+ def call_stream(request)
31
+ return call(request) unless block_given?
32
+
33
+ http, req = build_request(request)
34
+
35
+ status = nil
36
+ response_headers = {}
37
+ response_body = +""
38
+
39
+ http.request(req) do |response|
40
+ status = Integer(response.code)
41
+ response_headers = response.each_header.to_h
42
+
43
+ headers_lc = response_headers.transform_keys { |k| k.to_s.downcase }
44
+ content_type = headers_lc["content-type"]
45
+
46
+ if status >= 200 && status < 300 && content_type&.include?("text/event-stream")
47
+ response.read_body do |chunk|
48
+ yield chunk
49
+ end
50
+ response_body = nil
51
+ else
52
+ response_body = response.body.to_s
53
+ end
54
+ end
55
+
56
+ {
57
+ status: Integer(status),
58
+ headers: response_headers,
59
+ body: response_body,
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ def build_request(request)
66
+ uri = URI.parse(request.fetch(:url))
67
+
68
+ http = Net::HTTP.new(uri.host, uri.port)
69
+ http.use_ssl = uri.scheme == "https"
70
+
71
+ apply_timeouts(http, request)
72
+
73
+ req = http_class_for(request[:method]).new(uri.request_uri)
74
+ apply_headers(req, request[:headers] || {})
75
+ apply_body(req, request[:body])
76
+
77
+ [http, req]
78
+ end
79
+
80
+ def apply_timeouts(http, request)
81
+ timeout = request[:timeout]
82
+ open_timeout = request[:open_timeout] || timeout
83
+ read_timeout = request[:read_timeout] || timeout
84
+
85
+ http.open_timeout = open_timeout if open_timeout
86
+ http.read_timeout = read_timeout if read_timeout
87
+ end
88
+
89
+ def apply_headers(req, headers)
90
+ headers.each do |key, value|
91
+ req[key.to_s] = value
92
+ end
93
+ end
94
+
95
+ def apply_body(req, body)
96
+ return unless body
97
+
98
+ if body.is_a?(String)
99
+ req.body = body
100
+ elsif body.respond_to?(:read)
101
+ req.body_stream = body
102
+ if body.respond_to?(:size) && (size = body.size)
103
+ req.content_length = size
104
+ req.delete("Transfer-Encoding")
105
+ else
106
+ req["Transfer-Encoding"] = "chunked"
107
+ end
108
+ else
109
+ req.body = body.to_s
110
+ end
111
+ end
112
+
113
+ def http_class_for(method)
114
+ case method.to_s.upcase
115
+ when "GET" then Net::HTTP::Get
116
+ when "POST" then Net::HTTP::Post
117
+ when "PUT" then Net::HTTP::Put
118
+ when "PATCH" then Net::HTTP::Patch
119
+ when "DELETE" then Net::HTTP::Delete
120
+ else
121
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "httpx"
5
+ rescue LoadError => e
6
+ raise LoadError,
7
+ "httpx gem is required for SimpleInference::HTTPAdapters::HTTPX (add `gem \"httpx\"`)",
8
+ cause: e
9
+ end
10
+
11
+ # HTTPX's stream plugin expects all request objects to respond to `#stream`,
12
+ # however some internal requests (e.g. proxy CONNECT) don't include the plugin's
13
+ # RequestMethods. Add a harmless accessor at the base class level.
14
+ unless ::HTTPX::Request.method_defined?(:stream) && ::HTTPX::Request.method_defined?(:stream=)
15
+ ::HTTPX::Request.class_eval do
16
+ attr_accessor :stream
17
+ end
18
+ end
19
+
20
+ module SimpleInference
21
+ module HTTPAdapters
22
+ # Fiber-friendly HTTP adapter built on HTTPX.
23
+ class HTTPX < HTTPAdapter
24
+ def initialize(timeout: nil, client: ::HTTPX)
25
+ @timeout = timeout
26
+
27
+ unless client == ::HTTPX || client.is_a?(::HTTPX::Session)
28
+ raise ArgumentError,
29
+ "client must be ::HTTPX or an instance of ::HTTPX::Session (got #{client.class})"
30
+ end
31
+
32
+ @client = client
33
+ @stream_client = client.plugin(:stream)
34
+ end
35
+
36
+ def call(request)
37
+ method = request.fetch(:method).to_s.downcase.to_sym
38
+ url = request.fetch(:url)
39
+ headers = request[:headers] || {}
40
+ body = request[:body]
41
+
42
+ client = @client
43
+
44
+ # Mirror the SDK's timeout semantics:
45
+ # - `:timeout` is the overall request deadline (maps to HTTPX `request_timeout`)
46
+ # - `:open_timeout` and `:read_timeout` override connect/read deadlines
47
+ timeout = request[:timeout] || @timeout
48
+ open_timeout = request[:open_timeout] || timeout
49
+ read_timeout = request[:read_timeout] || timeout
50
+
51
+ timeout_opts = {}
52
+ timeout_opts[:request_timeout] = timeout.to_f if timeout
53
+ timeout_opts[:connect_timeout] = open_timeout.to_f if open_timeout
54
+ timeout_opts[:read_timeout] = read_timeout.to_f if read_timeout
55
+
56
+ unless timeout_opts.empty?
57
+ client = client.with(timeout: timeout_opts)
58
+ end
59
+
60
+ response = client.request(method, url, headers: headers, body: body)
61
+
62
+ # HTTPX may return an error response object instead of raising.
63
+ #
64
+ # NOTE: Some error response objects do not expose the normal response API
65
+ # (e.g. no `#headers`), so we must handle them explicitly.
66
+ if response.is_a?(::HTTPX::ErrorResponse)
67
+ raise Errors::ConnectionError, (response.error&.message || "HTTPX request failed")
68
+ end
69
+
70
+ if response.status.to_i == 0
71
+ raise Errors::ConnectionError, "HTTPX request failed"
72
+ end
73
+
74
+ response_headers = normalize_headers(response)
75
+
76
+ {
77
+ status: response.status.to_i,
78
+ headers: response_headers,
79
+ body: response.body.to_s,
80
+ }
81
+ rescue ::HTTPX::TimeoutError => e
82
+ raise Errors::TimeoutError, e.message
83
+ rescue ::HTTPX::Error, IOError, SystemCallError => e
84
+ raise Errors::ConnectionError, e.message
85
+ end
86
+
87
+ def call_stream(request)
88
+ return call(request) unless block_given?
89
+
90
+ method = request.fetch(:method).to_s.downcase.to_sym
91
+ url = request.fetch(:url)
92
+ headers = request[:headers] || {}
93
+ body = request[:body]
94
+
95
+ # Mirror the SDK's timeout semantics:
96
+ # - `:timeout` is the overall request deadline (maps to HTTPX `request_timeout`)
97
+ # - `:open_timeout` and `:read_timeout` override connect/read deadlines
98
+ timeout = request[:timeout] || @timeout
99
+ open_timeout = request[:open_timeout] || timeout
100
+ read_timeout = request[:read_timeout] || timeout
101
+
102
+ # The HTTPX stream plugin defaults to `read_timeout: Infinity`; align with the
103
+ # non-streaming defaults unless the caller explicitly overrides.
104
+ read_timeout ||= 60
105
+
106
+ timeout_opts = {}
107
+ timeout_opts[:request_timeout] = timeout.to_f if timeout
108
+ timeout_opts[:connect_timeout] = open_timeout.to_f if open_timeout
109
+ timeout_opts[:read_timeout] = read_timeout.to_f if read_timeout
110
+
111
+ stream_client = @stream_client
112
+ client =
113
+ if timeout_opts.empty?
114
+ stream_client.with(timeout: {})
115
+ else
116
+ stream_client.with(timeout: timeout_opts)
117
+ end
118
+
119
+ streaming = nil
120
+ response_headers = {}
121
+ status = nil
122
+ full_body = +"".b
123
+
124
+ stream_response = client.request(method, url, headers: headers, body: body, stream: true)
125
+
126
+ if stream_response.is_a?(::HTTPX::ErrorResponse)
127
+ raise Errors::ConnectionError, (stream_response.error&.message || "HTTPX request failed")
128
+ end
129
+
130
+ if stream_response.status.to_i == 0
131
+ raise Errors::ConnectionError, "HTTPX request failed"
132
+ end
133
+
134
+ begin
135
+ stream_response.each do |chunk|
136
+ status ||= stream_response.status.to_i
137
+ response_headers = normalize_headers(stream_response) if response_headers.empty?
138
+ streaming = streamable_sse?(status, response_headers) if streaming.nil?
139
+
140
+ if streaming
141
+ yield chunk
142
+ else
143
+ full_body << chunk.to_s
144
+ end
145
+ end
146
+ rescue ::HTTPX::HTTPError => e
147
+ # HTTPX's stream plugin raises for non-2xx. Swallow it and let the SDK
148
+ # raise `Errors::HTTPError` based on status.
149
+ status ||= e.response.status.to_i
150
+ response_headers = normalize_headers(e.response) if response_headers.empty?
151
+ end
152
+
153
+ status ||= stream_response.status.to_i
154
+ response_headers = normalize_headers(stream_response) if response_headers.empty?
155
+
156
+ if streamable_sse?(status, response_headers)
157
+ { status: status, headers: response_headers, body: nil }
158
+ else
159
+ { status: status, headers: response_headers, body: full_body.to_s }
160
+ end
161
+ rescue ::HTTPX::TimeoutError => e
162
+ raise Errors::TimeoutError, e.message
163
+ rescue ::HTTPX::Error, IOError, SystemCallError => e
164
+ raise Errors::ConnectionError, e.message
165
+ end
166
+
167
+ private
168
+
169
+ def normalize_headers(response)
170
+ response.headers.to_h.each_with_object({}) do |(k, v), out|
171
+ out[k.to_s] = v.is_a?(Array) ? v.join(", ") : v.to_s
172
+ end
173
+ end
174
+
175
+ def streamable_sse?(status, headers)
176
+ return false unless status.to_i >= 200 && status.to_i < 300
177
+
178
+ content_type =
179
+ headers.each_with_object({}) do |(k, v), out|
180
+ out[k.to_s.downcase] = v
181
+ end["content-type"].to_s
182
+
183
+ content_type.include?("text/event-stream")
184
+ end
185
+ end
186
+ end
187
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleInference
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple_inference
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - jasl
@@ -25,7 +25,8 @@ files:
25
25
  - lib/simple_inference/config.rb
26
26
  - lib/simple_inference/errors.rb
27
27
  - lib/simple_inference/http_adapter.rb
28
- - lib/simple_inference/http_adapter/httpx.rb
28
+ - lib/simple_inference/http_adapters/default.rb
29
+ - lib/simple_inference/http_adapters/httpx.rb
29
30
  - lib/simple_inference/version.rb
30
31
  - sig/simple_inference.rbs
31
32
  homepage: https://github.com/jasl/simple_inference_server/tree/main/sdks/ruby
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- begin
4
- require "httpx"
5
- rescue LoadError => e
6
- raise LoadError,
7
- "httpx gem is required for SimpleInference::HTTPAdapter::HTTPX (add `gem \"httpx\"`)",
8
- cause: e
9
- end
10
-
11
- module SimpleInference
12
- module HTTPAdapter
13
- # Fiber-friendly HTTP adapter built on HTTPX.
14
- #
15
- # NOTE: This adapter intentionally does NOT implement `#call_stream`.
16
- # Streaming consumers will still work via the SDK's full-body SSE parsing
17
- # fallback path (see SimpleInference::Client#handle_stream_response).
18
- class HTTPX
19
- def initialize(timeout: nil)
20
- @timeout = timeout
21
- end
22
-
23
- def call(request)
24
- method = request.fetch(:method).to_s.downcase.to_sym
25
- url = request.fetch(:url)
26
- headers = request[:headers] || {}
27
- body = request[:body]
28
-
29
- client = ::HTTPX
30
-
31
- # Mirror the SDK's timeout semantics:
32
- # - `:timeout` is the overall request deadline (maps to HTTPX `request_timeout`)
33
- # - `:open_timeout` and `:read_timeout` override connect/read deadlines
34
- timeout = request[:timeout] || @timeout
35
- open_timeout = request[:open_timeout] || timeout
36
- read_timeout = request[:read_timeout] || timeout
37
-
38
- timeout_opts = {}
39
- timeout_opts[:request_timeout] = timeout.to_f if timeout
40
- timeout_opts[:connect_timeout] = open_timeout.to_f if open_timeout
41
- timeout_opts[:read_timeout] = read_timeout.to_f if read_timeout
42
-
43
- unless timeout_opts.empty?
44
- client = client.with(timeout: timeout_opts)
45
- end
46
-
47
- response = client.request(method, url, headers: headers, body: body)
48
-
49
- # HTTPX may return an error response object instead of raising.
50
- if response.respond_to?(:status) && response.status.to_i == 0
51
- err = response.respond_to?(:error) ? response.error : nil
52
- raise Errors::ConnectionError, (err ? err.message : "HTTPX request failed")
53
- end
54
-
55
- response_headers =
56
- response.headers.to_h.each_with_object({}) do |(k, v), out|
57
- out[k.to_s] = v.is_a?(Array) ? v.join(", ") : v.to_s
58
- end
59
-
60
- {
61
- status: response.status.to_i,
62
- headers: response_headers,
63
- body: response.body.to_s,
64
- }
65
- rescue ::HTTPX::TimeoutError => e
66
- raise Errors::TimeoutError, e.message
67
- rescue ::HTTPX::Error, IOError, SystemCallError => e
68
- raise Errors::ConnectionError, e.message
69
- end
70
- end
71
- end
72
- end