dsh上modlens的自动GeminiAPI密钥轮换

给纯文本大模型装上”眼睛”:modlens 接入 + Gemini 多 Key 自动轮换实战

从”装不上、读不出、额度秒光”,到”全自动识图 + 多 Key 自动轮换”的完整记录。
环境:Windows + DeepSeek Harness (dsh, web profile),模型为纯文本的 deepseek-v4-flash。


TL;DR

  • 在 dsh 上,modlens 不是 skill,而是原生插件——装 skill 没用,一句话装插件即可
  • 图片读取走 Gemini 视觉引擎,配置在 ~/.modlens/config.json,所有 harness 共享
  • 踩了 4 类坑:直连失败 / 间歇 500/503 / 429 配额 / 代理节点地区限制
  • modlens 不支持多 Key,于是自研了 key 池 + 429 自动轮换 + 升级重打补丁三件套
  • 轮换逻辑打进插件引擎,一次修改覆盖 GUI 粘贴、工具调用、命令行所有入口
  • 已开源:https://github.com/retr67/modlens-key-rotation

1. 缘起:纯文本模型看不见图

当前会话的模型是纯文本输入(deepseek-v4-flash)。想让 Agent 分析截图、图表、扫描件,直接喂图片是喂不进去的——需要一层”视觉桥接”把图片变成模型能读的文字。

这就是 modlens 干的事:把图片转成结构化 JSON 证据——全量 OCR 转写(ocr.full_text)、版面布局(layout.regions)、语义(画面场景、意图、实体、关系)、视觉细节、以及不确定项列表。模型拿到这些 JSON,就像真的”看见”了图。

2. 关键认知:在 dsh 上 modlens 是插件,不是 skill

安装第一步就差点走偏。modlens 官方 INSTALL.md 写的是”把 skills/modlens 文件夹拷到你的 harness skill 目录”,但文档开头的 Step 0 明确警告:

如果你在 DeepSeek Harness (dsh) 里,停下来。dsh 上 modlens 不是 skill,是原生插件。只装 skill 文件夹,你会既没有 modlens_read_image 工具,也没有 (modlens vision) 模型条目。

判断依据很简单:存在 ~/.dsh/ 即 dsh 环境。安装只需一条命令:

1
npx -y @deepseek-ai/dsh plugin --profile web add @liustack/modlens@3.16.5

它会:把插件写进 web profile 的 package.json(依赖 + bundles)、跑 pnpm 安装、并处理 pnpm 11 的发布窗口限制(写入 minimumReleaseAgeExclude)。验证:

1
npx -y @deepseek-ai/dsh plugin --profile web list

装完需要重启 dsh,模型选择器里才会出现 (modlens vision) 条目。

3. 配置视觉引擎:Gemini + 代理

引擎配置存放在 ~/.modlens/config.json(所有 harness 共享),视觉引擎有 Gemini / OpenAI 兼容 / Antigravity CLI 等多条路径。推荐 Gemini(免费 key、无头环境可用、读取只要 5~10 秒)。

1
2
modlens config set gemini-api.apiKey <KEY>
modlens config set provider gemini-api

健康检查(不耗额度):

1
2
3
4
5
6
7
Node
[ok] v24.19.0 (minimum 22.19)
Providers
[ok] gemini-api: apiKey: file
Selected provider
gemini-api
reason: provider set in the config file

判断成功的标准就是 Selected provider 那两行:选中的 provider 必须是上面 Providers 列表里 [ok] 的那个。其他 provider 显示 [!!] 属正常,无需处理。

4. 踩坑实录(含金量最高的一段)

4.1 直连失败 → 走本地代理

首次读取报 UND_ERR_CONNECT_TIMEOUT。排查发现这台机器直连不了 Googlegoogle.comgenerativelanguage.googleapis.com 全部超时,而 GitHub / npm registry 正常——典型的网络环境限制。

解法:检测到系统已启用本地 Clash 代理 127.0.0.1:7897,配置给 modlens:

1
modlens config set proxy http://127.0.0.1:7897

顺带学到的排障技巧:确定”是不是代理问题”,先看套上代理后能否建立 TLS 隧道、能否 GET 通目标域名,再谈业务请求。

4.2 间歇性 500/503:服务端故障,重试即可

代理通了之后,读取又随机报 500 INTERNAL / 503 Unavailable。用脚本逐字节复现 modlens 的请求,发现同样的请求有时 200 有时 500,成功率大约 25%~33%,且多个 Gemini 模型(3.5/3.6/3.7-flash)都这样——这是上游服务端/代理出口的间歇性不稳定,不是请求格式或配置问题。策略就是”带退避的重试”。

4.3 429 配额:免费 key 的日常

免费 key 的日配额/限流触发 429 RESOURCE_EXHAUSTED / RATE_LIMIT_EXCEEDED关键认知:Gemini 的配额是按 key 独立计算的——一把 key 烧完,换一把全新 key 就是全新额度。这正是”多 Key 轮换”能解决问题的理论根基。

4.4 地区限制:轮换救不了的坑

某次突然全部读取报 400 "User location is not supported for the API use"。查代理出口 IP 是泰国(曼谷)——Gemini API 不支持泰国。这是最阴的坑:它按 IP 地域一刀切,换多少把 key 都没用,只能把代理节点切到支持地区(美/日/港/新/韩等)。教训:遇到全 key 同时失败的 400,先查出口地区,别急着怀疑 key。

5. 核心 DIY:多 Key 自动轮换

5.1 为什么需要

我查了 modlens 源码:每 provider 只有单 key,没有多 key / 轮换 / 429 重试。多把 key 只能”手动换”。于是自研了一套。

5.2 三件套结构

组件 作用
rotate.ps1 key 池管理:add / list / rotate / status
引擎补丁 打进 dist/main.js任何入口读到 429 自动换 key 重试
ml.ps1 命令行读取包装器:429 轮换 + 5xx 退避重试(双保险)
patch.ps1 modlens 升级后一键重打引擎补丁

Key 池存在 ~/.modlens/api-keys.json(与 config.json 同目录、同信任级别)。

5.3 引擎补丁原理

补丁目标函数是 executeGeminiApi(Gemini 读取的核心)。改前:读一次 key → 发一次请求 → 失败直接抛错(429 成死路)。改后for(;;) 循环,只有 429 才触发轮换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (;;) {
// 用 activeKey 发请求
const response = await apiFetch(url, { headers: { "x-goog-api-key": activeKey } });
if (response.ok) { /* 解析,返回 —— 不变 */ }

if (response.status === 429 && rotationCount < 8) {
const pool = JSON.parse(fs.readFileSync(poolPath, "utf-8"));
const next = pool[(idx + 1) % pool.length]; // 循环取下一个
if (next && next !== activeKey) {
setConfigValue("gemini-api.apiKey", next); // 持久化:下次会话直接用新 key
activeKey = next;
rotationCount++;
continue; // 重发同一个请求
}
}
throw new Error(`Gemini API error ${response.status}: ...`);
}

设计要点(每条都有讲究):

决策 理由
只对 429 轮换 配额按 key 独立,换 key 有效;而 400/500/503 对所有 key 一视同仁,轮换白费
取模循环 最后一把用完绕回第一把
先持久化再重试 复用 modlens 自己的 setConfigValue,让新 key 在下次读取/重启后仍然生效
上限 + 去重保护 防止死循环;key 池全烧完时明确报错提醒补 key
请求体一行不动 只改 x-goog-api-key 头,绝不破坏 API 契约

5.4 为什么”改一个文件”就全覆盖?

这是最有价值的架构洞见之一。modlens 包里其实是两个组件:

1
2
3
@liustack/modlens
├── dsh/index.js ← 插件壳:注册工具、粘贴转路径——没有任何读图逻辑
└── dist/main.js ← CLI 引擎:真正的读图、调 API、解析 JSON

插件壳的 execute() 不做读图,而是 spawn 子进程去跑 dist/main.js(源码里 CLI_PATH = new URL('../dist/main.js', import.meta.url))。所以GUI 粘贴、modlens_read_image 工具、命令行,最终都汇入同一份引擎代码——补丁只需打一处。这也解释了为什么 modlens 在 dsh 上要装成插件而不是 skill:插件是给 dsh 的适配壳,引擎是 pnpm 装进来的同一个 CLI。

6. 升级是补丁的天敌(以及怎么优雅应对)

npm 把 node_modules 里的包当不可变副本:升级 = 拉新 tarball = 手工改动被冲掉。实测:modlens 从 3.16.5 升到 3.16.7,补丁果然被覆盖

应对方案就是 patch.ps1:它内置补丁的前后文本 hunk,检测到 [patch:rotate] 标记消失就自动重打,再跑 node --check 校验语法。幂等可重复执行:

1
2
powershell -ExecutionPolicy Bypass -File "$env:USERPROFILE\.modlens\patch.ps1"
# => patch applied and syntax-checked OK

如果未来版本重写了目标函数、hunk 匹配不上,脚本会明确报 “no match” 而不是静默改坏文件——此时再人工出新的补丁。顺手收获:补丁被冲后重打时,发现激活 key 已经自动从 [1] 轮换到 [2]——说明轮换系统在真实读取中已经实战生效过。

7. 发布与合规:别把功劳和 license 搞错

沉淀成开源仓库 https://github.com/retr67/modlens-key-rotation 前,做了三件事:

  1. 隐私清理:仓库内无任何 key、代理端口、机器路径(脚本全部用 $env:USERPROFILE 派生),全仓正则扫描零命中
  2. 标注衍生关系:补丁是 modlens 源码的衍生修改,而 modlens 是 MIT(Copyright © 2026 Leon Liu/liustack)——MIT 允许修改再分发,但必须保留原始版权声明,所以仓库里放了 LICENSE.modlens(modlens 原文)+ ACKNOWLEDGMENTS.md(清晰区分”哪些来自上游、哪些是我们自己的”,并引导用户给上游点 star)
  3. 分清许可:我们自己的脚本/文档用 MIT,补丁 hunk 走 modlens 的 MIT

8. 最终效果

场景 识图 429 自动轮换
当前对话
新开对话 / 换工作区 ✅(插件是 profile 级) ✅(轮换在插件引擎内)
重启 dsh
命令行 ml.ps1 ✅(脚本 + 引擎双保险)

日常使用完全无感:粘贴图片 → 插件读取 → 429 自动换 key 重试。读一次约 3~5 秒,返回的是一份完整可引用的结构化证据。

9. 一些可以带走的心得

  1. 改第三方包先想清楚”不可变”代价:node_modules 补丁天然脆弱,必须脚本化 + 幂等 + 语法自检
  2. 架构决定补丁面:壳 + 引擎的分离,让”改一处覆盖所有入口”成为可能——设计工具时值得借鉴
  3. 排障先分层:连不上→查代理;全 key 挂→查地区;偶发失败→查服务端;单 key 挂→查配额
  4. 配额是按 key 独立的:多 key 轮换是真能续命的方案,几把免费 key 就能撑很久
  5. 合规不是小事:衍生作品必须带原作者 LICENSE 声明,署名页把归属写清楚,皆大欢喜

环境记录:Windows / DeepSeek Harness (dsh) web profile / model deepseek-v4-flash / modlens 3.16.7 / 2026-08