Skip to content

Add support for token counting in AI tasks - #217

Merged
sroussey merged 8 commits into
mainfrom
token-counting
Feb 20, 2026
Merged

Add support for token counting in AI tasks#217
sroussey merged 8 commits into
mainfrom
token-counting

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator
  • Introduced CountTokensTask for accurate token counting using various models.
  • Updated ContextBuilderTask and HierarchicalChunkerTask to utilize token counting functionality.
  • Enhanced package.json and bun.lock to include tiktoken as a dependency.
  • Refactored related tasks to support optional token counting models, improving flexibility in handling token budgets.

@sroussey

Copy link
Copy Markdown
Collaborator Author

#186

#188

…rkflows

- Added CountTokensTask to count tokens in a text string using specified model tokenizers.
- Enhanced ContextBuilderTask to support maxTokens input for better token management.
- Updated HierarchicalChunkerTask to utilize CountTokensTask for precise token budgeting.
- Integrated tiktoken for OpenAI provider to facilitate local token counting.
- Updated package.json and bun.lock to include tiktoken as a dependency.
- Enhanced documentation and tests to cover new functionality.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new token-counting capability to the AI task system, wiring it into multiple providers and beginning to expose token-budget-aware behavior in RAG utilities.

Changes:

  • Introduces CountTokensTask and registers it in the AI task index.
  • Adds provider implementations for CountTokensTask (OpenAI via tiktoken, Anthropic/Gemini via SDK calls, plus LlamaCpp/HF Transformers).
  • Updates RAG utilities/tests: ContextBuilderTask adds maxTokens + totalTokens, and HierarchicalChunkerTask optionally uses CountTokensTask when a model is provided; adjusts a few test expectations and dependencies (including tiktoken).

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
packages/ai/src/task/CountTokensTask.ts Adds new task type + schemas + workflow helper for token counting.
packages/ai/src/task/index.ts Registers/exports CountTokensTask.
packages/ai/src/task/HierarchicalChunkerTask.ts Adds optional model-driven token counting and token-budget chunking logic.
packages/ai/src/task/ContextBuilderTask.ts Adds maxTokens input + totalTokens output and token-budget truncation.
packages/ai-provider/src/provider-openai/common/OpenAI_JobRunFns.ts Implements OpenAI token counting using tiktoken and registers task.
packages/ai-provider/src/provider-openai/OpenAiProvider.ts Advertises CountTokensTask as supported.
packages/ai-provider/src/anthropic/common/Anthropic_JobRunFns.ts Implements Anthropic token counting and registers task.
packages/ai-provider/src/anthropic/AnthropicProvider.ts Advertises CountTokensTask as supported.
packages/ai-provider/src/google-gemini/common/Gemini_JobRunFns.ts Implements Gemini token counting and registers task.
packages/ai-provider/src/google-gemini/GoogleGeminiProvider.ts Advertises CountTokensTask as supported.
packages/ai-provider/src/provider-llamacpp/common/LlamaCpp_JobRunFns.ts Adds LlamaCpp token counting and registers task.
packages/ai-provider/src/provider-llamacpp/LlamaCppProvider.ts Advertises CountTokensTask as supported.
packages/ai-provider/src/hf-transformers/common/HFT_JobRunFns.ts Adds HF Transformers token counting and registers task.
packages/ai-provider/src/hf-transformers/HuggingFaceTransformersProvider.ts Advertises CountTokensTask as supported.
packages/ai-provider/package.json Adds tiktoken as an optional peer/dev dependency.
package.json Adds tiktoken dependency at repo root.
bun.lock Locks tiktoken and associated dependency updates.
packages/dataset/src/document/DocumentNode.ts Clarifies estimateTokens as a fallback estimator.
packages/test/src/test/ai-provider/OpenAiProvider.test.ts Updates expected supported task types/registrations to include CountTokensTask.
packages/test/src/test/ai-provider/GoogleGeminiProvider.test.ts Updates expected supported task types/registrations to include CountTokensTask.
packages/test/src/test/ai-provider/AnthropicProvider.test.ts Updates expected supported task types/registrations to include CountTokensTask.
packages/test/src/test/rag/ContextBuilderTask.test.ts Updates truncation expectation due to new behavior/thresholds.
packages/test/src/test/job-queue/genericJobQueueTests.ts Loosens pending-count assertion to reduce flakiness.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +230 to 232
const maxTokens = tokenBudget.maxTokensPerChunk - tokenBudget.reservedTokens;
const overlapTokens = tokenBudget.overlapTokens;

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxTokens can become <= 0 when reservedTokens >= maxTokensPerChunk (and maxTokens - overlapTokens can also become <= 0). In that case findCharBoundary() can return startChar, producing a zero-length chunk and leaving startOffset unchanged, which can lead to an infinite loop. Consider validating token budget inputs up front (e.g., enforce reservedTokens < maxTokensPerChunk and overlapTokens < maxTokens), or add a defensive fallback that forces progress when endOffset === startOffset.

Copilot uses AI. Check for mistakes.
totalTokens: {
type: "number",
title: "Total Tokens",
description: "Estimated or exact token count of the context",

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalTokens is described as “Estimated or exact token count”, but the implementation always uses estimateTokens(ctx) and there is no path to return an exact count. Consider changing the schema description to “Estimated token count”, or implementing an exact-count path (e.g., via CountTokensTask + model input).

Suggested change
description: "Estimated or exact token count of the context",
description: "Estimated token count of the context",

Copilot uses AI. Check for mistakes.
Comment on lines +113 to +115
* Pass a `model` in the input to use a real tokenizer for accurate token
* counting; when omitted, or when the model's provider does not support token counting,
* the task falls back to the character-based estimate via buildCountTokensFn.

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring references buildCountTokensFn, but that helper doesn’t exist in the codebase. Update the comment to reflect the actual behavior (direct CountTokensTask usage when input.model is provided, otherwise estimateTokens).

Suggested change
* Pass a `model` in the input to use a real tokenizer for accurate token
* counting; when omitted, or when the model's provider does not support token counting,
* the task falls back to the character-based estimate via buildCountTokensFn.
* Pass a `model` in the input to use {@link CountTokensTask} for accurate token
* counting; when omitted, the task falls back to the character-based estimate
* provided by {@link estimateTokens}.

Copilot uses AI. Check for mistakes.
model: modelSchema,
},
required: [],
required: ["documentTree"],

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc_id is validated as required at runtime (throws when missing), but the input schema does not mark it as required. This mismatch means schema-based validation/UI can allow invalid inputs that will always fail at execution time. Add doc_id to required, or remove the runtime requirement if it’s truly optional.

Suggested change
required: ["documentTree"],
required: ["doc_id", "documentTree"],

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +31
const modelSchema = TypeModel("model");

export const CountTokensInputSchema = {
type: "object",
properties: {
text: {
type: "string",
title: "Text",
description: "The text to count tokens for",
},
model: modelSchema,
},
required: ["text", "model"],
additionalProperties: false,

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CountTokensTask uses TypeModel("model"), which bypasses AiTask’s built-in compatibility check (model:TaskType). That allows selecting models that don’t advertise CountTokensTask support and will fail at runtime. Consider switching to TypeModel("model:CountTokensTask") for consistency with other AI text tasks and to allow automatic narrowing/validation.

Copilot uses AI. Check for mistakes.
Comment on lines +254 to +264
export const Gemini_CountTokens: AiProviderRunFn<
CountTokensTaskInput,
CountTokensTaskOutput,
GeminiModelConfig
> = async (input, model, onProgress, signal) => {
const GoogleGenerativeAI = await loadGeminiSDK();
const genAI = new GoogleGenerativeAI(getApiKey(model));
const genModel = genAI.getGenerativeModel({ model: getModelName(model) });
const result = await genModel.countTokens(input.text);
return { count: result.totalTokens };
};

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing Gemini provider tests cover the other run functions (generation/embedding/etc.), but there’s no test for the new Gemini_CountTokens path (e.g., that it calls countTokens and maps totalTokens -> count). Adding a small unit test would help prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +227 to +238
export const Anthropic_CountTokens: AiProviderRunFn<
CountTokensTaskInput,
CountTokensTaskOutput,
AnthropicModelConfig
> = async (input, model, onProgress, signal) => {
const client = await getClient(model);
const result = await client.messages.countTokens({
model: getModelName(model),
messages: [{ role: "user", content: input.text }],
});
return { count: result.input_tokens };
};

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are unit tests for other Anthropic run fns, but none for the new Anthropic_CountTokens behavior (e.g., request shape to messages.countTokens and mapping input_tokens -> count). Adding a focused test would improve coverage for this new functionality.

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +127
* Pass a `countTokensModel` in the input to use a real tokenizer for accurate token
* budgeting; when omitted, or when the model's provider does not support token counting,
* falls back to character-based estimation via buildCountTokensFn.

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring mentions passing countTokensModel and a buildCountTokensFn fallback, but neither exists in this task’s input schema or implementation. Please update the documentation to match the actual API (e.g., document that maxTokens uses estimateTokens, or add a model input and use CountTokensTask when provided).

Suggested change
* Pass a `countTokensModel` in the input to use a real tokenizer for accurate token
* budgeting; when omitted, or when the model's provider does not support token counting,
* falls back to character-based estimation via buildCountTokensFn.
*
* Token budgeting (for `maxTokens`) uses {@link estimateTokens} to approximate
* the token count of the generated context. This is a character-based estimate
* rather than an exact tokenizer tied to a specific model.

Copilot uses AI. Check for mistakes.
let countFn: (text: string) => Promise<number> = async (text: string) => estimateTokens(text);
if (input.model) {
const countTask = context.own(new CountTokensTask({ model: input.model }));
countFn = (text: string) => countTask.run({ text }).then((r) => r.count);

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When input.model is provided, this task always uses CountTokensTask for counting. Because model is declared with TypeModel("model") (no task compatibility enforcement) and there is no try/catch fallback, providing a model whose provider doesn’t implement CountTokensTask will cause the entire chunking task to fail rather than “fall back” as the docstring claims. Consider either (1) changing the schema to TypeModel("model:CountTokensTask") so only compatible models are accepted, or (2) catching CountTokensTask failures and falling back to estimateTokens per call.

Suggested change
countFn = (text: string) => countTask.run({ text }).then((r) => r.count);
countFn = async (text: string): Promise<number> => {
try {
const result = await countTask.run({ text });
return result.count;
} catch (_err) {
// Fall back to local token estimation if CountTokensTask is unavailable or fails.
return estimateTokens(text);
}
};

Copilot uses AI. Check for mistakes.
Comment on lines +295 to +298
// Limiter is 4 per 60s, so at most 4 jobs run in 10ms; at least 12 (test for 10 for some wiggle room) must remain pending.
// Do not assert a specific job is pending (claim order can vary by backend).
const pendingCount = await client.size(JobStatus.PENDING);
expect(pendingCount).toBeGreaterThanOrEqual(12);
expect(pendingCount).toBeGreaterThanOrEqual(10);

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The limiter test is now significantly looser (>=10 pending vs >=12). This reduces the test’s ability to catch rate-limiter regressions. If the flakiness is due to timing jitter, consider asserting based on elapsed time/number of claims observed, or increasing the sleep slightly to keep a stronger bound while remaining stable.

Copilot uses AI. Check for mistakes.
@sroussey

Copy link
Copy Markdown
Collaborator Author

@copilot open a new pull request to apply changes based on the comments in this thread

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

@sroussey I've opened a new pull request, #218, to work on those changes. Once the pull request is ready, I'll request review from you.

sroussey and others added 6 commits February 20, 2026 06:17
- Updated the binary search algorithm to use a ceil-biased midpoint for better accuracy in finding character boundaries.
- Adjusted the loop condition to ensure the last character is checked, preventing stalling at the start character.
- Introduced AiProviderReactiveRunFn type for lightweight reactive task execution.
- Enhanced AiProvider to support reactiveTasks, allowing for fast previews of task outputs.
- Updated AiProviderRegistry to register and retrieve reactive run functions.
- Modified AiTask to delegate to provider-registered reactive functions when available.
- Updated various provider classes to include reactiveTasks in their constructors.
- Added support for reactive task execution across various AI providers, enabling lightweight previews of task outputs.
- Updated AiProvider and AiProviderRegistry to register and handle reactive functions.
- Introduced new reactive task functions for CountTokens in multiple providers, including Anthropic, Google Gemini, Hugging Face Transformers, LlamaCpp, and OpenAI.
- Enhanced WorkerManager and WorkerServer to manage and execute reactive functions effectively.
- Integrated CountTokensTask into ContextBuilderTask to allow for model-specific token counting.
- Added a model schema to input validation, enabling optional model specification for token counting.
- Updated token estimation logic to utilize the CountTokensTask when a model is provided, with fallback to local estimation.
- Improved handling of token budget checks to ensure accurate context generation based on the specified model.
* Initial plan

* Address PR review feedback: fix bugs, improve tests, update docs

Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>

* Revert TypeModel semantic for CountTokensTask back to \"model\"

Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sroussey <127349+sroussey@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 35 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +278 to +301
const _encoderCache = new Map<string, ReturnType<typeof import("tiktoken").get_encoding>>();

async function getEncoder(modelName: string) {
const tiktoken = await loadTiktoken();
if (!_encoderCache.has(modelName)) {
try {
_encoderCache.set(
modelName,
tiktoken.encoding_for_model(
modelName as Parameters<typeof tiktoken.encoding_for_model>[0]
)
);
} catch {
// Fall back to cl100k_base for unknown/newer models.
const fallback = "cl100k_base";
if (!_encoderCache.has(fallback)) {
_encoderCache.set(fallback, tiktoken.get_encoding(fallback));
}
_encoderCache.set(modelName, _encoderCache.get(fallback)!);
}
}
return _encoderCache.get(modelName)!;
}

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The encoder cache _encoderCache will grow unbounded as new model names are encountered. While this is unlikely to be a significant issue in practice (since there are a limited number of model names), consider adding a size limit or using an LRU cache if this could become a concern with dynamic model names or long-running processes.

Additionally, tiktoken encoders should be explicitly freed when no longer needed. Consider implementing a cleanup function that calls encoder.free() on cached encoders, similar to the disposeLlamaCppResources pattern used in the LlamaCpp provider.

Suggested change
const _encoderCache = new Map<string, ReturnType<typeof import("tiktoken").get_encoding>>();
async function getEncoder(modelName: string) {
const tiktoken = await loadTiktoken();
if (!_encoderCache.has(modelName)) {
try {
_encoderCache.set(
modelName,
tiktoken.encoding_for_model(
modelName as Parameters<typeof tiktoken.encoding_for_model>[0]
)
);
} catch {
// Fall back to cl100k_base for unknown/newer models.
const fallback = "cl100k_base";
if (!_encoderCache.has(fallback)) {
_encoderCache.set(fallback, tiktoken.get_encoding(fallback));
}
_encoderCache.set(modelName, _encoderCache.get(fallback)!);
}
}
return _encoderCache.get(modelName)!;
}
type EncoderType = ReturnType<typeof import("tiktoken").get_encoding>;
const _encoderCache = new Map<string, EncoderType>();
const MAX_ENCODER_CACHE_SIZE = 32;
function evictLeastRecentlyUsedEncoder() {
const firstKey = _encoderCache.keys().next().value as string | undefined;
if (firstKey === undefined) {
return;
}
const encoder = _encoderCache.get(firstKey);
_encoderCache.delete(firstKey);
if (!encoder) {
return;
}
// Only free the encoder if no other cache entry refers to the same instance.
for (const other of _encoderCache.values()) {
if (other === encoder) {
return;
}
}
encoder.free();
}
async function getEncoder(modelName: string) {
const tiktoken = await loadTiktoken();
// Cache hit: refresh LRU position.
const existing = _encoderCache.get(modelName);
if (existing) {
_encoderCache.delete(modelName);
_encoderCache.set(modelName, existing);
return existing;
}
let encoder: EncoderType;
try {
encoder = tiktoken.encoding_for_model(
modelName as Parameters<typeof tiktoken.encoding_for_model>[0]
);
} catch {
// Fall back to cl100k_base for unknown/newer models.
const fallback = "cl100k_base";
let fallbackEncoder = _encoderCache.get(fallback);
if (!fallbackEncoder) {
if (_encoderCache.size >= MAX_ENCODER_CACHE_SIZE) {
evictLeastRecentlyUsedEncoder();
}
fallbackEncoder = tiktoken.get_encoding(fallback);
_encoderCache.set(fallback, fallbackEncoder);
}
encoder = fallbackEncoder;
}
if (_encoderCache.size >= MAX_ENCODER_CACHE_SIZE) {
evictLeastRecentlyUsedEncoder();
}
_encoderCache.set(modelName, encoder);
return encoder;
}
export function disposeOpenAITiktokenResources(): void {
const uniqueEncoders = new Set<EncoderType>();
for (const encoder of _encoderCache.values()) {
if (!uniqueEncoders.has(encoder)) {
uniqueEncoders.add(encoder);
try {
encoder.free();
} catch {
// Ignore errors during cleanup.
}
}
}
_encoderCache.clear();
}

Copilot uses AI. Check for mistakes.
Comment on lines +302 to +304
const nextStart = await findCharBoundary(startOffset, maxTokens - overlapTokens);
// Ensure we always make forward progress to prevent an infinite loop.
startOffset = nextStart > startOffset ? nextStart : endOffset;

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overlap calculation at line 302 has a subtle bug. The code attempts to find the next chunk's starting position by searching for maxTokens - overlapTokens from startOffset, but this assumes the current chunk is exactly maxTokens long, which may not be true (the binary search can find a shorter boundary).

The correct approach is to calculate the next start position relative to endOffset (the end of the current chunk) to create the desired overlap. For example:

// Find where overlapTokens worth of content starts, measuring backward from endOffset
const overlapStart = await findCharBoundaryBackward(endOffset, overlapTokens);
startOffset = overlapStart;

However, since findCharBoundary only searches forward, you'd need to implement a backward search, or alternatively, search forward from a reasonable starting point to find the overlap position.

Copilot uses AI. Check for mistakes.
const countTask = context.own(new CountTokensTask({ model: input.model }));
countFn = async (text: string): Promise<number> => {
try {
const result = await countTask.run({ text });

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The executeReactive method is calling countTask.run() which will execute the full task (potentially involving network calls for providers like OpenAI, Gemini, or Anthropic). This defeats the purpose of reactive execution being "lightweight and synchronous-ish" as documented in the AiProviderReactiveRunFn type.

Consider using countTask.runReactive() instead of countTask.run() to ensure the reactive path is used, which would use the fast, local estimation for providers that support it (like the character-based fallback for Gemini and Anthropic).

Suggested change
const result = await countTask.run({ text });
const result = await countTask.runReactive({ text });

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +173
const countTask = context.own(new CountTokensTask({ model: input.model }));
countFn = async (text: string): Promise<number> => {
try {
const result = await countTask.run({ text });
return result.count;
} catch (_err) {
// Fall back to local token estimation if CountTokensTask is unavailable or fails.
return estimateTokens(text);
}

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The executeReactive method is calling countTask.run() which will execute the full task (potentially involving network calls or heavy tokenization). This defeats the purpose of reactive execution being "lightweight" as documented.

Consider using countTask.runReactive() instead of countTask.run() to ensure the reactive path is used, which would use fast, local estimation for providers that support it.

Copilot uses AI. Check for mistakes.
Comment on lines +134 to 137
* Token budgeting (for `maxTokens`) uses {@link estimateTokens} to approximate
* the token count of the generated context. This is a character-based estimate
* rather than an exact tokenizer tied to a specific model.
*/

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that token budgeting uses estimateTokens to approximate token count, but this is not entirely accurate. When a model is provided in the input, the task will use the model-specific tokenizer via CountTokensTask (calling countTask.run()). The documentation should clarify that estimateTokens is only used as a fallback when no model is provided or when the CountTokensTask fails.

Copilot uses AI. Check for mistakes.
@sroussey

Copy link
Copy Markdown
Collaborator Author

@copilot investigate test failures

Copilot AI commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

@sroussey I've opened a new pull request, #220, to work on those changes. Once the pull request is ready, I'll request review from you.

- Introduced global fetch mock setup in beforeAll and restored original fetch in afterAll to ensure proper isolation of tests.
- Updated mockFetch reset logic in beforeEach for improved test reliability.
@sroussey
sroussey merged commit 836d35b into main Feb 20, 2026
1 check passed
@sroussey
sroussey deleted the token-counting branch February 25, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants