simple_inference 0.1.0 → 0.1.1

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: ccaaf770804e4ecb6f308d3e0a864f295ab30e9129a5ec7a8322d9a0dd07c89b
4
+ data.tar.gz: 67f3526c92ca12c38c01d00b38e8d624655d1ac101b1314475d6a2005dc2badf
5
5
  SHA512:
6
- metadata.gz: 25baf84b1d22f57ed1ccb4b0413acf79cad258efee5e2a31b33d6a0247a87b175492cf293a67c6c7c2503c7d3c28e32283e21dba1aeb1bcbbe621203975e7972
7
- data.tar.gz: ba78fc2c974118d4046119cc378202556e3d50611514df0cb4a9affa41412c5bb1a77aeccaef34071de15792a63b17bb5f484cafec8f506f7372660ba09fc5fa
6
+ metadata.gz: c27972ed32eb27a5923740e5902addae2ac617c124e0e56c41905eb21c119843072e5047487dd2b1e29665e123cb22692da173bd1e48717152f05b5b7c7322e5
7
+ data.tar.gz: 6219386deb601c540c27a9d309ca2356f6dc4b6604068039da3d9d527ad2a86f3ba882b9095be811dd7e8d3a0910ac9425132f5bf8333c4aa2c8f75f4eda15e5
@@ -426,6 +426,7 @@ module SimpleInference
426
426
  end
427
427
 
428
428
  body, headers = build_multipart_body(io, filename, form_fields)
429
+ headers["Transfer-Encoding"] = "chunked" if body.respond_to?(:size) && body.size.nil?
429
430
 
430
431
  request_env = {
431
432
  method: :post,
@@ -481,26 +482,115 @@ module SimpleInference
481
482
  "Content-Type" => "multipart/form-data; boundary=#{boundary}",
482
483
  }
483
484
 
484
- body = +""
485
-
485
+ parts = []
486
486
  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"
487
+ parts << "--#{boundary}\r\n".b
488
+ parts << %(Content-Disposition: form-data; name="#{name}"\r\n\r\n).b
489
+ parts << value.to_s.b
490
+ parts << "\r\n".b
491
+ end
492
+
493
+ parts << "--#{boundary}\r\n".b
494
+ parts << %(Content-Disposition: form-data; name="file"; filename="#{filename}"\r\n).b
495
+ parts << "Content-Type: application/octet-stream\r\n\r\n".b
496
+ parts << io
497
+ parts << "\r\n--#{boundary}--\r\n".b
498
+
499
+ [MultipartStream.new(parts), headers]
500
+ end
501
+
502
+ class MultipartStream
503
+ def initialize(parts)
504
+ @parts = parts
505
+ @part_index = 0
506
+ @string_offset = 0
507
+ @size = nil
508
+ end
509
+
510
+ def read(length = nil, outbuf = nil)
511
+ return "".b if length.nil? && eof?
512
+ return nil if eof?
513
+
514
+ out = outbuf ? outbuf.replace("".b) : +"".b
515
+
516
+ if length.nil?
517
+ while (chunk = read(16_384))
518
+ out << chunk
519
+ end
520
+ return out
521
+ end
522
+
523
+ while out.bytesize < length && !eof?
524
+ part = @parts.fetch(@part_index)
525
+
526
+ if part.is_a?(String)
527
+ remaining = part.bytesize - @string_offset
528
+ if remaining <= 0
529
+ advance_part!
530
+ next
531
+ end
532
+
533
+ take = [length - out.bytesize, remaining].min
534
+ out << part.byteslice(@string_offset, take)
535
+ @string_offset += take
536
+
537
+ advance_part! if @string_offset >= part.bytesize
538
+ else
539
+ chunk = part.read(length - out.bytesize)
540
+ if chunk.nil? || chunk.empty?
541
+ advance_part!
542
+ next
543
+ end
544
+
545
+ out << chunk
546
+ end
547
+ end
548
+
549
+ return nil if out.empty? && eof?
550
+
551
+ out
491
552
  end
492
553
 
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"
554
+ def size
555
+ @size ||= compute_size
556
+ end
557
+
558
+ private
496
559
 
497
- while (chunk = io.read(16_384))
498
- body << chunk
560
+ def eof?
561
+ @part_index >= @parts.length
499
562
  end
500
563
 
501
- body << "\r\n--#{boundary}--\r\n"
564
+ def advance_part!
565
+ @part_index += 1
566
+ @string_offset = 0
567
+ end
502
568
 
503
- [body, headers]
569
+ def compute_size
570
+ total = 0
571
+
572
+ @parts.each do |part|
573
+ if part.is_a?(String)
574
+ total += part.bytesize
575
+ next
576
+ end
577
+
578
+ return nil unless part.respond_to?(:size)
579
+
580
+ part_size = part.size
581
+ if part.respond_to?(:pos)
582
+ begin
583
+ part_size -= part.pos
584
+ rescue StandardError
585
+ # ignore pos errors
586
+ end
587
+ end
588
+
589
+ total += part_size
590
+ end
591
+
592
+ total
593
+ end
504
594
  end
505
595
 
506
596
  def handle_response(request_env, expect_json:, raise_on_http_error:)
@@ -523,7 +613,13 @@ module SimpleInference
523
613
 
524
614
  begin
525
615
  error_body = JSON.parse(body)
526
- message = error_body["error"] || error_body["message"] || message
616
+ error_field = error_body["error"]
617
+ message =
618
+ if error_field.is_a?(Hash)
619
+ error_field["message"] || error_body["message"] || message
620
+ else
621
+ error_field || error_body["message"] || message
622
+ end
527
623
  rescue JSON::ParserError
528
624
  # fall back to generic message
529
625
  end
@@ -11,13 +11,10 @@ end
11
11
  module SimpleInference
12
12
  module HTTPAdapter
13
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
14
  class HTTPX
19
- def initialize(timeout: nil)
15
+ def initialize(timeout: nil, client: ::HTTPX)
20
16
  @timeout = timeout
17
+ @client = client
21
18
  end
22
19
 
23
20
  def call(request)
@@ -26,7 +23,7 @@ module SimpleInference
26
23
  headers = request[:headers] || {}
27
24
  body = request[:body]
28
25
 
29
- client = ::HTTPX
26
+ client = @client
30
27
 
31
28
  # Mirror the SDK's timeout semantics:
32
29
  # - `:timeout` is the overall request deadline (maps to HTTPX `request_timeout`)
@@ -47,26 +44,160 @@ module SimpleInference
47
44
  response = client.request(method, url, headers: headers, body: body)
48
45
 
49
46
  # HTTPX may return an error response object instead of raising.
47
+ #
48
+ # NOTE: Some error response objects do not expose the normal response API
49
+ # (e.g. no `#headers`), so we must handle them explicitly.
50
+ if defined?(::HTTPX::ErrorResponse) && response.is_a?(::HTTPX::ErrorResponse)
51
+ err = response.respond_to?(:error) ? response.error : nil
52
+ raise Errors::ConnectionError, (err ? err.message : "HTTPX request failed")
53
+ end
54
+
50
55
  if response.respond_to?(:status) && response.status.to_i == 0
51
56
  err = response.respond_to?(:error) ? response.error : nil
52
57
  raise Errors::ConnectionError, (err ? err.message : "HTTPX request failed")
53
58
  end
54
59
 
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
60
+ response_headers = normalize_headers(response)
59
61
 
60
62
  {
61
63
  status: response.status.to_i,
62
64
  headers: response_headers,
63
- body: response.body.to_s,
65
+ body: response.respond_to?(:body) ? response.body.to_s : "",
64
66
  }
65
67
  rescue ::HTTPX::TimeoutError => e
66
68
  raise Errors::TimeoutError, e.message
67
69
  rescue ::HTTPX::Error, IOError, SystemCallError => e
68
70
  raise Errors::ConnectionError, e.message
69
71
  end
72
+
73
+ def call_stream(request)
74
+ return call(request) unless block_given?
75
+
76
+ method = request.fetch(:method).to_s.downcase.to_sym
77
+ url = request.fetch(:url)
78
+ headers = request[:headers] || {}
79
+ body = request[:body]
80
+
81
+ client = @client
82
+
83
+ # Mirror the SDK's timeout semantics:
84
+ # - `:timeout` is the overall request deadline (maps to HTTPX `request_timeout`)
85
+ # - `:open_timeout` and `:read_timeout` override connect/read deadlines
86
+ timeout = request[:timeout] || @timeout
87
+ open_timeout = request[:open_timeout] || timeout
88
+ read_timeout = request[:read_timeout] || timeout
89
+
90
+ # The HTTPX stream plugin defaults to `read_timeout: Infinity`; align with the
91
+ # non-streaming defaults unless the caller explicitly overrides.
92
+ read_timeout ||= 60
93
+
94
+ timeout_opts = {}
95
+ timeout_opts[:request_timeout] = timeout.to_f if timeout
96
+ timeout_opts[:connect_timeout] = open_timeout.to_f if open_timeout
97
+ timeout_opts[:read_timeout] = read_timeout.to_f if read_timeout
98
+
99
+ unless client.respond_to?(:plugin)
100
+ return call_stream_via_full_body(
101
+ method: method,
102
+ url: url,
103
+ headers: headers,
104
+ body: body,
105
+ timeout: timeout,
106
+ open_timeout: open_timeout,
107
+ read_timeout: read_timeout
108
+ ) { |chunk| yield chunk }
109
+ end
110
+
111
+ client = client.plugin(:stream)
112
+ client = client.with(timeout: timeout_opts) unless timeout_opts.empty?
113
+
114
+ streaming = nil
115
+ response_headers = {}
116
+ status = nil
117
+ full_body = +"".b
118
+
119
+ stream_response = client.request(method, url, headers: headers, body: body, stream: true)
120
+
121
+ begin
122
+ stream_response.each do |chunk|
123
+ status ||= stream_response.respond_to?(:status) ? stream_response.status.to_i : nil
124
+ response_headers = normalize_headers(stream_response) if response_headers.empty?
125
+ streaming = streamable_sse?(status, response_headers) if streaming.nil?
126
+
127
+ if streaming
128
+ yield chunk
129
+ else
130
+ full_body << chunk.to_s
131
+ end
132
+ end
133
+ rescue ::HTTPX::HTTPError => e
134
+ # HTTPX's stream plugin raises for non-2xx. Swallow it and let the SDK
135
+ # raise `Errors::HTTPError` based on status.
136
+ status ||= e.response.status.to_i if e.respond_to?(:response) && e.response.respond_to?(:status)
137
+ response_headers = normalize_headers(e.response) if response_headers.empty? && e.respond_to?(:response)
138
+ end
139
+
140
+ status ||= stream_response.respond_to?(:status) ? stream_response.status.to_i : 0
141
+ response_headers = normalize_headers(stream_response) if response_headers.empty?
142
+
143
+ if streamable_sse?(status, response_headers)
144
+ { status: status, headers: response_headers, body: nil }
145
+ else
146
+ { status: status, headers: response_headers, body: full_body.to_s }
147
+ end
148
+ rescue ::HTTPX::TimeoutError => e
149
+ raise Errors::TimeoutError, e.message
150
+ rescue ::HTTPX::Error, IOError, SystemCallError => e
151
+ raise Errors::ConnectionError, e.message
152
+ end
153
+
154
+ private
155
+
156
+ def normalize_headers(response)
157
+ return {} unless response.respond_to?(:headers)
158
+
159
+ response.headers.to_h.each_with_object({}) do |(k, v), out|
160
+ out[k.to_s] = v.is_a?(Array) ? v.join(", ") : v.to_s
161
+ end
162
+ end
163
+
164
+ def streamable_sse?(status, headers)
165
+ return false unless status.to_i >= 200 && status.to_i < 300
166
+
167
+ content_type =
168
+ headers.each_with_object({}) do |(k, v), out|
169
+ out[k.to_s.downcase] = v
170
+ end["content-type"].to_s
171
+
172
+ content_type.include?("text/event-stream")
173
+ end
174
+
175
+ def call_stream_via_full_body(method:, url:, headers:, body:, timeout:, open_timeout:, read_timeout:)
176
+ response =
177
+ call(
178
+ {
179
+ method: method,
180
+ url: url,
181
+ headers: headers,
182
+ body: body,
183
+ timeout: timeout,
184
+ open_timeout: open_timeout,
185
+ read_timeout: read_timeout,
186
+ }
187
+ )
188
+
189
+ content_type =
190
+ (response[:headers] || {}).each_with_object({}) do |(k, v), out|
191
+ out[k.to_s.downcase] = v
192
+ end["content-type"].to_s
193
+
194
+ if response[:status].to_i >= 200 && response[:status].to_i < 300 && content_type.include?("text/event-stream")
195
+ yield response[:body].to_s
196
+ return { status: response[:status].to_i, headers: response[:headers] || {}, body: nil }
197
+ end
198
+
199
+ response
200
+ end
70
201
  end
71
202
  end
72
203
  end
@@ -34,7 +34,21 @@ module SimpleInference
34
34
  end
35
35
 
36
36
  body = request[:body]
37
- req.body = body if body
37
+ if body
38
+ if body.is_a?(String)
39
+ req.body = body
40
+ elsif body.respond_to?(:read)
41
+ req.body_stream = body
42
+ if body.respond_to?(:size) && (size = body.size)
43
+ req.content_length = size
44
+ req.delete("Transfer-Encoding")
45
+ else
46
+ req["Transfer-Encoding"] = "chunked"
47
+ end
48
+ else
49
+ req.body = body.to_s
50
+ end
51
+ end
38
52
 
39
53
  response = http.request(req)
40
54
 
@@ -75,7 +89,21 @@ module SimpleInference
75
89
  end
76
90
 
77
91
  body = request[:body]
78
- req.body = body if body
92
+ if body
93
+ if body.is_a?(String)
94
+ req.body = body
95
+ elsif body.respond_to?(:read)
96
+ req.body_stream = body
97
+ if body.respond_to?(:size) && (size = body.size)
98
+ req.content_length = size
99
+ req.delete("Transfer-Encoding")
100
+ else
101
+ req["Transfer-Encoding"] = "chunked"
102
+ end
103
+ else
104
+ req.body = body.to_s
105
+ end
106
+ end
79
107
 
80
108
  status = nil
81
109
  response_headers = {}
@@ -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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - jasl