Skip to content

Commit 744fcc9

Browse files
committed
feat: add token usage tracker with status bar display
- Add TokenUsageTracker class for session-scoped token counting - Add status bar item showing real-time token usage - Wire tracker into DeepSeekChatProvider and accumulate usage data - Replace charsPerToken heuristic with improved estimateTokens() using character-class batching for more accurate token estimation - Reset tracker when API key is cleared
1 parent e06c550 commit 744fcc9

3 files changed

Lines changed: 255 additions & 6 deletions

File tree

src/extension.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { WALKTHROUGH_ID, WELCOME_SHOWN_KEY } from './consts';
33
import { t } from './i18n';
44
import { logger } from './logger';
55
import { DeepSeekChatProvider } from './provider';
6+
import { TokenUsageTracker, createStatusBarItem } from './tokenUsage';
67

78
let activeProvider: DeepSeekChatProvider | undefined;
89

@@ -20,7 +21,13 @@ export function activate(context: vscode.ExtensionContext) {
2021
);
2122

2223
try {
23-
const provider = new DeepSeekChatProvider(context);
24+
// Create session-scoped token usage tracker.
25+
const tokenUsageTracker = new TokenUsageTracker();
26+
27+
// Create and register the status bar item (pushed into subscriptions).
28+
createStatusBarItem(context, tokenUsageTracker);
29+
30+
const provider = new DeepSeekChatProvider(context, tokenUsageTracker);
2431
activeProvider = provider;
2532

2633
context.subscriptions.push(

src/provider/index.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getApiModelId, getBaseUrl, getMaxTokens } from '../config';
55
import { MODELS } from '../consts';
66
import { t } from '../i18n';
77
import { logger } from '../logger';
8+
import type { TokenUsageTracker } from '../tokenUsage';
89
import type { DeepSeekToolCall, ModelDefinition } from '../types';
910
import { type ReasoningEntry, pruneReasoningCache } from './cache';
1011
import { convertMessages, convertTools, countMessageChars } from './convert';
@@ -75,8 +76,12 @@ export class DeepSeekChatProvider implements vscode.LanguageModelChatProvider {
7576
*/
7677
private charsPerToken = 4.0;
7778

78-
constructor(context: vscode.ExtensionContext) {
79+
/** Session-scoped token usage tracker. */
80+
private readonly tokenUsageTracker: TokenUsageTracker;
81+
82+
constructor(context: vscode.ExtensionContext, tokenUsageTracker: TokenUsageTracker) {
7983
this.authManager = new AuthManager(context);
84+
this.tokenUsageTracker = tokenUsageTracker;
8085

8186
context.subscriptions.push(
8287
this.onDidChangeLanguageModelChatInformationEmitter,
@@ -111,6 +116,7 @@ export class DeepSeekChatProvider implements vscode.LanguageModelChatProvider {
111116
}
112117

113118
async clearApiKey(): Promise<void> {
119+
this.tokenUsageTracker.reset();
114120
await this.authManager.deleteApiKey();
115121
this.onDidChangeLanguageModelChatInformationEmitter.fire();
116122
vscode.window.showInformationMessage(t('auth.removed'));
@@ -279,6 +285,9 @@ export class DeepSeekChatProvider implements vscode.LanguageModelChatProvider {
279285
},
280286

281287
onUsage: (usage) => {
288+
// Accumulate into the session-scoped tracker.
289+
this.tokenUsageTracker.add(usage);
290+
282291
// Calibrate chars-per-token ratio from real API usage data.
283292
if (totalRequestChars > 0 && usage.prompt_tokens > 0) {
284293
const observedRatio = totalRequestChars / usage.prompt_tokens;
@@ -308,20 +317,21 @@ export class DeepSeekChatProvider implements vscode.LanguageModelChatProvider {
308317
_token: vscode.CancellationToken,
309318
): Promise<number> {
310319
if (typeof text === 'string') {
311-
return Math.max(1, Math.ceil(text.length / this.charsPerToken));
320+
return Math.max(1, estimateTokens(text));
312321
}
313322

314323
if (!text?.content || !Array.isArray(text.content)) {
315324
return 1;
316325
}
317326

318-
let total = 0;
327+
// Flatten all text parts into a single string before estimating.
328+
let combined = '';
319329
for (const part of text.content) {
320330
if (part instanceof vscode.LanguageModelTextPart) {
321-
total += part.value.length;
331+
combined += part.value;
322332
}
323333
}
324-
return Math.max(1, Math.ceil(total / this.charsPerToken));
334+
return Math.max(1, estimateTokens(combined));
325335
}
326336
}
327337

@@ -390,6 +400,65 @@ function toChatInfo(m: ModelDefinition, hasApiKey: boolean): ModelPickerChatInfo
390400
};
391401
}
392402

403+
/**
404+
* Estimate tokens for a string using character-class heuristics.
405+
*
406+
* DeepSeek V4 uses a cl100k_base-compatible tokenizer.
407+
* Heuristic breakdown by character class:
408+
* - Alphanumeric/underscore (code identifiers): ~3.5 chars per token
409+
* - Whitespace/newlines: ~0.3 tokens each
410+
* - Punctuation/operators: ~1 token each
411+
* - Non-ASCII (CJK, emoji, etc): ~1.5 chars per token
412+
*/
413+
function estimateTokens(str: string): number {
414+
let tokens = 0;
415+
let i = 0;
416+
while (i < str.length) {
417+
const code = str.charCodeAt(i);
418+
if (
419+
(code >= 65 && code <= 90) || // A-Z
420+
(code >= 97 && code <= 122) || // a-z
421+
(code >= 48 && code <= 57) || // 0-9
422+
code === 95 // _
423+
) {
424+
// Run of identifier chars — batch them
425+
let run = 1;
426+
while (i + run < str.length) {
427+
const c2 = str.charCodeAt(i + run);
428+
if (
429+
(c2 >= 65 && c2 <= 90) ||
430+
(c2 >= 97 && c2 <= 122) ||
431+
(c2 >= 48 && c2 <= 57) ||
432+
c2 === 95
433+
) {
434+
run++;
435+
} else {
436+
break;
437+
}
438+
}
439+
tokens += Math.ceil(run / 3.5);
440+
i += run;
441+
} else if (code === 32 || code === 9) {
442+
// Space or tab
443+
tokens += 0.3;
444+
i++;
445+
} else if (code === 10 || code === 13) {
446+
// Newline or carriage return
447+
tokens += 0.4;
448+
i++;
449+
} else if (code < 128) {
450+
// ASCII punctuation / operators
451+
tokens += 1;
452+
i++;
453+
} else {
454+
// Non-ASCII: consume 1-2 chars, count as ~0.7 tokens
455+
tokens += 0.7;
456+
i++;
457+
}
458+
}
459+
return Math.ceil(tokens);
460+
}
461+
393462
function getConfiguredThinkingEffort(options: ModelConfigurationOptions): ThinkingEffort {
394463
const configuredEffort =
395464
options.modelConfiguration?.reasoningEffort ?? options.configuration?.reasoningEffort;

src/tokenUsage.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import vscode from 'vscode';
2+
import { MODELS } from './consts';
3+
import { logger } from './logger';
4+
5+
/**
6+
* Usage data returned by the DeepSeek API for a single response.
7+
*/
8+
export interface DeepSeekUsage {
9+
readonly prompt_tokens: number;
10+
readonly completion_tokens: number;
11+
readonly total_tokens: number;
12+
readonly prompt_cache_hit_tokens?: number;
13+
readonly prompt_cache_miss_tokens?: number;
14+
}
15+
16+
/**
17+
* Accumulated token summary for the current session.
18+
*/
19+
export interface TokenSummary {
20+
promptTokens: number;
21+
completionTokens: number;
22+
totalTokens: number;
23+
cacheHitTokens: number;
24+
cacheMissTokens: number;
25+
}
26+
27+
/**
28+
* Session-scoped token usage accumulator.
29+
*
30+
* Collects DeepSeek API `usage` data across all requests and provides
31+
* running totals consumed by the status bar display.
32+
*/
33+
export class TokenUsageTracker {
34+
private promptTokens = 0;
35+
private completionTokens = 0;
36+
private cacheHitTokens = 0;
37+
private cacheMissTokens = 0;
38+
39+
/** Called after each `add()` to notify the status bar. */
40+
private onUpdate: (() => void) | undefined;
41+
42+
/**
43+
* Register a callback that fires after every usage update.
44+
* Typically set by `createStatusBarItem` to repaint the bar.
45+
*/
46+
setOnUpdate(callback: () => void): void {
47+
this.onUpdate = callback;
48+
}
49+
50+
/**
51+
* Accumulate a single API response's usage into the session totals.
52+
*/
53+
add(usage: DeepSeekUsage): void {
54+
this.promptTokens += usage.prompt_tokens;
55+
this.completionTokens += usage.completion_tokens;
56+
this.cacheHitTokens += usage.prompt_cache_hit_tokens ?? 0;
57+
this.cacheMissTokens += usage.prompt_cache_miss_tokens ?? 0;
58+
59+
this.onUpdate?.();
60+
}
61+
62+
/**
63+
* Reset all session counters to zero.
64+
*/
65+
reset(): void {
66+
this.promptTokens = 0;
67+
this.completionTokens = 0;
68+
this.cacheHitTokens = 0;
69+
this.cacheMissTokens = 0;
70+
71+
this.onUpdate?.();
72+
}
73+
74+
/**
75+
* Return the current accumulated totals.
76+
*/
77+
getSummary(): TokenSummary {
78+
return {
79+
promptTokens: this.promptTokens,
80+
completionTokens: this.completionTokens,
81+
totalTokens: this.promptTokens + this.completionTokens,
82+
cacheHitTokens: this.cacheHitTokens,
83+
cacheMissTokens: this.cacheMissTokens,
84+
};
85+
}
86+
}
87+
88+
// ---- Locale-aware number formatting ----
89+
90+
const nf = new Intl.NumberFormat();
91+
92+
function fmt(n: number): string {
93+
return nf.format(n);
94+
}
95+
96+
// ---- Status bar item factory ----
97+
98+
/**
99+
* Determine the warning threshold colour for the status bar based on
100+
* total (prompt + completion) usage relative to the model context limit.
101+
*
102+
* We use the first model's `maxInputTokens` (all current models share the
103+
* same 1,048,576 limit) as the reference ceiling.
104+
*/
105+
const CONTEXT_LIMIT = MODELS[0]?.maxInputTokens ?? 1_048_576;
106+
107+
/**
108+
* Create and register a VS Code status bar item that displays the current
109+
* session's token usage.
110+
*
111+
* The returned item is already pushed into `context.subscriptions` and will
112+
* be automatically disposed when the extension deactivates.
113+
*/
114+
export function createStatusBarItem(
115+
context: vscode.ExtensionContext,
116+
tracker: TokenUsageTracker,
117+
): vscode.StatusBarItem {
118+
const item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
119+
item.name = 'DeepSeek Token Usage';
120+
item.command = 'deepseek-copilot.showLogs';
121+
122+
function update(): void {
123+
const s = tracker.getSummary();
124+
125+
// Hide when no tokens have been consumed yet.
126+
if (s.totalTokens === 0) {
127+
item.hide();
128+
return;
129+
}
130+
131+
item.text = `🧠 ${fmt(s.totalTokens)} / ${fmt(CONTEXT_LIMIT)} tok`;
132+
133+
item.tooltip = new vscode.MarkdownString(
134+
[
135+
'**DeepSeek Token Usage (this session)**',
136+
'',
137+
`| | |`,
138+
`|---|---:|`,
139+
`| Prompt tokens | ${fmt(s.promptTokens)} |`,
140+
`| Completion tokens | ${fmt(s.completionTokens)} |`,
141+
`| **Total tokens** | **${fmt(s.totalTokens)}** |`,
142+
`| | |`,
143+
`| Context limit | ${fmt(CONTEXT_LIMIT)} |`,
144+
`| Cache hit tokens | ${fmt(s.cacheHitTokens)} |`,
145+
].join('\n'),
146+
);
147+
item.tooltip.isTrusted = true;
148+
149+
// Color thresholds
150+
const ratio = s.totalTokens / CONTEXT_LIMIT;
151+
if (ratio > 0.95) {
152+
item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
153+
} else if (ratio > 0.80) {
154+
item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
155+
} else {
156+
item.backgroundColor = undefined;
157+
}
158+
159+
item.show();
160+
}
161+
162+
// Wire the tracker update callback to our status bar repaint.
163+
tracker.setOnUpdate(() => update());
164+
165+
// Initial paint (hidden state — no tokens yet).
166+
update();
167+
168+
context.subscriptions.push(item);
169+
170+
logger.info('Token usage status bar created');
171+
172+
return item;
173+
}

0 commit comments

Comments
 (0)