@toninho09/opencode-usage 1.0.0 → 1.1.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 +62 -25
- package/index.ts +114 -3
- package/lib/providers/claude/formatter.ts +8 -10
- package/lib/providers/claude/index.ts +1 -1
- package/lib/providers/copilot/formatter.ts +6 -8
- package/lib/providers/copilot/index.ts +1 -1
- package/lib/providers/zai/formatter.ts +7 -9
- package/lib/providers/zai/index.ts +1 -1
- package/lib/shared/formatting.ts +3 -2
- package/lib/shared/notification.ts +0 -11
- package/lib/shared/utils.ts +23 -0
- package/lib/tracking/tracker.ts +89 -0
- package/lib/tracking/types.ts +30 -0
- package/lib/usage-handler.ts +172 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenCode Usage Plugin
|
|
2
2
|
|
|
3
|
-
Track your AI coding assistant usage in one place. This plugin shows quota information and usage statistics for GitHub Copilot, Claude Code, and Z.ai
|
|
3
|
+
Track your AI coding assistant usage in one place. This plugin shows both provider quota information and real-time token usage statistics for GitHub Copilot, Claude Code, and Z.ai.
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
@@ -69,7 +69,7 @@ npm install
|
|
|
69
69
|
#### Option B: Install via npm (coming soon)
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
npm install -g opencode-usage
|
|
72
|
+
npm install -g @toninho09/opencode-usage@latest
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
Then add to your `opencode.json` config file:
|
|
@@ -99,31 +99,68 @@ Now check your usage:
|
|
|
99
99
|
## What You'll See
|
|
100
100
|
|
|
101
101
|
```
|
|
102
|
-
|
|
103
|
-
║
|
|
104
|
-
|
|
105
|
-
Plan: individual
|
|
106
|
-
Premium: [## ] 11% (33/300)
|
|
107
|
-
Chat: Unlimited
|
|
108
|
-
Completions: Unlimited
|
|
109
|
-
Quota Resets:
|
|
110
|
-
|
|
111
|
-
║
|
|
112
|
-
|
|
113
|
-
5 Hour: [
|
|
114
|
-
7 Day: [
|
|
115
|
-
5h Resets:
|
|
116
|
-
7d Resets:
|
|
117
|
-
Extra Usage: Disabled
|
|
118
|
-
|
|
119
|
-
║
|
|
120
|
-
|
|
121
|
-
Account: xxxxxxxx............ (Z.AI Coding Plan)
|
|
122
|
-
Tokens: [
|
|
123
|
-
5h Resets:
|
|
124
|
-
MCP Searches: [ ] 0% (0/100)
|
|
102
|
+
╔════════════════════════════════════════════════════════════════════════════════╗
|
|
103
|
+
║ GITHUB COPILOT ║
|
|
104
|
+
╚════════════════════════════════════════════════════════════════════════════════╝
|
|
105
|
+
Plan: individual
|
|
106
|
+
Premium: [## ] 11% (33/300)
|
|
107
|
+
Chat: Unlimited
|
|
108
|
+
Completions: Unlimited
|
|
109
|
+
Quota Resets: 18d 0h (2026-02-28 21:00 UTC-03:00)
|
|
110
|
+
╔════════════════════════════════════════════════════════════════════════════════╗
|
|
111
|
+
║ CLAUDE CODE ║
|
|
112
|
+
╚════════════════════════════════════════════════════════════════════════════════╝
|
|
113
|
+
5 Hour: [### ] 15%
|
|
114
|
+
7 Day: [## ] 11%
|
|
115
|
+
5h Resets: 4h (2026-02-11 00:59 UTC-03:00)
|
|
116
|
+
7d Resets: 6d 1h (2026-02-16 21:59 UTC-03:00)
|
|
117
|
+
Extra Usage: Disabled
|
|
118
|
+
╔════════════════════════════════════════════════════════════════════════════════╗
|
|
119
|
+
║ Z.AI CODING PLAN ║
|
|
120
|
+
╚════════════════════════════════════════════════════════════════════════════════╝
|
|
121
|
+
Account: xxxxxxxx............ (Z.AI Coding Plan)
|
|
122
|
+
Tokens: [ ] 1%
|
|
123
|
+
5h Resets: 4h (2026-02-11 00:45 UTC-03:00)
|
|
124
|
+
MCP Searches: [ ] 0% (0/100)
|
|
125
|
+
╔════════════════════════════════════════════════════════════════════════════════╗
|
|
126
|
+
║ Current Session ║
|
|
127
|
+
╚════════════════════════════════════════════════════════════════════════════════╝
|
|
128
|
+
Total Tokens: 14,650
|
|
129
|
+
Messages: 2
|
|
130
|
+
Models:
|
|
131
|
+
────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
github-copilot/gpt-5-mini
|
|
133
|
+
In: 14,190 Out: 460 Rea: 0 Cache-Rd: 0 Cache-Wr: 0 Messages: 2
|
|
134
|
+
╔════════════════════════════════════════════════════════════════════════════════╗
|
|
135
|
+
║ All Sessions (2 sessions) ║
|
|
136
|
+
╚════════════════════════════════════════════════════════════════════════════════╝
|
|
137
|
+
Total Tokens: 30,332
|
|
138
|
+
Messages: 4
|
|
139
|
+
Models:
|
|
140
|
+
────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
zai-coding-plan/glm-4.7-flash
|
|
142
|
+
In: 15,102 Out: 492 Rea: 0 Cache-Rd: 88 Cache-Wr: 0 Messages: 2
|
|
143
|
+
github-copilot/gpt-5-mini
|
|
144
|
+
In: 14,190 Out: 460 Rea: 0 Cache-Rd: 0 Cache-Wr: 0 Messages: 2
|
|
125
145
|
```
|
|
126
146
|
|
|
147
|
+
**Token Tracking Key:**
|
|
148
|
+
- `In` = Input tokens
|
|
149
|
+
- `Out` = Output tokens
|
|
150
|
+
- `Rea` = Reasoning tokens
|
|
151
|
+
- `Cache-Rd` = Cache read tokens
|
|
152
|
+
- `Cache-Wr` = Cache write tokens
|
|
153
|
+
- `Messages` = Message count
|
|
154
|
+
|
|
155
|
+
## Features
|
|
156
|
+
|
|
157
|
+
- ✅ **Provider quota monitoring**: Shows usage limits and remaining quota for Copilot, Claude, and Z.ai
|
|
158
|
+
- ✅ **Real-time token tracking**: Automatically monitors token usage from all AI messages
|
|
159
|
+
- ✅ **Provider/Model breakdown**: See usage grouped by AI provider and model
|
|
160
|
+
- ✅ **Token type tracking**: Input, output, reasoning, cache read, and cache write tokens
|
|
161
|
+
- ✅ **Multi-session support**: View current session and all sessions history
|
|
162
|
+
- ✅ **No external dependencies**: Uses only in-memory tracking (reset when app closes)
|
|
163
|
+
|
|
127
164
|
## License
|
|
128
165
|
|
|
129
166
|
MIT
|
package/index.ts
CHANGED
|
@@ -1,30 +1,141 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { handleUsageCommand } from "./lib/usage-handler"
|
|
3
|
+
import { state } from "./lib/tracking/types"
|
|
4
|
+
import { addTokens } from "./lib/tracking/tracker"
|
|
3
5
|
|
|
4
6
|
export const TestPlugin: Plugin = async ({ client }) => {
|
|
5
7
|
return {
|
|
8
|
+
event: async ({ event }) => {
|
|
9
|
+
// Send notification on session completion
|
|
10
|
+
if (event.type === "message.updated") {
|
|
11
|
+
try {
|
|
12
|
+
const sessionID = event.properties?.info.sessionID;
|
|
13
|
+
const info = event.properties?.info;
|
|
14
|
+
const app = client.app
|
|
15
|
+
await app.log({
|
|
16
|
+
body: {
|
|
17
|
+
service: "usage-plugin",
|
|
18
|
+
level: "debug",
|
|
19
|
+
message: "message.updated event received",
|
|
20
|
+
extra: { sessionID, role: info?.role },
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
if (sessionID && info?.role === "assistant" && info?.tokens) {
|
|
25
|
+
const { input, output, reasoning, cache } = info.tokens;
|
|
26
|
+
const app = client.app
|
|
27
|
+
|
|
28
|
+
await app.log({
|
|
29
|
+
body: {
|
|
30
|
+
service: "usage-plugin",
|
|
31
|
+
level: "debug",
|
|
32
|
+
message: "Tokens detected in event",
|
|
33
|
+
extra: { input, output, reasoning, cacheRead: cache?.read, cacheWrite: cache?.write },
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
if ((input ?? 0) > 0 || (output ?? 0) > 0) {
|
|
38
|
+
const provider = info.providerID || "unknown";
|
|
39
|
+
const model = info.modelID || "unknown";
|
|
40
|
+
const app = client.app
|
|
41
|
+
|
|
42
|
+
await app.log({
|
|
43
|
+
body: {
|
|
44
|
+
service: "usage-plugin",
|
|
45
|
+
level: "debug",
|
|
46
|
+
message: "Adding tokens to session",
|
|
47
|
+
extra: { provider, model },
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
state.currentSessionID = sessionID;
|
|
51
|
+
|
|
52
|
+
addTokens(
|
|
53
|
+
sessionID,
|
|
54
|
+
provider,
|
|
55
|
+
model,
|
|
56
|
+
{
|
|
57
|
+
input: input ?? 0,
|
|
58
|
+
output: output ?? 0,
|
|
59
|
+
reasoning: reasoning ?? 0,
|
|
60
|
+
cacheRead: cache?.read ?? 0,
|
|
61
|
+
cacheWrite: cache?.write ?? 0,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
} else {
|
|
65
|
+
const app = client.app
|
|
66
|
+
|
|
67
|
+
await app.log({
|
|
68
|
+
body: {
|
|
69
|
+
service: "usage-plugin",
|
|
70
|
+
level: "warn",
|
|
71
|
+
message: "Zero or negative tokens - Ignoring",
|
|
72
|
+
extra: { input, output, reasoning },
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
const app = client.app
|
|
78
|
+
|
|
79
|
+
await app.log({
|
|
80
|
+
body: {
|
|
81
|
+
service: "usage-plugin",
|
|
82
|
+
level: "debug",
|
|
83
|
+
message: "Event does not contain token data",
|
|
84
|
+
extra: { sessionID, role: info?.role },
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const app = client.app
|
|
90
|
+
|
|
91
|
+
await app.log({
|
|
92
|
+
body: {
|
|
93
|
+
service: "usage-plugin",
|
|
94
|
+
level: "error",
|
|
95
|
+
message: "Error in message.updated",
|
|
96
|
+
extra: { error: error instanceof Error ? error.message : String(error) },
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
6
102
|
config: async (opencodeConfig) => {
|
|
7
103
|
opencodeConfig.command ??= {}
|
|
8
104
|
opencodeConfig.command["usage"] = {
|
|
9
105
|
template: "",
|
|
10
|
-
description: "Shows usage for
|
|
106
|
+
description: "Shows token usage for all sessions by provider and model",
|
|
11
107
|
}
|
|
12
108
|
},
|
|
13
109
|
"command.execute.before": async (input, output) => {
|
|
14
110
|
if (input.command === "usage") {
|
|
15
111
|
try {
|
|
112
|
+
const app = client.app
|
|
113
|
+
await app.log({
|
|
114
|
+
body: {
|
|
115
|
+
service: "usage-plugin",
|
|
116
|
+
level: "info",
|
|
117
|
+
message: "/usage command executed",
|
|
118
|
+
},
|
|
119
|
+
})
|
|
16
120
|
await handleUsageCommand({
|
|
17
121
|
client,
|
|
18
122
|
sessionID: input.sessionID,
|
|
19
123
|
params: output,
|
|
20
124
|
})
|
|
21
125
|
} catch (err) {
|
|
22
|
-
|
|
126
|
+
const app = client.app
|
|
127
|
+
await app.log({
|
|
128
|
+
body: {
|
|
129
|
+
service: "usage-plugin",
|
|
130
|
+
level: "error",
|
|
131
|
+
message: err instanceof Error ? err.message : String(err),
|
|
132
|
+
},
|
|
133
|
+
})
|
|
23
134
|
}
|
|
24
135
|
|
|
25
136
|
throw new Error("__USAGE_COMMAND_HANDLED__")
|
|
26
137
|
}
|
|
27
|
-
}
|
|
138
|
+
}
|
|
28
139
|
}
|
|
29
140
|
}
|
|
30
141
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatResetLine } from "../../shared/formatting";
|
|
2
|
-
import { createUsedProgressBar } from "../../shared/utils";
|
|
2
|
+
import { createUsedProgressBar, boxHeader } from "../../shared/utils";
|
|
3
3
|
import type { ClaudeUsageResponse, QuotaPeriod } from "./types";
|
|
4
4
|
|
|
5
5
|
export class ClaudeFormatter {
|
|
@@ -8,13 +8,13 @@ export class ClaudeFormatter {
|
|
|
8
8
|
*/
|
|
9
9
|
private formatQuotaLine(name: string, quota: QuotaPeriod | null): string {
|
|
10
10
|
if (!quota) {
|
|
11
|
-
return
|
|
11
|
+
return ` ${name.padEnd(16)} N/A`;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const percentUsed = Math.round(quota.utilization);
|
|
15
15
|
const progressBar = createUsedProgressBar(percentUsed, 20);
|
|
16
16
|
|
|
17
|
-
return
|
|
17
|
+
return ` ${name.padEnd(16)} ${progressBar} ${percentUsed}%`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -23,19 +23,17 @@ export class ClaudeFormatter {
|
|
|
23
23
|
format(data: ClaudeUsageResponse): string {
|
|
24
24
|
const lines: string[] = [];
|
|
25
25
|
|
|
26
|
-
lines.push("
|
|
27
|
-
lines.push("║ CLAUDE CODE ║");
|
|
28
|
-
lines.push("╚════════════════════════════════════════╝");
|
|
26
|
+
lines.push(boxHeader("CLAUDE CODE", 80));
|
|
29
27
|
|
|
30
28
|
lines.push(this.formatQuotaLine("5 Hour:", data.five_hour));
|
|
31
29
|
lines.push(this.formatQuotaLine("7 Day:", data.seven_day));
|
|
32
30
|
|
|
33
31
|
if (data.five_hour) {
|
|
34
|
-
lines.push(formatResetLine("5h Resets:", data.five_hour.resets_at,
|
|
32
|
+
lines.push(" " + formatResetLine("5h Resets:", data.five_hour.resets_at, 15));
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
if (data.seven_day) {
|
|
38
|
-
lines.push(formatResetLine("7d Resets:", data.seven_day.resets_at,
|
|
36
|
+
lines.push(" " + formatResetLine("7d Resets:", data.seven_day.resets_at, 15));
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
const extraUsage = data.extra_usage;
|
|
@@ -46,9 +44,9 @@ export class ClaudeFormatter {
|
|
|
46
44
|
extraUsage.used_credits !== null && extraUsage.monthly_limit !== null
|
|
47
45
|
? `${extraUsage.used_credits}/${extraUsage.monthly_limit}`
|
|
48
46
|
: "N/A";
|
|
49
|
-
lines.push(`Extra Usage: Enabled - ${utilization} (${credits})`);
|
|
47
|
+
lines.push(` Extra Usage: Enabled - ${utilization} (${credits})`);
|
|
50
48
|
} else {
|
|
51
|
-
lines.push("Extra Usage: Disabled");
|
|
49
|
+
lines.push(" Extra Usage: Disabled");
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
return lines.join("\n");
|
|
@@ -8,7 +8,7 @@ const formatter = new ClaudeFormatter();
|
|
|
8
8
|
export const claudeProvider: UsageProvider = {
|
|
9
9
|
name: "Claude Code",
|
|
10
10
|
id: "claude",
|
|
11
|
-
description: "
|
|
11
|
+
description: "Claude Code usage monitoring",
|
|
12
12
|
|
|
13
13
|
async getUsageData(): Promise<ProviderMessage | null> {
|
|
14
14
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatResetLine } from "../../shared/formatting";
|
|
2
|
-
import { createUsedProgressBar } from "../../shared/utils";
|
|
2
|
+
import { createUsedProgressBar, boxHeader } from "../../shared/utils";
|
|
3
3
|
import type { CopilotUsageResponse, QuotaDetail } from "./types";
|
|
4
4
|
|
|
5
5
|
export class CopilotFormatter {
|
|
@@ -12,7 +12,7 @@ export class CopilotFormatter {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
if (quota.unlimited) {
|
|
15
|
-
return
|
|
15
|
+
return ` ${name.padEnd(16)} Unlimited`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const total = quota.entitlement;
|
|
@@ -20,7 +20,7 @@ export class CopilotFormatter {
|
|
|
20
20
|
const percentUsed = Math.round((used / total) * 100);
|
|
21
21
|
const progressBar = createUsedProgressBar(percentUsed, 20);
|
|
22
22
|
|
|
23
|
-
return
|
|
23
|
+
return ` ${name.padEnd(16)} ${progressBar} ${percentUsed}% (${used}/${total})`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -29,10 +29,8 @@ export class CopilotFormatter {
|
|
|
29
29
|
format(data: CopilotUsageResponse): string {
|
|
30
30
|
const lines: string[] = [];
|
|
31
31
|
|
|
32
|
-
lines.push("
|
|
33
|
-
lines.push(
|
|
34
|
-
lines.push("╚════════════════════════════════════════╝");
|
|
35
|
-
lines.push(`Plan: ${data.copilot_plan}`);
|
|
32
|
+
lines.push(boxHeader("GITHUB COPILOT", 80));
|
|
33
|
+
lines.push(` Plan: ${data.copilot_plan}`);
|
|
36
34
|
|
|
37
35
|
const premium = data.quota_snapshots.premium_interactions;
|
|
38
36
|
if (premium) {
|
|
@@ -58,7 +56,7 @@ export class CopilotFormatter {
|
|
|
58
56
|
}
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
lines.push(formatResetLine("Quota Resets:", data.quota_reset_date,
|
|
59
|
+
lines.push(formatResetLine("Quota Resets:", data.quota_reset_date, 18));
|
|
62
60
|
|
|
63
61
|
return lines.join("\n");
|
|
64
62
|
}
|
|
@@ -8,7 +8,7 @@ const formatter = new CopilotFormatter();
|
|
|
8
8
|
export const copilotProvider: UsageProvider = {
|
|
9
9
|
name: "GitHub Copilot",
|
|
10
10
|
id: "copilot",
|
|
11
|
-
description: "
|
|
11
|
+
description: "GitHub Copilot usage monitoring",
|
|
12
12
|
|
|
13
13
|
async getUsageData(): Promise<ProviderMessage | null> {
|
|
14
14
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatResetLine, formatTokens, maskApiKey } from "../../shared/formatting";
|
|
2
|
-
import { createUsedProgressBar } from "../../shared/utils";
|
|
2
|
+
import { createUsedProgressBar, boxHeader } from "../../shared/utils";
|
|
3
3
|
import type { ZaiUsageResponse, UsageLimitItem } from "./types";
|
|
4
4
|
|
|
5
5
|
export class ZaiFormatter {
|
|
@@ -36,13 +36,11 @@ export class ZaiFormatter {
|
|
|
36
36
|
const limits = data.data.limits;
|
|
37
37
|
|
|
38
38
|
const maskedKey = maskApiKey(apiKey);
|
|
39
|
-
lines.push("
|
|
40
|
-
lines.push(
|
|
41
|
-
lines.push("╚════════════════════════════════════════╝");
|
|
42
|
-
lines.push(`Account: ${maskedKey} (Z.AI Coding Plan)`);
|
|
39
|
+
lines.push(boxHeader("Z.AI CODING PLAN", 80));
|
|
40
|
+
lines.push(` Account: ${maskedKey} (Z.AI Coding Plan)`);
|
|
43
41
|
|
|
44
42
|
if (!limits || limits.length === 0) {
|
|
45
|
-
lines.push("No quota data available");
|
|
43
|
+
lines.push(" No quota data available");
|
|
46
44
|
return lines.join("\n");
|
|
47
45
|
}
|
|
48
46
|
|
|
@@ -52,10 +50,10 @@ export class ZaiFormatter {
|
|
|
52
50
|
const progressBar = createUsedProgressBar(percentUsed, 20);
|
|
53
51
|
const usageDisplay = this.calculateUsageDisplay(tokensLimit);
|
|
54
52
|
|
|
55
|
-
lines.push(`Tokens: ${progressBar} ${percentUsed}%${usageDisplay}`);
|
|
53
|
+
lines.push(` Tokens: ${progressBar} ${percentUsed}%${usageDisplay}`);
|
|
56
54
|
|
|
57
55
|
if (tokensLimit.nextResetTime) {
|
|
58
|
-
lines.push(formatResetLine("5h Resets:", tokensLimit.nextResetTime,
|
|
56
|
+
lines.push(" " + formatResetLine("5h Resets:", tokensLimit.nextResetTime, 15));
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
59
|
|
|
@@ -68,7 +66,7 @@ export class ZaiFormatter {
|
|
|
68
66
|
const total = timeLimit.usage ?? (timeLimit.remaining ?? 0);
|
|
69
67
|
|
|
70
68
|
lines.push(
|
|
71
|
-
`MCP Searches: ${progressBar} ${percentUsed}% (${currentValue}/${total})`,
|
|
69
|
+
` MCP Searches: ${progressBar} ${percentUsed}% (${currentValue}/${total})`,
|
|
72
70
|
);
|
|
73
71
|
}
|
|
74
72
|
|
|
@@ -8,7 +8,7 @@ const formatter = new ZaiFormatter();
|
|
|
8
8
|
export const zaiProvider: UsageProvider = {
|
|
9
9
|
name: "Z.ai Coding Plan",
|
|
10
10
|
id: "zai",
|
|
11
|
-
description: "
|
|
11
|
+
description: "Z.ai Coding Plan usage monitoring",
|
|
12
12
|
|
|
13
13
|
async getUsageData(): Promise<ProviderMessage | null> {
|
|
14
14
|
try {
|
package/lib/shared/formatting.ts
CHANGED
|
@@ -33,15 +33,16 @@ export function formatResetLine(
|
|
|
33
33
|
label: string,
|
|
34
34
|
resetDate: string | number | undefined,
|
|
35
35
|
labelWidth: number = 15,
|
|
36
|
+
prefix: string = "",
|
|
36
37
|
): string {
|
|
37
38
|
if (!resetDate) {
|
|
38
|
-
return `${label.padEnd(labelWidth)} N/A`;
|
|
39
|
+
return `${prefix}${label.padEnd(labelWidth)} N/A`;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
const countdown = formatResetCountdown(resetDate);
|
|
42
43
|
const friendlyDate = formatFriendlyDate(resetDate);
|
|
43
44
|
|
|
44
|
-
return `${label.padEnd(labelWidth)} ${countdown} (${friendlyDate})`;
|
|
45
|
+
return `${prefix}${label.padEnd(labelWidth)} ${countdown} (${friendlyDate})`;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/**
|
|
@@ -2,23 +2,12 @@ export async function sendIgnoredMessage(
|
|
|
2
2
|
client: any,
|
|
3
3
|
sessionID: string,
|
|
4
4
|
text: string,
|
|
5
|
-
params: any,
|
|
6
5
|
): Promise<void> {
|
|
7
|
-
const agent = params.agent || undefined
|
|
8
|
-
const variant = params.variant || undefined
|
|
9
|
-
const model = params.providerId && params.modelId ? {
|
|
10
|
-
providerID: params.providerId,
|
|
11
|
-
modelID: params.modelId,
|
|
12
|
-
} : undefined
|
|
13
|
-
|
|
14
6
|
try {
|
|
15
7
|
await client.session.prompt({
|
|
16
8
|
path: { id: sessionID },
|
|
17
9
|
body: {
|
|
18
10
|
noReply: true,
|
|
19
|
-
agent,
|
|
20
|
-
model,
|
|
21
|
-
variant,
|
|
22
11
|
parts: [{ type: "text", text, ignored: true }],
|
|
23
12
|
},
|
|
24
13
|
})
|
package/lib/shared/utils.ts
CHANGED
|
@@ -41,3 +41,26 @@ export function formatFriendlyDate(dateInput: string | number | Date): string {
|
|
|
41
41
|
|
|
42
42
|
return `${year}-${month}-${day} ${hours}:${minutes} ${timezone}`;
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a centered box header with title
|
|
47
|
+
* Width is the inner content width (excludes the border characters)
|
|
48
|
+
* Example with width=80:
|
|
49
|
+
* ╔══════════════════════════════════════════════════════════════════════════════════╗
|
|
50
|
+
* ║ MY TITLE ║
|
|
51
|
+
* ╚══════════════════════════════════════════════════════════════════════════════════╝
|
|
52
|
+
*/
|
|
53
|
+
export function boxHeader(title: string, width: number = 80): string {
|
|
54
|
+
const topLine = "╔" + "═".repeat(width) + "╗";
|
|
55
|
+
const bottomLine = "╚" + "═".repeat(width) + "╝";
|
|
56
|
+
|
|
57
|
+
// Center the title within the width
|
|
58
|
+
const padding = Math.max(0, width - title.length);
|
|
59
|
+
const leftPad = Math.floor(padding / 2);
|
|
60
|
+
const rightPad = padding - leftPad;
|
|
61
|
+
const centerTitle = " ".repeat(leftPad) + title + " ".repeat(rightPad);
|
|
62
|
+
|
|
63
|
+
const middleLine = "║" + centerTitle + "║";
|
|
64
|
+
|
|
65
|
+
return topLine + "\n" + middleLine + "\n" + bottomLine;
|
|
66
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { state, SessionUsage, TokenMetrics } from "./types";
|
|
2
|
+
|
|
3
|
+
const logToApp = async (level: "debug" | "info" | "warn", message: string, extra?: Record<string, any>) => {
|
|
4
|
+
try {
|
|
5
|
+
const client = await (window as any).__opencodeClient
|
|
6
|
+
if (client?.app?.log) {
|
|
7
|
+
await client.app.log({
|
|
8
|
+
body: {
|
|
9
|
+
service: "tracking-plugin",
|
|
10
|
+
level,
|
|
11
|
+
message,
|
|
12
|
+
extra,
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getCurrentSession(): SessionUsage | null {
|
|
21
|
+
if (!state.currentSessionID) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return getSession(state.currentSessionID);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getSession(sessionID: string): SessionUsage {
|
|
28
|
+
if (!state.sessions.has(sessionID)) {
|
|
29
|
+
logToApp("info", "Creating new session", { sessionID })
|
|
30
|
+
state.sessions.set(sessionID, {
|
|
31
|
+
sessionID,
|
|
32
|
+
total: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
|
|
33
|
+
byModel: new Map(),
|
|
34
|
+
});
|
|
35
|
+
} else {
|
|
36
|
+
logToApp("debug", "Session found", { sessionID })
|
|
37
|
+
}
|
|
38
|
+
return state.sessions.get(sessionID)!;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function updateSession(sessionID: string, sessionData: Partial<SessionUsage>): void {
|
|
42
|
+
const session = getSession(sessionID);
|
|
43
|
+
logToApp("debug", "Updating session", { sessionID, sessionData })
|
|
44
|
+
Object.assign(session, sessionData);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function addTokens(
|
|
48
|
+
sessionID: string,
|
|
49
|
+
provider: string,
|
|
50
|
+
model: string,
|
|
51
|
+
tokens: TokenMetrics,
|
|
52
|
+
): void {
|
|
53
|
+
logToApp("debug", "Adding tokens", { sessionID, provider, model, tokens })
|
|
54
|
+
|
|
55
|
+
const session = getSession(sessionID);
|
|
56
|
+
|
|
57
|
+
session.total.input += tokens.input;
|
|
58
|
+
session.total.output += tokens.output;
|
|
59
|
+
session.total.reasoning += tokens.reasoning;
|
|
60
|
+
session.total.cacheRead += tokens.cacheRead;
|
|
61
|
+
session.total.cacheWrite += tokens.cacheWrite;
|
|
62
|
+
|
|
63
|
+
const key = `${provider}/${model}`;
|
|
64
|
+
let modelStats = session.byModel.get(key);
|
|
65
|
+
if (!modelStats) {
|
|
66
|
+
logToApp("info", "Creating new model stats", { key })
|
|
67
|
+
modelStats = {
|
|
68
|
+
provider,
|
|
69
|
+
model,
|
|
70
|
+
metrics: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
|
|
71
|
+
messageCount: 0,
|
|
72
|
+
};
|
|
73
|
+
session.byModel.set(key, modelStats);
|
|
74
|
+
}
|
|
75
|
+
modelStats.metrics.input += tokens.input;
|
|
76
|
+
modelStats.metrics.output += tokens.output;
|
|
77
|
+
modelStats.metrics.reasoning += tokens.reasoning;
|
|
78
|
+
modelStats.metrics.cacheRead += tokens.cacheRead;
|
|
79
|
+
modelStats.metrics.cacheWrite += tokens.cacheWrite;
|
|
80
|
+
modelStats.messageCount++;
|
|
81
|
+
|
|
82
|
+
logToApp("debug", "Tokens updated", { totalInput: session.total.input, totalOutput: session.total.output })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getAllSessions(): SessionUsage[] {
|
|
86
|
+
const sessions = Array.from(state.sessions.values());
|
|
87
|
+
logToApp("debug", "Returning total sessions", { count: sessions.length })
|
|
88
|
+
return sessions;
|
|
89
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface TokenMetrics {
|
|
2
|
+
input: number;
|
|
3
|
+
output: number;
|
|
4
|
+
reasoning: number;
|
|
5
|
+
cacheRead: number;
|
|
6
|
+
cacheWrite: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ModelStats {
|
|
10
|
+
provider: string;
|
|
11
|
+
model: string;
|
|
12
|
+
metrics: TokenMetrics;
|
|
13
|
+
messageCount: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SessionUsage {
|
|
17
|
+
sessionID: string;
|
|
18
|
+
total: TokenMetrics;
|
|
19
|
+
byModel: Map<string, ModelStats>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UsageState {
|
|
23
|
+
sessions: Map<string, SessionUsage>;
|
|
24
|
+
currentSessionID: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const state: UsageState = {
|
|
28
|
+
sessions: new Map(),
|
|
29
|
+
currentSessionID: null,
|
|
30
|
+
};
|
package/lib/usage-handler.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { sendIgnoredMessage } from "./shared/notification";
|
|
2
|
+
import { getCurrentSession, getAllSessions } from "./tracking/tracker";
|
|
2
3
|
import { registry } from "./providers/registry";
|
|
4
|
+
import { boxHeader } from "./shared/utils";
|
|
3
5
|
|
|
4
6
|
interface UsageContext {
|
|
5
7
|
client: any;
|
|
@@ -9,47 +11,183 @@ interface UsageContext {
|
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Main handler for the /usage command
|
|
12
|
-
*
|
|
14
|
+
* Shows provider usage data and token usage tracked from message events
|
|
13
15
|
*/
|
|
14
16
|
export async function handleUsageCommand(ctx: UsageContext): Promise<void> {
|
|
15
17
|
const { client, sessionID, params } = ctx;
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
try {
|
|
20
|
+
let output = "";
|
|
21
|
+
|
|
22
|
+
// Fetch provider data (Copilot, Claude, Z.ai)
|
|
23
|
+
const providers = registry.getAll();
|
|
24
|
+
const providerResults = await Promise.all(
|
|
25
|
+
providers.map(async (provider) => {
|
|
26
|
+
try {
|
|
27
|
+
const result = await provider.getUsageData();
|
|
28
|
+
if (result && result.content) {
|
|
29
|
+
return {
|
|
30
|
+
provider: provider.name,
|
|
31
|
+
content: result.content,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error(`[${provider.name} Error]`, error);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const validProviderResults = providerResults.filter(
|
|
43
|
+
(r): r is { provider: string; content: string } => r !== null,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Add provider data if available
|
|
47
|
+
if (validProviderResults.length > 0) {
|
|
48
|
+
output += validProviderResults.map((r) => r.content).join("\n\n");
|
|
49
|
+
output += "\n";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fetch and add token tracking data
|
|
53
|
+
const current = getCurrentSession();
|
|
54
|
+
const allSessions = getAllSessions();
|
|
55
|
+
|
|
56
|
+
if (allSessions.length === 0) {
|
|
57
|
+
if (validProviderResults.length === 0) {
|
|
58
|
+
output = "No usage data available yet.\nConfigure providers or start a conversation to track token usage.";
|
|
59
|
+
} else {
|
|
60
|
+
output += "\nNo token usage data available yet.\nStart a conversation to track token usage.";
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
if (current) {
|
|
64
|
+
output += formatSession(current, "Current Session");
|
|
65
|
+
} else {
|
|
66
|
+
output += "No active session.\n";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (allSessions.length > 1) {
|
|
70
|
+
output += formatAllSessions(allSessions, "All Sessions");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await sendIgnoredMessage(client, sessionID, output);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error("Error in handleUsageCommand:", error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatSession(session: any, title: string): string {
|
|
81
|
+
const { total, byModel } = session;
|
|
82
|
+
|
|
83
|
+
let output = `\n${boxHeader(title, 80)}\n`;
|
|
84
|
+
|
|
85
|
+
const totalTokens =
|
|
86
|
+
total.input + total.output + total.reasoning + total.cacheRead + total.cacheWrite;
|
|
87
|
+
output += ` Total Tokens: ${formatNumber(totalTokens)}\n`;
|
|
88
|
+
|
|
89
|
+
const messageCount =
|
|
90
|
+
byModel.size > 0
|
|
91
|
+
? Array.from(byModel.values()).reduce((a: number, b: any) => a + b.messageCount, 0)
|
|
92
|
+
: 0;
|
|
93
|
+
output += ` Messages: ${formatNumber(messageCount)}\n`;
|
|
94
|
+
|
|
95
|
+
if (byModel.size > 0) {
|
|
96
|
+
output += `\n Models:\n`;
|
|
97
|
+
output += ` ${"─".repeat(76)}\n`;
|
|
98
|
+
|
|
99
|
+
const models = Array.from(byModel.values())
|
|
100
|
+
.sort((a: any, b: any) => {
|
|
101
|
+
const totalA = sumMetrics(a.metrics);
|
|
102
|
+
const totalB = sumMetrics(b.metrics);
|
|
103
|
+
return totalB - totalA;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
models.forEach((modelStats: any) => {
|
|
107
|
+
const key = `${modelStats.provider}/${modelStats.model}`;
|
|
108
|
+
output += ` ${key}\n`;
|
|
109
|
+
output += ` ${formatMetricsLine(modelStats.metrics, modelStats.messageCount)}\n`;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return output;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatAllSessions(sessions: any[], title: string): string {
|
|
117
|
+
if (sessions.length <= 1) return "";
|
|
118
|
+
|
|
119
|
+
// Aggregate all sessions into a single summary
|
|
120
|
+
const aggregated = {
|
|
121
|
+
total: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
|
|
122
|
+
byModel: new Map<string, any>(),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
sessions.forEach((session: any) => {
|
|
126
|
+
aggregated.total.input += session.total.input;
|
|
127
|
+
aggregated.total.output += session.total.output;
|
|
128
|
+
aggregated.total.reasoning += session.total.reasoning;
|
|
129
|
+
aggregated.total.cacheRead += session.total.cacheRead;
|
|
130
|
+
aggregated.total.cacheWrite += session.total.cacheWrite;
|
|
131
|
+
|
|
132
|
+
if (session.byModel && session.byModel.size > 0) {
|
|
133
|
+
for (const [key, modelStats] of session.byModel.entries()) {
|
|
134
|
+
const existing = aggregated.byModel.get(key);
|
|
135
|
+
if (existing) {
|
|
136
|
+
existing.metrics.input += modelStats.metrics.input;
|
|
137
|
+
existing.metrics.output += modelStats.metrics.output;
|
|
138
|
+
existing.metrics.reasoning += modelStats.metrics.reasoning;
|
|
139
|
+
existing.metrics.cacheRead += modelStats.metrics.cacheRead;
|
|
140
|
+
existing.metrics.cacheWrite += modelStats.metrics.cacheWrite;
|
|
141
|
+
existing.messageCount += modelStats.messageCount;
|
|
142
|
+
} else {
|
|
143
|
+
aggregated.byModel.set(key, {
|
|
144
|
+
provider: modelStats.provider,
|
|
145
|
+
model: modelStats.model,
|
|
146
|
+
metrics: { ...modelStats.metrics },
|
|
147
|
+
messageCount: modelStats.messageCount,
|
|
148
|
+
});
|
|
30
149
|
}
|
|
31
|
-
return null;
|
|
32
|
-
} catch (error) {
|
|
33
|
-
console.error(`[${provider.name} Error]`, error);
|
|
34
|
-
return null;
|
|
35
150
|
}
|
|
36
|
-
}
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
let output = `\n${boxHeader(`${title} (${sessions.length} sessions)`, 80)}\n`;
|
|
155
|
+
|
|
156
|
+
const totalTokens =
|
|
157
|
+
aggregated.total.input + aggregated.total.output + aggregated.total.reasoning +
|
|
158
|
+
aggregated.total.cacheRead + aggregated.total.cacheWrite;
|
|
159
|
+
output += ` Total Tokens: ${formatNumber(totalTokens)}\n`;
|
|
160
|
+
|
|
161
|
+
const messageCount = aggregated.byModel.size > 0
|
|
162
|
+
? Array.from(aggregated.byModel.values()).reduce((a: number, b: any) => a + b.messageCount, 0)
|
|
163
|
+
: 0;
|
|
164
|
+
output += ` Messages: ${formatNumber(messageCount)}\n`;
|
|
165
|
+
|
|
166
|
+
if (aggregated.byModel.size > 0) {
|
|
167
|
+
output += `\n Models:\n`;
|
|
168
|
+
output += ` ${"─".repeat(76)}\n`;
|
|
169
|
+
|
|
170
|
+
const models = Array.from(aggregated.byModel.values())
|
|
171
|
+
.sort((a: any, b: any) => sumMetrics(b.metrics) - sumMetrics(a.metrics));
|
|
172
|
+
|
|
173
|
+
models.forEach((modelStats: any) => {
|
|
174
|
+
const key = `${modelStats.provider}/${modelStats.model}`;
|
|
175
|
+
output += ` ${key}\n`;
|
|
176
|
+
output += ` ${formatMetricsLine(modelStats.metrics, modelStats.messageCount)}\n`;
|
|
177
|
+
});
|
|
48
178
|
}
|
|
49
179
|
|
|
50
|
-
|
|
51
|
-
|
|
180
|
+
return output;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function formatMetricsLine(metrics: any, messageCount: number): string {
|
|
184
|
+
return `In: ${formatNumber(metrics.input)} Out: ${formatNumber(metrics.output)} Rea: ${formatNumber(metrics.reasoning)} Cache-Rd: ${formatNumber(metrics.cacheRead)} Cache-Wr: ${formatNumber(metrics.cacheWrite)} Messages: ${formatNumber(messageCount)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function formatNumber(num: number): string {
|
|
188
|
+
return num.toLocaleString("en-US");
|
|
189
|
+
}
|
|
52
190
|
|
|
53
|
-
|
|
54
|
-
|
|
191
|
+
function sumMetrics(metrics: any): number {
|
|
192
|
+
return metrics.input + metrics.output + metrics.reasoning + metrics.cacheRead + metrics.cacheWrite;
|
|
55
193
|
}
|