Metadata-Version: 2.5
Name: gigamux
Version: 0.6.3rc1
Summary: GigaChat client with certificate pool, shared limits and stop events
Author-email: Leon Kalentev <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.2
Requires-Dist: json-repair<1,>=0.30
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 на локальные счётчики и drop-in адаптеры
LangChain. Сервисы переходят на библиотеку заменой импорта, retry-логика и
троттлинг живут в ядре.

Запускаемые примеры по каждому сценарию v1 — в [docs/v1](docs/v1/README.md).

## 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]`.

Проверка контента на провокационность (`POST filter/check`, модель
`Gigafilter`):

```python
from gigamux import Message

verdict = await client.check_filter(
    "Gigafilter",
    (Message(role="user", content="проверяемый текст"),),
    settings={"neuro": True, "blacklist": True, "whitelist": True},
)
print(verdict.is_profane, verdict.filter_tokens)
```

`is_profane` — единый вердикт на весь батч сообщений (не по каждому
сообщению). `settings` опционален. Эндпоинт только v1: на v2-канале запрос
автоматически уходит на соседний `…/v1`.

## 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("...")
```

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

- `astream`/`astream_events` с привязанными функциями **стримят**: текст идёт
  дельтами, а вызов приезжает отдельным чанком с `tool_call_chunks`,
  `function_call` и `functions_state_id` — агрегация даёт то же сообщение, что
  и `ainvoke`. До 0.6.3 адаптер здесь молча делал обычный вызов и отдавал один
  чанк, потому что ядро стримить с функциями отказывалось; живой прогон контура
  показал, что шлюз это умеет.
- `astream` с `response_format` по-прежнему отдаёт один чанк: ремонт JSON и
  контракт `StructuredOutputError` живут в унарном пути, и стримить
  структурированный ответ значило бы потерять оба.
- Параллельные 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` шлёт `response_format` с JSON-схемой (`type: json_schema`,
  `strict: true`); ответ проходит через ремонт JSON, описанный ниже.
- `few_shot_examples` пробрасываются из `metadata` инструмента.

## Ремонт JSON-ответа

Модель, которой заказан `response_format` с JSON-схемой, отвечает текстом, и
этот текст иногда не разбирается: оборванная скобка, ```-забор, преамбула перед
объектом. Такой ответ библиотека чинит, а если ремонт не помог — переспрашивает.

```python
result = await core.chat(ChatRequest(
    model="GigaChat-Max",
    messages=(Message(role="user", content="верни объект по схеме"),),
    response_format={"type": "json_schema", "schema": {...}, "strict": True},
))
data = json.loads(result.content)      # разбор в объект — на потребителе
result.json_repaired                   # True, если текст починен библиотекой
```

Семантика:

- Включается само на `response_format["type"] == "json_schema"`. Форматы `text`
  и `regex`, как и запрос без `response_format`, не затрагиваются.
- Рубильник — `GIGA_JSON_REPAIR` (по умолчанию включено), квота повторов —
  `GIGA_JSON_RETRIES` (по умолчанию `1`, `0` оставляет ремонт без повторов).
- Ответ, в котором модель вызвала инструмент, под проверку не попадает: у него
  пустой `content` по обоим кодекам, и судить его означало бы принять пустую
  строку за битый JSON. Запрос вправе нести `functions` и `response_format` разом.
- Повтор шлёт **тот же** запрос без изменений: сообщения не дописываются, на
  ошибку модели не указывается. При `temperature=0` второй ответ вполне может
  повторить первый — рычаг здесь слабый, ценой в одну попытку.
- Ядро проверяет только разбираемость. Соответствие схеме — работа
  необязательного `chat(..., validate=...)`: его отказ тратит ремонтный круг так
  же, как битый JSON, а исключение валидатора наружу не выходит. Валидатор
  работает и при выключенном `GIGA_JSON_REPAIR` — это проверка вызывающего, и
  молча пропускать её из-за рубильника было бы неправильно.
- Обрезанный ответ (`finish_reason="length"`), который не разбирается,
  отбивается сразу: ремонт закрыл бы структуру поверх недостающих данных, а
  повтор упёрся бы в тот же `max_tokens`. Поднимайте лимит. Обрезанный ответ,
  который разбирается, принимается как есть.
- Когда квота исчерпана — `StructuredOutputError` с `raw`, `reason`,
  `finish_reason`, `attempts`, `truncated` и `result`. Сырой текст доступен на
  `.raw`, но в текст исключения не входит: сообщение уезжает в аналитику мимо
  `events_include_text`, а по той же причине от валидатора берётся только имя
  класса исключения — валидатор на pydantic цитирует входные данные.
- Стриминг не затрагивается: чанки уже у потребителя, чинить нечего.

Что важно знать до включения:

- **Починенный текст — это пересборка, а не исходные байты.** Пробелы
  нормализуются, несколько верхнеуровневых значений склеиваются в массив
  (`{"a":1} {"b":2}` → `[{"a":1},{"b":2}]`). Кириллица сохраняется как есть.
  Эвристики вытаскивают JSON и из прозы, поэтому отказ модели, в котором
  случайно есть скобка, может «починиться» в JSON, не имеющий отношения к
  вопросу. Если форма ответа важна — передавайте `validate=`.
- **Каждый провалившийся круг уезжает в аналитику отдельным событием** с
  `is_error=True` и `error_class="structured_output"` (статус `200`, канал и
  `request_id` того ответа). То есть удавшийся со второго круга `chat()` даст
  одно событие ошибки и одно успешное — при подсчёте error-rate это надо
  учитывать, иначе метрика скакнёт после апгрейда.
- **Круги умножаются на транспорт.** Круг — это не один запрос: он заново
  проходит цепочку кандидатов моделей и ретраи транспорта. Верхняя граница —
  `круги × кандидаты × попытки`.
- **Общий бюджет — это дедлайн ожидания слота, а не общее время вызова.**
  `acquire_timeout` ограничивает ожидание слота и ретраи 5xx; сам HTTP-запрос
  ограничен `read_timeout`. Круг, потративший время внутри запроса, может
  увести суммарную задержку `chat()` за `acquire_timeout`.
- **Адаптер LangChain остаётся при своём контракте:** отвергнутый ответ
  возвращается ему текстом, и парсер падает на нём ровно как до появления
  ремонта, поэтому `with_structured_output(..., include_raw=True)` по-прежнему
  не бросает, а кладёт причину в `parsing_error`.

## Стриминг

Ядро отдаёт дельты как `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` без молчаливого ретрая. `core.stream()` стримит и с
объявленными функциями — на обеих версиях, и у адаптера `llm.astream(...)`
тоже: вызов приходит отдельным чанком (см. раздел про function calling).
Один чанк остаётся только у `response_format`.

Потребитель, который может бросить стрим до конца, должен оборачивать его в
`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=300.0,
    max_retries=None,
)

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

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

| Поле | Дефолт | Назначение |
|---|---|---|
| `acquire_timeout` | 300.0 | дедлайн на **всю операцию**: ожидание слота + ретраи 5xx (429 — reroute без ожидания); `None` — без дедлайна (индексация) |
| `lease_ttl` / `heartbeat_interval` | 120 / ttl/3 | время жизни слота в Redis и период продления |
| `slot_max_lifetime` | 900.0 | максимум продления слота keepalive-ом; дальше lease истекает по TTL |
| `max_retries` / `backoff_factor` / `backoff_max` | `None` / 1.0 / 30.0 | потолок ретраев 5xx (`None` — без счётчика, ограничено только `acquire_timeout`) + экспоненциальная пауза с джиттером |
| `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)`.

**Личность канала: физический сертификат или `cn`.** Локально канал несёт
mTLS-пару (`cert_file`/`key_file`). За шлюзом, который сам хранит сертификаты
и переключает их по CN, вместо путей задаётся `cn` — тогда каждый запрос
канала уходит с заголовком `ai-custom-client-cert: <cn>`. Каналы, смотрящие
в один `base_url`, обязаны каждый нести личность (пару или `cn`) — иначе шлюз
их не различит и конфиг падает на старте. Одиночный канал без личности валиден
(шлюз возьмёт первый сертификат неймспейса).

```python
ChannelConfig(
    name="cert-a",
    base_url="https://giga.internal:10500/v1",
    cn="my-namespace-cert-a",
    limits={"GigaChat-2-Max": 2},
)
```

**Модель таймаутов и ретраев.** Три независимых уровня: `acquire_timeout`
— дедлайн на всю операцию (ожидание слота + все ретраи), `read_timeout` —
таймаут одного HTTP-аттемпта в гигу, `max_retries` — опциональный потолок
числа ретраев. По умолчанию `max_retries=None`, поэтому ретраи **5xx**
ограничены **только временем**: запрос терпит серверный шторм пока есть
бюджет `acquire_timeout`, и лишь затем падает с `RequestTimeoutError`
(с логом `giving up ... reason=deadline` — тихих смертей нет). Жёсткий
потолок попыток включается заданием `max_retries=N`: тогда сдача по тому,
что наступит раньше — счётчик или дедлайн. **429 не ретраится**: канал под
rate-limit сразу перебирается на другой (reroute без backoff), а когда 429
отдали все каналы — немедленный `RateLimitError`. `acquire_timeout=None`
вместе с `max_retries=None` означает «ретраить 5xx до победы» (бесконечно).

### Группы каналов

Каждый канал принадлежит одной группе (`group`, по умолчанию `"default"`).
Группы, перечисленные в `default_groups`, обслуживают вызовы, которые группу не
называют; остальные группы приватны — в них попадает только тот, кто назвал их
явно.

```jsonc
// GIGA_CHANNELS
[
  {"name": "os-01", "group": "opensource",  "base_url": "https://os-01.example/v1",  "limits": {"*": 5}},
  {"name": "gc-01", "group": "gigachat",    "base_url": "https://gc-01.example/v1",  "limits": {"*": 5}},
  {"name": "cx-01", "group": "consumer-x",  "base_url": "https://cx-01.example/v1",  "limits": {"*": 5}}
]

// GIGA_DEFAULT_GROUPS
["opensource", "gigachat"]
```

```python
await client.chat(request)                                     # дефолтный пул
await client.chat(request, groups=["consumer-x"])              # только своя группа
await client.chat(request, groups=["consumer-x", "gigachat"])  # своя, затем перелив
```

Порядок в списке — приоритет: следующая группа берётся только тогда, когда ни в
одной предыдущей нет свободного слота. `groups=[]` и `groups=None` означают
дефолтный набор; голая строка (`groups="consumer-x"`) принимается как одно имя
группы, а пустая (`groups=""`) — тоже дефолтный набор, чтобы обёртка вида
`request.query.get("groups", "")` не падала на «вызывающий не назвал ничего».
Неизвестное имя группы — `UnknownChannelGroupError`, до отправки запроса
и до захвата слота (для `stream()` — на первой итерации, так как это асинхронный
генератор, но всё так же до похода в шлюз).

**Исключение из правила перелива — `prefer_not`.** Канал, только что отдавший
**429**, из списка кандидатов *исключается* (`avoid`) — приоритет групп при этом
не нарушается. А канал, помеченный `prefer_not` (после **5xx** или неудачной
последней попытки), не исключается, а уезжает в самый **конец** списка — через
границы групп, потому что он последнее средство по определению. Следствие: если
в приоритетной группе свободный слот остался только на этом канале, вызов войдёт
в группу более низкого приоритета, хотя формально слот в приоритетной был.

Обратный случай важнее для тех, кто выбирает размер группы: **`prefer_not`
уезжает в конец, но здоровый сосед по той же группе по-прежнему старше следующей
группы**. Поэтому 5xx переливает вниз только тогда, когда в приоритетной группе
не осталось других кандидатов. Группа из двух каналов, где 5xx отдают оба, будет
перебирать их между собой до `max_retries`/`acquire_timeout` и не уйдёт в
следующую группу вовсе — перелив по приоритету отвечает за занятость слотов, а не
за отказы. Если нужна деградация именно по ошибкам, её место — на стороне
вызывающего, в выборе списка групп.

`probe()` — единственный из шести методов, который на неизвестной группе не
бросает: он ловит `GigaClientError` и возвращает `ProbeResult(False, ...)`.
Дашборд, который смотрит только на `ok`, прочитает опечатку в имени группы как
«модель недоступна»; полный текст `UnknownChannelGroupError` при этом лежит в
`ProbeResult.error` — если различать эти два случая важно, смотреть надо туда.

`groups` — keyword-only параметр и принимается всеми вызовами: `chat`, `stream`,
`embed`, `count_tokens`, `check_filter`, `probe`. Дефолтный набор остаётся одним
плоским пулом: внутри него каналы ранжируются по свободным слотам, как и до
появления групп; позиционный приоритет включает только явный список.

`default_groups` обязан называть группы, которые объявляет хотя бы один канал —
иначе конфиг не проходит валидацию на старте (`ConfigError` из
`config_from_env`): опечатка иначе молча опустошила бы дефолтный пул. Отсюда
следствие: если развести **все** каналы по именованным группам и не задать
`default_groups`, старт упадёт — дефолт `["default"]` не будет назван никем.

Группа канала, обслужившего запрос, видна в `ResponseMeta.group`,
`SlotEvent.group`, `GigaEvent.channel_group` и в строке лога ответа (`group=`);
запрошенный набор — в `GigaEvent.requested_groups` (`None`, когда вызов не
называл групп).

**Порядок обновления:** сначала выкатите gigamux ≥ 0.6.0, потом добавляйте
`group` в конфиг — на версиях ниже это поле не пройдёт валидацию.

## Версии API (v1 и v2)

Библиотека говорит и на v1, и на v2 GigaChat одним и тем же публичным
интерфейсом — версия выбирается **по каналу**. Версия определяется по
`base_url` (суффикс `…/v2` → v2) либо явным полем `api_version`; дефолт — v1.

```python
ChannelConfig(
    name="v2",
    base_url="https://giga.internal:10501/v2",  # api_version выводится как v2
    limits={"GigaChat-2-Max": 4},
)
ChannelConfig(
    name="v2-explicit",
    base_url="https://giga.internal:10501/gateway",
    api_version="v2",
    limits={"GigaChat-2-Max": 4},
)
```

Один канал = одна версия; разные версии — разные каналы пула. Код вызова
(`chat`/`stream`/`embed`/`count_tokens`) и адаптеры LangChain не меняются:
кодек версии собирает тело запроса и разбирает ответ. v2 добавляет `tools`,
content-как-массив, конверт `messages[]` и событийный SSE
(`response.message.done`); состояние функций (`functions_state_id` ↔
`tools_state_id`) прокидывается прозрачно. Эмбеддинги и `tokens/count`
существуют только в v1 и на v2-канале автоматически маршрутизируются на
соседний `…/v1`-путь.

### Инструменты: `functions` работает на обеих версиях, `tools` — только на v2

`functions`/`function_call` — версионно-нейтральный способ объявить **свои**
функции. Кодек кладёт их туда, где их читает его версия: на v1 это
верхнеуровневый `functions`, на v2 — запись массива `tools`:

```json
"tools": [{"functions": {"specifications": [{"name": "get_weather", "parameters": {…}}]}}]
```

Знать версию для этого не нужно — в этом и смысл. До 0.6.3 v2-кодек отправлял
v1-форму: шлюз отвечал **200**, функции до модели не доезжали, и ответ выглядел
как «модель решила ничего не вызывать».

`tools`/`tool_choice` описывают **серверные** инструменты шлюза и существуют
только в v2. На v1-канале они теперь поднимают `UnsupportedRequestError` — не
потому что библиотека привередничает, а потому что раньше они молча терялись:
тело v1 их не сериализовало, шлюз отвечал 200, и отличить «инструмент не
вызвали» от «объявление никуда не ушло» было нельзя.

Объявить одни и те же функции дважды — и в `functions`, и записью `functions`
внутри `tools` — тоже ошибка: шлюз отвергает такое тело как невалидный JSON.

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

| Переменная | Назначение |
|---|---|
| `GIGA_CHANNELS` | JSON-список каналов (`name`, `base_url`, `cert_file`, `key_file`, `ca_bundle`, `cn`, `api_version`, `group`, `limits`) |
| `GIGA_DEFAULT_GROUPS` | JSON-список групп, обслуживающих вызовы без `groups`; не задан — `["default"]` |
| `GIGA_EVENTS_INCLUDE_TEXT` | `true`/`false`: класть ли текст промпта/ответа в `GigaEvent` (дефолт false, PII) |
| `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` | дедлайн на всю операцию (слот + ретраи), дефолт 300; `none`/`null` — без дедлайна |
| `GIGA_LEASE_TTL` / `GIGA_HEARTBEAT_INTERVAL` / `GIGA_SLOT_MAX_LIFETIME` | тайминги lease |
| `GIGA_MAX_RETRIES` / `GIGA_BACKOFF_FACTOR` / `GIGA_BACKOFF_MAX` | политика ретраев; `GIGA_MAX_RETRIES=none` — без счётчика (только по времени) |
| `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 |
| `GIGA_STOPEVENT_MODE` | `stop` (дефолт) — при StopEvent летит `StopEventError`; `fallback` — переход на резервную модель |
| `GIGA_FALLBACK_MODELS` | JSON-список резервных моделей для `fallback`-режима, напр. `["GigaChat-Pro","GigaChat-2"]` |
| `GIGA_PREVIEW_ENABLED` | `true`/`1`/`yes`/`on` включает preview-маршрутизацию (дефолт выключено) |
| `GIGA_PREVIEW_MODELS` | JSON-карта основная→preview-модель, напр. `{"GigaChat":"GigaChat-preview"}` |
| `GIGA_PREVIEW_FRACTION` | доля трафика на preview, `0..0.05` (потолок 5%), дефолт `0.05` |
| `GIGA_PREVIEW_TIMEOUT` | бюджет одной preview-попытки в секундах (дефолт `5`); залипший preview уступает основной модели, не жгя весь `acquire_timeout` |
| `GIGA_JSON_REPAIR` | `true`/`false`: чинить ли неразбираемый ответ на `json_schema`-запрос (дефолт включено) |
| `GIGA_JSON_RETRIES` | сколько раз переспросить, когда ремонт не помог; `0..5`, дефолт `1` |
| `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` (по умолчанию 300 с, индексация — без таймаута), затем `SlotWaitTimeoutError`.
Этот же дедлайн ограничивает ретраи **5xx**: когда он истекает под длительным
серверным штормом, вызов падает с `RequestTimeoutError`, а не молча.
5xx на канале прозрачно ретраятся, а **429 перебирается** на другой канал
(reroute через `prefer_not`) без backoff; когда 429 отдали все каналы, сразу
летит `RateLimitError`. «Все каналы» здесь — все каналы выбранных групп: вызов
с `groups` не ждёт чужие каналы до `acquire_timeout`, а падает сразу, как только
исчерпал свои. Ошибка посреди стрима не ретраится молча — `StreamInterruptedError`.

Когда Redis недоступен, лимиты не отключаются, а деградируют: после
`redis_failure_threshold` ошибок подряд реплика считает слоты у себя и допускает
`max(1, лимит // replica_count)` — свою долю общего лимита. Redis переопрашивается
не чаще раза в `redis_probe_interval` — из `acquire`, `in_use` и из keepalive,
чтобы реплика, занятая одним длинным стримом, тоже возвращалась. На возврате
реплика сперва заново утверждает в Redis все свои живые аренды, с остатком срока,
и только потом снова допускает по общему счёту; затем разбирает расхождения,
накопившиеся за время утверждения. Ещё `redis_probe_interval + heartbeat_interval`
после этого реплика держится своей доли: соседи публикуют аренды, когда сами
дойдут до Redis, и до этого он недосчитывает флот. Цена — реплика ненадолго не
занимает слоты мёртвого соседа. Обратное окно, момент **срыва** в фолбэк,
ограничено сверху `лимит + (реплик − 1) × доля` и живёт до первого неудачного
обращения к Redis у отстающей реплики.

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

## Ошибки

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

| Исключение | Когда летит |
|---|---|
| `ConfigError` | невалидная конфигурация или переменные окружения |
| `NoChannelForModelError` | ни один канал не обслуживает запрошенную модель (в выбранных группах) |
| `UnknownChannelGroupError` | вызов назвал группу, которой нет ни у одного канала; летит до отправки запроса |
| `SlotWaitTimeoutError` | свободный слот не появился за `acquire_timeout` |
| `RequestTimeoutError` | дедлайн `acquire_timeout` истёк на ретраях 5xx; несёт `attempts`, `last_status`, `channel` |
| `RateLimitError` | 429 вернули все каналы (reroute исчерпан; при одном канале — сразу) |
| `ServerError` | 5xx или сетевая ошибка пережили все ретраи |
| `ApiError` | прочие неретраябельные ответы API (4xx); базовый класс двух предыдущих |
| `HistoryError` | результат функции в истории не следует за `assistant`-вызовом с тем же именем |
| `StreamInterruptedError` | стрим оборвался после уже отданных чанков |
| `ResponseDecodeError` | ответ пришёл с 200, но тело не разобралось в ожидаемую форму |
| `StreamingToolsUnsupportedError` | `stream()` с функциями, а ни один канал выбранных групп не умеет стримить вызовы инструментов; наследует и `ValueError`. С 0.6.3 оба кодека умеют, поэтому на штатной конфигурации не возникает |
| `StructuredOutputError` | ответ на `json_schema`-запрос не собрался в JSON: ремонт не помог и квота повторов исчерпана, либо ответ обрезан по `max_tokens` |

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

У `StructuredOutputError` доступны `raw` (отвергнутый текст), `reason`,
`finish_reason`, `attempts`, `truncated` и `result` — целиком отвергнутый
`ChatResult`, чтобы у вызывающего остались `meta.request_id` и `usage` того
круга: шлюз его посчитал. Сам текст в сообщение исключения не входит, см.
раздел про ремонт JSON.

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

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

## Логирование

gigamux пишет через глобальный логгер loguru (`from loguru import logger`) —
без собственных хендлеров и без `logger.disable`. Из коробки записи видны в
stderr; заглушить библиотеку целиком можно через `logger.disable("gigamux")`.

Если сервис строит свою конфигурацию loguru с патчером (проставляет
`extra`-поля, сериализует записи, фильтрует хендлеры по `extra`), патчер
обязан быть **глобальным**:

```python
from loguru import logger

logger.configure(patcher=my_patcher)   # так — видит записи всех библиотек
patched = logger.patch(my_patcher)     # так НЕЛЬЗЯ полагаться для чужих записей
```

`logger.patch()` возвращает новый инстанс, и патчер применяется только к
записям, отправленным через него. Записи gigamux (и любой библиотеки на голом
глобальном логгере) через инстанс-патчер не проходят: они не получают
проставляемых им `extra`-полей, и дальше два типовых исхода —

- строгий фильтр хендлера вида `record["extra"].get("target") == "log"`
  молча отбрасывает их (логи библиотеки «пропадают»);
- sink с форматом `format="{extra[serialized]}"` падает на каждой записи с
  `KeyError` (`--- Logging error in Loguru Handler ---`).

Глобальный патчер видит записи **всех** библиотек, поэтому он не должен
предполагать форму чужих `extra` (проверяйте типы через `isinstance`, не
обращайтесь к ключам без `.get`): упавший патчер роняет лог-вызов в точке
чужого кода. Хендлер-фильтры при этом можно оставлять строгими — патчер
отрабатывает раньше фильтров и сам проставляет недостающие поля.

## Проброс заголовков

`GigaCoreClient(header_provider=...)` принимает колбэк без аргументов, чьи
заголовки добавляются в каждый исходящий запрос (unary и stream). Это точка для
сквозного трейс-идентификатора: агент кладёт `X-Trace-Id`/`traceparent` в
contextvar, провайдер читает его на каждую попытку — значение переживает
reroute/retry. Ключи/значения приводятся к `str`; любой сбой провайдера не роняет
запрос (заголовки пропускаются с логом ошибки). Генерация и валидация
идентификатора — ответственность агента.

```python
from contextvars import ContextVar

trace_id: ContextVar[str] = ContextVar("trace_id")
client = GigaCoreClient(config, header_provider=lambda: {"X-Trace-Id": trace_id.get()})
```

## Fallback и preview-модели

`ModelRouter` превращает запрошенную модель в упорядоченную цепочку кандидатов, по
которой клиент идёт при переключениях. Настраивается через конфиг/env; по умолчанию
(режим `stop`, preview выключен) цепочка равна одной запрошенной модели и поведение
не отличается от прежнего.

**Fallback на StopEvent.** Когда модель отвечает временной недоступностью (HTTP 423
или 403 с сигнальным сообщением → `StopEventError`), в режиме `fallback` запрос
уходит на следующую модель из плоского списка. Переключение происходит только на
StopEvent — 429/5xx/фатальные ошибки обрабатываются в пределах модели и не
маскируются. Общий `acquire_timeout`-дедлайн делится на всех кандидатов.

```bash
GIGA_STOPEVENT_MODE=fallback
GIGA_FALLBACK_MODELS=["GigaChat-Pro","GigaChat-2"]
```

**Preview-маршрутизация.** Мастер-тумблер (дефолт выключен) уводит долю трафика
(≤5%, потолок enforced) на явно указанную preview-модель; на любой ошибке preview
запрос откатывается на основную модель. В стриме переход возможен только до первого
чанка — после отдачи данных ошибка пробрасывается без рестарта.

```bash
GIGA_PREVIEW_ENABLED=true
GIGA_PREVIEW_MODELS={"GigaChat":"GigaChat-preview"}
GIGA_PREVIEW_FRACTION=0.05
```

Решение «делать ли preview» и мониторинг доли — ответственность агента; библиотека
даёт механизм и enforcement потолка. Резервные/preview-модели должны обслуживаться
каналами, иначе переключение упрётся в `NoChannelForModelError`.

## Метрики насыщения (saturation)

`GigaCoreClient(on_slot_event=...)` принимает колбэк, вызываемый на события слота
конкурентности: `SlotEvent(kind, channel, model, wait_seconds, group)`, где `kind` —
`"acquired"` (слот получен, `wait_seconds` — время ожидания), `"released"` (освобождён)
или `"wait_timeout"` (не дождались слота за `acquire_timeout`; `channel=""`, `group=""`).
`group` — группа канала, так что насыщение считается и по группам, а не только по пулу. Это
единственный «золотой сигнал», которого нет в трейсинге исходящих вызовов. Пары
`acquired`/`released` сбалансированы, поэтому занятость считается инкрементами без
обращений к лимит-стору. Колбэк best-effort: его ошибка логируется и не роняет запрос.

```python
from prometheus_client import Counter, Gauge, Histogram

inflight = Gauge("giga_inflight", "occupied slots", ["channel", "model"])
wait = Histogram("giga_slot_wait_seconds", "slot wait", ["model"])
rejected = Counter("giga_slot_wait_timeout_total", "acquire timeouts", ["model"])

def on_slot(event):
    if event.kind == "acquired":
        inflight.labels(event.channel, event.model).inc()
        wait.labels(event.model).observe(event.wait_seconds)
    elif event.kind == "released":
        inflight.labels(event.channel, event.model).dec()
    elif event.kind == "wait_timeout":
        rejected.labels(event.model).inc()

client = GigaCoreClient(config, on_slot_event=on_slot)
```

Latency/Errors/Traffic снимаются из `on_response(meta)` (`duration_ms`, статус, канал,
модель, `attempts`) — см. «Метаданные ответа».

## События наблюдаемости (GigaEvent)

`GigaCoreClient(on_event=...)` — один структурный `GigaEvent` на **терминальный**
исход каждого вызова (chat / stream / embeddings / count_tokens / filter): успех
или доменная ошибка. В отличие от `on_response`, событие видит и то, где HTTP-ответа
не было: транспортные сбои (`status_code=0`), slot-таймауты, decode-ошибки. Внутри —
токены из `Usage`, `request_kind`, `error_class`, `attempts`, `duration_ms`,
`ttft_ms` (стрим), `queue_wait_ms`; проекции `to_pg_row()` (метрики без тяжёлого
текста) и `to_os_doc()` (Q&A + фасеты) — стык двух стоков по `request_id`.

### Что приходит в колбэк

`on_event` получает один `frozen`-датакласс `GigaEvent`. Поля (все, кроме
Q&A-блока, заполнены всегда):

| Группа | Поля |
|---|---|
| время / трассировка | `ts` (ISO-8601 UTC), `request_id` (X-Request-ID; `None` при транспортном сбое), `session_id`, `trace_id` |
| маршрутизация | `model`, `channel` (`None`, если слот так и не взяли), `channel_group` (группа обслужившего канала), `requested_groups` (что назвал вызывающий; `None`, когда он взял дефолтный набор), `request_kind` (`chat`/`stream`/`embeddings`/`count_tokens`/`filter`), `stream` |
| исход | `status_code` (`0` = HTTP-ответа не было), `is_error`, `error_class` (`stop_event`/`rate_limit`/`server`/`timeout`/…), `error_message`, `finish_reason`, `attempts` |
| тайминги | `duration_ms` (весь вызов с ретраями), `ttft_ms` (стрим), `queue_wait_ms` (ожидание слотов) |
| токены | `prompt_tokens`, `completion_tokens`, `total_tokens`, `cached_tokens`, `precached_prompt_tokens` |
| инструменты | `tool_names` (объявленные, в порядке объявления), `tool_choice` (`auto`/`none`/имя инструмента), `called_tool_name` (что вызвала модель) — **не PII, заполняются всегда** |
| структурный вывод | `json_verdict` — `clean`/`repaired` на успешном `json_schema`-ответе, `None` когда фича не применялась; на пути отказа всё сказано через `error_class="structured_output"`. **Не PII, заполняется всегда** |
| Q&A (PII, off by default) | `prompt_text`, `prompt_messages`, `response_text`, `function_call` (имя + аргументы вызова), `tools_spec` (полные схемы инструментов), `n_messages` |
| сервис | `app`, `env`, `lib_version` |

Готовых проекции две — они и есть подсказка, как раскладывать событие на два стока:

- `to_pg_row()` → узкая строка метрик: тяжёлый Q&A (`prompt_messages`,
  `response_text`, `function_call`, `tools_spec`) выброшен, `prompt_text` обрезан
  до 256 символов как превью. `tool_names`/`tool_choice`/`called_tool_name`
  остаются — по ним считается, сколько запросов шло с инструментами и чем
  кончилось. Ключи строки = имена колонок.
- `to_os_doc()` → документ поиска: `@timestamp` + `request_id` + фасеты
  (`model`/`channel_group`/`request_kind`/`is_error`/`session_id`) + полный
  Q&A-текст + полные схемы инструментов (`tools_spec`) и tool-ходы истории.

`prompt_messages` — не только `role`/`content`: у сообщений истории сохраняются
`name`, `function_call`, `functions_state_id`, `tools_state_id`, когда они
заданы. Иначе из истории пропадали обе половины tool-раунда: и вызов
ассистента, и результат функции. Оба диалекта читаются одинаково — v1
`functions`/`function_call` и v2 `tools`/`tool_choice` сводятся в одни и те же
`tool_names`/`tool_choice`.

Три тонкости, о которых лучше знать заранее:

- **Конфликт `tool_choice` и `function_call` разрешается ровно так же, как на
  проводе.** `tool_choice` побеждает, когда это словарь с именем/режимом или
  строка `none`/`auto`; любая другая строка кодеком не распознаётся, и в дело
  идёт `function_call` — событие называет то же, что реально ушло.
- **`tool_choice` не пропускает произвольные строки.** Поле заполняется всегда,
  мимо PII-выключателя, поэтому значение, которое не является ни известным
  режимом (`auto`/`none`/`forced`/`one_function`), ни именем объявленного
  инструмента, схлопывается в `"other"`.
- **Фасеты описывают запрос, а не закодированное тело.** Событие собирается из
  того, что попросил вызывающий, поэтому оно одинаково читается на обеих
  версиях. Пустой (не `None`) `tool_names` значит «инструменты объявлены, имён
  извлечь не удалось».

Две проекции соединяются по `request_id`: числа живут в метрик-сторе, текст — в
индексе, JOIN по общему ключу.

### Механизм доставки

Колбэк синхронный и вызывается в горячем пути — он **не должен блокироваться на
I/O**. Механизм доставки в комплекте: `EventPump` (bounded-очередь drop-oldest +
фоновый воркер с батчингом; при переполнении теряется старейшее событие, счётчик
`pump.dropped`) и `EventSink` (Protocol с `async def write(events)`). Сам `write`
крутится в фоне воркера, не в пути запроса, поэтому в нём можно делать настоящий
батч-I/O; исключение из `write` пампу безопасно — он логирует и роняет батч, а не
падает в обработку запроса.

### Слив в Postgres (метрики)

```python
from gigamux import EventPump, GigaCoreClient

PG_COLS = (  # порядок = порядок плейсхолдеров ниже
    "ts", "request_id", "session_id", "trace_id", "model", "channel",
    "channel_group", "requested_groups",
    "request_kind", "stream", "status_code", "is_error", "error_class",
    "error_message", "finish_reason", "attempts", "duration_ms", "ttft_ms",
    "queue_wait_ms", "prompt_tokens", "completion_tokens", "total_tokens",
    "cached_tokens", "precached_prompt_tokens", "prompt_text", "n_messages",
    "tool_names", "tool_choice", "called_tool_name", "json_verdict",
    "app", "env", "lib_version",
)
INSERT_SQL = (
    f"INSERT INTO giga_events ({', '.join(PG_COLS)}) "
    f"VALUES ({', '.join(f'${i}' for i in range(1, len(PG_COLS) + 1))}) "
    "ON CONFLICT (request_id) DO NOTHING"  # идемпотентность, см. ниже
)

class PgSink:
    def __init__(self, pool):
        self._pool = pool

    async def write(self, events):
        rows = [tuple(e.to_pg_row()[c] for c in PG_COLS) for e in events]
        async with self._pool.acquire() as conn:
            await conn.executemany(INSERT_SQL, rows)

async with EventPump(PgSink(pool)) as pump:
    client = GigaCoreClient(config, on_event=pump.push)
    ...
```

- Тип колонок: `ts`/`request_id`/строки → `text`, `*_tokens`/`attempts`/`status_code`
  → `bigint`/`int`, `*_ms` → `double precision`, `is_error`/`stream` → `boolean`.
  `request_id` — `PRIMARY KEY` (или уникальный индекс) для `ON CONFLICT`.
  `tool_names` — `text[]` (в событии это кортеж строк), `tool_choice` и
  `called_tool_name` → `text`. `channel_group` → `text`, `requested_groups` →
  `text[]` (в строке это список — по той же причине, что и `tool_names`).
- Не заводите колонку под каждое поле руками, если не нужно: можно один `jsonb`
  `payload` = `to_pg_row(e)` целиком, а «горячие» фильтруемые поля (`ts`, `model`,
  `is_error`, `request_kind`) вынести отдельными колонками для индексов.

### Слив в OpenSearch (Q&A + поиск)

```python
class OsSink:
    def __init__(self, os_client, index="giga-events"):
        self._os, self._index = os_client, index

    async def write(self, events):
        actions = []
        for e in events:
            doc = e.to_os_doc()
            _id = e.request_id or f"{e.ts}:{e.model}"   # см. про None ниже
            actions.append({"index": {"_index": self._index, "_id": _id}})
            actions.append(doc)
        await self._os.bulk(body=actions)   # один bulk на батч

async with EventPump(OsSink(os_client)) as pump:
    client = GigaCoreClient(config, on_event=pump.push)
    ...
```

- **`_id = request_id`** — ключевой приём. `to_os_doc()` намеренно не кладёт `_id`;
  задавайте его сами в bulk-заголовке, тогда повтор события просто перезапишет
  документ, а не создаст дубль (см. идемпотентность ниже).
- Маппинг индекса: `@timestamp` → `date`, `request_id`/`session_id`/`model`/
  `channel_group`/`request_kind`/`tool_names`/`tool_choice`/`called_tool_name`/`json_verdict` → `keyword`
  (для фасетов/агрегаций), `prompt_text`/`response_text` → `text`,
  `prompt_messages`/`function_call`/`tools_spec` → `object`/`flattened`. Не
  отдавайте их на dynamic mapping — `keyword` vs `text` под фасеты важен, а
  схемы инструментов лучше `flattened`, чтобы произвольные `parameters` не
  взрывали маппинг.
- Один `to_os_doc()` — плоская пара Q&A, поэтому это классический pattern
  «write-once, search-many»; ротацию делайте по `@timestamp` (data stream / ILM).

### Идемпотентность (важно)

Доставка при отмене/закрытии — **at-least-once**: если cancel прервал `write`,
памп пере-отправит весь батч целиком на flush-on-cancel. Поэтому оба стока должны
быть идемпотентны по `request_id` (`ON CONFLICT DO NOTHING` / bulk `_id`).
Тонкость: у транспортных сбоев `request_id is None` — для них дедуп по ключу не
работает; давайте им синтетический ключ (`ts`+`model`, как выше) либо миритесь с
редким дублем именно на этих событиях.

### Сквозной trace_id

`trace_id` — ваш сквозной идентификатор запроса (например, порождённый фронтом).
Передаётся keyword-аргументом в любой вызов и возвращается **тождественным**:

```python
res = await client.chat(request, trace_id="T-123")
res.meta.trace_id            # "T-123"

try:
    await client.chat(request, trace_id="T-123")
except GigaClientError as e:
    e.trace_id               # "T-123" — id доступен и на ошибке
```

Принимают `trace_id` **все** методы, но возвращается он по-разному — потому что не
у всех результатов есть `meta`:

| Метод | На результате | На исключении | В логе и `GigaEvent` |
|---|---|---|---|
| `chat`, `embed` | `res.meta.trace_id` | да | да |
| `stream` | `meta` финального чанка | да | да |
| `count_tokens`, `check_filter` | — (`TokenCount`/`FilterResult` без `meta`) | да | да |
| `probe` | — (`ProbeResult` без `meta`) | — (`probe` не бросает) | да |

Для трёх нижних значение тождественно переданному, поэтому вызывающий его и так
держит — отдельное echo-поле в эти типы не вводилось намеренно. Учти, что `probe`
гасит ошибки и возвращает `ProbeResult(ok=False, error=…)`, так что при его провале
trace_id виден только в логе и в событии.

⚠️ **В стриминге `meta` несёт только финальный чанк** (у остальных `meta is None`).
Если потребитель прерывает итерацию раньше — например, по `finish_reason == "stop"`
из чанка кодека — он до `meta.trace_id` не доберётся. Дочитывайте генератор до
конца либо держите trace_id на своей стороне.

**В GigaChat не отправляется.** Дока провайдера не содержит слота под сквозной
trace: есть только `X-Request-ID` / `X-Session-ID` / `X-Client-ID`, где
`X-Request-ID` уже соответствует `ResponseMeta.request_id`, назначаемому шлюзом.
Библиотека `trace_id` не генерирует, не изменяет и не читает из ответа — только
переносит, поэтому вернувшееся значение всегда равно переданному.

Куда попадает: `ResponseMeta.trace_id` (успех), атрибут `trace_id` любого
`GigaClientError` (ошибка), строка лога ответа рядом с `request_id`, и
`GigaEvent.trace_id` — то есть автоматически в ваши Postgres/OpenSearch-стоки,
без обёрток над `on_event`.

Смысл в том, что на обратном пути RAG читает id **с объекта ответа или ошибки**, а
не достаёт из ambient-контекста: это переживает границы контекста (очереди,
хендоффы задач, буферы стрима), где contextvar рвётся.

### PII

По умолчанию событие несёт только метрики и идентификаторы. Q&A-текст (`prompt_text`,
`prompt_messages`, `response_text`, `function_call`) и полные схемы инструментов
(`tools_spec` — в них человекописанные `description` и примеры) заполняются только при
`events_include_text=True` (`GIGA_EVENTS_INCLUDE_TEXT=true`) — слать ли его,
хэшировать или семплировать, решает сток, не либа. Поэтому текст и живёт в
OpenSearch-проекции (её включают осознанно), а метрик-строка Postgres несёт лишь
256-символьное превью. `app`/`env` заполняет потребитель (в обёртке `EventSink` или
враппере события); `trace_id` с 0.5.3 проставляет сама библиотека из аргумента вызова.
Под выключатель **не** попадают `tool_names`/`tool_choice`/`called_tool_name`: имя
инструмента — это код, а не пользовательский текст, и без него нельзя посчитать
tool-использование, не включая PII. По той же причине заполняются всегда
`channel_group` и `requested_groups` — иначе не ответить, сколько трафика
потребителя ушло за пределы его группы.

## Диагностика доступности (probe)

`await client.probe(model)` выполняет дешёвый реальный вызов (`count_tokens`, без
генерации) и возвращает `ProbeResult(ok, model, duration_ms, error)`, **не бросая** на
ожидаемых сбоях — сеть, ошибка API, stop-event, таймаут слота дают `ok=False` с текстом
в `error`. По умолчанию `acquire_timeout=5.0` — проба fail-fast, не виснет на занятом пуле.

```python
result = await client.probe("GigaChat")
if not result.ok:
    logger.warning(f"gigachat probe failed: {result.error}")
```

Это строительный блок для «контрольной корзины» приложения, а **не**
Kubernetes readiness-проба: доступность GigaChat — внешняя зависимость и не должна
гейтить `readinessProbe` сервиса. Проба идёт обычным путём, поэтому при включённом
fallback отражает эффективную обслуживаемость (в т.ч. через резервную модель).

## Тесты

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