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

Слои

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

sdk

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

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-host-tauri входит в workspace, но не в число участников по умолчанию, поэтому cargo check и cargo test без --workspace его пропускают. Для его сборки нужны заголовочные файлы webkit2gtk, gtk и libsoup. См. Сборка из исходного кода.

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

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

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

Хосты не должны сами отвечать ни на один из первых пяти вопросов. Иначе их ответ действовал бы по 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 (см. Запись конфигурации и перезагрузка).

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

Разрешение выполняется в sdk до движка, поэтому движок видит только разрешённые цели. Имя модели разрешается по первому подходящему правилу: Публичные имена сравниваются точно и раньше префиксной формы, поэтому оператор может опубликовать маршрут с буквальным именем openai/gpt-5. Префикс — это всегда имя провайдера, а не идентификатор канала: провайдер с именем openai на канале codex доступен как openai/…. Затем кандидаты сужаются по каналу (если вызывающий его указал), по допустимым идентификаторам провайдеров и по допустимым идентификаторам учётных данных. Фильтр по учётным данным не даёт запросам одной организации расходовать учётные данные другой. Пустой допустимый набор означает, что не разрешено ничего, и приводит к ошибке «нет цели». Внутри каждого кандидата отключённые, выведенные из эксплуатации и мёртвые учётные данные удаляются. Временно заблокированные удаляются, только пока у провайдера есть незаблокированные; провайдер, у которого все учётные данные временно заблокированы, остаётся в плане с более низким рангом здоровья, после всех здоровых провайдеров. Так провайдер, упёршийся в лимит, пробуется последним, а не считается недоступным. Кандидаты упорядочиваются по (tier, health, убывание weight, стабильный id). Tier — жёсткий приоритет: более поздний tier никогда не балансируется с более ранним. Балансируется только ведущая группа (кандидаты с теми же tier и здоровьем, что и у первого) по стратегии маршрута. Счётчики ротации хранятся в памяти процесса; их потеря лишь начинает ротацию заново с первого кандидата. Результат — Plan: разрешённое имя модели, общий max_attempts и непустой список Target, каждый из которых — один провайдер, одна модель апстрима и учётные данные этого провайдера, доступные вызывающему.

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

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

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

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

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

Экземпляры узнают о записях друг друга двумя способами: Уведомление не несёт состояния, а лишь сообщает экземпляру, что нужно проверить ещё раз. Источником истины служит опрос, и он не может пропустить запись, поэтому потерянное уведомление стоит не больше одного интервала опроса. Перезагрузки сериализованы и монотонны: старая ревизия никогда не заменяет более новую, а неудачная перезагрузка оставляет в работе предыдущий снимок. В режиме 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 описаны в разделе Сборка из исходного кода.

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

Меняя код, сохраняйте следующие свойства:
  • Движок не зависит от серверного фреймворка, маршрутизатора или UI.
  • Каждый запрос, дошедший до апстрима, выходит через воронку расчёта.
  • Преобразование выполняется попарно между диалектами вендоров, без промежуточного представления. Преобразователь читает только объявленные поля источника и никогда не читает и не пишет набор неизвестных полей.
  • Запись конфигурации и увеличение ревизии выполняются в одной транзакции.
  • Публикация снимков монотонна; перезагрузка никогда не откатывается назад.
  • Сборка не падает из-за одной плохой строки. Строка пропускается, учитывается и записывается в лог.
  • У хоста edge нет собственной таблицы маршрутов.
  • Типы фронтенда генерируются из Rust (см. Экспорт типов TypeScript) и никогда не пишутся вручную.