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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +303 -0
  5. data/lib/gigachat/auth/access_token.rb +18 -0
  6. data/lib/gigachat/auth/token_manager.rb +107 -0
  7. data/lib/gigachat/certs/russian_trusted_root_ca.pem +33 -0
  8. data/lib/gigachat/client.rb +242 -0
  9. data/lib/gigachat/configuration.rb +94 -0
  10. data/lib/gigachat/errors.rb +77 -0
  11. data/lib/gigachat/internal/chat_accumulator.rb +47 -0
  12. data/lib/gigachat/internal/mime_types.rb +23 -0
  13. data/lib/gigachat/internal/retry_policy.rb +67 -0
  14. data/lib/gigachat/internal/sse_decoder.rb +85 -0
  15. data/lib/gigachat/internal/transport.rb +99 -0
  16. data/lib/gigachat/internal/util.rb +21 -0
  17. data/lib/gigachat/internal/v1_chat_accumulator.rb +41 -0
  18. data/lib/gigachat/resources/base.rb +31 -0
  19. data/lib/gigachat/resources/batches.rb +55 -0
  20. data/lib/gigachat/resources/chat.rb +43 -0
  21. data/lib/gigachat/resources/chat_v1.rb +24 -0
  22. data/lib/gigachat/resources/embeddings.rb +13 -0
  23. data/lib/gigachat/resources/files.rb +55 -0
  24. data/lib/gigachat/resources/functions.rb +14 -0
  25. data/lib/gigachat/resources/models.rb +15 -0
  26. data/lib/gigachat/stream.rb +85 -0
  27. data/lib/gigachat/types/ai_check_result.rb +18 -0
  28. data/lib/gigachat/types/balance.rb +9 -0
  29. data/lib/gigachat/types/balance_entry.rb +10 -0
  30. data/lib/gigachat/types/base.rb +80 -0
  31. data/lib/gigachat/types/batch.rb +29 -0
  32. data/lib/gigachat/types/batch_list.rb +17 -0
  33. data/lib/gigachat/types/chat_completion.rb +33 -0
  34. data/lib/gigachat/types/chat_event.rb +28 -0
  35. data/lib/gigachat/types/content_part.rb +15 -0
  36. data/lib/gigachat/types/embedding.rb +12 -0
  37. data/lib/gigachat/types/embeddings.rb +13 -0
  38. data/lib/gigachat/types/file_deleted.rb +13 -0
  39. data/lib/gigachat/types/file_list.rb +9 -0
  40. data/lib/gigachat/types/file_object.rb +16 -0
  41. data/lib/gigachat/types/file_ref.rb +11 -0
  42. data/lib/gigachat/types/function_call.rb +22 -0
  43. data/lib/gigachat/types/function_validation.rb +20 -0
  44. data/lib/gigachat/types/message.rb +20 -0
  45. data/lib/gigachat/types/model.rb +12 -0
  46. data/lib/gigachat/types/model_list.rb +10 -0
  47. data/lib/gigachat/types/tokens_count.rb +11 -0
  48. data/lib/gigachat/types/tokens_count_list.rb +10 -0
  49. data/lib/gigachat/types/tool_execution.rb +12 -0
  50. data/lib/gigachat/types/usage.rb +12 -0
  51. data/lib/gigachat/types/v1/chat_completion.rb +18 -0
  52. data/lib/gigachat/types/v1/chat_completion_chunk.rb +18 -0
  53. data/lib/gigachat/types/v1/choice.rb +14 -0
  54. data/lib/gigachat/types/v1/message.rb +16 -0
  55. data/lib/gigachat/types/v1/usage.rb +14 -0
  56. data/lib/gigachat/version.rb +5 -0
  57. data/lib/gigachat-ruby.rb +4 -0
  58. data/lib/gigachat.rb +31 -0
  59. 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