@lijinzhao8/opencode-usage 1.0.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 +120 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +283 -0
- package/dist/pricing.d.ts +71 -0
- package/dist/tui.d.ts +12 -0
- package/dist/tui.js +163 -0
- package/dist/types.d.ts +83 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# opencode-usage
|
|
2
|
+
|
|
3
|
+
OpenCode 插件:实时显示 API 用量和费用统计,完全复制 API-Proxy 的定价逻辑(中转组、按模型费率、时间段倍率)。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 实时显示 token 用量(输入/输出/缓存)
|
|
8
|
+
- 按模型计算费用(复制 API-Proxy 的 `calculateAllowanceDebitUnits` 逻辑)
|
|
9
|
+
- 支持中转共享组(Relay Groups)概念
|
|
10
|
+
- 支持时间段倍率(Beijing time)
|
|
11
|
+
- 显示每条消息的增量费用
|
|
12
|
+
- 显示会话累计总量
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
opencode plugin @lijinzhao8/opencode-usage@latest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 配置
|
|
21
|
+
|
|
22
|
+
在 `.opencode/opencode.json` 的 `plugin` 数组中添加:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"plugin": [
|
|
27
|
+
"@lijinzhao8/opencode-usage@latest"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 定价配置
|
|
33
|
+
|
|
34
|
+
编辑 `src/pricing.ts` 中的 `DEFAULT_GROUPS` 和 `DEFAULT_PROVIDERS`,或者在插件初始化时传入 options:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// 在 opencode.json 中无法直接传 options,
|
|
38
|
+
// 请直接修改 src/pricing.ts 中的默认配置
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 中转组(Relay Groups)
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
DEFAULT_GROUPS = [
|
|
45
|
+
{
|
|
46
|
+
id: "grp_api_proxy",
|
|
47
|
+
name: "API-Proxy",
|
|
48
|
+
enabled: true,
|
|
49
|
+
provider_ids: ["api-proxy"], // 属于此组的 provider ID
|
|
50
|
+
initial_balance: 0,
|
|
51
|
+
rates_text: `
|
|
52
|
+
* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15
|
|
53
|
+
gpt-5.5 input=2.50 output=10.00
|
|
54
|
+
claude-opus-4-8 input=15.00 output=75.00
|
|
55
|
+
`,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 费率格式(rates_text)
|
|
61
|
+
|
|
62
|
+
每行一个模型规则,格式:`model_name key=value ...`
|
|
63
|
+
|
|
64
|
+
| Key | 说明 | 单位 |
|
|
65
|
+
|-----|------|------|
|
|
66
|
+
| `input` | 输入 token 费率 | 美元/百万 token |
|
|
67
|
+
| `output` | 输出 token 费率 | 美元/百万 token |
|
|
68
|
+
| `cache_hit` | 缓存命中费率 | 美元/百万 token |
|
|
69
|
+
| `cache_create` | 缓存创建费率 | 美元/百万 token |
|
|
70
|
+
| `calls` | 按次计费次数 | 次 |
|
|
71
|
+
| `call_value` | 每次费用 | 美元 |
|
|
72
|
+
| `multiplier` | 全局倍率 | 倍数 |
|
|
73
|
+
| `time_mult` | 时间段倍率 | 格式:`HH:MM-HH:MM=倍率;...` |
|
|
74
|
+
|
|
75
|
+
`*` 匹配所有未明确列出的模型。
|
|
76
|
+
|
|
77
|
+
### 时间段倍率
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# 北京时间 22:00-02:00 加价 50%
|
|
81
|
+
time_mult=22:00-02:00=1.5
|
|
82
|
+
|
|
83
|
+
# 多个时段
|
|
84
|
+
time_mult=22:00-02:00=1.5;08:00-20:00=1.0
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## 费用计算公式
|
|
88
|
+
|
|
89
|
+
完全复制 API-Proxy 的 `calculateAllowanceDebitUnits`:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
normalInput = max(0, input - cacheHit - cacheCreate)
|
|
93
|
+
cost = (normalInput / 1M × rate.input
|
|
94
|
+
+ output / 1M × rate.output
|
|
95
|
+
+ cacheHit / 1M × rate.cache_hit
|
|
96
|
+
+ cacheCreate / 1M × rate.cache_create)
|
|
97
|
+
× multiplier × timeMultiplier
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 显示位置
|
|
101
|
+
|
|
102
|
+
- **侧边栏底部**(session 视图)
|
|
103
|
+
- **首页底部**(home 视图)
|
|
104
|
+
|
|
105
|
+
## 开发
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# 安装依赖
|
|
109
|
+
npm install
|
|
110
|
+
|
|
111
|
+
# 构建
|
|
112
|
+
bun run build
|
|
113
|
+
|
|
114
|
+
# 类型检查
|
|
115
|
+
bun run typecheck
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-usage — Server Plugin
|
|
3
|
+
*
|
|
4
|
+
* Captures token usage from each AI response and calculates costs
|
|
5
|
+
* using the same logic as API-Proxy (relay groups, per-model rates,
|
|
6
|
+
* time multipliers). Persists data via the plugin kv store.
|
|
7
|
+
*/
|
|
8
|
+
import type { UsagePluginOptions } from "./types";
|
|
9
|
+
export default function opencodeUsagePlugin(ctx: any, options?: UsagePluginOptions): Promise<{
|
|
10
|
+
event: ({ event }: {
|
|
11
|
+
event: any;
|
|
12
|
+
}) => Promise<void>;
|
|
13
|
+
"chat.message": (input: any, output: any) => Promise<void>;
|
|
14
|
+
}>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// src/pricing.ts
|
|
2
|
+
var DEFAULT_GROUP_RATES = `* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15`;
|
|
3
|
+
var DEFAULT_GROUPS = [
|
|
4
|
+
{
|
|
5
|
+
id: "grp_api_proxy",
|
|
6
|
+
name: "API-Proxy",
|
|
7
|
+
enabled: true,
|
|
8
|
+
provider_ids: ["api-proxy"],
|
|
9
|
+
initial_balance: 0,
|
|
10
|
+
rates_text: DEFAULT_GROUP_RATES
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "grp_xiaomi",
|
|
14
|
+
name: "Xiaomi Token Plan",
|
|
15
|
+
enabled: true,
|
|
16
|
+
provider_ids: ["xiaomi-token-plan"],
|
|
17
|
+
initial_balance: 0,
|
|
18
|
+
rates_text: DEFAULT_GROUP_RATES
|
|
19
|
+
}
|
|
20
|
+
];
|
|
21
|
+
var DEFAULT_PROVIDERS = [
|
|
22
|
+
{
|
|
23
|
+
id: "api-proxy",
|
|
24
|
+
name: "API-Proxy",
|
|
25
|
+
enabled: true,
|
|
26
|
+
group_id: "grp_api_proxy",
|
|
27
|
+
model_rates: {}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "xiaomi-token-plan",
|
|
31
|
+
name: "Xiaomi Token Plan",
|
|
32
|
+
enabled: true,
|
|
33
|
+
group_id: "grp_xiaomi",
|
|
34
|
+
model_rates: {}
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "penguin-lm-gpt",
|
|
38
|
+
name: "Penguin LM GPT",
|
|
39
|
+
enabled: true,
|
|
40
|
+
group_id: "",
|
|
41
|
+
model_rates: {}
|
|
42
|
+
}
|
|
43
|
+
];
|
|
44
|
+
var ALLOWANCE_SCALE = 1e6;
|
|
45
|
+
function parseRateLine(line) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
48
|
+
return null;
|
|
49
|
+
const parts = trimmed.split(/\s+/);
|
|
50
|
+
if (parts.length < 2)
|
|
51
|
+
return null;
|
|
52
|
+
const model = parts[0];
|
|
53
|
+
const rate = {
|
|
54
|
+
input: 0,
|
|
55
|
+
output: 0,
|
|
56
|
+
cache_hit: 0,
|
|
57
|
+
cache_create: 0,
|
|
58
|
+
calls: 0,
|
|
59
|
+
call_value: 0,
|
|
60
|
+
multiplier: 1,
|
|
61
|
+
time_mult: ""
|
|
62
|
+
};
|
|
63
|
+
for (let i = 1;i < parts.length; i++) {
|
|
64
|
+
const eqIndex = parts[i].indexOf("=");
|
|
65
|
+
if (eqIndex === -1)
|
|
66
|
+
continue;
|
|
67
|
+
const key = parts[i].substring(0, eqIndex);
|
|
68
|
+
const value = parseFloat(parts[i].substring(eqIndex + 1));
|
|
69
|
+
if (key in rate) {
|
|
70
|
+
rate[key] = isNaN(value) ? 0 : value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { model, rate };
|
|
74
|
+
}
|
|
75
|
+
function parseRates(ratesText) {
|
|
76
|
+
const rates = {};
|
|
77
|
+
for (const line of ratesText.split(`
|
|
78
|
+
`)) {
|
|
79
|
+
const parsed = parseRateLine(line);
|
|
80
|
+
if (parsed) {
|
|
81
|
+
rates[parsed.model] = parsed.rate;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return rates;
|
|
85
|
+
}
|
|
86
|
+
function findRate(rates, model) {
|
|
87
|
+
return rates[model] || rates["*"] || null;
|
|
88
|
+
}
|
|
89
|
+
function computeTimeMultiplier(timeMultStr) {
|
|
90
|
+
if (!timeMultStr)
|
|
91
|
+
return 1;
|
|
92
|
+
const now = new Date;
|
|
93
|
+
const bjHour = (now.getUTCHours() + 8) % 24;
|
|
94
|
+
const bjMinute = now.getUTCMinutes();
|
|
95
|
+
const currentMinutes = bjHour * 60 + bjMinute;
|
|
96
|
+
const segments = timeMultStr.split(";");
|
|
97
|
+
for (const seg of segments) {
|
|
98
|
+
const match = seg.trim().match(/^(\d{2}):(\d{2})-(\d{2}):(\d{2})=(.+)$/);
|
|
99
|
+
if (!match)
|
|
100
|
+
continue;
|
|
101
|
+
const startH = parseInt(match[1], 10);
|
|
102
|
+
const startM = parseInt(match[2], 10);
|
|
103
|
+
const endH = parseInt(match[3], 10);
|
|
104
|
+
const endM = parseInt(match[4], 10);
|
|
105
|
+
const mult = parseFloat(match[5]);
|
|
106
|
+
if (isNaN(mult))
|
|
107
|
+
continue;
|
|
108
|
+
const startMin = startH * 60 + startM;
|
|
109
|
+
const endMin = endH * 60 + endM;
|
|
110
|
+
if (startMin <= endMin) {
|
|
111
|
+
if (currentMinutes >= startMin && currentMinutes <= endMin) {
|
|
112
|
+
return mult;
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
if (currentMinutes >= startMin || currentMinutes <= endMin) {
|
|
116
|
+
return mult;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
function calculateCost(usage, rate) {
|
|
123
|
+
if (!rate)
|
|
124
|
+
return 0;
|
|
125
|
+
const multiplier = (rate.multiplier || 1) * computeTimeMultiplier(rate.time_mult || "");
|
|
126
|
+
if (rate.calls > 0 && rate.call_value > 0) {
|
|
127
|
+
return Math.ceil(rate.calls) * rate.call_value * multiplier;
|
|
128
|
+
}
|
|
129
|
+
const normalInput = Math.max(0, usage.input - usage.cache_hit - usage.cache_create);
|
|
130
|
+
const cost = normalInput / ALLOWANCE_SCALE * rate.input + usage.output / ALLOWANCE_SCALE * rate.output + usage.cache_hit / ALLOWANCE_SCALE * rate.cache_hit + usage.cache_create / ALLOWANCE_SCALE * rate.cache_create;
|
|
131
|
+
return cost * multiplier;
|
|
132
|
+
}
|
|
133
|
+
function calculateModelCost(usage, providerId, modelId, groups, providers) {
|
|
134
|
+
const provider = providers.find((p) => p.id === providerId);
|
|
135
|
+
if (!provider || !provider.enabled)
|
|
136
|
+
return 0;
|
|
137
|
+
const providerRate = provider.model_rates?.[modelId];
|
|
138
|
+
if (providerRate) {
|
|
139
|
+
const baseRate = {
|
|
140
|
+
input: 0,
|
|
141
|
+
output: 0,
|
|
142
|
+
cache_hit: 0,
|
|
143
|
+
cache_create: 0,
|
|
144
|
+
calls: 0,
|
|
145
|
+
call_value: 0,
|
|
146
|
+
multiplier: 1,
|
|
147
|
+
time_mult: "",
|
|
148
|
+
...providerRate
|
|
149
|
+
};
|
|
150
|
+
return calculateCost(usage, baseRate);
|
|
151
|
+
}
|
|
152
|
+
if (provider.group_id) {
|
|
153
|
+
const group = groups.find((g) => g.id === provider.group_id && g.enabled);
|
|
154
|
+
if (group) {
|
|
155
|
+
const groupRates = parseRates(group.rates_text);
|
|
156
|
+
const rate = findRate(groupRates, modelId);
|
|
157
|
+
if (rate)
|
|
158
|
+
return calculateCost(usage, rate);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/index.ts
|
|
165
|
+
var KV_PREFIX = "ou_";
|
|
166
|
+
var KV_SESSION_PREFIX = `${KV_PREFIX}session_`;
|
|
167
|
+
var KV_OPTIONS_KEY = `${KV_PREFIX}options`;
|
|
168
|
+
function genId() {
|
|
169
|
+
return `ou_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
170
|
+
}
|
|
171
|
+
function parseUsageFromEvent(event) {
|
|
172
|
+
const props = event.properties || event;
|
|
173
|
+
const info = props.info || props;
|
|
174
|
+
const usage = info.usage || info.token_usage || info;
|
|
175
|
+
if (!usage)
|
|
176
|
+
return null;
|
|
177
|
+
const input = usage.input_tokens ?? usage.prompt_tokens ?? usage.inputTokens ?? usage.input_token_count ?? 0;
|
|
178
|
+
const output = usage.output_tokens ?? usage.completion_tokens ?? usage.outputTokens ?? usage.candidates_token_count ?? 0;
|
|
179
|
+
const cache_hit = usage.cache_tokens ?? usage.cached_tokens ?? usage.cached_content_token_count ?? usage.prompt_tokens_details?.cached_tokens ?? 0;
|
|
180
|
+
const cache_create = usage.cache_create_tokens ?? usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? 0;
|
|
181
|
+
if (input === 0 && output === 0)
|
|
182
|
+
return null;
|
|
183
|
+
return { input, output, cache_hit, cache_create };
|
|
184
|
+
}
|
|
185
|
+
async function opencodeUsagePlugin(ctx, options) {
|
|
186
|
+
const groups = options?.groups ?? DEFAULT_GROUPS;
|
|
187
|
+
const providers = options?.providers ?? DEFAULT_PROVIDERS;
|
|
188
|
+
let currentSessionId = null;
|
|
189
|
+
let sessionUsage = {
|
|
190
|
+
total_input: 0,
|
|
191
|
+
total_output: 0,
|
|
192
|
+
total_cache_hit: 0,
|
|
193
|
+
total_cache_create: 0,
|
|
194
|
+
total_cost: 0,
|
|
195
|
+
records: []
|
|
196
|
+
};
|
|
197
|
+
function saveSessionUsage(kv) {
|
|
198
|
+
if (!currentSessionId)
|
|
199
|
+
return;
|
|
200
|
+
try {
|
|
201
|
+
const key = `${KV_SESSION_PREFIX}${currentSessionId}`;
|
|
202
|
+
kv.set(key, JSON.stringify(sessionUsage));
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
function loadSessionUsage(kv, sessionId) {
|
|
206
|
+
try {
|
|
207
|
+
const key = `${KV_SESSION_PREFIX}${sessionId}`;
|
|
208
|
+
const raw = kv.get(key);
|
|
209
|
+
if (raw)
|
|
210
|
+
return JSON.parse(raw);
|
|
211
|
+
} catch {}
|
|
212
|
+
return {
|
|
213
|
+
total_input: 0,
|
|
214
|
+
total_output: 0,
|
|
215
|
+
total_cache_hit: 0,
|
|
216
|
+
total_cache_create: 0,
|
|
217
|
+
total_cost: 0,
|
|
218
|
+
records: []
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
event: async ({ event }) => {
|
|
223
|
+
const type = event.type;
|
|
224
|
+
const props = event.properties || {};
|
|
225
|
+
const info = props.info || props;
|
|
226
|
+
if (type === "session.created" || type === "session.updated") {
|
|
227
|
+
const sid = info.id || info.sessionID || info.sessionId;
|
|
228
|
+
if (sid && sid !== currentSessionId) {
|
|
229
|
+
currentSessionId = sid;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (type === "message.updated" || type === "message.completed" || type === "message.finished") {
|
|
233
|
+
const usage = parseUsageFromEvent(event);
|
|
234
|
+
if (!usage)
|
|
235
|
+
return;
|
|
236
|
+
const providerId = info.providerID || info.providerId || info.provider || "";
|
|
237
|
+
const modelId = info.modelID || info.modelId || info.model || "";
|
|
238
|
+
if (!modelId)
|
|
239
|
+
return;
|
|
240
|
+
const cost = calculateModelCost(usage, providerId, modelId, groups, providers);
|
|
241
|
+
const record = {
|
|
242
|
+
id: genId(),
|
|
243
|
+
provider_id: providerId,
|
|
244
|
+
model_id: modelId,
|
|
245
|
+
input_tokens: usage.input,
|
|
246
|
+
output_tokens: usage.output,
|
|
247
|
+
cache_tokens: usage.cache_hit,
|
|
248
|
+
cache_create_tokens: usage.cache_create,
|
|
249
|
+
cost,
|
|
250
|
+
timestamp: Date.now()
|
|
251
|
+
};
|
|
252
|
+
sessionUsage.total_input += usage.input;
|
|
253
|
+
sessionUsage.total_output += usage.output;
|
|
254
|
+
sessionUsage.total_cache_hit += usage.cache_hit;
|
|
255
|
+
sessionUsage.total_cache_create += usage.cache_create;
|
|
256
|
+
sessionUsage.total_cost += cost;
|
|
257
|
+
sessionUsage.records.push(record);
|
|
258
|
+
if (sessionUsage.records.length > 200) {
|
|
259
|
+
sessionUsage.records = sessionUsage.records.slice(-200);
|
|
260
|
+
}
|
|
261
|
+
if (typeof globalThis !== "undefined") {
|
|
262
|
+
globalThis.__opencode_usage_state = {
|
|
263
|
+
groups,
|
|
264
|
+
providers,
|
|
265
|
+
session: sessionUsage,
|
|
266
|
+
lastRecord: record
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
"chat.message": async (input, output) => {
|
|
272
|
+
if (input?.model?.providerID) {
|
|
273
|
+
if (typeof globalThis !== "undefined") {
|
|
274
|
+
globalThis.__opencode_usage_last_provider = input.model.providerID;
|
|
275
|
+
globalThis.__opencode_usage_last_model = input.model.modelID;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
export {
|
|
282
|
+
opencodeUsagePlugin as default
|
|
283
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pricing configuration for opencode-usage plugin.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the API-Proxy's rates_text format:
|
|
5
|
+
* model_name input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15
|
|
6
|
+
*
|
|
7
|
+
* Each line is: <model_pattern> <key>=<value> ...
|
|
8
|
+
*
|
|
9
|
+
* Supported keys:
|
|
10
|
+
* input - cost per million input tokens (after cache subtracted)
|
|
11
|
+
* output - cost per million output tokens
|
|
12
|
+
* cache_hit - cost per million cache hit tokens
|
|
13
|
+
* cache_create - cost per million cache creation tokens
|
|
14
|
+
* calls - cost per call (overrides per-token billing)
|
|
15
|
+
* call_value - dollar value per call
|
|
16
|
+
* multiplier - global cost multiplier (default: 1)
|
|
17
|
+
* time_mult - time-based multipliers (Beijing time)
|
|
18
|
+
* format: "HH:MM-HH:MM=mult;HH:MM-HH:MM=mult"
|
|
19
|
+
* supports crossing midnight (e.g. 22:00-02:00)
|
|
20
|
+
*
|
|
21
|
+
* The "*" model pattern matches any model not explicitly listed.
|
|
22
|
+
*/
|
|
23
|
+
import type { RelayGroup, ProviderConfig, RateConfig, TokenUsage } from "./types";
|
|
24
|
+
/** Default per-token rate for relay groups */
|
|
25
|
+
export declare const DEFAULT_GROUP_RATES = "* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15";
|
|
26
|
+
/** Default per-token rate for standalone providers */
|
|
27
|
+
export declare const DEFAULT_PROVIDER_RATES = "* input=1.00 output=4.00 cache_hit=0.15 cache_create=1.00";
|
|
28
|
+
export declare const DEFAULT_GROUPS: RelayGroup[];
|
|
29
|
+
export declare const DEFAULT_PROVIDERS: ProviderConfig[];
|
|
30
|
+
/**
|
|
31
|
+
* Parse a single rate line into a RateConfig object.
|
|
32
|
+
* Format: "model_name key=value key=value ..."
|
|
33
|
+
*/
|
|
34
|
+
export declare function parseRateLine(line: string): {
|
|
35
|
+
model: string;
|
|
36
|
+
rate: Partial<RateConfig>;
|
|
37
|
+
} | null;
|
|
38
|
+
/**
|
|
39
|
+
* Parse multiple rate lines into a map of model → RateConfig.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseRates(ratesText: string): Record<string, RateConfig>;
|
|
42
|
+
/**
|
|
43
|
+
* Find the rate for a specific model.
|
|
44
|
+
* Priority: exact match → wildcard "*" → null
|
|
45
|
+
*/
|
|
46
|
+
export declare function findRate(rates: Record<string, RateConfig>, model: string): RateConfig | null;
|
|
47
|
+
/**
|
|
48
|
+
* Compute the time-based multiplier for the current moment (Beijing time).
|
|
49
|
+
* Supports ranges that cross midnight, e.g. "22:00-02:00=1.5"
|
|
50
|
+
*/
|
|
51
|
+
export declare function computeTimeMultiplier(timeMultStr: string): number;
|
|
52
|
+
/**
|
|
53
|
+
* Calculate the cost of a request in dollars.
|
|
54
|
+
*
|
|
55
|
+
* Formula:
|
|
56
|
+
* normalInput = max(0, input - cacheHit - cacheCreate)
|
|
57
|
+
* cost = (normalInput / 1M * rate.input
|
|
58
|
+
* + output / 1M * rate.output
|
|
59
|
+
* + cacheHit / 1M * rate.cache_hit
|
|
60
|
+
* + cacheCreate / 1M * rate.cache_create)
|
|
61
|
+
* * multiplier * timeMultiplier
|
|
62
|
+
*
|
|
63
|
+
* For per-call billing:
|
|
64
|
+
* cost = calls * call_value
|
|
65
|
+
*/
|
|
66
|
+
export declare function calculateCost(usage: TokenUsage, rate: RateConfig): number;
|
|
67
|
+
/**
|
|
68
|
+
* Calculate cost for a specific provider + model combination.
|
|
69
|
+
* Looks up rates from the group's rates_text, then applies provider model_rates overrides.
|
|
70
|
+
*/
|
|
71
|
+
export declare function calculateModelCost(usage: TokenUsage, providerId: string, modelId: string, groups: RelayGroup[], providers: ProviderConfig[]): number;
|
package/dist/tui.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-usage — TUI Plugin
|
|
3
|
+
*
|
|
4
|
+
* Displays real-time API usage and cost information in the OpenCode TUI.
|
|
5
|
+
* Shows per-message cost and session totals in the sidebar footer.
|
|
6
|
+
*/
|
|
7
|
+
import type { UsagePluginOptions } from "./types";
|
|
8
|
+
declare const _default: {
|
|
9
|
+
id: string;
|
|
10
|
+
tui: (api: any, options?: UsagePluginOptions, meta?: any) => Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
export default _default;
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/pricing.ts
|
|
5
|
+
var DEFAULT_GROUP_RATES = `* input=0.15 output=0.60 cache_hit=0.03 cache_create=0.15`;
|
|
6
|
+
var DEFAULT_GROUPS = [
|
|
7
|
+
{
|
|
8
|
+
id: "grp_api_proxy",
|
|
9
|
+
name: "API-Proxy",
|
|
10
|
+
enabled: true,
|
|
11
|
+
provider_ids: ["api-proxy"],
|
|
12
|
+
initial_balance: 0,
|
|
13
|
+
rates_text: DEFAULT_GROUP_RATES
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "grp_xiaomi",
|
|
17
|
+
name: "Xiaomi Token Plan",
|
|
18
|
+
enabled: true,
|
|
19
|
+
provider_ids: ["xiaomi-token-plan"],
|
|
20
|
+
initial_balance: 0,
|
|
21
|
+
rates_text: DEFAULT_GROUP_RATES
|
|
22
|
+
}
|
|
23
|
+
];
|
|
24
|
+
var DEFAULT_PROVIDERS = [
|
|
25
|
+
{
|
|
26
|
+
id: "api-proxy",
|
|
27
|
+
name: "API-Proxy",
|
|
28
|
+
enabled: true,
|
|
29
|
+
group_id: "grp_api_proxy",
|
|
30
|
+
model_rates: {}
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "xiaomi-token-plan",
|
|
34
|
+
name: "Xiaomi Token Plan",
|
|
35
|
+
enabled: true,
|
|
36
|
+
group_id: "grp_xiaomi",
|
|
37
|
+
model_rates: {}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "penguin-lm-gpt",
|
|
41
|
+
name: "Penguin LM GPT",
|
|
42
|
+
enabled: true,
|
|
43
|
+
group_id: "",
|
|
44
|
+
model_rates: {}
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// src/tui.ts
|
|
49
|
+
function element(tag, props = {}, children = []) {
|
|
50
|
+
try {
|
|
51
|
+
const { createElement, insert, setProp } = __require("@opentui/solid");
|
|
52
|
+
const node = createElement(tag);
|
|
53
|
+
for (const [key, value] of Object.entries(props)) {
|
|
54
|
+
if (value !== undefined)
|
|
55
|
+
setProp(node, key, value);
|
|
56
|
+
}
|
|
57
|
+
for (const child of children) {
|
|
58
|
+
if (child === null || child === undefined || child === false)
|
|
59
|
+
continue;
|
|
60
|
+
insert(node, child);
|
|
61
|
+
}
|
|
62
|
+
return node;
|
|
63
|
+
} catch {
|
|
64
|
+
return { tag, props, children };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function text(props, children) {
|
|
68
|
+
return element("text", props, children);
|
|
69
|
+
}
|
|
70
|
+
function box(props = {}, children = []) {
|
|
71
|
+
return element("box", props, children);
|
|
72
|
+
}
|
|
73
|
+
function formatNumber(n) {
|
|
74
|
+
if (n >= 1e6)
|
|
75
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
76
|
+
if (n >= 1000)
|
|
77
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
78
|
+
return n.toString();
|
|
79
|
+
}
|
|
80
|
+
function formatCost(cost, decimals = 4) {
|
|
81
|
+
if (cost === 0)
|
|
82
|
+
return "$0";
|
|
83
|
+
if (cost < 0.0001)
|
|
84
|
+
return "<$0.0001";
|
|
85
|
+
return `$${cost.toFixed(decimals)}`;
|
|
86
|
+
}
|
|
87
|
+
var tui_default = {
|
|
88
|
+
id: "opencode-usage:tui",
|
|
89
|
+
tui: async (api, options, meta) => {
|
|
90
|
+
const t = api.theme.current;
|
|
91
|
+
const currency = options?.currency ?? "$";
|
|
92
|
+
const costDecimals = options?.cost_decimals ?? 4;
|
|
93
|
+
const showPerMsg = options?.show_per_message !== false;
|
|
94
|
+
const showTotals = options?.show_session_totals !== false;
|
|
95
|
+
const groups = options?.groups ?? DEFAULT_GROUPS;
|
|
96
|
+
const providers = options?.providers ?? DEFAULT_PROVIDERS;
|
|
97
|
+
function getUsageState() {
|
|
98
|
+
try {
|
|
99
|
+
const state = globalThis.__opencode_usage_state;
|
|
100
|
+
if (state) {
|
|
101
|
+
return {
|
|
102
|
+
session: state.session,
|
|
103
|
+
lastRecord: state.lastRecord
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
} catch {}
|
|
107
|
+
return { session: null, lastRecord: null };
|
|
108
|
+
}
|
|
109
|
+
function renderUsage() {
|
|
110
|
+
const { session, lastRecord } = getUsageState();
|
|
111
|
+
if (!session || session.total_input === 0) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const children = [];
|
|
115
|
+
if (showTotals) {
|
|
116
|
+
children.push(text({ fg: t.textMuted, bold: true }, [` ${currency} `]), text({ fg: t.accent }, [`T: ${formatNumber(session.total_input + session.total_output)}tok`]), text({ fg: t.textMuted }, [` | In: ${formatNumber(session.total_input)}`]), text({ fg: t.textMuted }, [` Out: ${formatNumber(session.total_output)}`]));
|
|
117
|
+
if (session.total_cache_hit > 0) {
|
|
118
|
+
children.push(text({ fg: t.textMuted }, [` Cache: ${formatNumber(session.total_cache_hit)}`]));
|
|
119
|
+
}
|
|
120
|
+
children.push(text({ fg: t.accent, bold: true }, [` ${formatCost(session.total_cost, costDecimals)}`]));
|
|
121
|
+
}
|
|
122
|
+
if (showPerMsg && lastRecord && lastRecord.cost > 0) {
|
|
123
|
+
const modelShort = lastRecord.model_id.split("/").pop() || lastRecord.model_id;
|
|
124
|
+
children.push(text({ fg: t.textMuted }, [` └ ${modelShort}: `]), text({ fg: t.accent }, [`+${formatCost(lastRecord.cost, costDecimals)}`]), text({ fg: t.textMuted }, [` (${formatNumber(lastRecord.input_tokens)}→${formatNumber(lastRecord.output_tokens)})`]));
|
|
125
|
+
}
|
|
126
|
+
if (children.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
return box({
|
|
129
|
+
width: "100%",
|
|
130
|
+
flexDirection: "column",
|
|
131
|
+
padding: { left: 1, right: 0, top: 0, bottom: 0 }
|
|
132
|
+
}, children);
|
|
133
|
+
}
|
|
134
|
+
api.slots.register({
|
|
135
|
+
order: 500,
|
|
136
|
+
slots: {
|
|
137
|
+
sidebar_footer() {
|
|
138
|
+
return renderUsage();
|
|
139
|
+
},
|
|
140
|
+
home_footer() {
|
|
141
|
+
return renderUsage();
|
|
142
|
+
},
|
|
143
|
+
app_bottom() {
|
|
144
|
+
return renderUsage();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
api.event.on("message.updated", () => {
|
|
149
|
+
try {
|
|
150
|
+
api.renderer.render();
|
|
151
|
+
} catch {}
|
|
152
|
+
});
|
|
153
|
+
api.event.on("message.completed", () => {
|
|
154
|
+
try {
|
|
155
|
+
api.renderer.render();
|
|
156
|
+
} catch {}
|
|
157
|
+
});
|
|
158
|
+
api.lifecycle.onDispose(() => {});
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
export {
|
|
162
|
+
tui_default as default
|
|
163
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface TokenUsage {
|
|
2
|
+
input: number;
|
|
3
|
+
output: number;
|
|
4
|
+
cache_hit: number;
|
|
5
|
+
cache_create: number;
|
|
6
|
+
}
|
|
7
|
+
export interface RateConfig {
|
|
8
|
+
/** Cost per million tokens for normal input (after subtracting cache) */
|
|
9
|
+
input: number;
|
|
10
|
+
/** Cost per million tokens for output */
|
|
11
|
+
output: number;
|
|
12
|
+
/** Cost per million tokens for cache hits */
|
|
13
|
+
cache_hit: number;
|
|
14
|
+
/** Cost per million tokens for cache creation */
|
|
15
|
+
cache_create: number;
|
|
16
|
+
/** Cost per call (if > 0, uses per-call billing instead of per-token) */
|
|
17
|
+
calls: number;
|
|
18
|
+
/** Cost per call in dollars */
|
|
19
|
+
call_value: number;
|
|
20
|
+
/** Global multiplier applied to the total cost */
|
|
21
|
+
multiplier: number;
|
|
22
|
+
/** Time-based multipliers (Beijing time), e.g. "22:00-02:00=1.5;08:00-20:00=1.0" */
|
|
23
|
+
time_mult: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RelayGroup {
|
|
26
|
+
/** Unique group ID */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Human-readable group name */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Whether this group is enabled */
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
/** List of provider IDs that belong to this group */
|
|
33
|
+
provider_ids: string[];
|
|
34
|
+
/** Initial balance in dollars (only used for display/reference) */
|
|
35
|
+
initial_balance: number;
|
|
36
|
+
/** Pricing rules text (one line per model, same format as API-Proxy) */
|
|
37
|
+
rates_text: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ProviderConfig {
|
|
40
|
+
/** Provider ID (e.g. "api-proxy", "xiaomi-token-plan") */
|
|
41
|
+
id: string;
|
|
42
|
+
/** Display name */
|
|
43
|
+
name: string;
|
|
44
|
+
/** Whether this provider is enabled */
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
/** Relay group ID this provider belongs to (empty = standalone) */
|
|
47
|
+
group_id: string;
|
|
48
|
+
/** Per-model rate overrides (overrides group rates) */
|
|
49
|
+
model_rates: Record<string, Partial<RateConfig>>;
|
|
50
|
+
}
|
|
51
|
+
export interface UsageRecord {
|
|
52
|
+
id: string;
|
|
53
|
+
provider_id: string;
|
|
54
|
+
model_id: string;
|
|
55
|
+
input_tokens: number;
|
|
56
|
+
output_tokens: number;
|
|
57
|
+
cache_tokens: number;
|
|
58
|
+
cache_create_tokens: number;
|
|
59
|
+
cost: number;
|
|
60
|
+
timestamp: number;
|
|
61
|
+
}
|
|
62
|
+
export interface SessionUsage {
|
|
63
|
+
total_input: number;
|
|
64
|
+
total_output: number;
|
|
65
|
+
total_cache_hit: number;
|
|
66
|
+
total_cache_create: number;
|
|
67
|
+
total_cost: number;
|
|
68
|
+
records: UsageRecord[];
|
|
69
|
+
}
|
|
70
|
+
export interface UsagePluginOptions {
|
|
71
|
+
/** Relay groups configuration */
|
|
72
|
+
groups?: RelayGroup[];
|
|
73
|
+
/** Provider configurations */
|
|
74
|
+
providers?: ProviderConfig[];
|
|
75
|
+
/** Currency symbol (default: "$") */
|
|
76
|
+
currency?: string;
|
|
77
|
+
/** Show per-message cost (default: true) */
|
|
78
|
+
show_per_message?: boolean;
|
|
79
|
+
/** Show session totals (default: true) */
|
|
80
|
+
show_session_totals?: boolean;
|
|
81
|
+
/** Decimal places for cost display (default: 4) */
|
|
82
|
+
cost_decimals?: number;
|
|
83
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lijinzhao8/opencode-usage",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode plugin that displays real-time API usage and cost tracking, replicating API-Proxy's pricing logic",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
},
|
|
13
|
+
"./tui": {
|
|
14
|
+
"import": "./dist/tui.js",
|
|
15
|
+
"types": "./dist/tui.d.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"clean": "rm -rf dist",
|
|
23
|
+
"build": "bun run clean && bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @opentui/core --external @opentui/solid && bun build src/tui.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @opentui/core --external @opentui/solid && tsc --emitDeclarationOnly",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"prepublishOnly": "bun run build"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"opencode",
|
|
29
|
+
"opencode-plugin",
|
|
30
|
+
"usage",
|
|
31
|
+
"cost",
|
|
32
|
+
"tracking",
|
|
33
|
+
"api-proxy",
|
|
34
|
+
"billing"
|
|
35
|
+
],
|
|
36
|
+
"author": "jinzhao-li",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/jinzhao-li/opencode-usage"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@opencode-ai/plugin": ">=1.0.0",
|
|
45
|
+
"@opentui/core": ">=0.3.0",
|
|
46
|
+
"@opentui/solid": ">=0.3.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@opentui/core": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@opentui/solid": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@opencode-ai/plugin": "^1.17.9",
|
|
58
|
+
"@opentui/core": "^0.3.4",
|
|
59
|
+
"@opentui/solid": "^0.3.4",
|
|
60
|
+
"@types/node": "^22.0.0",
|
|
61
|
+
"bun-types": "^1.3.0",
|
|
62
|
+
"typescript": "^5.9.3"
|
|
63
|
+
}
|
|
64
|
+
}
|