34 KiB
插件运行时与 CPA 集成开发文档
1. 定位
本模块是 cpa-ext 与 CLIProxyAPI(CPA)之间的工程适配层。它负责:
- native dynamic library ABI;
- JSON RPC envelope 和方法分发;
- capability 注册与 schema 协商;
- CPA callback 到 Core/采集模块的转换;
- 配置注册、热重配、运行时切换和 shutdown;
- Management API 与静态资源接入;
- 构建、动态库检查、安装和宿主集成测试。
它必须保持很薄。任何计价公式、余额规则、SQL、聚合算法或路由策略都不应写进 C ABI 入口或 RPC dispatcher。
生产安全前提见 security.md,安装/升级/恢复见 operations.md,所有 ABI、故障、性能和 soak 证据见 test-plan.md。
2. 当前兼容基线
设计时核对的源码版本:
| 项目 | revision |
|---|---|
CLIProxyAPI |
f43aad7637ad813745bf7d341acb5663617570c5 |
cpa-plugin-key-billing |
25b534ae386f830f537cca9215cff5586e630b3a |
cpa-usage-keeper |
d62cad3f345ae574089a14a4ac75cca023c7ead6 |
该 CPA revision 的契约:
- minimum host baseline:CLIProxyAPI
v7.2.130(上述 exact tag/revision); - Native ABI:
1; - RPC schema:最高
3; - 宿主实际调用的 RPC lifecycle:
plugin.register、plugin.reconfigure;plugin.shutdown目前只存在方法常量,没有宿主调用点; - CPA module 声明 Go
1.26.0,当前 cpa-ext module 声明 Go1.24;构建矩阵必须固定实际 Go/toolchain 版本并纳入双 runtime soak,不能只比较go.mod最低版本; - stream schema 3 的 payload chunk 不再重复携带 request body,必须在 header-init 或请求 hook 缓存关联所需的最小信息。
源码 checkout 才是最终规范。每次升级 CPA 都必须重新核对:
CLIProxyAPI/sdk/pluginabi/types.go;CLIProxyAPI/sdk/pluginapi/types.go;CLIProxyAPI/internal/pluginhost/rpc_schema.go;CLIProxyAPI/internal/pluginhost/rpc_client.go;- 相同 capability 的官方 example 和测试。
ABI 版本与 RPC schema 版本相互独立,不能因为两者当前分别是 1 和 3 就混成一个“插件版本”。
3. 目标 capability
完整产品需要以下 capability,但开发时只声明已经实现并测试的方法:
| Capability | RPC 方法 | 适配到内部能力 |
|---|---|---|
frontend_auth_provider |
frontend_auth.identifier、frontend_auth.authenticate |
插件自管下游 Key 认证 |
frontend_auth_provider_exclusive |
注册字段,无独立方法 | 生产模式阻止其他认证 provider 绕过 Core |
request_interceptor |
request.intercept_before、request.intercept_after |
准入、Request 建立、Execution attempt |
request_lifecycle_plugin |
request.complete |
成功/失败/拒绝/取消终态与清理 |
response_before_translator |
response.normalize_before |
读取接近 provider 原始语义的 Usage |
response_interceptor |
response.intercept_after |
非流式响应与 RequestID 关联 |
response_stream_interceptor |
response.intercept_stream_chunk |
流式 response ID/Usage 与 RequestID 关联 |
usage_plugin |
usage.handle |
CPA 标准 Usage、身份、TTFT、Latency、失败交叉校验 |
scheduler |
scheduler.pick |
Key/账户绑定到候选 AuthID |
management_api |
management.register、management.handle |
管理 JSON API、静态 shell、用户只读资源 |
不需要声明:model registrar/provider、auth provider、executor、translator、model router 等。cpa-ext 初期不替代 CPA 的 Codex 执行器或协议翻译器。
3.1 分阶段声明
建议 capability 开通顺序:
- 当前骨架:
usage_plugin; - 采集闭环:request interceptor + lifecycle + response hooks;
- 自管 Key:frontend auth,并在生产测试完成后打开 exclusive;
- 账户绑定:scheduler;
- 管理与 UI:management API。
未实现的方法不能返回空成功来假装支持。注册字段为 true 后,该 capability 隐含的所有方法都必须有结构化响应和测试。
4. Native ABI 入口
Go c-shared 入口放在 cmd/cpa-ext/main.go,使用 //go:build cshared。普通测试使用 main_stub.go 的 //go:build !cshared,确保 go test ./... 不要求 CGO。
cliproxy_plugin_init 必须严格按当前 CPA C 声明填充:
abi_version
call
free_buffer
shutdown (optional but cpa-ext 必须实现)
硬规则:
plugin == nil时返回非零;- 每次 call 开始先把 response ptr/len 初始化为零;
- 请求字节若要离开当前函数必须先复制到 Go-owned memory;
- 返回值使用 C allocator 分配,且只由插件自己的
free_buffer释放; - host callback 返回的 buffer 必须由 host 的 free function 释放;
- 不向 C 返回 Go heap pointer;
- 不在 ABI 层使用
os.Exit、log.Fatal或故意 panic; - recovered panic 转换成脱敏 error envelope;宿主自身也会 fuse panic 插件,但插件仍应保护 Core 边界。
入口只做:byte copy、envelope marshalling、App 指针读取、C buffer ownership。业务逻辑进入普通 Go dispatcher/Core。
5. RPC envelope 与本地 DTO
所有 RPC 使用:
{
"ok": true,
"result": {}
}
或:
{
"ok": false,
"error": {
"code": "stable_code",
"message": "safe message",
"http_status": 400,
"retryable": false
}
}
未知方法返回 unknown_method,不能 panic 或返回裸字符串。
支持多个 CPA release 时可以维护插件自己的 wire DTO,避免编译期强耦合宿主 module;但每个字段名、JSON tag、零值和新增字段都必须逐项对照当前 pluginapi/types.go 和 rpc_schema.go。CPA capability payload 多数嵌入无 JSON tag 的 exported struct,wire 字段通常是 PascalCase;生命周期与 registration wrapper 使用明确 snake_case,不能统一猜测命名规则。
6. 注册与热重配
6.1 plugin.register
宿主传入:
config_yaml;- host 支持的
schema_version。
插件执行:
- 解析 lifecycle request;
- 协商
min(host_schema, plugin_max_schema); - 严格解析和校验配置;
- 初始化或取得 Core Runtime;
- 返回 metadata、协商 schema 和已实现 capability;
- metadata 的 Name、Version、Author、GitHubRepository 必须非空且稳定。
6.2 plugin.reconfigure
plugin.reconfigure 返回的 metadata/capability shape 必须与 register 一致。配置更新流程:
parse → validate → build immutable candidate → open/check dependencies
→ atomic swap Runtime pointer → drain old Runtime
首次 plugin.register 配置无效时返回错误,不进入 active capabilities。已经注册后的 plugin.reconfigure 不能直接返回错误:当前 CPA 会因此把该插件从本轮 active records 移除、清除 exclusive provider,可能恢复原生认证。安全做法是保留最后一个有效 Runtime,记录 reconfigure_rejected 诊断,并仍以 success envelope 返回上一次有效 metadata/schema/capability shape。只有先修改 CPA 使 reconfigure 失败保留旧 active record 后,插件才可以对无效热配置返回 error。
配置分两类:
- 热配置:UI 选项、限流阈值、未绑定策略、统计刷新参数;
- 冷配置:database path、Key HMAC secret 来源、结算币种、插件 identity。
冷配置变化默认以上述 LKG 方式拒绝热切换并在诊断/API 提示重启;除非实现了完整的双 Runtime 打开、迁移和原子切换。当前 CPA 替换二进制时会把旧 native 实例放入 retired 集合直到宿主整体 shutdown,不能假定热替换时旧实例已排空。
6.3 建议配置
plugins:
enabled: true
dir: plugins
configs:
cpa-ext:
enabled: true
priority: 100
codex_only: true
data_dir: data/cpa-ext
database_file: cpa-ext.db
settlement_currency: USD
frontend_auth_exclusive: true
unbound_key_policy: deny
unpriced_usage_policy: deny_new_requests
实例 Key 校验 secret 使用环境变量、secret file 或操作系统 secret store,不直接写进该 YAML,也不放进管理 metadata。
6.4 Schema 协商也是性能契约
生命周期 DTO 必须同时读取 config_yaml 和宿主传入的 schema_version,注册结果使用:
negotiated_schema = min(host_schema, plugin_max_schema)
不能把 schema 固定成编译期常量后忽略宿主版本。当前 cpa-plugin-key-billing v0.3.1 正是一个反例:
internal/plugin/types.go固定SchemaVersion = 2;- 它自己的
LifecycleRequest只定义config_yaml,没有读取 host schema; registration()每次都返回固定 schema 2;- README 同时需要兼容最低 CPA
7.2.103,而其计费闭环使用 schema 2 已具备的 request lifecycle/terminate 能力。
因此可以确认“固定 2 是旧契约/兼容实现且尚未适配新性能契约”;无法仅凭源码断言作者的主观动机。当前 CPA v7.2.130 的 schema 3 专门解决一项流式放大:payload chunk 不再重复携带 OriginalRequest 和 RequestBody,只在 ChunkIndex=-1 的 header-init 发送一次。由于 Go JSON 会把 []byte 编成 base64,schema 2 在长 Prompt 下会把复制量和编码量放大到“请求体大小 × chunk 数”。
本项目规则:
- cpa-ext 的生产流式计费最低要求 RPC schema 3;宿主低于 3 时拒绝启用流式计费,不静默回退到高开销 schema 2;
- register 与 reconfigure 都返回实际协商版本,并用真机测试确认宿主记录的 schema;
- schema 3 只解决
response.intercept_stream_chunk的重复请求体,不解决全部流式开销; - 当前
response.normalize_before在每个上游流式帧仍携带OriginalRequest、TranslatedRequest和Body;stream interceptor 还会携带最多 64 个、合计 1 MiB 的HistoryChunks; - 插件本地 DTO 即使忽略这些字段,宿主侧 JSON/base64 编码、内存复制和 C ABI 调用已经发生,不能把“没有读取字段”当成零成本。
所以 schema 3 是必须的立即缓解,不是完整解决。完整解决需要缩小宿主 callback DTO 或消除逐 chunk callback,见 8.4 和 13.4。
7. Runtime 与模块装配
只保留一个有明确所有权的全局 App 入口:
atomic App/Runtime pointer
├─ Core facade
├─ CPA adapters
├─ repositories
├─ callback-driven maintenance
├─ optional sidecar client
└─ immutable config/catalog snapshots
建议目录:
cmd/cpa-ext/
main.go # cshared ABI
main_stub.go
internal/plugin/
dispatcher.go
registration.go
envelope.go
wire_*.go
internal/cpaadapter/
frontend_auth.go
interceptor.go
lifecycle.go
responses.go
usage.go
scheduler.go
management.go
internal/core/
internal/domain/
internal/repository/
internal/statistics/
web/ # React/Vite source
internal/webui/ # go:embed built assets
internal/plugin 只理解 RPC,internal/cpaadapter 只把 CPA DTO 转成内部 command/observation,internal/core 不 import C 或 CPA wire DTO。
7.1 P0:双 Go runtime 可行性门禁
cpa-plugin-key-billing/internal/billing/store.go 记录过实际事故:Go c-shared 插件在 Go 宿主进程中运行自己的 timer/GC/preemption 后,整个 CPA 因 fatal error: bad flushGen 崩溃,因此该插件刻意不保留 goroutine。当前 CPA 在 config disable 或二进制热替换时也不会立刻 shutdown 旧 native 实例,旧 goroutine 可能长期存活。
所以在实现业务前必须完成 runtime spike/soak,不能先把“动态库内 SQLite + 多个长期 worker”当成已成立:
- 最小 c-shared 插件零长期 goroutine,验证 ABI、并发 callback、GC 压力和反复 register/reconfigure;
- 加入选定 SQLite driver 的同步短事务,确认 driver/
database/sql是否暗启长期 goroutine/timer; - 分别测试 callback-driven 维护、一个受控 worker、完整 worker 集合;
- 覆盖 config disable、无效 reconfigure、binary replacement、plugin fuse、CPA shutdown;
- 在目标 Windows DLL 与 Linux/WSL
.so上执行并发压力和至少 24 小时 soak,任何宿主 crash、旧实例活动或不可解释 goroutine 增长都判失败。
门禁通过前的 MVP 采用 callback/management-request 驱动:事实同步短事务落库,聚合小批追赶、清理和 checkpoint 只在安全宿主调用中有预算地执行;备份和长任务交给外部 sidecar/运维命令。门禁失败时长期聚合、归档、备份、provider refresh 必须永久移到 sidecar,动态库只保留认证/准入/采集/结算的薄同步路径。
8. CPA 生命周期映射
8.1 Frontend auth
frontend_auth.authenticate 收到 method/path/headers/query/body。适配器应:
- 在读取/验证 Key 前应用 access-routing.md 的 method + path allowlist,未知入口默认
Authenticated=false; - 只读取允许的认证 Header;
- 限制 body/headers 的处理量;
- 不记录完整请求或 Key;
- 调用 Core credential authentication;
- 返回稳定 Credential ID 作为 Principal;
- 失败返回
Authenticated=false,不泄露“Key 是否存在”等枚举信息。
生产打开 exclusive 时,当前 CPA 会在多个 exclusive provider 中选择最高 priority,priority 相同按 plugin ID 决定。必须在真实宿主中确认 cpa-ext 是唯一生效的认证路径。
当前 CPA frontend-auth contract 不能携带插件自定义认证错误。internal/pluginhost/adapters_auth.go 会将插件 RPC 错误或 Authenticated=false 都转换为 access manager 的 NotHandled;如果所有生效 provider 都是 NotHandled,manager 返回宿主 401 no_credentials。由此得到三条实现约束:
- 第一版不要在 API 文档中承诺
invalid_credential、disabled/expired 的独立错误码; - credential lookup 数据库故障仍然 fail closed,但外部也会看到
401 no_credentials,内部必须保留可告警的诊断分类; - 如需向客户端准确返回认证服务
503,必须升级 CPA frontend-auth wire response/adapter,并更新兼容性矩阵。
Core readiness(数据库、HMAC keyring、账本、当前价格政策)不健康时,frontend auth 也返回 Authenticated=false,作为 interceptor 之前的第二道 fail-closed;外部仍只看到 401,真实原因进入诊断。
当前宿主在调用 frontend auth 前会 ReadAll 完整请求 Body 并将 headers/query/body 复制进 RPC,即使插件只检查 Header 也无法在内存分配前拒绝;Management handler 同样先被宿主完整读取。反向代理/CPA HTTP 层必须配置全局请求体上限,frontend-auth 对 Codex 大 Prompt 还要做容量基准。长期应推动 CPA 提供 header-only auth DTO 和宿主级 MaxBytesReader;插件自己的长度校验只减少后续解析,不能宣称保护了宿主内存。
更关键的是:exclusive 只在插件 active 时存在。当前 CPA 没有 required plugin 或“零认证 provider 默认拒绝”的配置;插件缺失/加载失败/被 fuse 时会清除 exclusive,恢复其他 provider,甚至在零 provider 时走 legacy 放行。生产部署必须同时满足:
- CPA 配置一个不分发给用户的 256-bit 以上随机 native sentinel key,确保插件缺失时不是零 provider;
cpa-ext注册 management readiness 路径,报告数据库、HMAC keyring、价格、exclusive/capability 自检结果;- 外部启动器/反向代理在 readiness 成功前不开放用户端口,并在插件丢失/fuse 后撤流量;
- 最终推动 CPA 增加 required-plugin + authentication-default-deny;在此之前发布说明必须标注这是部署安全前提,而非动态库自身能力。
sentinel 只由部署运维保管,不能交给用户、写进前端或作为日常调用 Key;否则它本身就是计费旁路。
8.2 Request interceptors
- before-upstream-auth(CPA 方法名 before-auth):同步耐久化 Request pending、金额准入、占用并发;
- after-auth:每次上游尝试记录 selected auth/model/format;
- metadata 视为只读 JSON-like snapshot;
- interceptor response 不能任意给后续阶段增加 metadata;
- nested
plugin_host_model_callback必须识别,避免重复计费。
当前 CPA 对 interceptor RPC error 或 host-boundary panic 的处理是记录后继续请求。所有预期拒绝以及 DB、账本、价格故障必须返回正常 RPC success envelope,结果为 Terminate=true、合法状态码和脱敏错误体;不得 return error 表示拒绝。dispatcher 在方法边界 recover,并把 interceptor panic 转为上述 503 termination。若 panic 已越过插件边界并导致宿主 fuse,当前请求无法由纯插件保证 fail closed,因此必须依赖上一节的部署门禁。
8.3 Scheduler
scheduler.pick 只能从宿主提供的 candidate 列表选择 AuthID,或明确 delegate 内置 scheduler。候选已经经过 provider/model/disabled/cooldown/tried 等过滤,插件不能选列表之外的 Auth。
宿主把 Handled=false、空响应、unknown AuthID 和 scheduler host-boundary panic 当作 unhandled,然后回退内置选择。strict 绑定的拒绝必须由插件内部 recover 后返回 scheduler RPC error;after-auth interceptor 在真正执行前还要复核 selected AuthID。不应承诺 scheduler 自定义 HTTP error body:当前契约通常只能向调用方形成 generic 5xx。
当前宿主只会让最高优先级的有效 scheduler 策略实际主导选择,因此所有 Key binding、strict/preferred/pool 和 fallback 规则应在 cpa-ext 一个 scheduler 内组合。CPA home.enabled 会跳过 plugin scheduler,并使 plugin Management/resource routes 返回 404;cpa-ext 第一版整体不支持 Home,生产启动检查必须拒绝该模式。
8.4 Response 与 Usage
- 翻译前 response 用来解析 provider 权威 Usage;
- 非流式/流式 response interceptor 用 RequestID 绑定 response ID;
- schema 3 stream payload chunk 不含重复 request body,关联数据在 header-init/请求阶段缓存;
usage.handle当前没有 RequestID,不能单独作为逐请求扣费提交点;- 多来源观察保留 provenance 和 quality,不一致时不静默覆盖;
- callback 可能并发、重复、乱序或迟到。
为什么参考插件会同时使用两个同步 response hook:
- 翻译前 hook 能看到 provider 权威 Usage,但当前没有 RequestID;
- 翻译后的 response/stream hook 有 RequestID,可以用 response ID 完成归属;
- Codex WebSocket 同协议透传时可能不经过翻译前 hook,
cpa-plugin-key-billing v0.3.1因而又从下游 chunk 读取 response ID/Usage。
这个关联思路可以借鉴,但 capability 组合不能原样复制。当前两个 hook 都在下游交付关键路径同步执行:每个 frame/chunk 要经过宿主结构复制、JSON/base64、C ABI、插件 JSON 解码和返回 envelope。快速判断“这个事件没 Usage”只能节省插件内部解析,无法消除前面的传输成本。
采集方案按以下优先级选择:
- 最优:扩展 CPA
UsageRecord,直接提供 RequestID、Execution/AttemptID、ResponseID、Usage revision/source;插件不注册逐 chunk hook; - 次优:新增只读 usage observer/final-frame callback,只发送 response ID、RequestID 和 Usage,不携带 Prompt、HistoryChunks 或可修改响应的能力;
- 过渡:必须使用双 hook 时协商 schema 3,在 header-init 缓存最小关联,并推动 response-before 增加同类“payload frame 省略 request bodies”契约以及 stream capability 的
needs_history=false; - 禁止:schema 2 + 双 hook 作为生产默认,或通过时间/AuthID 猜配 Usage 来换取速度。
在宿主契约尚未补齐时,是否保留 stream hook 由 13.4 的性能/正确性联合门禁决定,不能只因功能测试能扣到钱就发布。
每个 Execution 维护 canonical cumulative usage vector、source rank、response ID 和 hash。只有事务内 CAS 确认 vector 改变时才增加本地 usage_revision;相同 callback 重放不产生新 revision,多来源不能相加。response hook 取得新的可靠 canonical Usage 后,必须在返回 CPA 前同步写 SQLite observation/inbox;至少最终 Usage 不能只进入内存 channel。
若上游已经产生 Usage 后 SQLite 持久化失败,纯插件无法撤销该上游成本。adapter 记录高严重度诊断、把 Core readiness 置为 unhealthy,并让后续 frontend auth/interceptor 拒绝新请求;不得返回一个会被宿主忽略的 response-hook RPC error并假装已经可靠落库。该故障是管理员对账中的 persistence_gap,恢复后需要显式核对,不能猜费。
8.5 Request completion
当前 CPA 定义:succeeded、failed、rejected、canceled。completion 包含 RequestID、TraceID、模型、时间、状态和 metadata,但不包含 Token。
宿主以异步、单次且无 durable retry 的方式通知 lifecycle plugin,业务不能依赖调用方 context 仍然存活。适配器应快速完成持久化命令或投递到已经持久化的 inbox;不能只放进可能丢失的内存 channel。completion 只负责终态、释放并发和触发结算,不是 Request/Usage 的唯一落库点。启动恢复必须扫描 pending、已保存 Usage 但未结算的 Execution 和未发布 projection/outbox。
取消处理按 billing.md/core.md:释放并发与完成金额核算分离;有 Usage 结算,无 Usage 进入 awaiting,迟到 Usage 追加补记。
9. Management API 与用户页面限制
CPA 当前提供两类 plugin route:
9.1 Management routes
- 挂载于
/v0/management/...; - 由 CPA Management Key 认证;
- 支持声明的精确 HTTP method/path;
- 用于管理员 CRUD、账本、价格、路由、统计和诊断;
- handler 必须在 DTO 层拒绝过大 body、错误 content type,并限制分页和响应大小;但宿主已先完整读取 body,真正的内存上限必须设置在反向代理/CPA HTTP 层。
9.2 Resource routes
- 挂载于
/v0/resource/plugins/<pluginID>/...; - 当前只允许 browser GET resource;
- 不经过 CPA Management Key;
- 只能声明精确路径,不支持
:param、*、..; - 适合静态 HTML/JS/CSS shell,但天然不是安全的管理员 API。
因此:
- 静态 shell 可以公开;
- 管理员数据只从 Management routes 取得;
- 不能把 CPA Management Key 写入 HTML、JS、URL、localStorage 或插件 session;
- 用户自助页不能直接调用管理员接口。
MVP 用户只读数据可以注册少量精确 resource GET JSON 路径,并由插件 handler 自行校验 Authorization: Bearer <downstream-key>,只返回该 Credential 的金额投影。Key 只保存在页面内存,不放 URL、cookie 日志或 localStorage,刷新后重新输入。若需要 HttpOnly session、POST 操作、稳定登录和完整 CSRF 模型,应增加经过明确设计的外部 sidecar/public API,或推动 CPA 增加 authenticated user plugin routes;不能假装当前 Management capability 已经提供这类接口。
Home 模式下 Management 与 resource route 都不可用;不是只有 scheduler 失效。第一版 readiness 遇到 home.enabled 必须失败。
9.3 Adapter 数据最小化与脱敏
CPA wire payload 的秘密面比业务契约大。每个 adapter 在进入 Core 前执行 allowlist/drop:
| 入口 | 可能含秘密 | adapter 规则 |
|---|---|---|
| frontend auth | Authorization、query、完整 Prompt Body | 只提取允许 Header 和 method/path;验证后丢弃原值,禁止日志/持久化 |
| scheduler | Options.Headers 当前未保证已脱敏 |
路由只读取 host metadata 中 caller_scope;headers 默认全部丢弃 |
| Management | CPA Management Key、Cookie、完整 body | 进入 Core 前删除 Authorization/Cookie;body 严格 DTO 解码、字段/大小校验 |
| compatibility usage | UsageRecord.APIKey 可能是原始 CPA Key |
在 adapter 内立即映射/HMAC,之后只传 credential/scope;原值不得落库 |
| failure/response hooks | 错误 body、request/response 正文 | 只提取标准错误类别、response ID、Usage;原始正文默认丢弃 |
日志 API 默认只接收 stable IDs、preview、长度和分类,不接受通用 wire DTO。任何调试开关也不得输出 Authorization、Management Key、OAuth/provider token、Prompt 或完整响应。
10. 后台任务
只有通过 7.1 的目标平台 soak gate 后,插件才允许启用有界后台 worker;否则以下任务由 callback-driven runner 或 sidecar 承担:
- statistics aggregation;
- awaiting usage reconciliation;
- archive/cleanup;
- backup;
- catalog/quota refresh。
规则:
- 每个 worker 接收 Runtime context;
- queue 有界且可观测,关键事实先落 SQLite;
- 不持锁执行 host callback、网络或慢 SQL;
- 单个可选 worker 失败不杀死 CPA;
- migration/账本等核心失败进入 fail-closed health;
- reconfigure 不得重复启动同一 worker;
- shutdown cancel 后有界等待,超时记录错误并继续释放资源。
CPA config disable/热替换不会立即调用旧 native shutdown,因此“worker 属于 Runtime”仍不足以保证停止;启用 worker 的版本还必须有 host generation/lease,使失去 active 身份的旧 Runtime 在下一次 lease 检查时自行停机。纯插件拿不到可靠 active lease 时,不得启用长期 worker。
11. Shutdown
当前 CPA revision 只会通过 native function table 的 shutdown 真正通知关闭;虽然 ABI 常量中定义了 plugin.shutdown,宿主没有调用点,不能依赖它。native shutdown 必须进入一个幂等 sync.Once/状态机:
- 拒绝新管理写入与新准入;
- cancel worker context;
- 有界等待 worker;
- 提交已在事务中的必要账本写入;
- checkpoint/关闭 SQLite reader 和 writer;
- 清除 host callback context 引用;
- 重复 shutdown 返回成功。
不能无限等待外部网络、不能调用 os.Exit、不能在动态库卸载后留下仍访问插件状态的 goroutine。
不能依赖 shutdown 处理 config disable 或 binary hot replacement:当前 CPA 可能只摘除 capability/retire 旧库,直至整个 plugin host 关闭才调用旧实例 shutdown。生产更新动态库采用排空并重启 CPA,不支持带长期资源的原地热替换。
12. 构建、发现与安装
Go c-shared 需要目标平台 C toolchain。仅设置 GOOS/GOARCH 通常不足以交叉编译 CGO,应在目标系统或可靠对应 toolchain 中分别构建。
Linux/WSL:
gofmt -w cmd internal
go test ./...
go test -race ./...
CGO_ENABLED=1 go build -tags cshared -buildmode=c-shared \
-o bin/cpa-ext.so ./cmd/cpa-ext
file bin/cpa-ext.so
nm -D bin/cpa-ext.so | grep cliproxy_plugin_init
WSL 产出的 .so 只能给 Linux/WSL CPA 使用,不能放进 Windows CPA。
Windows:
$env:CGO_ENABLED='1'
go build -tags cshared -buildmode=c-shared -o bin/cpa-ext.dll ./cmd/cpa-ext
# 使用 Visual Studio dumpbin /exports 或 llvm-nm 验证 cliproxy_plugin_init
CPA discovery 支持:
plugins/<goos>/<goarch>/cpa-ext.so
plugins/cpa-ext.so
plugins/cpa-ext-v<version>.so
Windows 使用 .dll,macOS 使用 .dylib。插件 ID 必须与配置 key、文件名 ID 和 management/resource path 一致。版本化文件名不带前导 v 的版本字段部分,例如 cpa-ext-v0.1.0.so。
发布按 OS/architecture 分包,包含 checksum、插件版本、目标 CPA revision/minimum version、ABI/RPC schema、固定构建工具链和 migration 说明;不包含数据库、secret、auth 文件或生成的 .h(除非确有消费者)。版本化文件名、Metadata.Version 和 release metadata 必须一致。第一版升级要求排空并重启 CPA,不宣传 live binary hot reload。
13. 测试顺序
13.1 普通 Go 测试
- RPC envelope、未知方法、畸形 JSON;
- register/reconfigure 一致性;已注册后的无效 reconfigure 返回 LKG registration、保留旧 Runtime并记录诊断;
- Core 领域/用例;
- SQLite transaction、migration、幂等、恢复;
- 并发认证、准入、终态、迟到 Usage;
- 各 capability dispatcher;
- shutdown/reconfigure race。
- adapter redaction:Authorization/Management Key/Prompt/UsageRecord.APIKey 不进入 Core、日志或数据库。
13.2 动态库测试
- 实际 c-shared build;
cliproxy_plugin_initexport;- 文件名/目录 discovery;
- ABI 不匹配拒绝;
- response buffer 能被正确释放;
- shutdown 可重复。
- 零常驻 goroutine基线、SQLite driver goroutine/timer 清单和双 runtime 压力/soak。
13.3 CPA 真机端到端
按 capability 逐项验证:
- 宿主日志显示 plugin registered、schema 和 capability 正确;
- reconfigure 保留数据库且不重复 worker;
- 自管 Key 成功,未知/禁用 Key 失败,原生 Key 无法绕过 exclusive;
- 非流式与流式 Codex 请求;
- strict/preferred/pool Auth 路由;
- 成功、上游失败、本地拒绝、客户端取消;
- 取消前有 Usage 扣费、无 Usage 标 unmeasured、迟到 Usage 补记;
- 重试和重复 callback 不重复扣;
- 额度耗尽返回 429;
- 管理路由受 Management Key 保护,resource 不泄密;
- 重启后余额、账本、pending recovery 和统计 checkpoint 一致。
- 写入畸形热配置后 exclusive/LKG 仍 active;插件缺失、fuse 和 Home 模式被 gateway/readiness 阻断;
- 枚举全部 authenticated endpoint,allowlist 外入口拒绝;
- DB error、interceptor/scheduler panic/invalid response 故障注入不形成可控边界内的免费请求或串号路由;
- 大 Prompt/Management body 的代理上限和 frontend-auth 复制成本达到容量目标;
A & B <x>Management JSON 往返和 CSV 公式注入防护正确。
13.4 流式性能回归门禁
基准必须在同一个 CPA、同一个 OAuth 账户、同一模型、同一 effective service tier、同一 transport下做 A/B,避免把账号、Fast 或网络差异误判成插件开销。固定四组:
| 组别 | 目的 |
|---|---|
| 不加载任何目标插件 | CPA/OAuth 基线 |
| schema 3 空 hook 插件 | 测量宿主 callback/ABI 固有成本 |
| 仅采集与关联 | 测量 response parsing、锁和持久化成本 |
| 完整 cpa-ext | 最终性能与计费正确性 |
覆盖:
- SSE 与 Codex WebSocket;
- 1 KiB、128 KiB、1 MiB Prompt;
- 短输出以及 32、256、1024 chunks;
- 并发 1、8、32;
- standard 与 priority/Fast;
- 成功、上游失败和客户端取消。
采集指标:TTFT P50/P95、总时长、tokens/s 或 chunks/s、chunk 间隔、CPU、alloc/GC、插件 RPC 次数/总字节/最大 payload、锁等待,以及最终 canonical Usage/金额。
结构性断言:
- schema 3 payload chunk 的
OriginalRequest/RequestBody为空,完整请求只允许出现在 header-init; - 若仍启用 response-before,必须单独统计它重复携带请求体的字节数,不能混入“schema 3 已优化”的结论;
HistoryChunks未被业务使用时不得长期作为生产 payload;当前宿主无法关闭时,必须以基准证明成本可接受或先修改宿主;- 单个普通 chunk 的目标大小为
O(chunk),不得随 Prompt 或累计历史线性增长; - 优化前后响应字节、Usage、金额和取消结算结果一致。
默认发布阈值:常规负载吞吐下降不超过 5%,压力负载不超过 10%,P95 TTFT 增量不超过 max(20ms, 5%)。任一结构性断言失败,或正确性依赖猜配,均直接判定不通过,百分比阈值不能豁免。
14. 源码导航
CPA 权威源码:
| 问题 | 路径 |
|---|---|
| ABI/schema/method 常量 | CLIProxyAPI/sdk/pluginabi/types.go |
| capability DTO | CLIProxyAPI/sdk/pluginapi/types.go |
| registration wire schema | CLIProxyAPI/internal/pluginhost/rpc_schema.go |
| schema negotiation/RPC adapter | internal/pluginhost/rpc_client.go |
| 加载、配置和 lifecycle | internal/pluginhost/host.go、config.go |
| 动态库命名/发现 | internal/pluginhost/platform.go |
| C loading/ownership | internal/pluginhost/loader_windows.go、loader_unix.go |
| frontend auth/exclusive | internal/pluginhost/adapters_auth.go |
| scheduler | internal/pluginhost/scheduler.go |
| intercept/lifecycle adapters | internal/pluginhost/adapters_interceptors.go |
| usage/response adapters | internal/pluginhost/adapters_usage_translation.go |
| management/resource routes | internal/pluginhost/management.go |
| Principal → caller_scope | CLIProxyAPI/sdk/api/handlers/handlers.go、sdk/cliproxy/session/identity.go |
优先官方 examples:
examples/plugin/frontend-auth-exclusive/;examples/plugin/request-lifecycle/;examples/plugin/scheduler/;examples/plugin/usage/;examples/plugin/management-api/;examples/plugin/codex-service-tier/。
生产结构参考 cpa-plugin-key-billing/cmd/cpa-key-billing/main.go 和 internal/plugin/,但协议冲突时永远以目标 CPA 源码和同 revision 官方测试为准。
15. 验收标准
- C ABI 入口保持薄且内存所有权正确;
- register/reconfigure 协商不高于宿主 schema,并返回相同 capability shape;
- 只声明已实现能力,所有隐含方法均有测试;
- Core、领域和 Repository 可以在无 CGO 情况下测试;
- callback 并发、重复、乱序、迟到不会造成状态泄漏或重复账单;
- production exclusive auth 已通过真实 CPA 验证,不存在认证旁路;
- 全部 CPA 已认证 endpoint 已枚举,MVP allowlist 外入口用用户 Key 一律失败;
- 插件缺失、加载失败、host fuse 时 sentinel + readiness gate 能阻止用户流量;
- interceptor/scheduler 的 error、invalid response 和 panic 已验证不会在插件可控边界内放行;
- 部署检查拒绝
home.enabled,因为 scheduler 与全部插件 UI/API 均不可用; - Management API、resource 和用户只读路径权限分离;
- build 产物导出正确 init symbol,并能被目标 CPA revision 加载;
- shutdown/reconfigure 不泄漏 worker、数据库连接或 host callback context;
- Windows/Linux 目标工具链下双 Go runtime/SQLite spike 与 24h soak 通过,或所有长期任务已移入 sidecar;
- 发布说明准确报告 CPA revision、ABI、schema、capability、构建命令、产物和未执行检查。