@chenlongapps/opencode-token-usage 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/README.zh-CN.md +21 -1
- package/ROADMAP.md +9 -8
- package/dist/context-rpc.d.ts +52 -0
- package/dist/context-rpc.js +35 -0
- package/dist/context-sources.d.ts +22 -0
- package/dist/context-sources.js +108 -0
- package/dist/controller.d.ts +11 -3
- package/dist/controller.js +18 -4
- package/dist/index.js +23 -1
- package/dist/source.d.ts +3 -1
- package/dist/source.js +8 -0
- package/dist/tui.js +620 -13
- package/dist/usage.d.ts +32 -0
- package/dist/usage.js +110 -25
- package/docs/verification.md +36 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,26 @@ Restart OpenCode after installation. The panel appears in the native sidebar whe
|
|
|
40
40
|
|
|
41
41
|
For remote sessions, add the package name to `plugins` in your local `~/.config/opencode/cli.json` to load only the terminal entry point. The configuration path follows `XDG_CONFIG_HOME`.
|
|
42
42
|
|
|
43
|
+
### Detailed usage
|
|
44
|
+
|
|
45
|
+
Enter `/usage` in a session to open a native dialog with the session tree's token totals and estimated cost by recorded model. Scroll with ↑/↓, Page Up/Down, Home/End, press `d` to switch between compact and detailed numbers, and close with Escape. The command does not send a prompt to the model.
|
|
46
|
+
|
|
47
|
+
The dialog is split into five sections with separate statistics:
|
|
48
|
+
|
|
49
|
+
| Section | Contents |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Context Window | Used / limit and a percentage of the active model's context limit, with a usage bar |
|
|
52
|
+
| Last Request | The five token categories and cache rate of the viewed session's most recent reported call |
|
|
53
|
+
| Context Breakdown | Estimated prompt composition, largest first, with bars |
|
|
54
|
+
| Session | Tree totals: steps, calls, tokens, cache rate, cost (single line, or per-category rows in detailed mode) |
|
|
55
|
+
| By Model | Tokens, calls and cost per recorded model, highest cost first |
|
|
56
|
+
|
|
57
|
+
One line under the title names the session and its active model, and the body never repeats the model name. Compact mode uses `K`/`M` abbreviations (`812`, `139.4K`, `3.70M`), hides empty rows and merges the two tool families in Context Breakdown into a single `Tools` row. Detailed mode shows exact numbers, empty rows, and the `System Tools` / `MCP Tools` split. The sidebar keeps exact numbers and its own layout.
|
|
58
|
+
|
|
59
|
+
Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. Context sections are unavailable until the viewed session has reported usage and a known context limit.
|
|
60
|
+
|
|
61
|
+
**Context Breakdown** separates Messages, System Tools, System Prompt, Skills, MCP Tools, and Other. It estimates the text and tool definitions in the viewed session's **latest assembled model request**; its percentages use the sum of those estimates. These are not provider-reported token counts and do not add up to the measured context window, which also includes output and reasoning. Media and provider-specific framing cannot be measured from the request. A server plugin records only aggregate estimates (not prompt text); sessions without a captured request or an available server RPC show “Source estimates unavailable”.
|
|
62
|
+
|
|
43
63
|
### Metrics
|
|
44
64
|
|
|
45
65
|
| Metric | Definition |
|
|
@@ -76,7 +96,7 @@ npm run build
|
|
|
76
96
|
npm run test:smoke
|
|
77
97
|
```
|
|
78
98
|
|
|
79
|
-
`test:smoke` packages the real artifact and validates loading, refreshes, subagent aggregation, per-message pricing, official-price fallback, model switching, TPS, and TTFT against an isolated OpenCode instance and a local mock provider. It requires Python 3, an available local port, and npm network access. It never modifies your existing OpenCode configuration or calls paid models.
|
|
99
|
+
`test:smoke` packages the real artifact and validates loading, refreshes, `/usage`, subagent aggregation, per-message pricing, official-price fallback, model switching, TPS, and TTFT against an isolated OpenCode instance and a local mock provider. It requires Python 3, an available local port, and npm network access. It never modifies your existing OpenCode configuration or calls paid models.
|
|
80
100
|
|
|
81
101
|
To load the plugin from source, build the project and add the repository's absolute path to `plugins` in the target project.
|
|
82
102
|
|
package/README.zh-CN.md
CHANGED
|
@@ -40,6 +40,26 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
40
40
|
|
|
41
41
|
连接远程服务器时,可将包名加入本机 `~/.config/opencode/cli.json` 的 `plugins`,仅加载终端入口;配置路径遵循 `XDG_CONFIG_HOME`。
|
|
42
42
|
|
|
43
|
+
### 详细用量
|
|
44
|
+
|
|
45
|
+
在会话中输入 `/usage`,可打开原生弹窗查看当前会话树的 token 总量,以及按消息实际模型汇总的估算费用。使用 ↑/↓、Page Up/Down、Home/End 滚动,按 `d` 切换紧凑/详细数字,Escape 关闭。该命令不会向模型发送消息。
|
|
46
|
+
|
|
47
|
+
弹窗分为五个区域,统计口径互相独立:
|
|
48
|
+
|
|
49
|
+
| 区域 | 内容 |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Context Window | 已用 / 上限、占活动模型上下文上限的百分比,并带占用条 |
|
|
52
|
+
| Last Request | 被查看会话最近一次已上报调用的五类 token 与缓存命中率 |
|
|
53
|
+
| Context Breakdown | 提示词构成估算,按占比降序排列并带条形图 |
|
|
54
|
+
| Session | 全树汇总:steps、calls、tokens、缓存率与费用(紧凑模式为单行,详细模式逐类展开) |
|
|
55
|
+
| By Model | 各模型的 tokens、calls 与费用,按费用降序 |
|
|
56
|
+
|
|
57
|
+
标题下方一行注明会话与当前活动模型,正文不再重复模型名。紧凑模式使用 `K`/`M` 缩写(`812`、`139.4K`、`3.70M`),隐藏零值行,并把 Context Breakdown 中的两类工具合并为一行 `Tools`;详细模式显示精确数字、零值行及 `System Tools` / `MCP Tools` 拆分。侧边栏仍使用精确数字和原有布局。
|
|
58
|
+
|
|
59
|
+
费用沿用侧边栏的价格及 `partial`、不可定价、免费口径。当前会话尚未上报用量或上下文上限未知时,上下文相关区域显示不可用。
|
|
60
|
+
|
|
61
|
+
弹窗中的 **Context Breakdown** 将消息、系统工具、系统提示词、技能、MCP 工具及其他分开展示。它根据被查看会话**最近一次已组装的模型请求**中的文本和工具定义估算,占比的分母是六项估算值之和;这些不是提供商上报的精确 token,也不会与上方含输出、推理的实测上下文窗口相加一致。媒体内容与提供商额外包装无法准确计量。服务端插件仅保存分类数值,不保存提示词正文;未捕获到请求或服务端 RPC 不可用时显示“来源估算不可用”。
|
|
62
|
+
|
|
43
63
|
### 指标
|
|
44
64
|
|
|
45
65
|
| 指标 | 定义 |
|
|
@@ -76,7 +96,7 @@ npm run build
|
|
|
76
96
|
npm run test:smoke
|
|
77
97
|
```
|
|
78
98
|
|
|
79
|
-
`test:smoke` 会打包真实产物,并在隔离的 OpenCode
|
|
99
|
+
`test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、`/usage`、子代理累计、逐消息计价、官方价格补全、模型切换、TPS 和 TTFT。它需要 Python 3、可用的本地端口和 npm 网络访问,不会修改现有 OpenCode 配置或调用付费模型。
|
|
80
100
|
|
|
81
101
|
从源码加载时,先构建项目,再将仓库绝对路径加入目标项目的 `plugins`。
|
|
82
102
|
|
package/ROADMAP.md
CHANGED
|
@@ -90,14 +90,15 @@ Steps 1,234
|
|
|
90
90
|
|
|
91
91
|
## v0.4.0 — 用量命令
|
|
92
92
|
|
|
93
|
-
目标:按需提供比侧边栏更详细的 token
|
|
94
|
-
|
|
95
|
-
* [
|
|
96
|
-
* [
|
|
97
|
-
* [
|
|
98
|
-
* [
|
|
99
|
-
* [
|
|
100
|
-
* [
|
|
93
|
+
目标:按需提供比侧边栏更详细的 token 信息。以弹窗的形式显示。
|
|
94
|
+
|
|
95
|
+
* [x] 添加 `/usage`
|
|
96
|
+
* [x] 显示当前会话树的 token 信息
|
|
97
|
+
* [x] 显示 context 各项占比
|
|
98
|
+
* [x] 显示 token 明细与上下文用量
|
|
99
|
+
* [x] 显示各个模型费用
|
|
100
|
+
* [x] 拆分为 Context Window / Last Request / Context Breakdown / Session / By Model,口径互不混用
|
|
101
|
+
* [x] 紧凑数字(K/M)、来源条形图、Session 单行汇总、`d` 详细模式切换
|
|
101
102
|
|
|
102
103
|
## 后续评估
|
|
103
104
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export declare const ContextSourceRpc: {
|
|
2
|
+
readonly id: "opencode-token-usage.context";
|
|
3
|
+
readonly methods: {
|
|
4
|
+
readonly latest: {
|
|
5
|
+
readonly input: {
|
|
6
|
+
readonly type: "object";
|
|
7
|
+
readonly properties: {
|
|
8
|
+
readonly sessionID: {
|
|
9
|
+
readonly type: "string";
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
readonly required: readonly ["sessionID"];
|
|
13
|
+
readonly additionalProperties: false;
|
|
14
|
+
};
|
|
15
|
+
readonly output: {
|
|
16
|
+
readonly type: "object";
|
|
17
|
+
readonly properties: {
|
|
18
|
+
readonly estimate: {
|
|
19
|
+
readonly anyOf: readonly [{
|
|
20
|
+
readonly type: "null";
|
|
21
|
+
}, {
|
|
22
|
+
readonly type: "object";
|
|
23
|
+
readonly properties: {
|
|
24
|
+
readonly capturedAt: {
|
|
25
|
+
readonly type: "number";
|
|
26
|
+
};
|
|
27
|
+
readonly model: {
|
|
28
|
+
readonly type: "string";
|
|
29
|
+
};
|
|
30
|
+
readonly tokens: {
|
|
31
|
+
readonly type: "object";
|
|
32
|
+
readonly properties: {
|
|
33
|
+
[k: string]: {
|
|
34
|
+
type: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
readonly required: string[];
|
|
38
|
+
readonly additionalProperties: false;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
readonly required: readonly ["capturedAt", "model", "tokens"];
|
|
42
|
+
readonly additionalProperties: false;
|
|
43
|
+
}];
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
readonly required: readonly ["estimate"];
|
|
47
|
+
readonly additionalProperties: false;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
readonly events: {};
|
|
52
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/plugin/rpc";
|
|
2
|
+
const numbers = ["Messages", "System Tools", "System Prompt", "Skills", "MCP Tools", "Other"];
|
|
3
|
+
export const ContextSourceRpc = Rpc.define({
|
|
4
|
+
id: "opencode-token-usage.context",
|
|
5
|
+
methods: {
|
|
6
|
+
latest: {
|
|
7
|
+
input: {
|
|
8
|
+
type: "object", properties: { sessionID: { type: "string" } }, required: ["sessionID"], additionalProperties: false,
|
|
9
|
+
},
|
|
10
|
+
output: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
estimate: {
|
|
14
|
+
anyOf: [
|
|
15
|
+
{ type: "null" },
|
|
16
|
+
{
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
capturedAt: { type: "number" }, model: { type: "string" },
|
|
20
|
+
tokens: {
|
|
21
|
+
type: "object", properties: Object.fromEntries(numbers.map(label => [label, { type: "number" }])),
|
|
22
|
+
required: numbers, additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
required: ["capturedAt", "model", "tokens"], additionalProperties: false,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
required: ["estimate"], additionalProperties: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
events: {},
|
|
35
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SessionContext } from "@opencode/plugin/promise/session";
|
|
2
|
+
export declare const sourceLabels: readonly ["Messages", "System Tools", "System Prompt", "Skills", "MCP Tools", "Other"];
|
|
3
|
+
export type SourceLabel = typeof sourceLabels[number];
|
|
4
|
+
/** A best-effort inventory of the last assembled request, never provider-reported usage. */
|
|
5
|
+
export interface ContextSources {
|
|
6
|
+
capturedAt: number;
|
|
7
|
+
model: string;
|
|
8
|
+
tokens: Record<SourceLabel, number>;
|
|
9
|
+
}
|
|
10
|
+
export declare function estimateContextSources(request: Pick<SessionContext, "system" | "messages" | "tools" | "model">, mcpServers?: readonly string[], capturedAt?: number): ContextSources;
|
|
11
|
+
export declare function parseContextSources(value: unknown): ContextSources | undefined;
|
|
12
|
+
export interface BreakdownRow {
|
|
13
|
+
label: string;
|
|
14
|
+
tokens: number;
|
|
15
|
+
percent: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Presentation rows for the estimated sources, largest first. Compact mode merges the
|
|
19
|
+
* two tool families into one `Tools` row and hides empty rows; detailed mode keeps the
|
|
20
|
+
* six stored labels apart, including zeroes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function breakdownRows(sources: ContextSources, detailed: boolean): readonly BreakdownRow[];
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export const sourceLabels = ["Messages", "System Tools", "System Prompt", "Skills", "MCP Tools", "Other"];
|
|
2
|
+
const estimate = (text) => text ? Math.max(1, Math.ceil(Buffer.byteLength(text, "utf8") / 4)) : 0;
|
|
3
|
+
/** Remove a recognisable instruction block while retaining the rest for another category. */
|
|
4
|
+
function extract(text, pattern) {
|
|
5
|
+
let found = "";
|
|
6
|
+
const rest = text.replace(pattern, match => { found += match; return ""; });
|
|
7
|
+
return { rest, found };
|
|
8
|
+
}
|
|
9
|
+
function isMcpTool(name, servers) {
|
|
10
|
+
return servers.some(server => name.startsWith(`${server.replace(/[^a-zA-Z0-9_-]/g, "_")}_`));
|
|
11
|
+
}
|
|
12
|
+
function classifySystem(text, tokens, mcpServers) {
|
|
13
|
+
// Instruction sources are joined with blank lines by OpenCode; the Code Mode
|
|
14
|
+
// catalog precedes project instructions, skills and MCP guidance in that list.
|
|
15
|
+
let section = extract(text, /(?:# Code Mode|The Code Mode tool catalog(?: below)? is (?:partial|complete)\.|The Code Mode tool catalog has changed\.)[\s\S]*?(?=\n\n(?:Instructions from:|Skills provide specialized instructions|New skills are available|<mcp_instructions>|New MCP server instructions)|$)/g);
|
|
16
|
+
let namespace = "";
|
|
17
|
+
const codeMode = { "System Tools": [], "MCP Tools": [] };
|
|
18
|
+
for (const line of section.found.split("\n")) {
|
|
19
|
+
const heading = /^- ([a-zA-Z0-9_-]+) \(\d+ tools?/.exec(line);
|
|
20
|
+
if (heading)
|
|
21
|
+
namespace = heading[1];
|
|
22
|
+
const mcp = mcpServers.some(server => {
|
|
23
|
+
const name = server.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
24
|
+
return namespace === name || line.includes(`tools.${name}`);
|
|
25
|
+
});
|
|
26
|
+
codeMode[mcp ? "MCP Tools" : "System Tools"].push(line);
|
|
27
|
+
}
|
|
28
|
+
tokens["System Tools"] += estimate(codeMode["System Tools"].join("\n"));
|
|
29
|
+
tokens["MCP Tools"] += estimate(codeMode["MCP Tools"].join("\n"));
|
|
30
|
+
text = section.rest;
|
|
31
|
+
section = extract(text, /Skills provide specialized instructions[\s\S]*?(?:<\/available_skills>|No skills are currently available\.)/g);
|
|
32
|
+
tokens.Skills += estimate(section.found);
|
|
33
|
+
text = section.rest;
|
|
34
|
+
section = extract(text, /<mcp_instructions>[\s\S]*?<\/mcp_instructions>/g);
|
|
35
|
+
tokens["MCP Tools"] += estimate(section.found);
|
|
36
|
+
tokens["System Prompt"] += estimate(section.rest);
|
|
37
|
+
}
|
|
38
|
+
export function estimateContextSources(request, mcpServers = [], capturedAt = Date.now()) {
|
|
39
|
+
const tokens = Object.fromEntries(sourceLabels.map(label => [label, 0]));
|
|
40
|
+
for (const part of request.system)
|
|
41
|
+
classifySystem(part.text, tokens, mcpServers);
|
|
42
|
+
for (const [name, tool] of Object.entries(request.tools)) {
|
|
43
|
+
const size = estimate(`${name} ${tool.description} ${JSON.stringify(tool.input)}`);
|
|
44
|
+
tokens[isMcpTool(name, mcpServers) ? "MCP Tools" : "System Tools"] += size;
|
|
45
|
+
}
|
|
46
|
+
for (const message of request.messages) {
|
|
47
|
+
for (const part of message.content) {
|
|
48
|
+
if (part.type === "text") {
|
|
49
|
+
if (message.role === "system") {
|
|
50
|
+
classifySystem(part.text, tokens, mcpServers);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const section = extract(part.text, /<skill_content\b[\s\S]*?<\/skill_content>/g);
|
|
54
|
+
tokens.Skills += estimate(section.found);
|
|
55
|
+
tokens.Messages += estimate(section.rest);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (part.type === "media") {
|
|
59
|
+
// Media payload sizes do not translate to provider token counts.
|
|
60
|
+
tokens.Other += estimate(part.filename ?? part.mediaType);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (part.type === "tool-call") {
|
|
64
|
+
tokens.Messages += estimate(`${part.name} ${JSON.stringify(part.input)}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (part.type === "tool-result") {
|
|
68
|
+
const result = part.result;
|
|
69
|
+
const content = result.type === "content"
|
|
70
|
+
? result.value.map(item => item.type === "file" ? `${item.name ?? ""} ${item.mime}` : item.text).join(" ")
|
|
71
|
+
: JSON.stringify(result.value);
|
|
72
|
+
tokens.Messages += estimate(`${part.name} ${content}`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
tokens.Other += estimate(JSON.stringify(part));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { capturedAt, model: `${request.model.providerID}/${request.model.id}`, tokens };
|
|
79
|
+
}
|
|
80
|
+
export function parseContextSources(value) {
|
|
81
|
+
if (!value || typeof value !== "object")
|
|
82
|
+
return undefined;
|
|
83
|
+
const input = value;
|
|
84
|
+
if (typeof input.capturedAt !== "number" || !Number.isFinite(input.capturedAt) || typeof input.model !== "string")
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!input.tokens || typeof input.tokens !== "object")
|
|
87
|
+
return undefined;
|
|
88
|
+
if (!sourceLabels.every(label => typeof input.tokens?.[label] === "number" && Number.isFinite(input.tokens[label]) && input.tokens[label] >= 0))
|
|
89
|
+
return undefined;
|
|
90
|
+
return input;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Presentation rows for the estimated sources, largest first. Compact mode merges the
|
|
94
|
+
* two tool families into one `Tools` row and hides empty rows; detailed mode keeps the
|
|
95
|
+
* six stored labels apart, including zeroes.
|
|
96
|
+
*/
|
|
97
|
+
export function breakdownRows(sources, detailed) {
|
|
98
|
+
const t = sources.tokens;
|
|
99
|
+
const entries = detailed
|
|
100
|
+
? sourceLabels.map(label => [label, t[label]])
|
|
101
|
+
: [["Messages", t.Messages], ["Tools", t["System Tools"] + t["MCP Tools"]],
|
|
102
|
+
["System Prompt", t["System Prompt"]], ["Skills", t.Skills], ["Other", t.Other]];
|
|
103
|
+
const sum = sourceLabels.reduce((total, label) => total + t[label], 0);
|
|
104
|
+
return entries
|
|
105
|
+
.filter(([, tokens]) => detailed || tokens > 0)
|
|
106
|
+
.map(([label, tokens]) => ({ label, tokens, percent: sum ? tokens / sum * 100 : 0 }))
|
|
107
|
+
.sort((a, b) => b.tokens - a.tokens || a.label.localeCompare(b.label));
|
|
108
|
+
}
|
package/dist/controller.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type { UsageSource } from "./source.js";
|
|
2
2
|
import { PerformanceMonitor } from "./performance.js";
|
|
3
3
|
import type { PerformanceSummary } from "./performance.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { ContextSources } from "./context-sources.js";
|
|
5
|
+
import type { ContextDetails, ContextUsage, ModelCost, Summary } from "./usage.js";
|
|
5
6
|
export interface UsageState {
|
|
6
7
|
status: "loading" | "ready" | "stale" | "unavailable";
|
|
7
8
|
summary?: Summary;
|
|
8
9
|
context?: ContextUsage | undefined;
|
|
10
|
+
details?: {
|
|
11
|
+
context?: ContextDetails | undefined;
|
|
12
|
+
models: readonly ModelCost[];
|
|
13
|
+
sources?: ContextSources | undefined;
|
|
14
|
+
sessionTitle?: string | undefined;
|
|
15
|
+
};
|
|
9
16
|
performance?: PerformanceSummary;
|
|
10
17
|
model?: string;
|
|
11
18
|
}
|
|
@@ -16,13 +23,14 @@ export interface UsageEvent {
|
|
|
16
23
|
created?: number;
|
|
17
24
|
}
|
|
18
25
|
export type Subscribe = (listener: (event: UsageEvent) => void) => () => void;
|
|
19
|
-
/** Owns snapshot refreshes and throttled in-memory performance updates for one mounted
|
|
26
|
+
/** Owns snapshot refreshes and throttled in-memory performance updates for one mounted view. */
|
|
20
27
|
export declare class UsageController {
|
|
21
28
|
private readonly source;
|
|
22
29
|
private readonly publish;
|
|
23
30
|
private readonly delay;
|
|
24
31
|
private readonly retryDelay;
|
|
25
32
|
private readonly performanceDelay;
|
|
33
|
+
private readonly detailed;
|
|
26
34
|
private state;
|
|
27
35
|
private snapshot;
|
|
28
36
|
private sessionID;
|
|
@@ -38,7 +46,7 @@ export declare class UsageController {
|
|
|
38
46
|
private readonly unsubscribePerformance;
|
|
39
47
|
private readonly performance;
|
|
40
48
|
private readonly ownsPerformance;
|
|
41
|
-
constructor(source: UsageSource, subscribe: Subscribe, publish: (state: UsageState) => void, delay?: number, retryDelay?: number, performanceDelay?: number, performance?: PerformanceMonitor);
|
|
49
|
+
constructor(source: UsageSource, subscribe: Subscribe, publish: (state: UsageState) => void, delay?: number, retryDelay?: number, performanceDelay?: number, performance?: PerformanceMonitor, detailed?: boolean);
|
|
42
50
|
select(sessionID: string): void;
|
|
43
51
|
refresh(): void;
|
|
44
52
|
dispose(): void;
|
package/dist/controller.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
|
|
2
2
|
import { PerformanceMonitor } from "./performance.js";
|
|
3
|
-
import {
|
|
3
|
+
import { contextDetails, summarize, summarizeModels } from "./usage.js";
|
|
4
4
|
const sessionEvents = new Set([
|
|
5
5
|
"session.created", "session.deleted", "session.forked", "session.moved",
|
|
6
6
|
"session.model.selected", "session.agent.selected", "session.step.ended", "session.step.failed",
|
|
@@ -12,13 +12,14 @@ const globalEvents = new Set([
|
|
|
12
12
|
"server.connected", "model.updated", "provider.updated", "models-dev.refreshed",
|
|
13
13
|
"config.updated", "location.shutdown",
|
|
14
14
|
]);
|
|
15
|
-
/** Owns snapshot refreshes and throttled in-memory performance updates for one mounted
|
|
15
|
+
/** Owns snapshot refreshes and throttled in-memory performance updates for one mounted view. */
|
|
16
16
|
export class UsageController {
|
|
17
17
|
source;
|
|
18
18
|
publish;
|
|
19
19
|
delay;
|
|
20
20
|
retryDelay;
|
|
21
21
|
performanceDelay;
|
|
22
|
+
detailed;
|
|
22
23
|
state = { status: "loading" };
|
|
23
24
|
snapshot;
|
|
24
25
|
sessionID;
|
|
@@ -34,12 +35,13 @@ export class UsageController {
|
|
|
34
35
|
unsubscribePerformance;
|
|
35
36
|
performance;
|
|
36
37
|
ownsPerformance;
|
|
37
|
-
constructor(source, subscribe, publish, delay = 80, retryDelay = 3_000, performanceDelay = 100, performance) {
|
|
38
|
+
constructor(source, subscribe, publish, delay = 80, retryDelay = 3_000, performanceDelay = 100, performance, detailed = false) {
|
|
38
39
|
this.source = source;
|
|
39
40
|
this.publish = publish;
|
|
40
41
|
this.delay = delay;
|
|
41
42
|
this.retryDelay = retryDelay;
|
|
42
43
|
this.performanceDelay = performanceDelay;
|
|
44
|
+
this.detailed = detailed;
|
|
43
45
|
this.ownsPerformance = performance === undefined;
|
|
44
46
|
this.performance = performance ?? new PerformanceMonitor(subscribe);
|
|
45
47
|
this.unsubscribePerformance = this.performance.listen(sessionID => {
|
|
@@ -135,13 +137,25 @@ export class UsageController {
|
|
|
135
137
|
this.performance.reconcile(messages, this.sessions);
|
|
136
138
|
clearTimeout(this.performanceTimer);
|
|
137
139
|
this.performanceTimer = undefined;
|
|
140
|
+
const context = contextDetails(viewedMessages(snapshot), snapshot.model.context);
|
|
138
141
|
this.update({
|
|
139
142
|
status: "ready",
|
|
140
143
|
summary: summarize(messages, snapshot.model.catalog),
|
|
141
|
-
context:
|
|
144
|
+
context: context?.usage,
|
|
145
|
+
...(this.detailed ? { details: { context, models: summarizeModels(messages, snapshot.model.catalog), sessionTitle: snapshot.sessions.get(snapshot.viewedID)?.title } } : {}),
|
|
142
146
|
performance: this.performance.summary(messages, this.sessions),
|
|
143
147
|
model: snapshot.model.label,
|
|
144
148
|
});
|
|
149
|
+
// The server plugin may be absent. An optional RPC must not delay measured usage.
|
|
150
|
+
if (this.detailed && this.source.composition) {
|
|
151
|
+
void this.source.composition(snapshot.sessions.get(snapshot.viewedID), AbortSignal.any([request.signal, AbortSignal.timeout(5_000)]))
|
|
152
|
+
.then(sources => {
|
|
153
|
+
if (this.disposed || generation !== this.generation || this.snapshot !== snapshot || this.state.status !== "ready" || !this.state.details)
|
|
154
|
+
return;
|
|
155
|
+
this.update({ ...this.state, details: { ...this.state.details, sources } });
|
|
156
|
+
})
|
|
157
|
+
.catch(() => { });
|
|
158
|
+
}
|
|
145
159
|
}
|
|
146
160
|
catch {
|
|
147
161
|
if (this.disposed || generation !== this.generation)
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { estimateContextSources, parseContextSources } from "./context-sources.js";
|
|
3
|
+
import { ContextSourceRpc } from "./context-rpc.js";
|
|
2
4
|
export default Plugin.define({
|
|
3
5
|
id: "opencode-token-usage",
|
|
4
|
-
setup() {
|
|
6
|
+
async setup(context) {
|
|
7
|
+
const rpc = await context.rpc.register(ContextSourceRpc, {
|
|
8
|
+
latest: async (input) => {
|
|
9
|
+
const sessionID = input.sessionID;
|
|
10
|
+
return { estimate: parseContextSources(await context.storage.get(`context/${sessionID}`)) ?? null };
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
const hook = await context.session.hook("context", async (request) => {
|
|
14
|
+
try {
|
|
15
|
+
const servers = (await context.mcp.list()).data.map(server => server.name);
|
|
16
|
+
const estimate = estimateContextSources(request, servers);
|
|
17
|
+
await context.storage.set(`context/${request.sessionID}`, {
|
|
18
|
+
capturedAt: estimate.capturedAt, model: estimate.model, tokens: { ...estimate.tokens },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Instrumentation must not interrupt the model call.
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return async () => { await hook.dispose(); await rpc.dispose(); };
|
|
26
|
+
},
|
|
5
27
|
});
|
package/dist/source.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OpenCodeClient, SessionInfo } from "@opencode/client";
|
|
2
|
+
import type { ContextSources } from "./context-sources.js";
|
|
2
3
|
import type { PriceCatalog, UsageMessage } from "./usage.js";
|
|
3
|
-
export type Session = Pick<SessionInfo, "id" | "parentID" | "fork" | "model" | "location">;
|
|
4
|
+
export type Session = Pick<SessionInfo, "id" | "parentID" | "fork" | "model" | "location" | "title">;
|
|
4
5
|
export interface Page<T> {
|
|
5
6
|
data: T[];
|
|
6
7
|
cursor: {
|
|
@@ -19,6 +20,7 @@ export interface UsageSource {
|
|
|
19
20
|
children(id: string, cursor: string | undefined, signal: AbortSignal): Promise<Page<Session>>;
|
|
20
21
|
messages(id: string, cursor: string | undefined, signal: AbortSignal): Promise<Page<UsageMessage>>;
|
|
21
22
|
model(session: Session, signal: AbortSignal): Promise<ActiveModel>;
|
|
23
|
+
composition?(session: Session, signal: AbortSignal): Promise<ContextSources | undefined>;
|
|
22
24
|
}
|
|
23
25
|
export declare function createSource(client: OpenCodeClient): UsageSource;
|
|
24
26
|
export declare function pages<T>(read: (cursor?: string) => Promise<Page<T>>, signal: AbortSignal): Promise<T[]>;
|
package/dist/source.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ContextSourceRpc } from "./context-rpc.js";
|
|
2
|
+
import { parseContextSources } from "./context-sources.js";
|
|
1
3
|
import { modelKey } from "./usage.js";
|
|
2
4
|
export function createSource(client) {
|
|
3
5
|
return {
|
|
@@ -21,6 +23,12 @@ export function createSource(client) {
|
|
|
21
23
|
context: model?.limit?.context,
|
|
22
24
|
};
|
|
23
25
|
},
|
|
26
|
+
async composition(session, signal) {
|
|
27
|
+
const response = await client.rpc(ContextSourceRpc).latest({ sessionID: session.id }, { location: session.location, signal });
|
|
28
|
+
if (!response || typeof response !== "object" || !("estimate" in response))
|
|
29
|
+
return undefined;
|
|
30
|
+
return parseContextSources(response.estimate);
|
|
31
|
+
},
|
|
24
32
|
};
|
|
25
33
|
}
|
|
26
34
|
export async function pages(read, signal) {
|