@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 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
@@ -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
+ };
@@ -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
+ }