feat: complete architecture rewrite with Bun, OpenTUI, bash-only tools, and X Search API

Major rewrite of the Grok CLI agent:

- Runtime: Bun-first (removed Node.js/tsx fallbacks)
- UI: Replaced Ink with OpenTUI (@opentui/react) terminal framework
- Tools: Simplified to bash-only (removed text-editor, search, morph, todo, confirmation tools)
- Search: Integrated X Search API and Web Search via xAI Responses API
- Models: Updated to all latest Grok models (grok-4, grok-4.20-beta, grok-4-1-fast, grok-4-fast, grok-3-mini)
- Agent: Streamlined agent loop with async generator streaming
- Client: Dual API support (Chat Completions for tool calling, Responses API for search)
- Removed MCP integration, confirmation service, token counter, Morph editor
- Updated tsconfig for OpenTUI React JSX
- Clean 10-file architecture in src/

Co-authored-by: Ismail Pelaseyed <homanp@users.noreply.github.com>
This commit is contained in:
Cursor AgentandIsmail Pelaseyed committed 2026-03-17 23:06:55 +00:00
1 parent e763e2f472
commit ccfd3bb6fc
52 files changed
+1349 -8815

No files matched your search

-149
View File
@@ -1,149 +0,0 @@
import { exec } from "child_process";
import { promisify } from "util";
import { EventEmitter } from "events";
const execAsync = promisify(exec);
export interface ConfirmationOptions {
operation: string;
filename: string;
showVSCodeOpen?: boolean;
content?: string; // Content to show in confirmation dialog
}
export interface ConfirmationResult {
confirmed: boolean;
dontAskAgain?: boolean;
feedback?: string;
}
export class ConfirmationService extends EventEmitter {
private static instance: ConfirmationService;
private skipConfirmationThisSession = false;
private pendingConfirmation: Promise<ConfirmationResult> | null = null;
private resolveConfirmation: ((result: ConfirmationResult) => void) | null =
null;
// Session flags for different operation types
private sessionFlags = {
fileOperations: false,
bashCommands: false,
allOperations: false,
};
static getInstance(): ConfirmationService {
if (!ConfirmationService.instance) {
ConfirmationService.instance = new ConfirmationService();
}
return ConfirmationService.instance;
}
constructor() {
super();
}
async requestConfirmation(
options: ConfirmationOptions,
operationType: "file" | "bash" = "file"
): Promise<ConfirmationResult> {
// Check session flags
if (
this.sessionFlags.allOperations ||
(operationType === "file" && this.sessionFlags.fileOperations) ||
(operationType === "bash" && this.sessionFlags.bashCommands)
) {
return { confirmed: true };
}
// If VS Code should be opened, try to open it
if (options.showVSCodeOpen) {
try {
await this.openInVSCode(options.filename);
} catch (error) {
// If VS Code opening fails, continue without it
options.showVSCodeOpen = false;
}
}
// Create a promise that will be resolved by the UI component
this.pendingConfirmation = new Promise<ConfirmationResult>((resolve) => {
this.resolveConfirmation = resolve;
});
// Emit custom event that the UI can listen to (using setImmediate to ensure the UI updates)
setImmediate(() => {
this.emit("confirmation-requested", options);
});
const result = await this.pendingConfirmation;
if (result.dontAskAgain) {
// Set the appropriate session flag based on operation type
if (operationType === "file") {
this.sessionFlags.fileOperations = true;
} else if (operationType === "bash") {
this.sessionFlags.bashCommands = true;
}
// Could also set allOperations for global skip
}
return result;
}
confirmOperation(confirmed: boolean, dontAskAgain?: boolean): void {
if (this.resolveConfirmation) {
this.resolveConfirmation({ confirmed, dontAskAgain });
this.resolveConfirmation = null;
this.pendingConfirmation = null;
}
}
rejectOperation(feedback?: string): void {
if (this.resolveConfirmation) {
this.resolveConfirmation({ confirmed: false, feedback });
this.resolveConfirmation = null;
this.pendingConfirmation = null;
}
}
private async openInVSCode(filename: string): Promise<void> {
// Try different VS Code commands
const commands = ["code", "code-insiders", "codium"];
for (const cmd of commands) {
try {
await execAsync(`which ${cmd}`);
await execAsync(`${cmd} "${filename}"`);
return;
} catch (error) {
// Continue to next command
continue;
}
}
throw new Error("VS Code not found");
}
isPending(): boolean {
return this.pendingConfirmation !== null;
}
resetSession(): void {
this.sessionFlags = {
fileOperations: false,
bashCommands: false,
allOperations: false,
};
}
getSessionFlags() {
return { ...this.sessionFlags };
}
setSessionFlag(
flagType: "fileOperations" | "bashCommands" | "allOperations",
value: boolean
) {
this.sessionFlags[flagType] = value;
}
}
-26
View File
@@ -1,26 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
export function loadCustomInstructions(workingDirectory: string = process.cwd()): string | null {
try {
let instructionsPath = path.join(workingDirectory, '.grok', 'GROK.md');
if (fs.existsSync(instructionsPath)) {
const customInstructions = fs.readFileSync(instructionsPath, 'utf-8');
return customInstructions.trim();
}
instructionsPath = path.join(os.homedir(), '.grok', 'GROK.md');
if (fs.existsSync(instructionsPath)) {
const customInstructions = fs.readFileSync(instructionsPath, 'utf-8');
return customInstructions.trim();
}
return null;
} catch (error) {
console.warn('Failed to load custom instructions:', error);
return null;
}
}
+22
View File
@@ -0,0 +1,22 @@
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
export function loadCustomInstructions(): string | null {
const candidates = [
path.join(process.cwd(), ".grok", "GROK.md"),
path.join(os.homedir(), ".grok", "GROK.md"),
];
for (const p of candidates) {
try {
if (fs.existsSync(p)) {
return fs.readFileSync(p, "utf-8").trim();
}
} catch {
continue;
}
}
return null;
}
-56
View File
@@ -1,56 +0,0 @@
import { getSettingsManager, UserSettings, ProjectSettings } from './settings-manager.js';
export interface ModelOption {
model: string;
}
export type ModelConfig = string;
// Re-export interfaces for backward compatibility
export { UserSettings, ProjectSettings };
/**
* Get the effective current model
* Priority: project current model > user default model > system default
*/
export function getCurrentModel(): string {
const manager = getSettingsManager();
return manager.getCurrentModel();
}
/**
* Load model configuration
* Priority: user-settings.json models > default hardcoded
*/
export function loadModelConfig(): ModelOption[] {
const manager = getSettingsManager();
const models = manager.getAvailableModels();
return models.map(model => ({
model: model.trim()
}));
}
/**
* Get default models list
*/
export function getDefaultModels(): string[] {
const manager = getSettingsManager();
return manager.getAvailableModels();
}
/**
* Update the current model in project settings
*/
export function updateCurrentModel(modelName: string): void {
const manager = getSettingsManager();
manager.setCurrentModel(modelName);
}
/**
* Update the user's default model preference
*/
export function updateDefaultModel(modelName: string): void {
const manager = getSettingsManager();
manager.updateUserSetting('defaultModel', modelName);
}
-378
View File
@@ -1,378 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
/**
* Current settings version - increment this when adding new models or changing settings structure
* This triggers automatic migration for existing users
*/
const SETTINGS_VERSION = 2;
/**
* User-level settings stored in ~/.grok/user-settings.json
* These are global settings that apply across all projects
*/
export interface UserSettings {
apiKey?: string; // Grok API key
baseURL?: string; // API base URL
defaultModel?: string; // User's preferred default model
models?: string[]; // Available models list
settingsVersion?: number; // Version for migration tracking
}
/**
* Project-level settings stored in .grok/settings.json
* These are project-specific settings
*/
export interface ProjectSettings {
model?: string; // Current model for this project
mcpServers?: Record<string, any>; // MCP server configurations
}
/**
* Default values for user settings
*/
const DEFAULT_USER_SETTINGS: Partial<UserSettings> = {
baseURL: "https://api.x.ai/v1",
defaultModel: "grok-code-fast-1",
models: [
// Grok 4.1 Fast models (2M context, latest - November 2025)
"grok-4-1-fast-reasoning",
"grok-4-1-fast-non-reasoning",
// Grok 4 Fast models (2M context)
"grok-4-fast-reasoning",
"grok-4-fast-non-reasoning",
// Grok 4 flagship (256K context)
"grok-4",
"grok-4-latest",
// Grok Code (optimized for coding, 256K context)
"grok-code-fast-1",
// Grok 3 models (131K context)
"grok-3",
"grok-3-latest",
"grok-3-fast",
"grok-3-mini",
"grok-3-mini-fast",
],
};
/**
* Default values for project settings
*/
const DEFAULT_PROJECT_SETTINGS: Partial<ProjectSettings> = {
model: "grok-code-fast-1",
};
/**
* Unified settings manager that handles both user-level and project-level settings
*/
export class SettingsManager {
private static instance: SettingsManager;
private userSettingsPath: string;
private projectSettingsPath: string;
private constructor() {
// User settings path: ~/.grok/user-settings.json
this.userSettingsPath = path.join(
os.homedir(),
".grok",
"user-settings.json"
);
// Project settings path: .grok/settings.json (in current working directory)
this.projectSettingsPath = path.join(
process.cwd(),
".grok",
"settings.json"
);
}
/**
* Get singleton instance
*/
public static getInstance(): SettingsManager {
if (!SettingsManager.instance) {
SettingsManager.instance = new SettingsManager();
}
return SettingsManager.instance;
}
/**
* Ensure directory exists for a given file path
*/
private ensureDirectoryExists(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
/**
* Load user settings from ~/.grok/user-settings.json
*/
public loadUserSettings(): UserSettings {
try {
if (!fs.existsSync(this.userSettingsPath)) {
// Create default user settings if file doesn't exist
const newSettings = { ...DEFAULT_USER_SETTINGS, settingsVersion: SETTINGS_VERSION };
this.saveUserSettings(newSettings);
return newSettings;
}
const content = fs.readFileSync(this.userSettingsPath, "utf-8");
const settings = JSON.parse(content);
// Check if migration is needed
const currentVersion = settings.settingsVersion || 1;
if (currentVersion < SETTINGS_VERSION) {
const migratedSettings = this.migrateSettings(settings, currentVersion);
this.saveUserSettings(migratedSettings);
return migratedSettings;
}
// Merge with defaults to ensure all required fields exist
return { ...DEFAULT_USER_SETTINGS, ...settings };
} catch (error) {
console.warn(
"Failed to load user settings:",
error instanceof Error ? error.message : "Unknown error"
);
return { ...DEFAULT_USER_SETTINGS };
}
}
/**
* Migrate settings from an older version to the current version
*/
private migrateSettings(settings: UserSettings, fromVersion: number): UserSettings {
let migrated = { ...settings };
// Migration from version 1 to 2: Add new Grok 4.1 and Grok 4 Fast models
if (fromVersion < 2) {
const defaultModels = DEFAULT_USER_SETTINGS.models || [];
const existingModels = new Set(migrated.models || []);
// Add any new models that don't exist in user's current list
const newModels = defaultModels.filter(model => !existingModels.has(model));
// Prepend new models to the list (newest models first)
migrated.models = [...newModels, ...(migrated.models || [])];
}
// Add future migrations here:
// if (fromVersion < 3) { ... }
migrated.settingsVersion = SETTINGS_VERSION;
return migrated;
}
/**
* Save user settings to ~/.grok/user-settings.json
*/
public saveUserSettings(settings: Partial<UserSettings>): void {
try {
this.ensureDirectoryExists(this.userSettingsPath);
// Read existing settings directly to avoid recursion
let existingSettings: UserSettings = { ...DEFAULT_USER_SETTINGS };
if (fs.existsSync(this.userSettingsPath)) {
try {
const content = fs.readFileSync(this.userSettingsPath, "utf-8");
const parsed = JSON.parse(content);
existingSettings = { ...DEFAULT_USER_SETTINGS, ...parsed };
} catch (error) {
// If file is corrupted, use defaults
console.warn("Corrupted user settings file, using defaults");
}
}
const mergedSettings = { ...existingSettings, ...settings };
fs.writeFileSync(
this.userSettingsPath,
JSON.stringify(mergedSettings, null, 2),
{ mode: 0o600 } // Secure permissions for API key
);
} catch (error) {
console.error(
"Failed to save user settings:",
error instanceof Error ? error.message : "Unknown error"
);
throw error;
}
}
/**
* Update a specific user setting
*/
public updateUserSetting<K extends keyof UserSettings>(
key: K,
value: UserSettings[K]
): void {
const settings = { [key]: value } as Partial<UserSettings>;
this.saveUserSettings(settings);
}
/**
* Get a specific user setting
*/
public getUserSetting<K extends keyof UserSettings>(key: K): UserSettings[K] {
const settings = this.loadUserSettings();
return settings[key];
}
/**
* Load project settings from .grok/settings.json
*/
public loadProjectSettings(): ProjectSettings {
try {
if (!fs.existsSync(this.projectSettingsPath)) {
// Create default project settings if file doesn't exist
this.saveProjectSettings(DEFAULT_PROJECT_SETTINGS);
return { ...DEFAULT_PROJECT_SETTINGS };
}
const content = fs.readFileSync(this.projectSettingsPath, "utf-8");
const settings = JSON.parse(content);
// Merge with defaults
return { ...DEFAULT_PROJECT_SETTINGS, ...settings };
} catch (error) {
console.warn(
"Failed to load project settings:",
error instanceof Error ? error.message : "Unknown error"
);
return { ...DEFAULT_PROJECT_SETTINGS };
}
}
/**
* Save project settings to .grok/settings.json
*/
public saveProjectSettings(settings: Partial<ProjectSettings>): void {
try {
this.ensureDirectoryExists(this.projectSettingsPath);
// Read existing settings directly to avoid recursion
let existingSettings: ProjectSettings = { ...DEFAULT_PROJECT_SETTINGS };
if (fs.existsSync(this.projectSettingsPath)) {
try {
const content = fs.readFileSync(this.projectSettingsPath, "utf-8");
const parsed = JSON.parse(content);
existingSettings = { ...DEFAULT_PROJECT_SETTINGS, ...parsed };
} catch (error) {
// If file is corrupted, use defaults
console.warn("Corrupted project settings file, using defaults");
}
}
const mergedSettings = { ...existingSettings, ...settings };
fs.writeFileSync(
this.projectSettingsPath,
JSON.stringify(mergedSettings, null, 2)
);
} catch (error) {
console.error(
"Failed to save project settings:",
error instanceof Error ? error.message : "Unknown error"
);
throw error;
}
}
/**
* Update a specific project setting
*/
public updateProjectSetting<K extends keyof ProjectSettings>(
key: K,
value: ProjectSettings[K]
): void {
const settings = { [key]: value } as Partial<ProjectSettings>;
this.saveProjectSettings(settings);
}
/**
* Get a specific project setting
*/
public getProjectSetting<K extends keyof ProjectSettings>(
key: K
): ProjectSettings[K] {
const settings = this.loadProjectSettings();
return settings[key];
}
/**
* Get the current model with proper fallback logic:
* 1. Project-specific model setting
* 2. User's default model
* 3. System default
*/
public getCurrentModel(): string {
const projectModel = this.getProjectSetting("model");
if (projectModel) {
return projectModel;
}
const userDefaultModel = this.getUserSetting("defaultModel");
if (userDefaultModel) {
return userDefaultModel;
}
return DEFAULT_PROJECT_SETTINGS.model || "grok-code-fast-1";
}
/**
* Set the current model for the project
*/
public setCurrentModel(model: string): void {
this.updateProjectSetting("model", model);
}
/**
* Get available models list from user settings
*/
public getAvailableModels(): string[] {
const models = this.getUserSetting("models");
return models || DEFAULT_USER_SETTINGS.models || [];
}
/**
* Get API key from user settings or environment
*/
public getApiKey(): string | undefined {
// First check environment variable
const envApiKey = process.env.GROK_API_KEY;
if (envApiKey) {
return envApiKey;
}
// Then check user settings
return this.getUserSetting("apiKey");
}
/**
* Get base URL from user settings or environment
*/
public getBaseURL(): string {
// First check environment variable
const envBaseURL = process.env.GROK_BASE_URL;
if (envBaseURL) {
return envBaseURL;
}
// Then check user settings
const userBaseURL = this.getUserSetting("baseURL");
return (
userBaseURL || DEFAULT_USER_SETTINGS.baseURL || "https://api.x.ai/v1"
);
}
}
/**
* Convenience function to get the singleton instance
*/
export function getSettingsManager(): SettingsManager {
return SettingsManager.getInstance();
}
+84 -3
View File
@@ -1,4 +1,85 @@
// This file is kept for potential future convenience functions
// All settings management should use getSettingsManager() from './settings-manager.js'
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { DEFAULT_MODEL, getModelIds } from "../grok/models.js";
export { getSettingsManager, UserSettings, ProjectSettings } from './settings-manager.js';
export interface UserSettings {
apiKey?: string;
baseURL?: string;
defaultModel?: string;
models?: string[];
}
export interface ProjectSettings {
model?: string;
}
const USER_DIR = path.join(os.homedir(), ".grok");
const USER_SETTINGS_PATH = path.join(USER_DIR, "user-settings.json");
function ensureDir(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
function readJson<T>(filePath: string): T | null {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function writeJson(filePath: string, data: unknown): void {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
}
export function loadUserSettings(): UserSettings {
const defaults: UserSettings = {
baseURL: "https://api.x.ai/v1",
defaultModel: DEFAULT_MODEL,
models: getModelIds(),
};
const saved = readJson<UserSettings>(USER_SETTINGS_PATH);
return { ...defaults, ...saved };
}
export function saveUserSettings(partial: Partial<UserSettings>): void {
const current = loadUserSettings();
writeJson(USER_SETTINGS_PATH, { ...current, ...partial });
}
export function loadProjectSettings(): ProjectSettings {
const projectPath = path.join(process.cwd(), ".grok", "settings.json");
return readJson<ProjectSettings>(projectPath) || {};
}
export function saveProjectSettings(partial: Partial<ProjectSettings>): void {
const projectPath = path.join(process.cwd(), ".grok", "settings.json");
const current = loadProjectSettings();
writeJson(projectPath, { ...current, ...partial });
}
export function getApiKey(): string | undefined {
return process.env.GROK_API_KEY || loadUserSettings().apiKey;
}
export function getBaseURL(): string {
return process.env.GROK_BASE_URL || loadUserSettings().baseURL || "https://api.x.ai/v1";
}
export function getCurrentModel(): string {
if (process.env.GROK_MODEL) return process.env.GROK_MODEL;
const project = loadProjectSettings();
if (project.model) return project.model;
const user = loadUserSettings();
return user.defaultModel || DEFAULT_MODEL;
}
export function getAvailableModels(): string[] {
const user = loadUserSettings();
return user.models || getModelIds();
}
-221
View File
@@ -1,221 +0,0 @@
/**
* Text manipulation utilities for terminal input handling
* Inspired by Gemini CLI's text processing capabilities
*/
export interface TextPosition {
index: number;
line: number;
column: number;
}
export interface TextSelection {
start: number;
end: number;
}
/**
* Check if a character is a word boundary
*/
export function isWordBoundary(char: string | undefined): boolean {
if (!char) return true;
return /\s/.test(char) || /[^\w]/.test(char);
}
/**
* Find the start of the current word at the given position
*/
export function findWordStart(text: string, position: number): number {
if (position <= 0) return 0;
let pos = position - 1;
while (pos > 0 && !isWordBoundary(text[pos])) {
pos--;
}
// If we stopped at a word boundary, move forward to the actual word start
if (pos > 0 && isWordBoundary(text[pos])) {
pos++;
}
return pos;
}
/**
* Find the end of the current word at the given position
*/
export function findWordEnd(text: string, position: number): number {
if (position >= text.length) return text.length;
let pos = position;
while (pos < text.length && !isWordBoundary(text[pos])) {
pos++;
}
return pos;
}
/**
* Move cursor to the previous word boundary
*/
export function moveToPreviousWord(text: string, position: number): number {
if (position <= 0) return 0;
let pos = position - 1;
// Skip whitespace
while (pos > 0 && isWordBoundary(text[pos])) {
pos--;
}
// Find start of the word
while (pos > 0 && !isWordBoundary(text[pos - 1])) {
pos--;
}
return pos;
}
/**
* Move cursor to the next word boundary
*/
export function moveToNextWord(text: string, position: number): number {
if (position >= text.length) return text.length;
let pos = position;
// Skip current word
while (pos < text.length && !isWordBoundary(text[pos])) {
pos++;
}
// Skip whitespace
while (pos < text.length && isWordBoundary(text[pos])) {
pos++;
}
return pos;
}
/**
* Delete the word before the cursor
*/
export function deleteWordBefore(text: string, position: number): { text: string; position: number } {
const wordStart = moveToPreviousWord(text, position);
const newText = text.slice(0, wordStart) + text.slice(position);
return {
text: newText,
position: wordStart,
};
}
/**
* Delete the word after the cursor
*/
export function deleteWordAfter(text: string, position: number): { text: string; position: number } {
const wordEnd = moveToNextWord(text, position);
const newText = text.slice(0, position) + text.slice(wordEnd);
return {
text: newText,
position,
};
}
/**
* Get the current line and column from text position
*/
export function getTextPosition(text: string, index: number): TextPosition {
const lines = text.slice(0, index).split('\n');
return {
index,
line: lines.length - 1,
column: lines[lines.length - 1].length,
};
}
/**
* Move to the beginning of the current line
*/
export function moveToLineStart(text: string, position: number): number {
const beforeCursor = text.slice(0, position);
const lastNewlineIndex = beforeCursor.lastIndexOf('\n');
return lastNewlineIndex === -1 ? 0 : lastNewlineIndex + 1;
}
/**
* Move to the end of the current line
*/
export function moveToLineEnd(text: string, position: number): number {
const afterCursor = text.slice(position);
const nextNewlineIndex = afterCursor.indexOf('\n');
return nextNewlineIndex === -1 ? text.length : position + nextNewlineIndex;
}
/**
* Handle proper Unicode-aware character deletion
*/
export function deleteCharBefore(text: string, position: number): { text: string; position: number } {
if (position <= 0) {
return { text, position };
}
// Handle surrogate pairs and combining characters
let deleteCount = 1;
const charBefore = text.charAt(position - 1);
// Check for high surrogate (first part of surrogate pair)
if (position >= 2) {
const charBeforeBefore = text.charAt(position - 2);
if (charBeforeBefore >= '\uD800' && charBeforeBefore <= '\uDBFF' &&
charBefore >= '\uDC00' && charBefore <= '\uDFFF') {
deleteCount = 2;
}
}
const newText = text.slice(0, position - deleteCount) + text.slice(position);
return {
text: newText,
position: position - deleteCount,
};
}
/**
* Handle proper Unicode-aware character deletion forward
*/
export function deleteCharAfter(text: string, position: number): { text: string; position: number } {
if (position >= text.length) {
return { text, position };
}
// Handle surrogate pairs and combining characters
let deleteCount = 1;
const charAfter = text.charAt(position);
// Check for high surrogate (first part of surrogate pair)
if (position + 1 < text.length) {
const charAfterAfter = text.charAt(position + 1);
if (charAfter >= '\uD800' && charAfter <= '\uDBFF' &&
charAfterAfter >= '\uDC00' && charAfterAfter <= '\uDFFF') {
deleteCount = 2;
}
}
const newText = text.slice(0, position) + text.slice(position + deleteCount);
return {
text: newText,
position,
};
}
/**
* Insert text at the given position with proper Unicode handling
*/
export function insertText(text: string, position: number, insert: string): { text: string; position: number } {
const newText = text.slice(0, position) + insert + text.slice(position);
return {
text: newText,
position: position + insert.length,
};
}
-91
View File
@@ -1,91 +0,0 @@
import { get_encoding, encoding_for_model, Tiktoken } from 'tiktoken';
export class TokenCounter {
private encoder: Tiktoken;
constructor(model: string = 'gpt-4') {
try {
// Try to get encoding for specific model
this.encoder = encoding_for_model(model as any);
} catch {
// Fallback to cl100k_base (used by GPT-4 and most modern models)
this.encoder = get_encoding('cl100k_base');
}
}
/**
* Count tokens in a string
*/
countTokens(text: string): number {
if (!text) return 0;
return this.encoder.encode(text).length;
}
/**
* Count tokens in messages array (for chat completions)
*/
countMessageTokens(messages: Array<{ role: string; content: string | null; [key: string]: any }>): number {
let totalTokens = 0;
for (const message of messages) {
// Every message follows <|start|>{role/name}\n{content}<|end|\>\n
totalTokens += 3; // Base tokens per message
if (message.content && typeof message.content === 'string') {
totalTokens += this.countTokens(message.content);
}
if (message.role) {
totalTokens += this.countTokens(message.role);
}
// Add extra tokens for tool calls if present
if (message.tool_calls) {
totalTokens += this.countTokens(JSON.stringify(message.tool_calls));
}
}
totalTokens += 3; // Every reply is primed with <|start|>assistant<|message|>
return totalTokens;
}
/**
* Estimate tokens for streaming content
* This is an approximation since we don't have the full response yet
*/
estimateStreamingTokens(accumulatedContent: string): number {
return this.countTokens(accumulatedContent);
}
/**
* Clean up resources
*/
dispose(): void {
this.encoder.free();
}
}
/**
* Format token count for display (e.g., 1.2k for 1200)
*/
export function formatTokenCount(count: number): string {
if (count <= 999) {
return count.toString();
}
if (count < 1_000_000) {
const k = count / 1000;
return k % 1 === 0 ? `${k}k` : `${k.toFixed(1)}k`;
}
const m = count / 1_000_000;
return m % 1 === 0 ? `${m}m` : `${m.toFixed(1)}m`;
}
/**
* Create a token counter instance
*/
export function createTokenCounter(model?: string): TokenCounter {
return new TokenCounter(model);
}