Перейти к основному содержанию
Это руководство для тех, кто хочет подключить к GPROXY ещё не поддерживаемый upstream: каналом в этом репозитории или каналом в собственном крейте. Канал отвечает за одно семейство upstream-сервисов: его URL, способ подстановки учётных данных, поддерживаемые диалекты протокола, приведение ответов к стандартному ответу каждой операции (именно оттуда хост читает расход) и, для сервисов с аккаунтами, вход, обновление токенов и чтение квот. Маршрутизация, выбор учётных данных, переключение при отказе, преобразование протоколов, расчёты и capture относятся к движку. Канал не читает базу данных и не выбирает транспорт. Каналы компилируются в программу, во время работы ничего не загружается. Канал может находиться в одном из двух мест:
  • В этом репозитории, как модуль gproxy-channel за собственной Cargo-фичей, добавленный через pull request. Тогда он доступен всем хостам, включая Workers. См. Добавление канала в этот репозиторий.
  • В вашем крейте, как тип, реализующий BaseChannel по публичному контракту и подключённый к нативному бинарнику или Worker, которые в остальном совпадают со стандартными. В этом репозитории ничего менять не нужно. См. Вне этого репозитория.
Трейт и способ его реализации в обоих случаях одинаковы, различается только регистрация. По умолчанию ни один конкретный канал не компилируется.

Когда нужен канал

Канал придётся поддерживать вслед за чужим протоколом, поэтому добавлять его стоит, только если строка провайдера не может описать то, что нужно upstream-сервису: путь, зависящий от операции; тело запроса, которое надо переписать; интерфейс аккаунта, который надо читать; клиентскую идентичность, которую надо предъявить. Если отличие только в адресе и одном заголовке, используйте провайдер custom. См. Провайдеры, которым не нужен канал.

Контракт BaseChannel

BaseChannel — единственный обязательный трейт, а id — его единственный обязательный метод. У каждой операции протокола есть свой асинхронный метод с реализацией по умолчанию: собрать запрос через prepare (HTTP) или prepare_connect (WebSocket) и отправить его через клиент, назначенный хостом. Оба метода подготовки по умолчанию возвращают «не поддерживается». Поэтому канал поддерживает ровно те операции, которые он подготавливает или переопределяет.
Хост передаёт заимствованные представления, по возможности публичные:
  • ProviderView: id, channel, необязательный base_url и JSON config, который канал разбирает в собственные типизированные настройки.
  • CredentialView: id, provider_id, auth_kind, JSON secret (без Debug и Serialize), публичные metadata, записанные хостом, version и expires_at_ms.
  • PrepareContext: аргумент prepare и prepare_connect — оба представления, нативный OperationKey, WireRequest и необязательное переопределение адреса.
  • OperationContext: аргумент метода операции — оба представления, диалект операции, WireRequest, принадлежащий ему клиент, состояние между запросами, идентификатор экземпляра хоста и необязательное переопределение адреса.
  • CredentialContext: провайдер, учётные данные и клиент для обновления, квот и сервисов.
Кроме prepare, канал обычно переопределяет native_dialects (диалекты, которые upstream принимает для операции; пустой список означает, что решает конфигурация), а если он принимает запросы и в других диалектах, то и default_conversion_target: он указывает, в какой диалект хост преобразует запрос до вызова prepare.

Правила подготовки и ошибок

  • Подготовка синхронна и чиста: без ввода-вывода, скрытого состояния и создания клиентов. Переопределённая операция может выполнить несколько обменов, но только через context.client. Она может удерживать клиент и после окончания потока ответа, чтобы завершить работу, например освободить диалог на стороне поставщика.
  • Аутентификация источника не доходит до upstream. Вспомогательная функция пересылки отбрасывает hop-by-hop-заголовки, а также host, content-length, authorization, x-api-key, x-goog-api-key и api-key; strip_query_auth удаляет из строки запроса key, access_token и api_key. После этого канал добавляет собственную аутентификацию из учётных данных.
  • Пересылаются клиентские заголовки из объединения глобального списка разрешённых, allowed_headers провайдера и заголовков, объявленных каналом. content-type проходит всегда. Отсутствующий или пустой список ничего не добавляет.
  • Переопределение адреса — это полный URL метода. Оно заменяет и базовый URL, и путь канала по умолчанию; к нему ничего не дописывается.
  • Ответ с кодом не 2xx возвращается как ответ: со статусом, заголовками и лениво читаемым телом. Вариант ошибки предназначен для вызовов возможностей (вход, обновление, квоты) и для промежуточного обмена внутри многошаговой операции, где неудачный ответ не является возвращаемым.
  • RefreshRejected означает, что upstream окончательно отклонил учётные данные (invalid_grant, отзыв). По умолчанию хост помечает их как нерабочие. Канал может переопределить persist_rejection, чтобы причина отказа запоминалась только для текущей версии учётных данных в памяти, а перед следующей попыткой долговременные данные перечитывались. Временный сбой транспорта или 5xx нужно возвращать как ошибку транспорта, чтобы хост повторил попытку позже; RefreshRejected в этом случае выведет из строя рабочий аккаунт.
  • ContinuationElsewhere означает, что активное соединение с upstream держит другой процесс хоста. Хост перенаправит запрос туда; с учётными данными всё в порядке.

Необязательные возможности

Каждая возможность — отдельный трейт за методом доступа, который по умолчанию возвращает None. Канал реализует те, что есть у его upstream, и они не зависят друг от друга: канал может сообщать окна квот, не предлагая сброса. classify_rejection — обычный метод BaseChannel, а не метод доступа. Хост вызывает его для каждого ответа не 2xx, передавая ограниченный префикс тела, а также для кадра ошибки в начале потока 2xx. Метод сообщает, что отказ значит для учётных данных (ограничение частоты, исчерпанная квота, нет средств, нет аутентификации, модель не обслуживается или ошибка в самом запросе) и, если upstream это указал, сколько ждать. Реализация по умолчанию читает только структурированные поля ошибки; переопределите её, чтобы распознавать формулировки конкретного поставщика.

Учётные данные и обновление

Секреты, которые у оператора уже есть, проходят через обычный API создания и изменения учётных данных. Проверяйте их формат в validate_credential_secret. Если перед первым использованием нужен сетевой обмен (например, ключ сервисного аккаунта ещё не обменян на токен), верните Some(0) из initial_credential_expiry и реализуйте обмен через CredentialRefresh. Обновление возвращает полную замену секрета и срока действия, а также необязательные публичные метаданные (None сохраняет прежние). Хост сохраняет все три значения одним compare-and-swap по версии. Секретам не место в метаданных. CredentialRefresh::proactive_refresh_reason решает, обновлять ли учётные данные при запросе. По умолчанию обновление происходит, если токен доступа истекает в течение пяти минут или токен обновления (refresh_expires_at_ms в секрете) — в течение суток. Канал может переопределить это под расписание upstream и реализовать refresh_with_reason, если эндпоинт токенов принимает причину обновления. response_update позволяет каналу взять обновлённые учётные данные из заголовка ответа upstream; секретные заголовки он должен удалить из ответа, даже если обновление не принято. Для расхода возможность не нужна: хост читает его из стандартного ответа, который формирует канал. Если канал ничего не сообщает, расход считается неизвестным, а не нулевым.

Возможности дескриптора и состояние

Две возможности дескриптора нельзя вывести из методов доступа, поэтому канал, у которого они есть, указывает их в своём descriptor: websocket (он переопределяет операцию через сокет) и paid_usage (его QuotaModel::allows_paid_usage читает флаг allow_paid_usage в метаданных учётных данных; по нему консоль показывает соответствующий переключатель). Состояние между запросами хост ограничивает одним провайдером и одними учётными данными и записывает через compare-and-swap. Состояние привязки по умолчанию отклоняет любую запись, так что канал получает состояние, только если его предоставил хост.

Состояние входа (provider_state)

Оба потока OAuth передают словарь provider_state для сведений, которые нужны каналу после того, как пользователь дал согласие: то, что положил start (или authorize), без изменений возвращается в poll (или exchange). Содержимое определяет канал, например нестандартный идентификатор устройства или клиент, который вход зарегистрировал для себя. В отличие от provider_fields, которые хост публикует как метаданные учётных данных, этот словарь может содержать секреты. Он живёт только в сеансе входа на хосте, который недолговечен, хранится в кеше и никогда не отображается, и удаляется по окончании входа. Всё, что должно пережить вход, нужно вернуть из обмена: публичные сведения в provider_fields (они станут метаданными), секретные — в provider_secrets, которые шифруются вместе с токенами и метаданными не становятся.

Добавление канала в этот репозиторий

Ориентируйтесь на custom (API-ключ) и codex (аккаунт OAuth). Последний добавленный канал, opendesign, — небольшой канал с API-ключом; по его коммиту видны все файлы, которые затрагивает новый канал.
  1. Объявите фичу в crates/gproxy-channel/Cargo.toml, перечислив только те необязательные зависимости, которые нужны каналу. Зависимость только для wasm указывается в целевой таблице cfg(target_arch = "wasm32").
  2. Добавьте строку в список channels! в src/channels/mod.rs: фичу, модуль и значение, которое регистрирует хост. Эта строка одновременно объявляет модуль и добавляет канал в compiled_in(), через который его находят все хосты.
    Значение — это выражение, поэтому канал, который нужно сконструировать, записывается как claudeweb::ClaudeWeb::new(), а модуль, обслуживающий два продукта по одному протоколу, перечисляет оба (opencode::OpenCode::ZEN, opencode::OpenCode::GO). Если канал использует shared, добавьте его фичу и в cfg(any(...)) этого модуля.
  3. Разложите модуль по файлам, по одной задаче в каждом. Небольшой канал — один файл acme.rs, крупный — каталог:
    Документация модуля называет источник каждого факта о протоколе: справочник API поставщика или исходники CLI в samples/. Код другого канала источником не считается.
  4. Реализуйте BaseChannel. Минимум — id, native_dialects и prepare. Структура — единичная и без состояния, так что один экземпляр обслуживает все провайдеры этого канала. Переопределите и descriptor. Реализация по умолчанию заполняет возможности по методам доступа, но отображаемое имя и ключи, которые канал читает из JSON config провайдера, известны только самому каналу. Интерфейс управления строит форму провайдера только по дескриптору (с фичей ts это ещё и тип TypeScript), так что ключ, которого в нём нет, из интерфейса задать нельзя. Добавьте HOST_CONFIG_KEYS, чтобы объявить ключи самого хоста. Переопределяйте метод операции вместо prepare, если upstream требует нескольких обменов, локально сформированного ответа, другого транспорта или переписывания ответа в объявленный нативный диалект.
  5. Объявите, что отправляет клиент поставщика. Если канал имитирует CLI, экспортируйте константу с его заголовками и постройте из неё список разрешённых, чтобы allowed_headers провайдера не мог убрать собственные заголовки CLI. Передайте эти заголовки идентичности в список отбрасываемых у функции пересылки, чтобы клиент не мог их подделать. Если upstream распознаёт клиентов по отпечатку, верните подключение по умолчанию из default_connection; явно заданный профиль у учётных данных или провайдера всё равно имеет приоритет.
  6. Реализуйте возможности отдельными типами и возвращайте их из методов доступа. У каждого трейта свои правила: обновление возвращает полную замену, а не слияние; измерения квоты берутся из сведений о тарифе в метаданных учётных данных, а не из сети; наблюдатель расхода делает накопительные снимки, а его finish получает от хоста признак, завершился ли поток полностью или был прерван, потому что достижение EOF само по себе не означает, что расход полный.
  7. Переиспользуемые механизмы протокола помещайте в src/channels/shared/ под условием фич, которые их используют. Политика остаётся в канале; общий модуль делает только то, что просит канал.
  8. Тесты размещайте рядом с кодом, в tests/<id>.rs под #![cfg(feature = "…")]. Соберите представления провайдера и учётных данных вручную, вызовите prepare с тестовым секретом и проверьте URL, добавленную аутентификацию и отброшенные заголовки. Подайте записанные кадры наблюдателю расхода; разберите записанные заголовки квот и тела с расходом. Для многошаговых операций используйте сценарный исходящий клиент. Движок из этих тестов не тестируйте.
  9. Пробросьте фичу во все хосты. Движок сам каналы не перечисляет: gproxy-sdk регистрирует всё, что возвращает compiled_in(), поэтому бинарник может использовать канал, только если его набор фич включает фичу канала. Добавьте фичу с тем же именем в: Повторяющийся id отклоняется при регистрации. Провайдер, ссылающийся на незарегистрированный канал, — ошибка конфигурации; запасного варианта нет.
  10. Опишите канал в документации: добавьте строку в таблицу фич в crates/gproxy-channel/README.md и в таблицу «Поддерживаемые каналы» в guides/providers.mdx подмодуля документации, на всех четырёх языках.

Проверка сборки

В gproxy-channel по умолчанию не включён ни один канал, поэтому этим командам нужен --all-features; без него не скомпилируется ни один канал. Каждый канал должен собираться и для нативных целей, и для wasm32-unknown-unknown, благодаря чему хост Workers несёт полный набор каналов. Замечания линтера исправляйте в коде, а не через #[allow]. После слияния больше ничего не требуется: провайдеры нового канала появляются в GET /admin/api/channels вместе с дескриптором канала, и консоль строит по нему формы. Справочник каналов в этой документации CI пересобирает из зарегистрированных каналов.

Вне этого репозитория

Канал может жить в собственном крейте и выпускаться без форка, для любого из двух хостов. Нативный бинарник при этом остаётся стандартным gproxy (те же команды, флаги, консоль и база данных), а Worker — стандартным GPROXY Worker; в каждом ваш канал зарегистрирован рядом со встроенными. examples/channel-plugin содержит полный рабочий набор из трёх крейтов: канал с API-ключом, совместимый с OpenAI, в одном файле, и короткую точку входа для каждого хоста. CI собирает пример при каждом изменении, так что он соответствует текущему контракту.

Структура проекта

Канал вынесен в отдельный крейт, чтобы от него могли зависеть оба хоста. Сам он не должен зависеть от нативного хоста: gproxy не собирается под wasm, и тогда не собрался бы и Worker.
main_with — это весь стандартный main: загружает .env, разбирает те же аргументы, создаёт среду выполнения и запускается. Программа со своей средой выполнения вызывает вместо него gproxy::run_with(cli, channels).
fetch_with — это весь стандартный обработчик fetch. В модуле Worker может быть только один #[event(fetch)], поэтому крейт edge зависит от gproxy-host-edge без фичи entry, которая и объявляет стандартный обработчик. Замыкание выполняется один раз на isolate, когда первый запрос собирает экземпляр. В обоих хостах канал, чей id совпадает со встроенным, заменяет встроенный. Так выпускают исправленную копию существующего канала.

Зависимости

Зависьте от рабочей копии gproxy по пути, той версии, под которую собираете:
В таблице самой рабочей копии есть ещё патч glib; он нужен только настольному хосту, и внешнему workspace эта запись не требуется. Перед первой сборкой скопируйте Cargo.lock рабочей копии рядом с корневым Cargo.toml, чтобы зависимости разрешились в версии, с которыми тестировался gproxy. Рабочая копия используется вместо git-зависимости потому, что нативная консоль встраивается в бинарник из crates/gproxy-host-axum/assets/web, а этот каталог не хранится в репозитории. Сначала выполните pnpm install && pnpm build в console/ рабочей копии или уберите embedded-console из фич, чтобы собрать бинарник без консоли. Сборка Worker ниже тоже берёт консоль из рабочей копии. Списки фич gproxy и gproxy-host-edge работают так же, как в стандартных сборках: channels включает все встроенные каналы, а можно перечислить только те, что нужны развёртыванию.

Сборка Worker

deploy/cloudflare из рабочей копии собирает любой крейт Worker, а не только стандартный. Укажите на свой крейт абсолютным путём и разверните как обычно:
Команда запускает worker-build в вашем крейте, удаляет отладочные секции и копирует Worker к wrangler.toml рабочей копии, ресурсам консоли и файлу их заголовков. Всё из руководства по edge-развёртыванию (привязка D1, секреты, GPROXY_CONFIG) применяется без изменений. Канал для Worker должен собираться под wasm32-unknown-unknown, как и все встроенные каналы. На практике это значит: без сетевого tokio, без доступа к файловой системе, web-time вместо std::time::Instant, и все запросы — через клиент, предоставленный хостом. Проверка: cargo check --target wasm32-unknown-unknown -p my-channel.

Публичный контракт

Всё, что канал реализует или получает, публично в gproxy_channel::channel: BaseChannel, типы контекстов и представлений, все трейты возможностей, типы дескриптора (включая HOST_CONFIG_KEYS) и вспомогательные функции для запросов: forwardable и HeaderAllowlist для клиентских заголовков, insert_configured для статического заголовка, заданного оператором, strip_query_auth для строки запроса и standard_reason_observer. Словарь протокола (Dialect, Operation, OperationKey, HttpBody, WireRequest) находится в gproxy_protocol. gproxy_channel::channels::shared не публичен. Он подогнан под каналы этого репозитория и меняется вместе с ними; копируйте нужное. Правила контракта действуют без изменений, в первую очередь чистота подготовки и то, что аутентификация источника не доходит до upstream.

Отличия от встроенных каналов

  • Консоль строит интерфейс вашего канала по дескриптору, как и для любого другого: форму провайдера по config_keys, форму учётных данных по credential_kinds, кнопки входа по login_modes, элементы квот, сброса, обновления и платного расхода по capabilities. Нет только доработок, написанных для встроенных id, например особого текста подсказки или группы предустановленных моделей.
  • Самообновление в нативном бинарнике отказывается устанавливать новую версию. Опубликованный релиз — это сборка этого репозитория; после установки ваш канал пропал бы, и все провайдеры на нём перестали бы работать после следующего перезапуска. Пересоберите бинарник под новую версию. Проверка обновлений работает.
  • Контракт следует версии gproxy. На практике изменения только добавляются (большинство типов протокола помечены #[non_exhaustive], а новые методы BaseChannel имеют реализации по умолчанию), но собирайте под ту версию, которую запускаете. Пишите ChannelCapabilities { quota_query: true, ..Default::default() }, а не перечисляйте все поля, чтобы новая возможность не сломала сборку.

Контрольный список для агентов

Те же шаги в порядке, в котором их должен выполнять агент. В английской версии этот раздел называется «Checklist for Coding Agents», и на него ссылается AGENTS.md репозитория. Встроенный канал (pull request в этот репозиторий):
  1. Возьмите за образец crates/gproxy-channel/src/channels/nvidia.rs (API-ключ) или codex/ (OAuth) и прочитайте источник протокола поставщика в samples/ или его справочник API.
  2. Добавьте фичу в crates/gproxy-channel/Cargo.toml, строку в channels! в src/channels/mod.rs и фичу в каждый cfg(any(...)) в src/channels/shared/, который использует канал.
  3. Напишите src/channels/<id>.rs или src/channels/<id>/; переопределите descriptor.
  4. Пробросьте фичу в четыре места из шага 9: crates/gproxy-sdk/Cargo.toml (фича и список channels), crates/gproxy/Cargo.toml, crates/gproxy-host-edge/Cargo.toml и V4_CHANNELS в crates/gproxy-app/src/v3/channels.rs (увеличьте длину массива).
  5. Если канал учитывает allow_paid_usage или обслуживает WebSocket, установите paid_usage / websocket в возможностях его дескриптора.
  6. Добавьте строку канала в crates/gproxy-channel/README.md и в таблицу поддерживаемых каналов в docs/guides/providers.mdx, docs/zh-cn/guides/providers.mdx, docs/zh-tw/guides/providers.mdx и docs/ru/guides/providers.mdx (изменение документации коммитится в подмодуле).
  7. Выполните pnpm --dir docs generate:reference; он экспортирует зарегистрированные каналы в справочник. Сгенерированный JSON не коммитьте: его экспортирует и коммитит CI.
  8. Выполните команды из раздела Проверка сборки.
Канал вне репозитория:
  1. Скопируйте examples/channel-plugin за пределы рабочей копии: channel/ всегда, native/ и edge/ — для нужных хостов. Добавьте корневой Cargo.toml workspace, показанный выше, с таблицей [patch.crates-io], и скопируйте Cargo.lock.
  2. В Cargo.toml каждого крейта задайте собственные version и edition = "2024" вместо ключей *.workspace = true и замените каждую зависимость workspace = true зависимостью по пути, как показано выше.
  3. Переименуйте пакеты, бинарник, структуру и ID. Выберите id, который не использует ни один встроенный канал, если только цель не в том, чтобы его заменить.
  4. Реализуйте только через gproxy_channel::channel; не используйте channels::shared.
  5. Выполните cargo clippy --all-targets -- -D warnings; для Worker также cargo check --target wasm32-unknown-unknown -p <channel> -p <edge>.
  6. Запустите нативный бинарник на новом GPROXY_DATA_DIR или Worker через wrangler dev --local из deploy/cloudflare после описанной выше сборки и убедитесь, что GET /admin/api/channels содержит этот id.