gigachat-ruby 0.1.0
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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +303 -0
- data/lib/gigachat/auth/access_token.rb +18 -0
- data/lib/gigachat/auth/token_manager.rb +107 -0
- data/lib/gigachat/certs/russian_trusted_root_ca.pem +33 -0
- data/lib/gigachat/client.rb +242 -0
- data/lib/gigachat/configuration.rb +94 -0
- data/lib/gigachat/errors.rb +77 -0
- data/lib/gigachat/internal/chat_accumulator.rb +47 -0
- data/lib/gigachat/internal/mime_types.rb +23 -0
- data/lib/gigachat/internal/retry_policy.rb +67 -0
- data/lib/gigachat/internal/sse_decoder.rb +85 -0
- data/lib/gigachat/internal/transport.rb +99 -0
- data/lib/gigachat/internal/util.rb +21 -0
- data/lib/gigachat/internal/v1_chat_accumulator.rb +41 -0
- data/lib/gigachat/resources/base.rb +31 -0
- data/lib/gigachat/resources/batches.rb +55 -0
- data/lib/gigachat/resources/chat.rb +43 -0
- data/lib/gigachat/resources/chat_v1.rb +24 -0
- data/lib/gigachat/resources/embeddings.rb +13 -0
- data/lib/gigachat/resources/files.rb +55 -0
- data/lib/gigachat/resources/functions.rb +14 -0
- data/lib/gigachat/resources/models.rb +15 -0
- data/lib/gigachat/stream.rb +85 -0
- data/lib/gigachat/types/ai_check_result.rb +18 -0
- data/lib/gigachat/types/balance.rb +9 -0
- data/lib/gigachat/types/balance_entry.rb +10 -0
- data/lib/gigachat/types/base.rb +80 -0
- data/lib/gigachat/types/batch.rb +29 -0
- data/lib/gigachat/types/batch_list.rb +17 -0
- data/lib/gigachat/types/chat_completion.rb +33 -0
- data/lib/gigachat/types/chat_event.rb +28 -0
- data/lib/gigachat/types/content_part.rb +15 -0
- data/lib/gigachat/types/embedding.rb +12 -0
- data/lib/gigachat/types/embeddings.rb +13 -0
- data/lib/gigachat/types/file_deleted.rb +13 -0
- data/lib/gigachat/types/file_list.rb +9 -0
- data/lib/gigachat/types/file_object.rb +16 -0
- data/lib/gigachat/types/file_ref.rb +11 -0
- data/lib/gigachat/types/function_call.rb +22 -0
- data/lib/gigachat/types/function_validation.rb +20 -0
- data/lib/gigachat/types/message.rb +20 -0
- data/lib/gigachat/types/model.rb +12 -0
- data/lib/gigachat/types/model_list.rb +10 -0
- data/lib/gigachat/types/tokens_count.rb +11 -0
- data/lib/gigachat/types/tokens_count_list.rb +10 -0
- data/lib/gigachat/types/tool_execution.rb +12 -0
- data/lib/gigachat/types/usage.rb +12 -0
- data/lib/gigachat/types/v1/chat_completion.rb +18 -0
- data/lib/gigachat/types/v1/chat_completion_chunk.rb +18 -0
- data/lib/gigachat/types/v1/choice.rb +14 -0
- data/lib/gigachat/types/v1/message.rb +16 -0
- data/lib/gigachat/types/v1/usage.rb +14 -0
- data/lib/gigachat/version.rb +5 -0
- data/lib/gigachat-ruby.rb +4 -0
- data/lib/gigachat.rb +31 -0
- metadata +144 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
# Entry point to the GigaChat REST API. Thread-safe: share one instance across threads.
|
|
5
|
+
class Client
|
|
6
|
+
AUTH_OPTIONS = %i[credentials scope access_token user password auth_url base_url].freeze
|
|
7
|
+
REQUEST_OPTIONS = %i[timeout max_retries headers].freeze
|
|
8
|
+
STREAM_HEADERS = {
|
|
9
|
+
"Content-Type" => "application/json", "Accept" => "text/event-stream", "Cache-Control" => "no-store"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
def initialize(**options)
|
|
15
|
+
setup(Configuration.resolve(options))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A copy with some options changed. It reuses the cached access token unless an auth option changes.
|
|
19
|
+
def with_options(**overrides)
|
|
20
|
+
overrides = overrides.compact
|
|
21
|
+
config = Configuration.new(@config.to_h.merge(overrides))
|
|
22
|
+
token_manager = @token_manager unless overrides.keys.intersect?(AUTH_OPTIONS)
|
|
23
|
+
self.class.allocate.tap { it.send(:setup, config, token_manager:) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Forces authentication; nil when only mTLS is configured.
|
|
27
|
+
def token = @token_manager.token
|
|
28
|
+
|
|
29
|
+
def balance(request_options: {})
|
|
30
|
+
request(method: :get, path: "balance", type: Types::Balance, request_options:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def tokens_count(input:, model: nil, request_options: {})
|
|
34
|
+
body = { model: config.resolve_model(model), input: Array(input) }
|
|
35
|
+
request(method: :post, path: "tokens/count", body:, type: Types::TokensCountList, request_options:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ai_check(input:, model:, request_options: {})
|
|
39
|
+
request(method: :post, path: "ai/check", body: { input:, model: }, type: Types::AiCheckResult, request_options:)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def chat = @chat ||= Resources::Chat.new(self)
|
|
43
|
+
|
|
44
|
+
def embeddings = @embeddings ||= Resources::Embeddings.new(self)
|
|
45
|
+
|
|
46
|
+
def models = @models ||= Resources::Models.new(self)
|
|
47
|
+
|
|
48
|
+
def functions = @functions ||= Resources::Functions.new(self)
|
|
49
|
+
|
|
50
|
+
def files = @files ||= Resources::Files.new(self)
|
|
51
|
+
|
|
52
|
+
def batches = @batches ||= Resources::Batches.new(self)
|
|
53
|
+
|
|
54
|
+
# @api private
|
|
55
|
+
def chat_v2_url = @transport.chat_v2_url
|
|
56
|
+
|
|
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.
|
|
60
|
+
def request_stream(path:, body:, request_options: {}, &on_chunk)
|
|
61
|
+
options = check_request_options(request_options)
|
|
62
|
+
headers = STREAM_HEADERS.merge(options[:headers] || {})
|
|
63
|
+
payload = JSON.generate(body)
|
|
64
|
+
delivered = false
|
|
65
|
+
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
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
raise failure
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Low-level call with auth, retries and error mapping; also the escape hatch for undocumented endpoints.
|
|
81
|
+
# Hash/Array bodies are sent as JSON and `form:` as multipart. A JSON array response is wrapped as
|
|
82
|
+
# `{ data: [...] }` before building `type`; `binary: true` returns the raw body as a binary String.
|
|
83
|
+
def request(method:, path:, query: nil, body: nil, form: nil, headers: {}, type: nil, binary: false,
|
|
84
|
+
request_options: {})
|
|
85
|
+
options = check_request_options(request_options)
|
|
86
|
+
payload, request_headers = encode(body, form, headers.merge(options[:headers] || {}))
|
|
87
|
+
response = execute(method:, url: path, query:, body: payload, headers: request_headers, options:,
|
|
88
|
+
ios: upload_ios(form))
|
|
89
|
+
build_result(response, type, binary)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def inspect = "#<#{self.class.name} base_url=#{config.base_url.inspect} model=#{config.model.inspect}>"
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def setup(config, token_manager: nil)
|
|
97
|
+
@config = config
|
|
98
|
+
@transport = Internal::Transport.new(config)
|
|
99
|
+
@token_manager = token_manager || Auth::TokenManager.new(config, @transport)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def check_request_options(options)
|
|
103
|
+
unknown = options.keys - REQUEST_OPTIONS
|
|
104
|
+
raise ArgumentError, "Unknown request option(s): #{unknown.join(", ")}" if unknown.any?
|
|
105
|
+
|
|
106
|
+
options
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def encode(body, form, headers)
|
|
110
|
+
base = { "Accept" => "application/json" }
|
|
111
|
+
return [form, base.merge(headers)] if form
|
|
112
|
+
return [body, base.merge(headers)] unless body.is_a?(Hash) || body.is_a?(Array)
|
|
113
|
+
|
|
114
|
+
[JSON.generate(body), base.merge("Content-Type" => "application/json").merge(headers)]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def upload_ios(form)
|
|
118
|
+
return [] unless form
|
|
119
|
+
|
|
120
|
+
form.values.filter_map { it.io if it.respond_to?(:io) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Retry loop (outer) -> one 401 replay with a fresh token (inner) -> HTTP call.
|
|
124
|
+
def execute(method:, url:, query:, body:, headers:, options:, ios: [])
|
|
125
|
+
replayable = ios.all? { rewindable?(it) }
|
|
126
|
+
sends = 0
|
|
127
|
+
retry_policy(options).run(method:, replayable:) do
|
|
128
|
+
authenticated(headers, replay: -> { replayable }) do |signed|
|
|
129
|
+
ios.each(&:rewind) if (sends += 1) > 1
|
|
130
|
+
response = perform(method:, url:, query:, body:, headers: signed, options:)
|
|
131
|
+
next response if response.success?
|
|
132
|
+
|
|
133
|
+
raise APIError.for(status: response.status, body: Internal::Util.parse_json(response.body),
|
|
134
|
+
headers: response.headers)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Request headers merge after the token so a per-request Authorization wins (spec §4).
|
|
140
|
+
def authenticated(headers, replay:)
|
|
141
|
+
token = @token_manager.token
|
|
142
|
+
yield authorization(token).merge(headers)
|
|
143
|
+
rescue AuthenticationError => e
|
|
144
|
+
raise unless e.status == 401 && token && @token_manager.refreshable? && replay.call
|
|
145
|
+
|
|
146
|
+
@token_manager.invalidate!(token)
|
|
147
|
+
yield authorization(@token_manager.token).merge(headers)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def authorization(token) = token ? { "Authorization" => "Bearer #{token.access_token}" } : {}
|
|
151
|
+
|
|
152
|
+
# With on_data set, Faraday leaves response.body empty, so error bodies are collected here.
|
|
153
|
+
def stream_once(path, payload, headers, options)
|
|
154
|
+
sse = nil
|
|
155
|
+
seen = nil
|
|
156
|
+
error_body = +"".b
|
|
157
|
+
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
|
|
163
|
+
else
|
|
164
|
+
error_body << chunk.b
|
|
165
|
+
end
|
|
166
|
+
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
|
|
172
|
+
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
|
|
175
|
+
end
|
|
176
|
+
|
|
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
|
|
184
|
+
|
|
185
|
+
def event_stream?(headers) = headers["content-type"].to_s.start_with?("text/event-stream")
|
|
186
|
+
|
|
187
|
+
def raise_stream_failure(response, error_body)
|
|
188
|
+
body = Internal::Util.parse_json(error_body)
|
|
189
|
+
raise APIError.for(status: response.status, body:, headers: response.headers) unless response.success?
|
|
190
|
+
|
|
191
|
+
raise APIError.new(status: response.status, body:, headers: response.headers,
|
|
192
|
+
message: "Expected text/event-stream, got #{response.headers["content-type"].inspect}")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def perform(method:, url:, headers:, options:, query: nil, body: nil, on_data: nil)
|
|
196
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
197
|
+
response = Internal::Transport.wrap_errors do
|
|
198
|
+
@transport.api.run_request(method, url, body, headers) do |req|
|
|
199
|
+
query&.each { |key, value| req.params[key.to_s] = value }
|
|
200
|
+
req.options.timeout = options[:timeout] if options[:timeout]
|
|
201
|
+
req.options.on_data = on_data if on_data
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
log(method, url, response.status, response.headers, started)
|
|
205
|
+
response
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def build_result(response, type, binary)
|
|
209
|
+
return response.body.to_s.b if binary
|
|
210
|
+
|
|
211
|
+
parsed = Internal::Util.parse_json(response.body)
|
|
212
|
+
return parsed unless type
|
|
213
|
+
|
|
214
|
+
parsed = { data: parsed } if parsed.is_a?(Array)
|
|
215
|
+
unless parsed.is_a?(Hash)
|
|
216
|
+
raise APIError.new(status: response.status, body: parsed, headers: response.headers,
|
|
217
|
+
message: "Unexpected non-JSON response")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
type.new(parsed, x_headers: Internal::Transport.x_headers(response.headers))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def retry_policy(options)
|
|
224
|
+
Internal::RetryPolicy.new(max_retries: options.fetch(:max_retries, config.max_retries), logger: config.logger)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Pipes and sockets cannot seek back, so a retry would resend a half-read body.
|
|
228
|
+
def rewindable?(io)
|
|
229
|
+
io.respond_to?(:rewind) && io.respond_to?(:pos) && io.pos.is_a?(Integer)
|
|
230
|
+
rescue IOError, SystemCallError
|
|
231
|
+
false
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def log(method, url, status, headers, started)
|
|
235
|
+
return unless config.logger
|
|
236
|
+
|
|
237
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
238
|
+
config.logger.info("GigaChat: #{method.to_s.upcase} #{url} #{status} #{elapsed}ms " \
|
|
239
|
+
"req=#{headers["x-request-id"]}")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
# Client settings. `GigaChat.config` holds global overrides; `Configuration.resolve` builds a client's
|
|
5
|
+
# effective settings with precedence: explicit options > global config > GIGACHAT_* env > defaults.
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULTS = {
|
|
8
|
+
credentials: nil, scope: "GIGACHAT_API_PERS", access_token: nil, user: nil, password: nil,
|
|
9
|
+
base_url: "https://api.giga.chat/v1", auth_url: "https://ngw.devices.sberbank.ru:9443/api/v2/oauth",
|
|
10
|
+
model: nil, timeout: 60, open_timeout: 10, max_retries: 2,
|
|
11
|
+
verify_ssl_certs: true, bundled_ca: true, ca_bundle_file: nil,
|
|
12
|
+
cert_file: nil, key_file: nil, key_file_password: nil,
|
|
13
|
+
client_id: nil, session_id: nil, logger: nil
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# Same names as the Python SDK, so one .env serves both.
|
|
17
|
+
ENV_VARS = {
|
|
18
|
+
credentials: "GIGACHAT_CREDENTIALS", scope: "GIGACHAT_SCOPE", access_token: "GIGACHAT_ACCESS_TOKEN",
|
|
19
|
+
user: "GIGACHAT_USER", password: "GIGACHAT_PASSWORD", base_url: "GIGACHAT_BASE_URL",
|
|
20
|
+
auth_url: "GIGACHAT_AUTH_URL", model: "GIGACHAT_MODEL", timeout: "GIGACHAT_TIMEOUT",
|
|
21
|
+
max_retries: "GIGACHAT_MAX_RETRIES", verify_ssl_certs: "GIGACHAT_VERIFY_SSL_CERTS",
|
|
22
|
+
ca_bundle_file: "GIGACHAT_CA_BUNDLE_FILE", cert_file: "GIGACHAT_CERT_FILE",
|
|
23
|
+
key_file: "GIGACHAT_KEY_FILE", key_file_password: "GIGACHAT_KEY_FILE_PASSWORD"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
SECRETS = %i[credentials access_token password key_file_password].freeze
|
|
27
|
+
BOOLEANS = {
|
|
28
|
+
"true" => true, "1" => true, "yes" => true, "on" => true,
|
|
29
|
+
"false" => false, "0" => false, "no" => false, "off" => false
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
DEFAULTS.each_key do |key|
|
|
33
|
+
define_method(key) { @values[key] }
|
|
34
|
+
define_method(:"#{key}=") { |value| @values[key] = value }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.resolve(options = {}, global: GigaChat.config, env: ENV)
|
|
38
|
+
explicit = options.compact
|
|
39
|
+
config = new(explicit)
|
|
40
|
+
DEFAULTS.each do |key, default|
|
|
41
|
+
next if explicit.key?(key)
|
|
42
|
+
|
|
43
|
+
value = global.set?(key) ? global.public_send(key) : from_env(key, env, default)
|
|
44
|
+
config.public_send(:"#{key}=", value)
|
|
45
|
+
end
|
|
46
|
+
config
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.from_env(key, env, default)
|
|
50
|
+
raw = ENV_VARS[key] && env[ENV_VARS[key]]
|
|
51
|
+
return default if raw.nil? || raw.strip.empty?
|
|
52
|
+
|
|
53
|
+
cast(key, raw)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.cast(key, raw)
|
|
57
|
+
case key
|
|
58
|
+
when :verify_ssl_certs then BOOLEANS.fetch(raw.strip.downcase) { invalid!(key, raw) }
|
|
59
|
+
when :max_retries then Integer(raw.strip, 10) # base 10: "010" is ten, not octal eight
|
|
60
|
+
when :timeout then Float(raw.strip)
|
|
61
|
+
else raw
|
|
62
|
+
end
|
|
63
|
+
rescue ArgumentError
|
|
64
|
+
invalid!(key, raw)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.invalid!(key, raw)
|
|
68
|
+
raise ConfigurationError, "Invalid value for #{ENV_VARS[key]}: #{raw.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private_class_method :from_env, :cast, :invalid!
|
|
72
|
+
|
|
73
|
+
def initialize(values = {})
|
|
74
|
+
unknown = values.keys - DEFAULTS.keys
|
|
75
|
+
raise ConfigurationError, "Unknown option(s): #{unknown.join(", ")}" if unknown.any?
|
|
76
|
+
|
|
77
|
+
@values = values.dup
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# nil means "not set", as for keyword options: `c.base_url = ENV["PROXY_URL"]` must not wipe the default.
|
|
81
|
+
def set?(key) = !@values[key].nil?
|
|
82
|
+
|
|
83
|
+
def to_h = @values.dup
|
|
84
|
+
|
|
85
|
+
def resolve_model(model)
|
|
86
|
+
model || self.model || raise(ModelNotSpecifiedError, "Pass model: or set a default model (GIGACHAT_MODEL)")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def inspect
|
|
90
|
+
pairs = @values.map { |key, value| "#{key}=#{SECRETS.include?(key) && value ? "[FILTERED]" : value.inspect}" }
|
|
91
|
+
"#<#{self.class.name} #{pairs.join(", ")}>"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
|
|
8
|
+
class ModelNotSpecifiedError < Error; end
|
|
9
|
+
|
|
10
|
+
class APIConnectionError < Error
|
|
11
|
+
def initialize(message = "Connection error") = super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class APITimeoutError < APIConnectionError
|
|
15
|
+
def initialize(message = "Request timed out") = super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Non-2xx response. `body` is parsed JSON (symbol keys) when possible, otherwise the raw text.
|
|
19
|
+
class APIError < Error
|
|
20
|
+
attr_reader :status, :body, :headers
|
|
21
|
+
|
|
22
|
+
def self.for(status:, body: nil, headers: {})
|
|
23
|
+
error_class(status).new(status:, body:, headers:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.error_class(status)
|
|
27
|
+
return ServerError if status >= 500
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
400 => BadRequestError, 401 => AuthenticationError, 403 => PermissionDeniedError,
|
|
31
|
+
404 => NotFoundError, 413 => RequestEntityTooLargeError, 422 => UnprocessableEntityError,
|
|
32
|
+
429 => RateLimitError
|
|
33
|
+
}.fetch(status, APIError)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(status:, body: nil, headers: {}, message: nil)
|
|
37
|
+
@status = status
|
|
38
|
+
@body = body
|
|
39
|
+
@headers = headers || {}
|
|
40
|
+
super(message || default_message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def request_id = headers["x-request-id"]
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def default_message
|
|
48
|
+
request = "(request_id: #{request_id})" if request_id
|
|
49
|
+
[status, detail, request].reject { it.nil? || it.to_s.empty? }.join(" ")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The documented `message`, else the body itself (compact JSON or text), squashed to one short line.
|
|
53
|
+
def detail
|
|
54
|
+
text = body.is_a?(Hash) ? body[:message] || JSON.generate(body) : body.to_s
|
|
55
|
+
text.to_s.strip.gsub(/\s+/, " ")[0, 200]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class BadRequestError < APIError; end
|
|
60
|
+
|
|
61
|
+
class AuthenticationError < APIError; end
|
|
62
|
+
|
|
63
|
+
class PermissionDeniedError < APIError; end
|
|
64
|
+
|
|
65
|
+
class NotFoundError < APIError; end
|
|
66
|
+
|
|
67
|
+
class RequestEntityTooLargeError < APIError; end
|
|
68
|
+
|
|
69
|
+
class UnprocessableEntityError < APIError; end
|
|
70
|
+
|
|
71
|
+
class RateLimitError < APIError
|
|
72
|
+
# Seconds the server asked us to wait (Retry-After), or nil.
|
|
73
|
+
def retry_after = Internal::RetryPolicy.retry_after(headers)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class ServerError < APIError; end
|
|
77
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Internal
|
|
5
|
+
# Folds v2 stream events into one ChatCompletion: text parts are concatenated per message position,
|
|
6
|
+
# other parts (function calls, files, ...) are appended, and the last non-nil metadata wins.
|
|
7
|
+
class ChatAccumulator
|
|
8
|
+
META = %i[model created_at created thread_id message_id finish_reason usage additional_data].freeze
|
|
9
|
+
# Per-message fields; both state id spellings are kept as sent (see Types::Message#tools_state_id).
|
|
10
|
+
MESSAGE_KEYS = %i[role message_id tool_state_id tools_state_id].freeze
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@meta = {}
|
|
14
|
+
@messages = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def <<(event)
|
|
18
|
+
return self if event.tool_in_progress? # progress ticks, not content
|
|
19
|
+
|
|
20
|
+
@meta.merge!(event.to_h.slice(*META).compact)
|
|
21
|
+
(event.messages || []).each_with_index { |message, index| merge_message(index, message) }
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def result(x_headers: {})
|
|
26
|
+
Types::ChatCompletion.new({ **@meta, messages: @messages.compact.map { finalize(it) } }, x_headers:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def merge_message(index, message)
|
|
32
|
+
entry = @messages[index] ||= { text: +"", parts: [] }
|
|
33
|
+
MESSAGE_KEYS.each { |key| entry[key] = message[key] if message[key] }
|
|
34
|
+
(message.content || []).each do |part|
|
|
35
|
+
entry[:text] << part.text if part.text
|
|
36
|
+
rest = part.to_h.except(:text)
|
|
37
|
+
entry[:parts] << rest unless rest.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def finalize(entry)
|
|
42
|
+
content = entry[:text].empty? ? entry[:parts] : [{ text: entry[:text] }, *entry[:parts]]
|
|
43
|
+
entry.slice(*MESSAGE_KEYS).merge(content:)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Internal
|
|
5
|
+
# Upload MIME types exactly as the spec's file-storage table lists them (`xlsx` really is
|
|
6
|
+
# application/vnd.ms-excel there). `jpg` and `tif` are aliases the table does not spell out.
|
|
7
|
+
module MimeTypes
|
|
8
|
+
TYPES = {
|
|
9
|
+
"txt" => "text/plain", "doc" => "application/msword",
|
|
10
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
11
|
+
"pdf" => "application/pdf", "epub" => "application/epub", "ppt" => "application/ppt",
|
|
12
|
+
"pptx" => "application/pptx", "xlsx" => "application/vnd.ms-excel",
|
|
13
|
+
"jpeg" => "image/jpeg", "jpg" => "image/jpeg", "png" => "image/png", "tiff" => "image/tiff",
|
|
14
|
+
"tif" => "image/tiff", "bmp" => "image/bmp",
|
|
15
|
+
"mp4" => "audio/mp4", "mp3" => "audio/mp3", "m4a" => "audio/x-m4a", "wav" => "audio/x-wav",
|
|
16
|
+
"weba" => "audio/webm", "ogg" => "audio/x-ogg", "opus" => "audio/opus"
|
|
17
|
+
}.freeze
|
|
18
|
+
DEFAULT = "application/octet-stream"
|
|
19
|
+
|
|
20
|
+
def self.for(filename) = TYPES.fetch(File.extname(filename.to_s).delete_prefix(".").downcase, DEFAULT)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Internal
|
|
5
|
+
# Decides whether a failed request is retried and how long to wait first.
|
|
6
|
+
class RetryPolicy
|
|
7
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
8
|
+
INITIAL_DELAY = 0.5
|
|
9
|
+
MAX_DELAY = 8.0
|
|
10
|
+
MAX_RETRY_AFTER = 60.0
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Injectable so tests can record delays instead of sleeping.
|
|
14
|
+
attr_writer :sleeper
|
|
15
|
+
|
|
16
|
+
def sleeper = @sleeper ||= ->(seconds) { Kernel.sleep(seconds) }
|
|
17
|
+
|
|
18
|
+
# Seconds from a Retry-After header (delta-seconds or HTTP-date), capped; nil when absent or invalid.
|
|
19
|
+
def retry_after(headers)
|
|
20
|
+
value = headers && headers["retry-after"]
|
|
21
|
+
return if value.nil? || value.to_s.strip.empty?
|
|
22
|
+
|
|
23
|
+
seconds = Float(value, exception: false) || (Time.httpdate(value.to_s) - Time.now)
|
|
24
|
+
seconds.clamp(0.0, MAX_RETRY_AFTER)
|
|
25
|
+
rescue ArgumentError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(max_retries:, logger: nil)
|
|
31
|
+
@max_retries = max_retries.to_i
|
|
32
|
+
@logger = logger
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run(method:, replayable: true, retry_if: nil)
|
|
36
|
+
attempt = 0
|
|
37
|
+
begin
|
|
38
|
+
yield
|
|
39
|
+
rescue APIError, APIConnectionError => e
|
|
40
|
+
raise unless attempt < @max_retries && replayable && (retry_if.nil? || retry_if.call)
|
|
41
|
+
raise unless retryable?(e, method)
|
|
42
|
+
|
|
43
|
+
delay = delay_for(e, attempt)
|
|
44
|
+
@logger&.debug("GigaChat: retry #{attempt + 1}/#{@max_retries} in #{delay.round(2)}s after #{e.class}")
|
|
45
|
+
self.class.sleeper.call(delay)
|
|
46
|
+
attempt += 1
|
|
47
|
+
retry
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def retryable?(error, method)
|
|
54
|
+
case error
|
|
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
|
|
57
|
+
else RETRYABLE_STATUSES.include?(error.status)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def delay_for(error, attempt)
|
|
62
|
+
server = self.class.retry_after(error.headers) if error.is_a?(APIError)
|
|
63
|
+
server || ((INITIAL_DELAY * (2**attempt)).clamp(0.0, MAX_DELAY) * (1 - (0.25 * rand)))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Internal
|
|
5
|
+
# Incremental Server-Sent Events parser (WHATWG). It buffers raw bytes and decodes UTF-8 only on
|
|
6
|
+
# complete lines, so Cyrillic split across TCP chunks survives.
|
|
7
|
+
class SSEDecoder
|
|
8
|
+
Event = Data.define(:event, :data, :id)
|
|
9
|
+
|
|
10
|
+
BOM = "\xEF\xBB\xBF".b.freeze
|
|
11
|
+
CR = 13
|
|
12
|
+
LF = 10
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@buffer = +"".b
|
|
16
|
+
@data = []
|
|
17
|
+
@event = nil
|
|
18
|
+
@id = nil
|
|
19
|
+
@bom_checked = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def feed(chunk, &)
|
|
23
|
+
@buffer << chunk.b
|
|
24
|
+
strip_bom
|
|
25
|
+
while (line = next_line(final: false))
|
|
26
|
+
process(line, &)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def finish(&)
|
|
31
|
+
while (line = next_line(final: true))
|
|
32
|
+
process(line, &)
|
|
33
|
+
end
|
|
34
|
+
process(utf8(@buffer), &) unless @buffer.empty?
|
|
35
|
+
@buffer = +"".b
|
|
36
|
+
dispatch(&)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def strip_bom
|
|
42
|
+
return if @bom_checked || @buffer.bytesize < BOM.bytesize
|
|
43
|
+
|
|
44
|
+
@buffer = @buffer.byteslice(BOM.bytesize..) if @buffer.start_with?(BOM)
|
|
45
|
+
@bom_checked = true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A lone trailing "\r" may be the first half of "\r\n", so wait for more bytes unless finishing.
|
|
49
|
+
def next_line(final:)
|
|
50
|
+
return unless (index = @buffer.index(/[\r\n]/n))
|
|
51
|
+
|
|
52
|
+
width = 1
|
|
53
|
+
if @buffer.getbyte(index) == CR
|
|
54
|
+
return if index == @buffer.bytesize - 1 && !final
|
|
55
|
+
|
|
56
|
+
width = 2 if @buffer.getbyte(index + 1) == LF
|
|
57
|
+
end
|
|
58
|
+
line = @buffer.byteslice(0, index)
|
|
59
|
+
@buffer = @buffer.byteslice((index + width)..)
|
|
60
|
+
utf8(line)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def utf8(bytes) = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
64
|
+
|
|
65
|
+
def process(line, &)
|
|
66
|
+
return dispatch(&) if line.empty?
|
|
67
|
+
return if line.start_with?(":")
|
|
68
|
+
|
|
69
|
+
field, value = line.split(":", 2)
|
|
70
|
+
value = value.to_s.delete_prefix(" ")
|
|
71
|
+
case field
|
|
72
|
+
when "event" then @event = value
|
|
73
|
+
when "data" then @data << value
|
|
74
|
+
when "id" then @id = value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def dispatch
|
|
79
|
+
yield Event.new(event: @event, data: @data.join("\n"), id: @id) unless @data.empty?
|
|
80
|
+
@event = nil
|
|
81
|
+
@data = []
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|