跳转到主要内容
本文面向需要让 GPROXY 接入一个尚未支持的上游的贡献者,无论渠道放在本仓库里,还是放在自己的 crate 里。 一个渠道对应一族上游,负责这些事:上游的 URL、凭证的注入方式、支持哪些线协议方言、怎样把响应整形成各操作的标准响应(宿主从这里读取用量),以及账号型上游的登录、刷新和配额查询。路由、凭证选择、失败转移、协议转换、结算和 capture 由引擎负责。渠道不读数据库,也不选择传输后端。 渠道在编译时链接,不在运行时加载。渠道可以放在两个位置:
  • 本仓库:作为 gproxy-channel 的一个模块,挂在独立的 Cargo feature 后面,通过 PR 合入。合入后所有宿主都能使用,包括 Workers 宿主。见向本仓库添加渠道。
  • 自己的 crate:一个按公开契约实现 BaseChannel 的类型,链接进一个除此之外与原版相同的原生二进制或 Worker。本仓库无需任何改动。见在本仓库之外。
两种方式实现的是同一个 trait,写法相同,区别只在注册方式。默认不编译任何具体渠道。

什么时候需要渠道

渠道要跟着别人的线协议持续维护,所以只有在一行供应商配置表达不了上游的需求时才值得新增:路径随操作变化、请求体需要改写、有账号接口要读取,或者要模拟某个客户端身份。 如果差别只是源地址和一个请求头,用 custom 供应商即可,见不需要渠道的厂商。

BaseChannel 契约

BaseChannel 是唯一必须实现的 trait,id 是其中唯一必须实现的方法。每个协议操作都有独立的异步方法和默认实现:用 prepare(HTTP)或 prepare_connect(WebSocket)构造请求,再交给宿主分配的 client 发送。 两个准备钩子默认都返回“不支持”。因此渠道支持的操作就是它准备或覆写的那些,没有别的。
宿主传入的是借用的视图,能公开的字段都公开:
  • ProviderView:id、channel、可选的 base_url,以及由渠道自行解码为类型化设置的 config JSON。
  • CredentialView:id、provider_id、auth_kind、secret JSON(不实现 Debug 和 Serialize)、宿主记录的公开 metadata、version、expires_at_ms。
  • PrepareContext:prepare 和 prepare_connect 的参数,包含两个视图、原生 OperationKey、WireRequest 和可选的端点覆盖。
  • OperationContext:操作方法的参数,包含两个视图、操作的方言、WireRequest、一个归调用方持有的 client、跨请求状态、宿主实例 id 和可选的端点覆盖。
  • CredentialContext:供应商、凭证和 client,用于刷新、配额和服务。
除 prepare 外,渠道通常还要覆写 native_dialects(上游对某个操作原生接受的方言;返回空表示交给配置决定)。如果渠道也接受其他方言的请求,再覆写 default_conversion_target,指定宿主在调用 prepare 之前把请求转换成哪种方言。

准备与错误的规则

  • 准备是同步的纯函数:不做 I/O,不保留隐藏状态,不构造 client。操作覆写可以发起多次交换,但只能通过 context.client;响应流结束后也可以继续持有 client 完成收尾,例如释放厂商侧的会话。
  • 来源鉴权不会到达上游。转发助手会丢弃逐跳请求头,以及 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,同时替换 base URL 和渠道的默认路径,不会再往后拼接。
  • 非 2xx 应答作为响应返回,状态码、请求头和惰性读取的 body 保持原样。错误变体只用于能力调用(登录、刷新、配额),以及多次调用覆写中的中间交换,因为那里失败的应答不是最终返回的响应。
  • RefreshRejected 表示上游明确拒绝了这把凭证(invalid_grant、已撤销)。宿主默认把凭证标记为失效。渠道可以覆写 persist_rejection,只针对当前内存中的凭证版本记住某个拒绝原因,下次尝试前重新加载持久化的材料。短暂的传输失败或 5xx 必须作为传输错误返回,让宿主稍后重试;误用 RefreshRejected 会让一个正常的账号失效。
  • ContinuationElsewhere 表示一条活动的上游连接由另一个宿主进程持有。宿主会把请求转到那个实例,凭证本身没有问题。

可选能力

每项能力都是独立的 trait,通过默认返回 None 的访问器提供。渠道按上游实际提供的功能实现,各项之间互不依赖,例如可以报告配额窗口而不提供重置。 classify_rejection 是 BaseChannel 上的普通方法,不是访问器。宿主对每个非 2xx 应答调用它,并传入 body 的有限前缀;2xx 流以错误帧开头时也会调用。它返回这次拒绝对凭证意味着什么(限流、配额耗尽、余额不足、未认证、不提供该模型,或请求本身有误),上游给出等待时间时一并返回。默认实现只读取结构化的错误字段,需要识别厂商自己的措辞时再覆写。

凭证与刷新

运维已有的密钥走普通的凭证新增和更新接口,用 validate_credential_secret 校验格式。如果材料在首次使用前需要联网换取(例如尚未换取令牌的服务账号密钥),由 initial_credential_expiry 返回 Some(0),并通过 CredentialRefresh 实现换取过程。 刷新返回完整替换的密钥和过期时间,以及可选的公开 metadata(None 表示保留原值)。宿主用同一个版本 CAS 一起保存这三项。密钥不得放进 metadata。 CredentialRefresh::proactive_refresh_reason 决定请求时是否续期。默认在访问凭据五分钟内过期,或刷新凭据(密钥中的 refresh_expires_at_ms)一天内过期时续期。渠道可以覆写它以匹配上游的节奏;令牌接口接受续期原因时,再实现 refresh_with_reason。response_update 用于从上游响应头中获取轮换后的凭证;即使不接受任何更新,也必须从响应中删除含密钥的响应头。 用量不需要任何能力:宿主从渠道整形后的标准响应中读取。不报告用量的渠道记为“未报告”,而不是 0。

descriptor 能力与状态

有两项 descriptor 能力无法从访问器推断,具备的渠道需要在自己的 descriptor 中声明:websocket(覆写了某个 socket 操作)和 paid_usage(其 QuotaModel::allows_paid_usage 读取凭证 metadata 中的 allow_paid_usage 标志,控制台据此显示对应开关)。 跨请求状态由宿主限定在一个供应商加一把凭证的范围内,并以 CAS 写入。binding 的默认状态拒绝一切写入,所以渠道只有在宿主提供状态时才能使用。

登录状态(provider_state)

两种 OAuth 流程都带有一个 provider_state 映射,存放渠道在用户完成授权之后还需要的信息:start(或 authorize)放进去的内容会原样交回给 poll(或 exchange)。内容由渠道自行决定,例如非标准的设备句柄,或登录过程为自己注册的 client。 与宿主作为凭证 metadata 公开的 provider_fields 不同,这个映射可以存放密钥。它只保存在宿主的登录会话中,会话生命周期短、存于缓存、从不渲染,登录结束即丢弃。需要在登录之后继续保留的信息必须由 exchange 返回:公开信息放进 provider_fields(会成为 metadata),机密信息放进 provider_secrets,后者与令牌一起加密封存,不会成为 metadata。

向本仓库添加渠道

以 custom(API key)和 codex(OAuth 账号)为参考。最近新增的 opendesign 是一个小型 API key 渠道,可以从它的提交看到新渠道涉及的全部文件。
  1. 在 crates/gproxy-channel/Cargo.toml 中声明 feature,只列出该渠道需要的可选依赖。仅 wasm 需要的依赖放在 cfg(target_arch = "wasm32") 的 target 表下。
  2. 在 src/channels/mod.rs 的 channels! 列表中加一行:feature、模块,以及宿主要注册的值。这一行同时声明模块并把渠道加入 compiled_in(),所有宿主都通过它找到渠道。
    注册值是一个表达式,需要构造的渠道写成 claudeweb::ClaudeWeb::new();一个模块在同一套线协议上服务两个产品时,两个值都列出(opencode::OpenCode::ZEN, opencode::OpenCode::GO)。如果渠道用到 shared,还要把它的 feature 加进该模块的 cfg(any(...))。
  3. 按“一个文件一件事”组织模块。小渠道只有一个 acme.rs,大渠道用目录:
    模块文档要写明每一条线协议事实的来源:厂商的 API 参考,或 samples/ 下的 CLI 源码。其他渠道的代码不能作为来源。
  4. 实现 BaseChannel。最少需要 id、native_dialects 和 prepare。结构体是无状态的单元结构体,一个实例服务该渠道下的所有供应商。 同时覆写 descriptor。默认实现会根据访问器填好能力,但显示名称以及从供应商 config JSON 中解码哪些键,只有渠道自己知道。管理界面只依据 descriptor 渲染供应商表单(启用 ts feature 时它也是一个 TypeScript 类型),descriptor 中没有的键就无法在界面上设置。追加 HOST_CONFIG_KEYS 来声明宿主自己的键。 当上游需要多次交换、本地合成的应答、另一种传输方式,或需要把响应改写为声明的原生方言时,覆写具体的操作方法,而不是 prepare。
  5. 声明厂商客户端发送的内容。如果渠道模拟某个 CLI,导出它的请求头常量并据此构造白名单,这样供应商的 allowed_headers 去不掉 CLI 自己的请求头。同时把这些身份请求头传给转发助手的丢弃列表,防止客户端伪造。上游会对客户端做指纹识别时,由 default_connection 返回默认连接配置;凭证或供应商上显式指定的配置仍然优先。
  6. 把能力实现为独立的类型,由访问器返回。每个 trait 都有自己的规则:刷新返回完整替换,不做合并;配额维度来自凭证 metadata 中的套餐信息,不访问网络;用量观察者做累积快照,它的 finish 由宿主告知流是完整结束还是被中断,因为读到 EOF 并不代表用量完整。
  7. 可复用的线协议机制放在 src/channels/shared/,按使用它们的 feature 条件编译。策略留在渠道中,shared 模块只执行渠道要求的操作。
  8. 测试放在代码旁边的 tests/<id>.rs,用 #![cfg(feature = "…")] 控制。手工构造供应商和凭证视图,用测试密钥调用 prepare,断言 URL、注入的鉴权和被丢弃的请求头。把抓取的帧交给用量观察者,解析录制的配额请求头和用量 body。多次调用覆写用脚本化的出站 client 测试。这里不测试引擎。
  9. 把 feature 转发到每个宿主。引擎本身不列举渠道:gproxy-sdk 注册 compiled_in() 返回的全部渠道,所以只有当二进制的 feature 集合启用了渠道的 feature 时,二进制才能使用它。用同一个名字把 feature 加到: 注册时重复的 id 会被拒绝。供应商引用未注册的渠道属于配置错误,不会回退到其他渠道。
  10. 补充文档:在 crates/gproxy-channel/README.md 的 feature 表中加一行,并在文档子模块 guides/providers.mdx 的“支持的渠道”表中加一行,四种语言都要改。

检查构建

gproxy-channel 默认不启用任何渠道,所以这些命令必须带 --all-features,否则一个渠道都不会编译。每个渠道都必须能为原生目标和 wasm32-unknown-unknown 构建,Workers 宿主因此可以带上完整的渠道集合。lint 报告的问题要通过修改代码解决,不要加 #[allow]。 合入后不需要其他步骤:新渠道上的供应商会连同渠道的 descriptor 出现在 GET /admin/api/channels 中,控制台据此渲染表单。文档中的渠道参考由 CI 根据已注册的渠道重新生成。

在本仓库之外

渠道可以放在独立的 crate 中,不必 fork 就能发布,两种宿主都支持。原生二进制仍是原版 gproxy(命令、参数、控制台和数据库都相同),Worker 仍是原版 GPROXY Worker,只是在内置渠道之外多注册了你的渠道。examples/channel-plugin 是一套完整可运行的三个 crate:一个文件实现的 OpenAI 兼容 API key 渠道,以及每个宿主几行代码的入口。CI 每次改动都会构建这个示例,所以它与当前契约保持一致。

目录结构

渠道单独成为一个 crate,两个宿主才能都依赖它。渠道不能依赖原生宿主:gproxy 无法编译到 wasm,Worker 也会因此无法编译。
main_with 就是完整的原版 main:加载 .env,解析相同的参数,建立运行时并启动。自带运行时的程序改为调用 gproxy::run_with(cli, channels)。
fetch_with 就是完整的原版 fetch handler。一个 Worker 模块只能有一个 #[event(fetch)],所以 edge crate 依赖 gproxy-host-edge 时要关闭它的 entry feature,原版 handler 正是由这个 feature 声明的。闭包在每个 isolate 中只运行一次,即第一个请求组装实例时。 在两种宿主中,id 与内置渠道相同的渠道都会替换该内置渠道。要发布修改过的现有渠道,就用这种方式。

依赖

按路径依赖一份 gproxy checkout,版本与你要构建的目标一致:
checkout 自己的 patch 表里还有 glib,只有桌面宿主会用到,仓库外的 workspace 不需要这一项。第一次构建前,把 checkout 的 Cargo.lock 复制到根 Cargo.toml 旁边,使依赖解析到 gproxy 测试过的版本。 之所以使用 checkout 而不是 git 依赖,是因为原生控制台从 crates/gproxy-host-axum/assets/web 编译进二进制,而这个目录没有提交到仓库。先在 checkout 的 console/ 中执行 pnpm install && pnpm build;或者从 features 中去掉 embedded-console,构建不带控制台的二进制。下面的 Worker 构建同样从 checkout 获取控制台。 gproxy 和 gproxy-host-edge 的 feature 列表用法与原版构建相同:channels 启用全部内置渠道,也可以只列出部署要用的渠道。

构建 Worker

checkout 中的 deploy/cloudflare 可以构建任意 Worker crate,不限于原版。用绝对路径指向你的 crate,然后照常部署:
这一步会在你的 crate 中运行 worker-build,去掉调试段,再把 Worker 复制到 checkout 的 wrangler.toml、控制台资源及其 headers 文件旁边。边缘部署中的内容(D1 绑定、密钥、GPROXY_CONFIG)全部照常适用。 用于 Worker 的渠道必须能编译到 wasm32-unknown-unknown,所有内置渠道都满足这一点。具体来说:不使用 tokio 网络,不访问文件系统,用 web-time 代替 std::time::Instant,所有请求都通过宿主提供的 client 发送。用 cargo check --target wasm32-unknown-unknown -p my-channel 检查。

公开契约

渠道实现或接收的一切都在 gproxy_channel::channel 中公开:BaseChannel、上下文与视图类型、所有能力 trait、descriptor 类型(包括 HOST_CONFIG_KEYS),以及构造请求所需的助手:处理客户端请求头的 forwardable 和 HeaderAllowlist、写入运维配置的静态请求头的 insert_configured、清理查询串的 strip_query_auth,以及 standard_reason_observer。协议词汇(Dialect、Operation、OperationKey、HttpBody、WireRequest)来自 gproxy_protocol。 gproxy_channel::channels::shared 不公开。它针对本仓库的渠道编写,会随这些渠道一起变化,需要时复制过去。 契约中的规则同样适用,尤其是准备必须是纯函数、来源鉴权不会到达上游。

与内置渠道的区别

  • 控制台与对待其他渠道一样,根据 descriptor 渲染你的渠道:供应商表单来自 config_keys,凭证表单来自 credential_kinds,登录按钮来自 login_modes,配额、重置、刷新和付费用量控件来自 capabilities。缺少的是为内置 id 单独编写的内容,例如定制的提示文字或模型预设组。
  • 原生二进制的自更新会拒绝安装。发布的版本是本仓库的构建,装上之后会丢掉你的渠道,下次重启后其上的所有供应商都会失效。请基于新版本重新构建。检查更新功能照常可用。
  • 契约随 gproxy 的版本变化。实际变更都是增量的(多数协议类型带 #[non_exhaustive],BaseChannel 新增的方法都有默认实现),但仍应基于你实际运行的版本构建。写 ChannelCapabilities { quota_query: true, ..Default::default() },不要逐个列出字段,这样新增能力不会导致构建失败。

给编码 Agent 的清单

与上面相同的步骤,按 agent 执行的顺序列出。英文版本节标题为 “Checklist for Coding Agents”,仓库的 AGENTS.md 指向它。 内置渠道(向本仓库提 PR):
  1. 以 crates/gproxy-channel/src/channels/nvidia.rs(API key)或 codex/(OAuth)为模板,并阅读 samples/ 下厂商的线协议源码或其 API 参考。
  2. 在 crates/gproxy-channel/Cargo.toml 中加 feature,在 src/channels/mod.rs 的 channels! 中加一行,并把 feature 加进渠道用到的 src/channels/shared/ 中的每个 cfg(any(...))。
  3. 编写 src/channels/<id>.rs 或 src/channels/<id>/,覆写 descriptor。
  4. 在第 9 步列出的四处转发 feature:crates/gproxy-sdk/Cargo.toml(feature 和 channels 列表)、crates/gproxy/Cargo.toml、crates/gproxy-host-edge/Cargo.toml,以及 crates/gproxy-app/src/v3/channels.rs 中的 V4_CHANNELS(修改数组长度)。
  5. 如果渠道支持 allow_paid_usage 或提供 WebSocket,在 descriptor 的 capabilities 中设置 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 复制到 checkout 之外:channel/ 必须复制,native/ 和 edge/ 按要部署的宿主选择。加上前文带 [patch.crates-io] 表的 workspace 根 Cargo.toml,并复制 Cargo.lock。
  2. 在每个 crate 的 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 上运行原生二进制,或按前文构建后在 deploy/cloudflare 中用 wrangler dev --local 运行 Worker,确认 GET /admin/api/channels 列出了这个 id。