gigachat-ruby 0.1.1 → 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: 78c6d32e2190d2fe8549174536d3b659a98f9c5b2e1471638a807750f375b5c9
4
- data.tar.gz: d937faa30b6007f3263a45053277810006ff5992bc626c59f31dbf4740221886
3
+ metadata.gz: cd0ada64b65b7605a1c98a61e4b3615e3570c1614b9b528a0aafb84549134cfa
4
+ data.tar.gz: 3262d0b82317e3306d009cd2f2f5315a7cbe1aa2e8c37401fdbc976edefdcc50
5
5
  SHA512:
6
- metadata.gz: 932dd4fdb2a79f8ebed405f7f46de5045f8c93404d1d3a21e969647f7628a5a10f7772963a99b73be53591ee4c52471223b988cb5eb988523f8e966826bfc516
7
- data.tar.gz: d117c6b67bc49fa12c85c63c74a89f92713f65c9f9e0182f2cd68e856cc5c28c365c279bb060c767ccad9f6cd0392973f571fc3a07df0cc4be330b118bca46c0
6
+ metadata.gz: 7e166d6b863e049ca5004ea431538b0e9b1db681ad8833789f52cba3049c5eef0cf45ff1a6e5719fd0cd250247e2a029c840ddc96d7ed0f0942b1ddc9a33f170
7
+ data.tar.gz: 02bb5c155c2b9528d7644ab9fcda43d83c8e1fdfeb407ad1fce673833a1a69a335ed4518327b17b67539d6b651f56d476f6bfae1bb991aa1ef800912b43e8e00
data/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.1.2] - 2026-10-04
8
+
9
+ ### Fixed
10
+
11
+ - Chat streams (v1 and v2) now arrive as they are generated. GigaChat delivers server-sent events incrementally
12
+ only over HTTP/2 and buffers the whole answer over HTTP/1.1, which is all Net::HTTP (Faraday's default
13
+ adapter) speaks, so every event used to arrive at once after the answer was complete.
14
+ - Stopping a stream early (breaking out of the block, or an exception from it) resets the connection, so
15
+ GigaChat stops generating.
16
+
17
+ ### Changed
18
+
19
+ - Chat streams use [httpx](https://gitlab.com/os85/httpx) over HTTP/2; every other request stays on Faraday.
20
+ New runtime dependency: `httpx ~> 1.8`. Proxies from `HTTP(S)_PROXY` are not applied to streams.
21
+
7
22
  ## [0.1.1] - 2026-10-02
8
23
 
9
24
  ### Added
data/README.md CHANGED
@@ -126,7 +126,7 @@ stream.text.each { print it } # text deltas only
126
126
  stream.response # accumulated completion
127
127
  ```
128
128
 
129
- Breaking out of the block closes the connection.
129
+ Streams run over HTTP/2 (through [httpx](https://gitlab.com/os85/httpx)): GigaChat delivers events as they are generated only over HTTP/2, while over HTTP/1.1 the whole answer arrives at once. Breaking out of the block (or raising from it) aborts the request and closes the connection, so GigaChat stops generating. Proxies from `HTTP(S)_PROXY` are not applied to streams.
130
130
 
131
131
  ### Function calling
132
132
 
@@ -55,26 +55,23 @@ module GigaChat
55
55
  def chat_v2_url = @transport.chat_v2_url
56
56
 
57
57
  # @api private
58
- # Streaming POST that yields raw SSE bytes and response headers. Retries and the 401 replay happen
59
- # only until the first byte reaches the caller, so output is never duplicated.
58
+ # Streaming POST (HTTP/2, see Internal::Transport#stream) that yields raw SSE bytes and response
59
+ # headers. Retries and the 401 replay happen only until the first byte reaches the caller, so output is
60
+ # never duplicated; exceptions from the block surface unchanged.
60
61
  def request_stream(path:, body:, request_options: {}, &on_chunk)
61
62
  options = check_request_options(request_options)
62
63
  headers = STREAM_HEADERS.merge(options[:headers] || {})
63
64
  payload = JSON.generate(body)
64
65
  delivered = false
65
66
  fresh = -> { !delivered }
66
- escape = Object.new
67
- failure = catch(escape) do
68
- return retry_policy(options).run(method: :post, retry_if: fresh) do
69
- authenticated(headers, replay: fresh) do |signed|
70
- stream_once(path, payload, signed, options) do |chunk, response_headers|
71
- delivered = true
72
- deliver(on_chunk, chunk, response_headers, escape)
73
- end
67
+ retry_policy(options).run(method: :post, retry_if: fresh) do
68
+ authenticated(headers, replay: fresh) do |signed|
69
+ stream_once(path, payload, signed, options) do |chunk, response_headers|
70
+ delivered = true
71
+ on_chunk.call(chunk, response_headers)
74
72
  end
75
73
  end
76
74
  end
77
- raise failure
78
75
  end
79
76
 
80
77
  # Low-level call with auth, retries and error mapping; also the escape hatch for undocumented endpoints.
@@ -149,47 +146,38 @@ module GigaChat
149
146
 
150
147
  def authorization(token) = token ? { "Authorization" => "Bearer #{token.access_token}" } : {}
151
148
 
152
- # With on_data set, Faraday leaves response.body empty, so error bodies are collected here.
149
+ # The head arrives before any body, so a non-2xx or non-SSE response is buffered and raised here, while
150
+ # the retry and the 401 replay may still resend the request.
153
151
  def stream_once(path, payload, headers, options)
154
- sse = nil
155
- seen = nil
152
+ status = nil
153
+ response_headers = nil
156
154
  error_body = +"".b
157
155
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
- on_data = proc do |chunk, _bytes, env|
159
- seen = env
160
- sse = env.status.between?(200, 299) && event_stream?(env.response_headers) if sse.nil?
161
- if sse
162
- yield chunk, env.response_headers
156
+ @transport.stream(path, body: payload, headers:, timeout: options[:timeout]) do |event, *args|
157
+ if event == :head
158
+ status, response_headers = args
159
+ elsif sse?(status, response_headers)
160
+ yield args.first, response_headers
163
161
  else
164
- error_body << chunk.b
162
+ error_body << args.first.b
165
163
  end
166
164
  end
167
- response = perform(method: :post, url: path, body: payload, headers:, options:, on_data:)
168
- seen = nil # perform has logged the request
169
- sse = response.success? && event_stream?(response.headers) if sse.nil?
170
- raise_stream_failure(response, error_body) unless sse
171
- response
165
+ raise_stream_failure(status, response_headers, error_body) unless sse?(status, response_headers)
172
166
  ensure
173
- # A finished stream stops with throw (or the caller breaks), which unwinds past perform's log line.
174
- log(:post, path, seen.status, seen.response_headers, started) if seen
167
+ # Logged here so a stream that stops early (the caller breaks or [DONE] ends it) is logged too.
168
+ log(:post, path, status, response_headers, started) if status
175
169
  end
176
170
 
177
- # Faraday's adapter rescues IOError and SystemCallError raised inside on_data and re-raises them as
178
- # ConnectionFailed. Caller exceptions are thrown past it so they surface unchanged and are never retried.
179
- def deliver(on_chunk, chunk, headers, escape)
180
- on_chunk.call(chunk, headers)
181
- rescue StandardError => e
182
- throw escape, e
183
- end
171
+ def sse?(status, headers) = status&.between?(200, 299) && event_stream?(headers)
184
172
 
185
173
  def event_stream?(headers) = headers["content-type"].to_s.start_with?("text/event-stream")
186
174
 
187
- def raise_stream_failure(response, error_body)
175
+ def raise_stream_failure(status, headers, error_body)
188
176
  body = Internal::Util.parse_json(error_body)
189
- raise APIError.for(status: response.status, body:, headers: response.headers) unless response.success?
177
+ raise APIError.for(status:, body:, headers:) unless status.between?(200, 299)
190
178
 
191
- raise APIError.new(status: response.status, body:, headers: response.headers,
192
- message: "Expected text/event-stream, got #{response.headers["content-type"].inspect}")
179
+ raise APIError.new(status:, body:, headers:,
180
+ message: "Expected text/event-stream, got #{headers["content-type"].inspect}")
193
181
  end
194
182
 
195
183
  def perform(method:, url:, headers:, options:, query: nil, body: nil, on_data: nil)
@@ -53,7 +53,7 @@ module GigaChat
53
53
  def retryable?(error, method)
54
54
  case error
55
55
  when APITimeoutError then method.to_s.casecmp?("get") # a timed-out POST may already be billed
56
- when APIConnectionError then error.cause.is_a?(Faraday::ConnectionFailed) # never retry TLS errors
56
+ when APIConnectionError then Transport.retryable_connection_error?(error.cause) # never retry TLS errors
57
57
  else RETRYABLE_STATUSES.include?(error.status)
58
58
  end
59
59
  end
@@ -2,13 +2,25 @@
2
2
 
3
3
  module GigaChat
4
4
  module Internal
5
- # Builds the Faraday connections (API and OAuth) with TLS settings and default headers.
5
+ # Builds the Faraday connections (API and OAuth) with TLS settings and default headers, and runs chat
6
+ # streams over HTTP/2 with httpx: GigaChat's gateway delivers server-sent events incrementally only
7
+ # over HTTP/2 and buffers the whole response over HTTP/1.1, the only version Net::HTTP speaks.
6
8
  class Transport
7
9
  CA_FILE = File.expand_path("../certs/russian_trusted_root_ca.pem", __dir__)
8
10
  USER_AGENT = "gigachat-ruby/#{VERSION} ruby/#{RUBY_VERSION}".freeze
9
11
  X_HEADERS = %w[x-request-id x-session-id x-client-id].freeze
10
12
  TLS_HINT = "GigaChat certificates chain to the Russian Trusted Root CA; " \
11
13
  "see the TLS section of the gigachat-ruby README"
14
+ # Failures before a connection carries the request; TLS errors are left out on purpose.
15
+ RETRYABLE_CONNECTION_ERRORS = [
16
+ Faraday::ConnectionFailed, HTTPX::ConnectionError, HTTPX::ResolveError, SocketError, SystemCallError, IOError
17
+ ].freeze
18
+
19
+ # Raised into the httpx fiber when the caller stops reading early. httpx's error path resets the
20
+ # connection, whereas unwinding past it closes HTTP/2 gracefully and leaves the stream (and GigaChat's
21
+ # generation) running.
22
+ class Aborted < StandardError; end
23
+ private_constant :Aborted
12
24
 
13
25
  def self.wrap_errors
14
26
  yield
@@ -20,6 +32,8 @@ module GigaChat
20
32
  raise APIConnectionError, e.message
21
33
  end
22
34
 
35
+ def self.retryable_connection_error?(error) = RETRYABLE_CONNECTION_ERRORS.any? { error.is_a?(it) }
36
+
23
37
  def self.x_headers(headers) = X_HEADERS.to_h { [it, headers[it]] }.compact
24
38
 
25
39
  @cert_stores = {}
@@ -65,8 +79,60 @@ module GigaChat
65
79
  @cert_store ||= self.class.cert_store(bundled_ca: config.bundled_ca, ca_bundle_file: config.ca_bundle_file)
66
80
  end
67
81
 
82
+ # POSTs over HTTP/2 and yields `:head, status, headers`, then `:chunk, bytes` for each body chunk;
83
+ # returns once the body is complete. httpx runs in its own fiber, so the block never runs inside
84
+ # httpx: an exception, break or throw from it aborts the request and resets the connection.
85
+ def stream(path, body:, headers:, timeout: nil)
86
+ fiber = stream_fiber(URI.join(base_url, path).to_s, body, headers, timeout || config.timeout)
87
+ finished = false
88
+ loop do
89
+ event, *args = fiber.resume
90
+ next yield(event, *args) unless event == :done
91
+
92
+ finished = true
93
+ response = args.first
94
+ raise_stream_error(response.error) if response.is_a?(HTTPX::ErrorResponse)
95
+ return response
96
+ end
97
+ ensure
98
+ abort_stream(fiber) unless finished
99
+ end
100
+
68
101
  private
69
102
 
103
+ def stream_session = @stream_session ||= HTTPX.plugin(:callbacks).with(ssl: stream_ssl, headers: default_headers)
104
+
105
+ # Callbacks go on a per-request branch: registering them on the shared session would leak them into
106
+ # every later request.
107
+ def stream_fiber(url, body, headers, timeout)
108
+ timeouts = { connect_timeout: config.open_timeout, read_timeout: timeout, write_timeout: timeout }
109
+ session = stream_session
110
+ .with(timeout: timeouts)
111
+ .on_response_started { |_request, response| Fiber.yield([:head, response.status, response.headers]) }
112
+ .on_response_body_chunk { |_request, _response, chunk| Fiber.yield([:chunk, chunk]) }
113
+ Fiber.new { [:done, session.post(url, body:, headers:)] }
114
+ end
115
+
116
+ def abort_stream(fiber)
117
+ fiber.raise(Aborted) if fiber&.alive?
118
+ rescue Aborted
119
+ nil
120
+ end
121
+
122
+ def raise_stream_error(error)
123
+ case error
124
+ when HTTPX::TimeoutError then raise APITimeoutError, error.message, cause: error
125
+ when OpenSSL::SSL::SSLError then raise APIConnectionError, "#{error.message}. #{TLS_HINT}", cause: error
126
+ else raise APIConnectionError, error.message, cause: error
127
+ end
128
+ end
129
+
130
+ def stream_ssl
131
+ options = ssl_options(true)
132
+ { verify_mode: options[:verify] ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE,
133
+ cert_store: options[:cert_store], cert: options[:client_cert], key: options[:client_key] }.compact
134
+ end
135
+
70
136
  def connection(url, client_cert: false)
71
137
  request = { timeout: config.timeout, open_timeout: config.open_timeout, write_timeout: config.timeout }
72
138
  Faraday.new(url:, headers: default_headers, ssl: ssl_options(client_cert), request:) do |f|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GigaChat
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/gigachat.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "faraday"
4
4
  require "faraday/multipart"
5
+ require "httpx"
5
6
  require "json"
6
7
  require "openssl"
7
8
  require "securerandom"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gigachat-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aleksandr Dryzhuk
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: httpx
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.8'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.8'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: zeitwerk
42
56
  requirement: !ruby/object:Gem::Requirement