Skip to content

Commit 5d414dd

Browse files
committed
feat(perf): move token counting to Web Worker architecture
Improve performance by offloading token counting to a dedicated Web Worker. This prevents blocking the main thread during token calculations. Key changes: Add token-counter worker with optimized Tiktoken usage Implement robust WorkerManager with error handling and retries Update base provider to use worker for token counting Configure esbuild to properly bundle worker code
1 parent f1c7975 commit 5d414dd

File tree

5 files changed

+237
-36
lines changed

5 files changed

+237
-36
lines changed

esbuild.js

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,28 @@ const extensionConfig = {
187187
external: ["vscode"],
188188
}
189189

190+
const workerConfig = {
191+
bundle: true,
192+
minify: production,
193+
sourcemap: !production,
194+
logLevel: "silent",
195+
entryPoints: ["src/api/providers/workers/token-counter.worker.ts"],
196+
format: "cjs",
197+
platform: "node",
198+
outfile: "dist/workers/token-counter.worker.js",
199+
plugins: [esbuildProblemMatcherPlugin],
200+
}
201+
190202
async function main() {
191-
const extensionCtx = await esbuild.context(extensionConfig)
203+
// Create contexts for both worker and extension
204+
const [workerCtx, extensionCtx] = await Promise.all([
205+
esbuild.context(workerConfig),
206+
esbuild.context(extensionConfig),
207+
])
192208

193209
if (watch) {
194-
// Start the esbuild watcher
195-
await extensionCtx.watch()
210+
// Start the esbuild watchers
211+
await Promise.all([workerCtx.watch(), extensionCtx.watch()])
196212

197213
// Copy and watch locale files
198214
console.log("Copying locale files initially...")
@@ -201,8 +217,9 @@ async function main() {
201217
// Set up the watcher for locale files
202218
setupLocaleWatcher()
203219
} else {
204-
await extensionCtx.rebuild()
205-
await extensionCtx.dispose()
220+
// Build once and dispose
221+
await Promise.all([workerCtx.rebuild(), extensionCtx.rebuild()])
222+
await Promise.all([workerCtx.dispose(), extensionCtx.dispose()])
206223
}
207224
}
208225

knip.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://unpkg.com/knip@latest/schema.json",
3-
"entry": ["src/extension.ts", "src/activate/index.ts", "webview-ui/src/index.tsx"],
3+
"entry": ["src/extension.ts", "src/activate/index.ts", "webview-ui/src/index.tsx", "src/api/providers/workers/token-counter.worker.ts"],
44
"project": ["src/**/*.ts", "webview-ui/src/**/*.{ts,tsx}"],
55
"ignore": [
66
"**/__mocks__/**",

src/api/providers/base-provider.ts

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,34 @@ import { Anthropic } from "@anthropic-ai/sdk"
22
import { ApiHandler } from ".."
33
import { ModelInfo } from "../../shared/api"
44
import { ApiStream } from "../transform/stream"
5-
import { Tiktoken } from "js-tiktoken/lite"
6-
import o200kBase from "js-tiktoken/ranks/o200k_base"
5+
import { workerManager } from "../../services/workers/WorkerManager"
76

8-
// Reuse the fudge factor used in the original code
9-
const TOKEN_FUDGE_FACTOR = 1.5
7+
const TOKEN_WORKER_ID = "token-counter"
8+
const TOKEN_WORKER_PATH = "workers/token-counter.worker.js"
109

1110
/**
1211
* Base class for API providers that implements common functionality
1312
*/
1413
export abstract class BaseProvider implements ApiHandler {
15-
// Cache the Tiktoken encoder instance since it's stateless
16-
private encoder: Tiktoken | null = null
14+
private isDestroyed: boolean = false
15+
16+
/**
17+
* Class destructor to ensure cleanup
18+
*/
19+
public async [Symbol.asyncDispose](): Promise<void> {
20+
if (!this.isDestroyed) {
21+
this.isDestroyed = true
22+
await this.cleanup()
23+
}
24+
}
25+
26+
/**
27+
* Cleanup resources used by the provider
28+
* This method can be called explicitly or will be called automatically on destruction
29+
*/
30+
public async cleanup(): Promise<void> {
31+
this.isDestroyed = true
32+
}
1733
abstract createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
1834
abstract getModel(): { id: string; info: ModelInfo }
1935

@@ -30,35 +46,33 @@ export abstract class BaseProvider implements ApiHandler {
3046
async countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number> {
3147
if (!content || content.length === 0) return 0
3248

33-
let totalTokens = 0
49+
const worker = await workerManager.initializeWorker(TOKEN_WORKER_ID, TOKEN_WORKER_PATH)
3450

35-
// Lazily create and cache the encoder if it doesn't exist
36-
if (!this.encoder) {
37-
this.encoder = new Tiktoken(o200kBase)
38-
}
51+
return new Promise((resolve, reject) => {
52+
// Handle worker messages
53+
const messageHandler = (result: number | { error: string }) => {
54+
worker.removeListener("message", messageHandler)
55+
worker.removeListener("error", errorHandler)
3956

40-
// Process each content block using the cached encoder
41-
for (const block of content) {
42-
if (block.type === "text") {
43-
// Use tiktoken for text token counting
44-
const text = block.text || ""
45-
if (text.length > 0) {
46-
const tokens = this.encoder.encode(text)
47-
totalTokens += tokens.length
48-
}
49-
} else if (block.type === "image") {
50-
// For images, calculate based on data size
51-
const imageSource = block.source
52-
if (imageSource && typeof imageSource === "object" && "data" in imageSource) {
53-
const base64Data = imageSource.data as string
54-
totalTokens += Math.ceil(Math.sqrt(base64Data.length))
57+
if (typeof result === "number") {
58+
resolve(result)
5559
} else {
56-
totalTokens += 300 // Conservative estimate for unknown images
60+
reject(new Error(result.error))
5761
}
5862
}
59-
}
6063

61-
// Add a fudge factor to account for the fact that tiktoken is not always accurate
62-
return Math.ceil(totalTokens * TOKEN_FUDGE_FACTOR)
64+
// Handle worker errors
65+
const errorHandler = (error: Error) => {
66+
worker.removeListener("message", messageHandler)
67+
worker.removeListener("error", errorHandler)
68+
reject(error)
69+
}
70+
71+
worker.once("message", messageHandler)
72+
worker.once("error", errorHandler)
73+
74+
// Send content to worker
75+
worker.postMessage(content)
76+
})
6377
}
6478
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { parentPort } from "worker_threads"
2+
import { Tiktoken } from "js-tiktoken/lite"
3+
import o200kBase from "js-tiktoken/ranks/o200k_base"
4+
import { Anthropic } from "@anthropic-ai/sdk"
5+
6+
// Reuse the fudge factor used in the original code
7+
const TOKEN_FUDGE_FACTOR = 1.5
8+
9+
type ContentBlock = Anthropic.Messages.ContentBlockParam
10+
11+
// Initialize encoder once for reuse
12+
let encoder: Tiktoken | null = null
13+
14+
parentPort?.on("message", async (content: Array<ContentBlock>) => {
15+
try {
16+
if (!content || content.length === 0) {
17+
parentPort?.postMessage(0)
18+
return
19+
}
20+
21+
let totalTokens = 0
22+
23+
// Lazily create encoder if it doesn't exist
24+
if (!encoder) {
25+
encoder = new Tiktoken(o200kBase)
26+
}
27+
28+
// Process each content block
29+
for (const block of content) {
30+
if (block.type === "text") {
31+
const text = block.text || ""
32+
if (text.length > 0) {
33+
const tokens = encoder.encode(text)
34+
totalTokens += tokens.length
35+
}
36+
} else if (block.type === "image") {
37+
const imageSource = block.source
38+
if (imageSource && typeof imageSource === "object" && "data" in imageSource) {
39+
const base64Data = imageSource.data as string
40+
totalTokens += Math.ceil(Math.sqrt(base64Data.length))
41+
} else {
42+
totalTokens += 300 // Conservative estimate for unknown images
43+
}
44+
}
45+
}
46+
47+
// Apply fudge factor and send result
48+
parentPort?.postMessage(Math.ceil(totalTokens * TOKEN_FUDGE_FACTOR))
49+
} catch (error) {
50+
parentPort?.postMessage({ error: error instanceof Error ? error.message : "Unknown error" })
51+
}
52+
})
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { Worker } from "worker_threads"
2+
import path from "path"
3+
4+
interface WorkerConfig {
5+
retryAttempts: number
6+
maxRetries: number
7+
retryDelay: number
8+
}
9+
10+
export class WorkerManager {
11+
private static instance: WorkerManager
12+
private workers: Map<string, Worker>
13+
private configs: Map<string, WorkerConfig>
14+
private isDestroyed: boolean
15+
16+
private constructor() {
17+
this.workers = new Map()
18+
this.configs = new Map()
19+
this.isDestroyed = false
20+
}
21+
22+
public static getInstance(): WorkerManager {
23+
if (!WorkerManager.instance) {
24+
WorkerManager.instance = new WorkerManager()
25+
}
26+
return WorkerManager.instance
27+
}
28+
29+
public async initializeWorker(
30+
workerId: string,
31+
workerPath: string,
32+
config: Partial<WorkerConfig> = {},
33+
): Promise<Worker> {
34+
if (this.isDestroyed) {
35+
throw new Error("WorkerManager has been destroyed")
36+
}
37+
38+
// If worker already exists, return it
39+
if (this.workers.has(workerId)) {
40+
return this.workers.get(workerId)!
41+
}
42+
43+
const workerConfig: WorkerConfig = {
44+
retryAttempts: 0,
45+
maxRetries: config.maxRetries ?? 3,
46+
retryDelay: config.retryDelay ?? 1000,
47+
}
48+
49+
const absolutePath = path.resolve(__dirname, workerPath)
50+
const worker = new Worker(absolutePath)
51+
52+
worker.on("error", async (error) => {
53+
console.error(`Worker ${workerId} error:`, error)
54+
await this.handleWorkerError(workerId, workerConfig)
55+
})
56+
57+
worker.on("exit", async (code) => {
58+
if (code !== 0) {
59+
console.error(`Worker ${workerId} exited with code ${code}`)
60+
await this.handleWorkerError(workerId, workerConfig)
61+
}
62+
})
63+
64+
this.workers.set(workerId, worker)
65+
this.configs.set(workerId, workerConfig)
66+
67+
return worker
68+
}
69+
70+
private async handleWorkerError(workerId: string, config: WorkerConfig): Promise<void> {
71+
if (config.retryAttempts >= config.maxRetries) {
72+
console.error(`Max retry attempts reached for worker ${workerId}, worker will not restart`)
73+
return
74+
}
75+
76+
config.retryAttempts++
77+
const delay = config.retryDelay * Math.pow(2, config.retryAttempts - 1)
78+
79+
console.log(
80+
`Attempting to restart worker ${workerId} (attempt ${config.retryAttempts} of ${config.maxRetries}) after ${delay}ms`,
81+
)
82+
83+
await new Promise((resolve) => setTimeout(resolve, delay))
84+
85+
const worker = this.workers.get(workerId)
86+
if (worker) {
87+
await worker.terminate()
88+
this.workers.delete(workerId)
89+
this.configs.delete(workerId)
90+
}
91+
}
92+
93+
public getWorker(workerId: string): Worker | undefined {
94+
return this.workers.get(workerId)
95+
}
96+
97+
public async cleanup(): Promise<void> {
98+
if (this.isDestroyed) {
99+
return
100+
}
101+
102+
this.isDestroyed = true
103+
104+
const terminationPromises = Array.from(this.workers.values()).map((worker) => worker.terminate())
105+
106+
await Promise.all(terminationPromises)
107+
108+
this.workers.clear()
109+
this.configs.clear()
110+
}
111+
112+
public async [Symbol.asyncDispose](): Promise<void> {
113+
await this.cleanup()
114+
}
115+
}
116+
117+
// Export singleton instance
118+
export const workerManager = WorkerManager.getInstance()

0 commit comments

Comments
 (0)