CC Switch 的共享凭据文件没身份标记会怎样

2026-08-31

「model xxx not found」读起来像是模型名拼错了,或者这个账号没开通对应的模型。在 cc-switch 仓库里,有一条提交把这句报错一路倒推回去,最后落在一个跟模型完全无关的地方:~/.codex/auth.json 只有一个凭据槽位,而且这个槽位上没有任何标记说明它属于哪一个供应商。

从报错到根因隔了三层,中间每一层单独看都很正常。这类问题值得单独写一篇,不是因为它罕见,而是因为它的形状在同一个版本区间里重复出现了三次——三个互不相关的 issue,改动分别落在前端编辑对话框、代理服务、账号鉴权三个互不相干的模块上,但病灶是同一个。

本文的读法与边界

下面所有结论来自对 cc-switch 官方仓库的静态阅读,快照是 3217f725(仓库内版本号 3.20.1),核对日 2026-08-31,对照快照是上一版 v3.19.2。我们只读源码、提交记录和 docs/ 下的文档,没有编译、没有运行、也没有安装过这个桌面应用,因此不涉及任何界面外观、操作步骤或响应速度的描述。文中引用的行号一律以 v3.20.1 快照为准,这个项目迭代很快,行号和默认取值都会随版本变动。

另外要先说清楚:这个工具会读写 ~/.codex/~/.claude/ 这类真实 CLI 配置目录,凭据以明文形态落在本机配置文件里是它工作的前提。本文只描述仓库源码写了什么,不对任何配置方式做安全性背书。

病灶:一个槽位,没有归属

bbe8bb93(#6534)这条提交的说明里,把 auth.json 称作一个 single-slot、没有供应商身份的共享文件——它里面可能装着另一张卡遗留的 key。同一个措辞在 d2b070c9 的说明里又出现了一次,而这两条提交修的根本不是同一个 bug。

问题就出在「可能」这两个字上。供应商是多对一地共用这个槽位的,而槽位本身不记录自己现在归谁。只要有一次操作让 live 状态与数据库里的记录产生分歧——比如后面要讲的接管恢复,会把一份陈旧的快照逐字盖回 auth.json——这个槽位里就会躺着 A 的 key,而当前上下文以为它是 B 的。

只要有代码在这种时刻把槽位内容当作「当前供应商的凭据」采纳,错误就被固化了一次。反复几次之后,共享同一个 Base URL 的几张卡,密钥会悄悄趋同。此时才轮到那句 model xxx not found 出场:一个不该拿到某模型的 key 被送去请求那个模型,上游拒绝了,而拒绝的措辞跟凭据一个字都不沾边。

同一个形状,三个不相干的 bug

在 v3.19.2 到 v3.20.1 这段区间里,这个形状生出了三条修复。它们的症状描述完全不像同一件事:

提交症状(依提交信息与发布说明)被共享的是什么
bbe8bb93(#6534,body 写 Fixes #6414)编辑当前 Codex 供应商时,表单基底取自 live,凭据来自共享的 auth.json,可能是另一张卡遗留的 key,保存后写进这张卡的数据库记录单槽 auth.json
d2b070c9(body 写 Fixes #6277)接管开始时拍下的恢复备份此后不再从 live 刷新;用户在接管期间完成官方登录后,退出或崩溃恢复会把陈旧的第三方 API key 快照逐字盖回 auth.json同一份 auth.json,在「备份」与「live」两个时间点之间
c2ec78dd(#6780,发布说明称修复 #2245)账号以 workspace ID 为主键,同一个 Team 的两名成员合并成一条记录,后登录者静默覆盖前者的令牌把上游的 workspace 标识当成了本地主键

三条各自的修法,恰好对应「共享可变状态缺少所有权标记」的三种常规解法:

  • 换来源(#6534):不从共享槽位读,改从这张卡自己那份配置里重建凭据。
  • 定仲裁(#6277):既然两份数据都可能是对的,就写死一条判据决定谁赢。d2b070c9 给的判据是——live 凭据永远比快照新,因为只有 Codex 自己(登录、token 自刷新)会推进它们,所以哪怕备份看起来是官方形态,也不能拿它回滚 live token。
  • 加身份(#6780):src-tauri/src/proxy/providers/codex_oauth_auth.rs:16 的模块注释把主键与 workspace 解耦,明说本地账号 ID 只用于绑定和缓存,chatgpt_account_id 仅表示上游 workspace。:239-242 把这两者拆成两个字段。这条解法最彻底,代价也最直白::264-284 说明旧账号可能缺 id_token 或缺独立的上游 workspace 字段,缺任一都需要重新登录一次才能安全参与绑定——发布说明里那句「存量托管账号需逐个重新登录一次」就是从这里来的。

v3.20.1 新增的那个函数

第一条修复最终落在前端,函数是 src/components/providers/EditProviderDialog.tsx:56reconcileCodexLiveAuth。对照两份快照可以确认:v3.19.2 时这个函数还不存在,v3.20.1 才有。

它的职责只有一句话:当编辑对话框拿 live 配置当表单基底时,把凭据那一格换成这张卡自己的。函数体的顺序值得逐段读——

第一道门在 :61category === "official" 直接把 live 原样返回,官方类供应商完全不进这条路径。这跟同目录里另一处判定是一脉相承的:ProviderCard.tsx:279-286 有一段长注释论证过,涉及高代价决策时只认显式的 category === "official",不回退到「字段空不空」的启发式,因为空字段无法区分「想直连官方」和「自定义但还没填完」。

第二道是取值:从 live 的 config 文本里用 extractCodexExperimentalBearerToken(实现在 src/utils/providerConfigUtils.ts:1109)抠出 experimental_bearer_token取不到就原样返回 live。这一行决定了整个函数的适用范围——没有开启官方登录保留、配置里压根没有 bearer 的场景,这个函数是空操作,行为跟改动前完全一致。

第三步才是重建:auth 模板优先取数据库里存的 provider.settingsConfig.auth,而不是 live 的 auth,然后把 OPENAI_API_KEY 覆盖成刚才抠出来的 bearer。「模板取 DB、值取 config.toml」这个组合是整条修复的核心——两个来源都跟这张卡绑定,共享槽位被彻底绕开了。

第四步是第二道守卫,在 :78-80:如果模板里存在 OAuth 类材料(除 auth_modeOPENAI_API_KEY 之外还有非空字段),而这张卡自己又没有 API key,就不做替换。注释写明这是为了跟后端的 should_restore_codex_provider_token_for_backfill 保持一致,理由是一个纯 OAuth 供应商不能被悄悄转成 API-key 供应商。

调用点在 :245-250,条件是 appId === "codex" 且确实拿到了 liveSettings。其余情况维持原来的 auth 优先次序。

四道判断叠起来,实际效果是:这个函数只在一种很窄的情形下动手——Codex 应用、非官方类卡、配置里有 per-provider bearer、且不属于纯 OAuth 形态。修一个共享状态问题时,把作用域收到最窄,比把逻辑写得最全更重要,因为共享槽位上的任何多余写入都会变成下一个 bug 的种子。

这条提交本身的曲折也值得看

bbe8bb93 的子提交序列是:先做了一版后端修法(在 switch-away 回填时保留供应商自己的 key),然后 Revert 掉,再改从前端修,接着一条 prettier 格式化提交(修 CI 的 formatting 检查),最后一条把 bearer 重建的作用域再收窄到 live 编辑。

这意味着一件很具体的事:读这条 commit 的完整说明会同时读到两套互相矛盾的方案论证——被撤回的后端修法把理由写得很完整,但它并没有落地。它对「照着 commit message 理解代码」的读法是个提醒:引用这条提交的任何一段,都得说清引的是哪一段。

配套测试有两处:一处是既有文件 tests/components/EditProviderDialog.test.tsx 的扩充(这个文件 v3.19.2 就有,本次是 +407/−25 的改动,不是新建),另一处是新增的 tests/hooks/useCodexConfigState.bearer.test.ts(提交说明称含 4 个回归用例)。另外 src/components/providers/forms/hooks/useCodexConfigState.ts:118 附近还有一处纯顺序调整,把「设置 auth.json」的赋值挪到了「设置 config.toml」之后。

怎么判断自己碰上的是不是这一类

如果遇到凭据串到别的供应商上,可以按下面这几步逐个排除,每一步都对应源码里一个明确的分支:

  1. 看应用类型EditProviderDialog.tsx:245 的守卫是 appId === "codex"。其它被托管的 CLI 走的是原来的数据库快照与预设优先次序,这条重建路径根本不介入。
  2. ~/.codex/config.toml 里有没有 experimental_bearer_token。没有的话,reconcileCodexLiveAuth 在第二步就返回了,这条路径对你是空操作——此时 auth.json 仍然是活跃凭据所在的槽位。
  3. 看这张卡的 category。是 official 的话第一行就放行了,不受影响。
  4. 看这张卡存的 auth 里除了 auth_modeOPENAI_API_KEY 还有没有非空字段。有、且没有自己的 API key,说明它被判成了纯 OAuth 形态,:78-80 那道守卫会让重建整个跳过。

反过来,下面这些情况说明不是凭据身份的问题,别顺着这条线往下查:

  • 保存之后 live 文件纹丝不动。同区间的 926af949(#6779)修的是另一件事:崩溃残留的接管备份行让活跃供应商的编辑只更新数据库。src-tauri/src/services/provider/live.rs:1487proxy_owns_live_config 是新的所有权谓词,:1510-1516 的注释说明 enabled 标志只有在代理确实在跑时才被信任。症状看上去像,但改的是所有权判定这条链路。
  • 同一个 Team 的两个账号互相覆盖令牌。那是 #6780 的主键问题,修法在后端的账号表,跟编辑对话框读哪份 auth 无关。
  • 编辑框里的模型映射变空EditProviderDialog.tsx:253-269 有一段独立的保护逻辑,讲的是 modelCatalog 这个 cc-switch 私有字段的 SSOT 在数据库、live 的 config.toml 只投影一个指针,放任 live 覆盖会造成数据丢失。那是投影方向的问题,不是凭据归属。

一句收束

auth.json 单槽无身份这件事,不是某个人写错了一行。它是「被托管的上游 CLI 只设计了一个凭据位置,而管理工具要在这一个位置上表达 N 个供应商」这个结构错配的必然结果。管理工具能做的只有三件:换个有身份的地方读、写死一条谁赢的仲裁规则、或者干脆在自己这一侧补上身份字段。v3.20.1 这三条修复,恰好一样各用了一次。

值得记住的判据是:当你发现一个文件同时被多个逻辑实体读写,而文件本身不记录当前归属时,剩下的就只是等哪条路径先撞上而已


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、 路由指南与发布说明,以及 src/src-tauri/tests/ 的源码整理, 核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    在 CC Switch 里加一个国内直连的供应商

    力达云网关,注册送 ¥5 额度,一期提供 DeepSeek。

    去添加

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。