dsh-plugin-ai-switch
Verifieddsh-plugin-ai-switch · v0.1.0 · MIT · Web UI
AI account switch and local route proxy for the DeepSeek Harness web GUI — a port of ai-switch: per-platform account pools, an OpenAI/Anthropic/Gemini-compatible local proxy with protocol bridging, and safe native config writes for Codex, Claude Code, Gem
Install
dsh plugin add dsh-plugin-ai-switch Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
dsh-plugin-ai-switch · AI 切换
把 ai-switch 搬到 DSH web GUI 上:七个平台的账号池、一个本地路由代理(带四种协议之间的双向桥接)、以及对十七个客户端配置文件的安全直写。
界面是原样复刻的 —— 参考实现那 33.5k 行 React 一个字没改地搬了过来(唯一改动的是 src/lib/transport/index.ts 那一个文件,理由写在它的头注释里),用 Vite 打包成单页应用,由插件自己的 host 路由提供服务。换掉的是下面那一半:107k 行 Rust 变成约 1.1 万行 JavaScript,SQLite 变成 JSON 文档,Tauri IPC 变成 HTTP。
┌ 侧栏 ─────────┬ 主区 ─────────────────────────────────────────────────┐
│ ▸ Codex │ 账号池 (Codex) ● 代理运行中 127.0.0.1:19527 │
│ Claude ├───────────────────────────────────────────────────────┤
│ Gemini │ ┌──┬─────────────┬──────────┬────────┬──────────────┐ │
│ Grok │ │≡ │ 中转 A │ 优先级 1 │ 3 并发 │ 98% · 1.2s │ │
│ OpenCode │ │≡ │ 中转 B │ 优先级 3 │ 0 并发 │ 冷却 47s │ │
│ OpenClaw │ │≡ │ 官方账号 │ 优先级 3 │ 1 并发 │ 周额度 62% │ │
│ Hermes │ └──┴─────────────┴──────────┴────────┴──────────────┘ │
│ ▸ 会话 │ [ 池内 | 池外 | 已归档 ] 写入客户端配置 ⌄ │
│ ▸ 用量 │ 实时日志 200 gpt-5.6-sol → glm-5.3 via 中转A 1.1s │
└───────────────┴───────────────────────────────────────────────────────┘
一个插件 = 两个半边:
- host 半边(
exports ".")跑在 dsh 的 Node 进程里:账号存储、本地路由代理、协议桥接、客户端配置直写、面板事件 WebSocket、静态面板产物。它不注册任何 agent 工具,也不写系统提示 —— 普通会话里 agent 的行为完全不变。 - browser 半边(
exports "./client")跑在 web GUI 里:往侧栏放一个图标,点开后在中间栏挂一个同源 iframe。350 行,界面本身在 iframe 里。
它到底做什么
一句话:让本机的 agent CLI 指向一个本地地址,由这个插件决定每个请求实际走哪个上游账号。
- 你在面板里加若干账号(中转站的 API key,或导入的官方登录态),拖成一个有序的池。
- 插件在
127.0.0.1:19527起一个代理,并按平台各发一把sk-ai-switch-…的 key。 - 点「写入客户端配置」,插件就地改
~/.codex/config.toml、~/.claude/settings.json等等,把地址和 key 填进去 —— 只加自己那一段,别的一个字节不动。 - 之后
codex/claude跑起来打的就是这个代理:它挑账号、改写模型名、必要时翻译协议、失败了换下一个账号重试、把用量记进账本。
五件跟参考实现不一样的事
- 后端是 Node,不是 Rust。107k 行变成约 1.1 万行。原样保留了「会动别人文件」那部分的纪律:快照、原子替换、并发修改检测、带哈希守卫的回滚,以及两个保留格式的编辑器(
configwrite/toml.js、configwrite/yaml.js)—— 参考实现用toml_edit做到「改三个键、注释一个不掉」,JS 生态没有能往回写的 TOML 库,所以自己写了一个按行编辑的。会吃掉注释的配置写入,是一次之后就不敢再用的配置写入。 - SQLite 变成 JSON 文档。数据集就是几百个账号行加一个池顺序,开面板时读、用户动作时写;28 个迁移和 sqlx 校验和账本在这里买不到任何东西。原子写和 0600 保留了 —— 这些文件里有 API key 和 OAuth refresh token。
- 路由代理自己占端口(19527,被占就往上找),不挂在 dsh 的 web server 上。三个理由,按重要性排:CLI 需要一个不随 dsh 端口变化的固定地址;dsh 的 server 后面有一道这些 CLI 过不去的鉴权;上游的流式响应挂在同一个 origin 上会吃掉浏览器那六条连接里的一条。
- 面板事件走 WebSocket,同样是为了那六条连接 —— 这个仓库里九个插件已经把额度用满过一次,症状不是报错而是整个 GUI 不动。细节见
host/socket.js的头注释。 - 桌面独占的那一半没有了:Tailscale 私网、本地 HTTPS(要往系统信任库装根证书)、
ccswitch://协议注册、开机自启、带签名的自动更新。每一样都需要一个「已安装的应用程序」,而这是个插件。面板里对应的按钮不是坏的,是按参考实现自己的isDesktop()分支不显示。
平台支持
| 平台 | 路由账号与 API 路由 | 原生配置写入 | 官方导入与额度 |
|---|---|---|---|
| Codex | 支持 | 支持 | 支持 |
| Claude Code | 支持 | 支持 | 上游账号流程允许的范围内支持 |
| Gemini CLI | 支持 | 支持 | 支持导入;不声称支持官方额度 |
| Grok | 支持 | 支持 | 上游账号流程允许的范围内支持 |
| OpenCode | 部分:API 账号必须显式给 base URL 和接口格式 | 支持 | 不支持 |
| OpenClaw | 部分:同上 | 支持 | 不支持 |
| Hermes | 部分:同上 | 支持 | 不支持 |
后三个是 agent harness 不是模型厂商,没有自己的官方登录态 —— 「部分支持」的全部含义就是这个。
写入哪些文件
十七个客户端目标,每个都是「加一段 provider,别的不动」:
| 目标 | 文件 | 格式 |
|---|---|---|
| Codex | ~/.codex/config.toml + ai-switch-model-catalog.json |
TOML(保留注释) |
| Claude Code / Gemini CLI / Grok | ~/.claude、~/.gemini、~/.grok 下的 settings.json |
JSON |
| DeepSeek Harness | ~/.dsh/settings.yaml 的 llm-pi-ai.providers |
YAML(保留注释) |
| ZCode(Codex / Claude) | ~/.zcode/v2/config.json |
JSON |
| WorkBuddy / CodeBuddy CLI(各两个平台) | ~/.workbuddy、~/.codebuddy 下的 models.json |
JSON |
| Qoder CLI(Codex / Claude) | ~/.qoder/settings.json |
JSON |
| OpenCode | ~/.config/opencode/opencode.json |
JSON |
| OpenClaw | ~/.openclaw/openclaw.json |
JSON |
| Hermes | $HERMES_HOME/config.yaml |
YAML(保留注释) |
第三行值得单独说:它写的是 dsh 自己的配置。写完之后 dsh 的 agent 就跑在这个池上,这也是这个插件装在 dsh 里比装在别处更有意思的地方。
协议桥接
本地入口四种、上游四种,4×4 全实现(host/bridge/):
| → chat | → responses | → anthropic | → gemini | |
|---|---|---|---|---|
chat(/v1/chat/completions) |
直通 | ✓ | ✓ | ✓ |
responses(/v1/responses,Codex) |
✓ | 直通 | ✓ | ✓ |
anthropic(/v1/messages,Claude Code) |
✓ | ✓ | 直通 | ✓ |
gemini(:generateContent) |
✓ | ✓ | ✓ | 直通 |
非流式和流式都译,工具调用、思维链、图片、用量计数都过。对角线(from === to)是字节直通,故意的:绕一趟中间表示会把中间表示没有字段的东西悄悄吃掉(Responses 的 encrypted_content、Gemini 的 safetyRatings、Anthropic 的 cache_control)。
安全边界
- 凭据:API key 和 OAuth token 明文存在
<DSH 家目录>/dsh-plugin-ai-switch/accounts.json,0600,原子写。不加密是有意的(密钥只能放同一块盘上);真正起作用的是文件权限,以及浏览器永远拿不到明文 —— 列表接口把secret_payload_json换成{"__masked":true},编辑时 host 把没重发的密文合回去。 - 配置直写:路径必须是绝对路径、父目录必须是真目录、目标必须是普通文件 —— 软链接(含 Windows junction)直接拒绝,那是一次我们没有授权的重定向。写前后各算一次 sha256,写之前再比一次;被别人改过就报
config.concurrent_modification而不是覆盖。原字节留一份 0600 备份,回滚是逐字节还原,且回滚前要求文件仍是我们写的那个哈希。 - 代理只听回环,
127.0.0.1。它是一个本地凭据代理,不是网关。请求靠 bearer token 认平台,认不出就 401。 - 客户端 header 不整体转发给上游 —— 客户端的
authorization是我们自己的代理 key,绝不能出网。只放过user-agent(有中转站按它计费,且不含凭据)。 - Gemini 与 Grok 的配置里不写凭据:这两个 CLI 读哪个环境变量当自定义端点的 key,参考实现自己也没验证过,写一个猜的等于白往文件里放一把 key。
- 会话文件只读,且路径受限:
get_session_messages收到的路径要求解析后仍在扫描根内(比较时带路径分隔符,<root>-secrets过不去)。参考实现这里是任意文件读。 - 终端会真的起进程:Vibe 界面能在你选的目录里跑
codex/claude。这是它的用途,也是它的风险;插件退出时会杀掉所有会话,不留孤儿进程。
与参考实现的行为差异
~/.ai-switch/不共用。桌面版在那里放 SQLite 和自己的 settings;两个不同 schema 的进程写同一个目录、两个路由代理抢同一个端口,是等着发生的数据丢失。插件只拥有<DSH 家目录>/dsh-plugin-ai-switch/。要迁移就用面板里的导入。secret_storage报file而不是keyring。插件没有 OS keyring 服务可用,这个仓库里每个插件的密钥都在 0600 文件里。字段还在,值是老实的。ccswitch_deeplink_compat_supported永远是 false,注册 URL scheme 需要一个已安装的应用。- HTTPS 那组命令不提供。给本地代理上 HTTPS 需要往系统信任库(以及 Linux 上的 NSS)装一张根证书,插件不该做这件事。面板的 HTTPS 面板按
isDesktop()不显示。 node-pty是可选依赖。装上了终端就是真 TTY;没装退化成管道模式,host 侧回显输入,并在终端里直接写明「全屏 CLI 不会正常显示」—— 而不是给一个黑框让人猜。- YAML 的块序列缩进与 serde_yaml 不同(
-在父键下缩进,参考实现平齐)。两者解析结果一致,且陈旧检测比较的是我们自己的输出和自己的渲染,所以只是外观差异。 - 额度刷新一次也不会整批失败:单个账号出错变成一条带 message 的 outcome,这和参考实现一致,但值得写出来 —— 面板是按平台整批刷的。
安装
npm 上还没有,先从源码装:
cd plugins/dsh-plugin-ai-switch
npm install # ws 是运行时依赖,面板构建要 vite/react
npm run build
cd ../..
dsh plugin --profile web add link:plugins/dsh-plugin-ai-switch
装完重启 dsh 服务生效。侧栏会出现一个「AI 切换」图标。
卸载:
dsh plugin --profile web remove dsh-plugin-ai-switch
架构
src/
index.js cordis 入口:只 inject webServer
shared/protocol.js ApiError 信封 + 校验器(面板按 code 分支)
host/
routes.js 命令表(101 条,含桌面功能的诚实禁用状态)+ 路由挂载
http.js 信封、body、静态文件
socket.js 面板事件 WebSocket(SSE 兜底)
sdk.js DSH 家目录、原子写、语言(不 import @deepseek-ai/*)
store.js JSON 文档存储:原子写、串行化更新、坏文件挪到一边
platforms.js 七个平台、能力矩阵、十七个目标、CLI 启动器
accounts.js 账号域 + 池(选号、优先级带、并发、冷却)
models.js 模型目录:别名 → 上游名、上下文窗口、Codex 目录文件
keys.js 每平台一把代理 key,轮换后旧 key 仍受理
proxy.js 本地路由代理:挑号、重试、记账、实时日志
bridge/ 4×4 协议桥(canon/chat/responses/anthropic/gemini/sse)
configwrite/
safewrite.js 快照、原子替换、并发守卫、路径锁
toml.js yaml.js 保留格式的编辑器
adapters.js 十七个客户端适配器
index.js 协调器:准备 → 提交 → 回滚
sessions.js usage.js 读各 CLI 的会话记录与 token 用量
terminals.js agent 终端(node-pty 可选)
settings.js ledger.js 设置、请求账本
client/index.js 侧栏图标 + 同源 iframe
webview/ 参考实现的 React 前端 + 七个 Tauri shim
开发
npm run check # 语法检查 + 重建 lib/(跳过 vite)
npm test # check + node --test
npm run build # 含面板的完整构建
npm run typecheck # 面板的 tsc
lib/ 随提交入库(外壳打包时不跑 vite),所以改完 src/ 或 webview/ 要 npm run build 再提交。
界面语言
参考实现自带中英双语,语言存在 localStorage['ai-switch.language']。host 半边按 DSH_DESKTOP_LANG / LC_ALL / LANG 判出 zh-CN 或 en,client 半边把它放进 iframe 的 query,面板的 index.html 在 React 启动前种进那个 key。在面板里自己切过语言就以你切的为准。
已知限制
- 面板 JS 约 3.4 MB(React + three.js 的 Vibe 座舱 + xterm),首次打开要下。iframe 是点开才建的,不点不下。
- 模型连通性测试已走完整本地链路(客户端协议 → 本地代理 → 模型映射 → 协议桥 → 上游);结果页会同时展示入口、命中账号、上游接口和原始响应。
- 官方账号的额度刷新覆盖了参考实现的六种端点风格,但官方端点会变;失败会在卡片上写明原因而不是显示 0。
- 用量总览读的是各 CLI 自己的记录文件加本插件的账本;本插件之外发生的请求(直连上游)在账本里没有,这和参考实现一样。
许可
MIT。参考实现 ai-switch 亦为 MIT,其许可副本随包发布在 LICENSE-ai-switch。