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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2b0549b3d0c559ef154e36cfaa3fed6592068cdfbcf50e5fa29bda9d9167d8e2
4
+ data.tar.gz: 4264f7de916a13286744a74e2a8dec78c7e1b766dfcc0237d377352094b8ee34
5
+ SHA512:
6
+ metadata.gz: 51bb690904f272e089051596a55d8d2f6cf558e96c5c9082cf10065a2c5c7bdef069602c556e6a37c507e5b8c6702f0a370dab263c8800eb5b9c94aa4212098d
7
+ data.tar.gz: f27d1bfa89ed131c70f46633927fbd90e446f72c183675487cdf52eb841e003dbff825ebdf899bc69b19330d1b5de86e2bbfdcfa17d7cd6c473d3ebc27f00262
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-10-02
8
+
9
+ ### Added
10
+
11
+ - `GigaChat::Client`:
12
+ - authentication by OAuth (credentials + scope), static token, user/password or mTLS;
13
+ - thread-safe token caching with proactive refresh and a single replay on 401.
14
+ - Chat v2 (`client.chat.create` / `client.chat.stream`) and v1 (`client.chat.v1`), with SSE streaming,
15
+ typed events and accumulated responses.
16
+ - Embeddings, models, files (upload/list/retrieve/delete/content), token counting, balance, AI-text detection,
17
+ function validation, and batches (create/list/retrieve/results).
18
+ - Lenient typed response objects:
19
+ - unknown fields are kept;
20
+ - `request_id` is exposed;
21
+ - pattern matching is supported.
22
+ - Retries with exponential backoff and `Retry-After` support; a typed error hierarchy.
23
+ - The bundled Russian Trusted Root CA, added to a per-client certificate store.
24
+ - An opt-in live smoke suite (`rake test:live`).
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aleksandr Dryzhuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,303 @@
1
+ # gigachat-ruby
2
+
3
+ Ruby client for Sber's [GigaChat REST API](https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/gigachat-api).
4
+ It covers chat completions (v2 and v1) with streaming, embeddings, files, batches, token counting,
5
+ AI-text detection and function validation.
6
+
7
+ - Plain Ruby (4.0+). No Rails required.
8
+ - Automatic OAuth token management, retries with backoff, and typed errors.
9
+ - Works out of the box with GigaChat's TLS certificates (see [TLS](#tls)).
10
+
11
+ ## Installation
12
+
13
+ ```ruby
14
+ # Gemfile
15
+ gem "gigachat-ruby"
16
+ ```
17
+
18
+ ```ruby
19
+ require "gigachat"
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```ruby
25
+ client = GigaChat::Client.new(credentials: ENV["GIGACHAT_CREDENTIALS"], model: "GigaChat-2-Max")
26
+
27
+ completion = client.chat.create(messages: [{ role: "user", content: "Привет! Расскажи о себе." }])
28
+ puts completion.text
29
+ ```
30
+
31
+ `credentials` is the base64 **Authorization key** from your GigaChat Studio project. The client exchanges it for
32
+ a 30-minute access token, caches the token, and refreshes it before it expires.
33
+
34
+ ## Configuration
35
+
36
+ Options are resolved in this order: keyword arguments, then `GigaChat.configure`, then `GIGACHAT_*` environment
37
+ variables, then defaults.
38
+
39
+ ```ruby
40
+ GigaChat.configure do |c|
41
+ c.scope = "GIGACHAT_API_CORP"
42
+ c.model = "GigaChat-2-Max"
43
+ end
44
+
45
+ client = GigaChat::Client.new # picks up the global config and GIGACHAT_* variables
46
+ ```
47
+
48
+ | Option | Env var | Default |
49
+ |---|---|---|
50
+ | `credentials` | `GIGACHAT_CREDENTIALS` | — |
51
+ | `scope` | `GIGACHAT_SCOPE` | `GIGACHAT_API_PERS` (also `GIGACHAT_API_B2B`, `GIGACHAT_API_CORP`) |
52
+ | `access_token` | `GIGACHAT_ACCESS_TOKEN` | — (a pre-obtained token) |
53
+ | `user`, `password` | `GIGACHAT_USER`, `GIGACHAT_PASSWORD` | — (the `/token` flow; needs a `base_url` that serves it) |
54
+ | `base_url` | `GIGACHAT_BASE_URL` | `https://api.giga.chat/v1` |
55
+ | `auth_url` | `GIGACHAT_AUTH_URL` | `https://ngw.devices.sberbank.ru:9443/api/v2/oauth` |
56
+ | `model` | `GIGACHAT_MODEL` | — (required for chat and token counting) |
57
+ | `timeout` / `open_timeout` | `GIGACHAT_TIMEOUT` / — | `60` / `10` seconds |
58
+ | `max_retries` | `GIGACHAT_MAX_RETRIES` | `2` |
59
+ | `verify_ssl_certs` | `GIGACHAT_VERIFY_SSL_CERTS` | `true` |
60
+ | `bundled_ca` | — | `true` |
61
+ | `ca_bundle_file` | `GIGACHAT_CA_BUNDLE_FILE` | — |
62
+ | `cert_file`, `key_file`, `key_file_password` | same names, `GIGACHAT_` prefix | — (mTLS) |
63
+ | `client_id`, `session_id` | — | — (sent as `X-Client-ID` / `X-Session-ID`) |
64
+ | `logger` | — | — |
65
+
66
+ The environment variable names match the official Python SDK, so one `.env` file works for both.
67
+
68
+ ## TLS
69
+
70
+ GigaChat's certificates chain to the **Russian Trusted Root CA** (Ministry of Digital Development). Ruby's default
71
+ OpenSSL store doesn't include it, so plain HTTPS calls fail with `certificate verify failed`.
72
+
73
+ This gem ships that root certificate (valid until 2032-02-27; its SHA-256 fingerprint is pinned in the test suite).
74
+ It adds the certificate to a **per-client** certificate store, next to your system roots, so:
75
+
76
+ - verification stays on;
77
+ - nothing changes for other HTTPS connections in your process.
78
+
79
+ Options:
80
+
81
+ - `bundled_ca: false` uses only the system store, for example when your OS image already trusts the root.
82
+ - `ca_bundle_file: "/path/to/ca.pem"` adds more trusted CAs; they are appended, never replacing the defaults.
83
+ - `verify_ssl_certs: false` disables verification. Use it only for debugging; the client logs a warning.
84
+
85
+ ## Chat (v2)
86
+
87
+ ```ruby
88
+ completion = client.chat.create(
89
+ messages: [
90
+ { role: "system", content: "Ты — профессиональный переводчик на английский язык." },
91
+ { role: "user", content: "GigaChat — это сервис для диалога с пользователем." }
92
+ ],
93
+ model_options: { temperature: 0.3 }
94
+ )
95
+
96
+ completion.text # assistant text
97
+ completion.usage.total_tokens
98
+ completion.finish_reason # "stop", "length", "function_call", ...
99
+ completion.request_id # x-request-id, useful for support tickets
100
+ ```
101
+
102
+ `content: "text"` is shorthand for v2 content parts (`[{ text: "text" }]`). Any other keyword (`tools`,
103
+ `tool_config`, `model_options`, `user_info`, `ranker_options`, …) is sent to the API as is.
104
+
105
+ Keep the history by passing previous messages back, including response objects:
106
+
107
+ ```ruby
108
+ history = [{ role: "user", content: "Привет!" }]
109
+ reply = client.chat.create(messages: history)
110
+ history += [reply.message, { role: "user", content: "Расскажи анекдот" }]
111
+ client.chat.create(messages: history)
112
+ ```
113
+
114
+ ### Streaming
115
+
116
+ ```ruby
117
+ # A block streams events and returns the accumulated completion.
118
+ final = client.chat.stream(messages: [{ role: "user", content: "Напиши стих" }]) do |event|
119
+ print event.text if event.delta?
120
+ end
121
+ final.usage
122
+
123
+ # Without a block you get a lazy, single-use stream.
124
+ stream = client.chat.stream(messages: [{ role: "user", content: "Напиши стих" }])
125
+ stream.text.each { print it } # text deltas only
126
+ stream.response # accumulated completion
127
+ ```
128
+
129
+ Breaking out of the block closes the connection.
130
+
131
+ ### Function calling
132
+
133
+ ```ruby
134
+ weather = {
135
+ name: "weather_forecast",
136
+ description: "Weather forecast for a city",
137
+ parameters: { type: "object", properties: { location: { type: "string" } }, required: ["location"] }
138
+ }
139
+ tools = [{ functions: { specifications: [weather] } }]
140
+ messages = [{ role: "user", content: "Какая погода в Туле?" }]
141
+
142
+ first = client.chat.create(messages:, tools:)
143
+ if (call = first.function_call)
144
+ result = { temperature: 12 } # call your code with call.arguments (a Hash)
145
+ messages += [
146
+ first.message, # carries tool_state_id, which ties the result to this call
147
+ { role: "tool", content: [{ function_result: { name: call.name, result: JSON.generate(result) } }] }
148
+ ]
149
+ puts client.chat.create(messages:, tools:).text
150
+ end
151
+ ```
152
+
153
+ Pass the assistant message back unchanged: it carries the `tool_state_id` the API returned (also readable as
154
+ `first.message.tools_state_id`).
155
+
156
+ ### Structured output
157
+
158
+ ```ruby
159
+ client.chat.create(
160
+ messages: [{ role: "user", content: "27 октября 2023 года у меня родился сын" }],
161
+ model_options: {
162
+ response_format: {
163
+ type: "json_schema",
164
+ schema: { type: "object", properties: { date: { type: "string" } }, required: ["date"] },
165
+ strict: true
166
+ }
167
+ }
168
+ )
169
+ ```
170
+
171
+ ## Chat (v1)
172
+
173
+ The original `/chat/completions` API is fully supported:
174
+
175
+ ```ruby
176
+ client.chat.v1.create(messages: [{ role: "user", content: "Привет!" }]).text
177
+ client.chat.v1.stream(messages: [{ role: "user", content: "Привет!" }]) { |chunk| print chunk.text }
178
+ ```
179
+
180
+ ## Embeddings
181
+
182
+ ```ruby
183
+ client.embeddings.create(input: ["Первый текст", "Второй текст"]).vectors # model defaults to "Embeddings"
184
+ client.embeddings.create(input: "Текст", model: "EmbeddingsGigaR")
185
+ ```
186
+
187
+ ## Models
188
+
189
+ ```ruby
190
+ client.models.list.map(&:id)
191
+ client.models.retrieve("GigaChat-2-Max")
192
+ ```
193
+
194
+ ## Files
195
+
196
+ ```ruby
197
+ file = client.files.upload("report.pdf") # a path or Pathname
198
+ file = client.files.upload(io, filename: "notes.txt") # any IO; filename needed when the IO has no path
199
+ client.chat.create(messages: [{ role: "user", content: [{ text: "Summarize", files: [{ id: file.id }] }] }])
200
+
201
+ client.files.list.map(&:filename)
202
+ client.files.retrieve(file.id)
203
+ client.files.content(image_id) # binary String, e.g. a generated image
204
+ client.files.delete(file.id).deleted?
205
+ ```
206
+
207
+ ## Token counting and balance
208
+
209
+ ```ruby
210
+ client.tokens_count(input: ["Я к вам пишу — чего же боле?"]).map(&:tokens)
211
+ client.balance.map { [it.usage, it.value] } # prepaid packages only (403 on pay-as-you-go)
212
+ ```
213
+
214
+ ## AI-text detection
215
+
216
+ ```ruby
217
+ client.ai_check(input: text, model: "GigaCheckClassification").category # "ai", "human" or "mixed"
218
+ ```
219
+
220
+ ## Function validation
221
+
222
+ ```ruby
223
+ result = client.functions.validate(weather)
224
+ result.valid?
225
+ result.warnings.map(&:description)
226
+ ```
227
+
228
+ ## Batches
229
+
230
+ Batches are available on pay-as-you-go (`GIGACHAT_API_CORP`).
231
+
232
+ ```ruby
233
+ requests = [
234
+ { id: "1", request: { model: "GigaChat-2", messages: [{ role: "user", content: "Привет" }] } }
235
+ ]
236
+ batch = client.batches.create(requests, method: :chat_completions) # or method: :embedder
237
+ batch = client.batches.retrieve(batch.id)
238
+ batch.batch_method # "chat_completions"
239
+ client.batches.results(batch) if batch.completed? # parsed JSONL lines
240
+ ```
241
+
242
+ The API's `method` field is exposed as `batch_method`, because a `method` reader would shadow Ruby's
243
+ `Object#method`. The raw value is also available as `batch[:method]`.
244
+
245
+ ## Errors
246
+
247
+ | Status | Error |
248
+ |---|---|
249
+ | 400 | `GigaChat::BadRequestError` |
250
+ | 401 | `GigaChat::AuthenticationError` |
251
+ | 403 | `GigaChat::PermissionDeniedError` |
252
+ | 404 | `GigaChat::NotFoundError` |
253
+ | 413 | `GigaChat::RequestEntityTooLargeError` |
254
+ | 422 | `GigaChat::UnprocessableEntityError` |
255
+ | 429 | `GigaChat::RateLimitError` (`#retry_after`) |
256
+ | 5xx | `GigaChat::ServerError` |
257
+ | network | `GigaChat::APIConnectionError`, `GigaChat::APITimeoutError` |
258
+
259
+ Every `GigaChat::APIError` carries `#status`, `#body`, `#headers` and `#request_id`. All errors inherit from
260
+ `GigaChat::Error`.
261
+
262
+ ## Retries and timeouts
263
+
264
+ Retries apply to statuses 429, 500, 502, 503 and 504, to connection failures, and to timeouts on GET requests.
265
+ There are 2 retries by default:
266
+
267
+ - the delay is exponential with jitter, capped at 8 seconds;
268
+ - `Retry-After` wins over the default delay, capped at 60 seconds;
269
+ - streams are retried only before the first event arrives.
270
+
271
+ You can override this per call:
272
+
273
+ ```ruby
274
+ client.chat.create(messages:, request_options: { timeout: 300, max_retries: 0, headers: { "X-Client-ID" => "u-1" } })
275
+ client.with_options(session_id: "chat-42").chat.create(messages:) # a copy that reuses the access token
276
+ ```
277
+
278
+ ## Logging
279
+
280
+ ```ruby
281
+ client = GigaChat::Client.new(logger: Logger.new($stdout))
282
+ # GigaChat: POST https://api.giga.chat/v2/chat/completions 200 812ms req=…
283
+ ```
284
+
285
+ The log never contains request or response bodies, tokens or credentials.
286
+
287
+ ## Low-level requests
288
+
289
+ ```ruby
290
+ client.request(method: :get, path: "models") # auth, retries and error mapping included
291
+ ```
292
+
293
+ ## Development
294
+
295
+ ```sh
296
+ bundle install
297
+ bundle exec rake # unit tests + RuboCop
298
+ GIGACHAT_CREDENTIALS=… GIGACHAT_SCOPE=GIGACHAT_API_PERS bundle exec rake test:live # real API, opt-in
299
+ ```
300
+
301
+ ## License
302
+
303
+ MIT. See `LICENSE.txt`.
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GigaChat
4
+ module Auth
5
+ # `expires_at` is Unix time in milliseconds; 0 means "never expires locally" (a static token).
6
+ AccessToken = Data.define(:access_token, :expires_at) do
7
+ # The OAuth endpoint returns milliseconds, but /token (`exp`) may return seconds.
8
+ def self.normalize_ms(value) = value.positive? && value < 1_000_000_000_000 ? value * 1000 : value
9
+
10
+ def inspect = "#<#{self.class.name} access_token=[FILTERED] expires_at=#{expires_at}>"
11
+
12
+ alias_method :to_s, :inspect
13
+
14
+ # Data#pretty_print (used by pp and IRB) ignores #inspect and would print the token.
15
+ def pretty_print(printer) = printer.text(inspect)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GigaChat
4
+ module Auth
5
+ # Obtains and caches access tokens. Thread-safe: one manager serves every thread of a client.
6
+ class TokenManager
7
+ EXPIRY_BUFFER_MS = 60_000
8
+
9
+ def initialize(config, transport)
10
+ @config = config
11
+ @transport = transport
12
+ @credentials = clean(config.credentials)
13
+ @mutex = Mutex.new
14
+ @token = AccessToken.new(access_token: config.access_token, expires_at: 0) if config.access_token
15
+ end
16
+
17
+ # A usable token, fetched when missing or about to expire; nil when only mTLS is configured.
18
+ def token
19
+ current = @token # read once: another thread may invalidate! between the check and the return
20
+ return current if usable?(current)
21
+ return unauthenticated unless refreshable?
22
+
23
+ @mutex.synchronize { usable?(@token) ? @token : (@token = fetch) }
24
+ end
25
+
26
+ def refreshable? = !@credentials.nil? || !(@config.user.nil? || @config.password.nil?)
27
+
28
+ # Drops the token only if it is still the stale one, so a fresh token from another thread survives.
29
+ def invalidate!(stale)
30
+ @mutex.synchronize { @token = nil if @token.equal?(stale) }
31
+ end
32
+
33
+ # The default #inspect would print the long-lived credentials.
34
+ def inspect = "#<#{self.class.name} token=#{@token ? "[FILTERED]" : "nil"}>"
35
+
36
+ private
37
+
38
+ def usable?(token)
39
+ !token.nil? && (token.expires_at.zero? || token.expires_at > now_ms + EXPIRY_BUFFER_MS)
40
+ end
41
+
42
+ def now_ms = (Time.now.to_f * 1000).to_i
43
+
44
+ def unauthenticated
45
+ return if @config.cert_file
46
+
47
+ raise ConfigurationError, "No authentication configured: set credentials, access_token, " \
48
+ "user/password or cert_file"
49
+ end
50
+
51
+ def fetch
52
+ response = Internal::Transport.wrap_errors { @credentials ? request_oauth : request_password }
53
+ token = parse(response)
54
+ @config.logger&.debug("GigaChat: access token refreshed (expires_at=#{token.expires_at})")
55
+ token
56
+ end
57
+
58
+ def request_oauth
59
+ @transport.auth.post do |req|
60
+ req.headers["Authorization"] = "Basic #{@credentials}"
61
+ req.headers["RqUID"] = SecureRandom.uuid
62
+ req.headers["Accept"] = "application/json"
63
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
64
+ req.body = URI.encode_www_form(scope: @config.scope)
65
+ end
66
+ end
67
+
68
+ def request_password
69
+ basic = ["#{@config.user}:#{@config.password}"].pack("m0")
70
+ @transport.api.post("token") do |req|
71
+ req.headers["Authorization"] = "Basic #{basic}"
72
+ req.headers["Accept"] = "application/json"
73
+ end
74
+ end
75
+
76
+ def parse(response)
77
+ body = Internal::Util.parse_json(response.body)
78
+ raise auth_error(response, body) unless response.success?
79
+
80
+ data = body.is_a?(Hash) ? body : {}
81
+ value = data[:access_token] || data[:tok]
82
+ expires = data[:expires_at] || data[:exp]
83
+ unless value && expires
84
+ raise AuthenticationError.new(status: response.status, body:, headers: response.headers,
85
+ message: "Unexpected token response")
86
+ end
87
+
88
+ AccessToken.new(access_token: value, expires_at: AccessToken.normalize_ms(Integer(expires)))
89
+ end
90
+
91
+ # 4xx from the auth endpoint means bad credentials or scope; 429/5xx stay retryable.
92
+ def auth_error(response, body)
93
+ status = response.status
94
+ return APIError.for(status:, body:, headers: response.headers) if status == 429 || status >= 500
95
+
96
+ AuthenticationError.new(status:, body:, headers: response.headers)
97
+ end
98
+
99
+ # Keys copied from the dashboard often carry a "Basic " prefix, and `base64` wraps lines at 76 columns.
100
+ # Base64 never contains whitespace, so any of it is noise (and a CR/LF would break the header).
101
+ def clean(credentials)
102
+ value = credentials.to_s.sub(/\A\s*basic\s+/i, "").gsub(/\s+/, "")
103
+ value.empty? ? nil : value
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,33 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
3
+ PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
4
+ ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
5
+ Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
6
+ VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
7
+ YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
8
+ dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
9
+ qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
10
+ XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
11
+ zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
12
+ YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
13
+ Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
14
+ U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
15
+ 4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
16
+ G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
17
+ BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
18
+ ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
19
+ OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
20
+ BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
21
+ BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
22
+ AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
23
+ tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
24
+ W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
25
+ /3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
26
+ AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
27
+ C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
28
+ 4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
29
+ WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
30
+ D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
31
+ EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
32
+ 391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
33
+ -----END CERTIFICATE-----