- 本仓库:作为
gproxy-channel的一个模块,挂在独立的 Cargo feature 后面,通过 PR 合入。合入后所有宿主都能使用,包括 Workers 宿主。见向本仓库添加渠道。 - 自己的 crate:一个按公开契约实现
BaseChannel的类型,链接进一个除此之外与原版相同的原生二进制或 Worker。本仓库无需任何改动。见在本仓库之外。
什么时候需要渠道
渠道要跟着别人的线协议持续维护,所以只有在一行供应商配置表达不了上游的需求时才值得新增:路径随操作变化、请求体需要改写、有账号接口要读取,或者要模拟某个客户端身份。 如果差别只是源地址和一个请求头,用custom 供应商即可,见不需要渠道的厂商。
BaseChannel 契约
BaseChannel 是唯一必须实现的 trait,id 是其中唯一必须实现的方法。每个协议操作都有独立的异步方法和默认实现:用 prepare(HTTP)或 prepare_connect(WebSocket)构造请求,再交给宿主分配的 client 发送。
两个准备钩子默认都返回“不支持”。因此渠道支持的操作就是它准备或覆写的那些,没有别的。
ProviderView:id、channel、可选的base_url,以及由渠道自行解码为类型化设置的configJSON。CredentialView:id、provider_id、auth_kind、secretJSON(不实现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 渠道,可以从它的提交看到新渠道涉及的全部文件。
-
在
crates/gproxy-channel/Cargo.toml中声明 feature,只列出该渠道需要的可选依赖。仅 wasm 需要的依赖放在cfg(target_arch = "wasm32")的 target 表下。 -
在
src/channels/mod.rs的channels!列表中加一行:feature、模块,以及宿主要注册的值。这一行同时声明模块并把渠道加入compiled_in(),所有宿主都通过它找到渠道。注册值是一个表达式,需要构造的渠道写成claudeweb::ClaudeWeb::new();一个模块在同一套线协议上服务两个产品时,两个值都列出(opencode::OpenCode::ZEN, opencode::OpenCode::GO)。如果渠道用到shared,还要把它的 feature 加进该模块的cfg(any(...))。 -
按“一个文件一件事”组织模块。小渠道只有一个
acme.rs,大渠道用目录:模块文档要写明每一条线协议事实的来源:厂商的 API 参考,或samples/下的 CLI 源码。其他渠道的代码不能作为来源。 -
实现
BaseChannel。最少需要id、native_dialects和prepare。结构体是无状态的单元结构体,一个实例服务该渠道下的所有供应商。 同时覆写descriptor。默认实现会根据访问器填好能力,但显示名称以及从供应商configJSON 中解码哪些键,只有渠道自己知道。管理界面只依据 descriptor 渲染供应商表单(启用tsfeature 时它也是一个 TypeScript 类型),descriptor 中没有的键就无法在界面上设置。追加HOST_CONFIG_KEYS来声明宿主自己的键。 当上游需要多次交换、本地合成的应答、另一种传输方式,或需要把响应改写为声明的原生方言时,覆写具体的操作方法,而不是prepare。 -
声明厂商客户端发送的内容。如果渠道模拟某个 CLI,导出它的请求头常量并据此构造白名单,这样供应商的
allowed_headers去不掉 CLI 自己的请求头。同时把这些身份请求头传给转发助手的丢弃列表,防止客户端伪造。上游会对客户端做指纹识别时,由default_connection返回默认连接配置;凭证或供应商上显式指定的配置仍然优先。 -
把能力实现为独立的类型,由访问器返回。每个 trait 都有自己的规则:刷新返回完整替换,不做合并;配额维度来自凭证 metadata 中的套餐信息,不访问网络;用量观察者做累积快照,它的
finish由宿主告知流是完整结束还是被中断,因为读到 EOF 并不代表用量完整。 -
可复用的线协议机制放在
src/channels/shared/,按使用它们的 feature 条件编译。策略留在渠道中,shared 模块只执行渠道要求的操作。 -
测试放在代码旁边的
tests/<id>.rs,用#![cfg(feature = "…")]控制。手工构造供应商和凭证视图,用测试密钥调用prepare,断言 URL、注入的鉴权和被丢弃的请求头。把抓取的帧交给用量观察者,解析录制的配额请求头和用量 body。多次调用覆写用脚本化的出站 client 测试。这里不测试引擎。 -
把 feature 转发到每个宿主。引擎本身不列举渠道:
gproxy-sdk注册compiled_in()返回的全部渠道,所以只有当二进制的 feature 集合启用了渠道的 feature 时,二进制才能使用它。用同一个名字把 feature 加到:注册时重复的 id 会被拒绝。供应商引用未注册的渠道属于配置错误,不会回退到其他渠道。 -
补充文档:在
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 每次改动都会构建这个示例,所以它与当前契约保持一致。
目录结构
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,版本与你要构建的目标一致: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,然后照常部署:
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):
- 以
crates/gproxy-channel/src/channels/nvidia.rs(API key)或codex/(OAuth)为模板,并阅读samples/下厂商的线协议源码或其 API 参考。 - 在
crates/gproxy-channel/Cargo.toml中加 feature,在src/channels/mod.rs的channels!中加一行,并把 feature 加进渠道用到的src/channels/shared/中的每个cfg(any(...))。 - 编写
src/channels/<id>.rs或src/channels/<id>/,覆写descriptor。 - 在第 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(修改数组长度)。 - 如果渠道支持
allow_paid_usage或提供 WebSocket,在 descriptor 的 capabilities 中设置paid_usage/websocket。 - 在
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的“支持的渠道”表中加一行(文档改动在子模块中提交)。 - 运行
pnpm --dir docs generate:reference,把已注册的渠道导出到参考文档。不要提交生成的 JSON,CI 会导出并提交。 - 运行检查构建中的命令。
- 把
examples/channel-plugin复制到 checkout 之外:channel/必须复制,native/和edge/按要部署的宿主选择。加上前文带[patch.crates-io]表的 workspace 根Cargo.toml,并复制Cargo.lock。 - 在每个 crate 的
Cargo.toml中,用包自己的version和edition = "2024"替换*.workspace = true这些键,并把每个workspace = true依赖改为前文所示的路径依赖。 - 重命名包、二进制、结构体和
ID。除非就是要替换某个内置渠道,否则选一个内置渠道没有用过的 id。 - 只基于
gproxy_channel::channel实现,不要使用channels::shared。 - 运行
cargo clippy --all-targets -- -D warnings;部署 Worker 时再运行cargo check --target wasm32-unknown-unknown -p <channel> -p <edge>。 - 在全新的
GPROXY_DATA_DIR上运行原生二进制,或按前文构建后在deploy/cloudflare中用wrangler dev --local运行 Worker,确认GET /admin/api/channels列出了这个 id。