Compare commits

...
1 Commits
Author SHA1 Message Date
sky 7e3551babe feat: add provider presets and official quota for v0.3.17 2026-09-09 18:52:42 +08:00
54 changed files with 6041 additions and 633 deletions
+209 -314
View File
@@ -1,519 +1,414 @@
# Changelog
# 更新日志
All notable changes to Codex-X will be documented here.
记录 Codex-X 的新功能、体验改进和问题修复。
## [Unreleased]
## [v0.3.17] - 2026-09-09
### 新增与优化
- 官方账号支持查看剩余额度、重置时间和可用重置次数,列表中显示邮箱、套餐和登录状态。
- 新增 DeepSeek、MiniMax、小米 MiMo 等主流厂商预设和模型映射,方便在 Codex 中使用第三方模型,并保留已有配置。
### 问题修复
- 修复编辑正在使用的供应商时保存报错、切换后出现重复供应商,以及部分第三方连接认证失败的问题。
- 修复用量统计切换日期范围时页面闪白的问题。
## [v0.3.16] - 2026-09-09
### 新增与调整
### 新功能
- 官方登录与第三方供应商统一为一键复制:副本立即加入列表,不进入编辑页,不弹出二次确认;自动添加不重复的副本名称。
- 官方与第三方配置的 TOML 编辑区新增“开启 1M 上下文窗口”复选框,保留自定义压缩阈值及其他配置。
- 设置新增“通用设置 / 用量统计”标签页,并复用统一的淡入淡出动画;用量统计展示本地 Codex Token 总量、输入与输出、缓存命中率、每日趋势、模型分布和最近 10 个会话,支持时间范围、模型筛选与手动刷新。最近会话显示聊天标题,无标题时使用项目名。
- 用量统计按主会话归并子代理及其后代的实际用量,子代理不再单独列入最近会话或增加活跃会话数;用户主动分叉的新聊天保持独立。
- 支持添加、复制和重命名多个官方 Codex 登录配置,可与第三方 API 供应商一起管理和切换;各配置独立保存认证与完整 TOML,切换前保留当前账号最新登录状态。
- 指令提示词页面顶部标题的强调色统一为蓝色,适配浅色与深色主题。
- 支持保存多个官方 Codex 账号,并为每个账号起不同的名字,方便切换使用。
- 官方账号和第三方供应商都支持一键复制,点击后直接生成副本。
- 新增“开启 1M 上下文窗口”选项,可按需勾选使用(需要模型支持)。
- 设置页新增“用量统计”,可以查看 Token 用量、每日趋势、模型分布和最近 10 个会话,并按日期、模型筛选。
- AI 后台子任务的用量合并到主对话,聊天列表只显示主会话。最近会话显示聊天标题或项目名,更容易找到。
### 修复 Bug
### 体验改进
- 修复 GitHub 模板同步期间查看、编辑或启用提示词可能拖住供应商操作的问题;模板内容优先读取本地修改、缓存和内置版本,模板详情加载不再占用其他页面的操作状态。
- 修复首次识别到的 CC Switch 当前供应商在切换后从列表消失的问题;切换前保存完整配置与认证信息,并处理同名记录、重复配置和失败回滚。
- 提示词页面的标题统一为蓝色,设置页的标签切换加入过渡动画。
### 问题修复
- 修复同步 GitHub 提示词模板时,供应商按钮暂时无法使用的问题。
- 修复切换供应商后,原有的 CC Switch 供应商从列表消失的问题。
## [v0.3.15] - 2026-08-20
### 修复 Bug
### 问题修复
- 修复 Windows 重启 Codex 时仅隐藏到托盘、后台未退出,以及再次重启报错的问题。
- 修复 Windows 点击“重启 Codex”后只隐藏窗口、未真正退出,以及再次重启时报错的问题。
## [v0.3.14] - 2026-08-20
### 更新
- MCP 和 Skills 支持添加自定义备注。
- 供应商支持复制,同一 API 平台可保存多个模型或相同配置,复制项会添加到列表末尾。
- 供应商列表不再显示重复的模型信息。
- MCP 和 Skills 支持添加自定义备注,方便区分用途。
- 供应商支持复制,同一 API 平台可以保存多个模型或多份配置,副本放在列表末尾。
- 简化供应商列表,不再重复显示模型信息。
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
### 修复 Bug
### 问题修复
- 修复相同供应商配置被覆盖,以及复制当前供应商后启用状态丢失的问题。
- 修复子代理会话被误判为“待同步”的问题。
- 修复相同供应商配置被覆盖,以及复制当前供应商后丢失启用状态的问题。
- 修复 Codex 自动创建的子代理会话被错误标记为“待同步”的问题。
## [v0.3.13] - 2026-08-18
### 修复 Bug
### 问题修复
- 修复归档会话被错误标记为“待同步”并参与供应商同步的问题;归档会话现在会保持归档状态,不再作为同步候选。
- 已归档的会话不再被标记为“待同步”,切换供应商时会继续保持归档状态。
## [v0.3.12] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
- 提升使用流畅度,修复部分问题。
## [v0.3.11] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
- 提升使用流畅度,修复部分问题。
## [v0.3.10] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
- 提升使用流畅度,修复部分问题。
## [v0.3.9] - 2026-08-11
### 更新
- 优化了软件的流畅度和部分 bug。
- 提升使用流畅度,修复部分问题。
## [v0.3.8] - 2026-08-03
### 修复 Bug
### 问题修复
- 修复了某些问题。
- 修复部分使用问题。
## [v0.3.7] - 2026-08-02
### 修复 Bug
### 问题修复
- 修复了一些 bug。
- 修复部分使用问题。
## [v0.3.6] - 2026-07-31
### 更新
- 供应商与 OpenAI Official 切换改为热更新;切换后新建会话即可生效,不再要求重启 Codex 客户端。
- macOS 关闭主窗口后进入顶部栏驻留模式,Windows 隐藏任务栏窗口;托盘菜单仍可恢复窗口或明确退出应用。
- 新增独立的项目维护日志,记录配置写入、供应商切换、会话同步和桌面生命周期的长期约束与验证依据。
- 官方登录与第三方供应商切换后,新建会话即可使用,无需重启 Codex。
- 关闭主窗口后,软件会留在 macOS 菜单栏或 Windows 托盘中,可从菜单恢复窗口或退出。
### 修复 Bug
### 问题修复
- 修复官方认证被第三方供应商覆盖或清除的问题;第三方密钥仅写入当前 provider,官方 ChatGPT 登录与官方 API Key 均使用独立快照保存并可往返恢复。
- 修复编辑活动供应商会新建副本、重复导入 cc-switch 供应商产生多条相同记录,以及旧版 cc-switch 数据库缺少 `category` 字段时无法读取官方认证的问题。
- 修复供应商、提示词及其他配置操作并发写入时可能覆盖或破坏 `config.toml` 的问题;写入现在使用跨进程锁、原始快照、条件原子替换和失败回滚。
- 修复会话同步显示成功但 Codex 未实际使用同步结果的问题;只处理活动 SQLite,严格校验 SQLite 指定的 rollout 路径、文件身份和线程 ID,并兼容 `codex-dev.db` 与显式 `sqlite_home` 下的自定义数据库名。
- 修复会话列表将旧数据库或孤立 JSONL 计入活动会话、把 JSONL 记录行数误当会话数,以及同步过程中修改非活动数据库或工作目录字段的问题。
- 修复切换第三方供应商时丢失官方登录或官方 API Key 的问题,现在切回官方时可以恢复。
- 修复编辑当前供应商时意外新增副本、重复导入 cc-switch 产生重复项的问题,并兼容旧版 cc-switch 的官方登录导入。
- 修复同时操作供应商、提示词或其他设置时,配置被覆盖或损坏的问题。
- 修复会话同步提示成功却未生效的问题,并改善对自定义会话存储位置的支持。
- 修复旧记录被重复计入会话数量,以及同步时误改旧会话或项目位置的问题。
## [v0.3.5] - 2026-07-28
### 更新
- GitHub 指令提示词现在可以直接查看和编辑本地内容;模板名称和文件名继续跟随在线目录,Markdown 内容由用户在现有提示词编辑页中维护。
- 模板内容实际修改后会标记为“本地已修改”,后续同步永久跳过该模板的远端内容,同时继续发现和下载 GitHub 新增模板;启用时优先使用用户的本地版本。
- 仅查看或保存未变化的内容不会退出 GitHub 同步;本地修改在下次启用该模板时生效。
- GitHub 提示词模板可以直接查看和编辑内容,名称和文件名仍跟随在线版本。
- 编辑后的模板会标记为“本地已修改”,后续同步不会覆盖你的修改,新模板仍会正常下载;修改在下次启用模板时生效。
- 仅查看模板或保存未改动的内容,不会影响后续在线更新。
### 修复 Bug
### 问题修复
- 修复 jsDelivr `@main` 目录缓存旧版本时,“同步 GitHub 模板”只能看到 5 个模板、无法发现仓库中 11 个模板的问题;目录成员现在始终由 GitHub 源站确认,源站不可用时仍保留 CDN 和本地缓存回退。
- 修复当前使用中转供应商且 live `auth.json` 不存在时,概览页错误显示“认证文件未找到”的问题;已保存的官方认证快照现在会显示为“官方认证已保存”。
- 修复同步 GitHub 模板时只能看到 5 个、无法获取完整 11 个模板的问题;网络异常时仍可使用已有缓存。
- 修复使用第三方供应商时,概览页误报未找到官方登录信息的问题。
## [v0.3.4] - 2026-07-28
### 更新
- 在线提示词库新增 3 套软件开发模板和 3 套写作辅助模板,并按照模板文件名前缀自动归入对应分类;用户手动分类仍然优先。
- 软件开发分类新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”,其中“长期维护工程师”来自项目当前实际使用的维护规范。
- 写作辅助分类新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。6 套新增模板通过 GitHub 在线同步,不增加安装包体积。
- 在线模板库新增 6 套提示词,点击同步 GitHub 模板即可获取,不增加安装包体积。
- 软件开发新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”。
- 写作辅助新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。新模板会自动分类,仍保留用户手动设置的分类。
### 修复 Bug
### 问题修复
- 彻底隔离 OpenAI Official 与中转供应商认证:切换到中转前保存可信的官方配置快照,切回官方时恢复该快照,避免 `auth.json` 被中转或 cc-switch 覆盖后导致官方登录失效。
- “还原官方配置”现在只恢复独立快照,不再自动切换当前供应商;新增“新建官方配置”,可清除受污染的 live `auth.json` 并引导用户重新完成官方登录。
- 优化官方 `auth.json` 编辑器的高度、换行和长 Token 选取体验,避免内容看起来被截断或只能依赖底部横向滚动条。
- 修复切换第三方供应商或从 cc-switch 导入后,官方登录信息被覆盖、需要重新登录的问题。
- “还原官方配置”不再自动切换当前供应商;新增“新建官方配置”,方便清除异常认证后重新登录。
- 调整官方认证编辑框,长内容可以换行显示,更方便查看和选择。
## [v0.3.3] - 2026-07-26
### 调整
- 暂时下线皮肤中心并隐藏侧边栏入口,皮肤页面、运行时实现和用户本地主题文件继续保留,后续可以通过统一功能开关重新启用。
- 升级后会自动暂停 Codex-X 此前启用的皮肤并恢复官方显示;若自动停用失败,应用会显示明确错误,不会让用户在入口隐藏后失去恢复提示。
- 内置“椎名真白·樱花画室”不再注册为随应用编译的主题,其约 1.7 MB 壁纸不再进入正式安装包;主题源文件仍保留在仓库中。
- 暂时下线皮肤中心,隐藏侧边栏入口,保留用户已保存的本地主题文件。
- 升级后会自动关闭此前启用的皮肤,恢复 Codex 官方外观;若恢复失败,会显示原因。
- 安装包不再附带“椎名真白·樱花画室”主题壁纸,减小下载体积。
## [v0.3.2] - 2026-07-25
### 修复 Bug
### 问题修复
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
- 修复 Windows 启动 Codex 或进入新版首页、外观设置时,皮肤功能错误提示无法识别 Codex 的问题。
- 皮肤出现异常时仍可点击“关闭皮肤”,恢复过程中会避免重复操作。
### 安全
### 安全改进
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
- 皮肤功能仅用于已识别的官方 Codex 窗口,不会应用到普通网页。
## [v0.3.1] - 2026-07-24
### 更新
- 皮肤中心重新设计主题画廊:直接展示真实壁纸和侧边栏、会话区、输入框、按钮等整套界面效果,单个主题不再拉伸占满整行,并移除端点、目录、`theme.json` 路径和主题包结构等开发者信息。
- 皮肤中心新增可管理的分类筛选并进一步压缩主题卡片密度;导出主题时改用系统“另存为”窗口选择目录和文件名。
- 支持直接从图片创建自适应主题,并可编辑主题名称、简介和界面透明度;内置主题与图片主题共用相同的编辑与预览规格。
- 主题画廊根据当前分类数量自适应 1 至 4 列,少量主题保持紧凑,多个主题充分利用可用宽度。
- 实机换肤扩展到 Windows:动态校验官方 `OpenAI.Codex` Store 包,安全启动仅绑定回环地址的 CDP 会话,并在 Windows 发布包中附带固定校验的 Node.js 22 运行时;不修改 `WindowsApps`、`app.asar` 或权限。
- 皮肤中心只保留“关闭皮肤”入口,移除与其重复的“恢复官方外观”按钮;底层完整恢复能力继续保留用于异常恢复。
- 重新设计皮肤中心,可直接预览壁纸、侧边栏、会话区和输入框的整体效果,主题卡片更紧凑。
- 支持主题分类筛选,卡片会根据数量自动调整为 1 至 4 列;导出主题可使用系统“另存为”窗口。
- 支持从图片创建主题,并修改名称、简介和界面透明度。
- 皮肤功能支持 Windows 官方商店版 Codex,无需修改 Codex 安装文件。
- 合并重复的恢复按钮,统一使用“关闭皮肤”。
### 修复 Bug
### 问题修复
- 修复 macOS 皮肤已经注入后 Codex-X 仍长期显示“应用中”、重开后无法识别当前主题的问题;单次注入现在会明确退出,外部命令带有硬超时,并可从经过验证的 Codex 主进程恢复遗失的本机调试端口。
- 修复皮肤尚未运行时错误显示“重新应用”、刷新缺少明确反馈,以及提示词或皮肤卡片过多时页面无法继续向下滚动的问题。
- 优化普通横图在新任务首页超宽横幅中的自动焦点,避免人物头部被居中 `cover` 裁掉;主题包仍可通过 `art.focusY` 显式覆盖自动位置。
- 修复切换到中转供应商时覆盖官方 `auth.json` 的问题,官方登录凭据与供应商凭据现在分别保存和恢复。
- 改进 Codex Desktop 检测,兼容更多安装路径、包标识和版本输出格式,减少部分用户错误显示“未检测到 Codex”的情况。
- 修复启用皮肤后新会话输入框消失、输入框出现会话内容穿透,以及图片主题预览错误使用默认背景的问题。
- 修复浅色主题的固定白色遮罩洗淡壁纸颜色的问题;壁纸保持原图色彩,卡片和项目选择区域跟随透明度,同时为输入框保留可读性下限。
- 修复 macOS 应用皮肤后一直显示“应用中”,以及重开软件后无法识别当前主题的问题。
- 修复皮肤按钮状态错误、刷新缺少反馈,以及提示词或主题较多时无法继续向下滚动的问题。
- 优化横图壁纸的裁剪,减少人物头部被切掉的情况。
- 修复切换第三方供应商时覆盖官方登录信息的问题。
- 改善 Codex 安装检测,减少已安装却显示“未检测到 Codex”的情况。
- 修复启用皮肤后输入框消失、聊天内容透过输入框,以及图片主题预览显示错误背景的问题。
- 修复浅色主题下壁纸颜色发白的问题,并改善透明度调整和输入框文字的清晰度。
### 安全
### 安全与来源
- 换肤运行时只连接经过签名、进程归属和页面标记验证的官方 Codex,并将 CDP 限制在 `127.0.0.1`;停止注入器前会核对 PID、启动时间、Node 路径、脚本路径和端口。
- 主题导入增加 ZIP 越界、符号链接、重复文件、文件数量、图片大小和 Manifest 字段校验;运行时使用稳定快照,避免主题切换时配置与图片不一致。
### 开发
- 固定复用 Codex Dream Skin Studio `5fd8af532efbaa87d2d0092297fd2d45cd56574e` 的 MIT 注入实现,并加入 Rust 与 Node 专项回归测试;未包含上游声明为非 MIT 授权的真人主题素材。
- 加强官方 Codex 识别、主题导入和切换检查。
- 换肤功能使用 Codex Dream Skin Studio 的 MIT 授权代码,未包含其非 MIT 授权的真人主题素材。
## [v0.3.0] - 2026-07-16
### 更新
- 全面升级桌面界面,统一各页面布局、按钮、弹窗和 Toast,并加入深浅色模式与页面过渡动画。
- 新增应用内更新:macOS、Windows MSI 和 Linux AppImage 可直接下载、安装并重新启动;发现更新时会在侧边栏显示入口和进度。
- 完善供应商管理:删除增加二次确认,连接测试改用 `/v1/models`,添加或编辑时可获取并选择模型,同时简化 OpenAI Official 页面。
- 完善指令提示词管理:新增分类功能,内置两套新模板,精简模板卡片,添加或编辑后只保存、不自动启用。
- 在线提示词会在启动后后台同步并保存到本机;支持 CDN、GitHub 备用源和系统代理,离线时也能使用上次同步内容。
- 简化会话管理:统一同步到当前供应商,取消启动时自动修改,并支持查看、选择和删除内部会话。
- 优化 Skills/MCP 导入,自动排除已经导入的内容,没有新内容时不再弹出空窗口。
- 全面更新界面,统一页面、按钮、弹窗和通知样式,加入深浅色模式与页面切换动画。
- 新增应用内更新,macOS、Windows MSI 和 Linux AppImage 用户可直接下载、安装并重启;侧边栏会显示更新入口和进度。
- 改进供应商连接测试,删除前增加确认,添加或编辑时可以获取并选择模型,官方登录页面更简洁。
- 提示词新增分类和两套内置模板,卡片更简洁;添加或编辑后只保存,需要时再手动启用。
- 启动后会在后台同步在线模板,网络异常时尝试备用下载来源;模板会保存到本机,离线时仍能使用,支持系统代理。
- 会话统一同步到当前供应商,不再在启动时自动修改;支持查看、选择和删除内部会话。
- 导入 Skills / MCP 时会跳过已有内容,没有新内容时不再弹出空窗口。
### 修复 Bug
### 问题修复
- 修复旧提示词配置可能导致 Codex 报 `expected u32`,以及 Windows `.codex` 使用链接目录后无法正常读写的问题。
- 修复会话同步可能改写聊天内容、出现 `invalid_id_prefix`、重复显示会话或覆盖正在写入记录的问题。
- 修复深浅色模式下的文字、滚动条、复选框和弹窗显示异常,以及弹窗打开后背景仍能滚动的问题。
- 修复提示词缓存延迟造成的重复同步、错误更新或误删除,并修复应用内更新失败后的错误状态提示。
- 修复部分旧提示词配置导致 Codex 报错,以及 Windows 将配置文件夹链接到其他位置后无法读写的问题。
- 修复会话同步报错、会话重复显示,以及聊天记录被误改或覆盖的问题。
- 修复深浅色模式下部分文字和控件显示异常,以及打开弹窗后背景仍能滚动的问题。
- 修复提示词重复同步、错误更新或误删除,以及应用更新失败后提示不准确的问题。
- 完善安装包和更新的安全检查。
### 兼容说明
- `v0.3.0` 是首个支持应用内更新的版本,旧版用户需要手动安装本版本一次。
- Windows 便携版和 Linux deb/rpm 继续手动更新;Windows MSI 首次安装仍可能出现 SmartScreen 提示。
### 开发
- 增加更新包签名、macOS 签名与公证,并完成后端模块拆分和发布流程安全加固。
- Windows 便携版和 Linux deb/rpm 仍需手动更新;Windows MSI 首次安装可能出现系统安全提示(SmartScreen)。
## [v0.2.35] - 2026-07-13
### 更新
- 新增 `gpt-5.6-sol-unrestricted.md` 指令提示词模板,同步 GitHub 模板后即可在【指令提示词】页面启用。
- 优化第三方供应商切换:切换后新建或重新打开 Codex 会话即可使用新的中转,无需重启整个 Codex 客户端。
- 优化从 cc-switch 导入供应商的结果提示,现在会分别显示新增、更新、合并和跳过数量。
### 修复 Bug
- 修复相同 API 地址和 API Key 的供应商因名称、模型或其他配置不同而重复显示的问题;相同地址但不同 Key 的供应商仍会分别保留,升级后也会自动整理已有重复项。
- 修复切换到其他供应商或切回 OpenAI Official 后,原供应商可能从列表中消失的问题;切换前会自动保存当前供应商。
- 修复从 cc-switch 重复导入时可能覆盖 Codex-X 中已有供应商名称、模型或自定义配置的问题,并兼容旧版 cc-switch 数据。
- 优化会话列表:Codex 自动创建的后台子会话不再重复显示为普通会话,项目数量和会话总数更加准确。
- 优化会话编号显示,保留编号开头和结尾,减少多个会话看起来像同一个会话的情况。
- 修复检测到的供应商与本地供应商同名时,编辑保存可能因 ID 冲突覆盖另一条记录的问题。
- 新增 `gpt-5.6-sol-unrestricted.md` 提示词模板,同步 GitHub 模板后即可启用。
- 切换第三方供应商后,新建或重新打开 Codex 会话即可使用新供应商,无需重启整个 Codex 客户端。
- 自动整理 API 地址和密钥均相同的重复供应商;同一地址使用不同密钥的供应商仍分别保留。
- 修复切换供应商或切回官方登录后,原供应商可能消失的问题。
- 从 cc-switch 导入时,会显示新增、更新、合并和跳过数量;重复导入不再覆盖已有名称、模型和自定义配置,并兼容旧版 cc-switch 数据。
- 会话列表不再重复展示 Codex 自动创建的后台子会话,项目和会话数量更准确;会话编号保留首尾,方便区分。
- 修复编辑同名供应商时可能覆盖另一条记录的问题。
## [v0.2.34] - 2026-07-13
### 更新
- 会话管理新增指定会话永久删除:支持单选、多选,并在二次确认后从当前 Codex 存储中硬删除;操作不进入回收站、不创建新的删除备份,Codex-X 内无法撤销。
- “按项目路径分组”新增项目级三态勾选,可一次选中某个项目在当前列表中加载的全部会话,也支持跨多个项目批量选择;搜索状态下会同时显示当前命中数与项目会话总数。
- 重做会话列表为更克制的表格式布局,统一标题、更新时间、Provider、模型和 ID 列,降低高亮边框、彩色标签与重复装饰造成的视觉干扰。
- 移除“已选 / 需修复、选择需同步、清空选择、修复选中”等按条修复控件,保留顶部“检查会话”和“同步 / 修复”用于全量处理。
- 永久删除确认窗口会预览待删除会话,并要求确认已关闭正在使用相关会话的其他 Codex 窗口或 CLI;由所选会话派生的子会话也会一并处理。
### 修复 Bug
- 修复默认窗口大小下会话列表滚动条在下拉过程中消失、必须放大窗口才能继续查看的问题;列表现在使用稳定的内部滚动区域并阻止滚动穿透。
- 修复同时存在当前 `state_*.sqlite` 与旧版 `sqlite/state_*.sqlite` 时,会话总数重复统计并混入旧会话的问题;现在优先使用当前活动会话数据库。
- 修复批量删除部分失败时结果反馈不完整的问题,界面会保留未删除项并显示失败数量与原因,方便关闭占用进程后重试。
### 开发
- 永久删除优先调用 Codex App Server 官方 `thread/delete`,不可用时使用本地兼容清理,并复用跨平台 Codex 可执行文件探测与启动逻辑。
- 本地兼容清理覆盖父子会话、当前及旧版会话数据库关联记录、活动/归档 Rollout、会话索引、历史记录、Shell 快照以及日志、记忆、目标和本地目录等关联数据。
- 会话同步与删除共用维护锁,并增加 Rollout 规范路径校验与符号链接边界保护,避免并发维护或越界删除。
- 增加活动数据库优先级、父子会话与重复库清理、关联记录清理、Rollout 越界拒绝及符号链接防护测试。
- 会话管理支持单选或多选后永久删除,所选会话派生的子会话也会一并删除。删除不进入回收站、不创建新的备份,在 Codex-X 中无法撤销。
- 删除前可预览待删除会话,并需确认已关闭其他正在使用这些会话的 Codex 窗口或命令行进程。
- 支持按项目勾选当前已加载的会话,也可跨项目批量选择;搜索时同时显示匹配数量和项目会话总数。
- 会话列表改为紧凑表格,标题、更新时间、供应商、模型和编号更清晰。检查与修复统一使用顶部的“检查会话”和“同步 / 修复”。
- 修复默认窗口大小下会话列表无法继续向下滚动的问题。
- 修复升级 Codex 后可能重复统计会话、混入旧会话的问题。
- 批量删除部分失败时,会保留未删除项并显示失败数量和原因,方便处理后重试。
## [v0.2.33] - 2026-07-11
### 更新
- 重做【指令提示词】页面布局:当前状态、启用方式和模板列表使用统一的紧凑框架,模板来源与更新时间更容易查看。
- GitHub 提示词目录改为权威同步:自动发现并缓存新增 `.md`,清理远端已删除模板和历史别名缓存;离线时仍可使用上次成功同步的内容。
- GitHub `examples/` 新增 `海鸥3.0破甲.md`,并清理不再使用的旧示例模板。
### 修复 Bug
- 修复首次进入提示词页或切换导航返回时,会先显示历史缓存、随后再跳到 GitHub 最新模板的问题;同一次启动中会保留已同步状态,不再重复回退旧列表。
- 修复提示词模板较多时列表溢出最外层边框的问题,外层面板现在会随完整列表正确增高。
- 修复 GitHub 同步并发、部分模板下载失败和异常目录响应可能造成缓存状态错乱或误删的问题;失败后允许重新同步。
- 修复远端模板已删除但仍在启用时,重启后可能无法识别和关闭的问题;当前生效模板会保留明确的关闭入口。
- 删除 `gpt5.5-jeli.md` 介绍中多余的“测试生效”文案。
- 修复 macOS Apple Silicon / Intel、Windows 和 Linux 应用图标外围出现大块白边的问题,重新生成全平台透明图标资源。
### 开发
- 增加 GitHub 模板动态发现、缓存去重、离线回退、过期缓存删除和异常目录保护测试,并为缓存刷新增加互斥与事务保护。
- 修正发布脚本遗漏 `Cargo.lock` 的问题,确保 tag 内 Rust 包版本与应用版本一致。
- 重新整理【指令提示词】页面,当前状态、启用方式、模板来源和更新时间更清晰,并简化模板说明。
- 同步 GitHub 时会自动添加新模板、移除已删除模板;离线时仍可使用上次成功同步的内容。
- 新增 `海鸥3.0破甲.md` 示例模板,清理不再使用的旧示例。
- 修复返回提示词页面时列表先显示旧内容再跳到新内容,以及模板较多时列表超出边框的问题。
- 修复同步或下载失败时模板列表可能出错、误删的问题,失败后可重新同步;正在使用的模板即使已从 GitHub 删除,重启后仍可正常关闭。
- 修复 macOS、Windows 和 Linux 应用图标外围出现大块白边的问题。
## [v0.2.32] - 2026-07-11
### 更新
- 提示词页的“启用方式”新增问号说明,用户可以直接看到“保留原提示词 / 替换原提示词”对现有系统提示词和 `model_instructions_file` 的实际影响。
- 提示词注入页面继续沿用统一的当前状态展示,方便判断当前模板与启用方式是否一致。
### 修复 Bug
- 修复提示词启用方式说明不够直观的问题,减少误操作风险。
### 修复 Bug
- 修复相同 Base URL、API Key、模型但 TOML 内容不同的供应商可能同时显示“当前”的问题;现在优先按完整 live TOML 匹配唯一配置,并避免重复展示 detected custom 卡片。
- 修复追加提示词后禁用会误影响用户原有规则的风险;禁用只删除 Codex-X 管理的 AGENTS 区块或指令文件,用户其他内容保持不变。
- 修复并发操作在同一毫秒创建备份时可能发生临时文件名冲突的问题。
- 修复切换 Skill / MCP 开关后条目因启用状态排序而跳到列表其他位置的问题。
- 修复 macOS Overlay 标题栏无法拖动的问题:补齐 Tauri 2 的 `core:window:allow-start-dragging` capability,并统一顶部拖动区域样式。
- 修复 Windows 桌面应用环境中因 PATH 不完整或 `codex.cmd` 无法直接执行而检测不到 Codex CLI 版本的问题;新增 npm、ChatGPT、Cursor、VS Code 和常见安装目录探测。
### 开发
- 将 `AGENTS.md` 纳入 Codex-X 备份与恢复流程。
- 增加受管区块合并/卸载、GitHub 动态模板发现、完整 TOML 供应商匹配、追加/替换模式和 AGENTS 恢复测试。
- 提示词“启用方式”增加说明,方便了解“保留原提示词”和“替换原提示词”的区别,并核对当前启用状态。
- 修复配置相近的多个供应商可能同时显示“当前启用”、重复显示供应商卡片的问题。
- 关闭追加的提示词时会保留用户原有规则,自定义规则也支持备份和恢复。
- 修复多项操作同时备份时可能失败的问题。
- 修复切换 Skill 或 MCP 开关后,条目跳到列表其他位置的问题。
- 修复 macOS 窗口顶部无法拖动的问题,并改善 Windows 上 Codex 版本的识别。
## [v0.2.31] - 2026-07-09
### 更新
- 调整 macOS Toast 展示位置:Toast 现在直接挂在窗口根层,不再被内容区域下移影响,提示位置更靠上、更自然。
- 优化 Skills ZIP 安装体验:从 ZIP 安装 Skill 时会读取 `SKILL.md` 里的真实 `name` / `description`,不再显示 `skill-zip-时间戳` 这类临时目录名。
- Skills 页面刷新时会自动识别旧版 `skill-zip-*` 目录,并尝试重命名为 Skill 自身名称。
### 修复 Bug
- 修复启用第三方供应商后,关闭并重新打开 Codex-X 不再显示“当前启用”的问题。
- 修复 Windows / Linux 上供应商编辑页无法正常下拉滚动的问题,编辑 `config.toml` 时不再需要用 PageDown 绕过。
- 修复 Toast 受内容容器 `contain` 影响导致 macOS 位置调整不生效的问题。
### 开发
- 增加 Skill 元数据读取与旧 ZIP Skill 目录自动修正的单元测试。
- 调整 macOS 顶部通知的位置,避免提示随页面内容下移。
- 从 ZIP 安装 Skill 后显示其实际名称和说明;刷新时会尝试修正旧版安装留下的临时名称。
- 修复启用第三方供应商后,重新打开 Codex-X 不再显示“当前启用”的问题。
- 修复 Windows 和 Linux 供应商编辑页无法正常向下滚动的问题。
## [v0.2.30] - 2026-07-09
### 更新
- 优化会话管理页面布局:同步状态、目标 Provider、聊天总数、已展示数量、需修复数量合并为紧凑信息栏,把更多空间留给会话列表。
- 会话管理新增“启动自动修复”开关:开启后,Codex-X 启动时会在后台检查本地会话完整性,发现未同步会自动修复,不阻塞界面。
- 优化按钮点击体验:移除容易造成 WebView 闪烁/掉帧的水波纹、位移和 filter 效果,点击反馈更轻、更稳。
### 修复 Bug
- 修复同名供应商可能同时显示为“当前启用”的问题;Codex-X 现在会记录最后启用的供应商 ID,避免第三方 live Provider 都是 `custom` 时出现双亮。
- 修复新增同名供应商时 ID 冲突的问题;新建供应商会自动生成唯一 ID。
- 修复提示词管理中同名/同文件名 md 可能同时显示为启用的问题;新增或导入提示词会自动生成唯一文件名,当前状态按实际启用文件精确匹配。
- 修复会话管理中会话选择反馈不明显的问题;已选数量和可修复数量会实时展示。
### 体验
- 进一步降低供应商、提示词、TOML、会话管理等页面滚动时的闪烁和低帧率感。
- 精简会话管理顶部信息栏,为会话列表留出更多空间;选择会话时实时显示已选数量和可修复数量。
- 新增“启动自动修复”开关,开启后会在启动时后台检查会话,并自动修复未同步的会话,不影响界面操作。
- 减少按钮点击和页面滚动时的闪烁、卡顿。
- 修复同名供应商可能同时显示“当前启用”,以及新增同名供应商发生冲突的问题。
- 修复同名提示词可能同时显示为启用的问题。
## [v0.2.29] - 2026-07-07
### 更新
- 优化 Toast 提示样式:改为窗口顶部居中的产品化通知卡片,减少遮挡右上角操作按钮。
- Provider 连接测试文案改为简洁结果:成功显示“连接正常(耗时)”,失败显示 HTTP 状态与耗时。
- 第三方供应商编辑页支持保存 TOML 模板;点击“保存”只保存配置,点击“启用”时才写入 Codex live config。
- 技能和 MCP 页面“导入已有”改为先展示预览确认弹窗,列出将导入的 Skills / MCP,用户确认后才执行导入。
### 修复 Bug
- 修复 Codex 第三方 API auth 写入错误:`auth.json` 现在写入 Codex 实际识别的 `auth_mode = "apikey"`,避免桌面端回到登录页。
- 修复官方认证编辑页打开时会静默加载 cc-switch 旧 token,导致新登录账号 token 显示不更新的问题;现在默认读取 live `~/.codex/auth.json`,并提供“刷新当前 auth.json”。
- 修复切换到 OpenAI Official 时可能覆盖当前 live auth.json 的问题,避免把旧账号登录态写回。
- 修复 Provider 测试把 HTTP 403 误判为“连接正常”的问题;现在只有 2xx 状态会显示成功。
- 修复从 cc-switch 导入 Provider 时同名同 URL 供应商可能产生重复卡片的问题。
- 修复 SQLite 轻量迁移重复执行时 `duplicate column name: toml_config` 导致启动报错的问题。
### 开发
- 增加 Provider/auth 相关单元测试,覆盖第三方切换、官方 auth 保留、403 判定和 cc-switch 导入串台场景。
- 通知改为顶部居中显示,减少对右上角按钮的遮挡;供应商连接测试结果更简洁,显示连接状态和耗时。
- 第三方供应商支持保存配置模板:“保存”只保存配置,点击“启用”后才应用到 Codex。
- Skills 和 MCP 的“导入已有”增加预览,确认列表后才执行导入。
- 修复切换第三方供应商后 Codex 可能回到登录页的问题。
- 官方认证编辑页默认读取当前登录信息,并提供“刷新当前 auth.json”;修复切回官方登录时可能恢复成旧账号的问题。
- 修复连接测试将“访问被拒绝”误报为连接正常,以及从 cc-switch 导入时出现重复供应商卡片的问题。
- 修复部分用户升级后无法启动的问题。
## [v0.2.28] - 2026-07-07
- 修复从 cc-switch 导入 Codex Provider 时可能把供应商名称、base_url 与 API Key 串台的问题。
- 导入 cc-switch Provider 时改为先扫描所有 `[model_providers.<id>]` section,再按 provider row id 精确匹配,避免使用过期的 `model_provider` active 值。
- 兼容 cc-switch legacy `custom` provider 模板,同时保留 `experimental_bearer_token` 兜底。
- 增加复现 Sky2api / MagicAI 交叉配置的单元测试,防止 Provider 切换再次出现“看起来切了但实际没切对”的问题。
- 修复从 cc-switch 导入供应商时,名称、API 地址和密钥可能相互混淆的问题,避免启用后实际使用了其他供应商。
- 改善旧版 cc-switch 供应商配置的导入兼容性。
## [v0.2.27] - 2026-07-07
- 优化【指令提示词】页面为 Skills/MCP 风格的简洁列表:左侧显示模板名称与说明,右侧使用开启/关闭切换;自定义/导入的 md 提示词增加编辑与删除图标。
- 优化【供应商】页面操作区:移除“官方配置 / 本地保存 / gpt-5.5 / 不支持路由”等冗余信息,将切换改为“启用”,编辑/删除/测试连接改为图标按钮。
- 新增供应商 base_url 连通性测试按钮,方便切换前检查第三方 API 地址是否可达。
- 修复第三方 Provider 切换后可能未真正生效的问题:切换和保存 TOML 时会写入 Codex 实际读取的 `experimental_bearer_token`。
- 对齐 cc-switch 的 Codex Provider live config 思路:从 cc-switch 导入时会识别 `experimental_bearer_token`,并避免使用 Codex 内置保留 provider id。
- 优化启动加载链路:启动诊断、备份列表、会话同步状态改为后台刷新,减少首屏等待。
- 提示词页面改为简洁列表,名称和说明更清晰,右侧可直接开关;自定义和导入的提示词支持编辑、删除。
- 精简供应商操作区,将“切换”改为“启用”,编辑、删除和连接测试改为图标按钮。
- 新增供应商连接测试,方便启用前检查第三方 API 地址是否可用。
- 修复第三方供应商切换后可能未真正生效的问题,并改善从 cc-switch 导入配置的兼容性。
- 减少启动等待,检查、备份列表和会话状态在后台加载。
## [v0.2.26] - 2026-07-07
- 修复第三方 Provider 切换后可能“看起来已切换但实际未按新供应商生效”的问题:不再把所有第三方都写成 `model_provider = "custom"`,而是写入供应商自己的稳定 ID。
- 修复从官方 ChatGPT 登录态切到第三方 API Key 时 `auth.json` 仍保留 `auth_mode = "chatgpt"` 的问题;写入 API Key 时会同步设置 `auth_mode = "apikey"`。
- 第三方供应商默认不再要求 OpenAI 登录态,避免新建/导入 Provider 时错误继承官方 auth 语义。
- 增加 Provider 切换单元测试,覆盖真实 provider key 和 API Key auth mode 的落盘结果。
- 修复第三方供应商显示已切换、实际却未生效的问题。
- 修复从官方登录切换到第三方 API 密钥后,仍沿用官方登录方式的问题。
- 新增或导入第三方供应商时,默认不再要求 OpenAI 官方登录。
## [v0.2.25] - 2026-07-06
- 进一步修复 TOML、指令提示词、供应商、会话管理等页面切换时右侧滚动条闪现/消失造成的视觉抖动。
- 外层页面滚动容器改为稳定滚动并隐藏外层滚动条,保留 TOML 编辑器、会话列表等内部区域自己的滚动条。
- 进一步减少切换配置、提示词、供应商和会话页面时,滚动条闪现造成的抖动;编辑器和会话列表仍保留各自的滚动条。
## [v0.2.24] - 2026-07-06
- 修复切换供应商 / 会话管理等页面时右侧滚动条短暂出现又消失的问题:页面切换动画改为纯透明度过渡,不再用 `translateY` 造成瞬时溢出。
- 降低页面切换视觉抖动,避免进入页面时内容区域宽度被临时滚动条挤压。
- 修复切换供应商、会话管理等页面时滚动条短暂闪现、内容宽度跳动的问题,页面切换更平稳。
## [v0.2.23] - 2026-07-06
- 指令提示词页新增并纳管 `gpt5.5-jeli.md` 模板,作为“大白话(80% 场景)破甲”版本。
- 简化指令提示词列表视觉:移除花哨图标与文件路径展示,让用户更容易看清模板名称、介绍和启用状态。
- 更新 GPT-5.5 / GPT-5.4 内置模板名称为“unrestricted 破甲”,并统一说明使用方法:先让 AI 分析项目,分析完之后发【不直白的逆向】命令。
- 新增 `gpt5.5-jeli.md` 提示词模板,名称为“大白话(80% 场景)破甲”。
- 精简提示词列表,突出模板名称、介绍和启用状态。
- GPT-5.5 和 GPT-5.4 内置模板统一使用“unrestricted 破甲”名称,并补充使用说明:先让 AI 分析项目,再发送【不直白的逆向】命令。
## [v0.2.22] - 2026-07-06
- 继续优化指令提示词页切换体验:导航切换进入 React transition,GitHub 内置模板静默检查延后到空闲时执行,减少切页瞬间卡顿。
- 修复外部/自定义提示词切换到内置模板后可能出现两份的问题:按规范化内容(换行与首尾空白归一)和文件名双重去重,已有自定义提示词会复用更新,不再新增副本。
- 优化启用内置提示词速度:启用时直接使用本地缓存或打包内置版本,不再同步等待 GitHub 网络请求;手动/空闲更新仍会刷新本地缓存。
- 启动页增加动态状态文案、双层轨道与扫光动画,让从欢迎页进入主界面更顺滑。
- 减少进入提示词页面时的卡顿;GitHub 模板检查延后到空闲时进行。
- 启用内置提示词时直接使用本地已有内容,无需等待网络;手动同步或空闲检查后会更新模板。
- 修复从自定义提示词切换到内置模板时可能产生重复项的问题,已有相同提示词会直接更新。
- 优化启动页状态提示和过渡动画,进入主界面更顺畅。
## [v0.2.21] - 2026-07-06
- 进一步降低 UI 卡顿风险:将启动检测、Codex 状态读取、Provider/官方配置/备份/cc-switch 导入等剩余同步命令迁移到后台 worker。
- 优化前端串行请求:提示词启用后的备份/提示词刷新改为并发,保存并启用自定义提示词时不再重复拉取列表。
- 完善【技能和 MCP】导入已有:读取 cc-switch `mcp_servers` 数据库,按 `enabled_codex` 纳管并同步到 Codex `config.toml [mcp_servers]`。
- 增强 Skills 检查更新:对带有 cc-switch 仓库元数据的 Skill 拉取 GitHub 仓库 ZIP 计算远程 hash,显示“有新版本 / 已是最新 / 远程检查失败”等状态。
- 减少启动、供应商操作、备份和导入时的卡顿,并加快提示词保存、启用后的刷新。
- “导入已有”支持从 cc-switch 导入已为 Codex 启用的 MCP,并应用到 Codex。
- 来自 cc-switch 且保留仓库信息的 Skill 可检查 GitHub 更新,显示“有新版本”“已是最新”或“远程检查失败”。
## [v0.2.20] - 2026-07-06
- 继续优化指令提示词页性能:提示词列表/状态读取、保存、导入、启用、禁用等后端操作改为后台 worker,避免切页或启用模板时阻塞 UI。
- 修复外部自定义提示词重复保存问题:切换到内置模板前会按文件名与内容双重去重,并自动清理历史 `external-*` 重复项。
- 优化启动加载体验:启动页增加最短展示、退出淡出与动态过渡,避免从欢迎页突然跳到主页。
- 改善提示词页面响应速度,减少切换页面、保存、导入和开关模板时的卡顿。
- 修复从自定义提示词切换到内置模板后重复保存的问题,并自动整理已有的此类重复项。
- 优化启动页展示和退出动画,避免突然跳到主页。
## [v0.2.19] - 2026-07-06
- 新增【技能和 MCP】页面:展示 Codex 当前已安装 Skills 与 MCP,支持导入已有、从 ZIP 安装 Skill、启用/禁用 Skill、启用/禁用 MCP。
- MCP 管理会读写 `~/.codex/config.toml` 的 `[mcp_servers]`,禁用后保留到 Codex-X SQLite,后续可一键重新启用。
- Skills 管理会扫描 `~/.codex/skills`,并可从 `~/.agents/skills`、`~/.cc-switch/skills` 导入到 Codex;禁用后移动到 Codex-X 禁用目录,避免直接删除。
- 优化多个潜在卡顿点:指令提示词 GitHub 检查改为延迟后台执行,远程拉取、会话扫描/同步、Skills/MCP 扫描均放入后台 blocking worker。
- 修复外部自定义提示词切换到内置提示词后重复出现的问题,并自动清理同名 `external-*` 重复项。
- 增加页面切换过渡、启动页动态光效和首次启动向导退出动画,降低页面突然跳转感。
- 新增【技能和 MCP】页面,可查看已安装内容、导入已有配置、从 ZIP 安装 Skill,以及启用或禁用 Skill 和 MCP。
- 禁用 Skill 或 MCP 后仍保留原有内容,可随时重新启用;支持导入本机已有的 Skill 和 cc-switch 中的技能。
- 减少 GitHub 模板检查、会话检查与同步、Skills 和 MCP 加载时的卡顿。
- 修复从自定义提示词切换到内置模板后出现重复项的问题,并自动整理已有的同名重复项。
- 优化页面切换、启动页和首次启动向导的过渡动画。
## [v0.2.18] - 2026-07-05
- 指令提示词内置模板支持从 GitHub `examples/` 实时检查更新,并缓存到本地;启用内置模板时优先使用 GitHub 最新版本,离线时自动回退本地缓存或打包内置版本。
- 指令提示词页面新增“更新内置模板”状态与来源展示,可看到模板来自 GitHub 最新、本地缓存或打包内置。
- 继续保留导入 `.md` 提示词、外部提示词自动记忆、会话管理交互优化和 API Key 可见切换等体验改进。
- 内置提示词支持从 GitHub 检查更新,启用时优先使用最新模板;离线时可使用本地缓存或随应用提供的模板。
- 提示词页面新增更新状态和模板来源提示。
## [v0.2.17] - 2026-07-04
- 修复 macOS Intel Release 构建 runner:从已不可用/长时间排队的 `macos-13` 切换为 `macos-15-intel`。
- 继续保留 macOS Apple Silicon / macOS Intel / Windows MSI / Windows portable ZIP / Linux deb/rpm 多平台产物。
- 修复 Intel Mac 安装包发布失败或长时间等待的问题。
## [v0.2.16] - 2026-07-04
- Release 新增 macOS Intel 构建,Intel Mac 用户可下载 x64 DMG。
- macOS Release 现在同时提供 Apple Silicon 与 Intel 两种 DMG。
- Windows Release 新增 portable ZIP,包含可直接运行的 `Codex-X.exe`,无需 MSI 安装。
- 发布流程支持上传 `.zip` portable 产物,并更新 README 下载说明。
- 新增 Intel Mac 版 DMG,macOS 用户可按芯片类型选择 Intel 或 Apple Silicon 安装包。
- 新增 Windows 免安装 ZIP,解压后即可运行,无需安装 MSI。
## [v0.2.15] - 2026-07-04
- 会话管理页移除 CODEX_HOME 与 Provider Sync 备份位置展示,页面信息更简洁。
- 优化 Windows 打开下载页体验:后端改为后台线程 spawn 浏览器,不再等待 `cmd /C start`,避免 WebView 卡顿 2-3 秒。
- 统一所有外部链接按钮走异步打开逻辑,打开项目主页、反馈页、下载页都不会阻塞界面。
- 修复 macOS 顶部 toast 被 Overlay 标题栏遮挡的问题,toast 自动下移到标题栏安全区下方。
- 精简会话管理页面,移除不常用的配置和备份位置说明。
- 修复 Windows 打开下载页、项目主页等外部链接时界面短暂卡顿的问题。
- 修复 macOS 顶部提示消息被标题栏遮挡的问题。
## [v0.2.14] - 2026-07-04
- 修复 macOS Overlay 标题栏遮挡内容的问题,为红黄绿窗口按钮预留完整安全区。
- 调整侧边栏、内容区、Provider/TOML/指令提示词/会话管理页高度计算,避免顶部内容被标题栏压住。
- 加高 macOS 顶部拖拽区域并保持深色渐变,窗口顶部继续和应用色系统一。
- 修复 macOS 标题栏和窗口按钮遮挡页面内容的问题。
- 增大窗口顶部拖拽区域,并统一深色外观。
## [v0.2.13] - 2026-07-04
- macOS 窗口标题栏改为深色融合样式,避免顶部出现系统白色标题条。
- 会话管理页面移除多余外层玻璃边框,统一为完整内容容器。
- 优化会话列表边界与滚动区域,避免列表像卡在外层框外面。
- macOS 标题栏改为与应用一致的深色样式。
- 简化会话管理页面边框,改善列表布局和滚动体验。
## [v0.2.12] - 2026-07-04
- Windows MSI 安装器回退为默认简洁样式,移除上一版过重的自定义安装界面图。
- 【会话管理】页面新增会话列表,展示标题、Provider、模型、工作目录、更新时间、归档/需同步状态。
- 会话扫描会从 Codex 本地 SQLite threads 表读取最近会话,方便用户直接判断哪些历史 thread 需要同步修复。
- Windows MSI 恢复简洁的安装界面。
- 会话管理新增历史会话列表,可查看标题、供应商、模型、工作目录、更新时间,以及归档和待同步状态。
## [v0.2.11] - 2026-07-04
- 调整首次启动自动检查更新体验:不再弹出居中的强提醒窗口。
- 自动检测到新版本时仅显示轻量 toast,并保留概览页顶部“发现新版本”提示条。
- 只有用户在【关于】页面主动点击“检查更新”时,检测到新版本才弹出“现在下载 / 稍后”窗口。
- 启动时发现新版本改为轻量提示,概览页仍保留更新提示条。
- 仅在“关于”页面主动检查更新并发现新版本时,弹出“现在下载 / 稍后”窗口。
## [v0.2.10] - 2026-07-04
- 修复 Release 发布任务上传 Linux 产物时误把 `deb/`、`rpm/` 目录当作资产上传的问题。
- 发布任务现在只收集 `.dmg` / `.msi` / `.deb` / `.rpm` 文件并统一上传。
- 补齐稳定的三平台 Release 自动发布流程。
- 修复 Linux 安装包上传失败的问题,完善 macOS、Windows 和 Linux 安装包发布。
## [v0.2.9] - 2026-07-04
- 修复 GitHub Actions Release 发布流程:不再由三平台矩阵并发创建 Release,改为先上传构建产物,再由单独发布任务统一创建/更新 Release。
- 修复 `Resource not accessible by integration` 导致 Release 创建失败的问题。
- Release 仍会从 `CHANGELOG.md` 自动读取当前 tag 的更新日志,并上传 macOS / Windows / Linux 安装包。
- 修复 GitHub 版本发布失败的问题。
## [v0.2.8] - 2026-07-04
- 更新页进一步产品化:去掉资源/仓库调试信息,将“有更新”改为更明显的绿色标签。
- 概览页顶部新增轻量“发现新版本”提示条,可直接打开 Releases 页面。
- 新增【会话管理】页面:检查 Codex 本地 sessions / archived_sessions 与 SQLite threads 是否和当前 Provider 同步。
- 新增一键 Provider Sync / 修复历史会话:写入前备份到 `~/.codex/backups_state/provider-sync/`,并保留最近 5 份备份。
- 简化更新页面,使用醒目的绿色标签标示新版本;概览页新增可直接打开下载页的更新提示条。
- 新增“会话管理”页面,可检查历史会话是否与当前供应商匹配,并一键同步修复。
- 修复会话前自动备份,保留最近 5 份备份。
## [v0.2.7] - 2026-07-04
- 简化更新检查页展示。
- “打开下载页”改为打开 GitHub Releases 页面。
- 更新弹窗保持简洁,仅提示版本差异。
- 首次启动自动检查更新并弹窗提醒。
- 简化更新页面和弹窗,“打开下载页”直接前往 GitHub 版本下载页面。
- 首次启动自动检查更新,发现新版本时弹窗提醒。
## [v0.2.5] - 2026-07-04
- Windows 版双击启动不再额外弹出终端窗口。
- 改进 Windows MSI 安装器品牌展示与图标。
- About 页面外部链接改为系统默认浏览器打开。
- Release 流程加入 Rust cache,后续构建更快。
- 修复 Windows 双击启动时额外弹出终端窗口的问题。
- 改善 Windows MSI 安装界面的应用名称和图标展示。
- “关于”页面的外部链接使用系统默认浏览器打开。
## [v0.2.4] - 2026-07-04
- 美化 Windows MSI 安装器横幅与对话图。
- Windows 安装包加入应用图标。
- 美化 Windows MSI 安装界面,并为安装包加入应用图标。
## [v0.2.3] - 2026-07-04
- 修复 macOS DMG 中应用图标缺失问题。
- 补充 macOS 安装说明。
- Release 流程加入基础缓存优化。
- 修复 macOS DMG 中应用图标缺失的问题,并补充安装说明。
## [v0.2.2] - 2026-07-04
- macOS / Linux / Windows 首次三平台 Release。
- Linux 产物改为 `deb` / `rpm`,避免 AppImage 图标问题。
- 首次提供 macOS、Linux 和 Windows 三个平台的安装包。
- Linux 改为提供 DEB 和 RPM 安装包。
## [v0.2.1] - 2026-07-04
- 首次加入应用图标与 GitHub Release 自动发布。
- 首次加入应用图标,并通过 GitHub 提供版本下载。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.16",
"version": "0.3.17",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,16 @@
# Provider brand marks
These six unmodified SVG assets are from [Lobe Icons](https://github.com/lobehub/lobe-icons), licensed under the MIT License. The upstream copyright and permission notice is included in [LICENSE](./LICENSE).
Pinned upstream commit: `a94750e3f5f8fc33757b839d85030e742284e43a`.
| Local file | Upstream source |
| --- | --- |
| `deepseek.svg` | [packages/static-svg/icons/deepseek.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/deepseek.svg) |
| `minimax.svg` | [packages/static-svg/icons/minimax.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/minimax.svg) |
| `mimo.svg` | [packages/static-svg/icons/xiaomimimo.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/xiaomimimo.svg) |
| `kimi.svg` | [packages/static-svg/icons/kimi.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/kimi.svg) |
| `glm.svg` | [packages/static-svg/icons/zhipu.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/zhipu.svg) |
| `qwen.svg` | [packages/static-svg/icons/qwen.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/qwen.svg) |
The monochrome SVGs are rendered as CSS masks so their color follows the preset card state. Brand names and marks identify their respective providers; they do not imply endorsement.
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>

After

Width:  |  Height:  |  Size: 786 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>XiaomiMiMo</title><path d="M.958 15.936a.459.459 0 01.459.44v2.729a.46.46 0 01-.918 0v-2.729a.459.459 0 01.459-.44zm4.814-2.035a.46.46 0 01.553.45v4.754a.458.458 0 11-.918 0V15.48L3.74 17.202a.462.462 0 01-.655.016.462.462 0 01-.065-.082L.628 14.67a.459.459 0 01.658-.637l2.124 2.187 2.127-2.188a.46.46 0 01.235-.13zm2.068.004a.46.46 0 01.458.445v4.755a.46.46 0 01-.458.458.459.459 0 01-.458-.458V14.35a.459.459 0 01.458-.445zm1.973 2.014a.46.46 0 01.46.457v2.729a.46.46 0 01-.784.324.46.46 0 01-.134-.324v-2.729a.46.46 0 01.458-.458zm.002-2.045a.458.458 0 01.328.157l2.127 2.19 2.125-2.19a.459.459 0 01.784.318v4.756a.46.46 0 01-.455.458.46.46 0 01-.458-.458V15.48l-1.667 1.723a.46.46 0 01-.65.008l-.005-.005c0-.002-.002-.002-.004-.003l-2.455-2.534a.46.46 0 01-.008-.667.461.461 0 01.338-.128zm6.797 1.206a.46.46 0 01.53.651A1.966 1.966 0 0019.81 18.4a.462.462 0 01.623.18.46.46 0 01-.181.624 2.863 2.863 0 01-1.38.353l-.142-.004a2.88 2.88 0 01-2.393-4.263.461.461 0 01.274-.21zm.864-.931a2.884 2.884 0 013.915 3.914.46.46 0 01-.402.24l-.057-.004a.458.458 0 01-.164-.055.46.46 0 01-.182-.622 1.967 1.967 0 00-2.669-2.67.459.459 0 11-.441-.803zM9.59 6.368c1.481 0 1.696 1.202 1.696 1.654v2.648h-.917v-.432c-.26.346-.792.535-1.36.535-.133 0-1.289-.03-1.384-1.136-.082-.932.675-1.61 2.053-1.61h.691c0-.563-.367-.886-.983-.886-.44.013-.864.174-1.2.458l-.36-.664c.484-.379 1.012-.567 1.764-.567zm4.427.1c1.263 0 2.082.97 2.083 2.15 0 1.181-.824 2.154-2.083 2.154-1.26 0-2.084-.972-2.084-2.152 0-1.18.82-2.153 2.084-2.153zm6.801.015c.68 0 1.202.465 1.197 1.548v2.642H21.1V8.29c0-.312-.002-.98-.63-.98s-.628.667-.628.838v2.524h-.89V8.148c0-.17-.001-.838-.63-.838-.628 0-.628.668-.628.98v2.383h-.917v-4.03h.917V7a1.22 1.22 0 01.947-.516c.398 0 .76.193.982.686a1.321 1.321 0 011.195-.686zm-18.093.872l1.457-1.772H5.32L3.311 8.07l2.14 2.602H4.24L2.725 8.796 1.21 10.672H0L2.138 8.07.13 5.583h1.138l1.458 1.772zm4.149 3.317h-.916V6.644h.916v4.028zm16.99 0h-.916V6.644h.916v4.028zM9.925 8.71c-1.055 0-1.359.412-1.326.742.032.329.324.537.757.537a1.013 1.013 0 001.014-.968l.002-.31h-.447zM14.018 7.3c-.663 0-1.184.487-1.184 1.32 0 .832.52 1.32 1.184 1.32.662 0 1.182-.49 1.182-1.32 0-.832-.52-1.32-1.182-1.32zM6.417 5.001a.568.568 0 01.587.582.588.588 0 01-1.175 0A.57.57 0 016.417 5zm16.991 0a.57.57 0 01.592.582.588.588 0 01-1.174 0 .57.57 0 01.357-.542.572.572 0 01.225-.04z"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+2 -1
View File
@@ -490,8 +490,9 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.16"
version = "0.3.17"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"percent-encoding",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.16"
version = "0.3.17"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -20,6 +20,7 @@ tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2.10.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.22"
toml_edit = "0.22"
chrono = { version = "0.4", features = ["serde"] }
dirs = "5.0"
+50 -1
View File
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
const APP_DB_SCHEMA_VERSION: i64 = 4;
const APP_DB_SCHEMA_VERSION: i64 = 5;
struct DatabaseInitializer {
migration_lock: Mutex<()>,
@@ -148,6 +148,7 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
toml_config TEXT,
wire_api TEXT NOT NULL DEFAULT 'responses',
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
model_mappings_json TEXT NOT NULL DEFAULT '[]',
source TEXT NOT NULL DEFAULT 'manual',
source_id TEXT,
created_at TEXT NOT NULL,
@@ -240,6 +241,12 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
"source_id",
"ALTER TABLE providers ADD COLUMN source_id TEXT",
)?;
ensure_sqlite_column(
conn,
"providers",
"model_mappings_json",
"ALTER TABLE providers ADD COLUMN model_mappings_json TEXT NOT NULL DEFAULT '[]'",
)?;
conn.execute_batch(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
ON providers(source, source_id)
@@ -367,6 +374,48 @@ mod tests {
remove_test_db(&path);
}
#[test]
fn version_four_provider_rows_migrate_with_empty_model_mappings() {
let path = test_db_path("provider-model-mappings-v5");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
let legacy = Connection::open(&path).unwrap();
legacy.execute_batch("CREATE TABLE providers (
id TEXT PRIMARY KEY, provider_name TEXT NOT NULL, base_url TEXT NOT NULL,
model TEXT NOT NULL, api_key TEXT, toml_config TEXT,
wire_api TEXT NOT NULL DEFAULT 'responses', requires_openai_auth INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'manual', source_id TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
INSERT INTO providers (id, provider_name, base_url, model, api_key, source, source_id, created_at, updated_at)
VALUES ('legacy-models', 'Existing provider', 'https://migration.example.test/v1', 'existing-model', 'fixture-key', 'cc-switch', 'source-row', 'original-created', 'original-updated');
PRAGMA user_version = 4;").unwrap();
drop(legacy);
let migrated = DatabaseInitializer::new().open_at(&path).unwrap();
assert_eq!(schema_version(&migrated).unwrap(), 5);
let stored = crate::providers::list_saved_providers_on_connection(&migrated).unwrap();
assert_eq!(stored.len(), 1);
assert!(stored[0].model_mappings.is_empty());
assert_eq!(stored[0].model, "existing-model");
assert_eq!(stored[0].api_key.as_deref(), Some("fixture-key"));
let metadata: (String, String, String, String, String) = migrated.query_row(
"SELECT source, source_id, created_at, updated_at, model_mappings_json FROM providers WHERE id = 'legacy-models'", [],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
).unwrap();
assert_eq!(
metadata,
(
"cc-switch".into(),
"source-row".into(),
"original-created".into(),
"original-updated".into(),
"[]".into()
)
);
drop(migrated);
remove_test_db(&path);
}
#[test]
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
let path = test_db_path("skills-mcp-notes-migration");
+45 -39
View File
@@ -98,9 +98,8 @@ use providers::{
read_ccswitch_official_auth_inner, remember_active_provider_on_connection,
save_active_provider_inner, save_official_config_inner, save_provider_inner,
save_provider_toml_config_inner, switch_provider_inner, test_provider_connection_inner,
ImportResult, OfficialAuthCandidate, OfficialConfigDraft, OfficialConfigInput,
ProviderConnectionResult, ProviderInput, ProviderModelsResult, ProviderTomlInput,
SavedProvider,
ImportResult, OfficialAuthCandidate, OfficialConfigInput, ProviderConnectionResult,
ProviderInput, ProviderModelsResult, ProviderTomlInput, SavedProvider,
};
#[cfg(test)]
use sessions::{
@@ -1004,6 +1003,22 @@ async fn get_usage_statistics(
.map_err(|error| CodexxError::Config(format!("读取用量统计失败: {error}")))?
}
#[tauri::command]
async fn activate_saved_provider(
config_dir: Option<String>,
provider_id: String,
) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::Set(provider_id),
))
})
.await
.map_err(|error| CodexxError::Config(format!("启用供应商失败: {error}")))?
}
#[tauri::command]
async fn save_active_provider(
provider: SavedProvider,
@@ -1056,26 +1071,6 @@ async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionRe
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
}
#[tauri::command]
async fn get_official_config_draft(
config_dir: Option<String>,
) -> Result<Option<OfficialConfigDraft>> {
tauri::async_runtime::spawn_blocking(move || {
let detail = get_official_profile_inner(
config_dir,
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
)?;
Ok(Some(OfficialConfigDraft {
auth_json: detail.auth_json,
config_text: detail.config_text,
model: detail.profile.model,
source: detail.source,
}))
})
.await
.map_err(|e| CodexxError::Config(format!("读取官方配置快照失败: {e}")))?
}
#[tauri::command]
async fn list_official_profiles(config_dir: Option<String>) -> Result<Vec<OfficialProfileSummary>> {
tauri::async_runtime::spawn_blocking(move || list_official_profiles_inner(config_dir))
@@ -1083,6 +1078,30 @@ async fn list_official_profiles(config_dir: Option<String>) -> Result<Vec<Offici
.map_err(|error| CodexxError::Config(format!("读取官方配置列表失败: {error}")))?
}
#[tauri::command]
async fn get_official_profile_quota(
config_dir: Option<String>,
profile_id: String,
) -> Result<providers::quota::OfficialQuotaSnapshot> {
tauri::async_runtime::spawn_blocking(move || {
providers::quota::get_official_profile_quota_inner(config_dir, profile_id)
})
.await
.map_err(|_| CodexxError::Config("读取官方账号额度失败,请重试".to_string()))?
}
#[tauri::command]
async fn get_official_profile_reset_credits(
config_dir: Option<String>,
profile_id: String,
) -> Result<providers::quota::OfficialResetCreditsSnapshot> {
tauri::async_runtime::spawn_blocking(move || {
providers::quota::get_official_profile_reset_credits_inner(config_dir, profile_id)
})
.await
.map_err(|_| CodexxError::Config("读取官方账号重置次数失败,请重试".to_string()))?
}
#[tauri::command]
async fn get_official_profile(
config_dir: Option<String>,
@@ -1134,20 +1153,6 @@ async fn delete_official_profile(config_dir: Option<String>, profile_id: String)
.map_err(|error| CodexxError::Config(format!("删除官方配置失败: {error}")))?
}
#[tauri::command]
async fn restore_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
let result =
providers::official_profiles::restore_default_official_profile_inner(config_dir)?;
Ok(finish_provider_selection(
result,
ActiveProviderSelectionUpdate::ClearIfOfficial,
))
})
.await
.map_err(|e| CodexxError::Config(format!("还原官方配置失败: {e}")))?
}
#[tauri::command]
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
tauri::async_runtime::spawn_blocking(move || {
@@ -1622,17 +1627,18 @@ pub fn run() {
update_codex_context_window,
get_usage_statistics,
save_active_provider,
activate_saved_provider,
delete_saved_provider,
get_codex_state,
switch_official_provider,
get_official_config_draft,
list_official_profiles,
get_official_profile,
get_official_profile_quota,
get_official_profile_reset_credits,
save_official_profile,
duplicate_official_profile,
switch_official_profile,
delete_official_profile,
restore_official_provider,
reset_official_provider,
save_official_config,
enable_instruction,
@@ -315,6 +315,7 @@ pub(crate) fn build_ccswitch_codex_provider(
toml_config: Some(toml_config),
wire_api: section.wire_api,
requires_openai_auth: section.requires_openai_auth,
model_mappings: Vec::new(),
})
}
+704 -44
View File
@@ -10,7 +10,7 @@ use super::official_auth::{
use super::{
custom_provider_id, delete_provider_inner, experimental_bearer_token_from_doc,
is_placeholder_provider, list_saved_providers_on_connection,
matching_saved_provider_ids_for_live, normalize_saved_provider,
matching_saved_provider_ids_for_live_on_connection, normalize_saved_provider,
normalize_saved_provider_for_save, open_store, provider_template_from_document,
reconcile_active_provider_on_connection, reserved_codex_provider_id,
rollback_provider_store_inner, save_detected_provider_with_rollback_inner,
@@ -131,6 +131,37 @@ fn provider_auth_action(api_key: Option<&str>) -> LiveAuthAction {
LiveAuthAction::Replace(Value::Object(auth))
}
fn configure_live_provider_auth(
table: &mut toml_edit::Table,
api_key: Option<&str>,
requires_openai_auth: bool,
) {
table.remove("experimental_bearer_token");
// Codex ignores auth.json for providers that do not use OpenAI auth.
// Supply the selected provider's key directly, not a stale environment or
// command-backed credential inherited from a previous configuration.
if !requires_openai_auth {
if let Some(key) = api_key.map(str::trim).filter(|key| !key.is_empty()) {
table.remove("env_key");
table.remove("env_key_instructions");
table.remove("auth");
for headers in ["http_headers", "env_http_headers"] {
if let Some(headers) = table.get_mut(headers).and_then(Item::as_table_like_mut) {
let authorization = headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("authorization"))
.map(|(name, _)| name.to_string())
.collect::<Vec<_>>();
for name in authorization {
headers.remove(&name);
}
}
}
table["experimental_bearer_token"] = value(key);
}
}
}
fn live_auth_api_key(codex_dir: &Path) -> Result<Option<String>> {
let path = auth_path(codex_dir);
let Some(bytes) = read_file_snapshot(&path)? else {
@@ -551,6 +582,7 @@ pub(crate) fn detected_live_custom_provider(codex_dir: &Path) -> Result<Option<S
toml_config: (!toml_config.is_empty()).then_some(toml_config),
wire_api: section.wire_api,
requires_openai_auth: section.requires_openai_auth,
model_mappings: Vec::new(),
}))
}
@@ -560,7 +592,7 @@ pub(super) fn persist_detected_live_custom_provider(
let Some(live) = detected_live_custom_provider(codex_dir)? else {
return Ok(None);
};
save_detected_provider_with_rollback_inner(live)
save_detected_provider_with_rollback_inner(codex_dir, live)
}
pub(crate) fn build_provider_toml_draft_inner(
@@ -1032,7 +1064,11 @@ fn merge_provider_toml_into_live(
"该供应商需要 API Key,未切换且未修改 auth.json".to_string(),
));
}
source_provider.remove("experimental_bearer_token");
configure_live_provider_auth(
&mut source_provider,
api_key.as_deref(),
requires_openai_auth,
);
// New and cc-switch imports carry a complete provider config. Treat it as
// authoritative so provider-specific desktop/features/plugin settings make
@@ -1071,6 +1107,19 @@ fn save_provider_toml_config_locked<F>(
old_config: Option<Vec<u8>>,
pre_persist: F,
) -> Result<ActionResult>
where
F: FnOnce(&Path) -> Result<()>,
{
save_provider_toml_config_with_catalog_locked(codex_dir, input, old_config, pre_persist, None)
}
fn save_provider_toml_config_with_catalog_locked<F>(
codex_dir: &Path,
input: ProviderTomlInput,
old_config: Option<Vec<u8>>,
pre_persist: F,
saved: Option<&SavedProvider>,
) -> Result<ActionResult>
where
F: FnOnce(&Path) -> Result<()>,
{
@@ -1081,12 +1130,23 @@ where
pre_persist(codex_dir)?;
let backup_id = create_backup(codex_dir, "save-provider-toml")?;
let current_text = text_from_snapshot(&cfg, old_config.as_deref())?;
let (doc, api_key) = merge_provider_toml_into_live(
let (mut doc, api_key) = merge_provider_toml_into_live(
&cfg,
&current_text,
input.config_text.trim_end(),
input.api_key,
)?;
if let Some(saved) = saved {
// Resolve the menu after sparse legacy templates inherit common
// settings, so a previous provider's managed menu cannot leak back.
super::model_catalog::prepare_model_catalog(
codex_dir,
&saved.id,
&saved.model_mappings,
&saved.model,
&mut doc,
)?;
}
let provider_name = doc
.get("model_providers")
.and_then(|item| item.as_table())
@@ -1205,6 +1265,7 @@ where
"该供应商需要 API Key,未切换且未修改 auth.json".to_string(),
));
}
configure_live_provider_auth(provider_table, api_key.as_deref(), requires_openai_auth);
let auth_action = provider_auth_action(api_key.as_deref());
Ok((backup_id, doc.to_string(), auth_action))
})();
@@ -1268,7 +1329,12 @@ where
let live = detected_live_custom_provider(&codex_dir)?.ok_or_else(|| {
CodexxError::Config("当前不是可编辑的第三方供应商,未修改保存记录".to_string())
})?;
let matches = matching_saved_provider_ids_for_live(&live, &saved_before);
let matches = matching_saved_provider_ids_for_live_on_connection(
&conn,
&codex_dir,
&live,
&saved_before,
)?;
if matches.is_empty() {
if saved_before
.iter()
@@ -1305,36 +1371,48 @@ pub(crate) fn save_active_provider_inner(
provider: SavedProvider,
config_dir: Option<String>,
) -> Result<ActionResult> {
save_active_provider_with_apply(provider, config_dir, |saved, codex_dir, active_config| {
if let Some(config_text) = saved.toml_config.clone() {
save_provider_toml_config_locked(
codex_dir,
ProviderTomlInput {
config_dir: None,
config_text,
api_key: saved.api_key.clone(),
},
active_config,
|_| Ok(()),
)
} else {
switch_provider_locked(
codex_dir,
ProviderInput {
config_dir: None,
provider_id: Some(saved.id.clone()),
provider_name: saved.provider_name.clone(),
base_url: saved.base_url.clone(),
model: saved.model.clone(),
api_key: saved.api_key.clone(),
wire_api: Some(saved.wire_api.clone()),
requires_openai_auth: Some(saved.requires_openai_auth),
},
active_config,
|_| Ok(()),
)
}
})
save_active_provider_with_apply(provider, config_dir, apply_saved_provider_locked)
}
fn apply_saved_provider_locked(
saved: &SavedProvider,
codex_dir: &Path,
active_config: Option<Vec<u8>>,
) -> Result<ActionResult> {
if !saved.model_mappings.is_empty() && saved.wire_api != "responses" {
return Err(CodexxError::Config(
"模型映射需要供应商提供 Responses 兼容接口,请检查 Wire API 设置".into(),
));
}
let config_text =
build_provider_toml_draft_inner(saved.clone(), Some(codex_dir.display().to_string()))?;
save_provider_toml_config_with_catalog_locked(
codex_dir,
ProviderTomlInput {
config_dir: None,
config_text,
api_key: saved.api_key.clone(),
},
active_config,
|_| Ok(()),
Some(saved),
)
}
pub(crate) fn activate_saved_provider_inner(
config_dir: Option<String>,
provider_id: String,
) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let _lock = acquire_live_config_lock(&codex_dir)?;
migrate_legacy_prompt_config_locked(&codex_dir)?;
let saved = super::store::provider_by_id_on_connection(&open_store()?, provider_id.trim())?
.ok_or_else(|| CodexxError::Config("供应商已不存在,请刷新列表后重试".into()))?;
let active_config = read_file_snapshot(&config_path(&codex_dir))?;
let rollback = persist_detected_live_custom_provider(&codex_dir)?;
let result = apply_saved_provider_locked(&saved, &codex_dir, active_config);
rollback_persisted_provider(result, rollback)
}
pub(crate) fn delete_saved_provider_inner(id: &str, config_dir: Option<String>) -> Result<()> {
@@ -1346,7 +1424,9 @@ pub(crate) fn delete_saved_provider_inner(id: &str, config_dir: Option<String>)
let conn = open_store()?;
let providers = list_saved_providers_on_connection(&conn)?;
if let Some(live) = detected_live_custom_provider(&codex_dir)? {
let active_ids = matching_saved_provider_ids_for_live(&live, &providers);
let active_ids = matching_saved_provider_ids_for_live_on_connection(
&conn, &codex_dir, &live, &providers,
)?;
let active_id = reconcile_active_provider_on_connection(&conn, &codex_dir, &active_ids)?;
if live.id == id || active_id.as_deref() == Some(id) {
return Err(CodexxError::Config(
@@ -1395,6 +1475,7 @@ requires_openai_auth = false
)),
wire_api: "responses".to_string(),
requires_openai_auth: false,
model_mappings: Vec::new(),
}
}
@@ -1820,9 +1901,10 @@ trust_level = "untrusted"
merged["model_providers"]["custom"]["request_max_retries"].as_integer(),
Some(9)
);
assert!(merged["model_providers"]["custom"]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
merged["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
Some("sk-b")
);
assert_eq!(api_key.as_deref(), Some("sk-b"));
assert!(!text.contains("sk-a"));
assert!(!text.contains("sk-stale-template"));
@@ -1878,6 +1960,7 @@ command = "docs-server"
toml_config: None,
wire_api: "responses".to_string(),
requires_openai_auth: false,
model_mappings: Vec::new(),
},
Some(codex_dir.display().to_string()),
)
@@ -1962,6 +2045,559 @@ command = "docs-server"
.expect("saved provider")
}
#[test]
fn active_provider_identity_survives_external_model_and_name_changes() {
let _guard = crate::app_db::test_db_guard();
let tag = 50_001;
let id = format!("runtime-edit-{tag}");
let dir = active_provider_test_dir("runtime-identity", tag);
let original = active_provider_fixture(tag, &id, "Original", "saved-model", "fixture-key");
save_provider_inner(original.clone()).unwrap();
remember_active_provider_on_connection(&open_store().unwrap(), &dir, &id).unwrap();
let external = active_provider_fixture(
tag,
"custom",
"Codex renamed",
"runtime-model",
"fixture-key",
);
write_active_provider_files(&dir, &external);
assert_eq!(
build_state_after_migration(dir.clone())
.unwrap()
.active_saved_provider_id
.as_deref(),
Some(id.as_str())
);
let updated = active_provider_fixture(tag, &id, "User renamed", "new-model", "fixture-key");
let result = save_active_provider_inner(updated, Some(dir.display().to_string())).unwrap();
assert_eq!(
result.state.active_saved_provider_id.as_deref(),
Some(id.as_str())
);
assert_eq!(saved_provider(&id).provider_name, "User renamed");
assert_eq!(saved_provider(&id).model, "new-model");
assert_eq!(
list_saved_providers_inner()
.unwrap()
.iter()
.filter(|provider| provider.base_url == original.base_url)
.count(),
1
);
delete_provider_inner(&id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn selected_record_wins_when_runtime_model_matches_a_same_api_sibling() {
let _guard = crate::app_db::test_db_guard();
let tag = 50_002;
let first_id = format!("runtime-first-{tag}");
let second_id = format!("runtime-second-{tag}");
let dir = active_provider_test_dir("runtime-sibling", tag);
let first = save_provider_inner(active_provider_fixture(
tag,
&first_id,
"First",
"first-model",
"shared-fixture-key",
))
.unwrap();
let second = save_provider_inner(active_provider_fixture(
tag,
&second_id,
"Second",
"second-model",
"shared-fixture-key",
))
.unwrap();
remember_active_provider_on_connection(&open_store().unwrap(), &dir, &first_id).unwrap();
let external = active_provider_fixture(
tag,
"custom",
"Second",
"second-model",
"shared-fixture-key",
);
write_active_provider_files(&dir, &external);
assert_eq!(
build_state_after_migration(dir.clone())
.unwrap()
.active_saved_provider_id
.as_deref(),
Some(first_id.as_str())
);
let before_config = fs::read(config_path(&dir)).unwrap();
let before_auth = fs::read(auth_path(&dir)).unwrap();
assert!(
save_active_provider_inner(second.clone(), Some(dir.display().to_string())).is_err()
);
assert_eq!(saved_provider(&first_id), first);
assert_eq!(saved_provider(&second_id), second);
assert_eq!(fs::read(config_path(&dir)).unwrap(), before_config);
assert_eq!(fs::read(auth_path(&dir)).unwrap(), before_auth);
let updated = active_provider_fixture(
tag,
&first_id,
"Edited first",
"edited-model",
"shared-fixture-key",
);
save_active_provider_inner(updated, Some(dir.display().to_string())).unwrap();
assert_eq!(saved_provider(&second_id), second);
assert_eq!(saved_provider(&first_id).provider_name, "Edited first");
delete_provider_inner(&first_id).unwrap();
delete_provider_inner(&second_id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn unique_route_recovers_an_older_missing_selection_after_model_change() {
let _guard = crate::app_db::test_db_guard();
let tag = 50_003;
let id = format!("runtime-recover-{tag}");
let dir = active_provider_test_dir("runtime-recover", tag);
save_provider_inner(active_provider_fixture(
tag,
&id,
"Saved",
"before",
"fixture-key",
))
.unwrap();
write_active_provider_files(
&dir,
&active_provider_fixture(tag, "custom", "External", "after", "fixture-key"),
);
let updated = active_provider_fixture(tag, &id, "Renamed", "saved-again", "fixture-key");
let result = save_active_provider_inner(updated, Some(dir.display().to_string())).unwrap();
assert_eq!(
result.state.active_saved_provider_id.as_deref(),
Some(id.as_str())
);
assert_eq!(saved_provider(&id).provider_name, "Renamed");
delete_provider_inner(&id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn switching_after_cancelled_runtime_edit_updates_original_without_a_duplicate() {
let _guard = crate::app_db::test_db_guard();
let tag = 50_004;
let id = format!("runtime-cancel-{tag}");
let dir = active_provider_test_dir("runtime-cancel", tag);
let mut mapped = active_provider_fixture(tag, &id, "Original", "before", "fixture-key");
mapped.model_mappings = vec![
super::super::model_catalog::ProviderModelMapping {
model: "before".into(),
display_name: "Saved model".into(),
context_window: Some(128000),
},
super::super::model_catalog::ProviderModelMapping {
model: "external-model".into(),
display_name: "Other supported model".into(),
context_window: Some(256000),
},
];
let original = save_provider_inner(mapped).unwrap();
remember_active_provider_on_connection(&open_store().unwrap(), &dir, &id).unwrap();
let external = active_provider_fixture(tag, "custom", "D", "external-model", "fixture-key");
write_active_provider_files(&dir, &external);
// Opening/cancelling an editor only reads state; it must not persist a second row.
let current = build_state_after_migration(dir.clone()).unwrap();
assert_eq!(
current.active_saved_provider_id.as_deref(),
Some(id.as_str())
);
assert_eq!(saved_provider(&id), original);
switch_official_provider_inner(Some(dir.display().to_string())).unwrap();
let after = list_saved_providers_inner()
.unwrap()
.into_iter()
.filter(|provider| provider.base_url == original.base_url)
.collect::<Vec<_>>();
assert_eq!(after.len(), 1);
assert_eq!(after[0].id, id);
assert_eq!(after[0].provider_name, "D");
assert_eq!(after[0].model, "external-model");
assert_eq!(after[0].model_mappings, original.model_mappings);
delete_provider_inner(&id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn failed_runtime_edit_restores_original_record_and_selection() {
let _guard = crate::app_db::test_db_guard();
let tag = 50_005;
let id = format!("runtime-failure-{tag}");
let dir = active_provider_test_dir("runtime-failure", tag);
let original = save_provider_inner(active_provider_fixture(
tag,
&id,
"Original",
"before",
"fixture-key",
))
.unwrap();
remember_active_provider_on_connection(&open_store().unwrap(), &dir, &id).unwrap();
write_active_provider_files(
&dir,
&active_provider_fixture(tag, "custom", "D", "external-model", "fixture-key"),
);
let before_config = fs::read(config_path(&dir)).unwrap();
let before_auth = fs::read(auth_path(&dir)).unwrap();
let error = save_active_provider_with_apply(
active_provider_fixture(tag, &id, "Must roll back", "requested", "new-fixture-key"),
Some(dir.display().to_string()),
|_, _, _| Err(CodexxError::Config("fixture apply failure".to_string())),
)
.unwrap_err();
assert!(error.to_string().contains("fixture apply failure"));
assert_eq!(saved_provider(&id), original);
assert_eq!(fs::read(config_path(&dir)).unwrap(), before_config);
assert_eq!(fs::read(auth_path(&dir)).unwrap(), before_auth);
assert_eq!(
build_state_after_migration(dir.clone())
.unwrap()
.active_saved_provider_id
.as_deref(),
Some(id.as_str())
);
switch_official_provider_inner(Some(dir.display().to_string())).unwrap();
assert_eq!(
list_saved_providers_inner()
.unwrap()
.iter()
.filter(|provider| provider.base_url == original.base_url)
.count(),
1
);
delete_provider_inner(&id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn ambiguous_runtime_route_does_not_create_or_overwrite_a_saved_profile_on_switch() {
let _guard = crate::app_db::test_db_guard();
let tag = 50_006;
let dir = active_provider_test_dir("runtime-ambiguous", tag);
let first = save_provider_inner(active_provider_fixture(
tag,
&format!("runtime-a-{tag}"),
"A",
"model-a",
"fixture-key",
))
.unwrap();
let second = save_provider_inner(active_provider_fixture(
tag,
&format!("runtime-b-{tag}"),
"B",
"model-b",
"fixture-key",
))
.unwrap();
write_active_provider_files(
&dir,
&active_provider_fixture(tag, "custom", "D", "external-model", "fixture-key"),
);
assert!(build_state_after_migration(dir.clone())
.unwrap()
.active_saved_provider_id
.is_none());
switch_official_provider_inner(Some(dir.display().to_string())).unwrap();
assert_eq!(saved_provider(&first.id), first);
assert_eq!(saved_provider(&second.id), second);
assert_eq!(
list_saved_providers_inner()
.unwrap()
.iter()
.filter(|provider| provider.base_url == first.base_url)
.count(),
2
);
delete_provider_inner(&first.id).unwrap();
delete_provider_inner(&second.id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn active_provider_edit_rejects_a_different_live_credential_without_side_effects() {
let _guard = crate::app_db::test_db_guard();
let tag = 50_007;
let id = format!("runtime-foreign-{tag}");
let dir = active_provider_test_dir("runtime-foreign", tag);
let original = save_provider_inner(active_provider_fixture(
tag,
&id,
"Original",
"model",
"stored-fixture-key",
))
.unwrap();
remember_active_provider_on_connection(&open_store().unwrap(), &dir, &id).unwrap();
write_active_provider_files(
&dir,
&active_provider_fixture(tag, "custom", "Original", "model", "foreign-fixture-key"),
);
let before_config = fs::read(config_path(&dir)).unwrap();
let before_auth = fs::read(auth_path(&dir)).unwrap();
assert!(
save_active_provider_inner(original.clone(), Some(dir.display().to_string())).is_err()
);
assert_eq!(saved_provider(&id), original);
assert_eq!(fs::read(config_path(&dir)).unwrap(), before_config);
assert_eq!(fs::read(auth_path(&dir)).unwrap(), before_auth);
assert_eq!(
super::super::selection::selected_provider_id_on_connection(
&open_store().unwrap(),
&dir
)
.unwrap()
.as_deref(),
Some(id.as_str())
);
delete_provider_inner(&id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn saved_provider_activation_supplies_its_key_and_real_model_catalog() {
let _guard = crate::app_db::test_db_guard();
let tag = 60_001;
let dir = active_provider_test_dir("catalog-auth", tag);
let id = format!("catalog-auth-{tag}");
let mut provider =
active_provider_fixture(tag, &id, "DeepSeek", "deepseek-chat", "fixture-current-key");
provider.toml_config.as_mut().unwrap().push_str(
r#"env_key = "STALE_ENV_KEY"
env_key_instructions = "Stale environment instructions"
auth = { command = "fixture-never-executed" }
http_headers = { Authorization = "Bearer stale", "X-Trace" = "keep-static" }
env_http_headers = { aUtHoRiZaTiOn = "STALE_AUTH", "X-Project" = "PROJECT_ENV" }
"#,
);
provider.model_mappings = vec![
super::super::model_catalog::ProviderModelMapping {
model: "deepseek-chat".into(),
display_name: "DeepSeek V3".into(),
context_window: Some(128000),
},
super::super::model_catalog::ProviderModelMapping {
model: "deepseek-reasoner".into(),
display_name: "DeepSeek R1".into(),
context_window: Some(256000),
},
];
save_provider_inner(provider.clone()).unwrap();
let result =
activate_saved_provider_inner(Some(dir.display().to_string()), id.clone()).unwrap();
assert_eq!(result.state.model.as_deref(), Some("deepseek-chat"));
let doc = fs::read_to_string(config_path(&dir))
.unwrap()
.parse::<DocumentMut>()
.unwrap();
let route = doc["model_providers"]["custom"].as_table().unwrap();
assert_eq!(
route["experimental_bearer_token"].as_str(),
Some("fixture-current-key")
);
for key in ["env_key", "env_key_instructions", "auth"] {
assert!(route.get(key).is_none());
}
assert!(route["http_headers"]
.as_table_like()
.unwrap()
.get("Authorization")
.is_none());
assert!(route["env_http_headers"]
.as_table_like()
.unwrap()
.get("aUtHoRiZaTiOn")
.is_none());
assert_eq!(
route["http_headers"]["X-Trace"].as_str(),
Some("keep-static")
);
assert_eq!(
route["env_http_headers"]["X-Project"].as_str(),
Some("PROJECT_ENV")
);
let pointer = PathBuf::from(doc["model_catalog_json"].as_str().unwrap());
assert!(pointer.is_file());
let catalog: Value = serde_json::from_slice(&fs::read(&pointer).unwrap()).unwrap();
let models = catalog["models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0]["slug"], "deepseek-chat");
assert_eq!(models[0]["display_name"], "DeepSeek V3");
assert_eq!(models[1]["slug"], "deepseek-reasoner");
assert_eq!(models[1]["context_window"], 256000);
assert!(!fs::read_to_string(&pointer)
.unwrap()
.contains("fixture-current-key"));
assert!(!saved_provider(&id)
.toml_config
.unwrap()
.contains("fixture-current-key"));
delete_provider_inner(&id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn switching_to_unmapped_or_official_provider_removes_only_owned_catalog_pointer() {
let _guard = crate::app_db::test_db_guard();
let tag = 60_002;
let dir = active_provider_test_dir("catalog-switch", tag);
let mut mapped = active_provider_fixture(
tag,
&format!("catalog-mapped-{tag}"),
"Mapped",
"deepseek-chat",
"fixture-mapped-key",
);
mapped.model_mappings = vec![super::super::model_catalog::ProviderModelMapping {
model: "deepseek-chat".into(),
display_name: "DeepSeek".into(),
context_window: None,
}];
let mapped = save_provider_inner(mapped).unwrap();
let plain = save_provider_inner(active_provider_fixture(
tag + 1,
&format!("catalog-plain-{tag}"),
"Plain",
"plain-model",
"fixture-plain-key",
))
.unwrap();
activate_saved_provider_inner(Some(dir.display().to_string()), mapped.id.clone()).unwrap();
let doc = fs::read_to_string(config_path(&dir))
.unwrap()
.parse::<DocumentMut>()
.unwrap();
let generated = PathBuf::from(doc["model_catalog_json"].as_str().unwrap());
activate_saved_provider_inner(Some(dir.display().to_string()), plain.id.clone()).unwrap();
let plain_live = fs::read_to_string(config_path(&dir))
.unwrap()
.parse::<DocumentMut>()
.unwrap();
assert!(
plain_live.get("model_catalog_json").is_none(),
"a sparse unmapped provider must not inherit the previous generated model catalog"
);
assert!(
generated.is_file(),
"catalog snapshots may remain for saved configurations and rollback"
);
activate_saved_provider_inner(Some(dir.display().to_string()), mapped.id.clone()).unwrap();
switch_official_provider_inner(Some(dir.display().to_string())).unwrap();
let official = fs::read_to_string(config_path(&dir))
.unwrap()
.parse::<DocumentMut>()
.unwrap();
assert!(official.get("model_catalog_json").is_none());
// Use a fresh CODEX_HOME so an unrelated older official snapshot cannot
// replace the user-supplied catalog before the cleanup logic sees it.
let custom_dir = active_provider_test_dir("catalog-user-pointer", 60_004);
let custom_catalog = custom_dir.join("my-custom-models.json");
fs::write(&custom_catalog, "{\"models\":[]}").unwrap();
let mut custom = plain.clone();
let mut template = custom
.toml_config
.as_ref()
.unwrap()
.parse::<DocumentMut>()
.unwrap();
template["model_catalog_json"] = value(custom_catalog.display().to_string());
custom.toml_config = Some(template.to_string());
save_provider_inner(custom).unwrap();
activate_saved_provider_inner(Some(custom_dir.display().to_string()), plain.id.clone())
.unwrap();
switch_official_provider_inner(Some(custom_dir.display().to_string())).unwrap();
let official_custom = fs::read_to_string(config_path(&custom_dir))
.unwrap()
.parse::<DocumentMut>()
.unwrap();
assert_eq!(
official_custom["model_catalog_json"].as_str(),
Some(custom_catalog.to_str().unwrap())
);
assert!(custom_catalog.is_file());
delete_provider_inner(&mapped.id).unwrap();
delete_provider_inner(&plain.id).unwrap();
fs::remove_dir_all(custom_dir).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn model_catalog_failure_rolls_back_activation_adoption_and_active_edits() {
let _guard = crate::app_db::test_db_guard();
let tag = 60_003;
let dir = active_provider_test_dir("catalog-failure", tag);
let original = save_provider_inner(active_provider_fixture(
tag,
&format!("catalog-original-{tag}"),
"Original",
"before",
"fixture-original-key",
))
.unwrap();
remember_active_provider_on_connection(&open_store().unwrap(), &dir, &original.id).unwrap();
let external = active_provider_fixture(
tag,
"custom",
"Codex rename",
"external-model",
"fixture-original-key",
);
write_active_provider_files(&dir, &external);
let before_config = fs::read(config_path(&dir)).unwrap();
let before_auth = fs::read(auth_path(&dir)).unwrap();
let mut target = active_provider_fixture(
tag + 1,
&format!("catalog-target-{tag}"),
"Target",
"deepseek-chat",
"fixture-target-key",
);
target.model_mappings = vec![super::super::model_catalog::ProviderModelMapping {
model: "deepseek-chat".into(),
display_name: "DeepSeek".into(),
context_window: None,
}];
let target = save_provider_inner(target).unwrap();
fs::write(dir.join(".codex-x"), "fixture blocks generated directory").unwrap();
assert!(
activate_saved_provider_inner(Some(dir.display().to_string()), target.id.clone())
.is_err()
);
assert_eq!(saved_provider(&original.id), original);
assert_eq!(saved_provider(&target.id), target);
let mut updated = original.clone();
updated.provider_name = "Must roll back".into();
updated.model_mappings = vec![super::super::model_catalog::ProviderModelMapping {
model: "before".into(),
display_name: "Mapped before".into(),
context_window: None,
}];
assert!(save_active_provider_inner(updated, Some(dir.display().to_string())).is_err());
assert_eq!(saved_provider(&original.id), original);
assert_eq!(fs::read(config_path(&dir)).unwrap(), before_config);
assert_eq!(fs::read(auth_path(&dir)).unwrap(), before_auth);
assert_eq!(
super::super::selection::selected_provider_id_on_connection(
&open_store().unwrap(),
&dir
)
.unwrap()
.as_deref(),
Some(original.id.as_str())
);
delete_provider_inner(&original.id).unwrap();
delete_provider_inner(&target.id).unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn active_provider_save_updates_one_record_and_hot_applies() {
let _db_guard = crate::app_db::test_db_guard();
@@ -1990,9 +2626,10 @@ command = "docs-server"
doc["mcp_servers"]["docs"]["command"].as_str(),
Some("docs-server")
);
assert!(doc["model_providers"]["custom"]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
Some("sk-after")
);
assert_eq!(saved_provider(&id).provider_name, "After");
let auth_after: Value = serde_json::from_str(
&fs::read_to_string(auth_path(&codex_dir)).expect("read official auth"),
@@ -2555,7 +3192,7 @@ experimental_bearer_token = "sk-external"
}
#[test]
fn detected_live_provider_does_not_overwrite_ambiguous_saved_copies() {
fn detected_live_provider_only_captures_runtime_config_after_explicit_copy_selection() {
let _db_guard = crate::app_db::test_db_guard();
let tag = 40_006;
let codex_dir = active_provider_test_dir("detected-ambiguous", tag);
@@ -2574,6 +3211,9 @@ experimental_bearer_token = "sk-external"
api_key: live.api_key.clone(),
})
.unwrap();
assert!(activated.state.active_saved_provider_id.is_none());
assert_eq!(saved_provider(&first.id), first);
assert_eq!(saved_provider(&second.id), second);
let selected = crate::finish_provider_selection(
activated,
crate::ActiveProviderSelectionUpdate::Set(second.id.clone()),
@@ -2586,7 +3226,27 @@ experimental_bearer_token = "sk-external"
switch_official_provider_inner(Some(codex_dir.display().to_string())).unwrap();
assert_eq!(saved_provider(&first.id), first);
assert_eq!(saved_provider(&second.id), second);
let retained = saved_provider(&second.id);
assert_eq!(retained.id, second.id);
assert_eq!(retained.provider_name, second.provider_name);
assert_eq!(retained.base_url, second.base_url);
assert_eq!(retained.model, second.model);
assert_eq!(retained.api_key, second.api_key);
assert_eq!(retained.model_mappings, second.model_mappings);
let config = retained
.toml_config
.as_ref()
.unwrap()
.parse::<DocumentMut>()
.unwrap();
assert_eq!(config["approval_policy"].as_str(), Some("never"));
assert_eq!(
config["mcp_servers"]["docs"]["command"].as_str(),
Some("docs-server")
);
assert!(config["model_providers"]["custom"]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
list_saved_providers_inner()
.unwrap()
+10 -9
View File
@@ -1,8 +1,10 @@
mod ccswitch;
mod connection;
mod live;
pub(crate) mod model_catalog;
mod official_auth;
pub(crate) mod official_profiles;
pub(crate) mod quota;
mod selection;
mod store;
@@ -26,9 +28,9 @@ pub(crate) use connection::{
};
pub(crate) use live::detected_live_custom_provider;
pub(crate) use live::{
build_provider_toml_draft_inner, delete_saved_provider_inner, save_active_provider_inner,
save_official_config_inner, save_provider_toml_config_inner, switch_provider_inner,
OfficialConfigInput, ProviderInput, ProviderTomlInput,
activate_saved_provider_inner, build_provider_toml_draft_inner, delete_saved_provider_inner,
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
};
#[cfg(test)]
pub(crate) use live::{
@@ -40,9 +42,7 @@ pub(crate) use live::{
pub(crate) use official_auth::{
capture_live_chatgpt_config, get_official_config_draft_inner, official_snapshot_path_for_test,
};
pub(crate) use official_auth::{
document_is_official, official_auth_available, OfficialConfigDraft,
};
pub(crate) use official_auth::{document_is_official, official_auth_available};
pub(crate) use selection::{
clear_active_provider_on_connection, clear_provider_selections_on_connection,
reconcile_active_provider_on_connection, remember_active_provider_on_connection,
@@ -56,9 +56,10 @@ pub(crate) use store::{
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
delete_provider_inner, duplicate_provider_inner, experimental_bearer_token_from_doc,
is_placeholder_provider, list_saved_providers_inner, list_saved_providers_on_connection,
matching_saved_provider_ids_for_live, normalize_saved_provider,
normalize_saved_provider_for_save, provider_template_from_document, reserved_codex_provider_id,
rollback_provider_store_inner, save_detected_provider_with_rollback_inner, save_provider_inner,
matching_saved_provider_ids_for_live, matching_saved_provider_ids_for_live_on_connection,
normalize_saved_provider, normalize_saved_provider_for_save, provider_template_from_document,
reserved_codex_provider_id, rollback_provider_store_inner,
save_detected_provider_with_rollback_inner, save_provider_inner,
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
ProviderUpsertKind, SavedProvider,
@@ -0,0 +1,712 @@
//! Provider-local model menus using Codex's external ModelsResponse catalog.
//! A display name never changes the model slug sent to the provider. Catalogs
//! are immutable so a failed live-config transaction can keep its old pointer.
use crate::error::{CodexxError, Result};
use crate::file_io::{ensure_directory, io_err, write_private_json};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use toml_edit::{value, DocumentMut};
const MAX_MAPPINGS: usize = 64;
const MAX_NAME_CHARS: usize = 200;
const MAX_CONTEXT_WINDOW: i64 = 10_000_000;
const DEFAULT_CONTEXT_WINDOW: i64 = 128_000;
const OWNED_DIRECTORY: &str = ".codex-x";
const CATALOG_DIRECTORY: &str = "model-catalogs";
const CATALOG_FIELD: &str = "model_catalog_json";
const OWNERSHIP_FIELD: &str = "_codex_x_model_catalog";
const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
const BASE_INSTRUCTIONS: &str = "You are a coding assistant working with the user in a shared workspace. Use the available tools to inspect and edit project files, follow the user's requirements, and verify your changes.";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProviderModelMapping {
pub(crate) model: String,
#[serde(default)]
pub(crate) display_name: String,
#[serde(default)]
pub(crate) context_window: Option<i64>,
}
fn validate_name(name: &str, label: &str) -> Result<String> {
let name = name.trim();
if name.is_empty() {
return Err(CodexxError::Config(format!("{label}不能为空")));
}
if name.chars().count() > MAX_NAME_CHARS || name.chars().any(char::is_control) {
return Err(CodexxError::Config(format!(
"{label}最多 {MAX_NAME_CHARS} 个字符,且不能包含控制字符"
)));
}
Ok(name.to_owned())
}
pub(crate) fn normalize_mappings(
rows: &[ProviderModelMapping],
default_model: &str,
) -> Result<Vec<ProviderModelMapping>> {
if rows.is_empty() {
return Ok(Vec::new());
}
if rows.len() > MAX_MAPPINGS {
return Err(CodexxError::Config(format!(
"最多添加 {MAX_MAPPINGS} 个模型"
)));
}
let default_model = validate_name(default_model, "默认模型 ID")?;
let mut seen = HashSet::new();
let mut normalized = Vec::new();
for row in rows {
let model = validate_name(&row.model, "模型 ID")?;
let display_name = if row.display_name.trim().is_empty() {
model.clone()
} else {
validate_name(&row.display_name, "模型显示名称")?
};
if row
.context_window
.is_some_and(|window| !(1..=MAX_CONTEXT_WINDOW).contains(&window))
{
return Err(CodexxError::Config(format!(
"模型上下文窗口须为 1 至 {MAX_CONTEXT_WINDOW} 之间的整数"
)));
}
if seen.insert(model.clone()) {
normalized.push(ProviderModelMapping {
model,
display_name,
context_window: row.context_window,
});
}
}
if !seen.contains(&default_model) {
if normalized.len() >= MAX_MAPPINGS {
return Err(CodexxError::Config(format!(
"模型列表需要包含默认模型,合计最多 {MAX_MAPPINGS} 个模型"
)));
}
normalized.insert(
0,
ProviderModelMapping {
display_name: default_model.clone(),
model: default_model,
context_window: None,
},
);
}
Ok(normalized)
}
fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context: i64) -> Value {
let context_window = mapping.context_window.unwrap_or(default_context);
// Schema: openai/codex rust-v0.153.4, protocol/src/openai_models.rs,
// ModelInfo + ModelsResponse. Keep legacy required fields for older Codex.
// Unlike cloning a GPT cache entry, this does not import proprietary model
// instructions, hosted tools, service tiers, vision, or reasoning promises.
json!({
"slug": mapping.model,
"display_name": mapping.display_name,
"description": mapping.display_name,
"base_instructions": BASE_INSTRUCTIONS,
"default_reasoning_level": null,
"supported_reasoning_levels": [],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": priority,
"additional_speed_tiers": [],
"service_tiers": [],
"availability_nux": null,
"upgrade": null,
"supports_reasoning_summaries": false,
"supports_reasoning_summary_parameter": false,
"default_reasoning_summary": "none",
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": { "mode": "bytes", "limit": 10000 },
"supports_parallel_tool_calls": false,
"supports_image_detail_original": false,
"context_window": context_window,
"max_context_window": context_window,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": ["text"],
"supports_search_tool": false,
"use_responses_lite": false
})
}
fn build_catalog(mappings: &[ProviderModelMapping], doc: &DocumentMut) -> Value {
let default_context = doc
.get("model_context_window")
.and_then(|item| item.as_integer())
.filter(|window| (1..=MAX_CONTEXT_WINDOW).contains(window))
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
json!({"models": mappings.iter().enumerate()
.map(|(priority, mapping)| model_entry(mapping, priority, default_context))
.collect::<Vec<_>>()})
}
fn is_link(metadata: &fs::Metadata) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
// Include junctions/reparse points, not only ordinary symlinks.
metadata.file_attributes() & 0x400 != 0
}
#[cfg(not(windows))]
{
metadata.file_type().is_symlink()
}
}
fn owned_catalog_directory(codex_dir: &Path) -> PathBuf {
codex_dir.join(OWNED_DIRECTORY).join(CATALOG_DIRECTORY)
}
fn catalog_filename(catalog: &Value) -> Result<String> {
let content = serde_json::to_vec(catalog)
.map_err(|_| CodexxError::Config("无法生成供应商模型目录".into()))?;
let mut digest = Sha256::new();
digest.update(b"codex-x-provider-model-catalog-v2\0");
digest.update(&content);
Ok(format!("{:x}.json", digest.finalize()))
}
fn has_verified_ownership(path: &Path, filename: &str) -> bool {
let Ok(file) = fs::File::open(path) else {
return false;
};
let Ok(metadata) = file.metadata() else {
return false;
};
if !metadata.is_file() || metadata.len() > MAX_CATALOG_BYTES {
return false;
}
let mut bytes = Vec::new();
if file
.take(MAX_CATALOG_BYTES + 1)
.read_to_end(&mut bytes)
.is_err()
|| bytes.len() as u64 > MAX_CATALOG_BYTES
{
return false;
}
let Ok(catalog) = serde_json::from_slice::<Value>(&bytes) else {
return false;
};
let Some(owner) = catalog.get(OWNERSHIP_FIELD) else {
return false;
};
let Some(provider_hash) = owner.get("provider_hash").and_then(Value::as_str) else {
return false;
};
owner.get("source").and_then(Value::as_str) == Some("codex-x")
&& owner.get("format").and_then(Value::as_u64) == Some(1)
&& provider_hash.len() == 64
&& provider_hash.bytes().all(|byte| byte.is_ascii_hexdigit())
&& catalog
.get("models")
.and_then(Value::as_array)
.is_some_and(|models| !models.is_empty() && models.len() <= MAX_MAPPINGS)
&& catalog_filename(&catalog).is_ok_and(|expected| expected == filename)
}
fn prepare_owned_directory(codex_dir: &Path) -> Result<PathBuf> {
ensure_directory(codex_dir)?;
// The user's CODEX_HOME may intentionally be a symlink/junction. Resolve
// that once, then reject links inside our own generated-file directory.
let mut directory = codex_dir
.canonicalize()
.map_err(|error| io_err(codex_dir, error))?;
for component in [OWNED_DIRECTORY, CATALOG_DIRECTORY] {
directory.push(component);
match fs::symlink_metadata(&directory) {
Ok(metadata) if is_link(&metadata) || !metadata.is_dir() => {
return Err(CodexxError::Config(
"模型目录被文件或链接占用,请检查 Codex-X 模型目录".into(),
));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
ensure_directory(&directory)?;
}
Err(error) => return Err(io_err(&directory, error)),
}
let metadata =
fs::symlink_metadata(&directory).map_err(|error| io_err(&directory, error))?;
if is_link(&metadata) || !metadata.is_dir() {
return Err(CodexxError::Config(
"模型目录在创建时发生变化,请重试".into(),
));
}
}
Ok(directory)
}
fn is_owned_pointer(codex_dir: &Path, pointer: &str) -> bool {
let pointer = Path::new(pointer);
// Never treat a traversal path as our generated pointer, even if part of
// the spelling happens to resemble the owned directory.
if pointer
.components()
.any(|part| matches!(part, Component::ParentDir))
{
return false;
}
let Some(name) = pointer.file_name().and_then(|name| name.to_str()) else {
return false;
};
let Some(hash) = name.strip_suffix(".json") else {
return false;
};
if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return false;
}
let candidate = if pointer.is_absolute() {
pointer.to_path_buf()
} else {
codex_dir.join(pointer)
};
let root = owned_catalog_directory(codex_dir);
let Some(parent) = candidate.parent() else {
return false;
};
let Some(owner_directory) = parent.parent() else {
return false;
};
if parent
.file_name()
.is_none_or(|name| name != CATALOG_DIRECTORY)
|| owner_directory
.file_name()
.is_none_or(|name| name != OWNED_DIRECTORY)
{
return false;
}
// Only the CODEX_HOME ancestor may be redirected. A link placed inside the
// app-owned path (or at the catalog itself) must not claim a user's target.
for directory in [parent, owner_directory] {
if fs::symlink_metadata(directory)
.is_ok_and(|metadata| is_link(&metadata) || !metadata.is_dir())
{
return false;
}
}
if fs::symlink_metadata(&candidate)
.is_ok_and(|metadata| is_link(&metadata) || !metadata.is_file())
{
return false;
}
let same_parent = match (parent.canonicalize(), root.canonicalize()) {
(Ok(parent), Ok(root)) => parent == root,
_ => parent == root,
};
// Retain support for legacy/stale pointers in the current owned directory.
// For another CODEX_HOME, require a marker and a digest covering the whole
// catalog; a similarly named user catalog must remain untouched.
same_parent || has_verified_ownership(&candidate, name)
}
pub(crate) fn prepare_model_catalog(
codex_dir: &Path,
provider_id: &str,
mappings: &[ProviderModelMapping],
model: &str,
doc: &mut DocumentMut,
) -> Result<()> {
let mappings = normalize_mappings(mappings, model)?;
if mappings.is_empty() {
if doc
.get(CATALOG_FIELD)
.and_then(|item| item.as_str())
.is_some_and(|pointer| is_owned_pointer(codex_dir, pointer))
{
doc.as_table_mut().remove(CATALOG_FIELD);
}
return Ok(());
}
let mut catalog = build_catalog(&mappings, doc);
catalog[OWNERSHIP_FIELD] = json!({
"source": "codex-x", "format": 1,
"provider_hash": format!("{:x}", Sha256::digest(provider_id.as_bytes()))
});
let filename = catalog_filename(&catalog)?;
let directory = prepare_owned_directory(codex_dir)?;
let path = directory.join(filename);
match fs::symlink_metadata(&path) {
Ok(metadata) => {
if is_link(&metadata) || !metadata.is_file() || metadata.len() > MAX_CATALOG_BYTES {
return Err(CodexxError::Config(
"已生成的模型目录文件无效,请检查后重试".into(),
));
}
let existing = fs::read(&path).map_err(|error| io_err(&path, error))?;
if serde_json::from_slice::<Value>(&existing).ok().as_ref() != Some(&catalog) {
return Err(CodexxError::Config(
"已生成的模型目录文件被修改,未覆盖原文件".into(),
));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
write_private_json(&path, &catalog)?
}
Err(error) => return Err(io_err(&path, error)),
}
// Change the caller's document only after a complete catalog is available.
// The caller owns the live config transaction; no auth/config files are
// read or written here, and models_cache.json is intentionally untouched.
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"codex-x-catalog-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).unwrap();
Self(path)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn mapping(model: &str, display_name: &str, context: Option<i64>) -> ProviderModelMapping {
ProviderModelMapping {
model: model.into(),
display_name: display_name.into(),
context_window: context,
}
}
fn path(doc: &DocumentMut) -> PathBuf {
PathBuf::from(doc[CATALOG_FIELD].as_str().unwrap())
}
fn catalog(doc: &DocumentMut) -> Value {
serde_json::from_slice(&fs::read(path(doc)).unwrap()).unwrap()
}
#[test]
fn normalization_preserves_real_models_and_adds_missing_default() {
let result = normalize_mappings(
&[
mapping(" vendor/model-A ", "菜单名称", Some(1_048_576)),
mapping("vendor/model-A", "duplicate", Some(128_000)),
mapping("vendor/model-a", "", None),
],
"deepseek-chat",
)
.unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0], mapping("deepseek-chat", "deepseek-chat", None));
assert_eq!(
result[1],
mapping("vendor/model-A", "菜单名称", Some(1_048_576))
);
assert_eq!(result[2].display_name, "vendor/model-a");
assert!(normalize_mappings(&[], "").unwrap().is_empty());
}
#[test]
fn rejects_invalid_ids_names_context_and_too_many_models() {
for invalid in ["", "model\nname", &"a".repeat(201)] {
assert!(normalize_mappings(&[mapping(invalid, "", None)], "default").is_err());
}
assert!(normalize_mappings(&[mapping("model", "bad\nname", None)], "model").is_err());
for invalid in [0, -1, MAX_CONTEXT_WINDOW + 1] {
assert!(normalize_mappings(&[mapping("model", "", Some(invalid))], "model").is_err());
}
let rows: Vec<_> = (0..64)
.map(|index| mapping(&format!("model-{index}"), "", None))
.collect();
assert_eq!(normalize_mappings(&rows, "model-0").unwrap().len(), 64);
assert!(normalize_mappings(&rows, "missing-default").is_err());
let mut excessive = rows;
excessive.push(mapping("model-64", "", None));
assert!(normalize_mappings(&excessive, "model-0").is_err());
}
#[test]
fn serialized_mapping_allows_omitted_display_name_and_context() {
let mapping: ProviderModelMapping =
serde_json::from_str(r#"{"model":"deepseek-chat"}"#).unwrap();
let normalized = normalize_mappings(&[mapping], "deepseek-chat").unwrap();
assert_eq!(
serde_json::to_value(&normalized[0]).unwrap(),
json!({"model":"deepseek-chat","displayName":"deepseek-chat","contextWindow":null})
);
}
#[test]
fn catalog_never_impersonates_gpt_or_copies_cached_capabilities() {
let fixture = Fixture::new();
let cache = json!({"models":[{"slug":"gpt-5.5","model_messages":{"instructions_template":"PRIVATE CACHED INSTRUCTIONS"},"apply_patch_tool_type":"freeform","experimental_supported_tools":["namespace-secret"],"supports_image_detail_original":true}]});
let cache_text = serde_json::to_vec(&cache).unwrap();
fs::write(fixture.0.join("models_cache.json"), &cache_text).unwrap();
fs::write(fixture.0.join("config.toml"), "original-config").unwrap();
fs::write(fixture.0.join("auth.json"), "original-auth").unwrap();
let mut doc = DocumentMut::new();
prepare_model_catalog(
&fixture.0,
"provider-a",
&[mapping("deepseek-chat", "DeepSeek 常用", Some(131072))],
"deepseek-chat",
&mut doc,
)
.unwrap();
let catalog = catalog(&doc);
let model = &catalog["models"][0];
assert_eq!(model["slug"], "deepseek-chat");
assert_eq!(model["display_name"], "DeepSeek 常用");
assert_eq!(model["context_window"], 131072);
assert_eq!(model["apply_patch_tool_type"], Value::Null);
assert_eq!(model["input_modalities"], json!(["text"]));
assert!(!catalog.to_string().contains("gpt-5.5"));
assert!(!catalog.to_string().contains("PRIVATE"));
assert!(!catalog.to_string().contains("namespace-secret"));
assert_eq!(
fs::read(fixture.0.join("models_cache.json")).unwrap(),
cache_text
);
assert_eq!(
fs::read_to_string(fixture.0.join("config.toml")).unwrap(),
"original-config"
);
assert_eq!(
fs::read_to_string(fixture.0.join("auth.json")).unwrap(),
"original-auth"
);
}
#[test]
fn providers_and_changed_content_have_distinct_immutable_catalogs() {
let fixture = Fixture::new();
let rows = [mapping("model", "Name", None)];
let mut first = DocumentMut::new();
let mut second = DocumentMut::new();
prepare_model_catalog(&fixture.0, "../../provider-a", &rows, "model", &mut first).unwrap();
let original_path = path(&first);
let original_bytes = fs::read(&original_path).unwrap();
prepare_model_catalog(&fixture.0, "provider-b", &rows, "model", &mut second).unwrap();
assert_ne!(original_path, path(&second));
assert!(
original_path.starts_with(owned_catalog_directory(&fixture.0.canonicalize().unwrap()))
);
assert!(!original_path.to_string_lossy().contains("provider-a"));
prepare_model_catalog(
&fixture.0,
"../../provider-a",
&[mapping("model", "Changed", None)],
"model",
&mut first,
)
.unwrap();
assert_ne!(original_path, path(&first));
assert_eq!(fs::read(original_path).unwrap(), original_bytes);
}
#[test]
fn unchanged_content_is_reused_and_tampered_file_is_not_overwritten() {
let fixture = Fixture::new();
let rows = [mapping("model", "Name", None)];
let mut doc = DocumentMut::new();
prepare_model_catalog(&fixture.0, "provider", &rows, "model", &mut doc).unwrap();
let generated = path(&doc);
let modified = fs::metadata(&generated).unwrap().modified().unwrap();
prepare_model_catalog(&fixture.0, "provider", &rows, "model", &mut doc).unwrap();
assert_eq!(
fs::metadata(&generated).unwrap().modified().unwrap(),
modified
);
fs::write(&generated, "keep this changed file").unwrap();
let before = doc.to_string();
assert!(prepare_model_catalog(&fixture.0, "provider", &rows, "model", &mut doc).is_err());
assert_eq!(doc.to_string(), before);
assert_eq!(
fs::read_to_string(generated).unwrap(),
"keep this changed file"
);
}
#[test]
fn disabling_removes_only_our_pointer_without_deleting_catalog() {
let fixture = Fixture::new();
let mut doc = DocumentMut::new();
prepare_model_catalog(
&fixture.0,
"provider",
&[mapping("model", "Name", None)],
"model",
&mut doc,
)
.unwrap();
let generated = path(&doc);
prepare_model_catalog(&fixture.0, "provider", &[], "model", &mut doc).unwrap();
assert!(doc.get(CATALOG_FIELD).is_none());
assert!(generated.is_file());
for custom in [
"custom-models.json",
".codex-x/model-catalogs/my-custom.json",
".codex-x/model-catalogs/../custom.json",
] {
doc[CATALOG_FIELD] = value(custom);
prepare_model_catalog(&fixture.0, "provider", &[], "model", &mut doc).unwrap();
assert_eq!(doc[CATALOG_FIELD].as_str(), Some(custom));
}
}
#[test]
fn relative_and_stale_owned_pointers_can_be_removed() {
let fixture = Fixture::new();
let mut doc = DocumentMut::new();
let pointer = format!(
"{OWNED_DIRECTORY}/{CATALOG_DIRECTORY}/{}.json",
"a".repeat(64)
);
doc[CATALOG_FIELD] = value(pointer);
prepare_model_catalog(&fixture.0, "provider", &[], "", &mut doc).unwrap();
assert!(doc.get(CATALOG_FIELD).is_none());
}
#[test]
fn disabling_after_switching_codex_home_clears_verified_generated_pointer() {
let first_home = Fixture::new();
let second_home = Fixture::new();
let mut doc = DocumentMut::new();
prepare_model_catalog(
&first_home.0,
"provider-a",
&[mapping("deepseek-v4-pro", "DeepSeek", None)],
"deepseek-v4-pro",
&mut doc,
)
.unwrap();
let generated = path(&doc);
let before = fs::read(&generated).unwrap();
prepare_model_catalog(&second_home.0, "provider-b", &[], "another-model", &mut doc)
.unwrap();
assert!(doc.get(CATALOG_FIELD).is_none());
assert_eq!(fs::read(&generated).unwrap(), before);
assert!(!owned_catalog_directory(&second_home.0).exists());
}
#[test]
fn official_cleanup_recognizes_other_home_but_preserves_unverified_user_catalog() {
let first_home = Fixture::new();
let second_home = Fixture::new();
let mut doc = DocumentMut::new();
prepare_model_catalog(
&first_home.0,
"provider-a",
&[mapping("deepseek-v4-pro", "DeepSeek", None)],
"deepseek-v4-pro",
&mut doc,
)
.unwrap();
let generated = path(&doc);
let original: Value = serde_json::from_slice(&fs::read(&generated).unwrap()).unwrap();
prepare_model_catalog(&second_home.0, "official", &[], "", &mut doc).unwrap();
assert!(doc.get(CATALOG_FIELD).is_none());
// A matching-looking name without the app's marker is user content.
let mut no_marker = original.clone();
no_marker.as_object_mut().unwrap().remove(OWNERSHIP_FIELD);
// A copied marker does not authorize a modified catalog either.
let mut modified = original;
modified["models"][0]["display_name"] = json!("User's own menu");
for unverified in [no_marker, modified] {
fs::write(&generated, serde_json::to_vec(&unverified).unwrap()).unwrap();
doc[CATALOG_FIELD] = value(generated.to_string_lossy().to_string());
prepare_model_catalog(&second_home.0, "official", &[], "", &mut doc).unwrap();
assert_eq!(path(&doc), generated);
assert_eq!(
serde_json::from_slice::<Value>(&fs::read(&generated).unwrap()).unwrap(),
unverified
);
}
}
#[test]
fn omitted_context_uses_explicit_live_context_or_neutral_default() {
let rows = [
mapping("model", "Model", None),
mapping("other", "Other", Some(64000)),
];
let mut doc: DocumentMut = "model_context_window = 1000000".parse().unwrap();
let catalog = build_catalog(&rows, &doc);
assert_eq!(catalog["models"][0]["context_window"], 1_000_000);
assert_eq!(catalog["models"][1]["context_window"], 64_000);
doc["model_context_window"] = value(-1);
assert_eq!(
build_catalog(&rows, &doc)["models"][0]["context_window"],
DEFAULT_CONTEXT_WINDOW
);
}
#[cfg(unix)]
#[test]
fn generated_files_are_private_and_owned_directory_links_are_rejected() {
use std::os::unix::fs::{symlink, PermissionsExt};
let fixture = Fixture::new();
let mut doc = DocumentMut::new();
let rows = [mapping("model", "Model", None)];
prepare_model_catalog(&fixture.0, "provider", &rows, "model", &mut doc).unwrap();
assert_eq!(
fs::metadata(path(&doc)).unwrap().permissions().mode() & 0o777,
0o600
);
let linked = Fixture::new();
symlink(&fixture.0, linked.0.join(OWNED_DIRECTORY)).unwrap();
let before = doc.to_string();
assert!(prepare_model_catalog(&linked.0, "provider", &rows, "model", &mut doc).is_err());
assert_eq!(doc.to_string(), before);
}
#[cfg(unix)]
#[test]
fn redirected_codex_home_is_supported_but_custom_file_links_are_not_removed() {
use std::os::unix::fs::symlink;
let fixture = Fixture::new();
let links = Fixture::new();
let linked_home = links.0.join("codex-home");
symlink(&fixture.0, &linked_home).unwrap();
let mut doc = DocumentMut::new();
prepare_model_catalog(
&linked_home,
"provider",
&[mapping("model", "Model", None)],
"model",
&mut doc,
)
.unwrap();
let generated = path(&doc);
prepare_model_catalog(&linked_home, "provider", &[], "model", &mut doc).unwrap();
assert!(doc.get(CATALOG_FIELD).is_none());
fs::remove_file(&generated).unwrap();
let custom = links.0.join("custom.json");
fs::write(&custom, "user catalog").unwrap();
symlink(&custom, &generated).unwrap();
doc[CATALOG_FIELD] = value(generated.to_string_lossy().to_string());
prepare_model_catalog(&fixture.0, "provider", &[], "model", &mut doc).unwrap();
assert!(doc.get(CATALOG_FIELD).is_some());
assert_eq!(fs::read_to_string(custom).unwrap(), "user catalog");
}
}
@@ -27,6 +27,7 @@ pub(crate) struct OfficialConfigCandidate {
pub(crate) source: String,
}
#[cfg(test)]
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialConfigDraft {
@@ -247,6 +248,14 @@ pub(crate) fn build_official_config_text(
doc.as_table_mut().remove("model");
}
super::model_catalog::prepare_model_catalog(
codex_dir,
"official",
&[],
model.unwrap_or_default(),
&mut doc,
)?;
Ok(doc.to_string())
}
@@ -274,6 +283,13 @@ pub(crate) fn validate_official_config_text(
doc["model"] = value(model);
}
}
super::model_catalog::prepare_model_catalog(
codex_dir,
"official",
&[],
model.unwrap_or_default(),
&mut doc,
)?;
let text = doc.to_string();
let model = string_value(&doc, "model");
Ok((text, model))
@@ -19,6 +19,7 @@ use crate::live_config::{
use crate::paths::normalized_path_scope;
use crate::state::{build_state_after_migration, ActionResult};
use crate::{now_rfc3339, resolve_codex_dir};
use base64::Engine;
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -34,6 +35,10 @@ pub(crate) struct OfficialProfileSummary {
pub(crate) provider_name: String,
pub(crate) model: Option<String>,
pub(crate) has_auth: bool,
pub(crate) has_owned_auth: bool,
pub(crate) email: Option<String>,
pub(crate) plan_type: Option<String>,
pub(crate) can_query_quota: bool,
pub(crate) is_default: bool,
pub(crate) is_current: bool,
}
@@ -67,6 +72,158 @@ pub(crate) struct OfficialProfileActionResult {
pub(crate) profile: OfficialProfileSummary,
}
// Native-only credentials: intentionally neither Debug nor Serialize. The
// network caller reads these while holding the short live-config lock, then
// drops that lock before making a request.
pub(crate) struct OfficialProfileQuotaCredentials {
pub(crate) access_token: String,
pub(crate) account_id: Option<String>,
pub(crate) email: Option<String>,
}
fn nonempty_auth_string(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToString::to_string)
}
fn display_email(value: Option<&Value>) -> Option<String> {
nonempty_auth_string(value).filter(|email| {
email.len() <= 320
&& email.contains('@')
&& !email
.chars()
.any(|character| character.is_control() || character.is_whitespace())
})
}
fn jwt_display_claims(token: Option<&Value>) -> Option<Value> {
let token = token?.as_str()?;
// Decode only a bounded payload. JWT claims are unverified display metadata,
// never the source of account IDs, credentials, or authorization decisions.
let mut parts = token.split('.');
let header = parts.next()?;
let payload = parts.next()?;
let signature = parts.next()?;
if header.is_empty()
|| payload.is_empty()
|| signature.is_empty()
|| parts.next().is_some()
|| payload.len() > 32 * 1024
{
return None;
}
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
serde_json::from_slice(&payload).ok()
}
fn display_plan_type(value: Option<&Value>) -> Option<String> {
let raw = value?.as_str()?;
if raw.len() > 64 || raw.chars().any(char::is_control) {
return None;
}
let plan = raw.trim();
(!plan.is_empty()
&& plan
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')))
.then(|| plan.to_string())
}
#[derive(Default)]
struct OfficialAuthDisplayMetadata {
email: Option<String>,
plan_type: Option<String>,
}
fn auth_display_metadata(auth: &Value) -> OfficialAuthDisplayMetadata {
let chatgpt_auth = is_chatgpt_auth(auth);
let mut metadata = OfficialAuthDisplayMetadata {
email: display_email(auth.get("email")),
plan_type: chatgpt_auth
.then(|| {
display_plan_type(auth.get("plan_type"))
.or_else(|| display_plan_type(auth.get("chatgpt_plan_type")))
})
.flatten(),
};
for token in [
auth.pointer("/tokens/id_token"),
auth.pointer("/tokens/access_token"),
] {
if metadata.email.is_some() && (metadata.plan_type.is_some() || !chatgpt_auth) {
break;
}
let Some(claims) = jwt_display_claims(token) else {
continue;
};
metadata.email = metadata.email.or_else(|| {
display_email(claims.get("email")).or_else(|| {
display_email(claims.pointer("/https:~1~1api.openai.com~1profile/email"))
})
});
if chatgpt_auth && metadata.plan_type.is_none() {
// Codex's login/token_data.rs reads this namespaced ID-token claim.
// Keep its raw identifier for display; it never grants access.
metadata.plan_type = display_plan_type(
claims.pointer("/https:~1~1api.openai.com~1auth/chatgpt_plan_type"),
);
}
}
metadata
}
fn auth_display_email(auth: &Value) -> Option<String> {
auth_display_metadata(auth).email
}
fn quota_access_token(auth: &Value) -> Option<String> {
is_chatgpt_auth(auth)
.then(|| nonempty_auth_string(auth.pointer("/tokens/access_token")))
.flatten()
}
fn profile_owned_auth(codex_dir: &Path, profile_id: &str) -> Result<Option<Value>> {
profile_name(codex_dir, profile_id)?;
let is_current = selected_profile_id(codex_dir)? == profile_id
&& live_config_is_official(codex_dir)
.map_err(|_| CodexxError::Config("无法确认当前官方登录状态,请刷新配置".to_string()))?;
if is_current {
let text = read_to_string_if_exists(&crate::auth_path(codex_dir))
.map_err(|_| CodexxError::Config("无法读取当前官方认证,请重新登录".to_string()))?;
// Live logout/refresh owns the current profile. Do not resurrect a stale
// snapshot or consult another account's historical recovery credentials.
return parse_auth(&text)
.map_err(|_| CodexxError::Config("当前官方认证无效,请重新登录".to_string()));
}
saved_official_profile_candidate(codex_dir, profile_id)
.map(|candidate| candidate.and_then(|candidate| candidate.auth))
.map_err(|_| {
CodexxError::Config("无法读取此官方配置的认证,请重新保存登录信息".to_string())
})
}
pub(crate) fn official_profile_quota_credentials(
codex_dir: &Path,
profile_id: &str,
) -> Result<OfficialProfileQuotaCredentials> {
let auth = profile_owned_auth(codex_dir, profile_id)?.ok_or_else(|| {
CodexxError::Config("此官方配置尚未登录,请先在 Codex 中登录".to_string())
})?;
let access_token = quota_access_token(&auth).ok_or_else(|| {
CodexxError::Config("此配置没有可用的 ChatGPT 登录凭据,无法查询订阅额度".to_string())
})?;
Ok(OfficialProfileQuotaCredentials {
access_token,
account_id: nonempty_auth_string(auth.pointer("/tokens/account_id")),
email: auth_display_email(&auth),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileRecord {
id: String,
@@ -223,12 +380,21 @@ fn detail(codex_dir: &Path, id: &str) -> Result<OfficialProfileDetail> {
.transpose()
.map_err(|error| CodexxError::Config(format!("读取官方认证失败: {error}")))?
.unwrap_or_default();
let owned_auth = profile_owned_auth(codex_dir, id).ok().flatten();
let metadata = owned_auth
.as_ref()
.map(auth_display_metadata)
.unwrap_or_default();
Ok(OfficialProfileDetail {
profile: OfficialProfileSummary {
id: id.to_string(),
provider_name,
model,
has_auth: !auth_json.is_empty(),
has_owned_auth: owned_auth.is_some(),
email: metadata.email,
plan_type: metadata.plan_type,
can_query_quota: owned_auth.as_ref().and_then(quota_access_token).is_some(),
is_default: id == DEFAULT_OFFICIAL_PROFILE_ID,
is_current: live_config_is_official(codex_dir)?
&& selected_profile_id(codex_dir)? == id,
@@ -272,11 +438,13 @@ pub(crate) fn list_official_profiles_inner(
let config = parse_toml_document(&config_path, &read_to_string_if_exists(&config_path)?)?;
let is_official = document_is_official(&config);
let live_model = crate::string_value(&config, "model");
let live_has_auth = is_official
&& read_to_string_if_exists(&crate::auth_path(&codex_dir))
.ok()
.and_then(|text| parse_auth(&text).ok().flatten())
.is_some();
let live_auth = is_official
.then(|| {
read_to_string_if_exists(&crate::auth_path(&codex_dir))
.ok()
.and_then(|text| parse_auth(&text).ok().flatten())
})
.flatten();
// Startup only needs metadata and the explicit profile snapshot. Do not
// call the editor/recovery candidate path: its legacy fallback scans the
// complete backup history and synthesizes full config/auth documents.
@@ -287,14 +455,24 @@ pub(crate) fn list_official_profiles_inner(
.ok()
.flatten();
let is_current = is_official && selected == id;
let owned_auth = if is_current {
live_auth.as_ref()
} else {
saved.as_ref().and_then(|saved| saved.auth.as_ref())
};
let metadata = owned_auth.map(auth_display_metadata).unwrap_or_default();
OfficialProfileSummary {
model: if is_current {
live_model.clone()
} else {
saved.as_ref().and_then(|saved| saved.model.clone())
},
has_auth: (is_current && live_has_auth)
has_auth: (is_current && live_auth.is_some())
|| saved.as_ref().is_some_and(|saved| saved.auth.is_some()),
has_owned_auth: owned_auth.is_some(),
email: metadata.email,
plan_type: metadata.plan_type,
can_query_quota: owned_auth.and_then(quota_access_token).is_some(),
is_default: id == DEFAULT_OFFICIAL_PROFILE_ID,
id,
provider_name,
@@ -666,34 +844,6 @@ pub(crate) fn reset_default_official_profile_inner(
Ok(result)
}
pub(crate) fn restore_default_official_profile_inner(
config_dir: Option<String>,
) -> Result<ActionResult> {
let codex_dir = resolve_codex_dir(config_dir)?;
ensure_directory(&codex_dir)?;
let _lock = acquire_live_config_lock(&codex_dir)?;
let candidate = if selected_profile_id(&codex_dir)? == DEFAULT_OFFICIAL_PROFILE_ID {
official_config_candidate(&codex_dir, true)?
} else {
saved_official_profile_candidate(&codex_dir, DEFAULT_OFFICIAL_PROFILE_ID)?
}
.ok_or_else(|| {
CodexxError::Config("未找到默认官方配置的可信快照,请新建配置后重新登录".to_string())
})?;
with_mutation(&codex_dir, |mutation| {
mutation.snapshot(DEFAULT_OFFICIAL_PROFILE_ID, || {
write_official_profile_snapshot(
&codex_dir,
DEFAULT_OFFICIAL_PROFILE_ID,
candidate.config_text,
candidate.model,
candidate.auth,
)
})?;
action(&codex_dir, "已还原默认官方配置".to_string(), None)
})
}
pub(crate) fn switch_official_profile_inner(
config_dir: Option<String>,
profile_id: String,
@@ -818,6 +968,374 @@ mod tests {
json!({"auth_mode":"chatgpt","tokens":{"account_id":format!("account-{label}"),"access_token":format!("access-{label}"),"refresh_token":format!("refresh-{label}")}})
}
fn display_jwt(claims: Value) -> String {
format!(
"e30.{}.fixture-signature",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string())
)
}
#[test]
fn official_profile_email_decodes_only_bounded_display_claims() {
let mut authentication = auth("email");
authentication["tokens"]["id_token"] =
json!(display_jwt(json!({"email":"id@example.test"})));
authentication["tokens"]["access_token"] = json!(display_jwt(
json!({"https://api.openai.com/profile":{"email":"access@example.test"}})
));
assert_eq!(
auth_display_email(&authentication).as_deref(),
Some("id@example.test")
);
authentication["email"] = json!(" direct@example.test ");
assert_eq!(
auth_display_email(&authentication).as_deref(),
Some("direct@example.test")
);
authentication["email"] = json!("\n");
authentication["tokens"]["id_token"] = json!("invalid-token");
assert_eq!(
auth_display_email(&authentication).as_deref(),
Some("access@example.test")
);
authentication["tokens"]["access_token"] = json!(format!("e30.{}.sig", "A".repeat(32769)));
assert!(auth_display_email(&authentication).is_none());
authentication["email"] = json!("unsafe\nemail@example.test");
assert!(auth_display_email(&authentication).is_none());
}
#[test]
fn official_profile_plan_type_uses_chatgpt_metadata_with_validated_fallbacks() {
let mut authentication = auth("plan");
authentication["tokens"]["id_token"] = json!(display_jwt(json!({
"email": "id@example.test",
"https://api.openai.com/auth": {"chatgpt_plan_type": "pro"}
})));
authentication["tokens"]["access_token"] = json!(display_jwt(json!({
"https://api.openai.com/auth": {"chatgpt_plan_type": "team"}
})));
let metadata = auth_display_metadata(&authentication);
assert_eq!(metadata.email.as_deref(), Some("id@example.test"));
assert_eq!(metadata.plan_type.as_deref(), Some("pro"));
authentication["plan_type"] = json!(" pro_lite ");
authentication["chatgpt_plan_type"] = json!("business");
assert_eq!(
auth_display_metadata(&authentication).plan_type.as_deref(),
Some("pro_lite")
);
authentication["plan_type"] = Value::Null;
assert_eq!(
auth_display_metadata(&authentication).plan_type.as_deref(),
Some("business")
);
authentication["chatgpt_plan_type"] = Value::Null;
authentication["tokens"]["id_token"] = json!("malformed-token");
assert_eq!(
auth_display_metadata(&authentication).plan_type.as_deref(),
Some("team")
);
authentication["plan_type"] = json!("Future_Plan-2");
assert_eq!(
auth_display_metadata(&authentication).plan_type.as_deref(),
Some("Future_Plan-2")
);
authentication["auth_mode"] = json!("apikey");
authentication["OPENAI_API_KEY"] = json!("synthetic-api-key");
assert!(auth_display_metadata(&authentication).plan_type.is_none());
assert!(auth_display_metadata(&auth("missing-plan"))
.plan_type
.is_none());
}
#[test]
fn official_profile_plan_type_rejects_unsafe_and_oversized_metadata() {
for value in [
json!(""),
json!(" \t"),
json!("\npro"),
json!("pro\n"),
json!("pro lite"),
json!("pro\u{202e}lite"),
json!("<script>"),
json!("p".repeat(65)),
json!(false),
json!(["pro"]),
json!({"plan":"pro"}),
] {
let mut authentication = auth("invalid-plan");
authentication["plan_type"] = value.clone();
authentication["tokens"]["id_token"] = json!(display_jwt(json!({
"https://api.openai.com/auth": {"chatgpt_plan_type": value}
})));
assert!(auth_display_metadata(&authentication).plan_type.is_none());
}
let valid_token =
display_jwt(json!({"https://api.openai.com/auth":{"chatgpt_plan_type":"pro"}}));
for token in [
format!("e30.{}.sig", "A".repeat(32769)),
format!("{valid_token}.extra"),
valid_token.replacen("e30.", ".", 1),
valid_token.replace("fixture-signature", ""),
display_jwt(json!(["pro"])),
display_jwt(
json!({"https://api.openai.com/auth":{"chatgpt_plan_type":"pro"},"oversized":"x".repeat(32768)}),
),
] {
let mut authentication = auth("invalid-jwt");
authentication["tokens"]["id_token"] = json!(token);
assert!(auth_display_metadata(&authentication).plan_type.is_none());
}
}
#[test]
fn official_quota_credentials_follow_owned_profiles_live_refresh_and_account_switches() {
let _guard = crate::app_db::test_db_guard();
let dir = prepare_home("quota-profile-isolation");
let mut first_auth = auth("a");
first_auth["tokens"]["id_token"] = json!(display_jwt(
json!({"email":"first@example.test", "account_id":"untrusted-claim", "https://api.openai.com/auth":{"chatgpt_plan_type":"pro"}})
));
write_json(&auth_path(&dir), &first_auth).unwrap();
let first = official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).unwrap();
assert_eq!(first.access_token, "access-a");
assert_eq!(first.account_id.as_deref(), Some("account-a"));
assert_eq!(first.email.as_deref(), Some("first@example.test"));
let mut second_auth = auth("b");
second_auth["email"] = json!("second@example.test");
second_auth["tokens"]["id_token"] = json!(display_jwt(
json!({"https://api.openai.com/auth":{"chatgpt_plan_type":"team"}})
));
let second = save(
&dir,
None,
"Second account",
Some(config("b")),
Some(second_auth.to_string()),
);
assert_eq!(second.profile.email.as_deref(), Some("second@example.test"));
assert_eq!(second.profile.plan_type.as_deref(), Some("team"));
assert!(second.profile.can_query_quota);
let inactive = official_profile_quota_credentials(&dir, &second.profile.id).unwrap();
assert_eq!(inactive.access_token, "access-b");
assert_eq!(inactive.account_id.as_deref(), Some("account-b"));
switch(&dir, &second.profile.id);
let mut refreshed = auth("b-refreshed");
refreshed["email"] = json!("refreshed@example.test");
refreshed["chatgpt_plan_type"] = json!("prolite");
write_json(&auth_path(&dir), &refreshed).unwrap();
assert_eq!(
official_profile_quota_credentials(&dir, &second.profile.id)
.unwrap()
.access_token,
"access-b-refreshed"
);
assert_eq!(
official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
.unwrap()
.access_token,
"access-a"
);
let summaries = list_official_profiles_inner(scope(&dir)).unwrap();
assert_eq!(
summaries
.iter()
.find(|profile| profile.is_default)
.unwrap()
.plan_type
.as_deref(),
Some("pro")
);
assert_eq!(
summaries
.iter()
.find(|profile| profile.is_current)
.unwrap()
.plan_type
.as_deref(),
Some("prolite")
);
assert_eq!(
detail(&dir, &second.profile.id)
.unwrap()
.profile
.plan_type
.as_deref(),
Some("prolite")
);
assert_eq!(
summaries
.iter()
.find(|profile| profile.is_current)
.unwrap()
.email
.as_deref(),
Some("refreshed@example.test")
);
let serialized = serde_json::to_string(&summaries).unwrap();
assert!(serialized.contains("\"planType\":\"prolite\""));
assert!(!serialized.contains("access-b-refreshed"));
assert!(!serialized.contains("refresh-b-refreshed"));
switch(&dir, DEFAULT_OFFICIAL_PROFILE_ID);
assert_eq!(
detail(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
.unwrap()
.profile
.plan_type
.as_deref(),
Some("pro")
);
assert_eq!(
detail(&dir, &second.profile.id)
.unwrap()
.profile
.plan_type
.as_deref(),
Some("prolite")
);
assert_eq!(
official_profile_quota_credentials(&dir, &second.profile.id)
.unwrap()
.email
.as_deref(),
Some("refreshed@example.test")
);
assert_eq!(
official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
.unwrap()
.email
.as_deref(),
Some("first@example.test")
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn official_quota_credentials_never_recover_history_or_a_proxy_login() {
let _guard = crate::app_db::test_db_guard();
let dir = prepare_home("quota-no-history");
let mut historical = auth("historical");
historical["plan_type"] = json!("pro");
write_json(&auth_path(&dir), &historical).unwrap();
crate::backups::create_backup(&dir, "quota-ignored-history").unwrap();
write_text(&config_path(&dir), "model_provider='custom'\nmodel='proxy-model'\n[model_providers.custom]\nname='Proxy'\nbase_url='https://proxy.example.test/v1'\nrequires_openai_auth=true\n").unwrap();
let mut foreign = auth("foreign-live");
foreign["email"] = json!("foreign@example.test");
foreign["plan_type"] = json!("team");
write_json(&auth_path(&dir), &foreign).unwrap();
assert!(official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).is_err());
let summaries = list_official_profiles_inner(scope(&dir)).unwrap();
assert!(summaries[0].email.is_none());
assert!(summaries[0].plan_type.is_none());
assert!(!summaries[0].can_query_quota);
assert!(
!official_snapshot_path_for_profile(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
.unwrap()
.exists()
);
let named = save(
&dir,
None,
"Missing snapshot",
Some(config("b")),
Some(auth("b").to_string()),
);
fs::remove_file(official_snapshot_path_for_profile(&dir, &named.profile.id).unwrap())
.unwrap();
assert!(official_profile_quota_credentials(&dir, &named.profile.id).is_err());
assert!(list_official_profiles_inner(scope(&dir))
.unwrap()
.iter()
.find(|profile| profile.id == named.profile.id)
.unwrap()
.plan_type
.is_none());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn official_quota_credentials_reject_logout_api_keys_and_missing_access_tokens() {
let _guard = crate::app_db::test_db_guard();
let dir = prepare_home("quota-auth-states");
let mut authentication = auth("signed-in");
authentication["plan_type"] = json!("pro");
write_json(&auth_path(&dir), &authentication).unwrap();
save(
&dir,
Some(DEFAULT_OFFICIAL_PROFILE_ID),
"Default",
None,
None,
);
fs::remove_file(auth_path(&dir)).unwrap();
let summary = list_official_profiles_inner(scope(&dir)).unwrap().remove(0);
assert!(
summary.has_auth,
"legacy saved-auth availability is retained"
);
assert!(
!summary.has_owned_auth,
"a logged-out current profile must show the signed-out badge"
);
assert!(!summary.can_query_quota);
assert!(summary.email.is_none());
assert!(summary.plan_type.is_none());
assert!(detail(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
.unwrap()
.profile
.plan_type
.is_none());
assert!(official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).is_err());
write_text(
&auth_path(&dir),
"{\"tokens\":\"sensitive-test-marker\",invalid-json}",
)
.unwrap();
let error = official_profile_quota_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID)
.err()
.unwrap()
.to_string();
assert!(!error.contains("sensitive-test-marker"));
write_json(&auth_path(&dir), &auth("a")).unwrap();
for (name, authentication) in [
(
"API key",
json!({"auth_mode":"apikey", "OPENAI_API_KEY":"sk-test-only"}),
),
(
"Refresh only",
json!({"auth_mode":"chatgpt", "tokens":{"refresh_token":"refresh-only"}}),
),
] {
let profile = save(
&dir,
None,
name,
Some(config("b")),
Some(authentication.to_string()),
);
assert!(profile.profile.has_auth);
assert!(profile.profile.has_owned_auth);
assert!(!profile.profile.can_query_quota);
assert!(official_profile_quota_credentials(&dir, &profile.profile.id).is_err());
}
let empty = save(
&dir,
None,
"Not signed in",
Some(config("empty")),
Some(String::new()),
);
assert!(!empty.profile.has_auth);
assert!(!empty.profile.has_owned_auth);
assert!(!empty.profile.can_query_quota);
assert!(empty.profile.email.is_none());
assert!(empty.profile.plan_type.is_none());
assert!(official_profile_quota_credentials(&dir, &empty.profile.id).is_err());
fs::remove_dir_all(dir).unwrap();
}
fn prepare_home(label: &str) -> PathBuf {
let dir = test_home(label);
write_text(&config_path(&dir), &config("a")).unwrap();
@@ -1176,7 +1694,7 @@ mod tests {
}
#[test]
fn official_profiles_default_reset_and_restore_do_not_overwrite_a_named_account() {
fn official_profiles_default_reset_does_not_overwrite_a_named_account() {
let _guard = crate::app_db::test_db_guard();
let dir = prepare_home("default-reset");
save(
@@ -1195,12 +1713,6 @@ mod tests {
);
switch(&dir, &b.profile.id);
write_json(&auth_path(&dir), &auth("b-latest")).unwrap();
let restored = restore_default_official_profile_inner(scope(&dir)).unwrap();
assert_eq!(
restored.state.active_official_profile_id.as_deref(),
Some(b.profile.id.as_str())
);
assert_eq!(live_auth(&dir), auth("b-latest"));
let reset = reset_default_official_profile_inner(super::super::live::OfficialConfigInput {
config_dir: scope(&dir),
model: None,
@@ -0,0 +1,946 @@
//! Read-only subscription quota lookup for one saved official login. Credentials
//! stay in native code; neither network errors nor response bodies are logged.
use super::official_profiles::{
official_profile_quota_credentials, OfficialProfileQuotaCredentials,
};
use crate::error::{CodexxError, Result};
use crate::live_config::acquire_live_config_lock;
use crate::remote::ensure_crypto_provider;
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;
const QUOTA_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
const RESET_CREDITS_URL: &str = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
// The file lock is nonblocking. Concurrent quota and credit queries share this
// brief gate while reading credentials, so they cannot fail each other's read.
// Neither this gate nor the file lock is held during a network request.
static QUERY_CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
#[derive(Clone, Copy)]
enum OfficialQuery {
Quota,
ResetCredits,
}
impl OfficialQuery {
fn url(self) -> &'static str {
match self {
Self::Quota => QUOTA_URL,
Self::ResetCredits => RESET_CREDITS_URL,
}
}
fn label(self) -> &'static str {
match self {
Self::Quota => "额度",
Self::ResetCredits => "重置次数",
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialResetCreditsSnapshot {
profile_id: String,
available_count: u32,
checked_at: String,
}
#[derive(Deserialize)]
struct RawResetCreditsResponse {
// Required and unsigned: unknown or invalid counts must never look like 0.
available_count: u32,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialQuotaSnapshot {
profile_id: String,
email: Option<String>,
plan_type: Option<String>,
limits: Vec<OfficialQuotaLimit>,
checked_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialQuotaLimit {
id: String,
name: Option<String>,
allowed: Option<bool>,
limit_reached: Option<bool>,
windows: Vec<OfficialQuotaWindow>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OfficialQuotaWindow {
id: String,
window_seconds: Option<i64>,
used_percent: Option<f64>,
remaining_percent: Option<f64>,
resets_at: Option<String>,
}
#[derive(Default, Deserialize)]
struct RawWindow {
limit_window_seconds: Option<i64>,
used_percent: Option<f64>,
reset_at: Option<i64>,
reset_after_seconds: Option<i64>,
}
#[derive(Default, Deserialize)]
struct RawLimit {
allowed: Option<bool>,
limit_reached: Option<bool>,
primary_window: Option<RawWindow>,
secondary_window: Option<RawWindow>,
}
#[derive(Deserialize)]
struct RawAdditionalLimit {
limit_name: Option<String>,
name: Option<String>,
metered_feature: Option<String>,
rate_limit: Option<RawLimit>,
#[serde(flatten)]
direct_limit: RawLimit,
}
#[derive(Deserialize)]
struct RawQuotaResponse {
account_id: Option<String>,
email: Option<String>,
plan_type: Option<String>,
rate_limit: Option<RawLimit>,
code_review_rate_limit: Option<RawLimit>,
additional_rate_limits: Option<Vec<Option<RawAdditionalLimit>>>,
}
fn config_error(message: &str) -> CodexxError {
CodexxError::Config(message.to_owned())
}
fn nonempty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn display_email(value: Option<&str>) -> Option<String> {
nonempty(value)
.filter(|email| email.len() <= 320 && email.contains('@'))
.filter(|email| {
!email
.chars()
.any(|character| character.is_control() || character.is_whitespace())
})
.map(str::to_owned)
}
fn quota_headers(credentials: &OfficialProfileQuotaCredentials) -> Result<HeaderMap> {
let token = nonempty(Some(&credentials.access_token))
.ok_or_else(|| config_error("此官方配置尚未登录,请先在 Codex 中登录"))?;
let mut authorization = HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|_| config_error("此官方配置的登录凭据无效,请重新登录"))?;
authorization.set_sensitive(true);
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, authorization);
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
if let Some(account_id) = nonempty(credentials.account_id.as_deref()) {
let mut account = HeaderValue::from_str(account_id)
.map_err(|_| config_error("此官方配置的账号标识无效,请重新登录"))?;
account.set_sensitive(true);
headers.insert("ChatGPT-Account-Id", account);
}
Ok(headers)
}
fn quota_client() -> Result<Client> {
ensure_crypto_provider();
Client::builder()
.https_only(true)
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(15))
.connect_timeout(Duration::from_secs(5))
.build()
.map_err(|_| config_error("官方账号查询客户端初始化失败"))
}
fn check_status(status: StatusCode, query: OfficialQuery) -> Result<()> {
if status.is_success() {
return Ok(());
}
let label = query.label();
let message = match status.as_u16() {
401 => format!("官方登录已失效,请重新登录此账号后查询{label}(HTTP 401)"),
403 => format!("无法访问官方{label}服务(HTTP 403),请稍后重试或检查网络"),
429 => format!("官方{label}查询过于频繁,请稍后重试(HTTP 429)"),
300..=399 => format!("官方{label}接口发生重定向,已停止请求,请稍后重试"),
code @ 500..=599 => format!("官方{label}服务暂不可用(HTTP {code}),请稍后重试"),
code => format!("官方{label}查询失败(HTTP {code}),请稍后重试"),
};
Err(CodexxError::Config(message))
}
fn read_bounded_body(reader: impl Read, content_length: Option<u64>) -> Result<Vec<u8>> {
if content_length.is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) {
return Err(config_error("官方查询响应过大,已停止读取"));
}
let mut bytes = Vec::new();
reader
.take(MAX_RESPONSE_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|_| config_error("读取官方查询响应失败,请稍后重试"))?;
if bytes.len() > MAX_RESPONSE_BYTES {
return Err(config_error("官方查询响应过大,已停止读取"));
}
Ok(bytes)
}
fn window(id: &str, raw: RawWindow, checked_at: DateTime<Utc>) -> OfficialQuotaWindow {
let used_percent = raw
.used_percent
.filter(|value| value.is_finite())
.map(|value| value.clamp(0.0, 100.0));
let resets_at = raw
.reset_at
.and_then(|seconds| DateTime::from_timestamp(seconds, 0))
.or_else(|| {
raw.reset_after_seconds
.filter(|seconds| *seconds >= 0)
.and_then(ChronoDuration::try_seconds)
.and_then(|duration| checked_at.checked_add_signed(duration))
})
.map(|time| time.to_rfc3339());
OfficialQuotaWindow {
id: id.to_owned(),
window_seconds: raw.limit_window_seconds.filter(|seconds| *seconds > 0),
used_percent,
remaining_percent: used_percent.map(|value| (100.0 - value).clamp(0.0, 100.0)),
resets_at,
}
}
fn limit(
id: String,
name: Option<String>,
raw: RawLimit,
checked_at: DateTime<Utc>,
) -> OfficialQuotaLimit {
let windows = [
("primary", raw.primary_window),
("secondary", raw.secondary_window),
]
.into_iter()
.filter_map(|(id, raw)| raw.map(|raw| window(id, raw, checked_at)))
.collect();
OfficialQuotaLimit {
id,
name,
allowed: raw.allowed,
limit_reached: raw.limit_reached,
windows,
}
}
fn parse_quota_response(
bytes: &[u8],
profile_id: &str,
credentials: &OfficialProfileQuotaCredentials,
checked_at: DateTime<Utc>,
) -> Result<OfficialQuotaSnapshot> {
let raw: RawQuotaResponse = serde_json::from_slice(bytes)
.map_err(|_| config_error("官方额度响应格式无法识别,请稍后重试"))?;
if nonempty(raw.account_id.as_deref())
.zip(nonempty(credentials.account_id.as_deref()))
.is_some_and(|(actual, expected)| actual != expected)
{
return Err(config_error(
"额度响应与所选官方账号不一致,请重新登录此配置后重试",
));
}
let mut limits = Vec::new();
if let Some(raw) = raw.rate_limit {
limits.push(limit("main".to_owned(), None, raw, checked_at));
}
if let Some(raw) = raw.code_review_rate_limit {
limits.push(limit("code_review".to_owned(), None, raw, checked_at));
}
for (index, additional) in raw
.additional_rate_limits
.unwrap_or_default()
.into_iter()
.enumerate()
{
let Some(additional) = additional else {
continue;
};
let name = nonempty(additional.limit_name.as_deref())
.or_else(|| nonempty(additional.name.as_deref()))
.or_else(|| nonempty(additional.metered_feature.as_deref()))
.map(str::to_owned);
limits.push(limit(
format!("additional-{index}"),
name,
additional.rate_limit.unwrap_or(additional.direct_limit),
checked_at,
));
}
Ok(OfficialQuotaSnapshot {
profile_id: profile_id.to_owned(),
email: display_email(raw.email.as_deref())
.or_else(|| display_email(credentials.email.as_deref())),
plan_type: nonempty(raw.plan_type.as_deref()).map(str::to_owned),
limits,
checked_at: checked_at.to_rfc3339(),
})
}
fn credential_fingerprint(credentials: &OfficialProfileQuotaCredentials) -> [u8; 32] {
let mut digest = Sha256::new();
// Length-delimit components so two different identities cannot have the
// same concatenated input. Only this digest is compared after the request.
for value in [
Some(credentials.access_token.as_str()),
credentials.account_id.as_deref(),
credentials.email.as_deref(),
] {
let bytes = value.unwrap_or_default().as_bytes();
digest.update((bytes.len() as u64).to_le_bytes());
digest.update(bytes);
}
digest.finalize().into()
}
fn parse_reset_credits_response(
bytes: &[u8],
profile_id: &str,
checked_at: DateTime<Utc>,
) -> Result<OfficialResetCreditsSnapshot> {
let raw: RawResetCreditsResponse = serde_json::from_slice(bytes)
.map_err(|_| config_error("官方重置次数响应格式无法识别,请稍后重试"))?;
Ok(OfficialResetCreditsSnapshot {
profile_id: profile_id.to_owned(),
available_count: raw.available_count,
checked_at: checked_at.to_rfc3339(),
})
}
fn ensure_same_credentials(
expected: [u8; 32],
current: &OfficialProfileQuotaCredentials,
) -> Result<()> {
if expected != credential_fingerprint(current) {
return Err(config_error("此官方账号的登录信息已变化,请重新刷新"));
}
Ok(())
}
fn fetch_official_response(
query: OfficialQuery,
credentials: &OfficialProfileQuotaCredentials,
) -> Result<Vec<u8>> {
let headers = quota_headers(credentials)?;
let label = query.label();
let response = quota_client()?
.get(query.url())
.headers(headers)
.send()
.map_err(|error| {
if error.is_timeout() {
config_error(&format!("官方{label}查询超时,请检查网络后重试"))
} else if error.is_connect() {
config_error(&format!("无法连接官方{label}接口,请检查网络或代理设置"))
} else {
config_error(&format!("官方{label}网络请求失败,请稍后重试"))
}
})?;
check_status(response.status(), query)?;
let length = response.content_length();
read_bounded_body(response, length)
}
fn read_query_credentials(
codex_dir: &Path,
profile_id: &str,
) -> Result<OfficialProfileQuotaCredentials> {
let _query_guard = QUERY_CREDENTIAL_LOCK
.lock()
.map_err(|_| config_error("官方账号查询暂不可用,请重试"))?;
let _file_guard = acquire_live_config_lock(codex_dir)
.map_err(|_| config_error("官方配置正在修改,请稍后刷新"))?;
official_profile_quota_credentials(codex_dir, profile_id)
}
fn read_official_snapshot<T>(
codex_dir: &Path,
profile_id: &str,
fetch: impl FnOnce(&OfficialProfileQuotaCredentials) -> Result<Vec<u8>>,
parse: impl FnOnce(&[u8], &str, &OfficialProfileQuotaCredentials, DateTime<Utc>) -> Result<T>,
) -> Result<T> {
let credentials = read_query_credentials(codex_dir, profile_id)?;
let fingerprint = credential_fingerprint(&credentials);
let bytes = fetch(&credentials)?;
let snapshot = parse(&bytes, profile_id, &credentials, Utc::now())?;
let current = read_query_credentials(codex_dir, profile_id)
.map_err(|_| config_error("此官方配置已变化或被删除,请重新选择账号后查询"))?;
ensure_same_credentials(fingerprint, &current)?;
Ok(snapshot)
}
pub(crate) fn get_official_profile_quota_inner(
config_dir: Option<String>,
profile_id: String,
) -> Result<OfficialQuotaSnapshot> {
let codex_dir = crate::resolve_codex_dir(config_dir)?;
read_official_snapshot(
&codex_dir,
profile_id.trim(),
|credentials| fetch_official_response(OfficialQuery::Quota, credentials),
parse_quota_response,
)
}
pub(crate) fn get_official_profile_reset_credits_inner(
config_dir: Option<String>,
profile_id: String,
) -> Result<OfficialResetCreditsSnapshot> {
let codex_dir = crate::resolve_codex_dir(config_dir)?;
read_official_snapshot(
&codex_dir,
profile_id.trim(),
|credentials| fetch_official_response(OfficialQuery::ResetCredits, credentials),
|bytes, profile_id, _, checked_at| {
parse_reset_credits_response(bytes, profile_id, checked_at)
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn credentials(account: Option<&str>) -> OfficialProfileQuotaCredentials {
OfficialProfileQuotaCredentials {
access_token: "synthetic-access-token".to_owned(),
account_id: account.map(str::to_owned),
email: Some("saved@example.test".to_owned()),
}
}
fn checked_at() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-09-09T03:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
fn parse(value: serde_json::Value, account: Option<&str>) -> OfficialQuotaSnapshot {
parse_quota_response(
&serde_json::to_vec(&value).unwrap(),
"official-profile-a",
&credentials(account),
checked_at(),
)
.unwrap()
}
#[test]
fn pro_primary_window_can_be_weekly_and_account_id_can_be_absent() {
let snapshot = parse(
json!({
"plan_type":"pro", "email":"active@example.test", "account_id":"server-account",
"rate_limit":{"allowed":true,"limit_reached":false,"primary_window":{"limit_window_seconds":604800,"used_percent":37.5,"reset_after_seconds":600}},
"additional_rate_limits":null,"code_review_rate_limit":null
}),
None,
);
assert_eq!(snapshot.profile_id, "official-profile-a");
assert_eq!(snapshot.email.as_deref(), Some("active@example.test"));
assert_eq!(snapshot.plan_type.as_deref(), Some("pro"));
assert_eq!(snapshot.limits.len(), 1);
let limit = &snapshot.limits[0];
assert_eq!(limit.id, "main");
assert_eq!(limit.allowed, Some(true));
assert_eq!(limit.windows[0].id, "primary");
assert_eq!(limit.windows[0].window_seconds, Some(604800));
assert_eq!(limit.windows[0].remaining_percent, Some(62.5));
assert_eq!(
limit.windows[0].resets_at.as_deref(),
Some("2026-09-09T03:10:00+00:00")
);
}
#[test]
fn team_two_windows_preserve_real_durations_and_absolute_reset_priority() {
let reset = checked_at().timestamp() + 3600;
let snapshot = parse(
json!({
"account_id":"team-a","plan_type":"team",
"rate_limit":{"allowed":false,"limit_reached":true,
"primary_window":{"limit_window_seconds":18000,"used_percent":100,"reset_at":reset,"reset_after_seconds":20},
"secondary_window":{"limit_window_seconds":604800,"used_percent":23.25}}
}),
Some("team-a"),
);
let limit = &snapshot.limits[0];
assert_eq!(limit.windows.len(), 2);
assert_eq!(limit.limit_reached, Some(true));
assert_eq!(limit.windows[0].window_seconds, Some(18000));
assert_eq!(limit.windows[0].remaining_percent, Some(0.0));
assert_eq!(
limit.windows[0].resets_at.as_deref(),
Some("2026-09-09T04:00:00+00:00")
);
assert_eq!(limit.windows[1].window_seconds, Some(604800));
assert_eq!(limit.windows[1].remaining_percent, Some(76.75));
assert!(limit.windows[1].resets_at.is_none());
}
#[test]
fn code_review_and_additional_features_remain_separate_limits() {
let snapshot = parse(
json!({
"rate_limit":null,
"code_review_rate_limit":{"allowed":true,"primary_window":{"used_percent":10,"limit_window_seconds":604800}},
"additional_rate_limits":[null,
{"limit_name":"Codex Spark","metered_feature":"codex_bengal","rate_limit":{"allowed":true,"primary_window":{"used_percent":40,"limit_window_seconds":18000}}},
{"metered_feature":"future_feature","allowed":false,"limit_reached":true,"primary_window":{"used_percent":100,"limit_window_seconds":86400}}]
}),
None,
);
assert_eq!(snapshot.limits.len(), 3);
assert_eq!(snapshot.limits[0].id, "code_review");
assert_eq!(snapshot.limits[1].id, "additional-1");
assert_eq!(snapshot.limits[1].name.as_deref(), Some("Codex Spark"));
assert_eq!(snapshot.limits[2].name.as_deref(), Some("future_feature"));
assert_eq!(snapshot.limits[2].allowed, Some(false));
assert_eq!(snapshot.limits[2].windows[0].window_seconds, Some(86400));
}
#[test]
fn missing_usage_is_unknown_instead_of_zero_or_full_remaining() {
let snapshot = parse(
json!({"rate_limit":{"primary_window":{"limit_window_seconds":18000}}}),
None,
);
let window = &snapshot.limits[0].windows[0];
assert!(window.used_percent.is_none());
assert!(window.remaining_percent.is_none());
assert!(snapshot.limits[0].allowed.is_none());
assert!(snapshot.limits[0].limit_reached.is_none());
let empty = parse(json!({}), None);
assert!(empty.limits.is_empty());
}
#[test]
fn finite_percentages_are_clamped_and_nonfinite_values_stay_unknown() {
for (used, expected) in [
(-20.0, Some(0.0)),
(130.0, Some(100.0)),
(f64::NAN, None),
(f64::INFINITY, None),
] {
let value = window(
"primary",
RawWindow {
used_percent: Some(used),
..Default::default()
},
checked_at(),
);
assert_eq!(value.used_percent, expected);
assert_eq!(value.remaining_percent, expected.map(|used| 100.0 - used));
}
}
#[test]
fn invalid_reset_and_window_numbers_do_not_panic_or_invent_dates() {
let value = window(
"primary",
RawWindow {
limit_window_seconds: Some(-1),
reset_at: Some(i64::MAX),
reset_after_seconds: Some(i64::MAX),
..Default::default()
},
checked_at(),
);
assert!(value.window_seconds.is_none());
assert!(value.resets_at.is_none());
let negative = window(
"primary",
RawWindow {
reset_after_seconds: Some(-10),
..Default::default()
},
checked_at(),
);
assert!(negative.resets_at.is_none());
let now = window(
"primary",
RawWindow {
reset_after_seconds: Some(0),
..Default::default()
},
checked_at(),
);
assert_eq!(now.resets_at.as_deref(), Some("2026-09-09T03:00:00+00:00"));
}
#[test]
fn different_nonempty_response_account_is_rejected_without_identifiers_in_error() {
let error = parse_quota_response(
br#"{"account_id":"other-private-account"}"#,
"official-profile-a",
&credentials(Some("selected-private-account")),
checked_at(),
)
.unwrap_err()
.to_string();
assert!(error.contains("不一致"));
assert!(!error.contains("private-account"));
assert!(!error.contains("synthetic-access-token"));
assert!(
parse(json!({"account_id":null}), Some("selected-private-account"))
.limits
.is_empty()
);
}
#[test]
fn email_uses_response_first_then_safe_saved_display_metadata() {
assert_eq!(
parse(json!({"email":"response@example.test"}), None)
.email
.as_deref(),
Some("response@example.test")
);
assert_eq!(
parse(json!({"email":"bad\n@example.test"}), None)
.email
.as_deref(),
Some("saved@example.test")
);
assert_eq!(
parse(json!({}), None).email.as_deref(),
Some("saved@example.test")
);
}
#[test]
fn request_has_only_fixed_https_endpoint_and_sensitive_auth_headers() {
let request = quota_client()
.unwrap()
.get(QUOTA_URL)
.headers(quota_headers(&credentials(Some("account-a"))).unwrap())
.build()
.unwrap();
assert_eq!(request.method(), reqwest::Method::GET);
assert_eq!(request.url().as_str(), QUOTA_URL);
assert_eq!(request.url().host_str(), Some("chatgpt.com"));
assert_eq!(request.url().scheme(), "https");
assert!(request.headers()[AUTHORIZATION].is_sensitive());
assert_eq!(
request.headers()[AUTHORIZATION],
"Bearer synthetic-access-token"
);
assert!(request.headers()["chatgpt-account-id"].is_sensitive());
assert_eq!(request.headers()["chatgpt-account-id"], "account-a");
assert_eq!(request.headers()[ACCEPT], "application/json");
assert_eq!(request.headers()[USER_AGENT], "codex-cli");
assert!(!format!("{request:?}").contains("synthetic-access-token"));
assert!(!quota_headers(&credentials(None))
.unwrap()
.contains_key("chatgpt-account-id"));
let credits_request = quota_client()
.unwrap()
.get(OfficialQuery::ResetCredits.url())
.headers(quota_headers(&credentials(Some("account-a"))).unwrap())
.build()
.unwrap();
assert_eq!(credits_request.method(), reqwest::Method::GET);
assert_eq!(credits_request.url().as_str(), RESET_CREDITS_URL);
assert_eq!(credits_request.url().scheme(), "https");
assert_eq!(credits_request.url().host_str(), Some("chatgpt.com"));
assert!(credits_request.headers()[AUTHORIZATION].is_sensitive());
assert!(credits_request.headers()["chatgpt-account-id"].is_sensitive());
assert!(!format!("{credits_request:?}").contains("synthetic-access-token"));
}
#[test]
fn empty_or_injected_credentials_fail_without_revealing_content() {
let mut empty = credentials(None);
empty.access_token = " ".to_owned();
assert!(quota_headers(&empty)
.unwrap_err()
.to_string()
.contains("尚未登录"));
let mut invalid = credentials(None);
invalid.access_token = "private-secret\r\nInjected: true".to_owned();
let error = quota_headers(&invalid).unwrap_err().to_string();
assert!(!error.contains("private-secret"));
assert!(!error.contains("Injected"));
invalid = credentials(Some("private-account\r\nInjected: true"));
let error = quota_headers(&invalid).unwrap_err().to_string();
assert!(!error.contains("private-account"));
assert!(error.contains("账号标识无效"));
}
#[test]
fn error_statuses_have_safe_actionable_messages() {
for query in [OfficialQuery::Quota, OfficialQuery::ResetCredits] {
assert!(check_status(StatusCode::OK, query).is_ok());
for (status, expected) in [
(401, "重新登录"),
(403, "检查网络"),
(429, "过于频繁"),
(302, "重定向"),
(503, "HTTP 503"),
] {
let error = check_status(StatusCode::from_u16(status).unwrap(), query)
.unwrap_err()
.to_string();
assert!(error.contains(expected));
assert!(error.contains(query.label()));
}
}
}
#[test]
fn body_limit_applies_even_without_or_with_false_content_length() {
assert_eq!(read_bounded_body(&b"{}"[..], Some(2)).unwrap(), b"{}");
let large = vec![b'x'; MAX_RESPONSE_BYTES + 1];
for content_length in [None, Some(1), Some(large.len() as u64)] {
assert!(read_bounded_body(&large[..], content_length)
.unwrap_err()
.to_string()
.contains("响应过大"));
}
}
#[test]
fn malformed_response_never_enters_error_text() {
let error = parse_quota_response(
b"server accidentally returned private-secret-token",
"official-profile-a",
&credentials(None),
checked_at(),
)
.unwrap_err()
.to_string();
assert!(error.contains("格式无法识别"));
assert!(!error.contains("private-secret-token"));
}
#[test]
fn credential_change_rejects_stale_result_for_token_account_or_email() {
let original = credentials(Some("account-a"));
let fingerprint = credential_fingerprint(&original);
assert!(ensure_same_credentials(fingerprint, &credentials(Some("account-a"))).is_ok());
for field in ["token", "account", "email"] {
let mut changed = credentials(Some("account-a"));
match field {
"token" => changed.access_token = "private-replacement-token".into(),
"account" => changed.account_id = Some("account-b".into()),
_ => changed.email = Some("changed@example.test".into()),
}
let error = ensure_same_credentials(fingerprint, &changed)
.unwrap_err()
.to_string();
assert!(error.contains("登录信息已变化"));
assert!(!error.contains("private-replacement-token"));
assert!(!error.contains("account-b"));
assert!(!error.contains("changed@example.test"));
}
}
#[test]
fn serialized_snapshot_contains_only_display_fields_and_no_credentials() {
let snapshot = parse(
json!({"plan_type":"team","rate_limit":{"primary_window":{"used_percent":40,"limit_window_seconds":18000}}}),
Some("account-a"),
);
let value = serde_json::to_value(&snapshot).unwrap();
assert_eq!(value["profileId"], "official-profile-a");
assert_eq!(value["planType"], "team");
assert_eq!(value["limits"][0]["windows"][0]["remainingPercent"], 60.0);
let serialized = value.to_string();
assert!(!serialized.contains("access_token"));
assert!(!serialized.contains("synthetic-access-token"));
assert!(!serialized.contains("account-a"));
}
#[test]
fn reset_credits_uses_available_count_and_returns_no_credit_details() {
let raw = json!({
"available_count": 3,
"total_earned_count": 0,
"credits": [{"id":"private-credit-id", "status":"available", "title":"private-credit-title"}],
"immediate_reset_purchase_eligible": true
});
let snapshot = parse_reset_credits_response(
&serde_json::to_vec(&raw).unwrap(),
"official-profile-a",
checked_at(),
)
.unwrap();
assert_eq!(
serde_json::to_value(snapshot).unwrap(),
json!({
"profileId":"official-profile-a",
"availableCount":3,
"checkedAt":"2026-09-09T03:00:00+00:00"
})
);
let zero = parse_reset_credits_response(
br#"{"available_count":0,"credits":[{"status":"available"}]}"#,
"official-profile-a",
checked_at(),
)
.unwrap();
assert_eq!(zero.available_count, 0);
}
#[test]
fn reset_credits_unknown_or_invalid_counts_are_not_reported_as_zero() {
for raw in [
"{}",
"null",
r#"{"available_count":null}"#,
r#"{"available_count":-1}"#,
r#"{"available_count":1.5}"#,
r#"{"available_count":"3"}"#,
r#"{"available_count":true}"#,
r#"{"available_count":4294967296}"#,
"private-malformed-response",
] {
let error =
parse_reset_credits_response(raw.as_bytes(), "official-profile-a", checked_at())
.unwrap_err()
.to_string();
assert!(error.contains("重置次数响应格式无法识别"));
assert!(!error.contains("private-malformed-response"));
}
}
fn query_fixture(label: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"codex-x-official-query-{label}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
crate::file_io::write_text(
&crate::config_path(&dir),
"model_provider = \"openai\"\nmodel = \"synthetic-model\"\n",
)
.unwrap();
crate::file_io::write_json(
&crate::auth_path(&dir),
&json!({
"auth_mode":"chatgpt", "tokens":{
"access_token":"synthetic-query-token", "account_id":"synthetic-account"
}
}),
)
.unwrap();
dir
}
#[test]
fn quota_and_reset_queries_overlap_without_holding_the_live_config_lock() {
use super::super::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID;
use std::sync::mpsc;
let _db_guard = crate::app_db::test_db_guard();
let dir = query_fixture("parallel");
// Initialize the test database before starting the two independent reads.
read_query_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).unwrap();
let (started_tx, started_rx) = mpsc::channel();
let (quota_release_tx, quota_release_rx) = mpsc::channel();
let (credits_release_tx, credits_release_rx) = mpsc::channel();
std::thread::scope(|scope| {
let quota_started = started_tx.clone();
let quota_dir = &dir;
let quota = scope.spawn(move || {
read_official_snapshot(
quota_dir,
DEFAULT_OFFICIAL_PROFILE_ID,
|_| {
quota_started.send("quota").unwrap();
quota_release_rx
.recv_timeout(Duration::from_secs(5))
.unwrap();
Ok(b"{}".to_vec())
},
parse_quota_response,
)
});
let credits_dir = &dir;
let credits = scope.spawn(move || {
read_official_snapshot(
credits_dir,
DEFAULT_OFFICIAL_PROFILE_ID,
|_| {
started_tx.send("credits").unwrap();
credits_release_rx
.recv_timeout(Duration::from_secs(5))
.unwrap();
Ok(br#"{"available_count":3}"#.to_vec())
},
|bytes, id, _, time| parse_reset_credits_response(bytes, id, time),
)
});
// Both requests must start before either can finish. A lock held over
// the simulated network wait would make this fail instead of hang.
let first = started_rx.recv_timeout(Duration::from_secs(3)).unwrap();
let second = started_rx.recv_timeout(Duration::from_secs(3)).unwrap();
assert_ne!(first, second);
drop(acquire_live_config_lock(&dir).unwrap());
quota_release_tx.send(()).unwrap();
credits_release_tx.send(()).unwrap();
assert_eq!(
quota.join().unwrap().unwrap().profile_id,
DEFAULT_OFFICIAL_PROFILE_ID
);
assert_eq!(credits.join().unwrap().unwrap().available_count, 3);
});
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn reset_query_rejects_login_replaced_during_request() {
use super::super::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID;
let _db_guard = crate::app_db::test_db_guard();
let dir = query_fixture("changed-login");
let error = read_official_snapshot(
&dir, DEFAULT_OFFICIAL_PROFILE_ID,
|_| {
let _guard = acquire_live_config_lock(&dir).unwrap();
crate::file_io::write_json(&crate::auth_path(&dir), &json!({
"auth_mode":"chatgpt", "tokens":{
"access_token":"synthetic-replacement-token", "account_id":"synthetic-account"
}
})).unwrap();
Ok(br#"{"available_count":3}"#.to_vec())
},
|bytes, id, _, time| parse_reset_credits_response(bytes, id, time),
).unwrap_err().to_string();
assert!(error.contains("登录信息已变化"));
assert!(!error.contains("synthetic-replacement-token"));
std::fs::remove_dir_all(dir).unwrap();
}
}
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
use std::collections::HashSet;
use std::path::Path;
fn selected_provider_id_on_connection(
pub(super) fn selected_provider_id_on_connection(
conn: &Connection,
codex_dir: &Path,
) -> Result<Option<String>> {
+243 -13
View File
@@ -17,6 +17,8 @@ pub(crate) struct SavedProvider {
pub(crate) toml_config: Option<String>,
pub(crate) wire_api: String,
pub(crate) requires_openai_auth: bool,
#[serde(default)]
pub(crate) model_mappings: Vec<super::model_catalog::ProviderModelMapping>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -282,6 +284,62 @@ pub(crate) fn matching_saved_provider_ids_for_live(
.collect()
}
fn same_live_provider_route(live: &SavedProvider, saved: &SavedProvider) -> bool {
if !same_provider_endpoint(live, saved) {
return false;
}
match (
effective_provider_api_key(live),
effective_provider_api_key(saved),
) {
(Some(live_key), Some(saved_key)) => live_key == saved_key,
// Missing live authentication is tolerated for legacy providers, but
// without credentials a changed name is not enough evidence to adopt it.
_ => {
normalized_provider_name(&live.provider_name)
== normalized_provider_name(&saved.provider_name)
}
}
}
/// A saved record keeps its identity when Codex changes the runtime model or
/// display name. Prefer the remembered record only while its route/credential
/// still matches; otherwise fall back to the existing profile matching rules.
/// This lookup never changes a selection or writes a provider.
pub(crate) fn matching_saved_provider_ids_for_live_on_connection(
conn: &Connection,
codex_dir: &std::path::Path,
live: &SavedProvider,
providers: &[SavedProvider],
) -> Result<Vec<String>> {
if let Some(selected) = super::selection::selected_provider_id_on_connection(conn, codex_dir)? {
if providers
.iter()
.any(|provider| provider.id == selected && same_live_provider_route(live, provider))
{
return Ok(vec![selected]);
}
}
let matches = matching_saved_provider_ids_for_live(live, providers)
.into_iter()
.filter(|id| {
providers
.iter()
.any(|provider| &provider.id == id && same_live_provider_route(live, provider))
})
.collect::<Vec<_>>();
if !matches.is_empty() {
return Ok(matches);
}
// Several same-API records remain ambiguous without a remembered ID. Return
// all candidates so callers can leave them alone rather than create a copy.
Ok(providers
.iter()
.filter(|provider| same_live_provider_route(live, provider))
.map(|provider| provider.id.clone())
.collect())
}
#[cfg(test)]
fn unique_saved_provider_id_for_live(
live: &SavedProvider,
@@ -301,6 +359,13 @@ fn saved_provider_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SavedPro
toml_config: row.get(5)?,
wire_api: row.get(6)?,
requires_openai_auth: row.get::<_, i64>(7)? != 0,
model_mappings: serde_json::from_str(&row.get::<_, String>(8)?).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(
8,
rusqlite::types::Type::Text,
Box::new(error),
)
})?,
})
}
@@ -308,7 +373,7 @@ fn stored_providers_on_connection(conn: &Connection) -> Result<Vec<StoredProvide
let mut stmt = conn
.prepare(
"SELECT id, provider_name, base_url, model, api_key, toml_config, wire_api,
requires_openai_auth, created_at, updated_at, source, source_id
requires_openai_auth, model_mappings_json, created_at, updated_at, source, source_id
FROM providers
ORDER BY created_at ASC, rowid ASC",
)
@@ -317,10 +382,10 @@ fn stored_providers_on_connection(conn: &Connection) -> Result<Vec<StoredProvide
.query_map([], |row| {
Ok(StoredProvider {
provider: saved_provider_from_row(row)?,
created_at: row.get(8)?,
updated_at: row.get(9)?,
source: row.get(10)?,
source_id: row.get(11)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
source: row.get(11)?,
source_id: row.get(12)?,
})
})
.map_err(|e| CodexxError::Database(e.to_string()))?;
@@ -353,7 +418,7 @@ pub(crate) fn provider_by_id_on_connection(
let mut stmt = conn
.prepare(
"SELECT id, provider_name, base_url, model, api_key, toml_config, wire_api,
requires_openai_auth
requires_openai_auth, model_mappings_json
FROM providers WHERE id = ?1 LIMIT 1",
)
.map_err(|e| CodexxError::Database(e.to_string()))?;
@@ -380,14 +445,16 @@ fn write_provider_with_origin(
origin: Option<(&str, &str)>,
) -> Result<()> {
let now = now_rfc3339();
let model_mappings_json = serde_json::to_string(&provider.model_mappings)
.map_err(|error| CodexxError::Config(format!("序列化模型映射失败: {error}")))?;
let (source, source_id) = origin
.map(|(source, source_id)| (source, Some(source_id)))
.unwrap_or((MANUAL_PROVIDER_SOURCE, None));
conn.execute(
"INSERT INTO providers
(id, provider_name, base_url, model, api_key, toml_config, wire_api,
requires_openai_auth, source, source_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
requires_openai_auth, model_mappings_json, source, source_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12)
ON CONFLICT(id) DO UPDATE SET
provider_name = excluded.provider_name,
base_url = excluded.base_url,
@@ -396,6 +463,7 @@ fn write_provider_with_origin(
toml_config = excluded.toml_config,
wire_api = excluded.wire_api,
requires_openai_auth = excluded.requires_openai_auth,
model_mappings_json = excluded.model_mappings_json,
source = CASE
WHEN excluded.source_id IS NULL THEN providers.source
ELSE excluded.source
@@ -414,6 +482,7 @@ fn write_provider_with_origin(
provider.toml_config,
provider.wire_api,
if provider.requires_openai_auth { 1 } else { 0 },
model_mappings_json,
source,
source_id,
now,
@@ -473,6 +542,9 @@ fn merge_authoritative_import(
if incoming.wire_api.trim().is_empty() {
incoming.wire_api = existing.wire_api.clone();
}
if incoming.model_mappings.is_empty() {
incoming.model_mappings = existing.model_mappings.clone();
}
incoming
}
@@ -725,6 +797,8 @@ pub(crate) fn normalize_saved_provider(provider: SavedProvider) -> Result<SavedP
if raw_id.is_empty() {
return Err(CodexxError::Config("provider id 不能为空".to_string()));
}
let model_mappings =
super::model_catalog::normalize_mappings(&provider.model_mappings, &provider.model)?;
let mut normalized = SavedProvider {
id: custom_provider_id(raw_id),
provider_name: provider.provider_name.trim().to_string(),
@@ -744,6 +818,7 @@ pub(crate) fn normalize_saved_provider(provider: SavedProvider) -> Result<SavedP
provider.wire_api.trim().to_string()
},
requires_openai_auth: provider.requires_openai_auth,
model_mappings,
};
if normalized.provider_name.is_empty() {
return Err(CodexxError::Config("供应商名称不能为空".to_string()));
@@ -846,7 +921,21 @@ fn duplicate_provider_on_connection(
})?;
// Adopt an unsaved detected original before adding its copy. Otherwise
// the copy would become the only matching row and appear to be enabled.
if matching_saved_provider_ids_for_live(&detected, &saved).is_empty() {
let detected_matches = matching_saved_provider_ids_for_live_on_connection(
&transaction,
codex_dir,
&detected,
&saved,
)?;
if let [saved_id] = detected_matches.as_slice() {
if let Some(original) = saved.iter().find(|provider| &provider.id == saved_id) {
detected.id = original.id.clone();
detected.model_mappings = original.model_mappings.clone();
if detected.api_key.is_none() {
detected.api_key = original.api_key.clone();
}
}
} else if detected_matches.is_empty() {
detected.id = unique_provider_id_on_connection(
&transaction,
&custom_provider_id(&detected.provider_name),
@@ -860,7 +949,12 @@ fn duplicate_provider_on_connection(
super::reconcile_active_provider_on_connection(
&transaction,
codex_dir,
&matching_saved_provider_ids_for_live(live, &saved),
&matching_saved_provider_ids_for_live_on_connection(
&transaction,
codex_dir,
live,
&saved,
)?,
)?
} else {
None
@@ -910,6 +1004,7 @@ pub(crate) fn save_provider_with_rollback_inner(
}
pub(crate) fn save_detected_provider_with_rollback_inner(
codex_dir: &std::path::Path,
mut live: SavedProvider,
) -> Result<Option<ProviderStoreRollback>> {
let mut conn = open_db()?;
@@ -921,9 +1016,13 @@ pub(crate) fn save_detected_provider_with_rollback_inner(
.iter()
.map(|stored| stored.provider.clone())
.collect::<Vec<_>>();
let matches = matching_saved_provider_ids_for_live(&live, &saved);
let matches =
matching_saved_provider_ids_for_live_on_connection(&transaction, codex_dir, &live, &saved)?;
match matches.as_slice() {
[saved_id] => {
if let Some(saved) = saved.iter().find(|provider| &provider.id == saved_id) {
live.model_mappings = saved.model_mappings.clone();
}
if live.api_key.is_none() {
live.api_key = saved
.iter()
@@ -954,11 +1053,13 @@ pub(crate) fn save_detected_provider_with_rollback_inner(
fn insert_stored_provider(conn: &Connection, stored: &StoredProvider) -> Result<()> {
let provider = &stored.provider;
let model_mappings_json = serde_json::to_string(&provider.model_mappings)
.map_err(|error| CodexxError::Config(format!("序列化模型映射失败: {error}")))?;
conn.execute(
"INSERT INTO providers
(id, provider_name, base_url, model, api_key, toml_config, wire_api,
requires_openai_auth, source, source_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
requires_openai_auth, model_mappings_json, source, source_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
provider.id,
provider.provider_name,
@@ -968,6 +1069,7 @@ fn insert_stored_provider(conn: &Connection, stored: &StoredProvider) -> Result<
provider.toml_config,
provider.wire_api,
if provider.requires_openai_auth { 1 } else { 0 },
model_mappings_json,
stored.source,
stored.source_id,
stored.created_at,
@@ -1099,6 +1201,7 @@ mod tests {
model TEXT NOT NULL, api_key TEXT, toml_config TEXT,
wire_api TEXT NOT NULL DEFAULT 'responses',
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
model_mappings_json TEXT NOT NULL DEFAULT '[]',
source TEXT NOT NULL DEFAULT 'manual',
source_id TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);",
@@ -1117,6 +1220,7 @@ mod tests {
toml_config: None,
wire_api: "responses".to_string(),
requires_openai_auth: true,
model_mappings: Vec::new(),
}
}
@@ -1130,6 +1234,131 @@ mod tests {
conn
}
#[test]
fn model_mappings_roundtrip_and_copy_preserve_independent_records() {
let mut conn = copy_test_connection();
let mut original = provider("mapping-original", "Original", Some("fixture-key"));
original.model_mappings = vec![
super::super::model_catalog::ProviderModelMapping {
model: " gpt-5.5 ".into(),
display_name: "GPT".into(),
context_window: None,
},
super::super::model_catalog::ProviderModelMapping {
model: "deepseek-chat".into(),
display_name: "DeepSeek".into(),
context_window: Some(128000),
},
];
let original = save_manual_provider_on_connection(&conn, original).unwrap();
assert_eq!(original.model_mappings[0].model, "gpt-5.5");
assert_eq!(
provider_by_id_on_connection(&conn, &original.id)
.unwrap()
.unwrap()
.model_mappings,
original.model_mappings
);
let copied = duplicate_provider_on_connection(
&mut conn,
std::path::Path::new("/fixture/model-copy"),
Some(original.clone()),
Some(&original.id),
None,
)
.unwrap();
assert_ne!(copied.provider.id, original.id);
assert_eq!(copied.provider.model_mappings, original.model_mappings);
let mut detected = original.clone();
detected.id = "custom".into();
detected.model = "deepseek-chat".into();
detected.model_mappings.clear();
let detected_copy = duplicate_provider_on_connection(
&mut conn,
std::path::Path::new("/fixture/model-copy"),
Some(detected),
None,
None,
)
.unwrap();
assert_eq!(
detected_copy.provider.model_mappings,
original.model_mappings
);
let mut edited = copied.provider;
edited.model_mappings[1].display_name = "Independent copy".into();
save_manual_provider_on_connection(&conn, edited).unwrap();
assert_eq!(
provider_by_id_on_connection(&conn, &original.id)
.unwrap()
.unwrap()
.model_mappings,
original.model_mappings
);
let serialized = serde_json::to_value(&original).unwrap();
assert_eq!(serialized["modelMappings"][1]["contextWindow"], 128000);
let mut old_payload = serialized;
old_payload.as_object_mut().unwrap().remove("modelMappings");
assert!(serde_json::from_value::<SavedProvider>(old_payload)
.unwrap()
.model_mappings
.is_empty());
}
#[test]
fn cc_switch_refresh_preserves_locally_saved_model_mappings() {
let conn = copy_test_connection();
let mut original = provider("mapping-import", "Imported", Some("fixture-key"));
original.model_mappings = vec![super::super::model_catalog::ProviderModelMapping {
model: "gpt-5.5".into(),
display_name: "Local label".into(),
context_window: Some(256000),
}];
upsert_ccswitch_provider_on_connection(&conn, original.clone(), "mapping-source").unwrap();
let incoming = provider("external-id", "Fresh import", Some("fixture-key"));
let refreshed =
upsert_ccswitch_provider_on_connection(&conn, incoming, "mapping-source").unwrap();
assert_eq!(refreshed.provider.id, original.id);
assert_eq!(refreshed.provider.provider_name, "Fresh import");
assert_eq!(refreshed.provider.model_mappings, original.model_mappings);
}
#[test]
fn model_mapping_rollback_restores_mappings_and_origin_metadata() {
let _guard = crate::app_db::test_db_guard();
let mut original = provider("mapping-rollback-record", "Original", Some("fixture-key"));
original.model_mappings = vec![super::super::model_catalog::ProviderModelMapping {
model: "gpt-5.5".into(),
display_name: "Original model".into(),
context_window: Some(128000),
}];
let conn = open_db().unwrap();
upsert_ccswitch_provider_on_connection(&conn, original.clone(), "mapping-rollback-source")
.unwrap();
let metadata = |conn: &Connection| {
conn.query_row(
"SELECT source, source_id, created_at, updated_at FROM providers WHERE id = 'mapping-rollback-record'", [],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?)),
).unwrap()
};
let before = metadata(&conn);
let mut changed = original.clone();
changed.model_mappings[0].display_name = "Changed".into();
changed.model_mappings[0].context_window = Some(1000000);
let (_, rollback) = save_provider_with_rollback_inner(changed).unwrap();
rollback_provider_store_inner(rollback).unwrap();
assert_eq!(
provider_by_id_on_connection(&conn, &original.id)
.unwrap()
.unwrap()
.model_mappings,
original.model_mappings
);
assert_eq!(metadata(&conn), before);
drop(conn);
delete_provider_inner(&original.id).unwrap();
}
#[test]
fn immediate_copy_preserves_original_selection_and_complete_template() {
let mut conn = copy_test_connection();
@@ -1642,6 +1871,7 @@ enabled = true
toml_config: Some("\n".to_string()),
wire_api: String::new(),
requires_openai_auth: false,
model_mappings: Vec::new(),
};
let result = upsert_ccswitch_provider_on_connection(&conn, missing, "cc-row")
.expect("fill missing import fields from the existing source row");
+7 -2
View File
@@ -8,7 +8,7 @@ use crate::prompts::{
};
use crate::providers::{
clear_active_provider_on_connection, detected_live_custom_provider, document_is_official,
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
list_saved_providers_on_connection, matching_saved_provider_ids_for_live_on_connection,
official_auth_available, open_store, reconcile_active_provider_on_connection, SavedProvider,
};
use crate::{auth_path, config_path, string_value};
@@ -227,7 +227,12 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
clear_active_provider_on_connection(&conn, &codex_dir)?;
None
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
let candidates = matching_saved_provider_ids_for_live(&live, &saved_providers);
let candidates = matching_saved_provider_ids_for_live_on_connection(
&conn,
&codex_dir,
&live,
&saved_providers,
)?;
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
} else {
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
+23 -12
View File
@@ -133,6 +133,7 @@ fn provider_test_connection() -> Connection {
toml_config TEXT,
wire_api TEXT NOT NULL DEFAULT 'responses',
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
model_mappings_json TEXT NOT NULL DEFAULT '[]',
source TEXT NOT NULL DEFAULT 'manual',
source_id TEXT,
created_at TEXT NOT NULL,
@@ -160,6 +161,7 @@ fn provider_fixture(
toml_config: toml_config.map(ToString::to_string),
wire_api: "responses".to_string(),
requires_openai_auth: true,
model_mappings: Vec::new(),
}
}
@@ -960,6 +962,7 @@ wire_api = "responses"
toml_config: Some(toml.to_string()),
wire_api: "responses".to_string(),
requires_openai_auth: true,
model_mappings: Vec::new(),
};
let live = second_toml.replace(
"wire_api = \"responses\"",
@@ -1807,6 +1810,7 @@ enabled = true
toml_config: Some(provider_a_config.to_string()),
wire_api: "responses".to_string(),
requires_openai_auth: true,
model_mappings: Vec::new(),
},
SavedProvider {
id: provider_b_id.clone(),
@@ -1817,6 +1821,7 @@ enabled = true
toml_config: Some(provider_b_config.to_string()),
wire_api: "responses".to_string(),
requires_openai_auth: true,
model_mappings: Vec::new(),
},
] {
save_provider_inner(provider).expect("save provider record");
@@ -2093,9 +2098,10 @@ command = "docs-server"
.config_text
.parse::<DocumentMut>()
.expect("parse provider B live config");
assert!(live_doc["model_providers"]["custom"]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
live_doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
Some("sk-b")
);
let _ = fs::remove_dir_all(codex_dir);
}
@@ -2725,6 +2731,7 @@ requires_openai_auth = true
toml_config: Some(provider_config.to_string()),
wire_api: "responses".to_string(),
requires_openai_auth: true,
model_mappings: Vec::new(),
})
.expect("save provider without stored key");
write_text(&config_path(&codex_dir), provider_config).expect("write live provider config");
@@ -3192,6 +3199,7 @@ requires_openai_auth = true
toml_config: Some(custom_config.trim_end().to_string()),
wire_api: "responses".to_string(),
requires_openai_auth: true,
model_mappings: Vec::new(),
};
save_provider_inner(original.clone()).expect("save original provider");
assert_eq!(
@@ -3755,9 +3763,10 @@ requires_openai_auth = false
let config_doc = config_text
.parse::<DocumentMut>()
.expect("parse provider config");
assert!(config_doc["model_providers"]["custom"]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
config_doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
Some("sk-provider-table")
);
let auth_after: Value = serde_json::from_str(
&fs::read_to_string(auth_path(&codex_dir)).expect("read auth after save"),
)
@@ -3913,9 +3922,10 @@ command = "must-not-replace-live"
doc["model_providers"]["custom"]["base_url"].as_str(),
Some("https://new.example.com/v1")
);
assert!(doc["model_providers"]["custom"]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
Some("sk-new")
);
let auth: Value = serde_json::from_str(
&fs::read_to_string(auth_path(&codex_dir)).expect("read merged provider auth"),
)
@@ -4017,9 +4027,10 @@ requires_openai_auth = false
.config_text
.parse::<DocumentMut>()
.expect("parse provider B live config");
assert!(live_doc["model_providers"]["custom"]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
live_doc["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
Some("sk-b")
);
assert!(!result
.state
.config_text
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.16",
"version": "0.3.17",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
@@ -0,0 +1,36 @@
import { CheckCircle2, Crown, Gem, KeyRound, Mail, ShieldCheck, Sparkles, UserRound } from "lucide-react";
import { getOfficialPlan } from "../officialPlan";
import "../styles/official-account-badge.css";
export type OfficialAccountBadgeProps = {
lang: "zh" | "en";
email?: string | null;
hasAuth: boolean;
canQueryQuota: boolean;
planType?: string | null;
className?: string;
};
export function OfficialAccountBadge({ lang, email, hasAuth, canQueryQuota, planType, className = "" }: OfficialAccountBadgeProps) {
const signedOut = !hasAuth;
const label = signedOut
? (lang === "zh" ? "未登录" : "Not signed in")
: !canQueryQuota
? (lang === "zh" ? "已保存认证" : "Saved credentials")
: email || (lang === "zh" ? "已登录" : "Signed in");
const Icon = signedOut ? UserRound : !canQueryQuota ? KeyRound : email ? Mail : CheckCircle2;
const state = signedOut ? "signed-out" : canQueryQuota ? "signed-in" : "saved";
const plan = hasAuth && canQueryQuota ? getOfficialPlan(planType, lang) : null;
const PlanIcon = plan?.tone === "pro20" ? Crown : plan?.tone === "pro5" ? Gem : plan?.tone === "enterprise" ? ShieldCheck : Sparkles;
return <span className={`cx-official-account ${className}`.trim()}>
<span className={`cx-official-account-badge cx-official-account-badge--${state}`} title={label}>
<Icon size={14} strokeWidth={1.8} aria-hidden="true" />
<span>{label}</span>
</span>
{plan && <span className={`cx-official-plan cx-official-plan--${plan.tone}`} title={`${lang === "zh" ? "套餐" : "Plan"}:${plan.label}`}>
{plan.tone !== "free" && plan.tone !== "neutral" && <PlanIcon size={12} aria-hidden="true" />}
<span>{plan.label}</span>
</span>}
</span>;
}
@@ -0,0 +1,213 @@
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { AlertCircle, CheckCircle2, ChevronDown, Clock3, Gauge, Loader2, RefreshCw, Sparkles, Ticket } from "lucide-react";
import { Button, ModalShell } from "./ui";
import { OfficialAccountBadge } from "./OfficialAccountBadge";
import type { OfficialQuotaLimit, OfficialQuotaSnapshot, OfficialQuotaWindow, OfficialResetCreditsSnapshot } from "../officialQuotaTypes";
import { initialOfficialQuotaState, loadOfficialQuotaDetails, officialQuotaReducer } from "../officialQuotaState";
import { getOfficialPlan } from "../officialPlan";
import "../styles/official-quota.css";
type Language = "zh" | "en";
type QuotaProfile = { id: string; name: string; email: string | null; planType?: string | null };
export type OfficialQuotaDialogProps = {
lang: Language;
configDir: string;
profile: QuotaProfile | null;
onClose: () => void;
};
function getCopy(lang: Language) {
return lang === "zh" ? {
title: "官方 Codex 额度", close: "关闭", refresh: "刷新额度", refreshing: "查询中",
loading: "正在查询此账号的可用额度…", loadingHint: "可以随时关闭窗口,稍后重新查询。",
error: "暂时无法查询额度", previous: "刷新失败,当前保留上次查询结果。",
accountUnknown: "未提供账号邮箱", planUnknown: "未提供套餐信息", remaining: "剩余额度", used: "已使用",
resetUnknown: "未提供重置时间", resetDue: "已到重置时间,请刷新", percentUnknown: "未提供百分比",
windowUnknown: "未知窗口", weekly: "每周额度", main: "通用额度", others: "其他额度",
empty: "暂未返回通用额度", emptyHint: "服务未提供此账号的通用额度数据,可稍后刷新查询。",
noWindow: "未提供额度窗口", noWindowHint: "服务未提供此项额度的百分比或重置时间。",
unavailable: "当前不可用", reached: "额度已用完", available: "可用", checked: "查询于",
codeReview: "代码审查", mismatch: "返回的额度不属于当前账号,请重新查询。",
snapshotHint: "额度以最近一次查询结果为准。倒计时结束后请刷新确认。",
resets: "可用重置", resetLoading: "查询中", resetError: "暂时无法查询重置次数",
resetPrevious: "重置次数刷新失败,保留上次查询结果。", resetInvalid: "服务未返回有效的可用重置次数。",
} : {
title: "Official Codex quota", close: "Close", refresh: "Refresh quota", refreshing: "Checking",
loading: "Checking this account’s available quota…", loadingHint: "You can close this window and check again later.",
error: "Unable to check quota", previous: "Refresh failed. The last available results are shown.",
accountUnknown: "Account email not provided", planUnknown: "Plan not provided", remaining: "Remaining", used: "Used",
resetUnknown: "Reset time not provided", resetDue: "Reset time reached. Refresh to check.", percentUnknown: "Percentage not provided",
windowUnknown: "Unknown window", weekly: "Weekly quota", main: "General quota", others: "Other quotas",
empty: "General quota not returned", emptyHint: "The service did not provide this account’s general quota. Try refreshing later.",
noWindow: "Quota window not provided", noWindowHint: "The service did not provide percentages or reset times for this quota.",
unavailable: "Currently unavailable", reached: "Limit reached", available: "Available", checked: "Checked",
codeReview: "Code review", mismatch: "The returned quota belongs to a different account. Please try again.",
snapshotHint: "Quota reflects the last query. Refresh after the countdown ends to confirm availability.",
resets: "Available resets", resetLoading: "Checking", resetError: "Unable to check available resets",
resetPrevious: "Reset count refresh failed. The last result is shown.", resetInvalid: "The service did not return a valid available reset count.",
};
}
type QuotaCopy = ReturnType<typeof getCopy>;
function finitePercent(value: number | null): number | null {
return value !== null && Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : null;
}
function formatPercent(value: number): string {
return Number(value.toFixed(1)).toString();
}
function formatWindow(seconds: number | null, lang: Language, copy: QuotaCopy): string {
if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) return copy.windowUnknown;
if (seconds === 604_800) return copy.weekly;
let rest = Math.floor(seconds);
const values: string[] = [];
const units: [number, string, string][] = [[86_400, "天", "d"], [3_600, "小时", "h"], [60, "分钟", "m"], [1, "秒", "s"]];
for (const [size, zh, en] of units) {
const value = Math.floor(rest / size);
if (value) values.push(lang === "zh" ? `${value} ${zh}` : `${value}${en}`);
rest %= size;
}
const duration = values.join(" ");
return lang === "zh" ? `${duration}额度` : `${duration} quota`;
}
function formatReset(resetsAt: string | null, now: number, lang: Language, copy: QuotaCopy): { relative: string; exact: string | null; overdue: boolean } {
const time = resetsAt ? Date.parse(resetsAt) : Number.NaN;
if (!Number.isFinite(time)) return { relative: copy.resetUnknown, exact: null, overdue: false };
const exact = new Date(time).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", {
year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false,
});
if (time <= now) return { relative: copy.resetDue, exact, overdue: true };
const minutes = Math.ceil((time - now) / 60_000);
const days = Math.floor(minutes / 1_440);
const hours = Math.floor(minutes % 1_440 / 60);
const remainder = minutes % 60;
let duration: string;
if (lang === "zh") {
duration = days ? `${days} 天${hours ? ` ${hours} 小时` : ""}` : hours ? `${hours} 小时${remainder ? ` ${remainder} 分钟` : ""}` : `${minutes} 分钟`;
return { relative: `${duration}后重置`, exact, overdue: false };
}
duration = days ? `${days}d${hours ? ` ${hours}h` : ""}` : hours ? `${hours}h${remainder ? ` ${remainder}m` : ""}` : `${minutes}m`;
return { relative: `Resets in ${duration}`, exact, overdue: false };
}
function limitLabel(limit: OfficialQuotaLimit, copy: QuotaCopy): string {
if (limit.id === "main") return copy.main;
if (limit.id === "code_review" || limit.id === "code-review") return copy.codeReview;
return limit.name?.trim() || limit.id;
}
function QuotaWindow({ window, lang, copy, now, compact = false }: { window: OfficialQuotaWindow; lang: Language; copy: QuotaCopy; now: number; compact?: boolean }) {
const remaining = finitePercent(window.remainingPercent);
const used = finitePercent(window.usedPercent);
const reset = formatReset(window.resetsAt, now, lang, copy);
const label = formatWindow(window.windowSeconds, lang, copy);
const low = remaining !== null && remaining <= 10;
return <article className={`cx-quota-window${compact ? " cx-quota-window--compact" : ""}${low ? " cx-quota-window--low" : ""}`}>
<div className="cx-quota-window-summary">
<div className="cx-quota-window-heading"><strong>{label}</strong></div>
<div className="cx-quota-window-value"><span>{remaining === null ? "—" : formatPercent(remaining)}</span>{remaining !== null && <small>%</small>}</div>
</div>
<div className="cx-quota-window-progress">
<span>{copy.remaining}</span>
<div className={`cx-quota-meter${remaining === null ? " cx-quota-meter--unknown" : ""}`} role="progressbar" aria-label={`${label} · ${copy.remaining}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={remaining ?? undefined} aria-valuetext={remaining === null ? copy.percentUnknown : `${formatPercent(remaining)}%`}>
{remaining !== null && <span style={{ width: `${remaining}%` }} />}
</div>
<div className="cx-quota-window-used" title={used === null ? copy.percentUnknown : undefined}>{copy.used} {used === null ? "—" : `${formatPercent(used)}%`}</div>
</div>
<div className={`cx-quota-reset${reset.overdue ? " cx-quota-reset--due" : ""}`}>
<Clock3 size={12} aria-hidden="true" /><div><span>{reset.relative}</span>{reset.exact && <time dateTime={window.resetsAt!}>{reset.exact}</time>}</div>
</div>
</article>;
}
function QuotaLimit({ limit, lang, copy, now, compact = false }: { limit: OfficialQuotaLimit; lang: Language; copy: QuotaCopy; now: number; compact?: boolean }) {
const status = limit.limitReached === true ? copy.reached : limit.allowed === false ? copy.unavailable : limit.allowed === true ? copy.available : null;
const windowOrder = (window: OfficialQuotaWindow) => window.windowSeconds !== null && Number.isFinite(window.windowSeconds) && window.windowSeconds > 0 ? window.windowSeconds : Number.POSITIVE_INFINITY;
const windows = [...limit.windows].sort((a, b) => windowOrder(a) - windowOrder(b));
return <section className={`cx-quota-limit${compact ? " cx-quota-limit--compact" : ""}`}>
<div className="cx-quota-limit-heading"><h3>{limitLabel(limit, copy)}</h3>{status && <span className={`cx-quota-status${limit.limitReached === true || limit.allowed === false ? " cx-quota-status--unavailable" : ""}`}>{limit.allowed === true && limit.limitReached !== true && <CheckCircle2 size={11} aria-hidden="true" />}{status}</span>}</div>
{windows.length > 0 ? <div className="cx-quota-windows">{windows.map((window) => <QuotaWindow key={window.id} window={window} lang={lang} copy={copy} now={now} compact={compact} />)}</div> : <div className="cx-quota-no-window"><strong>{copy.noWindow}</strong><p>{copy.noWindowHint}</p></div>}
</section>;
}
export function OfficialQuotaDialog({ lang, configDir, profile, onClose }: OfficialQuotaDialogProps) {
const copy = getCopy(lang);
const profileId = profile?.id ?? null;
const queryKey = JSON.stringify([configDir, profileId]);
const [state, dispatch] = useReducer(officialQuotaReducer, initialOfficialQuotaState);
const [now, setNow] = useState(Date.now);
const requestId = useRef(0);
const currentKey = useRef(queryKey);
currentKey.current = queryKey;
const current = state.key === queryKey ? state : initialOfficialQuotaState;
const { data, busy: quotaBusy, error } = current.quota;
const { data: resetCredits, busy: resetBusy, error: resetError } = current.resetCredits;
const busy = quotaBusy || resetBusy;
const load = useCallback(async (retainResult = false) => {
if (!profileId) return;
const request = ++requestId.current;
dispatch({ type: "start", key: queryKey, requestId: request, retainResult });
await loadOfficialQuotaDetails({
key: queryKey, requestId: request, profileId,
loadQuota: () => invoke<OfficialQuotaSnapshot>("get_official_profile_quota", { configDir: configDir || null, profileId }),
loadResetCredits: () => invoke<OfficialResetCreditsSnapshot>("get_official_profile_reset_credits", { configDir: configDir || null, profileId }),
dispatch,
isCurrent: () => request === requestId.current && currentKey.current === queryKey,
mismatchMessage: copy.mismatch, invalidCountMessage: copy.resetInvalid,
});
}, [configDir, profileId, queryKey, copy.mismatch, copy.resetInvalid]);
useEffect(() => {
if (!profileId) { dispatch({ type: "clear", requestId: ++requestId.current }); return; }
void load();
return () => { requestId.current += 1; };
}, [profileId, load]);
useEffect(() => {
if (!profileId) return;
setNow(Date.now());
const timer = window.setInterval(() => setNow(Date.now()), 60_000);
return () => window.clearInterval(timer);
}, [profileId, configDir]);
useEffect(() => { if (data) setNow(Date.now()); }, [data]);
const close = () => {
dispatch({ type: "clear", requestId: ++requestId.current });
onClose();
};
const main = data?.limits.find((limit) => limit.id === "main");
const others = data?.limits.filter((limit) => limit.id !== "main") ?? [];
const email = data?.email || profile?.email;
const checked = data?.checkedAt && Number.isFinite(Date.parse(data.checkedAt)) ? new Date(data.checkedAt).toLocaleString(lang === "zh" ? "zh-CN" : "en-US", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) : null;
return <ModalShell open={Boolean(profile)} onClose={close} title={copy.title} description={profile?.name} closeLabel={copy.close} size="md" className="cx-official-quota-dialog" bodyClassName="cx-official-quota-body" footer={
<><Button variant="secondary" onClick={close}>{copy.close}</Button><Button variant="primary" icon={busy ? <Loader2 size={14} className="cx-quota-spin" /> : <RefreshCw size={14} />} disabled={busy} onClick={() => void load(true)}>{busy ? copy.refreshing : copy.refresh}</Button></>
}>
<div className="cx-quota-account"><div className="cx-quota-account-icon" aria-hidden="true"><Gauge size={21} strokeWidth={1.7} /></div><div className="cx-quota-account-copy"><div><Sparkles size={12} aria-hidden="true" /><strong>{getOfficialPlan(data?.planType ?? profile?.planType, lang)?.label || copy.planUnknown}</strong></div><OfficialAccountBadge lang={lang} email={email} hasAuth canQueryQuota className="cx-quota-account-email" /></div></div>
<section className={`cx-quota-reset-credits${resetError ? " cx-quota-reset-credits--error" : ""}`} aria-label={copy.resets} aria-busy={resetBusy}>
<div className="cx-quota-reset-credits-row">
<span><Ticket size={16} aria-hidden="true" />{copy.resets}</span>
<div aria-live="polite">
{resetBusy && <Loader2 size={13} className="cx-quota-spin" aria-hidden="true" />}
{resetCredits ? <strong>{resetCredits.availableCount}<small>{lang === "zh" ? "次" : resetCredits.availableCount === 1 ? "reset" : "resets"}</small></strong> : <span className="cx-quota-reset-credits-status">{resetBusy ? copy.resetLoading : "—"}</span>}
</div>
</div>
{resetError && <p role="alert"><AlertCircle size={12} aria-hidden="true" /><span><strong>{resetCredits ? copy.resetPrevious : copy.resetError}</strong><span>{resetError}</span></span></p>}
</section>
{error && <div className="cx-quota-error" role="alert"><AlertCircle size={17} aria-hidden="true" /><div><strong>{copy.error}</strong>{data && <p>{copy.previous}</p>}<p>{error}</p></div></div>}
{quotaBusy && !data && <div className="cx-quota-loading" role="status"><Loader2 size={24} className="cx-quota-spin" aria-hidden="true" /><strong>{copy.loading}</strong><p>{copy.loadingHint}</p><div className="cx-quota-loading-bars" aria-hidden="true"><span /><span /></div></div>}
{data && <>
{main ? <QuotaLimit limit={main} lang={lang} copy={copy} now={now} /> : <div className="cx-quota-empty"><Gauge size={26} aria-hidden="true" /><strong>{copy.empty}</strong><p>{copy.emptyHint}</p></div>}
{others.length > 0 && <details className="cx-quota-other"><summary><span>{copy.others}<small>{others.length}</small></span><ChevronDown size={15} aria-hidden="true" /></summary><div>{others.map((limit) => <QuotaLimit key={limit.id} limit={limit} lang={lang} copy={copy} now={now} compact />)}</div></details>}
<div className="cx-quota-footnote">{checked && <span><CheckCircle2 size={11} aria-hidden="true" />{copy.checked} {checked}</span>}<p>{copy.snapshotHint}</p></div>
</>}
</ModalShell>;
}
@@ -0,0 +1,173 @@
import { useEffect, useId, useMemo, useState } from "react";
import { ChevronDown, Download, ListFilter, Plus, Trash2 } from "lucide-react";
import type { ProviderModelMapping } from "../types";
type Language = "zh" | "en";
type MappingError = { model?: string; contextWindow?: string };
const MAX_MAPPINGS = 64;
const MAX_CONTEXT_WINDOW = 10_000_000;
function getCopy(lang: Language) {
return lang === "zh" ? {
title: "模型映射", optional: "可选", subtitle: "让 Codex 的模型菜单显示这个供应商实际提供的模型。",
hint: "适用于支持 Responses 接口的模型。保存并启用后,重启 Codex 更新模型菜单。已有会话会保留原来的模型选择。",
displayName: "菜单显示名称", displayPlaceholder: "留空使用模型 ID", model: "实际模型 ID", modelPlaceholder: "填写供应商提供的模型 ID",
context: "上下文窗口", contextPlaceholder: "可选,如 128000", contextHint: "单位为 Token;留空使用默认值,最大 10,000,000。",
addCurrent: "添加当前模型", importModels: "导入已获取模型", addRow: "添加空行", remove: "删除映射",
empty: "暂未设置模型映射", emptyHint: "留空表示不设置自定义模型菜单。可以添加当前模型,或先获取供应商的模型列表再导入。",
noCurrent: "请先填写上方的模型", currentExists: "当前模型已添加", fetchFirst: "请先在上方获取模型列表", allImported: "已获取的模型均已添加",
limit: "模型菜单最多支持 64 个模型(包含当前默认模型)。", reserved: "当前默认模型会自动保留在菜单中,并计入 64 个模型的上限。",
missingModel: "请填写实际模型 ID", invalidModel: "模型 ID 不能包含控制字符", longModel: "模型 ID 最多 200 个字符",
invalidContext: "请填写 1 至 10,000,000 的整数,或留空。", fixErrors: "请先修正标红的内容,再保存供应商。",
rowLabel: (index: number) => `第 ${index + 1} 行`, imported: (count: number) => `已添加 ${count} 个模型。`,
importLimited: (added: number, skipped: number) => `已添加 ${added} 个模型,达到 64 个上限,另有 ${skipped} 个未添加。`,
invalidDefault: "上方的当前模型 ID 不符合要求,请先修改(最多 200 个字符,不能包含控制字符)。",
} : {
title: "Model mappings", optional: "Optional", subtitle: "Show this provider’s actual models in the Codex model menu.",
hint: "For models that support the Responses API. Save and enable this provider, then restart Codex to update its model menu. Existing conversations keep their selected model.",
displayName: "Menu display name", displayPlaceholder: "Defaults to the model ID", model: "Actual model ID", modelPlaceholder: "Enter the provider’s model ID",
context: "Context window", contextPlaceholder: "Optional, e.g. 128000", contextHint: "In tokens. Leave blank for the default; maximum 10,000,000.",
addCurrent: "Add current model", importModels: "Import fetched models", addRow: "Add row", remove: "Remove mapping",
empty: "No model mappings yet", emptyHint: "Leave this empty to avoid setting a custom model menu. Add the current model, or fetch the provider’s models above and import them.",
noCurrent: "Enter a model above first", currentExists: "The current model is already included", fetchFirst: "Fetch the model list above first", allImported: "All fetched models are already included",
limit: "The model menu supports up to 64 models, including the current default model.", reserved: "The current default model is kept in the menu automatically and counts toward the 64-model limit.",
missingModel: "Enter the actual model ID", invalidModel: "Model IDs cannot contain control characters", longModel: "Model IDs cannot exceed 200 characters",
invalidContext: "Enter a whole number from 1 to 10,000,000, or leave blank.", fixErrors: "Correct the highlighted fields before saving this provider.",
rowLabel: (index: number) => `Row ${index + 1}`, imported: (count: number) => `Added ${count} model(s).`,
importLimited: (added: number, skipped: number) => `Added ${added} model(s). The 64-model limit was reached; ${skipped} were not added.`,
invalidDefault: "Correct the current model ID above first: at most 200 characters, without control characters.",
};
}
function hasInvalidModelId(value: string): boolean {
return Array.from(value).length > 200 || /[\u0000-\u001f\u007f-\u009f]/.test(value);
}
function menuSize(rows: readonly ProviderModelMapping[], currentModel: string): number {
const current = currentModel.trim();
return rows.length + (rows.length > 0 && current && !rows.some((row) => row.model.trim() === current) ? 1 : 0);
}
export function validateProviderModelMappings(rows: readonly ProviderModelMapping[], currentModel: string, lang: Language) {
const copy = getCopy(lang);
const counts = new Map<string, number>();
for (const row of rows) {
const id = row.model.trim();
if (id) counts.set(id, (counts.get(id) ?? 0) + 1);
}
const errors: MappingError[] = rows.map((row) => {
const id = row.model.trim();
const error: MappingError = {};
if (!id) error.model = copy.missingModel;
else if (/[\u0000-\u001f\u007f-\u009f]/.test(id)) error.model = copy.invalidModel;
else if (Array.from(id).length > 200) error.model = copy.longModel;
else if ((counts.get(id) ?? 0) > 1) error.model = lang === "zh" ? "此模型 ID 已添加,请删除重复行。" : "This model ID is already included. Remove the duplicate row.";
if (row.contextWindow !== null && (!Number.isSafeInteger(row.contextWindow) || row.contextWindow <= 0 || row.contextWindow > MAX_CONTEXT_WINDOW)) error.contextWindow = copy.invalidContext;
return error;
});
const size = menuSize(rows, currentModel);
const defaultInvalid = rows.length > 0 && hasInvalidModelId(currentModel.trim());
return { errors, size, valid: size <= MAX_MAPPINGS && !defaultInvalid && errors.every((error) => !error.model && !error.contextWindow), defaultInvalid };
}
export function ProviderModelMappings({ lang, rows, currentModel, availableModels, disabled, onChange }: {
lang: Language;
rows: readonly ProviderModelMapping[];
currentModel: string;
availableModels: readonly string[];
disabled: boolean;
onChange: (rows: ProviderModelMapping[]) => void;
}) {
const copy = getCopy(lang);
const id = useId();
const [expanded, setExpanded] = useState(rows.length > 0);
const [notice, setNotice] = useState("");
// Keep incomplete numeric input visible. Its parent value is the invalid
// sentinel 0, so both the form and save validation reject it instead of
// silently serializing it as an omitted optional value.
const [contextDrafts, setContextDrafts] = useState<Record<number, { value: number | null; text: string }>>({});
const validation = validateProviderModelMappings(rows, currentModel, lang);
const current = currentModel.trim();
const currentIncluded = Boolean(current) && rows.some((row) => row.model.trim() === current);
const candidates = useMemo(() => {
const existing = new Set(rows.map((row) => row.model.trim()));
return Array.from(new Set(availableModels.map((model) => model.trim()).filter(Boolean))).filter((model) => !existing.has(model));
}, [availableModels, rows]);
const canAppend = (model: string) => menuSize([...rows, { model, displayName: model, contextWindow: null }], current) <= MAX_MAPPINGS;
const canAddCurrent = Boolean(current) && !currentIncluded && !hasInvalidModelId(current) && canAppend(current);
const canImport = candidates.some(canAppend);
const reserved = rows.length > 0 && Boolean(current) && !currentIncluded;
useEffect(() => { if (rows.length > 0) setExpanded(true); }, [rows.length]);
const update = (index: number, patch: Partial<ProviderModelMapping>) => {
setNotice("");
onChange(rows.map((row, position) => position === index ? { ...row, ...patch } : row));
};
const add = (model: string) => {
if (disabled || !canAppend(model)) return;
setNotice("");
setExpanded(true);
onChange([...rows, { model, displayName: model, contextWindow: null }]);
};
const remove = (index: number) => {
if (disabled) return;
setContextDrafts((previous) => Object.fromEntries(Object.entries(previous).filter(([key]) => Number(key) !== index).map(([key, value]) => [Number(key) > index ? Number(key) - 1 : Number(key), value])));
setNotice("");
onChange(rows.filter((_, position) => position !== index));
};
const importModels = () => {
if (disabled) return;
let next = [...rows];
let added = 0;
let skipped = 0;
for (const model of candidates) {
const proposed = [...next, { model, displayName: model, contextWindow: null }];
if (menuSize(proposed, current) > MAX_MAPPINGS) { skipped += 1; continue; }
next = proposed;
added += 1;
}
if (added) onChange(next);
setExpanded(true);
setNotice(skipped ? copy.importLimited(added, skipped) : copy.imported(added));
};
return <details className="cx-provider-mappings" open={expanded} onToggle={(event) => setExpanded(event.currentTarget.open)}>
<summary><ListFilter size={17} aria-hidden="true" /><div><strong>{copy.title}<span>{copy.optional}</span></strong><p>{copy.subtitle}</p></div><span className={`cx-provider-mappings-count${!validation.valid ? " cx-provider-mappings-count--invalid" : ""}`} title={copy.limit}>{validation.size} / {MAX_MAPPINGS}</span><ChevronDown size={16} className="cx-provider-mappings-chevron" aria-hidden="true" /></summary>
<div className="cx-provider-mappings-body">
<p className="cx-provider-mappings-hint">{copy.hint}</p>
<div className="cx-provider-mappings-toolbar">
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add(current)} disabled={disabled || !canAddCurrent} title={currentIncluded ? copy.currentExists : !current ? copy.noCurrent : hasInvalidModelId(current) ? copy.invalidDefault : !canAddCurrent ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addCurrent}</button>
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={importModels} disabled={disabled || !canImport} title={!availableModels.length ? copy.fetchFirst : !candidates.length ? copy.allImported : !canImport ? copy.limit : undefined}><Download size={14} aria-hidden="true" />{copy.importModels}</button>
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add("")} disabled={disabled || !canAppend("")} title={!canAppend("") ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addRow}</button>
</div>
{notice && <p className="cx-provider-mappings-notice" role="status">{notice}</p>}
{rows.length === 0 ? <div className="cx-provider-mappings-empty"><ListFilter size={22} aria-hidden="true" /><strong>{copy.empty}</strong><p>{copy.emptyHint}</p></div> : <>
<div className="cx-provider-mappings-labels" aria-hidden="true"><span>{copy.displayName}</span><span>{copy.model}</span><span>{copy.context}</span><span /></div>
<div className="cx-provider-mappings-rows">
{rows.map((row, index) => {
const error = validation.errors[index];
const contextDraft = contextDrafts[index];
const contextText = contextDraft && Object.is(contextDraft.value, row.contextWindow) ? contextDraft.text : row.contextWindow === null ? "" : String(row.contextWindow);
return <div className="cx-provider-mapping-row" key={index} role="group" aria-label={copy.rowLabel(index)}>
<label className="cx-provider-mapping-field"><span>{copy.displayName}</span><input value={row.displayName} placeholder={row.model.trim() || copy.displayPlaceholder} aria-label={`${copy.displayName} · ${copy.rowLabel(index)}`} onChange={(event) => update(index, { displayName: event.currentTarget.value })} disabled={disabled} /></label>
<label className="cx-provider-mapping-field"><span>{copy.model}</span><input value={row.model} placeholder={copy.modelPlaceholder} aria-label={`${copy.model} · ${copy.rowLabel(index)}`} aria-invalid={Boolean(error.model)} aria-describedby={error.model ? `${id}-${index}-model-error` : undefined} onChange={(event) => update(index, { model: event.currentTarget.value })} disabled={disabled} spellCheck={false} />{error.model && <small id={`${id}-${index}-model-error`}>{error.model}</small>}</label>
<label className="cx-provider-mapping-field"><span>{copy.context}</span><input value={contextText} inputMode="numeric" placeholder={copy.contextPlaceholder} aria-label={`${copy.context} · ${copy.rowLabel(index)}`} aria-invalid={Boolean(error.contextWindow)} aria-describedby={error.contextWindow ? `${id}-${index}-context-error` : `${id}-context-hint`} onChange={(event) => {
const text = event.currentTarget.value;
const trimmed = text.trim();
const number = Number(trimmed);
const value = !trimmed ? null : /^\d+$/.test(trimmed) && Number.isSafeInteger(number) && number > 0 && number <= MAX_CONTEXT_WINDOW ? number : 0;
setContextDrafts((previous) => ({ ...previous, [index]: { text, value } }));
update(index, { contextWindow: value });
}} disabled={disabled} />{error.contextWindow && <small id={`${id}-${index}-context-error`}>{error.contextWindow}</small>}</label>
<button type="button" className="cx-providers-icon-button cx-providers-icon-button--danger cx-provider-mapping-remove" onClick={() => remove(index)} disabled={disabled} title={`${copy.remove} · ${copy.rowLabel(index)}`} aria-label={`${copy.remove} · ${copy.rowLabel(index)}`}><Trash2 size={14} aria-hidden="true" /></button>
</div>;
})}
</div>
<p className="cx-provider-mappings-field-hint" id={`${id}-context-hint`}>{copy.contextHint}</p>
{reserved && <p className="cx-provider-mappings-field-hint">{copy.reserved}</p>}
{!validation.valid && <p className="cx-provider-mappings-error" role="status">{validation.size > MAX_MAPPINGS ? copy.limit : validation.defaultInvalid ? copy.invalidDefault : copy.fixErrors}</p>}
</>}
</div>
</details>;
}
@@ -0,0 +1,53 @@
import { useId } from "react";
import type { ReactNode } from "react";
import { Check, SlidersHorizontal } from "lucide-react";
import "../styles/provider-preset-picker.css";
export type ProviderPresetBrand = "custom" | "openai" | "deepseek" | "minimax" | "mimo" | "qwen" | "kimi" | "glm" | "doubao";
export type ProviderPresetChoice = {
id: string;
name: string;
nameEn?: string;
brand: ProviderPresetBrand;
subtitle?: string;
subtitleEn?: string;
};
function PresetMark({ brand }: { brand: ProviderPresetBrand }) {
const logo = brand === "openai" ? "/openai.svg" : `/provider-logos/${brand}.svg`;
return <span className={`cx-preset-mark cx-preset-mark--${brand}`} aria-hidden="true">
{brand === "custom" ? <SlidersHorizontal size={19} strokeWidth={1.8} /> : brand === "doubao" ? "豆" : <span className="cx-preset-brand-logo" style={{ WebkitMaskImage: `url("${logo}")`, maskImage: `url("${logo}")` }} />}
</span>;
}
export function ProviderPresetPicker({ lang, presets, selectedId, disabled = false, onSelect, children }: {
lang: "zh" | "en";
presets: readonly ProviderPresetChoice[];
selectedId: string;
disabled?: boolean;
onSelect: (id: string) => void;
children?: ReactNode;
}) {
const id = useId();
const title = lang === "zh" ? "预设供应商" : "Provider presets";
return <section className="cx-preset-picker" aria-label={title}>
<fieldset className="cx-preset-fieldset" disabled={disabled} aria-label={title}>
<div className="cx-preset-grid">
{presets.map((preset) => {
const name = lang === "en" ? preset.nameEn ?? preset.name : preset.name;
return <label className="cx-preset-option" key={preset.id}>
<input className="cx-preset-radio" type="radio" name={`${id}-provider-preset`} value={preset.id} checked={preset.id === selectedId} onChange={() => onSelect(preset.id)} aria-label={name} />
<span className="cx-preset-card">
<PresetMark brand={preset.brand} />
<span className="cx-preset-copy"><strong>{name}</strong></span>
<span className="cx-preset-selected" aria-hidden="true"><Check size={13} strokeWidth={2.8} /></span>
</span>
</label>;
})}
</div>
</fieldset>
{children && <div className="cx-preset-details">{children}</div>}
</section>;
}
+129 -48
View File
@@ -24,6 +24,9 @@ import { cx } from "./components/ui";
import { appUpdater, useAppUpdater } from "./appUpdater";
import { providerProfilesMatch, type ProviderProfile } from "./providerProfiles";
import { orderProviderRows } from "./providerRowOrder";
import { createPresetProvider, getProviderPreset, getProviderPresetVariant } from "./providerPresets";
import { validateProviderModelMappings } from "./components/ProviderModelMappings";
import { createOfficialProfileMonitor } from "./officialProfileMonitor";
import type {
AboutInfo,
ActionResult,
@@ -37,7 +40,6 @@ import type {
InstructionTemplate,
Lang,
OfficialAuthCandidate,
OfficialConfigDraft,
OfficialProfileActionResult,
OfficialProfileDetail,
OfficialProfileSummary,
@@ -207,7 +209,7 @@ const dict = {
model: "模型",
wireApi: "Wire API",
apiKey: "API Key",
apiKeyPlaceholder: "用于写入 auth.json",
apiKeyPlaceholder: "填写 API Key",
requiresAuth: "requires_openai_auth",
save: "保存到列表",
saveAndSwitch: "保存",
@@ -308,7 +310,7 @@ const dict = {
model: "Model",
wireApi: "Wire API",
apiKey: "API Key",
apiKeyPlaceholder: "Written to auth.json",
apiKeyPlaceholder: "Enter API key",
requiresAuth: "requires_openai_auth",
save: "Save",
saveAndSwitch: "Save",
@@ -385,7 +387,6 @@ function getProviderPageCopy(lang: Lang): ProviderCopy {
officialTomlLabel: "config.toml (TOML)",
officialSaveLabel: isChinese ? "保存官方配置" : "Save official config",
loadCcSwitchOfficialLabel: isChinese ? "从 CC Switch 载入" : "Load from CC Switch",
restoreOfficialLabel: isChinese ? "读取已保存配置" : "Load saved config",
resetOfficialLabel: isChinese ? "重置默认官方配置" : "Reset default official config",
resetOfficialTitle: isChinese ? "重置默认官方配置" : "Reset default official config",
resetOfficialDescription: isChinese
@@ -411,13 +412,13 @@ function getProviderPageCopy(lang: Lang): ProviderCopy {
modelLabel: t.provider.model,
fetchModelsLabel: isChinese ? "获取模型列表" : "Fetch models",
fetchingModelsLabel: isChinese ? "获取中" : "Fetching",
chooseModelLabel: (count) => isChinese ? `选择已获取的模型(${count})` : `Choose a fetched model (${count})`,
chooseModelLabel: (count) => isChinese ? `选择模型(${count})` : `Choose a model (${count})`,
wireApiLabel: t.provider.wireApi,
requiresAuthLabel: t.provider.requiresAuth,
authPreviewTitle: "auth.json (JSON)",
authPreviewDescription: isChinese
? "启用该供应商时,auth.json 只写入 OPENAI_API_KEY;API Key 留空会移除第三方认证文件。"
: "When enabled, auth.json contains only OPENAI_API_KEY. Leaving the API key empty removes third-party authentication.",
? "启用时会按供应商的认证方式应用 API Key;此处预览 auth.json 中保存的内容。"
: "Enabling applies the API key using this provider's authentication settings. This previews the contents saved in auth.json.",
tomlTitle: "config.toml (TOML)",
tomlDescription: isChinese
? "上方标准字段是启用时的权威值;已有模板中的其他扩展字段会保留。只有点击“重置生成”才会替换为标准模板。"
@@ -752,6 +753,8 @@ function App() {
const [officialProfiles, setOfficialProfiles] = React.useState<OfficialProfileSummary[]>([]);
const [editingOfficialProfileId, setEditingOfficialProfileId] = React.useState<string | null>(DEFAULT_OFFICIAL_PROFILE_ID);
const [creatingProvider, setCreatingProvider] = React.useState(false);
const [selectedPresetId, setSelectedPresetId] = React.useState("custom");
const [selectedPresetVariantId, setSelectedPresetVariantId] = React.useState("");
const [officialAuthDirty, setOfficialAuthDirty] = React.useState(false);
const [activeProviderId, setActiveProviderId] = React.useState(() => localStorage.getItem(ACTIVE_PROVIDER_KEY) || "");
const [savedPrompts, setSavedPrompts] = React.useState<SavedPrompt[]>([]);
@@ -814,6 +817,10 @@ function App() {
const providerDraftRequestRef = React.useRef(0);
const officialDraftRequestRef = React.useRef(0);
const officialProfilesRequestRef = React.useRef(0);
const officialProfilesLiveRef = React.useRef(officialProfiles);
officialProfilesLiveRef.current = officialProfiles;
const officialMonitorReadyRef = React.useRef(false);
officialMonitorReadyRef.current = Boolean(state) && !refreshing;
const savedProvidersRequestRef = React.useRef(0);
const loadingGenerationRef = React.useRef(0);
const loadingTokensRef = React.useRef(new Set<number>());
@@ -1162,6 +1169,10 @@ function App() {
model: null,
isDefault: true,
hasAuth: Boolean(state?.officialAuthAvailable),
hasOwnedAuth: Boolean(state?.officialAuthAvailable),
email: null,
planType: null,
canQueryQuota: false,
};
const officialRow = (profile: typeof defaultProfile): ProviderRow => ({
id: profile.id,
@@ -1174,7 +1185,10 @@ function App() {
requiresOpenaiAuth: true,
isCurrent: profile.id === currentOfficialProfileId,
isDefaultOfficial: profile.isDefault,
meta: !profile.hasAuth ? (lang === "zh" ? "待登录" : "Sign-in required") : undefined,
hasAuth: profile.hasOwnedAuth,
email: profile.email,
planType: profile.planType,
canQueryQuota: profile.canQueryQuota,
});
const rows = orderProviderRows(officialRow(defaultProfile), detectedRows, localRows);
return [rows[0], ...officialProfiles.filter((profile) => !profile.isDefault).map(officialRow), ...rows.slice(1)];
@@ -1218,6 +1232,10 @@ function App() {
requiresOpenaiAuth: row.requiresOpenaiAuth,
isCurrent: row.isCurrent,
isDefaultOfficial: row.isDefaultOfficial,
hasAuth: row.hasAuth,
email: row.email,
planType: row.planType,
canQueryQuota: row.canQueryQuota,
meta: row.meta,
sourceLabel: row.source === "official" ? (lang === "zh" ? "Codex 登录" : "Codex login") : undefined,
editable: row.source === "official" || Boolean(local) || row.source === "detected",
@@ -1390,6 +1408,37 @@ function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
const directory = state?.codexDir;
if (!directory || !(tab === "dashboard" || (tab === "provider" && providerMode === "list"))) return;
const scope = normalizedConfigDirForComparison(directory);
const monitor = createOfficialProfileMonitor({
read: () => invoke<OfficialProfileSummary[]>("list_official_profiles", { configDir: directory }),
revision: () => officialProfilesRequestRef.current,
isVisible: () => document.visibilityState !== "hidden",
canRead: () => officialMonitorReadyRef.current
&& activeConfigDirKeyRef.current === scope
&& loadingTokensRef.current.size === 0
&& actionBusyTokensRef.current.size === 0,
apply: (profiles) => {
if (JSON.stringify(profiles) === JSON.stringify(officialProfilesLiveRef.current)) return;
// This newer snapshot supersedes older foreground list reads, while a
// mutation that starts after the poll still invalidates it via revision.
officialProfilesRequestRef.current += 1;
officialProfilesLiveRef.current = profiles;
setOfficialProfiles(profiles);
},
});
window.addEventListener("focus", monitor.wake);
document.addEventListener("visibilitychange", monitor.wake);
monitor.wake();
return () => {
monitor.stop();
window.removeEventListener("focus", monitor.wake);
document.removeEventListener("visibilitychange", monitor.wake);
};
}, [state?.codexDir, tab, providerMode]);
React.useEffect(() => {
if (!state?.codexDir) return;
const loadKey = state.codexDir;
@@ -1681,6 +1730,9 @@ function App() {
});
const applyProviderConfig = (provider: SavedProvider) => {
if (savedProviders.some((saved) => saved.id === provider.id)) {
return invoke<ActionResult>("activate_saved_provider", { configDir: configDir || null, providerId: provider.id });
}
const tomlConfig = provider.tomlConfig?.trim();
if (tomlConfig) {
return invoke<ActionResult>("save_provider_toml_config", {
@@ -1708,6 +1760,15 @@ function App() {
const saveProviderOnly = () => {
const pendingProvider = normalizedProviderForm();
const mappingValidation = validateProviderModelMappings(pendingProvider.modelMappings || [], pendingProvider.model, lang);
if (pendingProvider.modelMappings?.length && pendingProvider.wireApi !== "responses") {
setError(lang === "zh" ? "模型映射需要 Responses 接口,请将 Wire API 设为 responses" : "Model mappings require the Responses API. Set Wire API to responses");
return;
}
if (!mappingValidation.valid) {
setError(lang === "zh" ? "请先修正模型映射中的错误" : "Correct the model mappings before saving");
return;
}
if (!pendingProvider.providerName || !pendingProvider.baseUrl || !pendingProvider.model) {
setError(lang === "zh"
? "请填写供应商名称、API 请求地址和模型"
@@ -1743,7 +1804,9 @@ function App() {
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderTomlDirty(false);
setToast(applied
setToast(applied && pendingProvider.modelMappings?.length
? (lang === "zh" ? "已保存,请重启 Codex 更新模型菜单" : "Saved. Restart Codex to update its model menu")
: applied
? (lang === "zh" ? "供应商配置已保存并热更新" : "Provider saved and hot-applied")
: (lang === "zh" ? "供应商配置已保存" : "Provider saved"));
},
@@ -1757,6 +1820,9 @@ function App() {
localStorage.setItem(ACTIVE_PROVIDER_KEY, provider.id);
setActiveProviderId(provider.id);
handleActionResult(result);
if (provider.modelMappings?.length) {
setToast(lang === "zh" ? "已启用,请重启 Codex 更新模型菜单" : "Enabled. Restart Codex to update its model menu");
}
},
);
@@ -1830,30 +1896,6 @@ function App() {
},
);
const restoreOfficialProvider = () =>
call(
async () => {
const result = await invoke<ActionResult>("restore_official_provider", { configDir: configDir || null });
const draft = await invoke<OfficialConfigDraft | null>("get_official_config_draft", {
configDir: configDir || null,
});
return { result, draft };
},
({ result, draft }) => {
if (draft) {
setOfficialAuthDirty(false);
setOfficialForm({
providerName: officialForm.providerName,
model: draft.model || "gpt-5.5",
authJson: draft.authJson,
configText: draft.configText || buildOfficialTomlPreview(draft.model || "gpt-5.5"),
});
}
handleActionResult(result);
setToast(lang === "zh" ? "已读取保存的官方配置" : "Saved official config loaded");
},
);
const loadCcSwitchOfficial = async () => {
const requestId = ++officialDraftRequestRef.current;
const actionToken = beginActionBusy("loadCcSwitchOfficial");
@@ -2378,29 +2420,64 @@ function App() {
}
};
const newCustomProviderForm = (): SavedProvider => ({
...blankProviderForm,
model: state?.model?.trim() || blankProviderForm.model,
wireApi: currentProvider?.wireApi?.trim() || blankProviderForm.wireApi,
requiresOpenaiAuth: currentProvider?.requiresOpenaiAuth ?? blankProviderForm.requiresOpenaiAuth,
tomlConfig: state?.configText?.trim() || "",
});
const openAddProvider = () => {
officialDraftRequestRef.current += 1;
setCreatingProvider(true);
setSelectedPresetId("custom");
setSelectedPresetVariantId("");
setEditingOfficialProfileId(null);
setOfficialAuthDirty(false);
setOfficialForm({ providerName: "", model: state?.model || "gpt-5.5", configText: "", authJson: "" });
const liveToml = state?.configText?.trim() || "";
const next = {
...blankProviderForm,
model: state?.model?.trim() || blankProviderForm.model,
wireApi: currentProvider?.wireApi?.trim() || blankProviderForm.wireApi,
requiresOpenaiAuth: currentProvider?.requiresOpenaiAuth ?? blankProviderForm.requiresOpenaiAuth,
tomlConfig: liveToml,
};
const next = newCustomProviderForm();
resetAvailableProviderModels();
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderForm(next);
setProviderTomlDraft(liveToml || buildProviderTomlPreview(next));
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderMode("form");
};
const applyProviderPreset = (presetId: string, variantId: string) => {
const preset = getProviderPreset(presetId);
if (!creatingProvider || !preset) return;
const variant = getProviderPresetVariant(presetId, variantId);
setSelectedPresetId(presetId);
setSelectedPresetVariantId(variant?.id || "");
resetAvailableProviderModels();
setError("");
if (presetId === "official") {
void changeProviderKind("official");
return;
}
officialDraftRequestRef.current += 1;
providerDraftRequestRef.current += 1;
clearActionBusy("loadOfficialDraft");
const inherited = newCustomProviderForm();
const draft = createPresetProvider(presetId, variantId, inherited.tomlConfig) || inherited;
// Presets use the same full TOML base as Custom. The existing draft builder
// updates provider fields while retaining common and extended settings.
// Switching services still starts with an empty API key field.
const next = {
...draft,
id: draft.id ? uniqueId(draft.id, savedProviders.map((provider) => provider.id)) : "",
};
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderApiKeyVisible(false);
setAvailableProviderModels(variant?.models.map((entry) => ({ id: entry.model })) || []);
setProviderMode("form");
};
const openEditProvider = (provider: SavedProvider) => {
setCreatingProvider(false);
resetAvailableProviderModels();
@@ -2729,8 +2806,8 @@ function App() {
configExists={Boolean(state?.configExists)}
providerLabel={currentOfficialProfile?.providerName || currentProvider?.name || state?.modelProvider}
instructionEnabled={Boolean(state?.instructionEnabled)}
authExists={Boolean(state?.authExists)}
officialAuthAvailable={Boolean(state?.officialAuthAvailable)}
authExists={currentOfficialProfile?.isCurrent ? currentOfficialProfile.hasOwnedAuth : Boolean(state?.authExists)}
officialAuthAvailable={currentOfficialProfile?.isCurrent ? currentOfficialProfile.hasAuth : Boolean(state?.officialAuthAvailable)}
configPath={state?.configPath}
modelProvider={state?.modelProvider}
instructionPath={state
@@ -2753,11 +2830,14 @@ function App() {
copy={getProviderPageCopy(lang)}
mode={providerMode}
creatingProvider={creatingProvider}
providerKind={providerMode === "official" ? "official" : "api"}
onProviderKindChange={changeProviderKind}
selectedPresetId={selectedPresetId}
selectedPresetVariantId={selectedPresetVariantId}
onPresetSelect={(id) => applyProviderPreset(id, "")}
onPresetVariantSelect={(id) => applyProviderPreset(selectedPresetId, id)}
officialProfileIsDefault={editingOfficialProfileId === DEFAULT_OFFICIAL_PROFILE_ID}
canLoadCurrentOfficial={Boolean(state.isOfficialProvider)}
providerRows={providerPageRows}
configDir={configDir}
loading={loading}
testingId={providerTestingId}
actionBusy={actionBusy}
@@ -2779,6 +2859,8 @@ function App() {
current: state.isOfficialProvider ? currentOfficialProfile?.providerName || "OpenAI Official" : state.modelProvider,
}}
providerAuthPreview={<JsonPreview text={providerAuthPreview} />}
providerModelMappings={providerForm.modelMappings || []}
onProviderModelMappingsChange={(modelMappings) => setProviderForm((current) => ({ ...current, modelMappings }))}
providerTomlDraft={providerTomlDraft}
providerTomlRef={providerTomlEditorRef}
apiKeyVisible={providerApiKeyVisible}
@@ -2829,7 +2911,6 @@ function App() {
const local = findLocalProviderForRow(row);
return local ? removeProvider(local.id, row.isCurrent) : Promise.resolve(false);
}}
onRestoreOfficial={restoreOfficialProvider}
onResetOfficial={resetOfficialProvider}
onCancelMode={() => {
officialDraftRequestRef.current += 1;
@@ -2858,7 +2939,7 @@ function App() {
onProviderNameChange={(value) => setProviderForm((current) => ({
...current,
providerName: value,
id: editingProviderId || customProviderId(value),
id: editingProviderId || uniqueId(customProviderId(value), savedProviders.map((provider) => provider.id)),
}))}
onProviderModelChange={(value) => setProviderForm((current) => ({ ...current, model: value }))}
onFetchModels={() => void fetchProviderModels()}
+22
View File
@@ -0,0 +1,22 @@
export type OfficialPlanTone = "pro20" | "pro5" | "plus" | "team" | "enterprise" | "edu" | "free" | "neutral";
export type OfficialPlan = { label: string; tone: OfficialPlanTone };
// Display aliases requested for the account UI; unknown plans remain neutral.
export function getOfficialPlan(planType: string | null | undefined, lang: "zh" | "en"): OfficialPlan | null {
if (typeof planType !== "string" || planType.length > 64 || /[\u0000-\u001f\u007f-\u009f]/.test(planType)) return null;
const raw = planType.trim();
if (!raw) return null;
const key = raw.toLowerCase().replace(/[_\s]+/g, "-");
if (["pro", "pro20x", "pro-20x"].includes(key)) return { label: "Pro 20x", tone: "pro20" };
if (["prolite", "pro-lite", "pro5x", "pro-5x"].includes(key)) return { label: "Pro 5x", tone: "pro5" };
const known: Record<string, OfficialPlan> = {
plus: { label: "Plus", tone: "plus" },
team: { label: "Team", tone: "team" },
business: { label: "Business", tone: "team" },
enterprise: { label: "Enterprise", tone: "enterprise" },
edu: { label: "Edu", tone: "edu" },
free: { label: lang === "zh" ? "免费版" : "Free", tone: "free" },
go: { label: "Go", tone: "neutral" },
};
return Object.prototype.hasOwnProperty.call(known, key) ? known[key] : { label: raw, tone: "neutral" };
}
@@ -0,0 +1,74 @@
import type { OfficialProfileSummary } from "./types";
type Options = {
read: () => Promise<OfficialProfileSummary[]>;
apply: (profiles: OfficialProfileSummary[]) => void;
revision: () => number;
canRead: () => boolean;
isVisible: () => boolean;
intervalMs?: number;
schedule?: (callback: () => void, delay: number) => () => void;
};
// Only refreshes local display metadata. No full state reload, draft writes,
// network quota requests, or shared loading indicators belong in this monitor.
export function createOfficialProfileMonitor(options: Options) {
const schedule = options.schedule ?? ((callback, delay) => {
const timer = setTimeout(callback, delay);
return () => clearTimeout(timer);
});
let stopped = false;
let inFlight = false;
let wakePending = false;
let cancelTimer: (() => void) | undefined;
function next() {
cancelTimer?.();
cancelTimer = undefined;
if (!stopped && options.isVisible()) {
cancelTimer = schedule(wake, options.intervalMs ?? 2_000);
}
}
async function read() {
if (stopped || !options.isVisible()) return;
if (!options.canRead()) { next(); return; }
inFlight = true;
const revision = options.revision();
try {
const profiles = await options.read();
if (!stopped && !wakePending && options.isVisible() && options.canRead()
&& options.revision() === revision) {
options.apply(profiles);
}
} catch {
// A temporary file/IPC failure must not blank the list or interrupt work.
} finally {
inFlight = false;
if (wakePending) {
wakePending = false;
wake();
} else {
next();
}
}
}
function wake() {
cancelTimer?.();
cancelTimer = undefined;
if (stopped) return;
if (inFlight) { wakePending = true; return; }
void read();
}
return {
wake,
stop() {
stopped = true;
wakePending = false;
cancelTimer?.();
cancelTimer = undefined;
},
};
}
+80
View File
@@ -0,0 +1,80 @@
import type { OfficialQuotaSnapshot, OfficialResetCreditsSnapshot } from "./officialQuotaTypes";
type QueryResult<T> = { data: T | null; busy: boolean; error: string };
export type OfficialQuotaState = {
key: string | null;
requestId: number;
quota: QueryResult<OfficialQuotaSnapshot>;
resetCredits: QueryResult<OfficialResetCreditsSnapshot>;
};
const emptyResult = () => ({ data: null, busy: false, error: "" });
export const initialOfficialQuotaState: OfficialQuotaState = {
key: null, requestId: 0, quota: emptyResult(), resetCredits: emptyResult(),
};
type RequestIdentity = { key: string; requestId: number };
export type OfficialQuotaAction =
| ({ type: "start"; retainResult: boolean } & RequestIdentity)
| { type: "clear"; requestId: number }
| ({ type: "quota-success"; data: OfficialQuotaSnapshot } & RequestIdentity)
| ({ type: "reset-success"; data: OfficialResetCreditsSnapshot } & RequestIdentity)
| ({ type: "quota-error" | "reset-error"; error: string } & RequestIdentity);
export function officialQuotaReducer(state: OfficialQuotaState, action: OfficialQuotaAction): OfficialQuotaState {
if (action.type === "clear") {
return action.requestId < state.requestId ? state : { ...initialOfficialQuotaState, requestId: action.requestId };
}
if (action.type === "start") {
if (action.requestId < state.requestId) return state;
const retain = action.retainResult && action.key === state.key;
return {
key: action.key, requestId: action.requestId,
quota: { data: retain ? state.quota.data : null, busy: true, error: "" },
resetCredits: { data: retain ? state.resetCredits.data : null, busy: true, error: "" },
};
}
if (action.key !== state.key || action.requestId !== state.requestId) return state;
if (action.type === "quota-success") return { ...state, quota: { data: action.data, busy: false, error: "" } };
if (action.type === "reset-success") return { ...state, resetCredits: { data: action.data, busy: false, error: "" } };
if (action.type === "quota-error") return { ...state, quota: { ...state.quota, busy: false, error: action.error } };
return { ...state, resetCredits: { ...state.resetCredits, busy: false, error: action.error } };
}
type QuotaDetailsRequest = RequestIdentity & {
profileId: string;
loadQuota: () => Promise<OfficialQuotaSnapshot>;
loadResetCredits: () => Promise<OfficialResetCreditsSnapshot>;
dispatch: (action: OfficialQuotaAction) => void;
isCurrent: () => boolean;
mismatchMessage: string;
invalidCountMessage: string;
};
// Start both native reads in the same turn. Each publishes its own result;
// waiting for the slower endpoint must never delay the other endpoint's UI.
export async function loadOfficialQuotaDetails(request: QuotaDetailsRequest): Promise<void> {
const { key, requestId, profileId, dispatch, isCurrent } = request;
const quota = (async () => {
try {
const data = await request.loadQuota();
if (!isCurrent()) return;
if (data.profileId !== profileId) throw new Error(request.mismatchMessage);
dispatch({ type: "quota-success", key, requestId, data });
} catch (cause) {
if (isCurrent()) dispatch({ type: "quota-error", key, requestId, error: cause instanceof Error ? cause.message : String(cause) });
}
})();
const resetCredits = (async () => {
try {
const data = await request.loadResetCredits();
if (!isCurrent()) return;
if (data.profileId !== profileId) throw new Error(request.mismatchMessage);
if (!Number.isSafeInteger(data.availableCount) || data.availableCount < 0) throw new Error(request.invalidCountMessage);
dispatch({ type: "reset-success", key, requestId, data });
} catch (cause) {
if (isCurrent()) dispatch({ type: "reset-error", key, requestId, error: cause instanceof Error ? cause.message : String(cause) });
}
})();
await Promise.all([quota, resetCredits]);
}
+29
View File
@@ -0,0 +1,29 @@
export type OfficialQuotaWindow = {
id: string;
windowSeconds: number | null;
usedPercent: number | null;
remainingPercent: number | null;
resetsAt: string | null;
};
export type OfficialQuotaLimit = {
id: string;
name: string | null;
allowed: boolean | null;
limitReached: boolean | null;
windows: OfficialQuotaWindow[];
};
export type OfficialQuotaSnapshot = {
profileId: string;
email: string | null;
planType: string | null;
limits: OfficialQuotaLimit[];
checkedAt: string;
};
export type OfficialResetCreditsSnapshot = {
profileId: string;
availableCount: number;
checkedAt: string;
};
+115 -66
View File
@@ -11,18 +11,23 @@ import {
Eye,
EyeOff,
FilePlus2,
Gauge,
Loader2,
PencilLine,
Plus,
RefreshCw,
RotateCcw,
Trash2,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Ref } from "react";
import { PageTransition } from "../components/PageTransition";
import { OfficialQuotaDialog } from "../components/OfficialQuotaDialog";
import { OfficialAccountBadge } from "../components/OfficialAccountBadge";
import { ProviderModelMappings, validateProviderModelMappings } from "../components/ProviderModelMappings";
import { ProviderPresetPicker } from "../components/ProviderPresetPicker";
import { PROVIDER_PRESETS, getProviderPreset, getProviderPresetVariant } from "../providerPresets";
import { Button, Checkbox, ModalShell } from "../components/ui";
import type { ProviderMode } from "../types";
import type { ProviderMode, ProviderModelMapping } from "../types";
import "../styles/providers-page.css";
export type ProviderRowSource = "official" | "local" | "detected";
@@ -38,6 +43,10 @@ export type ProviderRow = {
requiresOpenaiAuth: boolean;
isCurrent: boolean;
isDefaultOfficial?: boolean;
email?: string | null;
planType?: string | null;
hasAuth?: boolean;
canQueryQuota?: boolean;
sourceLabel?: string;
editable?: boolean;
duplicable?: boolean;
@@ -92,7 +101,6 @@ export type ProviderCopy = {
officialTomlLabel: string;
officialSaveLabel: string;
loadCcSwitchOfficialLabel: string;
restoreOfficialLabel: string;
resetOfficialLabel: string;
resetOfficialTitle: string;
resetOfficialDescription: string;
@@ -134,6 +142,7 @@ export type ProviderOfficialInfo = {
export type ProvidersPageProps = {
lang: "zh" | "en";
configDir: string;
copy: ProviderCopy;
mode: ProviderMode;
providerRows: readonly ProviderRow[];
@@ -142,8 +151,11 @@ export type ProvidersPageProps = {
actionBusy?: string;
editingProviderId: string | null;
creatingProvider: boolean;
providerKind: "api" | "official";
selectedPresetId: string;
selectedPresetVariantId: string;
providerForm: ProviderFormValue;
providerModelMappings: readonly ProviderModelMapping[];
onProviderModelMappingsChange: (rows: ProviderModelMapping[]) => void;
officialForm: OfficialFormValue;
officialProfileIsDefault: boolean;
canLoadCurrentOfficial: boolean;
@@ -158,10 +170,10 @@ export type ProvidersPageProps = {
fetchingModels: boolean;
onImportCcSwitch: () => void;
onAddProvider: () => void;
onProviderKindChange: (kind: "api" | "official") => void;
onPresetSelect: (id: string) => void;
onPresetVariantSelect: (id: string) => void;
onLoadCcSwitchOfficial: () => void;
onLoadCurrentOfficial: () => void;
onRestoreOfficial: () => void;
onResetOfficial: () => void;
onEnableProvider: (row: ProviderRow) => void;
onTestProvider: (row: ProviderRow) => void;
@@ -317,28 +329,19 @@ function ContextWindowControl({
);
}
function ProviderKindSelect({
lang,
creatingProvider,
providerKind,
onProviderKindChange,
disabled,
}: Pick<ProvidersPageProps, "lang" | "creatingProvider" | "providerKind" | "onProviderKindChange"> & { disabled: boolean }) {
function ProviderPresetSection({ lang, creatingProvider, selectedPresetId, selectedPresetVariantId, onPresetSelect, onPresetVariantSelect, disabled }: Pick<ProvidersPageProps, "lang" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect"> & { disabled: boolean }) {
if (!creatingProvider) return null;
return (
<div className="cx-providers-form-grid cx-providers-form-grid--single">
<Field label={lang === "zh" ? "供应商类型" : "Provider type"}>
<select
value={providerKind}
onChange={(event) => onProviderKindChange(event.target.value === "official" ? "official" : "api")}
disabled={disabled}
>
<option value="api">{lang === "zh" ? "第三方 API" : "Third-party API"}</option>
<option value="official">{lang === "zh" ? "官方 Codex 登录" : "Official Codex login"}</option>
</select>
</Field>
</div>
);
const preset = getProviderPreset(selectedPresetId);
const variant = getProviderPresetVariant(selectedPresetId, selectedPresetVariantId);
return <ProviderPresetPicker lang={lang} presets={PROVIDER_PRESETS} selectedId={selectedPresetId} onSelect={onPresetSelect} disabled={disabled}>
{variant && <div className="cx-preset-variant-panel">
{preset && preset.variants.length > 1 && <div className="cx-preset-variants" role="group" aria-label={lang === "zh" ? "接入方式" : "API service"}>
<span>{lang === "zh" ? "接入方式" : "API service"}</span>
{preset.variants.map((item) => <button key={item.id} type="button" aria-pressed={item.id === variant.id} disabled={disabled} onClick={() => onPresetVariantSelect(item.id)}>{lang === "zh" ? item.label : item.labelEn}</button>)}
</div>}
<p>{lang === "zh" ? variant.note : variant.noteEn}</p>
</div>}
</ProviderPresetPicker>;
}
function ProviderAvatar({ row }: { row: ProviderRow }) {
@@ -384,6 +387,8 @@ function ActionIconButton({
}
function ListPage({
lang,
configDir,
copy,
providerRows,
loading,
@@ -391,16 +396,23 @@ function ListPage({
actionBusy,
onImportCcSwitch,
onAddProvider,
onRestoreOfficial,
onEnableProvider,
onTestProvider,
onEditProvider,
onDuplicateProvider,
onDeleteProvider,
}: Pick<ProvidersPageProps, "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onRestoreOfficial" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
const [quotaSelection, setQuotaSelection] = useState<{ id: string; configDir: string; email: string | null } | null>(null);
const [deleting, setDeleting] = useState(false);
const providerActionsBusy = loading || Boolean(actionBusy);
const quotaProfile = quotaSelection?.configDir === configDir
? providerRows.find((row) => row.source === "official" && row.id === quotaSelection.id && row.canQueryQuota && (row.email ?? null) === quotaSelection.email)
: undefined;
useEffect(() => {
if (quotaSelection && !quotaProfile) setQuotaSelection(null);
}, [quotaSelection, quotaProfile]);
const closeDeleteDialog = () => {
if (!deleting) setProviderToDelete(null);
@@ -447,21 +459,25 @@ function ListPage({
) : providerRows.map((row) => {
const testingKey = row.testingKey || `${row.source}-${row.id}`;
const isTesting = testingId === testingKey;
const isProtectedOfficial = row.source === "official" && row.isDefaultOfficial === true;
return (
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
<ProviderAvatar row={row} />
<div className="cx-providers-row-main">
<div className="cx-providers-row-title">
<strong>{row.providerName}</strong>
{row.sourceLabel && (
<span className={`cx-providers-source-badge${row.source === "official" ? " cx-providers-source-badge--official" : ""}`}>
{row.sourceLabel}
</span>
)}
<div className="cx-providers-row-content">
<div className="cx-providers-row-main">
<div className="cx-providers-row-title">
<strong title={row.providerName}>{row.providerName}</strong>
{row.sourceLabel && (
<span className={`cx-providers-source-badge${row.source === "official" ? " cx-providers-source-badge--official" : ""}`}>
{row.sourceLabel}
</span>
)}
</div>
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
{row.source !== "official" && row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
</div>
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
{row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
</div>
{row.source === "official" && <OfficialAccountBadge lang={lang} email={row.email} planType={row.planType} hasAuth={Boolean(row.hasAuth)} canQueryQuota={Boolean(row.canQueryQuota)} className="cx-providers-login-badge" />}
<div className="cx-providers-row-actions">
{row.isCurrent && <span className="cx-providers-current-badge"><span aria-hidden="true" />{copy.currentLabel}</span>}
<button
@@ -472,12 +488,16 @@ function ListPage({
>
{copy.enableLabel}
</button>
{row.source === "official" && row.isDefaultOfficial && (
{row.source === "official" && (
<ActionIconButton
icon={RotateCcw}
label={copy.restoreOfficialLabel}
onClick={onRestoreOfficial}
disabled={providerActionsBusy}
icon={Gauge}
label={row.canQueryQuota
? (lang === "zh" ? "查看额度" : "View quota")
: row.hasAuth
? (lang === "zh" ? "查看额度:此认证不支持订阅额度查询" : "View quota: these credentials do not support subscription quota queries")
: (lang === "zh" ? "查看额度:请先登录官方 Codex" : "View quota: sign in to official Codex first")}
onClick={() => setQuotaSelection({ id: row.id, configDir, email: row.email ?? null })}
disabled={providerActionsBusy || !row.canQueryQuota}
/>
)}
{row.testable !== false && (
@@ -494,8 +514,14 @@ function ListPage({
{row.duplicable && (
<ActionIconButton icon={Copy} label={copy.duplicateLabel} onClick={() => onDuplicateProvider(row)} disabled={providerActionsBusy} />
)}
{row.deletable && (
<ActionIconButton icon={Trash2} label={copy.removeLabel} onClick={() => setProviderToDelete(row)} disabled={providerActionsBusy} danger />
{(row.deletable || isProtectedOfficial) && (
<ActionIconButton
icon={Trash2}
label={isProtectedOfficial ? (lang === "zh" ? "默认官方配置不可删除" : "The default official profile cannot be deleted") : copy.removeLabel}
onClick={() => { if (!isProtectedOfficial) setProviderToDelete(row); }}
disabled={providerActionsBusy || isProtectedOfficial}
danger={!isProtectedOfficial}
/>
)}
</div>
</article>
@@ -503,6 +529,13 @@ function ListPage({
})}
</div>
<OfficialQuotaDialog
lang={lang}
configDir={configDir}
profile={quotaProfile ? { id: quotaProfile.id, name: quotaProfile.providerName, email: quotaProfile.email ?? null, planType: quotaProfile.planType } : null}
onClose={() => setQuotaSelection(null)}
/>
<ModalShell
open={Boolean(providerToDelete)}
onClose={closeDeleteDialog}
@@ -559,8 +592,10 @@ function OfficialForm({
lang,
copy,
creatingProvider,
providerKind,
onProviderKindChange,
selectedPresetId,
selectedPresetVariantId,
onPresetSelect,
onPresetVariantSelect,
officialForm,
officialProfileIsDefault,
canLoadCurrentOfficial,
@@ -577,9 +612,8 @@ function OfficialForm({
onSaveOfficial,
onLoadCcSwitchOfficial,
onLoadCurrentOfficial,
onRestoreOfficial,
onResetOfficial,
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "providerKind" | "onProviderKindChange" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onRestoreOfficial" | "onResetOfficial">) {
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onResetOfficial">) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const [contextWindowBusy, setContextWindowBusy] = useState(false);
const loadingCcSwitch = actionBusy === "loadCcSwitchOfficial";
@@ -606,7 +640,7 @@ function OfficialForm({
onCancel={onCancelMode}
disabled={formBusy}
/>
<ProviderKindSelect lang={lang} creatingProvider={creatingProvider} providerKind={providerKind} onProviderKindChange={onProviderKindChange} disabled={formBusy} />
<ProviderPresetSection lang={lang} creatingProvider={creatingProvider} selectedPresetId={selectedPresetId} selectedPresetVariantId={selectedPresetVariantId} onPresetSelect={onPresetSelect} onPresetVariantSelect={onPresetVariantSelect} disabled={formBusy} />
<div className="cx-providers-info-grid">
<div><span>{copy.officialUrlLabel}</span><code>{officialInfo.officialUrl}</code></div>
<div><span>{copy.authPathLabel}</span><code>{officialInfo.authPath}</code></div>
@@ -656,14 +690,9 @@ function OfficialForm({
{copy.loadCcSwitchOfficialLabel}
</button>
{showDefaultActions && (
<>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={onRestoreOfficial} disabled={formBusy}>
<RotateCcw size={15} aria-hidden="true" />{copy.restoreOfficialLabel}
</button>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
</button>
</>
<button type="button" className="cx-providers-button cx-providers-button--secondary" onClick={() => setResetConfirmOpen(true)} disabled={formBusy}>
<FilePlus2 size={15} aria-hidden="true" />{copy.resetOfficialLabel}
</button>
)}
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveOfficial} disabled={formBusy}><CheckCircle2 size={15} aria-hidden="true" />{copy.officialSaveLabel}</button>
</div>
@@ -694,9 +723,13 @@ function ProviderForm({
lang,
copy,
creatingProvider,
providerKind,
onProviderKindChange,
selectedPresetId,
selectedPresetVariantId,
onPresetSelect,
onPresetVariantSelect,
providerForm,
providerModelMappings,
onProviderModelMappingsChange,
loading,
editingProviderId,
providerAuthPreview,
@@ -717,11 +750,12 @@ function ProviderForm({
onProviderTomlDraftChange,
onResetProviderToml,
onSaveProvider,
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "providerKind" | "onProviderKindChange" | "providerForm" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "providerForm" | "providerModelMappings" | "onProviderModelMappingsChange" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
const modelListId = useId();
const [contextWindowBusy, setContextWindowBusy] = useState(false);
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
const formBusy = loading || fetchingModels || contextWindowBusy;
const mappingsValid = validateProviderModelMappings(providerModelMappings, providerForm.model, lang).valid;
return (
<>
@@ -733,7 +767,7 @@ function ProviderForm({
onCancel={onCancelMode}
disabled={formBusy}
/>
<ProviderKindSelect lang={lang} creatingProvider={creatingProvider} providerKind={providerKind} onProviderKindChange={onProviderKindChange} disabled={formBusy} />
<ProviderPresetSection lang={lang} creatingProvider={creatingProvider} selectedPresetId={selectedPresetId} selectedPresetVariantId={selectedPresetVariantId} onPresetSelect={onPresetSelect} onPresetVariantSelect={onPresetVariantSelect} disabled={formBusy} />
<section className="cx-providers-form-section">
<div className="cx-providers-section-heading">
@@ -815,6 +849,16 @@ function ProviderForm({
</div>
</section>
<ProviderModelMappings
key={editingProviderId ?? "new-provider"}
lang={lang}
rows={providerModelMappings}
currentModel={providerForm.model}
availableModels={availableModels}
disabled={formBusy}
onChange={onProviderModelMappingsChange}
/>
<section className="cx-providers-form-section">
<div className="cx-providers-section-heading"><div><h3>{copy.authPreviewTitle}</h3><p>{copy.authPreviewDescription}</p></div></div>
<div className="cx-providers-preview">{providerAuthPreview}</div>
@@ -832,7 +876,8 @@ function ProviderForm({
</section>
<div className="cx-providers-form-actions cx-providers-form-actions--save">
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy}>
{!mappingsValid && <span className="cx-provider-mappings-save-hint">{lang === "zh" ? "请先修正模型映射中的错误。" : "Correct the model mapping errors before saving."}</span>}
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy || !mappingsValid}>
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
{loading ? copy.savingLabel : copy.saveLabel}
</button>
@@ -842,8 +887,12 @@ function ProviderForm({
}
export function ProvidersPage(props: ProvidersPageProps) {
const pageKey = props.creatingProvider && props.mode !== "list"
? "providers:add"
: `providers:${props.mode}`;
return (
<PageTransition pageKey={`providers:${props.mode}`}>
<PageTransition pageKey={pageKey}>
<section className={`cx-providers cx-page cx-providers--${props.mode}`}>
{props.mode === "list" && <ListPage {...props} />}
{props.mode === "official" && <OfficialForm {...props} />}
+29 -16
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { invoke } from "@tauri-apps/api/core";
import {
@@ -7,6 +7,7 @@ import {
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { UsageDay, UsageModel, UsageRange, UsageStatistics } from "../usageTypes";
import { initialUsageLoadState, sameUsageQuery, usageLoadReducer } from "../usageStatisticsState";
import "../styles/usage-statistics.css";
type Language = "zh" | "en";
@@ -77,6 +78,8 @@ function copyFor(lang: Language) {
noDataDescription: "当前范围内还没有可统计的会话记录。使用 Codex 后点击刷新,或试试更大的日期范围。",
showAll: "查看全部时间", loading: "正在整理本地会话用量…",
error: "暂时无法读取用量", stale: "刷新失败,下面保留上次读取的结果。",
updatingResults: "正在更新用量,保留当前结果。", showingResults: "当前显示",
loadingResults: "正在加载", previousResults: "下方暂时显示", failedResults: "未能加载",
partial: "部分记录未计入", partialHint: "部分本地记录不完整,当前结果仅包含可读取的用量。",
source: "本地会话记录", updated: "更新于", notes: "缓存输入已包含在输入 Token 中,推理 Token 已包含在输出 Token 中。历史记录缺失的用量无法补算。",
scanned: "已检查", files: "份记录", filtered: "当前模型", activity: "使用概览",
@@ -99,6 +102,8 @@ function copyFor(lang: Language) {
noDataDescription: "No usage records in this range yet. Refresh after using Codex, or try a broader date range.",
showAll: "View all time", loading: "Reading local session usage…",
error: "Unable to load usage", stale: "Refresh failed. The last available results are shown below.",
updatingResults: "Updating usage. Current results stay visible.", showingResults: "Showing",
loadingResults: "Loading", previousResults: "Results below", failedResults: "Unable to load",
partial: "Some records were excluded", partialHint: "Some local records are incomplete. These results include readable usage only.",
source: "Local session records", updated: "Updated", notes: "Cached input is included in input tokens; reasoning is included in output tokens. Usage cannot be recovered from missing historical records.",
scanned: "Checked", files: "records", filtered: "Selected model", activity: "Usage overview",
@@ -236,27 +241,31 @@ export function UsageStatisticsPage({ lang, configDir, active = true }: UsageSta
const copy = copyFor(lang);
const [range, setRange] = useState<UsageRange>("7d");
const [model, setModel] = useState("");
const [record, setRecord] = useState<{ key: string; data: UsageStatistics } | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [loadState, dispatch] = useReducer(usageLoadReducer, initialUsageLoadState);
const requestRef = useRef(0);
const previousDir = useRef(configDir);
const queryKey = JSON.stringify([configDir, range, model]);
const data = record?.key === queryKey ? record.data : null;
const query = useMemo(() => ({ configDir, range, model }), [configDir, range, model]);
const record = loadState.record?.query.configDir === configDir ? loadState.record : null;
const data = record?.data ?? null;
const currentRequest = sameUsageQuery(loadState.query, query);
const busy = active && (!currentRequest || loadState.busy);
const error = currentRequest ? loadState.error : "";
const displayingPrevious = record !== null && !sameUsageQuery(record.query, query);
const queryLabel = (value: typeof query) => `${copy.ranges[value.range]} · ${value.model ? modelLabel(value.model, lang) : copy.allModels}`;
const statusText = displayingPrevious
? `${error ? copy.failedResults : copy.loadingResults} ${queryLabel(query)} · ${copy.previousResults} ${queryLabel(record.query)}`
: busy ? copy.updatingResults : `${copy.showingResults} ${queryLabel(query)}`;
const load = useCallback(async (forceRefresh = false) => {
if (!active) return;
const request = ++requestRef.current;
setBusy(true);
setError("");
dispatch({ type: "start", requestId: request, query });
try {
const next = await invoke<UsageStatistics>("get_usage_statistics", { configDir, range, model: model || null, forceRefresh });
if (request === requestRef.current) setRecord({ key: queryKey, data: next });
dispatch({ type: "success", requestId: request, data: next });
} catch (cause) {
if (request === requestRef.current) setError(cause instanceof Error ? cause.message : String(cause));
} finally {
if (request === requestRef.current) setBusy(false);
dispatch({ type: "failure", requestId: request, error: cause instanceof Error ? cause.message : String(cause) });
}
}, [active, configDir, range, model, queryKey]);
}, [active, configDir, range, model, query]);
useEffect(() => {
if (previousDir.current !== configDir) {
previousDir.current = configDir;
@@ -266,10 +275,10 @@ export function UsageStatisticsPage({ lang, configDir, active = true }: UsageSta
}
}
void load();
return () => { requestRef.current += 1; };
return () => { dispatch({ type: "cancel", requestId: ++requestRef.current }); };
}, [configDir, model, load]);
const modelOptions = useMemo(() => {
const available = record?.key && JSON.parse(record.key)[0] === configDir ? record.data.availableModels : [];
const available = record?.data.availableModels ?? [];
return Array.from(new Set([...available, ...(model ? [model] : [])])).sort();
}, [record, configDir, model]);
const partial = data && (data.coverage.skippedFiles > 0 || data.coverage.truncated || data.coverage.warnings.length > 0);
@@ -285,6 +294,10 @@ export function UsageStatisticsPage({ lang, configDir, active = true }: UsageSta
<div className="cx-usage-toolbar-actions"><label className="cx-usage-model-select"><span className="cx-usage-sr-only">{copy.model}</span><select value={model} onChange={(event) => setModel(event.currentTarget.value)} aria-label={copy.model}><option value="">{copy.allModels}</option>{modelOptions.map((value) => <option key={value} value={value}>{modelLabel(value, lang)}</option>)}</select></label>
<button type="button" className="cx-page-button cx-page-button--secondary cx-usage-refresh" disabled={busy} onClick={() => void load(true)}><RefreshCw size={14} className={busy ? "cx-page-spin" : ""} aria-hidden="true" />{busy ? copy.refreshing : copy.refresh}</button>
</div>
<div className="cx-usage-query-status" role="status" aria-live="polite" aria-atomic="true">
{busy && data && <Loader2 size={12} className="cx-page-spin" aria-hidden="true" />}
<span>{data ? statusText : "\u00a0"}</span>
</div>
</div>
{error && <div className="cx-usage-notice cx-usage-notice--error" role="alert"><AlertCircle size={18} aria-hidden="true" /><div><strong>{copy.error}</strong>{data && <p>{copy.stale}</p>}<p>{error}</p></div><button type="button" onClick={() => void load(true)} disabled={busy}>{copy.retry}</button></div>}
{!data && !error && <div className="cx-usage-loading" role="status"><Loader2 size={27} className="cx-page-spin" aria-hidden="true" /><p>{copy.loading}</p><div className="cx-usage-loading-cards" aria-hidden="true"><span /><span /><span /></div></div>}
@@ -293,7 +306,7 @@ export function UsageStatisticsPage({ lang, configDir, active = true }: UsageSta
{data && hasUsage && <>
<section className="cx-usage-summary" aria-label={copy.activity}>
<article className="cx-usage-total">
<div className="cx-usage-total-heading"><span><Activity size={16} aria-hidden="true" />{copy.total}</span><span className="cx-usage-period-label">{copy.ranges[range]}</span></div>
<div className="cx-usage-total-heading"><span><Activity size={16} aria-hidden="true" />{copy.total}</span><span className="cx-usage-period-label">{copy.ranges[record?.query.range ?? range]}</span></div>
<div className="cx-usage-total-value"><TokenValue value={data.totals.totalTokens} lang={lang} /><span>{copy.tokens}</span></div>
<div className="cx-usage-total-caption"><span>{exact(data.totals.totalTokens, lang)} {copy.tokens}</span><span>{copy.totalHint}</span></div>
<div className="cx-usage-composition" aria-hidden="true"><span className="cx-usage-swatch--input" style={{ width: `${ratio(data.totals.inputTokens - data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--cache" style={{ width: `${ratio(data.totals.cachedInputTokens, data.totals.totalTokens)}%` }} /><span className="cx-usage-swatch--output" style={{ width: `${ratio(data.totals.outputTokens, data.totals.totalTokens)}%` }} /></div>
+99
View File
@@ -0,0 +1,99 @@
import type { ProviderPresetChoice } from "./components/ProviderPresetPicker";
import type { ProviderModelMapping, SavedProvider } from "./types";
export type ProviderPresetVariant = {
id: string;
label: string;
labelEn: string;
providerName: string;
baseUrl: string;
models: readonly ProviderModelMapping[];
note: string;
noteEn: string;
};
export type ProviderPreset = ProviderPresetChoice & {
variants: readonly ProviderPresetVariant[];
sources?: readonly string[];
};
const model = (id: string, displayName: string, contextWindow: number | null): ProviderModelMapping => ({ model: id, displayName, contextWindow });
const deepseekModels = [model("deepseek-v4-flash", "DeepSeek V4 Flash", 1048576), model("deepseek-v4-pro", "DeepSeek V4 Pro", 1048576)];
const minimaxModels = [model("MiniMax-M3", "MiniMax M3", 1000000), model("MiniMax-M2.7", "MiniMax M2.7", 204800)];
const mimoModels = [model("mimo-v2.5-pro", "MiMo V2.5 Pro", 1048576), model("mimo-v2.5", "MiMo V2.5", 1048576)];
// Reviewed against vendor documentation on 2026-09-09. Only native Responses
// endpoints belong here; Chat/Anthropic-only plans need a separate adapter.
export const PROVIDER_PRESETS: readonly ProviderPreset[] = [
{ id: "custom", name: "自定义配置", nameEn: "Custom", brand: "custom", subtitle: "自行填写 API 配置", subtitleEn: "Your own API settings", variants: [] },
{ id: "official", name: "OpenAI Official", brand: "openai", subtitle: "ChatGPT 账号登录", subtitleEn: "Sign in with ChatGPT", variants: [] },
{
id: "deepseek", name: "DeepSeek", brand: "deepseek", subtitle: "V4 Flash / Pro",
variants: [{ id: "default", label: "开放平台", labelEn: "API platform", providerName: "DeepSeek", baseUrl: "https://api.deepseek.com", models: deepseekModels,
note: "使用 DeepSeek 开放平台的 API Key。", noteEn: "Use an API key from the DeepSeek platform." }],
sources: ["https://api-docs.deepseek.com/quick_start/agent_integrations/codex"],
},
{
id: "minimax", name: "MiniMax", brand: "minimax", subtitle: "M3 / M2.7 · 国内与国际", subtitleEn: "M3 / M2.7 · China & global",
variants: [
{ id: "cn", label: "国内", labelEn: "China", providerName: "MiniMax 国内", baseUrl: "https://api.minimax.cn/v1", models: minimaxModels,
note: "使用 MiniMax 国内平台的 API Key;请确认账号已开通所选模型。", noteEn: "Use a MiniMax China API key with access to the selected model." },
{ id: "global", label: "国际", labelEn: "Global", providerName: "MiniMax 国际", baseUrl: "https://api.minimax.io/v1", models: minimaxModels,
note: "使用 MiniMax 国际平台的 API Key;请确认账号已开通所选模型。", noteEn: "Use a MiniMax global API key with access to the selected model." },
],
sources: ["https://platform.minimaxi.com/docs/token-plan/codex", "https://platform.minimax.io/docs/token-plan/codex", "https://platform.minimax.io/docs/api-reference/responses-create"],
},
{
id: "mimo", name: "小米 MiMo", nameEn: "Xiaomi MiMo", brand: "mimo", subtitle: "V2.5 Pro / V2.5",
variants: [
{ id: "api", label: "按量付费", labelEn: "Pay as you go", providerName: "小米 MiMo", baseUrl: "https://api.xiaomimimo.com/v1", models: mimoModels,
note: "使用按量付费的 sk- 开头 API Key。", noteEn: "Use a pay-as-you-go API key beginning with sk-." },
{ id: "token-plan", label: "Token Plan 套餐", labelEn: "Token Plan", providerName: "小米 MiMo Token Plan", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mimoModels,
note: "使用 Token Plan 套餐的 tp- 开头 API Key,套餐与按量付费地址不同。", noteEn: "Use a Token Plan key beginning with tp-. This plan has its own API endpoint." },
],
sources: ["https://mimo.mi.com/docs/zh-CN/tokenplan/integration/codex-configuration"],
},
{
id: "kimi", name: "Kimi", brand: "kimi", subtitle: "K3 · 开放平台", subtitleEn: "K3 · API platform",
variants: [{ id: "default", label: "开放平台", labelEn: "API platform", providerName: "Kimi", baseUrl: "https://api.moonshot.cn/v1", models: [model("kimi-k3", "Kimi K3", 1048576)],
note: "使用 Kimi 开放平台的 API Key,不适用于 Kimi Code 订阅密钥。", noteEn: "Use a Kimi API platform key. Kimi Code subscription keys use a different service." }],
sources: ["https://platform.kimi.com/docs/guide/codex-kimi", "https://platform.kimi.com/docs/api/responses"],
},
{
id: "glm", name: "智谱 GLM", nameEn: "Zhipu GLM", brand: "glm", subtitle: "GLM Coding Plan",
variants: [{ id: "cn", label: "Coding Plan", labelEn: "Coding Plan", providerName: "智谱 GLM", baseUrl: "https://open.bigmodel.cn/api/v1", models: [model("glm-5.3", "GLM-5.3", 1048576), model("glm-5-turbo", "GLM-5 Turbo", 204800)],
note: "使用对应 GLM Coding Plan 套餐的 API Key;团队套餐请使用团队密钥。", noteEn: "Use the API key for your GLM Coding Plan. Team plans require their team key." }],
sources: ["https://docs.bigmodel.cn/cn/coding-plan/tool/codex"],
},
{
id: "qwen", name: "阿里千问", nameEn: "Alibaba Qwen", brand: "qwen", subtitle: "Qwen3.8 Max / Flash",
variants: [{ id: "beijing", label: "北京地域", labelEn: "Beijing region", providerName: "阿里千问", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: [model("qwen3.8-max", "Qwen3.8 Max", null), model("qwen3.8-flash", "Qwen3.8 Flash", null)],
note: "使用阿里云百炼北京地域的普通 API Key,不适用于 Coding Plan 专属密钥。", noteEn: "Use a regular Model Studio API key for Beijing. Coding Plan keys use a different endpoint." }],
sources: ["https://help.aliyun.com/zh/model-studio/compatibility-with-openai-responses-api"],
},
];
export function getProviderPreset(id: string): ProviderPreset | undefined {
return PROVIDER_PRESETS.find((preset) => preset.id === id);
}
export function getProviderPresetVariant(presetId: string, variantId: string): ProviderPresetVariant | undefined {
const preset = getProviderPreset(presetId);
return preset?.variants.find((variant) => variant.id === variantId) ?? preset?.variants[0];
}
export function createPresetProvider(presetId: string, variantId: string, inheritedToml = ""): SavedProvider | null {
const variant = getProviderPresetVariant(presetId, variantId);
if (!variant) return null;
return {
id: `${presetId}-${variant.id}`,
providerName: variant.providerName,
baseUrl: variant.baseUrl,
model: variant.models[0].model,
apiKey: "",
tomlConfig: inheritedToml,
wireApi: "responses",
requiresOpenaiAuth: false,
modelMappings: variant.models.map((entry) => ({ ...entry })),
};
}
@@ -0,0 +1,75 @@
.cx-official-account {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
}
.cx-official-account-badge {
--cx-account-text: #747b85;
--cx-account-border: #e0e4e9;
--cx-account-surface: #f5f6f8;
min-width: 0;
max-width: 100%;
min-height: 30px;
width: fit-content;
flex: 0 1 auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border: 1px solid var(--cx-account-border);
border-radius: 7px;
color: var(--cx-account-text);
background: var(--cx-account-surface);
font-size: 12px;
font-weight: 550;
line-height: 1.4;
}
.cx-official-account-badge > svg { flex: 0 0 14px; }
.cx-official-account-badge > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cx-official-account-badge--signed-out { --cx-account-text: #8d7552; --cx-account-border: #e8dfd1; --cx-account-surface: #f8f5ef; }
.cx-official-account-badge--signed-in { --cx-account-text: #416aaa; --cx-account-border: #dce6f6; --cx-account-surface: #f3f7ff; }
:root[data-theme="dark"] .cx-official-account-badge { --cx-account-text: #b1b6bf; --cx-account-border: #484c55; --cx-account-surface: #373a42; }
:root[data-theme="dark"] .cx-official-account-badge--signed-out { --cx-account-text: #cab591; --cx-account-border: #5a5142; --cx-account-surface: #3b3730; }
:root[data-theme="dark"] .cx-official-account-badge--signed-in { --cx-account-text: #a4c4f5; --cx-account-border: #465c7d; --cx-account-surface: #304059; }
.cx-official-plan {
--cx-plan-text: #596579;
--cx-plan-line: #dce2eb;
--cx-plan-background: #f3f5f8;
min-width: 0;
max-width: 125px;
min-height: 25px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 7px;
border: 1px solid var(--cx-plan-line);
border-radius: 6px;
color: var(--cx-plan-text);
background: var(--cx-plan-background);
font-size: 10px;
font-weight: 720;
line-height: 1.4;
white-space: nowrap;
}
.cx-official-plan > svg { flex: 0 0 12px; }
.cx-official-plan > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.cx-official-plan--pro20 { --cx-plan-text: #815009; --cx-plan-line: #d6ad56; --cx-plan-background: linear-gradient(125deg, #fff9e5, #f6d891 55%, #fff1c9); box-shadow: 0 1px 7px rgb(175 125 31 / 16%), inset 0 1px 0 rgb(255 255 255 / 80%); }
.cx-official-plan--pro5 { --cx-plan-text: #6d36b5; --cx-plan-line: #c5a5ef; --cx-plan-background: linear-gradient(125deg, #faf4ff, #e9d9fc); }
.cx-official-plan--plus { --cx-plan-text: #275cc0; --cx-plan-line: #bacef3; --cx-plan-background: #eaf2ff; }
.cx-official-plan--team { --cx-plan-text: #157168; --cx-plan-line: #a1d5c9; --cx-plan-background: #e4f5ef; }
.cx-official-plan--enterprise { --cx-plan-text: #e9f1ff; --cx-plan-line: #718fbf; --cx-plan-background: linear-gradient(125deg, #25466e, #536991); box-shadow: inset 0 1px 0 rgb(255 255 255 / 20%); }
.cx-official-plan--edu { --cx-plan-text: #3e6489; --cx-plan-line: #b4cde3; --cx-plan-background: #eaf3fa; }
:root[data-theme="dark"] .cx-official-plan { --cx-plan-text: #bac4d3; --cx-plan-line: #4b5361; --cx-plan-background: #333b46; }
:root[data-theme="dark"] .cx-official-plan--pro20 { --cx-plan-text: #ffe3a3; --cx-plan-line: #a9813d; --cx-plan-background: linear-gradient(125deg, #493721, #67502a 55%, #443522); box-shadow: 0 1px 8px rgb(213 159 57 / 14%), inset 0 1px 0 rgb(255 228 166 / 12%); }
:root[data-theme="dark"] .cx-official-plan--pro5 { --cx-plan-text: #e1c4ff; --cx-plan-line: #8f64b9; --cx-plan-background: linear-gradient(125deg, #46305f, #593872); }
:root[data-theme="dark"] .cx-official-plan--plus { --cx-plan-text: #b5d2ff; --cx-plan-line: #506f9f; --cx-plan-background: #2c4366; }
:root[data-theme="dark"] .cx-official-plan--team { --cx-plan-text: #a4e4d6; --cx-plan-line: #497f76; --cx-plan-background: #284b46; }
:root[data-theme="dark"] .cx-official-plan--enterprise { --cx-plan-text: #edf4ff; --cx-plan-line: #86a2cf; --cx-plan-background: linear-gradient(125deg, #2c405f, #4e6081); }
:root[data-theme="dark"] .cx-official-plan--edu { --cx-plan-text: #b7d8ee; --cx-plan-line: #577c98; --cx-plan-background: #304a5d; }
+111
View File
@@ -0,0 +1,111 @@
.ui-modal.cx-official-quota-dialog {
--ui-accent: #2563eb;
--ui-accent-strong: #1d4ed8;
--cx-quota-blue: #2563eb;
--cx-quota-blue-soft: #f1f6ff;
--cx-quota-blue-line: #dce7fb;
--cx-quota-track: #dfe9fa;
--cx-quota-warning: #a16722;
--cx-quota-warning-soft: #fdf6ec;
width: min(560px, calc(100vw - 40px));
border-radius: 14px;
}
.cx-official-quota-dialog .ui-modal__description { overflow-wrap: anywhere; }
.cx-official-quota-body { display: grid; gap: 18px; }
.cx-quota-account { min-width: 0; display: flex; align-items: center; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--ui-border-subtle); }
.cx-quota-account-icon { flex: 0 0 43px; width: 43px; height: 43px; display: grid; place-items: center; border: 1px solid var(--cx-quota-blue-line); border-radius: 12px; color: var(--cx-quota-blue); background: var(--cx-quota-blue-soft); }
.cx-quota-account-copy { min-width: 0; max-width: calc(100% - 55px); display: grid; justify-items: start; gap: 7px; }
.cx-quota-account-copy > div { min-width: 0; display: flex; align-items: center; gap: 5px; color: var(--cx-quota-blue); }
.cx-quota-account-copy strong { color: var(--ui-text); font-size: 14px; line-height: 1.3; font-weight: 670; overflow-wrap: anywhere; }
.cx-quota-account-email { max-width: 100%; font-size: 12px; }
.cx-quota-account-copy svg { flex-shrink: 0; }
.cx-quota-reset-credits { min-width: 0; margin-top: -4px; padding: 11px 13px; border: 1px solid var(--cx-quota-blue-line); border-radius: 9px; background: var(--cx-quota-blue-soft); }
.cx-quota-reset-credits-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.cx-quota-reset-credits-row > span, .cx-quota-reset-credits-row > div { display: flex; align-items: center; gap: 8px; color: var(--cx-quota-blue); }
.cx-quota-reset-credits-row > span { color: var(--ui-text); font-size: 12px; font-weight: 600; }
.cx-quota-reset-credits-row > span > svg { color: var(--cx-quota-blue); }
.cx-quota-reset-credits-row strong { display: flex; align-items: baseline; gap: 5px; font-size: 20px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
.cx-quota-reset-credits-row strong small { color: var(--ui-text-muted); font-size: 11px; font-weight: 500; }
.cx-quota-reset-credits-status { color: var(--ui-text-muted); font-size: 11px; }
.cx-quota-reset-credits > p { display: flex; align-items: flex-start; gap: 6px; margin: 9px 0 0; color: var(--ui-danger); font-size: 10px; line-height: 1.6; }
.cx-quota-reset-credits > p > svg { flex-shrink: 0; margin-top: 2px; }
.cx-quota-reset-credits > p > span { min-width: 0; display: grid; gap: 2px; overflow-wrap: anywhere; }
.cx-quota-reset-credits > p strong { font-weight: 600; }
.cx-quota-reset-credits--error { border-color: color-mix(in srgb, var(--ui-danger) 25%, var(--cx-quota-blue-line)); }
.cx-quota-limit { min-width: 0; }
.cx-quota-limit-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 7px 10px; margin-bottom: 11px; }
.cx-quota-limit-heading h3 { margin: 0; color: var(--ui-text); font-size: 12px; font-weight: 660; line-height: 1.5; overflow-wrap: anywhere; }
.cx-quota-status { display: inline-flex; align-items: center; gap: 4px; padding: 3px 6px; border-radius: 5px; color: var(--cx-quota-blue); background: var(--cx-quota-blue-soft); font-size: 9px; line-height: 1.2; }
.cx-quota-status--unavailable { color: var(--cx-quota-warning); background: var(--cx-quota-warning-soft); }
.cx-quota-windows { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
.cx-quota-window { min-width: 0; display: grid; grid-template-columns: minmax(94px, .8fr) minmax(100px, 1fr) minmax(150px, 1.2fr); align-items: center; gap: 14px; padding: 14px; border: 1px solid var(--cx-quota-blue-line); border-radius: 10px; background: var(--cx-quota-blue-soft); }
.cx-quota-window-summary, .cx-quota-window-progress { min-width: 0; }
.cx-quota-window-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.cx-quota-window-heading strong { color: var(--ui-text); font-size: 12px; font-weight: 650; line-height: 1.5; }
.cx-quota-window-progress > span { display: block; color: var(--ui-text-muted); font-size: 10px; line-height: 1.5; }
.cx-quota-window-value { display: flex; align-items: baseline; gap: 3px; margin-top: 5px; color: var(--cx-quota-blue); font-variant-numeric: tabular-nums; }
.cx-quota-window-value > span { font-size: 34px; line-height: 1.12; font-weight: 730; letter-spacing: -.045em; }
.cx-quota-window-value > small { font-size: 16px; font-weight: 540; }
.cx-quota-meter { width: 100%; height: 6px; margin-top: 8px; overflow: hidden; border-radius: 5px; background: var(--cx-quota-track); }
.cx-quota-meter > span { display: block; height: 100%; border-radius: 5px; background: var(--cx-quota-blue); }
.cx-quota-meter--unknown { background: repeating-linear-gradient(120deg, var(--cx-quota-track) 0 5px, transparent 5px 9px); }
.cx-quota-window-used { margin-top: 6px; color: var(--ui-text-muted); font-size: 11px; line-height: 1.5; font-variant-numeric: tabular-nums; }
.cx-quota-reset { min-width: 0; display: flex; align-items: flex-start; gap: 6px; padding-left: 12px; border-left: 1px solid var(--cx-quota-blue-line); color: var(--ui-text-muted); }
.cx-quota-reset > svg { flex-shrink: 0; margin-top: 2px; }
.cx-quota-reset > div { min-width: 0; display: grid; gap: 3px; }
.cx-quota-reset span { font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.cx-quota-reset time { color: var(--ui-text-muted); font-size: 11px; line-height: 1.5; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.cx-quota-reset--due { color: var(--cx-quota-warning); }
.cx-quota-window--low .cx-quota-window-value { color: var(--cx-quota-warning); }
.cx-quota-window--low .cx-quota-meter > span { background: var(--cx-quota-warning); }
.cx-quota-other { border: 1px solid var(--ui-border-subtle); border-radius: 10px; }
.cx-quota-other > summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; color: var(--ui-text-muted); font-size: 11px; font-weight: 590; cursor: pointer; list-style: none; }
.cx-quota-other > summary::-webkit-details-marker { display: none; }
.cx-quota-other > summary > span { display: inline-flex; align-items: center; gap: 7px; }
.cx-quota-other > summary small { min-width: 19px; padding: 2px 5px; border-radius: 5px; background: var(--ui-surface-muted); text-align: center; font-size: 9px; font-variant-numeric: tabular-nums; }
.cx-quota-other > summary > svg { transition: transform 140ms ease; }
.cx-quota-other[open] > summary > svg { transform: rotate(180deg); }
.cx-quota-other > summary:focus-visible { outline: 2px solid var(--cx-quota-blue); outline-offset: 3px; border-radius: 8px; }
.cx-quota-other > div { display: grid; gap: 18px; padding: 0 13px 13px; }
.cx-quota-limit--compact { border-top: 1px solid var(--ui-border-subtle); padding-top: 13px; }
.cx-quota-window--compact { padding: 12px; background: var(--ui-surface-elevated); border-color: var(--ui-border-subtle); border-radius: 8px; }
.cx-quota-window--compact .cx-quota-window-value { margin-top: 5px; }
.cx-quota-window--compact .cx-quota-window-value > span { font-size: 29px; }
.cx-quota-window--compact .cx-quota-window-value > small { font-size: 14px; }
.cx-quota-window--compact .cx-quota-meter { height: 5px; }
.cx-quota-window--compact .cx-quota-reset { border-left-color: var(--ui-border-subtle); }
.cx-quota-footnote { color: var(--ui-text-subtle); font-size: 10px; line-height: 1.6; }
.cx-quota-footnote > span { display: flex; align-items: center; gap: 5px; font-variant-numeric: tabular-nums; }
.cx-quota-footnote > span > svg { color: var(--cx-quota-blue); }
.cx-quota-footnote p { margin: 4px 0 0; font-size: 10px; }
.cx-quota-loading, .cx-quota-empty { min-height: 195px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 20px 15px; text-align: center; }
.cx-quota-loading > svg, .cx-quota-empty > svg { color: var(--cx-quota-blue); }
.cx-quota-loading > strong, .cx-quota-empty > strong { margin-top: 1px; color: var(--ui-text); font-size: 12px; line-height: 1.5; font-weight: 610; }
.cx-quota-loading > p, .cx-quota-empty > p { max-width: 310px; margin: -5px 0 0; color: var(--ui-text-muted); font-size: 11px; line-height: 1.7; }
.cx-quota-loading-bars { display: grid; gap: 7px; width: 170px; margin-top: 5px; }
.cx-quota-loading-bars > span { height: 6px; border-radius: 5px; background: var(--cx-quota-track); }
.cx-quota-loading-bars > span:last-child { width: 70%; }
.cx-quota-no-window { padding: 16px; border: 1px dashed var(--ui-border); border-radius: 9px; color: var(--ui-text-muted); }
.cx-quota-no-window strong { font-size: 11px; font-weight: 600; }
.cx-quota-no-window p { margin: 6px 0 0; font-size: 10px; line-height: 1.6; }
.cx-quota-error { display: flex; align-items: flex-start; gap: 9px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--ui-danger) 25%, transparent); border-radius: 9px; color: var(--ui-danger); background: var(--ui-danger-soft); }
.cx-quota-error > svg { flex-shrink: 0; margin-top: 2px; }
.cx-quota-error > div { min-width: 0; }
.cx-quota-error strong { font-size: 11px; line-height: 1.5; font-weight: 640; }
.cx-quota-error p { margin: 5px 0 0; font-size: 10px; line-height: 1.6; overflow-wrap: anywhere; }
.cx-quota-spin { animation: cx-quota-spin 1s linear infinite; }
:root[data-theme="dark"] .cx-official-quota-dialog { --ui-accent: #3375e5; --ui-accent-strong: #4285f4; --cx-quota-blue: #76a8ff; --cx-quota-blue-soft: #2c3c56; --cx-quota-blue-line: #415675; --cx-quota-track: #41536e; --cx-quota-warning: #dfb16f; --cx-quota-warning-soft: #463c2e; }
@keyframes cx-quota-spin { to { transform: rotate(360deg); } }
@media (max-width: 540px) {
.cx-quota-window { grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); gap: 12px 18px; }
.cx-quota-reset { grid-column: 1 / -1; padding: 10px 0 0; border-left: 0; border-top: 1px solid var(--cx-quota-blue-line); }
.cx-quota-reset > div { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 4px 10px; flex: 1; }
.cx-quota-window--compact .cx-quota-reset { border-top-color: var(--ui-border-subtle); }
}
@media (prefers-reduced-motion: reduce) {
.cx-quota-spin { animation: none; }
.cx-quota-other > summary > svg { transition: none; }
}
@@ -0,0 +1,189 @@
.cx-preset-picker {
--cx-preset-surface: #f6f7f9;
--cx-preset-hover: #eef3fb;
--cx-preset-border: #e8ebf0;
--cx-preset-text: var(--cx-provider-text, #20242a);
--cx-preset-muted: var(--cx-provider-muted, #747b85);
--cx-preset-blue: #2563eb;
width: 100%;
min-width: 0;
margin-bottom: 18px;
container: provider-presets / inline-size;
}
.cx-preset-fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.cx-preset-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 9px;
}
.cx-preset-option {
position: relative;
min-width: 0;
cursor: pointer;
}
.cx-preset-radio {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.cx-preset-card {
min-height: 48px;
height: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--cx-preset-border);
border-radius: 10px;
color: var(--cx-preset-text);
background: var(--cx-preset-surface);
transition: none;
}
.cx-preset-radio:not(:disabled) + .cx-preset-card:hover {
border-color: #b8cdf4;
background: var(--cx-preset-hover);
}
.cx-preset-radio:focus-visible + .cx-preset-card {
outline: 2px solid var(--cx-preset-blue);
outline-offset: 3px;
}
.cx-preset-radio:checked + .cx-preset-card,
.cx-preset-radio:checked:not(:disabled) + .cx-preset-card:hover {
color: #fff;
border-color: var(--cx-preset-blue);
background: var(--cx-preset-blue);
box-shadow: 0 3px 8px rgb(37 99 235 / 14%);
}
.cx-preset-copy {
flex: 1;
min-width: 0;
}
.cx-preset-copy strong {
font-size: 13px;
font-weight: 650;
line-height: 1.35;
overflow-wrap: anywhere;
}
.cx-preset-mark {
display: flex;
flex: 0 0 24px;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--cx-preset-brand, #6b7280);
border-radius: 6px;
background: color-mix(in srgb, var(--cx-preset-brand, #6b7280) 9%, transparent);
font-size: 20px;
font-weight: 780;
letter-spacing: -0.04em;
}
.cx-preset-mark--openai { --cx-preset-brand: #10967a; }
.cx-preset-mark--deepseek { --cx-preset-brand: #4d6bfe; }
.cx-preset-mark--minimax { --cx-preset-brand: #e95657; }
.cx-preset-mark--mimo { --cx-preset-brand: #ed7014; font-size: 18px; }
.cx-preset-mark--qwen { --cx-preset-brand: #7c4ddd; }
.cx-preset-mark--kimi { --cx-preset-brand: #6055e8; }
.cx-preset-mark--glm { --cx-preset-brand: #3879e1; }
.cx-preset-mark--doubao { --cx-preset-brand: #258b83; font-size: 17px; }
.cx-preset-brand-logo {
display: block;
width: 20px;
height: 20px;
background: currentColor;
-webkit-mask: url("/openai.svg") center / contain no-repeat;
mask: url("/openai.svg") center / contain no-repeat;
}
.cx-preset-radio:checked + .cx-preset-card .cx-preset-mark {
color: #fff;
background: rgb(255 255 255 / 14%);
}
.cx-preset-selected {
flex: 0 0 17px;
display: flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
border-radius: 50%;
visibility: hidden;
color: var(--cx-preset-blue);
background: #fff;
}
.cx-preset-radio:checked + .cx-preset-card .cx-preset-selected { visibility: visible; }
.cx-preset-radio:disabled + .cx-preset-card { cursor: not-allowed; opacity: 0.55; box-shadow: none; }
.cx-preset-details { margin-top: 14px; }
.cx-preset-variant-panel {
padding: 12px 14px;
border: 1px solid var(--cx-preset-border);
border-radius: 10px;
background: var(--cx-preset-surface);
}
.cx-preset-variants { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.cx-preset-variants > span { margin-right: 4px; color: var(--cx-preset-muted); font-size: 12px; }
.cx-preset-variants button {
border: 1px solid var(--cx-preset-border);
border-radius: 7px;
background: transparent;
padding: 6px 12px;
color: var(--cx-preset-text);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.cx-preset-variants button[aria-pressed="true"] { color: white; background: var(--cx-preset-blue); border-color: var(--cx-preset-blue); }
.cx-preset-variants button:focus-visible { outline: 2px solid var(--cx-preset-blue); outline-offset: 2px; }
.cx-preset-variants button:disabled { opacity: 0.55; cursor: not-allowed; }
.cx-preset-variant-panel p { margin: 8px 0 0; font-size: 12px; line-height: 1.6; color: var(--cx-preset-muted); }
.cx-preset-variant-panel p:first-child { margin-top: 0; }
:root[data-theme="dark"] .cx-preset-picker {
--cx-preset-surface: var(--cx-dark-surface-raised, #30333a);
--cx-preset-hover: #303d55;
--cx-preset-border: var(--cx-dark-border, #454a54);
}
:root[data-theme="dark"] .cx-preset-mark--openai { --cx-preset-brand: #4ccbb0; }
:root[data-theme="dark"] .cx-preset-mark--deepseek { --cx-preset-brand: #91a3ff; }
:root[data-theme="dark"] .cx-preset-mark--minimax { --cx-preset-brand: #fa9393; }
:root[data-theme="dark"] .cx-preset-mark--mimo { --cx-preset-brand: #f9ab66; }
:root[data-theme="dark"] .cx-preset-mark--qwen { --cx-preset-brand: #b49aff; }
:root[data-theme="dark"] .cx-preset-mark--kimi { --cx-preset-brand: #aaa1ff; }
:root[data-theme="dark"] .cx-preset-mark--glm { --cx-preset-brand: #87b4fa; }
:root[data-theme="dark"] .cx-preset-mark--doubao { --cx-preset-brand: #75c8b8; }
@container provider-presets (max-width: 670px) {
.cx-preset-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@container provider-presets (max-width: 490px) {
.cx-preset-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
+122 -1
View File
@@ -222,6 +222,18 @@
box-shadow: 0 0 0 1px rgba(147, 197, 253, 0.5), 0 3px 10px rgba(37, 99, 235, 0.08);
}
/* Give official account emails their own centered column. Current-state
badges can change the actions without moving the email in any row. */
.cx-providers-row--official {
grid-template-columns: 42px minmax(0, 1fr) minmax(200px, 300px) minmax(310px, 1fr);
}
.cx-providers-row--official > .cx-providers-login-badge {
width: 100%;
max-width: 100%;
justify-self: center;
}
.cx-providers-avatar {
width: 42px;
height: 42px;
@@ -262,6 +274,29 @@
min-width: 0;
}
.cx-providers-row-content {
min-width: 0;
display: flex;
align-items: center;
gap: 14px;
}
.cx-providers-row-content > .cx-providers-row-main {
flex: 1 1 auto;
}
.cx-providers-row--official .cx-providers-row-title {
flex-wrap: nowrap;
}
.cx-providers-row--official .cx-providers-source-badge {
flex-shrink: 0;
}
.cx-providers-login-badge {
min-width: 0;
}
.cx-providers-row-title {
min-width: 0;
display: flex;
@@ -776,6 +811,75 @@
margin-top: 18px;
}
.cx-provider-mappings {
--cx-mapping-surface: #ffffff;
--cx-mapping-row: #fafbfc;
--cx-mapping-control: #ffffff;
min-width: 0;
margin-bottom: 14px;
border: 1px solid var(--cx-provider-border);
border-radius: 8px;
background: var(--cx-mapping-surface);
box-shadow: 0 3px 12px rgba(27, 35, 48, 0.035);
}
.cx-provider-mappings > summary { min-width: 0; display: flex; align-items: center; gap: 10px; padding: 15px; cursor: pointer; list-style: none; }
.cx-provider-mappings > summary::-webkit-details-marker { display: none; }
.cx-provider-mappings > summary > svg { flex-shrink: 0; color: var(--cx-provider-blue); }
.cx-provider-mappings > summary > div { min-width: 0; flex: 1; }
.cx-provider-mappings > summary strong { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; color: var(--cx-provider-text); font-size: 14px; font-weight: 690; line-height: 1.4; }
.cx-provider-mappings > summary strong > span { color: var(--cx-provider-muted); font-size: 10px; font-weight: 500; }
.cx-provider-mappings > summary p { margin: 4px 0 0; color: var(--cx-provider-muted); font-size: 11px; line-height: 1.5; }
.cx-provider-mappings > summary:focus-visible { outline: 2px solid var(--cx-provider-blue); outline-offset: 3px; border-radius: 7px; }
.cx-provider-mappings-count { flex-shrink: 0; padding: 4px 7px; border: 1px solid var(--cx-provider-border-soft); border-radius: 6px; color: var(--cx-provider-muted); font-size: 10px; line-height: 1.3; font-variant-numeric: tabular-nums; }
.cx-provider-mappings-count--invalid { color: var(--ui-danger); border-color: color-mix(in srgb, var(--ui-danger) 30%, transparent); }
.cx-provider-mappings-chevron { transition: transform 140ms ease; }
.cx-provider-mappings[open] .cx-provider-mappings-chevron { transform: rotate(180deg); }
.cx-provider-mappings-body { min-width: 0; padding: 0 15px 15px; }
.cx-provider-mappings-hint { margin: 0 0 12px; color: var(--cx-provider-muted); font-size: 11px; line-height: 1.7; }
.cx-provider-mappings-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 15px; }
.cx-provider-mappings-toolbar .cx-providers-button { min-height: 32px; padding-inline: 10px; gap: 5px; border-radius: 7px; font-size: 11px; }
.cx-provider-mappings-notice { margin: -3px 0 12px; color: var(--cx-provider-blue); font-size: 11px; line-height: 1.6; }
.cx-provider-mappings-labels, .cx-provider-mapping-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr) minmax(130px, .8fr) 34px; gap: 10px; }
.cx-provider-mappings-labels { padding: 0 10px 7px; color: var(--cx-provider-muted); font-size: 10px; font-weight: 600; line-height: 1.4; }
.cx-provider-mappings-rows { display: grid; gap: 9px; }
.cx-provider-mapping-row { min-width: 0; align-items: start; padding: 10px; border: 1px solid var(--cx-provider-border-soft); border-radius: 8px; background: var(--cx-mapping-row); }
.cx-provider-mapping-field { min-width: 0; display: grid; gap: 5px; }
.cx-provider-mapping-field > span { display: none; color: var(--cx-provider-muted); font-size: 10px; font-weight: 600; line-height: 1.5; }
.cx-provider-mapping-field > input { min-width: 0; width: 100%; height: 38px; padding: 0 9px; border: 1px solid var(--cx-provider-border); border-radius: 6px; outline: none; color: var(--cx-provider-text); background: var(--cx-mapping-control); font-size: 12px; }
.cx-provider-mapping-field > input::placeholder { color: var(--cx-provider-muted); opacity: .8; font-size: 11px; }
.cx-provider-mapping-field > input:focus { border-color: var(--cx-provider-blue); box-shadow: 0 0 0 2px color-mix(in srgb, var(--cx-provider-blue) 12%, transparent); }
.cx-provider-mapping-field > input:disabled { opacity: .6; cursor: not-allowed; }
.cx-provider-mapping-field > input[aria-invalid="true"] { border-color: color-mix(in srgb, var(--ui-danger) 65%, var(--cx-provider-border)); }
.cx-provider-mapping-field > small { color: var(--ui-danger); font-size: 10px; line-height: 1.5; overflow-wrap: anywhere; }
.cx-provider-mapping-remove { margin-top: 2px; }
.cx-provider-mappings-field-hint { margin: 10px 0 0; color: var(--cx-provider-muted); font-size: 10px; line-height: 1.6; }
.cx-provider-mappings-error { margin: 10px 0 0; color: var(--ui-danger); font-size: 11px; line-height: 1.6; }
.cx-provider-mappings-save-hint { color: var(--ui-danger); font-size: 11px; line-height: 1.6; }
.cx-provider-mappings-empty { display: flex; align-items: center; flex-direction: column; gap: 8px; padding: 23px 18px; border: 1px dashed var(--cx-provider-border); border-radius: 8px; background: var(--cx-mapping-row); text-align: center; }
.cx-provider-mappings-empty > svg { color: var(--cx-provider-blue); }
.cx-provider-mappings-empty > strong { color: var(--cx-provider-text); font-size: 12px; font-weight: 600; }
.cx-provider-mappings-empty > p { max-width: 470px; margin: 0; color: var(--cx-provider-muted); font-size: 11px; line-height: 1.7; }
:root[data-theme="dark"] .cx-provider-mappings { --cx-mapping-surface: var(--cx-dark-surface); --cx-mapping-row: var(--cx-dark-surface-recessed); --cx-mapping-control: var(--cx-dark-surface-raised); }
@media (max-width: 900px) {
.cx-provider-mappings-labels { display: none; }
.cx-provider-mapping-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 34px; }
.cx-provider-mapping-field > span { display: block; }
.cx-provider-mapping-field:nth-child(3) { grid-column: 1 / 3; max-width: 260px; }
.cx-provider-mapping-remove { grid-column: 3; grid-row: 1; margin-top: 22px; }
}
@media (max-width: 640px) {
.cx-provider-mappings > summary { align-items: flex-start; gap: 7px; padding: 12px; }
.cx-provider-mappings > summary > svg { margin-top: 2px; }
.cx-provider-mappings-body { padding-inline: 12px; }
.cx-provider-mappings-count { padding-inline: 5px; }
.cx-provider-mapping-row { grid-template-columns: minmax(0, 1fr) 34px; }
.cx-provider-mapping-field, .cx-provider-mapping-field:nth-child(3) { grid-column: 1; max-width: none; }
.cx-provider-mapping-remove { grid-column: 2; }
}
.cx-providers-spin {
animation: cx-providers-spin 1s linear infinite;
}
@@ -785,6 +889,15 @@
}
@media (max-width: 980px) {
.cx-providers-row-content {
gap: 9px;
}
.cx-providers-login-badge .cx-official-account-badge {
padding-inline: 6px;
gap: 4px;
}
.cx-providers-header,
.cx-providers-form-header {
flex-direction: column;
@@ -800,10 +913,17 @@
}
@media (max-width: 760px) {
.cx-providers-row {
.cx-providers-row,
.cx-providers-row--official {
grid-template-columns: 40px minmax(0, 1fr);
}
.cx-providers-row--official > .cx-providers-login-badge {
grid-column: 2;
width: min(300px, 100%);
justify-self: start;
}
.cx-providers-row-actions {
grid-column: 2;
justify-content: flex-start;
@@ -829,6 +949,7 @@
}
@media (prefers-reduced-motion: reduce) {
.cx-provider-mappings-chevron { transition: none; }
.cx-providers-button,
.cx-providers-icon-button,
.cx-providers-row {
@@ -61,6 +61,8 @@
.cx-usage-range button:hover { color: var(--cx-page-blue); background: var(--cx-page-surface-hover); }
.cx-usage-range button.cx-usage-range-active { color: var(--cx-page-blue); background: var(--cx-page-blue-soft); }
.cx-usage-toolbar-actions { min-width: 0; display: flex; align-items: center; gap: 8px; }
.cx-usage-query-status { flex-basis: 100%; min-height: 16px; display: flex; align-items: center; gap: 6px; color: var(--cx-page-muted); font-size: 10px; line-height: 16px; }
.cx-usage-query-status > svg { flex-shrink: 0; color: var(--cx-page-blue); }
.cx-usage-model-select { min-width: 0; display: block; }
.cx-usage-model-select select { width: 170px; max-width: 100%; height: 38px; padding: 0 24px 0 11px; border: 1px solid var(--cx-page-border); border-radius: 7px; background: var(--cx-page-control); color: var(--cx-page-text); font-size: 11px; text-overflow: ellipsis; }
.cx-usage .cx-usage-refresh { min-height: 38px; padding: 0 12px; font-size: 11px; white-space: nowrap; }
+11 -7
View File
@@ -22,6 +22,12 @@ export type ProviderSummary = {
isCurrent: boolean;
};
export type ProviderModelMapping = {
model: string;
displayName: string;
contextWindow: number | null;
};
export type SavedProvider = {
id: string;
providerName: string;
@@ -31,6 +37,7 @@ export type SavedProvider = {
tomlConfig?: string;
wireApi: string;
requiresOpenaiAuth: boolean;
modelMappings?: ProviderModelMapping[];
};
export type OfficialProfileSummary = {
@@ -38,6 +45,10 @@ export type OfficialProfileSummary = {
providerName: string;
model: string | null;
hasAuth: boolean;
hasOwnedAuth: boolean;
email: string | null;
planType?: string | null;
canQueryQuota: boolean;
isDefault: boolean;
isCurrent: boolean;
};
@@ -129,13 +140,6 @@ export type ActionResult = {
state: CodexState;
};
export type OfficialConfigDraft = {
authJson: string;
configText: string;
model?: string;
source: string;
};
export type OfficialAuthCandidate = {
authJson: string;
configText?: string | null;
+47
View File
@@ -0,0 +1,47 @@
import type { UsageRange, UsageStatistics } from "./usageTypes";
export type UsageQuery = { configDir: string; range: UsageRange; model: string };
export type UsageRecord = { query: UsageQuery; data: UsageStatistics };
export type UsageLoadState = {
requestId: number;
query: UsageQuery | null;
record: UsageRecord | null;
busy: boolean;
error: string;
};
export const initialUsageLoadState: UsageLoadState = {
requestId: 0, query: null, record: null, busy: false, error: "",
};
type UsageLoadAction =
| { type: "start"; requestId: number; query: UsageQuery }
| { type: "success"; requestId: number; data: UsageStatistics }
| { type: "failure"; requestId: number; error: string }
| { type: "cancel"; requestId: number };
export function sameUsageQuery(first: UsageQuery | null, second: UsageQuery): boolean {
return first !== null && first.configDir === second.configDir
&& first.range === second.range && first.model === second.model;
}
export function usageLoadReducer(state: UsageLoadState, action: UsageLoadAction): UsageLoadState {
if (action.type === "start") {
if (action.requestId < state.requestId) return state;
return {
requestId: action.requestId, query: action.query, busy: true, error: "",
// Range and model changes retain the rendered charts. A different Codex
// directory must never inherit another directory's usage or model list.
record: state.record?.query.configDir === action.query.configDir ? state.record : null,
};
}
if (action.type === "cancel") {
if (action.requestId < state.requestId) return state;
return { ...state, requestId: action.requestId, busy: false };
}
if (action.requestId !== state.requestId || !state.busy || !state.query) return state;
if (action.type === "success") {
return { ...state, record: { query: state.query, data: action.data }, busy: false, error: "" };
}
return { ...state, busy: false, error: action.error };
}
+70
View File
@@ -160,6 +160,76 @@ with the production response shape from `src/usageTypes.ts`. Both refresh
paths return newly aggregated sample data; there is no filesystem scan or
real usage cache in this fixture.
## Official account badges and quota
Reset the fixture when migrating older session data so synthetic authentication
includes `one@example.test`. **Fixture:登录第二个官方账号** writes
`two@example.test`. Official summaries expose `email`, `hasOwnedAuth`, and
`canQueryQuota` from the selected profile's live authentication or its own saved
snapshot. An empty official profile shows the unsigned-in badge. No additional
profiles are added by default; use **新增供应商 → 官方 Codex 登录** to create one.
Use the compact **Fixture:额度模式** selector before clicking a profile's quota
button or the quota dialog's refresh action:
- **双窗口 Team**: 5-hour and weekly windows with 93% and 52% remaining.
- **单周 Pro**: one weekly window with 91% remaining.
- **Spark 双窗 + reserve 周**: the main windows plus Spark 5-hour/week and a
separate reserve weekly allowance.
- **无主额度窗口**: no limits and an unknown plan.
- **未知窗口周期**: a 2-hour window plus an unknown duration/percentage/reset.
- **认证过期 401**: a sanitized sign-in-expired error.
- **延迟:完成后成功/失败**: keeps the request pending until
**Fixture:完成额度请求** is clicked. The scenario is captured when the request
starts, so changing the selector does not alter an already pending response.
Changing fixture mode does not trigger a request. Diagnostics expose `quotaMode`
and `pendingQuota`. Node assertions may use `setQuotaMode`, `completeQuota`, and
`failQuota` on `window.__CODEX_X_FIXTURE__`. Responses follow
`src/officialQuotaTypes.ts`; all accounts, limits and timestamps are synthetic,
and the fixture never contacts the ChatGPT endpoint.
## Provider model mappings
The fixture preserves `modelMappings` when saving and copying a supplier, and
implements `activate_saved_provider` with the production `providerId` argument.
Fetch models, expand **模型映射**, import the list, and edit the display name,
actual model ID or optional context window. Invalid IDs, duplicate rows and
invalid context sizes disable Save. Reopening a saved item retains its mappings;
enabling it shows the restart notice. Actual catalog files, outbound model IDs,
authentication and rollback are covered by the Rust and native Codex checks.
## Provider presets and retained usage results
The add-provider form exposes eight preset cards. Cards show only brand marks and names; switching among all presets, including official sign-in, keeps the same add-page transition key. The initial custom TOML includes a reasoning setting, a fixture MCP server and a project entry for inheritance checks. Selecting a vendor fills its
native Responses endpoint and model mappings. MiniMax region and MiMo plan
buttons change the endpoint and clear the synthetic key. Reopening a saved
preset retains its selected model and mappings.
For the usage-switching regression, load the initial seven-day result, then turn
on **Fixture:暂停用量响应**. Select Today, 30 days and All time in succession.
The existing summary and charts should remain visible with their original range
label and a small loading status. **Fixture:完成最新用量请求** resolves the last
selection first; **Fixture:完成旧用量请求** resolves one older request at a time.
Those older results must not replace the latest displayed range. Diagnostics
include `pendingUsage`; all data remains synthetic and in memory.
## Reset credits and plan badges
Official fixture logins expose a `planType` (the default login is Pro, and the
external second login is Pro Lite). Additional fake official logins can specify
`plan_type` in their synthetic auth JSON to inspect Plus, Team or other badges.
No account key or network request is involved in these metadata displays.
**Fixture:重置次数模式** controls the independent reset-count response: 3, 0,
failure, delayed success or delayed failure. To verify concurrency, select a
delayed quota and delayed reset-count response, open the quota dialog, and check
`pendingQuota` and `pendingResetCredits` are both 1 in the visible diagnostics.
**Fixture:完成重置次数请求** resolves only the count; it must render while quota
is still loading. **Fixture:完成额度请求** resolves the other request. Closing
and changing profiles invalidate both pending results. There is no redemption
operation in the fixture or this feature.
## Diagnostics and scope
The accessible **Fixture 命令记录** output (`#fixture-command-log`) displays JSON
+165 -14
View File
@@ -9,7 +9,7 @@
const counts = {};
const switches = [];
const commands = [];
const pending = { sync: [], detail: [] };
const pending = { sync: [], detail: [], quota: [], resetCredits: [], usage: [] };
const callbacks = new Map();
let nextCallback = 1;
let output;
@@ -18,6 +18,9 @@
let externalOfficialLogins = 0;
let savedPrompts = [];
let usageMode = "sample";
let pauseUsage = false;
let quotaMode = "dual";
let resetCreditsMode = "available";
function officialToml(model) {
return `model_provider = "openai"\nmodel = ${JSON.stringify(model || "fixture-official-model")}\n`;
@@ -26,6 +29,8 @@
function officialAuth(account) {
return JSON.stringify({
auth_mode: "chatgpt",
email: `${account}@example.test`,
plan_type: account === "one" ? "pro" : "pro-lite",
OPENAI_API_KEY: null,
tokens: {
id_token: `fixture-only-id-token-${account}`,
@@ -104,7 +109,10 @@
wireApi: "responses",
requiresOpenaiAuth: false,
};
detectedProvider.tomlConfig = toml(detectedProvider);
// Visible inherited settings for provider-preset regression checks.
detectedProvider.tomlConfig = '# fixture inherited settings\nmodel_reasoning_effort = "high"\napproval_policy = "on-request"\n'
+ toml(detectedProvider)
+ '\n[mcp_servers.fixture_docs]\ncommand = "fixture-docs-server"\n\n[features]\nshell_snapshot = true\n\n[projects."/fixture/project"]\ntrust_level = "trusted"\n';
let savedProviders = [{
id: "fixture-saved",
providerName: "Fixture Saved Provider",
@@ -207,6 +215,10 @@
return {
pendingSync: pending.sync.length,
pendingDetail: pending.detail.length,
pendingQuota: pending.quota.length,
pendingResetCredits: pending.resetCredits.length,
resetCreditsMode,
quotaMode,
commandCounts: clone(counts),
providerSwitches: clone(switches),
currentProvider: state.modelProvider,
@@ -218,6 +230,7 @@
})),
externalOfficialLogins,
usageMode,
pendingUsage: pending.usage.length,
savedProviderIds: savedProviders.map((provider) => provider.id),
commands: clone(commands),
};
@@ -234,11 +247,18 @@
}
function officialSummary(profile) {
const authJson = state.isOfficialProvider && state.activeOfficialProfileId === profile.id ? state.authText : profile.authJson;
let authentication;
try { authentication = JSON.parse(authJson || "null"); } catch { authentication = null; }
return {
id: profile.id,
providerName: profile.providerName,
model: profile.model || null,
hasAuth: Boolean(profile.authJson.trim()),
hasOwnedAuth: Boolean(authentication && typeof authentication === "object"),
email: typeof authentication?.email === "string" ? authentication.email.trim() || null : null,
planType: typeof authentication?.tokens?.access_token === "string" ? authentication.plan_type || null : null,
canQueryQuota: Boolean(!authentication?.OPENAI_API_KEY && typeof authentication?.tokens?.access_token === "string" && authentication.tokens.access_token.trim()),
isDefault: profile.id === defaultOfficialId,
isCurrent: state.isOfficialProvider && state.activeOfficialProfileId === profile.id,
};
@@ -274,16 +294,16 @@
return true;
}
function defer(kind, result) {
function defer(kind, result, failure = null) {
return new Promise((resolve, reject) => {
pending[kind].push({ resolve, reject, result });
pending[kind].push({ resolve, reject, result, failure });
render();
});
}
function settle(kind, fail) {
for (const request of pending[kind].splice(0)) {
if (fail) request.reject(new Error(`Fixture ${kind} failure`));
if (fail || request.failure) request.reject(new Error(request.failure || `Fixture ${kind} failure`));
else request.resolve(clone(request.result()));
}
render();
@@ -400,7 +420,8 @@
function preserveDetectedProvider() {
// This stub models the expected Rust persistence behavior. It does not test Rust.
if (state.modelProvider === detectedProvider.id
&& !savedProviders.some((provider) => provider.id === detectedProvider.id)) {
&& !savedProviders.some((provider) => provider.id === detectedProvider.id
|| provider.baseUrl === detectedProvider.baseUrl && provider.apiKey === detectedProvider.apiKey)) {
saveProvider(detectedProvider);
}
}
@@ -485,6 +506,73 @@
render();
}
const quotaModes = [
["dual", "双窗口 Team:93% / 52%"],
["weekly", "单周 Pro:91%"],
["additional", "Spark 双窗 + reserve 周"],
["empty", "无主额度窗口"],
["unknown", "未知窗口周期"],
["error", "认证过期 401"],
["pending", "延迟:完成后成功"],
["pending-error", "延迟:完成后失败"],
];
function setQuotaMode(mode) {
if (!quotaModes.some(([id]) => id === mode)) throw new Error(`Unknown fixture quota mode: ${mode}`);
quotaMode = mode;
render();
}
function officialQuota({ profileId }) {
const profile = officialProfile(profileId);
const summary = officialSummary(profile);
if (!summary.canQueryQuota) throw new Error("Fixture:此官方配置没有可查询额度的登录凭据");
const requestedMode = quotaMode;
const failure = "Fixture:登录已过期或无效(HTTP 401),请在 Codex 中重新登录";
if (requestedMode === "error") throw new Error(failure);
const checkedAt = new Date();
const window = (id, seconds, remaining, resetSeconds) => ({
id, windowSeconds: seconds, usedPercent: remaining === null ? null : 100 - remaining,
remainingPercent: remaining,
resetsAt: resetSeconds === null ? null : new Date(checkedAt.getTime() + resetSeconds * 1000).toISOString(),
});
const main = { id: "main", name: null, allowed: true, limitReached: false, windows: [window("primary", 18000, 93, 7200), window("secondary", 604800, 52, 259200)] };
const result = { profileId, email: summary.email, planType: summary.planType || "team", limits: [main], checkedAt: checkedAt.toISOString() };
if (requestedMode === "weekly") {
result.planType = "pro";
main.windows = [window("primary", 604800, 91, 432000)];
} else if (requestedMode === "additional") {
result.limits.push(
{ id: "additional-spark", name: "GPT-5.3-Codex-Spark", allowed: true, limitReached: false, windows: [window("primary", 18000, 78, 1800), window("secondary", 604800, 64, 345600)] },
{ id: "reserve", name: "Reserve", allowed: true, limitReached: false, windows: [window("primary", 604800, 86, 172800)] },
);
} else if (requestedMode === "empty") {
result.planType = null;
result.limits = [];
} else if (requestedMode === "unknown") {
main.windows = [window("primary", 7200, 37, 1200), window("secondary", null, null, null)];
main.allowed = null;
main.limitReached = null;
}
// Capture the selected scenario now; changing the selector cannot settle an
// existing request differently. Only explicit quota/refresh clicks invoke it.
if (requestedMode === "pending" || requestedMode === "pending-error") {
return defer("quota", () => result, requestedMode === "pending-error" ? failure : null);
}
return clone(result);
}
function officialResetCredits({ profileId }) {
const profile = officialProfile(profileId);
if (!officialSummary(profile).canQueryQuota) throw new Error("Fixture:请先登录此账号");
const mode = resetCreditsMode;
const failure = "Fixture:重置次数查询失败,请稍后重试";
if (mode === "error") throw new Error(failure);
const result = { profileId, availableCount: mode === "zero" ? 0 : 3, checkedAt: new Date().toISOString() };
if (mode === "pending" || mode === "pending-error") return defer("resetCredits", () => result, mode === "pending-error" ? failure : null);
return clone(result);
}
function switchProvider(provider) {
preserveDetectedProvider();
captureCurrentOfficial();
@@ -601,6 +689,8 @@
case "get_official_profile":
captureCurrentOfficial();
return officialDetail(officialProfile(args.profileId));
case "get_official_profile_quota": return officialQuota(args);
case "get_official_profile_reset_credits": return officialResetCredits(args);
case "save_official_profile": return saveOfficial(args.input);
case "duplicate_official_profile": return duplicateOfficial(args.profileId, args.providerName);
case "switch_official_profile": return switchOfficial(args.profileId);
@@ -659,10 +749,18 @@
return action("Fixture saved prompt enabled");
case "build_provider_toml_draft": return providerTomlDraft(args.provider);
case "update_codex_context_window": return updateContextWindow(args);
case "get_usage_statistics": return usageStatistics(args);
case "get_usage_statistics": {
const result = usageStatistics(args);
return pauseUsage ? defer("usage", () => result) : result;
}
case "duplicate_provider": return duplicateProvider(args.providerId, args.providerName);
case "save_provider": return saveProvider(args.provider);
case "save_active_provider": return switchProvider(saveProvider(args.provider));
case "activate_saved_provider": {
const provider = savedProviders.find((item) => item.id === args.providerId);
if (!provider) throw new Error("Unknown fixture provider");
return switchProvider(provider);
}
case "switch_provider": {
const input = args.input;
return switchProvider(savedProviders.find((provider) => provider.id === input.providerId) || {
@@ -676,8 +774,7 @@
if (!provider) throw new Error("Unknown fixture provider");
return switchProvider({ ...provider, tomlConfig: args.input.configText, apiKey: args.input.apiKey });
}
case "switch_official_provider":
case "restore_official_provider": return switchOfficial();
case "switch_official_provider": return switchOfficial();
case "reset_official_provider":
saveOfficial({ id: defaultOfficialId, providerName: officialProfile(defaultOfficialId).providerName, model: "fixture-official-model", authJson: "", configText: "" });
return switchOfficial();
@@ -685,11 +782,6 @@
const input = args.input || args;
return saveOfficial({ ...input, id: defaultOfficialId, providerName: officialProfile(defaultOfficialId).providerName });
}
case "get_official_config_draft": {
captureCurrentOfficial();
const profile = officialProfile(defaultOfficialId);
return { authJson: profile.authJson, configText: profile.configText, model: profile.model, source: "fixture" };
}
case "read_ccswitch_official_auth": return {
authJson: officialAuth("one"), configText: officialToml("fixture-official-model"),
model: "fixture-official-model", source: "fixture",
@@ -735,6 +827,9 @@
snapshot,
loginSecondOfficialAccount,
setUsageMode,
setQuotaMode,
completeQuota: () => settle("quota", false),
failQuota: () => settle("quota", true),
completeSync: () => settle("sync", false),
failSync: () => settle("sync", true),
completeDetail: () => settle("detail", false),
@@ -776,6 +871,62 @@
loginButton.style.cssText = "font:inherit;padding:5px;border:1px solid #aaa;border-radius:4px;background:#eee;color:#111;cursor:pointer";
loginButton.addEventListener("click", loginSecondOfficialAccount);
controls.append(loginButton);
const quotaModeLabel = document.createElement("label");
quotaModeLabel.textContent = "Fixture:额度模式 ";
const quotaModeSelect = document.createElement("select");
quotaModeSelect.setAttribute("aria-label", "Fixture:额度模式");
quotaModeSelect.style.cssText = "font:inherit;max-width:200px;padding:4px;color:#111;background:#fff";
for (const [value, text] of quotaModes) {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
quotaModeSelect.append(option);
}
quotaModeSelect.value = quotaMode;
quotaModeSelect.addEventListener("change", () => setQuotaMode(quotaModeSelect.value));
quotaModeLabel.append(quotaModeSelect);
controls.append(quotaModeLabel);
const quotaCompleteButton = document.createElement("button");
quotaCompleteButton.type = "button";
quotaCompleteButton.textContent = "Fixture:完成额度请求";
quotaCompleteButton.style.cssText = loginButton.style.cssText;
quotaCompleteButton.addEventListener("click", () => settle("quota", false));
controls.append(quotaCompleteButton);
const resetModeLabel = document.createElement("label");
resetModeLabel.textContent = "Fixture:重置次数模式 ";
const resetModeSelect = document.createElement("select");
resetModeSelect.setAttribute("aria-label", "Fixture:重置次数模式");
resetModeSelect.style.cssText = quotaModeSelect.style.cssText;
for (const [value, text] of [["available", "可用 3 次"], ["zero", "可用 0 次"], ["error", "查询失败"], ["pending", "延迟成功"], ["pending-error", "延迟失败"]]) {
const option = document.createElement("option");
option.value = value; option.textContent = text; resetModeSelect.append(option);
}
resetModeSelect.addEventListener("change", () => { resetCreditsMode = resetModeSelect.value; render(); });
resetModeLabel.append(resetModeSelect); controls.append(resetModeLabel);
const resetCompleteButton = document.createElement("button");
resetCompleteButton.type = "button";
resetCompleteButton.textContent = "Fixture:完成重置次数请求";
resetCompleteButton.style.cssText = loginButton.style.cssText;
resetCompleteButton.addEventListener("click", () => settle("resetCredits", false));
controls.append(resetCompleteButton);
const pauseUsageLabel = document.createElement("label");
const pauseUsageInput = document.createElement("input");
pauseUsageInput.type = "checkbox";
pauseUsageInput.addEventListener("change", () => { pauseUsage = pauseUsageInput.checked; });
pauseUsageLabel.append(pauseUsageInput, "Fixture:暂停用量响应");
controls.append(pauseUsageLabel);
for (const [text, newest] of [["Fixture:完成最新用量请求", true], ["Fixture:完成旧用量请求", false]]) {
const button = document.createElement("button");
button.type = "button";
button.textContent = text;
button.style.cssText = loginButton.style.cssText;
button.addEventListener("click", () => {
const request = newest ? pending.usage.pop() : pending.usage.shift();
if (request) request.resolve(clone(request.result()));
render();
});
controls.append(button);
}
for (const [text, mode] of [
["Fixture:用量示例数据", "sample"],
["Fixture:用量空数据", "empty"],
+20
View File
@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getOfficialPlan } from "../src/officialPlan.ts";
test("Pro and Pro Lite aliases keep distinct names and badge styles", () => {
assert.deepEqual(getOfficialPlan("pro", "zh"), { label: "Pro 20x", tone: "pro20" });
for (const value of ["prolite", "pro-lite", "PRO_LITE", " pro_lite "]) {
assert.deepEqual(getOfficialPlan(value, "zh"), { label: "Pro 5x", tone: "pro5" });
}
assert.equal(getOfficialPlan("plus", "en").label, "Plus");
assert.equal(getOfficialPlan("team", "zh").label, "Team");
});
test("missing or invalid plans stay unknown and future plans do not claim a premium tier", () => {
for (const value of [undefined, null, "", " ", "pro\n", "pro\n<script>", "x".repeat(65)]) {
assert.equal(getOfficialPlan(value, "zh"), null);
}
assert.deepEqual(getOfficialPlan("Future_Plan-2", "en"), { label: "Future_Plan-2", tone: "neutral" });
assert.deepEqual(getOfficialPlan("constructor", "en"), { label: "constructor", tone: "neutral" });
});
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createOfficialProfileMonitor } from "../src/officialProfileMonitor.ts";
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
const signedIn = [{ id: "official", email: "login@example.test", hasOwnedAuth: true, canQueryQuota: true }];
const signedOut = [{ id: "official", email: null, hasOwnedAuth: false, canQueryQuota: false }];
function harness() {
const requests = [], applied = [], timers = new Map();
let nextTimer = 0;
const state = { visible: true, ready: true, revision: 0 };
const monitor = createOfficialProfileMonitor({
read: () => { const request = deferred(); requests.push(request); return request.promise; },
apply: (value) => applied.push(value),
revision: () => state.revision,
isVisible: () => state.visible,
canRead: () => state.ready,
schedule: (callback, delay) => {
assert.equal(delay, 2_000);
const id = ++nextTimer;
timers.set(id, callback);
return () => timers.delete(id);
},
});
function tick() {
assert.equal(timers.size, 1);
const callback = timers.values().next().value;
timers.clear();
callback();
}
return { ...monitor, requests, applied, timers, state, tick };
}
test("external login and logout are reflected by scheduled metadata reads", async () => {
const h = harness();
h.wake();
h.requests[0].resolve(signedOut);
await flush();
h.tick();
h.requests[1].resolve(signedIn);
await flush();
h.tick();
h.requests[2].resolve(signedOut);
await flush();
assert.deepEqual(h.applied, [signedOut, signedIn, signedOut]);
h.stop();
assert.equal(h.timers.size, 0);
});
test("focus and visibility events coalesce without concurrent reads", async () => {
const h = harness();
h.wake(); h.wake(); h.wake();
assert.equal(h.requests.length, 1);
h.requests[0].resolve(signedOut);
await flush();
assert.deepEqual(h.applied, []);
assert.equal(h.requests.length, 2);
h.requests[1].resolve(signedIn);
await flush();
assert.deepEqual(h.applied, [signedIn]);
assert.equal(h.timers.size, 1);
h.stop();
});
test("an earlier poll cannot overwrite a newer save, deletion, or directory revision", async () => {
const h = harness();
h.wake();
h.state.revision += 1;
h.requests[0].resolve(signedOut);
await flush();
assert.deepEqual(h.applied, []);
h.tick();
h.requests[1].resolve(signedIn);
await flush();
assert.deepEqual(h.applied, [signedIn]);
h.stop();
});
test("mutations block both sending and accepting background metadata", async () => {
const h = harness();
h.state.ready = false;
h.wake();
assert.equal(h.requests.length, 0);
h.state.ready = true;
h.tick();
h.state.ready = false;
h.requests[0].resolve(signedOut);
await flush();
assert.deepEqual(h.applied, []);
h.state.ready = true;
h.tick();
h.requests[1].resolve(signedIn);
await flush();
assert.deepEqual(h.applied, [signedIn]);
h.stop();
});
test("hidden windows pause reads and resume immediately on wake", async () => {
const h = harness();
h.wake();
h.requests[0].resolve(signedOut);
await flush();
h.state.visible = false;
h.wake();
assert.equal(h.requests.length, 1);
assert.equal(h.timers.size, 0);
h.state.visible = true;
h.wake();
assert.equal(h.requests.length, 2);
h.stop();
h.requests[1].resolve(signedIn);
await flush();
assert.deepEqual(h.applied, [signedOut]);
assert.equal(h.timers.size, 0);
});
test("temporary read failures retry silently and stopped readers never reschedule", async () => {
const h = harness();
h.wake();
h.requests[0].reject(new Error("auth file is being replaced"));
await flush();
assert.deepEqual(h.applied, []);
h.tick();
h.stop();
h.requests[1].resolve(signedIn);
await flush();
h.wake();
assert.equal(h.requests.length, 2);
assert.equal(h.timers.size, 0);
assert.deepEqual(h.applied, []);
});
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test from "node:test";
import { initialOfficialQuotaState, loadOfficialQuotaDetails, officialQuotaReducer } from "../src/officialQuotaState.ts";
function deferred() {
let resolve, reject;
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
const quota = (profileId = "official-a") => ({ profileId, email: "a@example.test", planType: "team", limits: [], checkedAt: "2026-09-09T00:00:00Z" });
const resets = (availableCount = 3, profileId = "official-a") => ({ profileId, availableCount, checkedAt: "2026-09-09T00:00:00Z" });
const key = (dir = "/codex/a", profileId = "official-a") => JSON.stringify([dir, profileId]);
const tick = () => Promise.resolve();
function harness() {
let state = initialOfficialQuotaState;
let sequence = 0;
const calls = [];
const dispatch = (action) => { state = officialQuotaReducer(state, action); };
function start({ queryKey = key(), profileId = "official-a", retainResult = false } = {}) {
const requestId = ++sequence;
const quotaRead = deferred();
const resetRead = deferred();
dispatch({ type: "start", key: queryKey, requestId, retainResult });
const done = loadOfficialQuotaDetails({
key: queryKey, requestId, profileId,
loadQuota: () => { calls.push("quota"); return quotaRead.promise; },
loadResetCredits: () => { calls.push("resets"); return resetRead.promise; },
dispatch, isCurrent: () => sequence === requestId,
mismatchMessage: "wrong account", invalidCountMessage: "invalid count",
});
return { quotaRead, resetRead, done };
}
return { start, calls, dispatch, get state() { return state; }, close() { dispatch({ type: "clear", requestId: ++sequence }); } };
}
test("quota and reset reads start together; resets can render while quota is still pending", async () => {
const view = harness();
const read = view.start();
assert.deepEqual(view.calls, ["quota", "resets"]);
read.resetRead.resolve(resets());
await tick();
assert.equal(view.state.resetCredits.data.availableCount, 3);
assert.equal(view.state.resetCredits.busy, false);
assert.equal(view.state.quota.data, null);
assert.equal(view.state.quota.busy, true);
read.quotaRead.resolve(quota());
await read.done;
assert.equal(view.state.quota.busy, false);
});
test("quota renders independently and reset failure remains unknown instead of becoming zero", async () => {
const view = harness();
const read = view.start();
read.quotaRead.resolve(quota());
await tick();
assert.equal(view.state.quota.data.planType, "team");
assert.equal(view.state.quota.busy, false);
assert.equal(view.state.resetCredits.busy, true);
read.resetRead.reject(new Error("reset service unavailable"));
await read.done;
assert.equal(view.state.resetCredits.data, null);
assert.equal(view.state.resetCredits.error, "reset service unavailable");
assert.equal(view.state.quota.error, "");
});
test("quota failure does not suppress a valid zero reset count", async () => {
const view = harness();
const read = view.start();
read.quotaRead.reject(new Error("quota service unavailable"));
read.resetRead.resolve(resets(0));
await read.done;
assert.equal(view.state.quota.error, "quota service unavailable");
assert.equal(view.state.resetCredits.data.availableCount, 0);
assert.equal(view.state.resetCredits.error, "");
});
test("each failed refresh retains and marks only its own previous result", async () => {
const view = harness();
const first = view.start();
first.quotaRead.resolve(quota());
first.resetRead.resolve(resets());
await first.done;
const previousQuota = view.state.quota.data;
const second = view.start({ retainResult: true });
second.quotaRead.reject(new Error("quota refresh failed"));
second.resetRead.resolve(resets(2));
await second.done;
assert.equal(view.state.quota.data, previousQuota);
assert.equal(view.state.quota.error, "quota refresh failed");
assert.equal(view.state.resetCredits.data.availableCount, 2);
assert.equal(view.state.resetCredits.error, "");
const third = view.start({ retainResult: true });
third.quotaRead.resolve({ ...quota(), planType: "pro" });
third.resetRead.reject(new Error("reset refresh failed"));
await third.done;
assert.equal(view.state.quota.data.planType, "pro");
assert.equal(view.state.quota.error, "");
assert.equal(view.state.resetCredits.data.availableCount, 2);
assert.equal(view.state.resetCredits.error, "reset refresh failed");
});
test("closing a dialog discards both late results, including after reopening the same account", async () => {
const view = harness();
const old = view.start();
view.close();
const current = view.start();
old.quotaRead.resolve(quota());
old.resetRead.resolve(resets(99));
await old.done;
assert.equal(view.state.quota.data, null);
assert.equal(view.state.resetCredits.data, null);
current.quotaRead.resolve(quota());
current.resetRead.resolve(resets(2));
await current.done;
assert.equal(view.state.resetCredits.data.availableCount, 2);
});
test("changing account or directory cannot retain old data or accept its late responses", async () => {
for (const [queryKey, profileId] of [[key("/codex/a", "official-b"), "official-b"], [key("/codex/b"), "official-a"]]) {
const view = harness();
const initial = view.start();
initial.quotaRead.resolve(quota());
initial.resetRead.resolve(resets());
await initial.done;
const old = view.start({ retainResult: true });
const current = view.start({ queryKey, profileId, retainResult: true });
assert.equal(view.state.quota.data, null);
assert.equal(view.state.resetCredits.data, null);
old.quotaRead.reject(new Error("stale failure"));
old.resetRead.resolve(resets(99));
await old.done;
assert.equal(view.state.quota.error, "");
assert.equal(view.state.resetCredits.data, null);
current.quotaRead.resolve(quota(profileId));
current.resetRead.resolve(resets(1, profileId));
await current.done;
assert.equal(view.state.key, queryKey);
assert.equal(view.state.resetCredits.data.availableCount, 1);
}
});
test("mismatched accounts and missing, fractional, or negative counts are errors rather than usable results", async () => {
for (const invalid of [null, undefined, -1, 1.5, Number.NaN]) {
const view = harness();
const read = view.start();
read.quotaRead.resolve(quota("official-b"));
read.resetRead.resolve({ ...resets(), availableCount: invalid });
await read.done;
assert.equal(view.state.quota.data, null);
assert.equal(view.state.quota.error, "wrong account");
assert.equal(view.state.resetCredits.data, null);
assert.equal(view.state.resetCredits.error, "invalid count");
}
const view = harness();
const read = view.start();
read.quotaRead.resolve(quota());
read.resetRead.resolve(resets(5, "official-b"));
await read.done;
assert.equal(view.state.resetCredits.data, null);
assert.equal(view.state.resetCredits.error, "wrong account");
});
test("a synchronous failure starting one endpoint still starts the other endpoint", async () => {
const calls = [];
const actions = [];
await loadOfficialQuotaDetails({
key: key(), requestId: 1, profileId: "official-a",
loadQuota: () => { calls.push("quota"); throw new Error("bridge unavailable"); },
loadResetCredits: () => { calls.push("resets"); return Promise.resolve(resets()); },
dispatch: (action) => actions.push(action), isCurrent: () => true,
mismatchMessage: "wrong account", invalidCountMessage: "invalid count",
});
assert.deepEqual(calls, ["quota", "resets"]);
assert.equal(actions.find((action) => action.type === "reset-success").data.availableCount, 3);
});
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createPresetProvider, PROVIDER_PRESETS } from "../src/providerPresets.ts";
test("region and plan changes create independent credentials and endpoint settings", () => {
const china = createPresetProvider("minimax", "cn");
china.apiKey = "synthetic-china-only";
china.tomlConfig = "synthetic old authentication";
const global = createPresetProvider("minimax", "global");
assert.equal(global.baseUrl, "https://api.minimax.io/v1");
assert.equal(global.apiKey, "");
assert.equal(global.tomlConfig, "");
const metered = createPresetProvider("mimo", "api");
metered.apiKey = "synthetic-metered-only";
const plan = createPresetProvider("mimo", "token-plan");
assert.equal(plan.baseUrl, "https://token-plan-cn.xiaomimimo.com/v1");
assert.equal(plan.apiKey, "");
assert.notEqual(plan.id, metered.id);
});
test("editing an instantiated model mapping cannot change future presets", () => {
const edited = createPresetProvider("deepseek", "default");
edited.modelMappings[0].model = "user-custom-model";
edited.modelMappings[0].contextWindow = 12345;
const fresh = createPresetProvider("deepseek", "default");
assert.equal(fresh.modelMappings[0].model, "deepseek-v4-flash");
assert.equal(fresh.modelMappings[0].contextWindow, 1048576);
});
test("every preset and plan inherits the custom form's full TOML before provider fields are applied", () => {
const customToml = `# existing user configuration
model = "existing-model"
model_provider = "custom"
model_reasoning_effort = "high"
approval_policy = "on-request"
[model_providers.custom]
name = "Existing provider"
base_url = "https://existing.example.test/v1"
[mcp_servers.docs]
command = "docs-server"
[features]
shell_snapshot = true
[projects."/fixture/project"]
trust_level = "trusted"
`;
for (const preset of PROVIDER_PRESETS) {
for (const variant of preset.variants) {
const created = createPresetProvider(preset.id, variant.id, customToml);
assert.equal(created.tomlConfig, customToml, `${preset.id}/${variant.id} lost inherited settings`);
assert.equal(created.baseUrl, variant.baseUrl);
assert.equal(created.model, variant.models[0].model);
assert.equal(created.apiKey, "");
}
}
});
test("preset menus contain their real default model and use native Responses routes", () => {
for (const preset of PROVIDER_PRESETS) {
for (const variant of preset.variants) {
const provider = createPresetProvider(preset.id, variant.id);
assert.equal(provider.wireApi, "responses");
assert.equal(provider.requiresOpenaiAuth, false);
assert.ok(provider.modelMappings.some((row) => row.model === provider.model));
assert.equal(new Set(provider.modelMappings.map((row) => row.model)).size, provider.modelMappings.length);
assert.ok(!provider.baseUrl.includes("anthropic"));
assert.ok(!provider.modelMappings.some((row) => row.model.startsWith("gpt-")));
}
}
assert.deepEqual(createPresetProvider("kimi", "default").modelMappings.map((row) => row.model), ["kimi-k3"]);
assert.equal(createPresetProvider("official", ""), null);
assert.equal(createPresetProvider("custom", ""), null);
});
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import { initialUsageLoadState, sameUsageQuery, usageLoadReducer } from "../src/usageStatisticsState.ts";
const query = (range = "7d", model = "", configDir = "/codex/a") => ({ configDir, range, model });
const result = (totalTokens) => ({ totals: { totalTokens }, availableModels: ["deepseek-v4-pro"] });
function loaded(queryValue = query()) {
let state = usageLoadReducer(initialUsageLoadState, { type: "start", requestId: 1, query: queryValue });
return usageLoadReducer(state, { type: "success", requestId: 1, data: result(700) });
}
test("changing date range retains the mounted data and its original range until replacement arrives", () => {
const previous = loaded();
let state = usageLoadReducer(previous, { type: "start", requestId: 2, query: query("today") });
assert.equal(state.record, previous.record);
assert.equal(state.busy, true);
assert.equal(state.record.query.range, "7d");
assert.equal(sameUsageQuery(state.record.query, state.query), false);
state = usageLoadReducer(state, { type: "success", requestId: 2, data: result(100) });
assert.equal(state.record.query.range, "today");
assert.equal(state.record.data.totals.totalTokens, 100);
assert.equal(state.busy, false);
});
test("rapid range selections cannot be overwritten by slower earlier responses or errors", () => {
let state = loaded();
state = usageLoadReducer(state, { type: "start", requestId: 2, query: query("today") });
state = usageLoadReducer(state, { type: "start", requestId: 3, query: query("30d") });
state = usageLoadReducer(state, { type: "start", requestId: 4, query: query("all") });
state = usageLoadReducer(state, { type: "success", requestId: 4, data: result(9_000) });
const latest = state;
state = usageLoadReducer(state, { type: "success", requestId: 2, data: result(100) });
state = usageLoadReducer(state, { type: "failure", requestId: 3, error: "old read failed" });
assert.equal(state, latest);
assert.equal(state.record.query.range, "all");
assert.equal(state.record.data.totals.totalTokens, 9_000);
assert.equal(state.error, "");
});
test("a failed new filter retains explicitly identifiable previous results and retry clears the error", () => {
const previous = loaded();
let state = usageLoadReducer(previous, { type: "start", requestId: 2, query: query("today", "deepseek-v4-pro") });
state = usageLoadReducer(state, { type: "failure", requestId: 2, error: "temporarily unavailable" });
assert.equal(state.record, previous.record);
assert.equal(state.record.query.model, "");
assert.equal(state.query.model, "deepseek-v4-pro");
assert.equal(state.busy, false);
assert.equal(state.error, "temporarily unavailable");
state = usageLoadReducer(state, { type: "start", requestId: 3, query: state.query });
assert.equal(state.error, "");
assert.equal(state.record, previous.record);
});
test("changing Codex directory cannot show another directory's usage or accept its pending request", () => {
let state = loaded();
state = usageLoadReducer(state, { type: "start", requestId: 2, query: query("all") });
state = usageLoadReducer(state, { type: "start", requestId: 3, query: query("all", "", "/codex/b") });
assert.equal(state.record, null);
state = usageLoadReducer(state, { type: "success", requestId: 2, data: result(9_000) });
assert.equal(state.record, null);
assert.equal(state.busy, true);
state = usageLoadReducer(state, { type: "success", requestId: 3, data: result(20) });
assert.equal(state.record.query.configDir, "/codex/b");
assert.equal(state.record.data.totals.totalTokens, 20);
});
test("leaving the tab invalidates in-flight reads while keeping data available when returning", () => {
let state = loaded();
const record = state.record;
state = usageLoadReducer(state, { type: "start", requestId: 2, query: query() });
state = usageLoadReducer(state, { type: "cancel", requestId: 3 });
state = usageLoadReducer(state, { type: "success", requestId: 2, data: result(999) });
assert.equal(state.record, record);
assert.equal(state.busy, false);
state = usageLoadReducer(state, { type: "start", requestId: 4, query: query() });
assert.equal(state.record, record);
state = usageLoadReducer(state, { type: "success", requestId: 4, data: result(800) });
assert.equal(state.record.data.totals.totalTokens, 800);
});
+141
View File
@@ -8,6 +8,147 @@
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
## 2026-09-09:v0.3.17 发布准备
- 将已完成本地验证的官方账号额度/重置次数、套餐徽标、主流预设、模型映射与供应商编辑修复整理为 v0.3.17。
更新根目录与桌面 package.json、Tauri 配置、Cargo 清单及锁文件,发布说明按用户功能合并为四条。
- 发布前版本一致性、前端类型检查与构建、cargo check --locked 通过;沿用已通过的 415 项后端、30 项前端测试。
现有 Release workflow 从版本标签构建四个平台,完成安装包、签名和更新清单校验后再发布。
## 2026-09-09:重置次数查询与套餐徽标
- 额度弹窗新增独立只读重置卡请求,读取 rate-limit-reset-credits 的 available_count,仅展示可用次数。
与 usage 请求同时发起,各自返回即更新,独立处理加载与错误;空字段和失败不当作零,刷新失败可保留
对应上次结果并明示。关闭、重开、切换账号或目录后丢弃旧返回,不提供兑换操作。
- 原生请求共用敏感请求头、固定 HTTPS 地址、超时、响应大小限制和凭据复核。文件锁采用非阻塞方式,
因此仅在读取/复核凭据时用短互斥协调两请求,网络阶段不持锁,后端并发回归验证两路可重叠执行。
- 官方摘要从自己拥有的认证解析套餐,与邮箱共用有界 JWT 读取,刷新及注销遵循同一归属规则。
数据字段对照 [Codex token_data.rs](https://raw.githubusercontent.com/openai/codex/main/codex-rs/login/src/token_data.rs),
展示映射按用户要求区分 Pro 20x / Pro 5x;未知套餐中性显示,套餐徽标不作权限或认证依据。
- 邮箱改为外层对齐容器、内层随文字收缩的边框,字号由 10px 增至 12px,后接套餐徽标。
Pro 20x 使用金色,Pro 5x 使用紫色,Plus/Team 等使用各自配色;列表邮箱列不受“当前使用”挤动。
- 验证:415 项后端测试、30 项前端测试及类型检查通过。浏览器确认两接口同时待返回,重置次数先
显示时额度仍可独立加载;在 App 最小窗口下,邮箱边框贴合内容、字号为 12px,套餐并列且操作按钮不换行。
- 本地 App 已构建、签名校验、替换并启动;旧版本和构建副本均已校验压缩归档,只保留一份安装。
## 2026-09-09:添加页文案与官方邮箱对齐
- 删除预设卡片上方可见标题与说明,保留 fieldset 的无障碍名称;API Key 输入提示改为普通用户可理解的文案。
- 官方列表邮箱移到独立居中的固定宽度列,左右信息与操作区域平均分配剩余空间,
“当前使用”出现或消失不再改变邮箱位置。窄窗口保留单独换行布局,额度弹窗徽标不受影响。
- 类型检查通过;浏览器检查预设标题/说明已消失、API Key 提示正确。在 App 最小窗口下,两个官方
账号轮流启用时邮箱左边界和宽度均一致,所有操作按钮仍在同一行,浅色与深色显示正常。
- 已构建、校验签名并替换启动本地 App。旧版本及构建副本均压缩归档,只保留 Applications 中的一份安装。
## 2026-09-09:预设继承与紧凑选择体验
- 添加流程内的官方/第三方表单共用稳定的 providers:add 过渡标识,切换卡片时直接更新内容,
卡片自身颜色过渡也取消;从列表进入添加或编辑等正常页面导航仍沿用已有动画。
- 新增自定义和预设共用 newCustomProviderForm 取得当前完整 TOML。预设只提供自己的供应商字段及
模型映射,再交给已有 build_provider_toml_draft 更新对应字段,保留 MCP、项目、推理及其他扩展设置。
修复了此前用简版 TOML 作为预设草稿、使编辑页与保存模板缺失继承设置的问题。
- 卡片删除模型与品牌介绍,仅展示图标、名称和选择状态,高度从 70px 缩为 48px。
- 验证:20 项前端测试和类型检查通过,现有 Rust 完整 TOML 草稿回归通过;浏览器验证从自定义切到
预设时 MCP/项目/推理设置保留,官方与第三方互切均保持页面过渡 idle 状态。
保存并重新编辑后继承设置仍保留。本地 App 已构建、签名校验、替换并启动,旧 App 及构建产物压缩归档。
## 2026-09-09:主流厂商预设与统计切换体验
- 添加供应商的类型下拉替换为 8 张卡片:自定义、官方登录、DeepSeek、MiniMax、MiMo、Kimi、GLM、Qwen。
MiniMax 国内/国际、MiMo 按量/Token Plan 以厂商内部按钮区分,预填各自 Responses 地址及真实模型映射。
更换厂商、区域或套餐创建独立草稿并清空 API key,避免把上一服务凭据带到新地址;保存后仍走原有供应商流程。
厂商品牌使用 Lobe Icons 的小型 MIT SVG 静态资产,来源、固定版本和许可随资产保留,无新增运行时依赖。
- PR #32 仅参考 MiniMax 预设选择思路,未合并其 Anthropic 配置。各预设按厂商当前官方文档核验,
sources 记录在 providerPresets.ts;Kimi 只列原生 Responses 支持的 kimi-k3,GLM 使用 /api/v1,
MiniMax 国内使用新版 api.minimax.cn。Qwen 上下文数值未能核准,因此保持可选默认值而不猜测。
模型可用性取决于用户实际账号和套餐;本次不使用用户密钥向这些厂商发起测试请求。
- 用量页此前按完整筛选 key 获取 data,日期变化立即得到 null,导致图表卸载并替换为大片加载占位。
改为保留同一 Codex 目录的已显示结果,轻量状态行明确待加载范围及当前展示范围,成功后原位更新。
请求序号保护快速切换和错误返回,切换目录隔离结果,离开标签页取消旧请求的更新权。
原有页面与设置标签页渐入渐出动画不变。
- 验证:19 项前端测试和类型检查通过。浏览器验证区域地址/密钥清空、模型选择、预设保存后映射保留,
延迟并逆序完成统计请求时图表持续可见,旧响应不覆盖最后选中的范围;检查浅/深色及 App 最小窗口布局。
- 本地 macOS App 已构建、签名校验、替换并启动,旧安装和构建 App 压缩归档,只注册一份安装。
## 2026-09-09:供应商身份修复与第三方模型菜单
- 实时供应商匹配此前把模型计入身份,Codex 改变当前模型后会丢失保存项选择,编辑时误报 ID 冲突,
切换时还可能把实时配置另存为新记录。现在先验证已选 ID 的接口和凭据,再恢复唯一匹配项;
同接口不同密钥保持隔离,多个候选且无已选 ID 时不猜测,不覆盖或新增重复项。
- 新增模型映射编辑,支持当前模型、已获取模型批量导入,以及自定义菜单名称、实际模型 ID 和上下文大小。
数据库迁移至 schema 5,映射随供应商保存、复制、导入更新和失败回滚保留。
启用时通过 Codex 的 model_catalog_json 加载实际模型目录,不改 models_cache.json。
显示名称不影响发给供应商的模型 ID;模型能力采用保守默认值,未承诺图片、托管工具或推理参数支持。
- 目录生成放在旧版简略模板与当前配置合并之后,避免丢失公共设置或重新带入旧菜单。
切换无映射供应商、官方账号时移除 App 管理的目录指针,保留用户自己的目录文件。
生成文件不可变,失败保留原配置和目录指针;保存记录与当前配置共同回滚。
来源标记与内容摘要支持识别其他 CODEX_HOME 中生成的目录,避免跨目录切换残留菜单。
- requires_openai_auth=false 时 Codex 不从 auth.json 读取 API key。现在为当前接口写入显式 Bearer,
清理冲突认证字段及 Authorization 头,其他请求头保留;供应商保存模板不包含明文 Bearer。
requires_openai_auth=true 保持原 API key 认证路径。
- 参考 CC Switch 的原生 Responses 模型映射方式和 Codex 的外部模型目录结构。
DeepSeek 当前已提供原生 Responses 接口,本次不增加 Chat Completions/Anthropic 协议代理。
参考:[DeepSeek Responses](https://api-docs.deepseek.com/guides/responses_api/)、
[DeepSeek Codex 配置](https://api-docs.deepseek.com/quick_start/agent_integrations/codex)、
[Codex 配置参考](https://learn.chatgpt.com/docs/config-file/config-reference)。
- 验证中实际 Codex 0.153.4 的 model/list 只显示生成的第三方目录;本机模拟 Responses 服务收到正确的
模型 ID、合成认证和 stream 请求,Codex 接收固定回复并完成 turn。DeepSeek 实际 Responses 请求返回 200。
测试不把认证写入日志,不修改真实 Codex 配置;新菜单需要重启 Codex,已有会话保留原模型选择。
- 最终验证:409 项 Rust 测试、11 项前端测试和类型检查通过。浏览器验证批量导入、重复模型与非法
上下文阻止保存、改名与映射重新编辑、启用提示及浅色/深色显示。
- macOS 本地 App 已构建、签名验证并替换启动;旧版本和构建产物已校验 ZIP 归档,
LaunchServices 只登记 Applications 中的一份 App。本批修改未推送或发布。
## 2026-09-09:简化默认官方配置操作
- 移除列表与编辑页的“读取已保存配置”按钮、前端回调,以及专用的 restore_official_provider 和
get_official_config_draft 旧 IPC 入口。前者原本用于手动恢复默认快照,并非日常账号切换。
正常编辑加载、切换、独立快照保存与默认配置重置继续使用已有路径。
- 官方与第三方行的操作位置保持一致。默认官方行显示灰色禁用的删除按钮,悬停说明不能删除;
原后端 DEFAULT_OFFICIAL_PROFILE_ID 删除保护继续生效。
- 清除不再使用的默认手动恢复实现,并保留对重置默认配置时保护命名账号的回归验证。
- 类型检查、Rust 开发检查及 15 项官方配置回归通过;macOS App 已构建、严格校验签名并替换启动。
旧安装和构建产物保持 ZIP 归档,只登记 Applications 中的一份 App。
## 2026-09-09:登录自动识别、额度行布局与本地安装清理
- 登录状态过去只在启动或手动操作后读取。新增可见概览/供应商列表的 2 秒只读元数据检测,进入页面、
窗口 focus 和恢复可见时立即检查;不读取完整 CodexState、不查询网络额度、不更新编辑草稿或全局 loading。
- 检测器单飞执行,焦点事件合并为一次后续检查;发送和接收时检查操作忙状态及共享请求序号,旧目录、
已停止检测器与过期操作结果均丢弃。仅数据改变时更新列表并使更早的列表读取失效,失败静默重试。
概览认证展示由当前官方摘要同步派生,保留原认证正文与配置状态。
- 卡片与额度弹窗共用 OfficialAccountBadge。额度窗口按实际时长排序并纵向排列,只显示接口返回的窗口。
打开的额度弹窗在对应邮箱变化时关闭,避免展示另一个账号的旧额度,也不自动发起新额度请求。
- 本地旧 App 备份与构建产物也会被 LaunchServices/Spotlight 注册。今后本地安装保留压缩归档,验证
ZIP 校验和及可执行文件摘要后移除多余 `.app` 实体并注销其注册,只保留 Applications 内的安装。
- 验证:11 项前端测试通过,其中 6 项检测器回归覆盖外部登录/注销、焦点合并、操作竞态、隐藏暂停与停止。
浏览器模拟启用空账号后外部登录,邮箱及额度按钮约 2 秒更新,get_codex_state 调用数不增加、额度请求数为零;
换邮箱后旧额度自动关闭。双窗口纵向定位及单周套餐检查通过,类型检查和 renderer 构建通过。
- macOS App 已构建、严格校验签名并替换启动;旧备份和构建 App 均改为已校验 ZIP 归档。
最终 LaunchServices 只登记 `/Applications/Codex-X.app`,备份及构建目录没有重复 `.app`。
## 2026-09-09:官方账号徽标与订阅额度
- 官方卡片在名称/URL 块右侧、操作列左侧显示邮箱或登录徽标。当前账号展示字段只来自实时认证,
非当前账号只来自自身显式快照;已注销的当前账号不会因旧快照仍存在而显示已登录。
保留原 `has_auth` 恢复语义,通过 `has_owned_auth` 提供当前展示状态。
- 邮箱读取认证内的 email 或 JWT 展示字段;解码有大小限制,展示字段不用于认证或账号选择。
只有对应配置的 ChatGPT access token 可查询订阅额度;其他已保存认证使用中性状态显示。
- 新增仅原生代码可用的凭据读取与固定 HTTPS `GET /backend-api/wham/usage` 查询,按需发送 Bearer 与
可选 ChatGPT-Account-Id。读取凭据时短暂加锁,网络期间释放锁,返回前核对凭据摘要以防账号被替换。
15 秒总超时、5 秒连接超时、禁止重定向、响应上限 1 MiB;错误不包含令牌、请求头或响应正文。
- 弹窗显示套餐、邮箱、剩余大字与进度条、已用比例、相对重置时间和本地完整日期;附加模型及代码审查
额度默认折叠。窗口标题依据 `limit_window_seconds`,不固定把 primary 当作 5 小时;缺失值不补零。
已到重置时间只提示刷新,不擅自恢复额度。保留同一账号上次成功结果时明确标记刷新失败。
- 新请求独立加载;关闭、切换账号或配置目录会使旧回调失效,列表没有后台额度请求。
- 认证缓存行为参考 [OpenAI 认证说明](https://learn.chatgpt.com/docs/auth),额度周期说明参考
[OpenAI 用量说明](https://learn.chatgpt.com/docs/pricing);接口字段依据用户提供的响应样例及只读请求验证。
- 验证:Rust 全量 382 项测试通过,覆盖认证隔离、当前注销、JWT 展示、Pro/Team 窗口、空字段、请求头、
错配与错误处理;参考 GET 返回 HTTP 200。浏览器使用合成账号验证同排徽标、双窗/单周/额外额度、
未知值、失败重试、保留旧结果与关闭后的迟到响应;浅深色布局通过检查。
- TypeScript 检查、5 项现有前端回归、renderer 与 macOS App 构建及签名严格校验通过。
已备份并替换本地测试 App,安装后可执行文件摘要与构建一致;此批改动记录在 Unreleased。
## 2026-09-08:子代理用量归属主会话
- 根因:用量聚合使用物理线程 ID 作为会话 ID,未接入会话管理的子代理分类与父子关系,导致内部线程
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.16",
"version": "0.3.17",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",