active_harness 0.3.7 → 0.3.8

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: 54990f7712275af0727b75a7518f30f2ecf5ea798fb14548b5872997a6b272bc
4
- data.tar.gz: d81370f86507d5c1d42bc12aac01209afb76bed63d2bb5545bb686ef85cb49ad
3
+ metadata.gz: 5c6df0d88c07312b2546e83f5e69c58d5b3628e4ff31b0885e10a72d032a5a5e
4
+ data.tar.gz: f2427084db1992f1c0cdcd44c7fe6518cfaa0d348f1d3720ce9fcf78e40783c2
5
5
  SHA512:
6
- metadata.gz: cc8f9d543c65fde9818611d68073b2a7de6285f37c815d81e6730c393103add7aed45ba9c9eaf7e9461ddbcf5062b3bd2e4978b3b690cbb190b0cf8eeff19209
7
- data.tar.gz: 73f6e6c7a606e4f36bebf8cdd330534d4a37fd028f5f53fd2133c8a365caa136e62e5044baa54518b9ff8883022a6c4e4af4a01f84ea0d4bb4798c0d9b584d01
6
+ metadata.gz: 39575f2575e8dd5d1dc90a512f1d550512d90b57d37d182c10055901c5f15f6bb4a7d425c5a362acacf0be248c237707d0e1362d71d912b2eb449340c20a9ef9
7
+ data.tar.gz: 41f8adbbb47d9a0e2a17505f470b8a77ffac7f275eb530ee87f3a5f60c3eaa707a6f2610f16f63fc1a73872d75cb02cfad81a0251dd831b685d02fce0f5bad85
@@ -4,35 +4,82 @@ module ActiveHarness
4
4
  module Http
5
5
  # Thin Net::HTTP wrapper — no external dependencies.
6
6
  class Client
7
- # @param url [URI]
7
+ METHODS = {
8
+ get: Net::HTTP::Get,
9
+ post: Net::HTTP::Post,
10
+ put: Net::HTTP::Put,
11
+ patch: Net::HTTP::Patch,
12
+ delete: Net::HTTP::Delete
13
+ }.freeze
14
+
15
+ # @param method [Symbol] :get, :post, :put, :patch or :delete
16
+ # @param url [URI] https (default) or http — TLS follows the URL scheme
8
17
  # @param headers [Hash{String => String}]
9
- # @param body [String] JSON-serialized body
18
+ # @param body [String, nil] request body (usually JSON-serialized); omit for GET/DELETE
10
19
  # @param timeout [Integer] seconds (open + read)
11
20
  # @return [String] raw response body; also responds to #http_status
12
21
  # (Integer) so providers can classify errors by
13
22
  # status without changing the return type
23
+ def request(method, url, headers: {}, body: nil, timeout: 30)
24
+ klass = METHODS[method.to_sym] or raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
25
+
26
+ begin
27
+ res = build_http(url, timeout).request(build_request(klass, url, headers, body))
28
+ rescue Net::OpenTimeout, Net::ReadTimeout
29
+ raise Errors::TimeoutError, "Request to #{url.host} timed out"
30
+ rescue => e
31
+ raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
32
+ end
33
+
34
+ with_http_status(res)
35
+ end
36
+
14
37
  def post(url, headers:, body:, timeout: 30)
38
+ request(:post, url, headers: headers, body: body, timeout: timeout)
39
+ end
40
+
41
+ def get(url, headers: {}, timeout: 30)
42
+ request(:get, url, headers: headers, timeout: timeout)
43
+ end
44
+
45
+ def put(url, headers:, body:, timeout: 30)
46
+ request(:put, url, headers: headers, body: body, timeout: timeout)
47
+ end
48
+
49
+ def patch(url, headers:, body:, timeout: 30)
50
+ request(:patch, url, headers: headers, body: body, timeout: timeout)
51
+ end
52
+
53
+ def delete(url, headers: {}, timeout: 30)
54
+ request(:delete, url, headers: headers, timeout: timeout)
55
+ end
56
+
57
+ private
58
+
59
+ def build_http(url, timeout)
15
60
  http = Net::HTTP.new(url.host, url.port)
16
- http.use_ssl = true
61
+ http.use_ssl = url.scheme == "https"
17
62
  http.open_timeout = timeout
18
63
  http.read_timeout = timeout
64
+ http
65
+ end
19
66
 
20
- req = Net::HTTP::Post.new(url)
67
+ def build_request(klass, url, headers, body)
68
+ req = klass.new(url)
21
69
  headers.each { |k, v| req[k] = v }
22
- req.body = body
70
+ req.body = body if body
71
+ req
72
+ end
23
73
 
24
- res = http.request(req)
74
+ # Returns the response body with an #http_status accessor attached.
75
+ def with_http_status(res)
25
76
  body = res.body
26
77
  return body if body.nil?
27
78
 
28
- body = body.dup
79
+ body = body.dup
29
80
  status = res.code.to_i
30
81
  body.define_singleton_method(:http_status) { status }
31
82
  body
32
- rescue Net::OpenTimeout, Net::ReadTimeout
33
- raise Errors::TimeoutError, "Request to #{url.host} timed out"
34
- rescue => e
35
- raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
36
83
  end
37
84
  end
38
85
  end
@@ -18,7 +18,7 @@ module ActiveHarness
18
18
  # @return [Hash] { content: String, usage: Hash|nil }
19
19
  def post(url, headers:, body:, timeout: 60, on_token:, parse_chunk: nil)
20
20
  http = Net::HTTP.new(url.host, url.port)
21
- http.use_ssl = true
21
+ http.use_ssl = url.scheme == "https"
22
22
  http.open_timeout = timeout
23
23
  http.read_timeout = timeout
24
24
 
@@ -31,6 +31,10 @@ module ActiveHarness
31
31
  usage = {}
32
32
 
33
33
  http.request(req) do |response|
34
+ # Non-2xx: the body is a plain JSON error, not SSE — read it whole and
35
+ # raise instead of silently returning an empty stream.
36
+ raise_for_status!(response.code.to_i, response.read_body) unless response.is_a?(Net::HTTPSuccess)
37
+
34
38
  response.read_body do |chunk|
35
39
  buffer += chunk
36
40
  while (line_end = buffer.index("\n"))
@@ -55,12 +59,40 @@ module ActiveHarness
55
59
  { content: content, usage: usage.empty? ? nil : usage }
56
60
  rescue Net::OpenTimeout, Net::ReadTimeout
57
61
  raise Errors::TimeoutError, "Request to #{url.host} timed out"
62
+ rescue Errors::ProviderError
63
+ raise
58
64
  rescue => e
59
65
  raise Errors::ProviderUnavailableError, "#{url.host} unreachable: #{e.message}"
60
66
  end
61
67
 
62
68
  private
63
69
 
70
+ # Maps an HTTP error status + body to the same error classes the
71
+ # non-streaming providers raise. The message is pulled from the common
72
+ # error shapes ({error:{message}}, {message}, {detail}), else the raw body.
73
+ def raise_for_status!(status, body)
74
+ msg = error_message(body)
75
+ code = status.to_s
76
+
77
+ case status
78
+ when 401, 403 then raise Errors::InvalidApiKeyError.new(msg, error_code: code)
79
+ when 429 then raise Errors::RateLimitError.new(msg, error_code: code)
80
+ when 503 then raise Errors::ProviderUnavailableError.new(msg, error_code: code)
81
+ when 500..599 then raise Errors::ServerError.new(msg, error_code: code)
82
+ else raise Errors::InvalidRequestError.new(msg, error_code: code)
83
+ end
84
+ end
85
+
86
+ def error_message(body)
87
+ data = JSON.parse(body.to_s)
88
+ msg = data.dig("error", "message") if data["error"].is_a?(Hash)
89
+ msg ||= data["error"] if data["error"].is_a?(String)
90
+ msg ||= data["message"] || data["detail"]
91
+ (msg.is_a?(String) ? msg : data.to_json)
92
+ rescue JSON::ParserError, NoMethodError, TypeError
93
+ body.to_s.strip.empty? ? "HTTP error with empty body" : body.to_s.strip[0, 500]
94
+ end
95
+
64
96
  def default_chunk(parsed)
65
97
  token = parsed.dig("choices", 0, "delta", "content")
66
98
  raw_u = parsed["usage"]
@@ -41,7 +41,7 @@ require_relative "active_harness/pipeline"
41
41
  require_relative "active_harness/railtie" if defined?(Rails::Railtie)
42
42
 
43
43
  module ActiveHarness
44
- VERSION = "0.3.7"
44
+ VERSION = "0.3.8"
45
45
 
46
46
  class << self
47
47
  # Configure ActiveHarness.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_harness
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.7
4
+ version: 0.3.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - the-teacher