# 用量统计适配器开发引导(v2 契约,面向 Agent 与用户) > 适用插件:`@wingsky-1/dsh-provider-usage`(v2 契约重构版)。 > 本文是 Agent 自主引导用户接入自定义数据源的权威流程手册。 > 快速参考:契约细节见第 3 节;参考实现见内置适配器源码(`src/domain1/adapters/opencode-go.mjs`、 > `src/domain1/adapters/deepseek-official.mjs`、`src/domain1/adapters/zai-coding-cn.mjs`)。 > 用户只需编写**纯 JS 的 .mjs 文件**(零 import、完全自包含),无需关心任何类型声明。 > 插件内部运行机制(取数管道 / 注册表 / 热更新 / 设置页交互)的图解见 [architecture.md](architecture.md)。 --- ## 1. 自主引导原则(先读) 1. **能自己做的绝不问**:接口地址、鉴权方式、字段含义、命名、保存路径……凡能从用户既有信息(模型配置、会话上下文)推断或查证的,一律自行完成。API 端点优先从模型配置的 baseUrl 读取,用量接口按同域惯例(`/v1/usage`、`/usage`、`/quota` 等)确认——不为此问用户;拿不到 baseUrl 且官方文档也没有用量接口说明时,才在审核卡里如实标「端点未知,需要用户提供」。 2. **决策必须交还审核**:自主做的一切实质决策(数据源选择、name/展示名、接口地址、保存路径),在动手生成前,用**决策审核卡**(2.3 节)一次性呈现给用户确认。用户确认后继续,用户改了就按改的来。 3. **只有确认无解才问**:穷尽所有可查手段(官方文档、配置、历史上下文)仍无法确定接口/鉴权时,才向用户提出**最小必要**问题(问数据源地址或鉴权方式,不重复问命名等可由 agent 决策的事)。 4. **类型决定策略**:执行前先判断 provider 属于哪一类——通用大平台走官方文档,非大平台走主动询问。不同类别的端点获取、鉴权确认方式截然不同(见 2.2 节分类执行表)。 --- ## 2. 一句话指令 + 完整引导流程 ### 2.1 一句话指令 用户从设置页「用量统计」→「接入自定义适配器」复制引导指令发到会话,即触发本流程。指令包含本文档的 **GitHub 链接**(任何工作目录下的 Agent 都能读取): > 请为提供商 `` 创建用量统计适配器(v2 契约):以该提供商在模型配置中的 API 端点(baseUrl)为起点,自行确认用量接口与鉴权方式,自主设计适配器方案(name/展示名/接口路径),**先确认账户类型(预付费额度 / 用量套餐 / 订阅)决定展示内容(金额 or 纯百分比),展示基准对齐内置适配器**,先给我审核方案(含 API 端点、接口字段全景、渲染效果示意),确认后生成 .mjs 文件、告诉保存路径并引导我在「用量统计」设置页添加适配器。按用量统计适配器开发引导文档(https://github.com/wingsky-1/dsh-plugin-hub/blob/main/packages/dsh-provider-usage/docs/adapter-guide.md)执行引导流程。 收到后按以下流程执行(默认全程自主,只保留审核点): ### 2.2 步骤 1-2:盘点已知 + 类型识别 **盘点已知**(0 提问):从用户当前会话/模型配置收集: - provider 名、baseUrl、apiKey 来源 - 是否有用量接口的已知信息(文档、历史对话) **提供商类型识别**:根据下表判断 provider 类型,按对应分支执行。 | 类型 | 判定特征 | 执行策略 | |------|---------|---------| | **通用大平台** | Anthropic、OpenAI、DeepSeek、Google、Azure 等知名 API 提供商 | 自行查官方文档找用量/配额接口,确认端点路径与鉴权方式。baseUrl 从模型配置读,用量接口路径按官方文档确认 | | **非大平台 / 自建中转** | 非上述知名平台,或用户称"自己的中转站" | 自行设计路径(`/v1/usage`、`/usage`、`/quota` 等常见路径探测),备选方案在审核卡里列出。确认不了的端点如实标注「待用户提供」 | **端点探测判读**(探测时按状态码判断路径是否有效): > `200/401` → 路径存在(401 是缺鉴权,补 Authorization 重试)|`404` → 路径不存在,换下一候选|`405` → 存在但方法不符(试 GET/POST)。探测请求一律**不带 Authorization**(确认路径存在后再发鉴权请求),防凭据泄漏到猜测路径;请求前校验最终 host 与 baseUrl 同注册域,防 redirect 到仿冒域(见 §4)。 **鉴权面隔离**:适配器只用 API key 鉴权(Bearer / Header)。浏览器控制台的 cookie/会话鉴权**不适用也不可迁移**——若某接口需 cookie 才能访问,如实标注「无 API key 面接口」,不尝试接入 cookie 面。 | **已知已有内置适配器** | opencode-go 等 | 跳过本流程,直接告知用户使用内置适配器(无需额外配置) | **账户类型识别**(与 provider 类型并列判断,直接决定展示内容): 从 baseUrl / 接口响应字段 / 会话上下文判断用户账户形态,不确定时在审核卡「账户类型」项标「待确认」并列出候选。 > **顺序与优先级**:识别可在出审核卡前做一次**只读探测调用**(无凭据则跳过);判定优先级 = **用户原话 > 接口字段语义 > 默认「不确定」**。避免机械套用「看到金额语义字段就判预付费」——`credits`/`balance` 字段也可能只是窗口用量单位,非账户余额。 | 账户类型 | 判定特征 | 展示内容 | |---------|---------|---------| | **预付费额度** | 接口有 `balance`/`credits`/`remaining` 等余额字段 | 可展示剩余额度 + 用量百分比 | | **用量套餐 / 订阅** | 接口只有窗口 used/cap、无余额语义;或会话上下文用户称"套餐" | **只展示窗口百分比**,不展示任何金额/额度(避免误导) | | **不确定** | 字段语义含糊 | 审核卡「账户类型」标「待确认」,列候选请用户选,**不要默认展示金额** | **展示基准**(默认锚点,避免"简陋"返工): - 默认对齐**内置 opencode-go 适配器**的展示水准:多窗口卡片 + SVG 迷你图(平滑面积图 / 100% 参考线 / 重置标记线 / 趋势 / 降采样 / 明暗自适应色板)+ 胶囊多窗口短名(如 `5h 3% · 周 1% · 月 1%`)。 - **使用注入的共享图表工具(`FetchContext.utils` / `PanelInput.utils`,#215)**:宿主端在 `fetchData`/`formatPanel` 入参注入 `utils`,内含 `miniAreaSvg`/`niceDomain`/`trendOf`/ `downsample`/`escHtml`/`escAttr`/`dayKey`/`lastNDayKeys` 等(见第 3.3 节工具清单)。 适配器内 `const U = input.utils` 后直接调用,无需复制图表代码;CSS 类(`dou-card`/ `dou-miniChart` 等)仍按内置样式使用。 - **内置源码 = 使用范例**(`packages/dsh-provider-usage/src/domain1/adapters/opencode-go.mjs`、 `src/domain1/adapters/deepseek-official.mjs`、`src/domain1/adapters/zai-coding-cn.mjs`):照其结构与 注入消费方式实现,而非仅凭文字脑补。这些是**纯 JS 的 .mjs**(无任何 import)—— 你的适配器也必须是这样的纯 JS 文件,**不要写 `import` / `import type`**(Node ESM 不认识 TS 语法,写了加载即失败)。 - 除非用户在审核卡明确选择「简单表格」,否则按此基准实现。 - 生成前在审核卡给出**渲染效果示意**:胶囊文案示例 + 面板卡片结构描述(见 2.3)。 ### 2.3 步骤 3:决策审核卡(一次性呈现给用户) 在动手生成代码前,把以下决策整理为审核卡,**一次性**让用户确认: ``` ## 适配器方案审核 - provider: `<名称>` - baseUrl: `<模型配置中的 API 端点>`(或「待用户提供」) - 账户类型: `<预付费额度 / 用量套餐 / 订阅 / 待确认>`(决定是否展示金额,见 2.2) - 用量接口路径: `<确认的路径>`(或备选方案列表) - 鉴权方式: `` - 适配器 name: `<机器名: ^[A-Za-z0-9_-]{2,64}$>` - 展示名: `<人类可读>` - 数据结构: 简要说明 fetchData 返回的字段(用户可确认字段是否够用) - 接口字段全景: 已确认接口的**关键字段** + **未用字段按类归并摘要**(如「额度类 ×3、窗口类 ×4、Token 明细 ×2」), 及未用字段可增加的展示(Token 明细、账期、成功率、超限标记等,不用逐个列全量字段) - 展示偏好: 阈值警示(默认 ≥80% 变黄)/ 附加明细(Token/请求数/成功率)/ 趋势显示等, 列候选让用户勾选 - 渲染效果示意: 胶囊文案示例(如 `5h 3% · 周 1% · 月 1%`)+ 面板卡片结构描述 - 保存路径: `<建议路径,如 ~/.dsh/adapters/.mjs>` - 配置示例: 用户层 cordis.patch.yml 的片段 > 请确认以上方案,或告诉我需要修改的地方。 ``` 用户确认后进入步骤 4。 ### 2.4 步骤 4:生成适配器文件 按 v2 契约生成 mjs 文件(见第 3 节契约规格),写入选定路径。确保: - 密钥通过 `fetchData({ apiEndpoint, apiKey, ... })` 入参获取,**绝不写死在源码中** - `fetchData` 只返回展示所需的最小数据集(不返回全量原始日志) - 所有外部 API 数据拼入 HTML 模板前经 `esc()` 转义 - **历史兼容**:修改展示或数据存储时考虑旧 JSONL 历史(见 3.4),`formatPanel` 对旧字段结构防御读取 - **生成后验收(必做)**:用真实凭据跑一次 `fetchData` 并计时,总耗时须在固定 5s 超时内(含最慢可选请求的降级路径);超预算先给可选请求加短超时降级再交审 ### 2.5 步骤 5:引导用户接线 **主路径:设置页「用量统计」(运行时热注册,无需重启)**。告知用户: ``` 适配器文件已保存到 `<路径>`。请打开 dsh 设置 → 插件 →「用量统计」: 1. 在目标 provider 下点「+ 添加适配器」,输入路径 `<路径>` 2. 点「检测文件」确认导出信息(name/label/providers) 3. 确认添加——自动注册并成为该 provider 启用者,胶囊下个轮询周期(≤60s)即出数据 4. 面板图表需 ≥2 个采样点才显示;刚添加时显示「数据采集中」属正常。采样频率取决于 GUI 是否开启:面板可见期间客户端每 ~60s 轮询记录一点(约 2 分钟后出现趋势图);GUI 关闭时 仅后台预热定时器记录(约每 5 分钟一点,与内置适配器空态文案口径一致) ``` **热更新边界(重要)**:`autoReload` 默认开启,编辑 mjs 后 2s 内自动热更新(无需重启);如因安全/稳定性顾虑可显式关闭 `autoReload: false`。 **可选叠加**:也可在用户层 cordis.patch.yml 声明(启动时加载,改后需重启 dsh web): ```yml plugins: '@wingsky-1/dsh-provider-usage': adapter: <路径> provider: staticPath: <用量接口路径> # autoReload 默认开启(编辑 mjs 后 2s 内自动热更新,无需重启); # 如因安全/稳定性顾虑可显式关闭:autoReload: false ``` --- ## 3. 契约规格速查(v2) ### 3.1 必填导出 > **文件形态**:适配器是**纯 JS 的 .mjs 文件,零 import、完全自包含**(不 import 任何 > 模块/类型——Node ESM 不认识 `import type` 等 TS 语法,写了加载即失败)。所有能力 > (图表工具、转义、日界)都经入参 `utils` 注入,见 3.3。 ```js export const version = 2; // 固定 2 export const name = "my-stats"; // ^[A-Za-z0-9_-]{2,64}$ export const providers = ["my-provider"]; // 非空字符串数组 export async function fetchData({ apiEndpoint, staticPath, apiKey, signal, timeoutMs }) { // 取原始数据,返回对象 } export function formatCapsule({ time, data, status, error, esc }) { // 胶囊内容,返回 HTML } export function formatPanel({ entries, range, truncated, esc }) { // 面板内容,返回 HTML } ``` ### 3.2 可选导出 ```js export const label = "我的统计"; // 展示名 ``` ### 3.3 注入的共享图表工具(`utils`,#215) 宿主端在 `fetchData` 入参(`FetchContext.utils`)与 `formatPanel` 入参 (`PanelInput.utils`)中注入共享工具集。**mjs 鸭子类型下字段可选**:适配器内 `const U = input.utils || {}` 后优先消费,缺失时回退文件内兜底副本(内置适配器 即按此写法,见其源码)。工具清单: | 工具 | 签名 | 说明 | |------|------|------| | `miniAreaSvg` | `({samples,color,lo,hi,resetsAt,resetPeriodMs,dateOnly}) => string` | 迷你面积图 SVG(平滑曲线/面积填充/100% 参考线/重置标记线/x 轴刻度/降采样) | | `niceDomain` | `(pcts:number[]) => [lo,hi]` | 百分比 y 域自适应(dmax≥90 抬到 100) | | `trendOf` | `(pcts:(number\|null)[]) => {delta,up,down}\|null` | 首末有效点趋势 | | `downsample` | `(points,maxPoints) => points` | 降采样(**peak 语义**=区间取最大,末点保留) | | `smoothPath` | `(pts) => string` | Catmull-Rom → 三次贝塞尔平滑路径 | | `resetTicks` | `(resetsAt,periodMs,t0,t1) => number[]` | 重置标记刻度(resetsAt 支持 ISO 字符串 / epochMs) | | `timeTicks` / `timeTickStep` | `(t0,t1,minGapMs) => number[]` | x 轴时间刻度 | | `fmtAxisTime` / `axisLabelWidthPx` | — | x 轴标签格式化 / 宽度估计 | | `fmtPctTick` | `(v:number) => string` | y 轴百分比刻度文案 | | `niceStep` | `(raw:number) => number` | 好看步长(1/2/5×10^k) | | `escHtml` / `escAttr` | `(s:unknown) => string` | HTML/属性上下文转义(含引号) | | `fin` | `(v,min?,max?) => number\|null` | 数值安全化 + clamp | | `dayKey` / `lastNDayKeys` | `(t) => string` / `(n,now) => string[]` | 本地时区日界(宿主时区口径) | > 示例(`formatPanel` 内消费):`const U = input.utils || {}; const svg = U.miniAreaSvg({...})`。 > 内置适配器全部按此消费方式编写——`opencode-go.mjs` 展示注入用法最全,可作模板。 ### 3.4 关键约束 | 约束 | 说明 | |------|------| | **密钥配置注入** | `apiKey`/`apiEndpoint` 由插件经配置链注入 `fetchData` 入参;**适配器源码中绝不写死密钥** | | **HTML 转义义务** | 凡来自外部 API 的字符串拼入 HTML 模板,一律 `esc()` 转义(如 `esc(data.name)`) | | **最小数据集** | `fetchData` 只返回展示所需字段(这些数据按天落盘) | | **`name` 白名单** | `^[A-Za-z0-9_-]{2,64}$`:不能有空格/路径分隔符/中文 | | **超时意识** | fetchData 被强制 **固定 5s** 超时(插件级常量,不可配置)。**禁止串行多请求**(总耗时叠加必超时);**并行请求(`Promise.all`)允许**,但总耗时 = 最慢请求。**关键请求(决定胶囊主数值的)不做短超时降级;可选增强数据(订阅窗口/明细)单独设短超时并降级**——`.catch(() => null)` 只防抛错不防挂起,须用 `Promise.race` 真超时:
`const withTimeout = (p, ms) => Promise.race([p, new Promise(r => setTimeout(() => r(null), ms))]);`
`const subs = await withTimeout(fetchSubs(), 800).catch(() => null); // 可选请求:短超时+降级` | | **超时控制位置** | **不要在客户端做超时控制,宿主端控制即可**——客户端 fetch 不得设 `AbortSignal.timeout` 等硬超时(会先于宿主端 5s abort 请求导致取数被误杀);适配器内可用 `ctx.timeoutMs`/`ctx.signal`(宿主注入),或对可选请求自行 `Promise.race` 降级 | | **历史兼容** | 修改展示或数据存储时,要考虑历史数据兼容:旧 JSONL 字段结构变化会让新 `formatPanel` 读不到数据(静默无图/无数据),`formatPanel` 对旧结构字段做防御(`?? "--"` 或兼容读取) | --- ## 4. 安全模型速览 - 适配器 = **宿主进程完整 Node 权限**(等同用户自己写插件)——只加载信任的本地文件 - 密钥仅存宿主内存,浏览器端不可见(通过入参注入,不写死在源码) - 历史按天分片 JSONL 落盘(0600 权限),30 天 / 20MB 自动清理 - 所有 HTTP 路由 loopback 围栏(仅本机可访问) - 若需局域网访问:必须先加 token 鉴权再放开(V1 未提供,需自行扩展) - **端点探测安全**:探测猜测路径时一律**不带 Authorization**(确认路径存在后再发鉴权请求);请求前校验最终 host 与 baseUrl 为同一注册域(防 redirect 到仿冒域泄漏 Bearer key),不一致即中止 --- ## 5. 排障 | 现象 | 排查 | |------|------| | 设置页「适配器」区显示 load 错误 | 违反 fail-fast 校验(缺导出/name 非法),按错误信息修复 | | `/stats` 返回 `status:"stale"` + error | fetchData 抛错/超时:看 error 字段(no-api-key / unauthorized / http-xxx / network / timeout) | | 胶囊不显示 | provider 未启用适配器或无数据:`/health` 看 adapters 列表 | | 热更新不生效 | `autoReload` 被显式关闭 / 文件 mtime+size 未变化 / 新版本契约校验失败(保留旧版) | | 取数正常(胶囊有数据)但面板无图表/显示旧格式 | 历史 JSONL 是旧版字段结构,新 `formatPanel` 读不到:清历史或等新结构采样积累 ≥2 点(见 3.4 历史兼容) | --- ## 6. v1 → v2 迁移对照 > 一般适配器都是新建(直接写 v2 契约),无需迁移;仅当维护旧 v1 适配器时才展开本节。
v1 → v2 迁移对照(点击展开) | v1(旧) | v2(新) | |---------|---------| | `export default { version:1, id, label, providers, fetchUsage }` | 具名导出 `version:2, name, providers, fetchData` | | 客户端渲染器 `.js` + `window.__DSH_USAGE__` 桥接 | `formatCapsule`/`formatPanel` 返回 HTML(宿主端渲染) | | `summarize`/`samplePoint`/windows 归一化 | 移除,胶囊/面板直接由 format 函数产出 | | 设置页运行时 add/select 适配器 | **保留并增强**:设置页「用量统计」承载检测/添加/切换/停用;cordis.patch.yml 声明降为可选叠加 | | 历史 v3 多文件 JSON 桶 | 按天分片 JSONL(旧数据启动时自动迁移) |
--- ## 7. 内置适配器速览 | 适配器 | provider | name | 数据源 | 展示 | |--------|----------|------|--------|------| | `opencode-go.mjs` | `opencode-go` | `opencode-go-builtin` | OpenCode Go 官方用量接口 | 三窗口迷你图卡片 + 胶囊短名百分比 | | `deepseek-official.mjs` | `deepseek-official` | `deepseek-official-builtin` | DeepSeek 官方余额接口(区间记账法) | 余额折线(B2 断轴)+ 近 15 日用量柱形图 + 峰谷徽标 | | `zai-coding-cn.mjs` | `zai-coding-cn` | `zai-coding-cn-builtin` | 智谱 Coding Plan (CN) 配额接口 | 5h/周双窗口迷你图 + 工具额度卡(条件渲染)+ 胶囊 `5h 79% · 周 79% · Lite` | **智谱 Coding Plan (CN) 接口实测备忘**(2026-08-27 真 key 实测,实现见 `zai-coding-cn.mjs`): - 端点:`GET https://open.bigmodel.cn/api/monitor/usage/quota/limit`(**平台级固定路径, 不拼接 baseURL 的 /api/coding/paas/v4 段**;OpenTokenUsage 文档的 `/api/biz/monitor/...` 是错的;社区 opencode-glm-quota 同款三域名)。 - 响应 `{ code: 200, data: { limits: [...], level: "lite" } }`: - `CREDIT_LIMIT unit=3,number=5` → 5h 窗口(usage/currentValue/remaining/percentage/nextResetTime); - `CREDIT_LIMIT unit=6,number=1` → 周窗口(**按 unit 而非 number 判定**); - `percentage` 为**服务端权威口径**:5h 刚重置时 `usage=2000,currentValue=0,remaining=2000, percentage=0`(用量 0%);周用满时 `percentage=100`——勿用 remaining/total 自行推断 (remaining 是剩余量,混用会算反); - `TIME_LIMIT` → 工具配额类(本用户套餐无,面板条件渲染); - `data.level` = 套餐等级(lite/pro/max),替代国际站 subscription/list。 - 网关对未知路径也回 HTTP 200 + `{code:404,...}`——**必须校验业务码** `raw.code !== 200 → bad-data`。