dsh-workspace-api-key
Verifieddsh-workspace-api-key · v0.2.6 · MIT · Web UI
A DeepSeek Harness plugin that binds a separate DeepSeek API key to each workspace, or to a single session, so the spend of one key stays with the sessions you choose.
Install
dsh plugin add dsh-workspace-api-key Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Readme
dsh-workspace-api-key
English | 中文
一个 DeepSeek Harness 插件:为每个工作区——必要时也可以为单个会话——单独绑定一把 DeepSeek API key。
DSH 只从一个全局凭据 ref(默认 DEEPSEEK_API_KEY)解析 DeepSeek key,于是所有工作区的所有会话花的都是同一把 key。这个插件加了一个面板,让单个工作区、甚至单个会话可以用自己的 key,这样一把 key 的花费就只落在你指定的会话上。没有单独配置的层级继续用上层设置:
会话 key → 工作区 key → 系统默认
你会得到
- 一行侧栏入口,叫「API Key 分配」,紧跟在宿主自己的「插件」行下方,点开是全宽页面。
- 按工作区配置的 key —— 列出
workspaceRegistry服务里注册的全部工作区,各自一个 key 输入框。 - 按会话配置的 key —— 每个工作区行可以展开(默认折叠),列出该目录下的会话,每个会话有自己的 key;从上层继承的会话会如实标注层级,而不是假装自己配了 key。
- 系统默认卡片 —— 当前生效的 ref、值的来源(
file、env等)以及打码后的值。 - 测试连接 —— 用该 key 真发一次
POST <baseURL>/messages(max_tokens: 1),返回ok、invalid、quota、rate-limit或unreachable;测试成功会顺手清掉失效标记。 - key 失效提醒 —— 401/403 把绑定的 key 标为
invalid,402 标为quota,429 标为rate-limit。当你点进一个生效 key 已失效的会话时,覆盖层会说明情况,并给出去配置、改用上层设置或稍后。 - 不匹配提醒 —— 如果绑 key 时记下的是某个服务商(比如
deepseek-official),而该会话现在跑在另一个服务商上,点进这个会话时会提醒你,因为那次请求会拿着对方不认的 key 发出去。 - 绝不悄悄换 key —— 两种提醒都不会改动实际使用的 key,决定权始终在你手上;自动替换会直接破坏「按会话分离计费」这个初衷。
- 一键回退 —— 清空会话条目回到工作区 key,清空工作区条目回到系统默认。
实现方式
宿主半边(lib/index.js):
- 每个进程包装一次
credentials.resolve(通过ctx.effect在卸载时还原),而不是去注册第二个同名服务或第二条 provider 路由——对一个已被占用的名字,cordis 不允许这两者; - 从
agents.currentInitiator()?.session取当前会话的目录与 id:agent loop 把整个回合跑在那个异步作用域里,所以解析凭据时这两个值都拿得到,解析器据此选中「有 key 的最具体层级」; - 把该目录与
workspaceRegistry.list()做规范化后比对(绝对化、去掉尾部分隔符、Windows 折大小写); - 由每一层派生出稳定的 ref —— 工作区是
DEEPSEEK_API_KEY_WS_<sha1(规范化路径) 前 16 位十六进制大写>,会话是DEEPSEEK_API_KEY_SS_<sha1(会话 id) 前 16 位十六进制大写>(会话 id 含-,不能直接当 ref 名)——通过凭据服务写入 key,并返回这个 ref 的值; - 只改写 provider 真正会读的 ref:默认 ref、每个适配器配置的
apiKeyEnv,以及插件自己生成的 ref;其它 ref 原样透传; - 会话列表来自
sessionController服务(list(),会拆开它{items: […]}的信封),控件缺失 / 抛错 / 列表为空时再退到宿主 live 会话服务(ctx.get('sessions').list()),所以那种情况下面板仍能列出会话;侧栏藏起来的两种会话这里也藏:子代理会话(origin: 'subagent'或带parentSession)和空会话(建了但一句话都没发过,侧栏只显示当前开着的那个)。两种都不过滤的话,一个只有 1 个真实会话的工作区会列出 13 个; /wsk-api/state会带上插件自身版本与会话探针(available、shape、source、count、withoutCwd、subagents、samples、error,以及工作区路径样本),面板据此说明为什么没有列出会话:宿主半边是旧版本(只刷新了页面而没重启应用)、没有sessionController、list()抛错、信封形状不认识,还是会话的cwd与任何工作区路径都对不上;- 自己的账本(哪一层对应哪个 ref、失效标记、key 是给哪个服务商记的)落在
$DSH_HOME/storages/dsh-workspace-api-key.json; - 通过
agent/request-errorwaterfall 观察失败,但从不返回{kind: 'retry'}——只记录判定结果并next()放行。
浏览器半边(lib/client.js)注册到 sidebar.panellist 槽(order: 1)、main 槽(key 为 workspace-api-key)与 shell.overlay 槽(点进会话时的提醒),和 dsh-skill-mcp-panel、dsh-429-guard 用的是同一套槽。全部是朴素的 React.createElement,没有构建步骤,也不打包任何依赖。它会把宿主行与浏览器端 sessions 存储(ctx.get('sessions').list.getSnapshot(),也就是侧栏自己渲染的数据)合并起来,因此标题取自 displayTitle,浏览器端只认识一部分会话时也不会把宿主行挤掉,宿主列不出会话时面板也照样有会话可列。这个存储由 dsh-api-session-controller 在插件 apply() 之后才提供,所以面板与覆盖层都会为它重试(每 500ms 一次,最多 40 次),而不是首次渲染拿不到就放弃。覆盖层向 /wsk-api/check?sessionId=…&path=… 查询当前占据主视图的会话,每个会话最多问一次,它的按钮只负责把你带到面板——改 key 只能由面板里的操作完成。
HTTP 接口(仅限回环)
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | /wsk-api/state |
默认 ref 状态、工作区及其会话、覆盖项、失效标记 |
| GET | /wsk-api/resolve |
当前会话此刻解析到哪一层、哪个 ref |
| GET | /wsk-api/check |
?sessionId=…&path=… 只读判定:生效层级、key、失效标记、服务商不匹配、needsAttention |
| POST | /wsk-api/set |
{scope: 'workspace' | 'session', path | sessionId, title, key, provider?} 绑定 key;{useDefault: true} 或空 key 为清除 |
| POST | /wsk-api/test |
{scope, path | sessionId | ref}(或 {key})真发一次请求并返回判定;invalid 打标记,ok 清标记 |
| POST | /wsk-api/clear-flag |
{ref} 或一个目标,清掉失效/配额标记 |
只接受本机回环调用(127.0.0.1、::1、::ffff:127.0.0.1),且 Origin 必须同源或缺失。任何响应都不会包含明文 key,只有掩码。
工作区目标也接受不是注册工作区的目录;这类条目会作为「未分组目录」和它们所属的工作区一起列在面板里。
安装
插件被市场收录后,可以直接在「设置 → 插件市场」里一键安装;也可以手动装:
# 在 profile 目录里,例如 ~/.dsh/profiles/desktop
pnpm add "dsh-workspace-api-key@github:COH2357/dsh-workspace-api-key"
然后把 "dsh-workspace-api-key" 加进该 profile package.json 的 dsh.profile.bundles,重启 DSH。本地开发用 link 代替:
"dependencies": { "dsh-workspace-api-key": "link:/path/to/dsh-workspace-api-key" }
测试
node test/host.test.mjs # 130 条断言
node test/client.test.mjs # 139 条断言
两个套件都不需要启动 DSH。宿主套件用一个假 ctx(假的 credentials、workspaceRegistry、agents、sessionController、sessions、webServer,以及打桩的 fetch)驱动 apply(),覆盖 ref 派生、会话→工作区→默认的级联、工作区匹配、把第二个适配器的 apiKeyEnv 一起重定向、清回默认、401/402/429/5xx 的判定、回环与来源校验、状态落盘,以及会话探针(含 {items} 信封与 live 兜底)。浏览器套件通过一个极小的 window.__ModuleLoader__ 与自制 React 运行时加载 lib/client.js,渲染出面板与覆盖层,断言槽注册、首屏渲染、会话折叠/展开、会话级保存与测试、宿主列不出会话时从浏览器端存储恢复会话(标题取 displayTitle、隐藏子代理会话与空会话、浏览器端只认识一部分会话时不挤掉宿主行)、会话列表服务晚于插件提供时的重试、未分组目录卡片、失效与不匹配提醒、「去配置」的交接、没有会话列表时的降级,以及 ctx.get('layout') 在插件 apply 之后才可用时返回键仍然有效。
要求与限制
- 已在 DSH Desktop 2.0.17(
@deepseek-ai/dsh*0.2.0-rc.2、@deepseek-ai/cordis4.0.4)上验证。插件没有运行时依赖,也不 import 任何@deepseek-ai/*包,只用宿主服务(credentials、workspaceRegistry、agents、sessionController、webServer、llm、settings)与 Node 内置模块。 - 每一层只有一把 key:一个会话无论被哪个 provider 路由读取,用的都是同一把 key;key 记下的服务商与会话当前服务商不一致时会被报为「不匹配」,但同一服务商内换模型不算不匹配,因为 DeepSeek key 是账号级的。
- 会话按目录(
cwd)归入工作区。宿主自己的list()会跳过从未存过cwd的历史会话,而 live 兜底只认识当前装载中的会话,所以面板列出的会话可能比侧栏少;只在浏览器端存储里可见的会话,除了侧栏知道的那个cwd之外没有别的路径信息。 - 「测试连接」说的是 Anthropic Messages 协议(
POST <baseURL>/messages带x-api-key与anthropic-version: 2023-06-01),也就是 DeepSeek 路由用的协议;换了别的协议的 provider 一样可以按层绑 key,只是测试可能把有效的 key 报成失败。 - key 通过凭据服务存放在
$DSH_HOME/.credentials.yaml的refs:里,明文,和默认 key 完全一样。它不会被写进会话日志、插件状态文件或插件目录。 - 匹配是规范化之后的文本比较。如果某个会话的
cwd是指向已注册工作区路径的符号链接或 junction,则匹配不上,会走上层层级。 - 卸载插件不会删除已生成的
*_WS_*/*_SS_*ref,可在「设置 → 模型」里清掉。 - 如果面板列出了工作区却没有可展开的会话,请看面板底部的诊断行。出现红色「宿主端插件还是旧版本」横幅,说明 DSH 进程没有重启过:宿主半边只在启动时加载,关窗口和刷新页面都不算——请完全退出 DSH Desktop 再打开。其余情况诊断行会写明宿主看到多少个会话、会话列表来自哪个来源(
sessionController、live 兜底,还是浏览器端存储);一个都对不上时,还会并排给出会话cwd样本与工作区路径样本,便于比对。
许可
MIT