gigamux 0.2.0__tar.gz

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 (52) hide show
  1. gigamux-0.2.0/.gitignore +6 -0
  2. gigamux-0.2.0/LICENSE +35 -0
  3. gigamux-0.2.0/PKG-INFO +260 -0
  4. gigamux-0.2.0/README.md +230 -0
  5. gigamux-0.2.0/pyproject.toml +58 -0
  6. gigamux-0.2.0/src/gigamux/__init__.py +52 -0
  7. gigamux-0.2.0/src/gigamux/adapters/__init__.py +1 -0
  8. gigamux-0.2.0/src/gigamux/adapters/embeddings.py +69 -0
  9. gigamux-0.2.0/src/gigamux/adapters/function_calling.py +66 -0
  10. gigamux-0.2.0/src/gigamux/adapters/langchain_chat.py +370 -0
  11. gigamux-0.2.0/src/gigamux/client.py +504 -0
  12. gigamux-0.2.0/src/gigamux/config.py +150 -0
  13. gigamux-0.2.0/src/gigamux/errors.py +54 -0
  14. gigamux-0.2.0/src/gigamux/limits/__init__.py +1 -0
  15. gigamux-0.2.0/src/gigamux/limits/base.py +27 -0
  16. gigamux-0.2.0/src/gigamux/limits/fallback.py +125 -0
  17. gigamux-0.2.0/src/gigamux/limits/local.py +47 -0
  18. gigamux-0.2.0/src/gigamux/limits/redis.py +88 -0
  19. gigamux-0.2.0/src/gigamux/models.py +135 -0
  20. gigamux-0.2.0/src/gigamux/pool.py +64 -0
  21. gigamux-0.2.0/src/gigamux/py.typed +0 -0
  22. gigamux-0.2.0/src/gigamux/redis_factory.py +54 -0
  23. gigamux-0.2.0/src/gigamux/stop_parser.py +91 -0
  24. gigamux-0.2.0/src/gigamux/stops/__init__.py +1 -0
  25. gigamux-0.2.0/src/gigamux/stops/base.py +20 -0
  26. gigamux-0.2.0/src/gigamux/stops/local.py +27 -0
  27. gigamux-0.2.0/src/gigamux/stops/redis.py +100 -0
  28. gigamux-0.2.0/src/gigamux/transport.py +53 -0
  29. gigamux-0.2.0/tests/__init__.py +0 -0
  30. gigamux-0.2.0/tests/conftest.py +129 -0
  31. gigamux-0.2.0/tests/test_adapter_chat.py +164 -0
  32. gigamux-0.2.0/tests/test_adapter_embeddings.py +94 -0
  33. gigamux-0.2.0/tests/test_adapter_structured.py +201 -0
  34. gigamux-0.2.0/tests/test_adapter_tools.py +361 -0
  35. gigamux-0.2.0/tests/test_client_cancellation.py +173 -0
  36. gigamux-0.2.0/tests/test_client_errors.py +231 -0
  37. gigamux-0.2.0/tests/test_client_redis_e2e.py +91 -0
  38. gigamux-0.2.0/tests/test_client_stream.py +215 -0
  39. gigamux-0.2.0/tests/test_client_unary.py +158 -0
  40. gigamux-0.2.0/tests/test_config.py +141 -0
  41. gigamux-0.2.0/tests/test_langgraph_smoke.py +72 -0
  42. gigamux-0.2.0/tests/test_limits_fallback.py +162 -0
  43. gigamux-0.2.0/tests/test_limits_leak.py +36 -0
  44. gigamux-0.2.0/tests/test_limits_local.py +48 -0
  45. gigamux-0.2.0/tests/test_limits_redis.py +52 -0
  46. gigamux-0.2.0/tests/test_models.py +109 -0
  47. gigamux-0.2.0/tests/test_pool.py +61 -0
  48. gigamux-0.2.0/tests/test_public_api.py +12 -0
  49. gigamux-0.2.0/tests/test_redis_factory.py +120 -0
  50. gigamux-0.2.0/tests/test_stop_parser.py +115 -0
  51. gigamux-0.2.0/tests/test_stops.py +196 -0
  52. gigamux-0.2.0/tests/test_transport.py +63 -0
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
6
+ *.egg-info/
gigamux-0.2.0/LICENSE ADDED
@@ -0,0 +1,35 @@
1
+ PROPRIETARY LICENSE
2
+
3
+ Copyright (c) 2026 Kalentev Leon Konstantinovich. All rights reserved.
4
+
5
+ Subject to the terms of this license, the copyright holder grants any
6
+ person obtaining a copy of this software ("the Software") from the Python
7
+ Package Index a limited, non-exclusive, non-transferable, revocable
8
+ permission to install and use unmodified copies of the Software solely in
9
+ its distributed form.
10
+
11
+ Except for the limited permission expressly granted above, all rights are
12
+ reserved. Without prior written permission of the copyright holder, you
13
+ may NOT:
14
+
15
+ 1. modify, adapt, translate, or create derivative works of the Software
16
+ or any part of it;
17
+ 2. redistribute, sublicense, sell, rent, lease, lend, or otherwise
18
+ transfer the Software or access to it, in source or binary form;
19
+ 3. incorporate the Software or any part of its source code into other
20
+ software;
21
+ 4. remove or alter this license, the copyright notice, or any attribution;
22
+ 5. publish benchmarks or reverse-engineer the Software except to the
23
+ extent expressly permitted by applicable law notwithstanding this
24
+ limitation.
25
+
26
+ Any use of the Software outside the scope of this license automatically
27
+ terminates the permission granted above.
28
+
29
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
32
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
33
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
34
+ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
35
+ SOFTWARE.
gigamux-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,260 @@
1
+ Metadata-Version: 2.4
2
+ Name: gigamux
3
+ Version: 0.2.0
4
+ Summary: GigaChat client with certificate pool, shared limits and stop events
5
+ Author-email: Kalentev Leon Konstantinovich <itqop@icloud.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: coredis>=6.7
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: loguru>=0.7
19
+ Requires-Dist: pydantic>=2.7
20
+ Provides-Extra: dev
21
+ Requires-Dist: fakeredis[lua]>=2.25; extra == 'dev'
22
+ Requires-Dist: langchain-core>=1.0; extra == 'dev'
23
+ Requires-Dist: langgraph>=1.0; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.8; extra == 'dev'
27
+ Provides-Extra: langchain
28
+ Requires-Dist: langchain-core>=1.0; extra == 'langchain'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # gigamux
32
+
33
+ Асинхронный клиент GigaChat: пул сертификатов-каналов, общие in-flight лимиты
34
+ через Redis (coredis) с fallback на локальные счётчики, stop-события (429 + время
35
+ блокировки) и drop-in адаптеры LangChain. Сервисы переходят на библиотеку заменой
36
+ импорта, retry-логика и троттлинг живут в ядре.
37
+
38
+ ## Quick start
39
+
40
+ Ядро напрямую:
41
+
42
+ ```python
43
+ from gigamux import GigaCoreClient, config_from_env, ChatRequest, Message
44
+
45
+ config = config_from_env()
46
+ async with GigaCoreClient(config) as client:
47
+ result = await client.chat(
48
+ ChatRequest(
49
+ model="GigaChat-2-Max",
50
+ messages=(Message(role="user", content="привет"),),
51
+ )
52
+ )
53
+ print(result.content, result.meta.channel)
54
+ ```
55
+
56
+ LangChain-сервисы (адаптеры импортируются напрямую, требуют extra `langchain`):
57
+
58
+ ```python
59
+ from gigamux.adapters.langchain_chat import PooledGigaChat
60
+
61
+ llm = PooledGigaChat(core=client, model="GigaChat-2-Max", temperature=0.1)
62
+ message = await llm.ainvoke("привет")
63
+ ```
64
+
65
+ Индексация (один батч = один HTTP-запрос = один слот, нарезка по 20 в адаптере):
66
+
67
+ ```python
68
+ from gigamux.adapters.embeddings import PooledGigaEmbeddings
69
+
70
+ embedder = PooledGigaEmbeddings(core=client, model="EmbeddingsGigaR")
71
+ vectors = await embedder.aembed_documents(texts)
72
+ ```
73
+
74
+ ## Function calling и structured output
75
+
76
+ `PooledGigaChat` поддерживает инструменты и структурированный вывод —
77
+ паритет с langchain-gigachat, включая react-агентов LangGraph:
78
+
79
+ ```python
80
+ from langchain_core.tools import tool
81
+ from langgraph.prebuilt import create_react_agent
82
+
83
+ @tool
84
+ def get_weather(city: str) -> str:
85
+ """Return current weather for a city."""
86
+ ...
87
+
88
+ agent = create_react_agent(llm, [get_weather])
89
+ state = await agent.ainvoke({"messages": [...]})
90
+ ```
91
+
92
+ Extra `gigamux[langchain]` ставит только langchain-core; для примера выше
93
+ нужен отдельно установленный langgraph (`pip install langgraph`).
94
+
95
+ Структурированный вывод доступен двумя методами эталонной библиотеки:
96
+ `function_calling` (по умолчанию) и `json_mode` (через `response_format`).
97
+
98
+ ```python
99
+ chain = llm.with_structured_output(MyModel)
100
+ chain = llm.with_structured_output(MyModel, method="json_mode")
101
+ result = await chain.ainvoke("...")
102
+ ```
103
+
104
+ Ограничения и семантика:
105
+
106
+ - Стриминг с инструментами на уровне API невозможен, поэтому
107
+ `astream`/`astream_events` с привязанными функциями прозрачно выполняют
108
+ обычный вызов и отдают ответ одним чанком: токенного стриминга нет, но
109
+ react-агенты под `astream_events`/`stream_mode="messages"` работают.
110
+ - Параллельные tool_calls невозможны на стороне GigaChat: больше одного
111
+ tool_call в сообщении — `ValueError`.
112
+ - В `with_structured_output` поддерживаются pydantic-модели обоих поколений
113
+ (v2 и `pydantic.v1`) в обоих методах; v1-класс возвращается экземпляром,
114
+ как и v2.
115
+ - `finish_reason` доступен в `response_metadata` каждого ответа адаптера.
116
+ - `with_structured_output` методом `function_calling` возвращает `None`,
117
+ если модель ответила текстом вместо вызова функции (поведение эталона);
118
+ с `include_raw=True` сырое сообщение доступно в `output["raw"]`.
119
+ - `json_mode` использует бета-фичу API `response_format` — при её
120
+ отсутствии на контуре ошибка идентична поведению langchain-gigachat.
121
+ - `few_shot_examples` пробрасываются из `metadata` инструмента.
122
+
123
+ ## Стриминг
124
+
125
+ Ядро отдаёт дельты как `StreamChunk`; слот канала занят весь стрим, метаданные
126
+ приходят в последнем чанке:
127
+
128
+ ```python
129
+ request = ChatRequest(
130
+ model="GigaChat-2-Max",
131
+ messages=(Message(role="user", content="расскажи анекдот"),),
132
+ )
133
+ async for chunk in client.stream(request):
134
+ print(chunk.delta, end="")
135
+ if chunk.meta is not None:
136
+ print(chunk.meta.channel, chunk.meta.duration_ms)
137
+ ```
138
+
139
+ Ошибки до первого чанка ретраятся как обычные запросы; после начала стрима —
140
+ `StreamInterruptedError` без молчаливого ретрая. У адаптера работает
141
+ `llm.astream(...)`; без инструментов это настоящий токенный стриминг, а с
142
+ привязанными функциями ответ приходит одним финальным чанком (см. раздел
143
+ про function calling).
144
+
145
+ Потребитель, который может бросить стрим до конца, должен оборачивать его в
146
+ `contextlib.aclosing(...)`, иначе слот освобождается только сборщиком мусора.
147
+ Keepalive слота ограничен `slot_max_lifetime` (дефолт 900 с): после этого
148
+ лимита lease перестаёт продлеваться и истекает по TTL даже без финализации
149
+ генератора.
150
+
151
+ ## Конфигурация в коде
152
+
153
+ `config_from_env()` — удобный путь, но конфиг можно собрать руками:
154
+
155
+ ```python
156
+ from gigamux import ChannelConfig, ClientConfig, GigaCoreClient
157
+
158
+ config = ClientConfig(
159
+ channels=[
160
+ ChannelConfig(
161
+ name="cert-a",
162
+ base_url="https://giga.internal:10501/v1",
163
+ cert_file="/etc/certs/a/tls.pem",
164
+ key_file="/etc/certs/a/key.pem",
165
+ ca_bundle="/etc/certs/ca.pem",
166
+ limits={"GigaChat-2-Max": 2, "EmbeddingsGigaR": 4},
167
+ ),
168
+ ChannelConfig(
169
+ name="cert-b",
170
+ base_url="https://giga.internal:10502/v1",
171
+ limits={"*": 3},
172
+ ),
173
+ ],
174
+ redis_url="rediss://redis.internal:6380/0",
175
+ redis_username="svc",
176
+ redis_password="...",
177
+ acquire_timeout=30.0,
178
+ max_retries=3,
179
+ )
180
+
181
+ async with GigaCoreClient(config) as client:
182
+ ...
183
+ ```
184
+
185
+ Ключевые поля `ClientConfig` (все имеют дефолты):
186
+
187
+ | Поле | Дефолт | Назначение |
188
+ |---|---|---|
189
+ | `acquire_timeout` | 30.0 | сколько ждать свободный слот; `None` — ждать без таймаута (индексация) |
190
+ | `lease_ttl` / `heartbeat_interval` | 120 / ttl/3 | время жизни слота в Redis и период продления |
191
+ | `slot_max_lifetime` | 900.0 | максимум продления слота keepalive-ом; дальше lease истекает по TTL |
192
+ | `max_retries` / `backoff_factor` / `backoff_max` | 3 / 1.0 / 30.0 | ретраи 429/5xx с экспоненциальной паузой |
193
+ | `connect_timeout` / `read_timeout` | 10 / 120 | таймауты HTTP |
194
+ | `redis_failure_threshold` / `redis_probe_interval` | 3 / 15.0 | circuit breaker: после N ошибок Redis лимиты локальные, проба каждые 15 с |
195
+
196
+ `limits` канала — единственный источник правды о том, какие модели канал
197
+ обслуживает: ключ — имя модели, значение — число одновременных запросов,
198
+ `"*"` — wildcard. `cert_file`/`key_file` задаются только вместе; без них
199
+ канал работает без mTLS. `acquire_timeout` можно переопределить и на один
200
+ вызов: `client.chat(request, acquire_timeout=None)`.
201
+
202
+ ## Переменные окружения
203
+
204
+ | Переменная | Назначение |
205
+ |---|---|
206
+ | `GIGA_CHANNELS` | JSON-список каналов (`name`, `base_url`, `cert_file`, `key_file`, `ca_bundle`, `limits`) |
207
+ | `GIGA_REDIS_URL` | URL Redis для распределённых лимитов; не задан -> локальные лимиты |
208
+ | `GIGA_REDIS_USERNAME` / `GIGA_REDIS_PASSWORD` | аутентификация Redis |
209
+ | `GIGA_REDIS_KEY_FILE` / `GIGA_REDIS_CERT_FILE` / `GIGA_REDIS_CA_BUNDLE` | mTLS для Redis |
210
+ | `GIGACHAT_HOST` / `GIGACHAT_PORT` / `GIGACHAT_TLS_CERT_FILEPATH` / `GIGACHAT_KEY_FILEPATH` / `GIGACHAT_CA_BUNDLE_FILEPATH` | режим совместимости: один канал из legacy-переменных |
211
+ | `GIGACHAT_ENDPOINT` | legacy-путь base_url в режиме совместимости (по умолчанию `/v1`) |
212
+ | `GIGACHAT_MAX_CONCURRENCY` | лимит на канал в режиме совместимости (wildcard-модель) |
213
+
214
+ Если задан `GIGA_CHANNELS`, он имеет приоритет. Иначе из `GIGACHAT_*` строится
215
+ пул из одного канала с wildcard-лимитом, что даёт миграцию без изменения конфига.
216
+
217
+ ## Семантика
218
+
219
+ Лимит — in-flight слоты на пару канал+модель, общие между репликами. Слот берётся
220
+ как lease с TTL в Redis и продлевается heartbeat-ом, поэтому упавший под не держит
221
+ слот навсегда. При занятых слотах вызов ждёт в очереди с джиттером до
222
+ `acquire_timeout` (онлайн ~30 с, индексация — без таймаута), затем `SlotWaitTimeoutError`.
223
+ Stop-событие выводит канал из ротации до времени T и прозрачно ретраит на другом
224
+ канале; когда все каналы в стопе — сразу `AllChannelsStoppedError(resume_at)`.
225
+ Ошибка посреди стрима не ретраится молча — `StreamInterruptedError`.
226
+
227
+ ## Ошибки
228
+
229
+ Все исключения наследуют `GigaClientError` и импортируются из `gigamux`:
230
+
231
+ | Исключение | Когда летит |
232
+ |---|---|
233
+ | `ConfigError` | невалидная конфигурация или переменные окружения |
234
+ | `NoChannelForModelError` | ни один канал не обслуживает запрошенную модель |
235
+ | `SlotWaitTimeoutError` | свободный слот не появился за `acquire_timeout` |
236
+ | `AllChannelsStoppedError` | все каналы в стопе; `resume_at` — когда вернётся первый |
237
+ | `RateLimitError` | 429 пережил все ретраи |
238
+ | `ServerError` | 5xx или сетевая ошибка пережили все ретраи |
239
+ | `ApiError` | прочие неретраябельные ответы API (4xx); базовый класс двух предыдущих |
240
+ | `StreamInterruptedError` | стрим оборвался после уже отданных чанков |
241
+
242
+ У `ApiError` и наследников доступны `status_code`, `body`, `channel`,
243
+ `request_id`. Типовая обработка: `SlotWaitTimeoutError` и `AllChannelsStoppedError`
244
+ — перегрузка, имеет смысл отдать 429/503 наверх; `ApiError` — ошибка запроса,
245
+ ретраить бесполезно.
246
+
247
+ ## Метаданные ответа
248
+
249
+ Каждый результат (`ChatResult`, `EmbedResult`, финальный `StreamChunk`) несёт
250
+ `meta: ResponseMeta` — `request_id`, `headers`, `channel`, `model`, `attempts`,
251
+ `duration_ms`, `status_code`. Это сквозной способ узнать, через какой канал
252
+ ушёл запрос и сколько он занял, например для логов и метрик.
253
+
254
+ ## Тесты
255
+
256
+ ```
257
+ python -m venv .venv
258
+ .venv\Scripts\pip install -e ".[dev]"
259
+ .venv\Scripts\python -m pytest
260
+ ```
@@ -0,0 +1,230 @@
1
+ # gigamux
2
+
3
+ Асинхронный клиент GigaChat: пул сертификатов-каналов, общие in-flight лимиты
4
+ через Redis (coredis) с fallback на локальные счётчики, stop-события (429 + время
5
+ блокировки) и drop-in адаптеры LangChain. Сервисы переходят на библиотеку заменой
6
+ импорта, retry-логика и троттлинг живут в ядре.
7
+
8
+ ## Quick start
9
+
10
+ Ядро напрямую:
11
+
12
+ ```python
13
+ from gigamux import GigaCoreClient, config_from_env, ChatRequest, Message
14
+
15
+ config = config_from_env()
16
+ async with GigaCoreClient(config) as client:
17
+ result = await client.chat(
18
+ ChatRequest(
19
+ model="GigaChat-2-Max",
20
+ messages=(Message(role="user", content="привет"),),
21
+ )
22
+ )
23
+ print(result.content, result.meta.channel)
24
+ ```
25
+
26
+ LangChain-сервисы (адаптеры импортируются напрямую, требуют extra `langchain`):
27
+
28
+ ```python
29
+ from gigamux.adapters.langchain_chat import PooledGigaChat
30
+
31
+ llm = PooledGigaChat(core=client, model="GigaChat-2-Max", temperature=0.1)
32
+ message = await llm.ainvoke("привет")
33
+ ```
34
+
35
+ Индексация (один батч = один HTTP-запрос = один слот, нарезка по 20 в адаптере):
36
+
37
+ ```python
38
+ from gigamux.adapters.embeddings import PooledGigaEmbeddings
39
+
40
+ embedder = PooledGigaEmbeddings(core=client, model="EmbeddingsGigaR")
41
+ vectors = await embedder.aembed_documents(texts)
42
+ ```
43
+
44
+ ## Function calling и structured output
45
+
46
+ `PooledGigaChat` поддерживает инструменты и структурированный вывод —
47
+ паритет с langchain-gigachat, включая react-агентов LangGraph:
48
+
49
+ ```python
50
+ from langchain_core.tools import tool
51
+ from langgraph.prebuilt import create_react_agent
52
+
53
+ @tool
54
+ def get_weather(city: str) -> str:
55
+ """Return current weather for a city."""
56
+ ...
57
+
58
+ agent = create_react_agent(llm, [get_weather])
59
+ state = await agent.ainvoke({"messages": [...]})
60
+ ```
61
+
62
+ Extra `gigamux[langchain]` ставит только langchain-core; для примера выше
63
+ нужен отдельно установленный langgraph (`pip install langgraph`).
64
+
65
+ Структурированный вывод доступен двумя методами эталонной библиотеки:
66
+ `function_calling` (по умолчанию) и `json_mode` (через `response_format`).
67
+
68
+ ```python
69
+ chain = llm.with_structured_output(MyModel)
70
+ chain = llm.with_structured_output(MyModel, method="json_mode")
71
+ result = await chain.ainvoke("...")
72
+ ```
73
+
74
+ Ограничения и семантика:
75
+
76
+ - Стриминг с инструментами на уровне API невозможен, поэтому
77
+ `astream`/`astream_events` с привязанными функциями прозрачно выполняют
78
+ обычный вызов и отдают ответ одним чанком: токенного стриминга нет, но
79
+ react-агенты под `astream_events`/`stream_mode="messages"` работают.
80
+ - Параллельные tool_calls невозможны на стороне GigaChat: больше одного
81
+ tool_call в сообщении — `ValueError`.
82
+ - В `with_structured_output` поддерживаются pydantic-модели обоих поколений
83
+ (v2 и `pydantic.v1`) в обоих методах; v1-класс возвращается экземпляром,
84
+ как и v2.
85
+ - `finish_reason` доступен в `response_metadata` каждого ответа адаптера.
86
+ - `with_structured_output` методом `function_calling` возвращает `None`,
87
+ если модель ответила текстом вместо вызова функции (поведение эталона);
88
+ с `include_raw=True` сырое сообщение доступно в `output["raw"]`.
89
+ - `json_mode` использует бета-фичу API `response_format` — при её
90
+ отсутствии на контуре ошибка идентична поведению langchain-gigachat.
91
+ - `few_shot_examples` пробрасываются из `metadata` инструмента.
92
+
93
+ ## Стриминг
94
+
95
+ Ядро отдаёт дельты как `StreamChunk`; слот канала занят весь стрим, метаданные
96
+ приходят в последнем чанке:
97
+
98
+ ```python
99
+ request = ChatRequest(
100
+ model="GigaChat-2-Max",
101
+ messages=(Message(role="user", content="расскажи анекдот"),),
102
+ )
103
+ async for chunk in client.stream(request):
104
+ print(chunk.delta, end="")
105
+ if chunk.meta is not None:
106
+ print(chunk.meta.channel, chunk.meta.duration_ms)
107
+ ```
108
+
109
+ Ошибки до первого чанка ретраятся как обычные запросы; после начала стрима —
110
+ `StreamInterruptedError` без молчаливого ретрая. У адаптера работает
111
+ `llm.astream(...)`; без инструментов это настоящий токенный стриминг, а с
112
+ привязанными функциями ответ приходит одним финальным чанком (см. раздел
113
+ про function calling).
114
+
115
+ Потребитель, который может бросить стрим до конца, должен оборачивать его в
116
+ `contextlib.aclosing(...)`, иначе слот освобождается только сборщиком мусора.
117
+ Keepalive слота ограничен `slot_max_lifetime` (дефолт 900 с): после этого
118
+ лимита lease перестаёт продлеваться и истекает по TTL даже без финализации
119
+ генератора.
120
+
121
+ ## Конфигурация в коде
122
+
123
+ `config_from_env()` — удобный путь, но конфиг можно собрать руками:
124
+
125
+ ```python
126
+ from gigamux import ChannelConfig, ClientConfig, GigaCoreClient
127
+
128
+ config = ClientConfig(
129
+ channels=[
130
+ ChannelConfig(
131
+ name="cert-a",
132
+ base_url="https://giga.internal:10501/v1",
133
+ cert_file="/etc/certs/a/tls.pem",
134
+ key_file="/etc/certs/a/key.pem",
135
+ ca_bundle="/etc/certs/ca.pem",
136
+ limits={"GigaChat-2-Max": 2, "EmbeddingsGigaR": 4},
137
+ ),
138
+ ChannelConfig(
139
+ name="cert-b",
140
+ base_url="https://giga.internal:10502/v1",
141
+ limits={"*": 3},
142
+ ),
143
+ ],
144
+ redis_url="rediss://redis.internal:6380/0",
145
+ redis_username="svc",
146
+ redis_password="...",
147
+ acquire_timeout=30.0,
148
+ max_retries=3,
149
+ )
150
+
151
+ async with GigaCoreClient(config) as client:
152
+ ...
153
+ ```
154
+
155
+ Ключевые поля `ClientConfig` (все имеют дефолты):
156
+
157
+ | Поле | Дефолт | Назначение |
158
+ |---|---|---|
159
+ | `acquire_timeout` | 30.0 | сколько ждать свободный слот; `None` — ждать без таймаута (индексация) |
160
+ | `lease_ttl` / `heartbeat_interval` | 120 / ttl/3 | время жизни слота в Redis и период продления |
161
+ | `slot_max_lifetime` | 900.0 | максимум продления слота keepalive-ом; дальше lease истекает по TTL |
162
+ | `max_retries` / `backoff_factor` / `backoff_max` | 3 / 1.0 / 30.0 | ретраи 429/5xx с экспоненциальной паузой |
163
+ | `connect_timeout` / `read_timeout` | 10 / 120 | таймауты HTTP |
164
+ | `redis_failure_threshold` / `redis_probe_interval` | 3 / 15.0 | circuit breaker: после N ошибок Redis лимиты локальные, проба каждые 15 с |
165
+
166
+ `limits` канала — единственный источник правды о том, какие модели канал
167
+ обслуживает: ключ — имя модели, значение — число одновременных запросов,
168
+ `"*"` — wildcard. `cert_file`/`key_file` задаются только вместе; без них
169
+ канал работает без mTLS. `acquire_timeout` можно переопределить и на один
170
+ вызов: `client.chat(request, acquire_timeout=None)`.
171
+
172
+ ## Переменные окружения
173
+
174
+ | Переменная | Назначение |
175
+ |---|---|
176
+ | `GIGA_CHANNELS` | JSON-список каналов (`name`, `base_url`, `cert_file`, `key_file`, `ca_bundle`, `limits`) |
177
+ | `GIGA_REDIS_URL` | URL Redis для распределённых лимитов; не задан -> локальные лимиты |
178
+ | `GIGA_REDIS_USERNAME` / `GIGA_REDIS_PASSWORD` | аутентификация Redis |
179
+ | `GIGA_REDIS_KEY_FILE` / `GIGA_REDIS_CERT_FILE` / `GIGA_REDIS_CA_BUNDLE` | mTLS для Redis |
180
+ | `GIGACHAT_HOST` / `GIGACHAT_PORT` / `GIGACHAT_TLS_CERT_FILEPATH` / `GIGACHAT_KEY_FILEPATH` / `GIGACHAT_CA_BUNDLE_FILEPATH` | режим совместимости: один канал из legacy-переменных |
181
+ | `GIGACHAT_ENDPOINT` | legacy-путь base_url в режиме совместимости (по умолчанию `/v1`) |
182
+ | `GIGACHAT_MAX_CONCURRENCY` | лимит на канал в режиме совместимости (wildcard-модель) |
183
+
184
+ Если задан `GIGA_CHANNELS`, он имеет приоритет. Иначе из `GIGACHAT_*` строится
185
+ пул из одного канала с wildcard-лимитом, что даёт миграцию без изменения конфига.
186
+
187
+ ## Семантика
188
+
189
+ Лимит — in-flight слоты на пару канал+модель, общие между репликами. Слот берётся
190
+ как lease с TTL в Redis и продлевается heartbeat-ом, поэтому упавший под не держит
191
+ слот навсегда. При занятых слотах вызов ждёт в очереди с джиттером до
192
+ `acquire_timeout` (онлайн ~30 с, индексация — без таймаута), затем `SlotWaitTimeoutError`.
193
+ Stop-событие выводит канал из ротации до времени T и прозрачно ретраит на другом
194
+ канале; когда все каналы в стопе — сразу `AllChannelsStoppedError(resume_at)`.
195
+ Ошибка посреди стрима не ретраится молча — `StreamInterruptedError`.
196
+
197
+ ## Ошибки
198
+
199
+ Все исключения наследуют `GigaClientError` и импортируются из `gigamux`:
200
+
201
+ | Исключение | Когда летит |
202
+ |---|---|
203
+ | `ConfigError` | невалидная конфигурация или переменные окружения |
204
+ | `NoChannelForModelError` | ни один канал не обслуживает запрошенную модель |
205
+ | `SlotWaitTimeoutError` | свободный слот не появился за `acquire_timeout` |
206
+ | `AllChannelsStoppedError` | все каналы в стопе; `resume_at` — когда вернётся первый |
207
+ | `RateLimitError` | 429 пережил все ретраи |
208
+ | `ServerError` | 5xx или сетевая ошибка пережили все ретраи |
209
+ | `ApiError` | прочие неретраябельные ответы API (4xx); базовый класс двух предыдущих |
210
+ | `StreamInterruptedError` | стрим оборвался после уже отданных чанков |
211
+
212
+ У `ApiError` и наследников доступны `status_code`, `body`, `channel`,
213
+ `request_id`. Типовая обработка: `SlotWaitTimeoutError` и `AllChannelsStoppedError`
214
+ — перегрузка, имеет смысл отдать 429/503 наверх; `ApiError` — ошибка запроса,
215
+ ретраить бесполезно.
216
+
217
+ ## Метаданные ответа
218
+
219
+ Каждый результат (`ChatResult`, `EmbedResult`, финальный `StreamChunk`) несёт
220
+ `meta: ResponseMeta` — `request_id`, `headers`, `channel`, `model`, `attempts`,
221
+ `duration_ms`, `status_code`. Это сквозной способ узнать, через какой канал
222
+ ушёл запрос и сколько он занял, например для логов и метрик.
223
+
224
+ ## Тесты
225
+
226
+ ```
227
+ python -m venv .venv
228
+ .venv\Scripts\pip install -e ".[dev]"
229
+ .venv\Scripts\python -m pytest
230
+ ```
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "gigamux"
3
+ version = "0.2.0"
4
+ description = "GigaChat client with certificate pool, shared limits and stop events"
5
+ readme = "README.md"
6
+ license = "LicenseRef-Proprietary"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Kalentev Leon Konstantinovich", email = "itqop@icloud.com" },
10
+ ]
11
+ requires-python = ">=3.12"
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "httpx>=0.27",
23
+ "coredis>=6.7",
24
+ "pydantic>=2.7",
25
+ "loguru>=0.7",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ langchain = ["langchain-core>=1.0"]
30
+ dev = [
31
+ "pytest>=8",
32
+ "pytest-asyncio>=0.24",
33
+ "fakeredis[lua]>=2.25",
34
+ "langchain-core>=1.0",
35
+ "langgraph>=1.0",
36
+ "ruff>=0.8",
37
+ ]
38
+
39
+ [tool.ruff]
40
+ line-length = 110
41
+ src = ["src", "tests"]
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+ testpaths = ["tests"]
49
+
50
+ [build-system]
51
+ requires = ["hatchling"]
52
+ build-backend = "hatchling.build"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/gigamux"]
56
+
57
+ [tool.hatch.build.targets.sdist]
58
+ only-include = ["src", "tests", "README.md", "pyproject.toml"]
@@ -0,0 +1,52 @@
1
+ """GigaChat client with certificate pool, shared limits and stop events."""
2
+
3
+ from gigamux.client import GigaCoreClient
4
+ from gigamux.config import ChannelConfig, ClientConfig, config_from_env
5
+ from gigamux.errors import (
6
+ AllChannelsStoppedError,
7
+ ApiError,
8
+ ConfigError,
9
+ GigaClientError,
10
+ NoChannelForModelError,
11
+ RateLimitError,
12
+ ServerError,
13
+ SlotWaitTimeoutError,
14
+ StreamInterruptedError,
15
+ )
16
+ from gigamux.models import (
17
+ ChatRequest,
18
+ ChatResult,
19
+ EmbedResult,
20
+ Message,
21
+ ResponseMeta,
22
+ StopEvent,
23
+ StreamChunk,
24
+ Usage,
25
+ )
26
+ from gigamux.stop_parser import StopEventParser, default_stop_parser
27
+
28
+ __all__ = [
29
+ "AllChannelsStoppedError",
30
+ "ApiError",
31
+ "ChannelConfig",
32
+ "ChatRequest",
33
+ "ChatResult",
34
+ "ClientConfig",
35
+ "ConfigError",
36
+ "EmbedResult",
37
+ "GigaClientError",
38
+ "GigaCoreClient",
39
+ "Message",
40
+ "NoChannelForModelError",
41
+ "RateLimitError",
42
+ "ResponseMeta",
43
+ "ServerError",
44
+ "SlotWaitTimeoutError",
45
+ "StopEvent",
46
+ "StopEventParser",
47
+ "StreamChunk",
48
+ "StreamInterruptedError",
49
+ "Usage",
50
+ "config_from_env",
51
+ "default_stop_parser",
52
+ ]
@@ -0,0 +1 @@
1
+ """Optional framework adapters; importing them requires the extras installed."""