Files
cpa-plugin/docs/modules/dev.md
T

34 KiB
Raw Blame History

插件运行时与 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 baselineCLIProxyAPI v7.2.130(上述 exact tag/revision);
  • Native ABI1
  • RPC schema:最高 3
  • 宿主实际调用的 RPC lifecycleplugin.registerplugin.reconfigureplugin.shutdown 目前只存在方法常量,没有宿主调用点;
  • CPA module 声明 Go 1.26.0,当前 cpa-ext module 声明 Go 1.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.identifierfrontend_auth.authenticate 插件自管下游 Key 认证
frontend_auth_provider_exclusive 注册字段,无独立方法 生产模式阻止其他认证 provider 绕过 Core
request_interceptor request.intercept_beforerequest.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.registermanagement.handle 管理 JSON API、静态 shell、用户只读资源

不需要声明:model registrar/provider、auth provider、executor、translator、model router 等。cpa-ext 初期不替代 CPA 的 Codex 执行器或协议翻译器。

3.1 分阶段声明

建议 capability 开通顺序:

  1. 当前骨架:usage_plugin
  2. 采集闭环:request interceptor + lifecycle + response hooks
  3. 自管 Keyfrontend auth,并在生产测试完成后打开 exclusive;
  4. 账户绑定:scheduler
  5. 管理与 UImanagement 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.Exitlog.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.gorpc_schema.go。CPA capability payload 多数嵌入无 JSON tag 的 exported structwire 字段通常是 PascalCase;生命周期与 registration wrapper 使用明确 snake_case,不能统一猜测命名规则。

6. 注册与热重配

6.1 plugin.register

宿主传入:

  • config_yaml
  • host 支持的 schema_version

插件执行:

  1. 解析 lifecycle request
  2. 协商 min(host_schema, plugin_max_schema)
  3. 严格解析和校验配置;
  4. 初始化或取得 Core Runtime
  5. 返回 metadata、协商 schema 和已实现 capability
  6. 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 不再重复携带 OriginalRequestRequestBody,只在 ChunkIndex=-1 的 header-init 发送一次。由于 Go JSON 会把 []byte 编成 base64schema 2 在长 Prompt 下会把复制量和编码量放大到“请求体大小 × chunk 数”。

本项目规则:

  • cpa-ext 的生产流式计费最低要求 RPC schema 3;宿主低于 3 时拒绝启用流式计费,不静默回退到高开销 schema 2;
  • register 与 reconfigure 都返回实际协商版本,并用真机测试确认宿主记录的 schema;
  • schema 3 只解决 response.intercept_stream_chunk 的重复请求体,不解决全部流式开销
  • 当前 response.normalize_before 在每个上游流式帧仍携带 OriginalRequestTranslatedRequestBodystream 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 只理解 RPCinternal/cpaadapter 只把 CPA DTO 转成内部 command/observationinternal/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”当成已成立:

  1. 最小 c-shared 插件零长期 goroutine,验证 ABI、并发 callback、GC 压力和反复 register/reconfigure
  2. 加入选定 SQLite driver 的同步短事务,确认 driver/database/sql 是否暗启长期 goroutine/timer
  3. 分别测试 callback-driven 维护、一个受控 worker、完整 worker 集合;
  4. 覆盖 config disable、无效 reconfigure、binary replacement、plugin fuse、CPA shutdown
  5. 在目标 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 中选择最高 prioritypriority 相同按 plugin ID 决定。必须在真实宿主中确认 cpa-ext 是唯一生效的认证路径。

当前 CPA frontend-auth contract 不能携带插件自定义认证错误。internal/pluginhost/adapters_auth.go 会将插件 RPC 错误或 Authenticated=false 都转换为 access manager 的 NotHandled;如果所有生效 provider 都是 NotHandledmanager 返回宿主 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 放行。生产部署必须同时满足:

  1. CPA 配置一个不分发给用户的 256-bit 以上随机 native sentinel key,确保插件缺失时不是零 provider;
  2. cpa-ext 注册 management readiness 路径,报告数据库、HMAC keyring、价格、exclusive/capability 自检结果;
  3. 外部启动器/反向代理在 readiness 成功前不开放用户端口,并在插件丢失/fuse 后撤流量;
  4. 最终推动 CPA 增加 required-plugin + authentication-default-deny;在此之前发布说明必须标注这是部署安全前提,而非动态库自身能力。

sentinel 只由部署运维保管,不能交给用户、写进前端或作为日常调用 Key;否则它本身就是计费旁路。

8.2 Request interceptors

  • before-upstream-authCPA 方法名 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 errorafter-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 返回 404cpa-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”只能节省插件内部解析,无法消除前面的传输成本。

采集方案按以下优先级选择:

  1. 最优:扩展 CPA UsageRecord,直接提供 RequestID、Execution/AttemptID、ResponseID、Usage revision/source;插件不注册逐 chunk hook
  2. 次优:新增只读 usage observer/final-frame callback,只发送 response ID、RequestID 和 Usage,不携带 Prompt、HistoryChunks 或可修改响应的能力;
  3. 过渡:必须使用双 hook 时协商 schema 3,在 header-init 缓存最小关联,并推动 response-before 增加同类“payload frame 省略 request bodies”契约以及 stream capability 的 needs_history=false
  4. 禁止: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_scopeheaders 默认全部丢弃
Management CPA Management Key、Cookie、完整 body 进入 Core 前删除 Authorization/Cookiebody 严格 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/状态机:

  1. 拒绝新管理写入与新准入;
  2. cancel worker context
  3. 有界等待 worker
  4. 提交已在事务中的必要账本写入;
  5. checkpoint/关闭 SQLite reader 和 writer
  6. 清除 host callback context 引用;
  7. 重复 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 使用 .dllmacOS 使用 .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 redactionAuthorization/Management Key/Prompt/UsageRecord.APIKey 不进入 Core、日志或数据库。

13.2 动态库测试

  • 实际 c-shared build
  • cliproxy_plugin_init export
  • 文件名/目录 discovery
  • ABI 不匹配拒绝;
  • response buffer 能被正确释放;
  • shutdown 可重复。
  • 零常驻 goroutine基线、SQLite driver goroutine/timer 清单和双 runtime 压力/soak。

13.3 CPA 真机端到端

按 capability 逐项验证:

  1. 宿主日志显示 plugin registered、schema 和 capability 正确;
  2. reconfigure 保留数据库且不重复 worker;
  3. 自管 Key 成功,未知/禁用 Key 失败,原生 Key 无法绕过 exclusive
  4. 非流式与流式 Codex 请求;
  5. strict/preferred/pool Auth 路由;
  6. 成功、上游失败、本地拒绝、客户端取消;
  7. 取消前有 Usage 扣费、无 Usage 标 unmeasured、迟到 Usage 补记;
  8. 重试和重复 callback 不重复扣;
  9. 额度耗尽返回 429
  10. 管理路由受 Management Key 保护,resource 不泄密;
  11. 重启后余额、账本、pending recovery 和统计 checkpoint 一致。
  12. 写入畸形热配置后 exclusive/LKG 仍 active;插件缺失、fuse 和 Home 模式被 gateway/readiness 阻断;
  13. 枚举全部 authenticated endpointallowlist 外入口拒绝;
  14. DB error、interceptor/scheduler panic/invalid response 故障注入不形成可控边界内的免费请求或串号路由;
  15. 大 Prompt/Management body 的代理上限和 frontend-auth 复制成本达到容量目标;
  16. 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.goconfig.go
动态库命名/发现 internal/pluginhost/platform.go
C loading/ownership internal/pluginhost/loader_windows.goloader_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.gosdk/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.gointernal/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、构建命令、产物和未执行检查。