gigachat-ruby 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/CHANGELOG.md +22 -0
- data/README.md +2 -1
- data/lib/gigachat/client.rb +26 -38
- data/lib/gigachat/errors.rb +6 -3
- data/lib/gigachat/internal/retry_policy.rb +1 -1
- data/lib/gigachat/internal/transport.rb +67 -1
- data/lib/gigachat/version.rb +1 -1
- data/lib/gigachat.rb +1 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd0ada64b65b7605a1c98a61e4b3615e3570c1614b9b528a0aafb84549134cfa
|
|
4
|
+
data.tar.gz: 3262d0b82317e3306d009cd2f2f5315a7cbe1aa2e8c37401fdbc976edefdcc50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e166d6b863e049ca5004ea431538b0e9b1db681ad8833789f52cba3049c5eef0cf45ff1a6e5719fd0cd250247e2a029c840ddc96d7ed0f0942b1ddc9a33f170
|
|
7
|
+
data.tar.gz: 02bb5c155c2b9528d7644ab9fcda43d83c8e1fdfeb407ad1fce673833a1a69a335ed4518327b17b67539d6b651f56d476f6bfae1bb991aa1ef800912b43e8e00
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ 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
|
+
|
|
22
|
+
## [0.1.1] - 2026-10-02
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `GigaChat::PaymentRequiredError` for HTTP 402 (token balance exhausted). The OpenAPI spec doesn't list
|
|
27
|
+
this status, but the API returns it in practice.
|
|
28
|
+
|
|
7
29
|
## [0.1.0] - 2026-10-02
|
|
8
30
|
|
|
9
31
|
### 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
|
|
|
@@ -248,6 +248,7 @@ The API's `method` field is exposed as `batch_method`, because a `method` reader
|
|
|
248
248
|
|---|---|
|
|
249
249
|
| 400 | `GigaChat::BadRequestError` |
|
|
250
250
|
| 401 | `GigaChat::AuthenticationError` |
|
|
251
|
+
| 402 | `GigaChat::PaymentRequiredError` (token balance exhausted) |
|
|
251
252
|
| 403 | `GigaChat::PermissionDeniedError` |
|
|
252
253
|
| 404 | `GigaChat::NotFoundError` |
|
|
253
254
|
| 413 | `GigaChat::RequestEntityTooLargeError` |
|
data/lib/gigachat/client.rb
CHANGED
|
@@ -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
|
|
59
|
-
# only until the first byte reaches the caller, so output is
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
155
|
-
|
|
152
|
+
status = nil
|
|
153
|
+
response_headers = nil
|
|
156
154
|
error_body = +"".b
|
|
157
155
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
yield
|
|
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 <<
|
|
162
|
+
error_body << args.first.b
|
|
165
163
|
end
|
|
166
164
|
end
|
|
167
|
-
|
|
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
|
-
#
|
|
174
|
-
log(:post, path,
|
|
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
|
-
|
|
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(
|
|
175
|
+
def raise_stream_failure(status, headers, error_body)
|
|
188
176
|
body = Internal::Util.parse_json(error_body)
|
|
189
|
-
raise APIError.for(status
|
|
177
|
+
raise APIError.for(status:, body:, headers:) unless status.between?(200, 299)
|
|
190
178
|
|
|
191
|
-
raise APIError.new(status
|
|
192
|
-
message: "Expected text/event-stream, got #{
|
|
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)
|
data/lib/gigachat/errors.rb
CHANGED
|
@@ -27,9 +27,9 @@ module GigaChat
|
|
|
27
27
|
return ServerError if status >= 500
|
|
28
28
|
|
|
29
29
|
{
|
|
30
|
-
400 => BadRequestError, 401 => AuthenticationError,
|
|
31
|
-
|
|
32
|
-
429 => RateLimitError
|
|
30
|
+
400 => BadRequestError, 401 => AuthenticationError, 402 => PaymentRequiredError,
|
|
31
|
+
403 => PermissionDeniedError, 404 => NotFoundError, 413 => RequestEntityTooLargeError,
|
|
32
|
+
422 => UnprocessableEntityError, 429 => RateLimitError
|
|
33
33
|
}.fetch(status, APIError)
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -60,6 +60,9 @@ module GigaChat
|
|
|
60
60
|
|
|
61
61
|
class AuthenticationError < APIError; end
|
|
62
62
|
|
|
63
|
+
# 402: the token balance is exhausted. Not in the OpenAPI spec, but returned in practice.
|
|
64
|
+
class PaymentRequiredError < APIError; end
|
|
65
|
+
|
|
63
66
|
class PermissionDeniedError < APIError; end
|
|
64
67
|
|
|
65
68
|
class NotFoundError < APIError; end
|
|
@@ -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
|
|
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|
|
data/lib/gigachat/version.rb
CHANGED
data/lib/gigachat.rb
CHANGED
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.
|
|
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
|