> ## Documentation Index
> Fetch the complete documentation index at: https://gproxy.leenhawk.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Архитектура

> Устройство исходного кода GPROXY v4: слои sdk, app и host, крейты workspace, путь запроса, переключение при отказе, синхронизация конфигурации, расчёт и сборка под WebAssembly.

Этот документ описывает, как устроено дерево исходного кода GPROXY v4 и как
через него проходит запрос. Прочитайте его, прежде чем менять что-либо ниже
хостов, или когда нужно понять, в каком крейте должна находиться та или иная
логика.

v4 переписана с нуля по сравнению с v3. Шлюз выполняет ту же работу, но код
разделён иначе, и большинство правил в конце страницы нужны для того, чтобы это
разделение не размывалось.

## Слои

Код разделён на три слоя. Каждый следующий слой — это предыдущий плюс один
класс решений:

```text theme={null}
sdk  =  engine + configuration writes + login + resolution + queries + sync
app  =  sdk + identity and admission + the typed admin / portal / issuer operations
host =  app + a transport
```

### sdk

`gproxy-sdk` — встраиваемый дескриптор (handle). Он собирает движок
(`gproxy-core`) из реализаций по умолчанию, выполняет записи конфигурации,
которые увеличивают `settings.config_revision`, превращает завершённый вход в
строку учётных данных, разрешает имя модели в план выполнения и держит все
экземпляры развёртывания на одной ревизии. В нём нет HTTP-сервера и нет понятия
пользователей. См. [Встраивание SDK](https://gproxy.leenhawk.com/ru/development/embedding.md).

### app

`gproxy-app` — продуктовый слой: кто вызывает и к чему у него есть доступ. Ему
принадлежат пользователи, API-ключи шлюза, организации, команды, права, лимиты
запросов, OAuth issuer и аудит, а также операции, которые их записывают. В нём
нет маршрутизатора, рантайма и CLI, поэтому один и тот же код работает и за
нативным сервером axum, и внутри Cloudflare Worker.

### host

Хост превращает транспорт в типизированные вызовы app и превращает результаты
обратно в байты. Хостов три: HTTP-сервер на axum, хост для Cloudflare Workers и
приложение Tauri для десктопа и Android.

Каждый слой передаёт вниз уже суженный ответ, и нижние слои его не
пересматривают. Движок получает разрешённую цель и допустимый набор учётных
данных; самого вызывающего он не видит и не может заново вывести, кто это.

## Крейты workspace

Все крейты лежат в `crates/`. Версия и редакция задаются на уровне workspace
(`edition = "2024"`). Крейты с лицензией MIT можно использовать отдельно,
остальные распространяются под AGPL-3.0-or-later.

| Крейт | Лицензия | Содержимое | Не делает |
| - | - | - | - |
| `gproxy-protocol` | MIT | Модель соединения (`WireRequest`, `WireResponse`, тела, фрейминг, сокеты), реестр операций, wire-типы OpenAI / Claude / Gemini, попарные преобразования и интерфейсы возможностей хоста, нужные для адаптации протоколов | Не знает о каналах; не сопоставляет URL-пути |
| `gproxy-protocol-macros` | MIT | Derive-макросы `WireBuilder` и `DeclaredFields` для wire-типов | |
| `gproxy-channel` | AGPL | `BaseChannel`, необязательные трейты возможностей и адаптеры апстримов, по одной Cargo feature на канал | Не зависит от движка и хранилища; не выбирает транспортный бэкенд |
| `gproxy-client` | MIT | Контракт исходящего транспорта (`OutboundClient`) и его реализации на `reqwest` / `wreq`, а также бэкенды wasm `fetch` и Workers | Не читает базу данных, не выбирает учётные данные, не преобразует протоколы |
| `gproxy-core` | AGPL | Движок: снимок выполнения провайдеров, выбор, обновление и учёт состояния учётных данных внутри допустимого набора, преобразование, перезапись, бюджеты, тарификация, расчёт и наблюдение | Не разрешает маршруты, не проверяет права, не запускает сервер |
| `gproxy-store` | MIT | Сущности SeaORM, пакетные репозитории, миграции схемы; бэкенд БД выбирается feature | |
| `gproxy-cache` | MIT | TTL-состояние, атомарные счётчики, разрешения и аренды, межэкземплярная инвалидация; бэкенды Memory и Redis | Не хранит бизнес-сущности; не откатывается на локальное состояние при отказе бэкенда |
| `gproxy-seaorm` | MIT | Пакетное чтение и запись SeaORM, привязка D1, отображение типов, синхронизация схемы | Не хранит бизнес-сущности |
| `gproxy-tokenizer` | MIT | Подсчёт токенов в строке по локальному общему словарю | Не разбирает запросы, ничего не скачивает, не работает в фоне |
| `gproxy-file` | MIT | Необязательное локальное хранилище или S3/R2 через OpenDAL | Не ведёт метаданные и владение файлами |
| `gproxy-sdk` | AGPL | Дескриптор: сборка, `manage()`, `login()`, разрешение, сессии, `call()`, `query()`, синхронизация | Не содержит кода HTTP-сервера и идентификации |
| `gproxy-client-sdk` | MIT | Клиент для программ, которые обращаются к экземпляру GPROXY, с версионируемым IR языковых моделей | |
| `gproxy-app` | AGPL | Идентификация, допуск, семейства операций администрирования и портала, OAuth issuer | Не содержит транспорта |
| `gproxy-host-axum` | AGPL | Таблица маршрутов: HTTP-путь → операция | Не принимает собственных продуктовых решений |
| `gproxy-host-edge` | AGPL | Обработчик `fetch` для Workers, монтирующий маршрутизатор `gproxy-host-axum` | Не держит второй таблицы маршрутов |
| `gproxy-host-tauri` | AGPL | Приложение для десктопа и Android: Tauri IPC для административной и пользовательской частей и внутрипроцессный data plane `gproxy-host-axum` на loopback для CLI-клиентов | Не реализует собственный data plane |
| `gproxy` | AGPL | Командная строка (`gproxy`, а с feature `postgres` ещё и `gproxy-serverless`): окружение, файл конфигурации, сокет, терминал | Не делает того, что могла бы сделать библиотека |

`gproxy-host-tauri` входит в workspace, но не в число участников по умолчанию,
поэтому `cargo check` и `cargo test` без `--workspace` его пропускают. Для его
сборки нужны заголовочные файлы webkit2gtk, gtk и libsoup. См.
[Сборка из исходного кода](https://gproxy.leenhawk.com/ru/development/building.md).

### Направление зависимостей

Зависимости направлены только вниз:

```text theme={null}
gproxy, gproxy-host-edge, gproxy-host-tauri
        └─ gproxy-host-axum
              └─ gproxy-app
                    └─ gproxy-sdk
                          └─ gproxy-core
                                ├─ gproxy-channel ─ gproxy-client ─ gproxy-protocol
                                ├─ gproxy-store ─ gproxy-cache, gproxy-seaorm
                                └─ gproxy-tokenizer, gproxy-file
```

`gproxy-client-sdk` используется программами, которые обращаются к экземпляру
GPROXY. Он зависит только от `gproxy-protocol`, `gproxy-client` и `gproxy-file`,
но не от движка. Встроенный бэкенд `gproxy_sdk::ClientBackend` находится в
`gproxy-sdk` за функцией `client-sdk`, поэтому зависимость направлена от
движка под AGPL к клиенту под MIT, а не наоборот.

У движка никогда не появляется слушающий сокет, маршрутизатор или UI. Если
изменение требует, чтобы `gproxy-core` знал об HTTP-путях, пользователях или
правах, его место в более высоком слое.

### Кто отвечает на какой вопрос

| Вопрос | Кто отвечает |
| - | - |
| Кто вызывает | аутентификатор `gproxy-app` |
| К чему есть доступ и из чьего бюджета платить | допуск (admission) `gproxy-app` |
| Какой провайдер обслуживает это имя модели | резолвер sdk |
| Какие учётные данные использовать и что делать при отказе | движок |
| Что делает операция | семейства операций `gproxy-app` и `manage()` в sdk |
| Какой HTTP-статус и код ошибки соответствуют отказу | `AppError::status_code` / `code` |
| Какой URL соответствует какой операции | хост axum |

Хосты не должны сами отвечать ни на один из первых пяти вопросов. Иначе их
ответ действовал бы по HTTP, но не по IPC (или наоборот), и два входа вели бы
себя по-разному.

## Путь запроса

Запрос data plane через нативный сервер проходит так:

1. `gproxy-host-axum` сопоставляет путь с операцией и диалектом, декодирует
   запрос в `DataPlaneRequest`, а для диалектов, в которых модель указана в
   пути (Gemini), извлекает её оттуда.
2. `gproxy-app` аутентифицирует запрос и получает `Caller`.
3. `App::call` выполняет допуск. Результат, `Admitted`, содержит scope,
   атрибуцию использования, цепочку владельцев бюджетов, допустимые наборы
   провайдеров и учётных данных и идентификатор сессии. Здесь же списываются
   лимиты запросов.
4. app вызывает построитель запроса sdk ровно с этими значениями и с
   извлечённым именем модели. Движок `Caller` не видит.
5. sdk разрешает имя модели в `Plan` (см. ниже) и перебирает его цели.
6. Для каждой цели движок выбирает учётные данные из допустимого набора,
   готовит запрос через канал, при необходимости преобразует его в диалект
   апстрима, затем применяет правила перезаписи провайдера к запросу, который
   действительно будет отправлен (поэтому правило всегда видит формат
   апстрима), и отправляет его через `gproxy-client`.
7. Движок классифицирует ответ: повторяет запрос с другими учётными данными
   того же провайдера, возвращает ошибку в sdk (который может перейти к
   следующему провайдеру) или отдаёт ответ через воронку расчёта.
8. Хост записывает ответ. При потоковом ответе он держит `Admitted` живым до
   последнего байта: разрешение на параллельность считает запросы в процессе
   выполнения, а поток продолжается и после возврата из `call()`.

Хост edge запускает тот же маршрутизатор, поэтому шаги 1–8 там те же; он лишь
вызывает `tick()` перед шагом 1 (см.
[Запись конфигурации и перезагрузка](#configuration-writes-and-reload)).

## Разрешение модели и план

Разрешение выполняется в sdk до движка, поэтому движок видит только
разрешённые цели. Имя модели разрешается по первому подходящему правилу:

| Имя | Разрешается в | Бюджет попыток |
| - | - | - |
| отсутствует | все включённые провайдеры, без модели апстрима | `settings.max_attempts` |
| публичное имя модели | включённые участники этого маршрута | собственный бюджет маршрута |
| `provider/model` | один этот провайдер | `settings.max_attempts` |
| всё остальное | ошибка неизвестной модели | — |

Публичные имена сравниваются точно и раньше префиксной формы, поэтому оператор
может опубликовать маршрут с буквальным именем `openai/gpt-5`. Префикс — это
всегда имя провайдера, а не идентификатор канала: провайдер с именем `openai`
на канале `codex` доступен как `openai/…`.

Затем кандидаты сужаются по каналу (если вызывающий его указал), по допустимым
идентификаторам провайдеров и по допустимым идентификаторам учётных данных.
Фильтр по учётным данным не даёт запросам одной организации расходовать учётные
данные другой. Пустой допустимый набор означает, что не разрешено ничего, и
приводит к ошибке «нет цели».

Внутри каждого кандидата отключённые, выведенные из эксплуатации и мёртвые
учётные данные удаляются. Временно заблокированные удаляются, только пока у
провайдера есть незаблокированные; провайдер, у которого все учётные данные
временно заблокированы, остаётся в плане с более низким рангом здоровья, после
всех здоровых провайдеров. Так провайдер, упёршийся в лимит, пробуется
последним, а не считается недоступным.

Кандидаты упорядочиваются по `(tier, health, убывание weight, стабильный id)`.
Tier — жёсткий приоритет: более поздний tier никогда не балансируется с более
ранним. Балансируется только ведущая группа (кандидаты с теми же tier и
здоровьем, что и у первого) по стратегии маршрута. Счётчики ротации хранятся в
памяти процесса; их потеря лишь начинает ротацию заново с первого кандидата.

Результат — `Plan`: разрешённое имя модели, общий `max_attempts` и непустой
список `Target`, каждый из которых — один провайдер, одна модель апстрима и
учётные данные этого провайдера, доступные вызывающему.

## Переключение при отказе

Переключение происходит на двух уровнях.

Внутри провайдера движок перебирает его допустимые учётные данные: выбирает
одни, отправляет запрос, классифицирует ответ и, если другие учётные данные
могли бы помочь, выбирает следующие.

Между провайдерами sdk переходит к следующей цели плана, но только при отказах,
которые другой провайдер мог бы обслужить:

| Результат | Следующий провайдер |
| - | - |
| нет пригодных учётных данных, учётные данные мертвы, конфликт обновления | да |
| продолжение закреплено за другим экземпляром | да |
| любая ошибка канала или транспорта | да |
| ответ апстрима 401, 403, 429 или 5xx (движок уже перебрал учётные данные этого провайдера) | да |
| бюджет исчерпан, отмена, превышен срок, запрещено | нет |
| ошибки преобразования, маршрута, перезаписи, несоответствия операции, недопустимая цель, не реализовано | нет |
| ошибки хранилища, кеша, секретов, лимитов, сборки или файлов | нет |

Вторая группа — собственные ограничения вызывающего, третья — запрос или
конфигурация, которые не сработают нигде, четвёртая означает, что неисправен
сам экземпляр. Повтор у другого провайдера в этих случаях только увеличивает
расходы.

Бюджет попыток общий на весь план. Каждой цели выделяется не больше попыток,
чем у неё учётных данных, и не больше, чем осталось; выделенное списывается,
даже если не было израсходовано. Поэтому план никогда не делает больше вызовов
апстрима, чем его `max_attempts`. Когда целей не осталось, возвращается
последний ответ как есть: 429 от последнего провайдера вызывающий получает
как 429.

## Запись конфигурации и перезагрузка

Все записи конфигурации проходят через один примитив sdk:

```text theme={null}
commit_revision([statements…, config_revision += 1, read back])   one transaction
      ↓
reload   the snapshot this instance serves
      ↓
publish  ConfigurationChanged { revision, scopes }
```

Строки и увеличение ревизии записываются в одной транзакции. Если бы строки
записались без увеличения ревизии, другие экземпляры никогда не узнали бы, что
нужно перезагрузиться, и изменение было бы у них невидимо. Если бы ревизия
увеличилась без строк, все экземпляры перезагружались бы впустую.

Записывающий экземпляр сначала перезагружается сам и только потом публикует
уведомление, чтобы никогда не объявлять ревизию, которую он ещё не может
обслуживать. Публикация выполняется по возможности: если кеш отклонил
сообщение, изменение дойдёт до остальных экземпляров при следующем опросе, а
записывающий экземпляр запишет предупреждение в лог.

Перечисленные при записи scope определяют, какая перезагрузка последует.
Запись учётных данных, затрагивающая только секрет, срок его действия и статус
жизненного цикла, может использовать scope состояния учётных данных и более
дешёвый путь `reload_credentials`. Всё остальное в учётных данных фиксируется в
снимке выполнения при сборке и требует полной перезагрузки.

### Синхронизация экземпляров

Экземпляры узнают о записях друг друга двумя способами:

| Механизм | Задержка | Как отказывает |
| - | - | - |
| Уведомление об инвалидации в общем кеше | миллисекунды | сообщения могут теряться |
| Опрос `settings.config_revision` | по умолчанию 30 с | медленно |

Уведомление не несёт состояния, а лишь сообщает экземпляру, что нужно
проверить ещё раз. Источником истины служит опрос, и он не может пропустить
запись, поэтому потерянное уведомление стоит не больше одного интервала опроса.
Перезагрузки сериализованы и монотонны: старая ревизия никогда не заменяет
более новую, а неудачная перезагрузка оставляет в работе предыдущий снимок.

В режиме `SyncMode::Background` (по умолчанию для нативной сборки) дескриптор
сам запускает циклы подписки и опроса. В wasm между запросами нет живого
процесса, поэтому синхронизация ручная: хост edge в начале каждого запроса
вызывает `tick()`, который применяет ожидающие уведомления и один раз
опрашивает ревизию.

Развёртыванию с несколькими экземплярами нужен общий кеш: Redis в нативной
сборке или `StoreCache` поверх D1 или libSQL на edge. `MemoryCache` по
умолчанию существует только внутри процесса.

## Расчёт и учёт

Каждый путь, доходящий до апстрима, заканчивается в одной и той же воронке
расчёта движка: тарифицировать обмен, списать со всех применимых окон бюджета и
передать отчёт об использовании наблюдателю (observer). Расчёт выполняется
ровно один раз на запрос. Быстрого пути в обход него нет, потому что такой путь
означал бы неучтённый трафик.

Подробности:

* Бюджеты проверяются до первой попытки, а списываются по тарифицированной
  стоимости после ответа апстрима. Стоимость известна только после ответа,
  поэтому выполняющиеся и параллельные вызовы могут превысить бюджет.
* Расчёт идемпотентен по идентификатору запроса. Если расчёт не удался,
  теряется учёт, но не уже доставленный вызывающему ответ.
* Модель без подходящего ценового правила рассчитывается по нулевой стоимости,
  а её использование помечается измерением `unpriced = true`, чтобы оператор
  мог её найти.
* Использование записывается по каждому физическому вызову апстрима.
* Необязательная работа, например захват запросов, решается до выполнения
  запроса. Если захват выключен, тело не копируется и под него ничего не
  выделяется. (v3 сначала выполняла работу, а потом позволяла приёмнику её
  отбросить.)

Тарификация и расчёт принадлежат движку, сохранение и маскирование —
наблюдателю. Наблюдатель по умолчанию, `StoreObserver`, пишет в базу данных;
хост может его заменить.

## Сборка под WebAssembly

`gproxy-app` и маршрутизатор axum собираются под `wasm32-unknown-unknown`.
Поэтому `gproxy-host-edge` монтирует тот же `Router`, а не держит собственную
таблицу маршрутов. Серверные части axum (`http1`, `http2`, `tokio`) — это
Cargo features, а сам `Router` не зависит от целевой платформы, так что сборка
под wasm теряет работу с сокетами, но не маршруты.

Препятствие — `Send`. В wasm движок намеренно `!Send`: транспортный дескриптор
JS принадлежит изоляту, который его создал, и `ClientBounds` в `gproxy-client`
равен `Send + Sync` в нативной сборке и пуст в wasm. axum же требует
`Send + Sync` для состояния маршрутизатора и `Send` для future обработчиков.
Это примиряют два моста, оба опираются на то, что изолят Worker
однопоточный:

* `gproxy_app::App` хранит дескриптор sdk в `send_wrapper::SendWrapper`.
* `gproxy_host_axum::send` оборачивает тело каждого обработчика (и тело каждого
  потокового ответа). В нативной сборке это тождественная функция; в wasm —
  `SendWrapper`, который паникует при опросе из другого потока.

Обработчик без `send` не компилируется под wasm, и ошибка называет этот
обработчик. Именно поэтому обёртка стоит в каждом обработчике, а не за `cfg` на
маршрутизаторе: маршрут, который не компилируется для edge, не может молча
отсутствовать там.

В wasm кеш по умолчанию — `StoreCache`, а синхронизация всегда ручная (см.
выше). Функции, доступные только в нативной сборке (PostgreSQL, MySQL,
локальное файловое хранилище, прокси, эмуляция TLS), недоступны; базы данных
для edge — `libsql` и привязка D1. Сборка и развёртывание Worker описаны в
разделе [Сборка из исходного кода](https://gproxy.leenhawk.com/ru/development/building.md).

## Правила проектирования

Меняя код, сохраняйте следующие свойства:

* Движок не зависит от серверного фреймворка, маршрутизатора или UI.
* Каждый запрос, дошедший до апстрима, выходит через воронку расчёта.
* Преобразование выполняется попарно между диалектами вендоров, без
  промежуточного представления. Преобразователь читает только объявленные поля
  источника и никогда не читает и не пишет набор неизвестных полей.
* Запись конфигурации и увеличение ревизии выполняются в одной транзакции.
* Публикация снимков монотонна; перезагрузка никогда не откатывается назад.
* Сборка не падает из-за одной плохой строки. Строка пропускается,
  учитывается и записывается в лог.
* У хоста edge нет собственной таблицы маршрутов.
* Типы фронтенда генерируются из Rust (см.
  [Экспорт типов TypeScript](https://gproxy.leenhawk.com/ru/development/embedding.md#typescript-type-export))
  и никогда не пишутся вручную.


## Related topics

- [GPROXY](https://gproxy.leenhawk.com/ru/index.md)
- [Встраивание SDK](https://gproxy.leenhawk.com/ru/development/embedding.md)
- [Пользователи и API-ключи](https://gproxy.leenhawk.com/ru/guides/users-and-keys.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.