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 +4 -4
- data/README.md +3 -1
- data/lib/simple_inference/client.rb +204 -107
- data/lib/simple_inference/http_adapter.rb +35 -114
- data/lib/simple_inference/http_adapters/default.rb +126 -0
- data/lib/simple_inference/http_adapters/httpx.rb +187 -0
- data/lib/simple_inference/version.rb +1 -1
- metadata +3 -2
- data/lib/simple_inference/http_adapter/httpx.rb +0 -72
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c062a86e32995fae41fc56269622856569f8bf3e77a1a25be45697ca008332e8
|
|
4
|
+
data.tar.gz: 1988b5edc14d9c3b83963cb34a9aa75a9bea5595511fb00b8668187826aa1d74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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::
|
|
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 ||
|
|
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
|
-
|
|
176
|
+
streamed = false
|
|
172
177
|
|
|
173
178
|
raw_response =
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
197
|
+
unless streamed
|
|
210
198
|
buffer = body_str.dup
|
|
211
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
485
|
-
|
|
460
|
+
parts = []
|
|
486
461
|
fields.each do |name, value|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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
|
-
|
|
485
|
+
def read(length = nil, outbuf = nil)
|
|
486
|
+
return "".b if length.nil? && eof?
|
|
487
|
+
return nil if eof?
|
|
502
488
|
|
|
503
|
-
|
|
504
|
-
end
|
|
489
|
+
out = outbuf ? outbuf.replace("".b) : +"".b
|
|
505
490
|
|
|
506
|
-
|
|
507
|
-
|
|
491
|
+
if length.nil?
|
|
492
|
+
while (chunk = read(16_384))
|
|
493
|
+
out << chunk
|
|
494
|
+
end
|
|
495
|
+
return out
|
|
496
|
+
end
|
|
508
497
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
body = response[:body].to_s
|
|
498
|
+
while out.bytesize < length && !eof?
|
|
499
|
+
part = @parts.fetch(@part_index)
|
|
512
500
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
522
|
-
message = "HTTP #{status}"
|
|
524
|
+
return nil if out.empty? && eof?
|
|
523
525
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
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.
|
|
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/
|
|
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
|