砍掉 OAuth,只留两种协议——一次接入收敛的取舍
Open Council 复盘:从 CLI / OAuth / 订阅 / 中间层的一张宽接入矩阵,收敛到只走 anthropic + openai 两种标准 API。为什么做减法,减法怎么减得诚实。
Open Council 是个本地多模型辩论系统,要同时接好几家模型。早期为了「什么都能接」,它撑起了一张很宽的接入矩阵:CLI 子进程、OAuth 订阅登录、20 多个 provider 的家族映射,全靠一个中间层依赖粘在一起。
后来这张矩阵被整个砍掉,只留下两种标准 API 协议:anthropic(@anthropic-ai/sdk)和 openai(openai SDK)。这篇讲为什么做这个减法,以及怎么把减法减得诚实。
砍之前,接入面有多宽
收敛前,一个中间层 pi-ai 支撑着一整套东西:
- CLI 通道:spawn 子进程、
SIGTERM → SIGKILL收尾、解析 codex 的 JSONL、stdin 的 EPIPE 守卫,上面还压一层「API 优先、CLI 回退」的编排; - OAuth / 订阅:一整套
discoverOAuthCredentials/readCodexAuthFile/readClaudeCodeKeychain,复用 Claude Code、Codex、Gemini 的订阅登录态; - 20+ provider 家族映射:
RELATED_PROVIDERS、LEGACY_TO_PIAI、GOOGLE_FAMILY、PROVIDER_PRIORITY一堆表,做注册表模糊匹配。
功能上它很全。问题是全得没有重点。
为什么砍
三个理由,从最实在到最工程:
- 订阅额度接入效果差、不稳定。这是第一动机,不是洁癖——通过 OAuth / CLI 蹭订阅额度,响应质量和稳定性都不如直接打标准 API。一个要产出「可靠答案」的辩论系统,底层接入不稳定是致命的;
- 大依赖只用零头。
pi-ai的核心价值是「20+ provider 适配 + OAuth」,可这些正是要砍的部分。砍完之后,留着这个依赖只是为了用它剩下的两协议 invoke——扛一个大依赖,只用它的零头; - 去掉中间层没有能力损失。
pi-ai内部本来就是包着@anthropic-ai/sdk和openai两个官方 SDK。剥掉中间层直接用官方 SDK,一行能力都不少,反而换回了结构化的错误对象和原生AbortSignal。
收敛的终点很干净:只有两种线协议,凭证只有「API key(环境变量或 0o600 key 文件)+ 可选 base_url」,彻底不再有 OAuth 登录、Token 刷新、keychain 读取、CLI 子进程。
换引擎,但不拆房子
最关键的一条纪律:对上层的契约一个字都不改。
core 层通过 InvocationAdapter { invoke, healthCheck } 这个接口调底层,收敛把所有破坏点全部锁在实现层内部——删掉 CLI 适配器、内部换成官方 SDK——而 invoke / healthCheck 的签名纹丝不动。结果是 core 层零改动,几百个测试兜底住行为语义不变。
设计文档甚至把这点变成一个验收信号:core 层的测试如果因为这次重构挂了,就说明有 CLI 的假设泄漏到了 core 里。一个划得干净的接口边界,让「换引擎」不至于变成「拆整栋房子」。
兼容端点的接入也因此塌缩成一行——工厂函数里一个 ??:
const baseURL = config.base_url ?? OFFICIAL_BASE_URL[config.protocol];
base_url 不填就走官方端点,填了就透传给 SDK。DeepSeek、Moonshot、Ollama、vLLM、LM Studio 的兼容端点,全靠这一个覆盖接进来,不用改一行代码。
一个反直觉的坚持:把 SDK 的重试关掉
官方 SDK 默认 maxRetries: 2,会自动重试失败请求。收敛后第一件事反而是把它关成 maxRetries: 0,自己扛重试。
为什么不省事地让 SDK 重试?因为自研的重试要协调三件 SDK 根本看不到的事:
- 流式已经吐字就不能重试——半路失败再重试会重复吐一遍,用闭包计数
emitted,只有emitted === 0才允许重试; - 熔断器只能记一次失败——SDK 偷偷重试成功,会对熔断器隐藏掉一次真实失败,让健康统计失真;
- 自适应节流要基于真实的成败来调。
如果放任 SDK 也重试,就成了双重重试,还把失败信号藏了起来。当你的上层逻辑依赖「每次失败都被如实记一笔」,就不能让底层背着你偷偷重试。
顺带一个红利:砍掉中间层后,错误分类从「字符串关键字匹配」翻转成「读 .status 主路径」——官方 SDK 抛结构化的 APIError(带 status)和专属的 RateLimitError,429/5xx/408 判可重试、4xx 判永久失败,字符串匹配降级成只应对兼容网关裸文本的兜底。换了忠实的底层,就该顺手删掉当年为不忠实底层写的那些防御代码。
迁移的诚实:禁用 + 标注,而不是假装能转
老配置里有一堆依赖 OAuth / CLI / 订阅的模型。schema 从 v1 升 v2,迁移的铁律写死成三条:绝不硬报错、绝不静默丢弃、绝不伪造一个注定失败的模型。
迁移逻辑按 8 条优先级分类:已经是新格式的原样放行;带 base_url 的自定义端点从 URL 猜协议后平移;provider 是 anthropic/openai 且有可用 key 的转成官方端点;而依赖 Google 家族、Copilot、CLI、或者干脆没 key 的——一律禁用并标注原因,不删除。
为什么不干脆自动转、或者直接丢掉?设计文档里那句话最能说明:无法凭空得到一个可用的 API key;自动「转换」只会造出一个注定失败的模型,禁用加清晰指引更诚实。 被禁用的模型仍然列在 council models 里带着原因(比如直接给出 Google 的兼容端点地址),补上 key 就能重新启用。原配置还先备份成 .v1.bak 才写回,且整个迁移不碰磁盘、纯决策,读写交给上层——迁移失败下次加载重试,幂等。
小结
一次把宽接入矩阵收敛到两种标准协议的重构:
- 做减法的理由要实在:订阅接入效果差不稳定、大依赖只用零头、去中间层无能力损失——不是为了简洁而简洁;
- 换引擎不拆房子:锁死
InvocationAdapter契约,破坏点全在实现层,core 零改动,测试兜底语义; - 别让底层背着你重试:
maxRetries: 0自己扛,因为流式去重、熔断器计数、自适应节流都得基于真实成败; - 删掉给旧底层写的防御:忠实的官方 SDK 到位,字符串错误匹配、racing 超时兜底都能删;
- 迁移要诚实:不可转换的模型禁用 + 标注原因,绝不伪造一个必然失败的模型。
一句话:收敛不是把功能删短,而是把「什么值得长期维护」想清楚——留下的每一样,都要对得起它占的那份复杂度。
留言