# dsh-plugin-ollama-usage 技术笔记 > 本文件承载 README 之外的操作、排障、契约与内部结构信息,面向要改动或排查本插件的开发者。 > 内容由原 README 的「契约 / 操作 / 结构 / 自检 / 排障」各节搬运而来,只删了过程化叙述。 ## 契约 ### 席位 | 位置 | 席位 | |---|---| | 活动会话输入框下方 | `conversation.composer.dock`(id `ollama-usage`, order 1)——**同排由壳负责**:dsh ≥ `0.1.6-alpha.2` 给该席位一个居中的 `flex; gap:12px` 行,本插件的 entry 只是普通流式项,与内置 stats / context 胶囊自动同行;旧版该席位是纵向列,同一份 DOM 自动降级为「自家居中一行」(`0.1.5-rc.2` 下 composer.dock 即退化为自家一行) | | 新会话(hero) | `conversation.input.dock`(id `ollama-usage-hero`, order 1,输入卡片上方那行;与 statusline 的任务 pill 同行) | | 配置 | `settings.section`(id `ollama-usage`, order 170)——设置导航里本插件自己的页面;装了 suite hub 时让位给 `plugin-suite.panel` | **本插件不测量、不定位**(2026-09-21 起):dock entry 没有任何坐标、观察者或版本探测; `conversation.composer.dock` 的排列完全交回壳,`conversation.input.dock` 那行只用一条 `[data-slot="conversation.input.dock"]{display:flex !important; flex-flow:row wrap; …}` 把座位锚点 从内联 `display:contents` 改成换行行(`!important` 不可省),其余照旧。 ### 配置与设置契约(dsh 0.2 起) 0.2 删除了 `ctx.settings.register(ns, schema)` 与 `settings.yaml`。取而代之的契约是: - **命名空间就是 loader row id**:本包的 `cordis.patch.yml` 插入 `- id: ollama-usage`, `settings.update("ollama-usage", …)` 与 `settings.describe()` 的 `ns` 都是这个字符串。 - **schema 就是 Host 半导出的 `Config`**:`baseURL` / `credentialMode` / `apiKeyEnv` 三个字段, 默认 `https://ollama.com` / `reference` / `OLLAMA_API_KEY`,**每个字段都 `.volatile()`** —— 没有 volatile 字段的 entry 会被 `describe()` 整个跳过,写入也会被拒绝 (`Plugin entry "ollama-usage" has no volatile fields`)。 - **值存放在 profile patch**:`~/.dsh/profiles//cordis.patch.yml` 的 `- id: ollama-usage` / `config:` 段。插件自身的保存路径(`config/save`)就是写这里; 手改这个文件在 HMR 下同样生效。 - **配置页是 `settings.section`**:浏览器半用 `ctx.slots.inject('settings.section', …)` 注册自己的 页面(id `ollama-usage`, order 170,label 随 locale 为「Ollama 用量」/「Ollama Usage」); 装了 suite hub(`plugin-suite.panel` 被声明)时撤销自有页面、把同一张卡片挂到 hub 面板, hub 折叠后恢复自有页面。注册进**未声明**的 slot 会抛 `slot "" is not declared`, 会把整个浏览器半带下去(表现为 "did not activate"),所以必须走 `inject` 等待壳声明。 - 「Settings → 插件 → 插件配置」的 **`settings.plugin.item` keyed 卡片席位在 0.2 已不存在**: 这正是旧卡片在那处「静默不渲染」的原因,而不是本插件仍然在等待某个已删除的席位。 (0.2 的「设置 → 插件」区域用 `settings.plugins.tab`,本包不占。) > 这也解释了本包为什么不再「零 `@deepseek-ai/*` 依赖」:0.1.x 的取舍是把一个手搓的 > callable + `toJSON()` 交给 `settings.register`,而 0.2 的设置服务要求真正的 schemastery > schema(`volatileForm()` 读 `schema.meta.volatile`,`schema(entry)` 要求 `toJSON`), > 手搓形状不再可能通过。于是只引入 **`@deepseek-ai/schemastery`** 这一个 peer, > 与同工作区的 `dsh-plugin-web-search` 采用同一套机制。 ### 凭据:两种模式 配置完全由本插件自己持有:row id(即设置命名空间)`ollama-usage` 的 `baseURL` / `credentialMode` / `apiKeyEnv`,密钥经官方凭据 seam 存取。 | 模式 | 密钥来源 | 本插件能否写入 | |---|---|---| | **凭据模式**(`reference`,默认) | 复用已有 `apiKeyEnv`(默认 `OLLAMA_API_KEY`) | **不能** —— Host 半根本没有接受外部引用名的写入接口 | | **密钥模式**(`direct`) | 本插件专属引用 `OLLAMA_USAGE_API_KEY` | 只能写它自己 | 写入接口(`credential/set` / `credential/unset`)**不接受 ref 参数**,物理上无法覆盖某个提供方共用的 `OLLAMA_API_KEY`;密钥只在 Host 侧读取,从不回传给浏览器,错误信息里也不含密钥。 ### 取数口径 `GET {baseURL}/api/balance` + `Authorization: Bearer `。`baseURL` 填 `https://ollama.com` 或 `https://ollama.com/api` 都可以(原生 API base 以 `/api` 结尾,代码会补齐)。 **2026-10 上游改版**:旧口径(`GET /api/usage` 的 `limits.session/weekly.{usage,models}`)已消失。 `/api/usage` 现在只按天桶给 `request_count`/`usage_usd`/token 计数,**per-model 明细没有任何继任端点**: 官方 OpenAPI 的 15 条路径里账号数据面只有 `/api/usage` 与 `/api/balance`,且参数白名单只有 `range`/`scope`。配额百分比搬到了 `/api/balance`,其 `included` 是 union: | 分支 | 形状 | 映射 | |---|---|---| | 旧套餐(legacy) | `{session:{remaining_percent,resets_at}, weekly:{…}}` | 两个窗口:**已用** % `= 100 - remaining_percent` | | 额度制(credit) | `{balance_usd, allowance_usd, period:{from,until}}` | 映射为 `monthly` 窗口:已用 % `= (allowance - balance) / allowance`,重置取 `period.until` | 百分比一律先钳制到 0..1,再按 1e-6 取整(`(100-96.01)/100` 的二进制浮点噪声不进 UI,也不进断言)。 状态口径:404 → `unsupported` 静默;非 2xx / 网络失败 / 未配置凭据 → `none` 静默; **HTTP 200 但载荷解不出(含 200 非 JSON)→ `unrecognised`** —— 设置卡片显示「端点返回了无法识别的 用量格式」,Host 端按顶层键签名去重记一行日志。这一条正是 2026-10 事故的补丁:旧代码把那类情况 一并归入静默 `none`,于是插件无声失效了一个月。 ### 路由与信任栅栏 patch 只插入自己的一行。Host 半在组合的 `webServer` 上注册**自己的路由**(`POST /ollama-usage/`), 每个请求先问组合的 `connection.requestRejection`(`401`/`403` 逐字回写;缺这个 seam 时 `503` 拒绝服务)—— 与 shipped `dsh-host-open-in-app` 同一套形状。早期版本靠拓宽 shipped `connection` 行的 `inject` 让 `connection.rpc.handle` 能注册通道;自有路由没有这个耦合:本插件的可用性不再依赖别的 bundle 的配置与层序。 ## 操作与排障 ### 安装形态 本包是 dsh **bundle**:声明 `dsh.bundle` + `cordis.patch.yml`,安装即把 bundle 追加进 profile 并应用它的 patch 层 ([官方指南](https://deepseek-harness.github.io/deepseek-harness/en/develop/basic/publish))。 两条受支持的安装路径: ```bash git clone https://github.com/CJ-SH/dsh-plugin-ollama-usage dsh plugin --profile web add ./dsh-plugin-ollama-usage ``` 或者直接用 git URL(不必自己 clone): ```bash dsh plugin --profile web add github:CJ-SH/dsh-plugin-ollama-usage ``` npm 尚未发布;发布后命令相同,只是把路径换回包名 `dsh-plugin-ollama-usage`。`npm pack` / tgz 安装路线同样等到发布之后。 `lib/` 是仓库里的纯 JavaScript,没有构建步骤,所以 git 安装拿到的就是可运行的产物。 ### `link:` 安装额外一步:`npm run link-imports` Host 半从 0.2 起需要 **schemastery**(`Config` 必须是真正的 schemastery schema,见「配置与设置契约」)。 `link:` 安装的插件位于 profile 之外,Node 的父目录回溯到不了 `$DSH_HOME/profiles/node_modules`,因此这个 peer 会以 `ERR_MODULE_NOT_FOUND` 让整行加载失败。 把 profile 已维护的安装闭包链进包内即可(幂等;materialized 安装不需要): ```bash npm run link-imports # 链接 @deepseek-ai/schemastery -> $DSH_HOME/profiles/node_modules/… ``` **不要**用 `npm install` 装这个 peer:拷贝会解析成功但破坏模块同一性,`Config` 必须是 设置服务所内省的那一个 schemastery 实例。 然后预检并**重启 dsh** —— 插件在启动时加载,而重启会结束 agent 进程,请自己执行: ```bash dsh --profile web --dump-config | grep ollama-usage ``` ### 卸载与数据保留 ```bash dsh plugin --profile web remove dsh-plugin-ollama-usage ``` 设置与凭据是用户数据,卸载后保留;不需要时手动清理 `~/.dsh/profiles//cordis.patch.yml` 里的 `- id: ollama-usage` 段(0.2 起没有 `settings.yaml` 了;本插件不会写入任何凭据, 除非你显式使用密钥模式)。 ### 故障排查 **面板完全不显示。** 依次排查:凭据不可用(凭据模式的 `apiKeyEnv` 未配置,或密钥模式的引用为空); `GET {baseURL}/api/balance` 返回 404 或非 2xx;`baseURL` 填错;dsh 升级移动了席位。 面板不显示占位符、也不报错 —— **不显示本身就是失败模式**。 **上游又改版了。** 症状:胶囊与 hero 行不显示,但设置 →「Ollama 用量」卡片的状态行写着 「端点返回了无法识别的用量格式」,启动日志里有一行 `[ollama-usage] unrecognised /api/balance payload (top-level keys: …)`(按键名签名去重,每种形状只记一次, 5 分钟轮询不会刷屏)。此时照键名改 `parseBalance` 的分支,并补一条反假绿断言 —— 不要再让形状失配退回静默。 **整行没激活(插件整个不存在)。** dsh 只会把原因写进启动日志:`1 entry did not activate` / `TypeError` / `ERR_MODULE_NOT_FOUND`。先看 peer 是否解析得了(`npm run link-imports`), 再看 `credentialMode` 有没有被手改成 `reference` / `direct` 之外的值(`Config` 是 union, 非法值会让这一步直接失败,而不是被静默改写)。 **卡片一直停在「读取中…」。** 说明请求没有拿到答复信封:看 dsh 启动日志里有没有 `[ollama-usage] route unavailable`(路由被别的行占用)或 `trust fence unavailable`(组合里没有 `connection.requestRejection`)。 ## 内部结构 ``` lib/index.js Host 半:Config 声明、凭据 seam、/balance 取数、自有的 /ollama-usage 路由 lib/client.js Client 半:三处 UI(module-loader bundle 形式,仅依赖基线 react) locale/{zh,en}.json 插件卡片/清单的显示文案({"meta":{"title","description"}}) icon.svg 清单里的图标(`package.json` 顶层 `icon`) tools/link-imports.mjs `link:` 安装时把 schemastery 链进包内(见安装一节) test/host.test.mjs 自检:Config 契约、写路径、栅栏/方法/媒体类型/请求体、信封、凭据边界、 空态与 unrecognised 上报(82 条断言) test/client.test.mjs 自检:席位、样式生命周期、"无测量"、面板锚点、无死类名、跨半边契约(41 条断言) test/card.test.mjs 自检:设置卡片,含 unrecognised 状态文案(11 条断言) test/hero.test.mjs 自检:hero 行(6 条断言) cordis.patch.yml 安装时合入 profile 的 loader 行(不覆盖任何别的行) ``` **无构建步骤**:Host 半只 import 一个 peer —— `@deepseek-ai/schemastery`(0.2 的 `Config` 契约 要求真正的 schemastery schema;详见「配置与设置契约」),Client 半只 `require('react')`(平台基线)。 ## 开发与验证 ```bash npm test # 四个 harness,共 140 条断言,无需测试框架 ``` - **host.test.mjs**:用假 cordis ctx 挂载 Host 半并断言行为 —— **Config 契约**(默认值、 序列化、`volatileForm` / `isVolatilePath` 两条规则的本地复刻、以及"必须是真 schemastery" 而非手搓形状)、**写路径**(`settings.update` 的 ns 等于 row id、revision 冲突、 非法 patch 不落盘)、**栅栏最先**(401/403 逐字回写、缺 seam 时 503 fail-closed)与方法/媒体类型/ 请求体校验(405/415/400)、`config/read` 信封与端点推导(含脏值归一化与 "describe 失败时回落到 row 层")、**凭据写入边界**(凭据模式零写入、密钥模式只写 `OLLAMA_USAGE_API_KEY`)、空态(无凭据服务 / 未配置 / 404 / 非 2xx)、错误信息不含密钥, 以及**反假绿核心**:`/api/balance` 的 legacy 与 credit 两个分支各解出预期百分比与重置时间, 而**旧 `limits` 形状、新 `/api/usage` 桶形状、200 非 JSON 三种都必须是 `unrecognised`** (旧的静默 `none` 正是这次事故藏身之处),日志只带顶层键名不带值、同形状重复只记一行日志,以及 R7 的拒绝路径(allowance=0、百分比越界)同样落到 `unrecognised`。 另有两类**反假绿**守卫:源码(去注释后)里不得再出现 `settings.register` / `installSection`, 且清单的 peer 区间必须真的匹配运行时——0.2 会**拒绝**区间不满足的 row,而"不声明"则静默放行。 - **client.test.mjs**:通过假 `window.__ModuleLoader__` 加载真实 bundle 并挂载到假 ctx —— 断言 三个席位(**槽名与 id/key 分离**,这正是原型期踩过的坑)、order、样式标签的注入与回收、 以及**跨半边契约**(客户端调用的每个 endpoint 在 Host 都有对应分支,路由前缀与命名空间两边一致); 并断言**测量路径确已删除**——源码(去注释后)里没有 `ResizeObserver` / `getBoundingClientRect` / `data-flow` / 任何测量助手函数,样式表里也没有 `height:0` / `position:absolute` 的 dock 规则。 设置页部分断言 `settings.section` 的槽名 / id / order,以及在 hub 声明时让位、折叠后恢复, 并确认死掉的 `settings.plugin.item` 席位没有复活。 - **card.test.mjs** / **hero.test.mjs**:设置卡片与 hero 行的渲染、交互与卸载(请求走假 `fetch`); hero 的相位规则("dock 行挂载时 hero 行不出现")由后者钉住。 ## 出处与许可 [MIT](../LICENSE) © 2026 HenTaiCJN Ollama 是 Ollama Inc. 的产品;本插件与它没有隶属关系,只调用其公开 API。