Metadata-Version: 2.4
Name: gigamux
Version: 0.2.3
Summary: GigaChat client with certificate pool, shared limits and stop events
Author-email: Kalentev Leon Konstantinovich <itqop@icloud.com>
License-Expression: LicenseRef-Proprietary
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: coredis>=6.7
Requires-Dist: httpx>=0.27
Requires-Dist: loguru>=0.7
Requires-Dist: pydantic>=2.7
Provides-Extra: dev
Requires-Dist: fakeredis[lua]>=2.25; extra == 'dev'
Requires-Dist: langchain-core>=1.0; extra == 'dev'
Requires-Dist: langgraph>=1.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Provides-Extra: langchain
Requires-Dist: langchain-core>=1.0; extra == 'langchain'
Description-Content-Type: text/markdown

# gigamux

Асинхронный клиент GigaChat: пул сертификатов-каналов, общие in-flight лимиты
через Redis (coredis) с fallback на локальные счётчики, stop-события (429 + время
блокировки) и drop-in адаптеры LangChain. Сервисы переходят на библиотеку заменой
импорта, retry-логика и троттлинг живут в ядре.

## Quick start

Ядро напрямую:

```python
from gigamux import GigaCoreClient, config_from_env, ChatRequest, Message

config = config_from_env()
async with GigaCoreClient(config) as client:
    result = await client.chat(
        ChatRequest(
            model="GigaChat-2-Max",
            messages=(Message(role="user", content="привет"),),
        )
    )
    print(result.content, result.meta.channel)
```

LangChain-сервисы (адаптеры импортируются напрямую, требуют extra `langchain`):

```python
from gigamux.adapters.langchain_chat import PooledGigaChat

llm = PooledGigaChat(core=client, model="GigaChat-2-Max", temperature=0.1)
message = await llm.ainvoke("привет")
```

Индексация (один батч = один HTTP-запрос = один слот, нарезка по 20 в адаптере):

```python
from gigamux.adapters.embeddings import PooledGigaEmbeddings

embedder = PooledGigaEmbeddings(core=client, model="EmbeddingsGigaR")
vectors = await embedder.aembed_documents(texts)
```

Подсчёт токенов без генерации (`POST tokens/count`):

```python
counts = await client.count_tokens("GigaChat-2-Max", "сколько здесь токенов?")
print(counts[0].tokens, counts[0].characters)
```

`count_tokens` принимает строку или список строк и всегда возвращает
`list[TokenCount]`.

## Function calling и structured output

`PooledGigaChat` поддерживает инструменты и структурированный вывод —
паритет с langchain-gigachat, включая react-агентов LangGraph:

```python
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent

@tool
def get_weather(city: str) -> str:
    """Return current weather for a city."""
    ...

agent = create_react_agent(llm, [get_weather])
state = await agent.ainvoke({"messages": [...]})
```

Extra `gigamux[langchain]` ставит только langchain-core; для примера выше
нужен отдельно установленный langgraph (`pip install langgraph`).

Структурированный вывод доступен двумя методами эталонной библиотеки:
`function_calling` (по умолчанию) и `json_mode` (через `response_format`).

```python
chain = llm.with_structured_output(MyModel)
chain = llm.with_structured_output(MyModel, method="json_mode")
result = await chain.ainvoke("...")
```

Ограничения и семантика:

- Стриминг с инструментами на уровне API невозможен, поэтому
  `astream`/`astream_events` с привязанными функциями прозрачно выполняют
  обычный вызов и отдают ответ одним чанком: токенного стриминга нет, но
  react-агенты под `astream_events`/`stream_mode="messages"` работают.
- Параллельные tool_calls невозможны на стороне GigaChat: больше одного
  tool_call в сообщении — `ValueError`.
- В `with_structured_output` поддерживаются pydantic-модели обоих поколений
  (v2 и `pydantic.v1`) в обоих методах; v1-класс возвращается экземпляром,
  как и v2.
- `finish_reason` доступен в `response_metadata` каждого ответа адаптера.
- `with_structured_output` методом `function_calling` возвращает `None`,
  если модель ответила текстом вместо вызова функции (поведение эталона);
  с `include_raw=True` сырое сообщение доступно в `output["raw"]`.
- `json_mode` использует бета-фичу API `response_format` — при её
  отсутствии на контуре ошибка идентична поведению langchain-gigachat.
- `few_shot_examples` пробрасываются из `metadata` инструмента.

## Стриминг

Ядро отдаёт дельты как `StreamChunk`; слот канала занят весь стрим, метаданные
приходят в последнем чанке:

```python
request = ChatRequest(
    model="GigaChat-2-Max",
    messages=(Message(role="user", content="расскажи анекдот"),),
)
async for chunk in client.stream(request):
    print(chunk.delta, end="")
    if chunk.meta is not None:
        print(chunk.meta.channel, chunk.meta.duration_ms, chunk.usage)
```

Если API прислал блок `usage` в стриме, он доступен на чанке, который его
принёс, и продублирован на финальном чанке (`chunk.usage`); у адаптера
финальный `AIMessageChunk` несёт `usage_metadata`.

Ошибки до первого чанка ретраятся как обычные запросы; после начала стрима —
`StreamInterruptedError` без молчаливого ретрая. У адаптера работает
`llm.astream(...)`; без инструментов это настоящий токенный стриминг, а с
привязанными функциями ответ приходит одним финальным чанком (см. раздел
про function calling).

Потребитель, который может бросить стрим до конца, должен оборачивать его в
`contextlib.aclosing(...)`, иначе слот освобождается только сборщиком мусора.
Keepalive слота ограничен `slot_max_lifetime` (дефолт 900 с): после этого
лимита lease перестаёт продлеваться и истекает по TTL даже без финализации
генератора.

## Конфигурация в коде

`config_from_env()` — удобный путь, но конфиг можно собрать руками:

```python
from gigamux import ChannelConfig, ClientConfig, GigaCoreClient

config = ClientConfig(
    channels=[
        ChannelConfig(
            name="cert-a",
            base_url="https://giga.internal:10501/v1",
            cert_file="/etc/certs/a/tls.pem",
            key_file="/etc/certs/a/key.pem",
            ca_bundle="/etc/certs/ca.pem",
            limits={"GigaChat-2-Max": 2, "EmbeddingsGigaR": 4},
        ),
        ChannelConfig(
            name="cert-b",
            base_url="https://giga.internal:10502/v1",
            limits={"*": 3},
        ),
    ],
    redis_url="rediss://redis.internal:6380/0",
    redis_username="svc",
    redis_password="...",
    acquire_timeout=30.0,
    max_retries=3,
)

async with GigaCoreClient(config) as client:
    ...
```

Ключевые поля `ClientConfig` (все имеют дефолты):

| Поле | Дефолт | Назначение |
|---|---|---|
| `acquire_timeout` | 30.0 | сколько ждать свободный слот; `None` — ждать без таймаута (индексация) |
| `lease_ttl` / `heartbeat_interval` | 120 / ttl/3 | время жизни слота в Redis и период продления |
| `slot_max_lifetime` | 900.0 | максимум продления слота keepalive-ом; дальше lease истекает по TTL |
| `max_retries` / `backoff_factor` / `backoff_max` | 3 / 1.0 / 30.0 | ретраи 429/5xx с экспоненциальной паузой и джиттером |
| `connect_timeout` / `read_timeout` | 10 / 120 | таймауты HTTP |
| `redis_failure_threshold` / `redis_probe_interval` | 3 / 15.0 | circuit breaker: после N ошибок Redis лимиты локальные, проба каждые 15 с |

`limits` канала — единственный источник правды о том, какие модели канал
обслуживает: ключ — имя модели, значение — число одновременных запросов,
`"*"` — wildcard. `cert_file`/`key_file` задаются только вместе; без них
канал работает без mTLS. `acquire_timeout` можно переопределить и на один
вызов: `client.chat(request, acquire_timeout=None)`.

## Переменные окружения

| Переменная | Назначение |
|---|---|
| `GIGA_CHANNELS` | JSON-список каналов (`name`, `base_url`, `cert_file`, `key_file`, `ca_bundle`, `limits`) |
| `GIGA_REDIS_URL` | URL Redis для распределённых лимитов; не задан -> локальные лимиты |
| `GIGA_REDIS_USERNAME` / `GIGA_REDIS_PASSWORD` | аутентификация Redis |
| `GIGA_REDIS_KEY_FILE` / `GIGA_REDIS_CERT_FILE` / `GIGA_REDIS_CA_BUNDLE` | mTLS для Redis (нужны все три, иначе соединение без TLS с предупреждением) |
| `GIGA_ACQUIRE_TIMEOUT` | таймаут ожидания слота; `none`/`null` — ждать без таймаута |
| `GIGA_LEASE_TTL` / `GIGA_HEARTBEAT_INTERVAL` / `GIGA_SLOT_MAX_LIFETIME` | тайминги lease |
| `GIGA_MAX_RETRIES` / `GIGA_BACKOFF_FACTOR` / `GIGA_BACKOFF_MAX` | политика ретраев |
| `GIGA_CONNECT_TIMEOUT` / `GIGA_READ_TIMEOUT` / `GIGA_WRITE_TIMEOUT` / `GIGA_POOL_TIMEOUT` | таймауты HTTP |
| `GIGA_REDIS_CONNECT_TIMEOUT` / `GIGA_REDIS_STREAM_TIMEOUT` | таймауты Redis |
| `GIGA_REDIS_FAILURE_THRESHOLD` / `GIGA_REDIS_PROBE_INTERVAL` / `GIGA_REPLICA_COUNT` | circuit breaker и локальный fallback |
| `GIGACHAT_HOST` / `GIGACHAT_PORT` / `GIGACHAT_TLS_CERT_FILEPATH` / `GIGACHAT_KEY_FILEPATH` / `GIGACHAT_CA_BUNDLE_FILEPATH` | режим совместимости: один канал из legacy-переменных |
| `GIGACHAT_ENDPOINT` | legacy-путь base_url в режиме совместимости (по умолчанию `/v1`) |
| `GIGACHAT_MAX_CONCURRENCY` | лимит на канал в режиме совместимости (wildcard-модель) |

Если задан `GIGA_CHANNELS`, он имеет приоритет. Иначе из `GIGACHAT_*` строится
пул из одного канала с wildcard-лимитом, что даёт миграцию без изменения конфига.

Начиная с 0.2.1 конфигурация строгая: неизвестное поле в `GIGA_CHANNELS` или
`ClientConfig` (например, опечатка в имени) — это `ConfigError` на старте,
а не молчаливое игнорирование.

## Семантика

Лимит — in-flight слоты на пару канал+модель, общие между репликами. Слот берётся
как lease с TTL в Redis и продлевается heartbeat-ом, поэтому упавший под не держит
слот навсегда. При занятых слотах вызов ждёт в очереди с джиттером до
`acquire_timeout` (онлайн ~30 с, индексация — без таймаута), затем `SlotWaitTimeoutError`.
Stop-событие выводит канал из ротации до времени T и прозрачно ретраит на другом
канале; когда все каналы в стопе — сразу `AllChannelsStoppedError(resume_at)`.
Ошибка посреди стрима не ретраится молча — `StreamInterruptedError`.

GigaChat принимает только одно `system`-сообщение и только первым. Поэтому при
сериализации запроса все `system`-сообщения склеиваются в одно ведущее (содержимое
через `"\n\n"` в порядке появления, пустые/пробельные отбрасываются); относительный
порядок остальных сообщений сохраняется. Запрос с единственным ведущим `system` не
меняется. Когда схлопывается больше одного непустого `system`, пишется одна
`debug`-строка лога — иначе мутация молчит. Это делает библиотеку чуть «прощающей»
относительно `langchain_gigachat` — осознанное улучшение, а не баг.

## Ошибки

Все исключения наследуют `GigaClientError` и импортируются из `gigamux`:

| Исключение | Когда летит |
|---|---|
| `ConfigError` | невалидная конфигурация или переменные окружения |
| `NoChannelForModelError` | ни один канал не обслуживает запрошенную модель |
| `SlotWaitTimeoutError` | свободный слот не появился за `acquire_timeout` |
| `AllChannelsStoppedError` | все каналы в стопе; `resume_at` — когда вернётся первый |
| `RateLimitError` | 429 пережил все ретраи |
| `ServerError` | 5xx или сетевая ошибка пережили все ретраи |
| `ApiError` | прочие неретраябельные ответы API (4xx); базовый класс двух предыдущих |
| `HistoryError` | результат функции в истории не следует за `assistant`-вызовом с тем же именем |
| `StreamInterruptedError` | стрим оборвался после уже отданных чанков |

У `ApiError` и наследников доступны `status_code`, `body`, `channel`,
`request_id`; в `body` кладётся `message` из тела `{status, message}`, когда оно
есть. Типовая обработка: `SlotWaitTimeoutError` и `AllChannelsStoppedError`
— перегрузка, имеет смысл отдать 429/503 наверх; `ApiError` — ошибка запроса,
ретраить бесполезно.

## Метаданные ответа

Каждый результат (`ChatResult`, `EmbedResult`, финальный `StreamChunk`) несёт
`meta: ResponseMeta` — `request_id`, `headers`, `channel`, `model`, `attempts`,
`duration_ms`, `status_code`. Это сквозной способ узнать, через какой канал
ушёл запрос и сколько он занял, например для логов и метрик. На каждый ответ
пишется строка лога с `request_id` (из заголовка ответа), каналом, моделью,
статусом и длительностью — для корреляции с логами goprodigy/core; отключается
через `ClientConfig.log_responses`.

## Тесты

```
python -m venv .venv
.venv\Scripts\pip install -e ".[dev]"
.venv\Scripts\python -m pytest
```
