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
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-----
|