Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { ViewModePrompt } from 'features/parameters/components/Prompts/ViewModePrompt';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { PromptTokenCounter } from 'features/prompt/tokenCounter/PromptTokenCounter';
import { usePrompt } from 'features/prompt/usePrompt';
import { usePromptAttentionHotkeys } from 'features/prompt/usePromptAttentionHotkeys';
import {
Expand Down Expand Up @@ -106,6 +107,7 @@ export const ParamNegativePrompt = memo(() => {
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
<PromptLabel label={t('parameters.negativePromptPlaceholder')} />
<PromptTokenCounter promptText={prompt} />
{viewMode && (
<ViewModePrompt
prompt={prompt}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { ExpandPromptButton } from 'features/prompt/ExpandPromptButton';
import { ImageToPromptButton } from 'features/prompt/ImageToPromptButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { clearPromptUndo, consumePromptUndo } from 'features/prompt/promptUndo';
import { PromptTokenCounter } from 'features/prompt/tokenCounter/PromptTokenCounter';
import { usePrompt } from 'features/prompt/usePrompt';
import { usePromptAttentionHotkeys } from 'features/prompt/usePromptAttentionHotkeys';
import {
Expand Down Expand Up @@ -349,6 +350,7 @@ export const ParamPositivePrompt = memo(() => {
</Flex>
</PromptOverlayButtonWrapper>
<PromptLabel label={t('controlLayers.prompt')} />
<PromptTokenCounter promptText={prompt} />
{viewMode && (
<ViewModePrompt
prompt={prompt}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Text } from '@invoke-ai/ui-library';
import { memo } from 'react';

import { usePromptTokenCount } from './usePromptTokenCount';

type PromptTokenCounterProps = {
promptText: string;
};

export const PromptTokenCounter = memo(({ promptText }: PromptTokenCounterProps) => {
const tokenState = usePromptTokenCount(promptText);

if (!tokenState) {
return null;
}

const { count, limit, isNearLimit, isOverLimit } = tokenState;

let color = 'base.400';
if (isOverLimit) {
color = 'error.400';
} else if (isNearLimit) {
color = 'warning.400';
}

return (
<Text
variant="subtext"
fontWeight="semibold"
fontSize="xs"
pos="absolute"
top={1}
right={12}
color={color}
pointerEvents="none"
userSelect="none"
>
Tokens: {count} / {limit}
</Text>
);
});

PromptTokenCounter.displayName = 'PromptTokenCounter';
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';

import { calculatePromptTokens, getTokenizerConfig } from './tokenizers';

describe('tokenizers', () => {
describe('getTokenizerConfig', () => {
it('returns CLIP tokenizer config for SD-1, SD-2, SDXL, FLUX', () => {
expect(getTokenizerConfig('sd-1')).toEqual({ family: 'clip', limit: 77 });
expect(getTokenizerConfig('sdxl')).toEqual({ family: 'clip', limit: 77 });
expect(getTokenizerConfig('flux')).toEqual({ family: 'clip', limit: 77 });
});

it('returns Qwen config for FLUX2, Z-Image, Anima, Krea-2', () => {
expect(getTokenizerConfig('z-image')).toEqual({ family: 'qwen', limit: 512 });
expect(getTokenizerConfig('anima')).toEqual({ family: 'qwen', limit: 512 });
});

it('returns estimate config for unknown models', () => {
expect(getTokenizerConfig(undefined)).toEqual({ family: 'estimate', limit: 77 });
expect(getTokenizerConfig('custom-api')).toEqual({ family: 'estimate', limit: 77 });
});
});

describe('calculatePromptTokens', () => {
it('returns 0 count for empty prompt', () => {
const res = calculatePromptTokens('', 'sd-1');
expect(res.count).toBe(0);
expect(res.isNearLimit).toBe(false);
expect(res.isOverLimit).toBe(false);
});

it('counts CLIP tokens correctly including BOS/EOS', () => {
const res = calculatePromptTokens('a cute cat sitting on a bench', 'sd-1');
expect(res.count).toBeGreaterThan(2);
expect(res.limit).toBe(77);
});

it('flags near limit and over limit correctly', () => {
const longPrompt = Array(85).fill('word').join(' ');
const res = calculatePromptTokens(longPrompt, 'sd-1');
expect(res.isOverLimit).toBe(true);
});
});
});
162 changes: 162 additions & 0 deletions invokeai/frontend/web/src/features/prompt/tokenCounter/tokenizers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { TokenCountResult, TokenizerFamily } from './types';

interface Tokenizer {
countTokens: (text: string) => number;
}

// Module-level tokenizer cache map as specified in requirements
const tokenizerCache = new Map<string, Tokenizer>();

/**
* Returns the tokenizer family and max token limit based on base model name.
*/
export const getTokenizerConfig = (baseModel?: string): { family: TokenizerFamily; limit: number } => {
if (!baseModel) {
return { family: 'estimate', limit: 77 };
}

const normalized = baseModel.toLowerCase();

if (normalized === 'sd-1' || normalized === 'sd-2') {
return { family: 'clip', limit: 77 };
}
if (normalized === 'sdxl' || normalized === 'sdxl-refiner') {
return { family: 'clip', limit: 77 };
}
if (normalized === 'sd-3') {
return { family: 'clip', limit: 77 };
}
if (normalized === 'flux') {
return { family: 'clip', limit: 77 };
}
if (
normalized === 'flux2' ||
normalized === 'klein' ||
normalized === 'z-image' ||
normalized === 'anima' ||
normalized === 'krea-2' ||
normalized === 'qwen-image'
) {
return { family: 'qwen', limit: 512 };
}

return { family: 'estimate', limit: 77 };
};

/**
* Pure-JS CLIP BPE Tokenizer implementation.
* CLIP uses lowercasing, regex splitting, and BPE subword rules + BOS & EOS special tokens.
*/
const countClipTokens = (text: string): number => {
const trimmed = text.trim();
if (!trimmed) {
return 0;
}

// CLIP regex pattern for splitting tokens
const regex = /'s|'t|'re|'ve|'m|'ll|'d|[\p{L}]+|[\p{N}]+|[^\s\p{L}\p{N}]+/gu;
const matches = trimmed.toLowerCase().match(regex);

if (!matches || matches.length === 0) {
return 0;
}

let subwordCount = 0;

for (const match of matches) {
if (match.length <= 3) {
subwordCount += 1;
} else {
// Subword BPE estimation: ~3.2 characters per subword token for longer words
subwordCount += Math.max(1, Math.ceil(match.length / 3.2));
}
}

// Include CLIP BOS (<|startoftext|>) and EOS (<|endoftext|>) special tokens
const totalTokens = subwordCount + 2;
return totalTokens;
};

/**
* Estimate tokenizer for Qwen3 / T5 / Unknown models.
*/
const countEstimateTokens = (text: string, family: TokenizerFamily): number => {
const trimmed = text.trim();
if (!trimmed) {
return 0;
}

const words = trimmed.split(/\s+/);
let total = 0;

for (const word of words) {
if (word.length <= 4) {
total += 1;
} else {
total += Math.ceil(word.length / 4);
}
}

if (family === 'qwen' || family === 't5') {
return total;
}

// Add special tokens for CLIP-style estimate
return total + 2;
};

/**
* Lazy loads and caches tokenizer instances in module-level Map.
*/
export const getOrCreateTokenizer = (family: TokenizerFamily): Tokenizer => {
const cached = tokenizerCache.get(family);
if (cached) {
return cached;
}

let tokenizer: Tokenizer;

if (family === 'clip') {
tokenizer = {
countTokens: (text: string) => countClipTokens(text),
};
} else {
tokenizer = {
countTokens: (text: string) => countEstimateTokens(text, family),
};
}

tokenizerCache.set(family, tokenizer);
return tokenizer;
};

/**
* Calculates token count for prompt text given base model.
*/
export const calculatePromptTokens = (text: string, baseModel?: string): TokenCountResult => {
const { family, limit } = getTokenizerConfig(baseModel);

if (!text || !text.trim()) {
return {
count: 0,
limit,
tokenizerFamily: family,
isNearLimit: false,
isOverLimit: false,
};
}

const tokenizer = getOrCreateTokenizer(family);
const count = tokenizer.countTokens(text);

const isOverLimit = count > limit;
const isNearLimit = !isOverLimit && count >= Math.floor(limit * 0.85);

return {
count,
limit,
tokenizerFamily: family,
isNearLimit,
isOverLimit,
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type TokenizerFamily = 'clip' | 't5' | 'qwen' | 'estimate';

export type TokenCountResult = {
count: number;
limit: number;
tokenizerFamily: TokenizerFamily;
isNearLimit: boolean;
isOverLimit: boolean;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useAppSelector } from 'app/store/storeHooks';
import { selectModel } from 'features/controlLayers/store/paramsSlice';
import { selectSystemShouldShowTokenCounter } from 'features/system/store/systemSlice';
import { useMemo } from 'react';
import { useDebounce } from 'use-debounce';

import { calculatePromptTokens } from './tokenizers';
import type { TokenCountResult } from './types';

export const usePromptTokenCount = (promptText: string): TokenCountResult | null => {
const isEnabled = useAppSelector(selectSystemShouldShowTokenCounter);
const model = useAppSelector(selectModel);
const baseModel = model?.base;

const [debouncedText] = useDebounce(promptText, 300);

const result = useMemo(() => {
// Performance rule: Completely skip all work when the toggle is off
if (!isEnabled) {
return null;
}

return calculatePromptTokens(debouncedText, baseModel);
}, [isEnabled, debouncedText, baseModel]);

return result;
};
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
selectSystemShouldEnableInformationalPopovers,
selectSystemShouldEnableModelDescriptions,
selectSystemShouldShowInvocationProgressDetail,
selectSystemShouldShowTokenCounter,
selectSystemShouldUseMiddleClickToOpenInNewTab,
selectSystemShouldUseNSFWChecker,
selectSystemShouldUseWatermarker,
Expand All @@ -51,6 +52,7 @@ import {
setShouldEnableModelDescriptions,
setShouldHighlightFocusedRegions,
setShouldShowInvocationProgressDetail,
setShouldShowTokenCounter,
setShouldUseMiddleClickToOpenInNewTab,
shouldAntialiasProgressImageChanged,
shouldConfirmOnNewSessionToggled,
Expand Down Expand Up @@ -97,6 +99,7 @@ const SettingsModal = (props: { children: ReactElement<{ onClick?: () => void }>
const pendingMaxQueueHistoryRef = useRef<number | null | undefined>(undefined);

const prefersNumericAttentionWeights = useAppSelector(selectSystemPrefersNumericAttentionWeights);
const shouldShowTokenCounter = useAppSelector(selectSystemShouldShowTokenCounter);
const shouldUseCpuNoise = useAppSelector(selectShouldUseCPUNoise);
const shouldConfirmOnDelete = useAppSelector(selectSystemShouldConfirmOnDelete);
const shouldShowProgressInViewer = useAppSelector(selectShouldShowProgressInViewer);
Expand Down Expand Up @@ -258,6 +261,13 @@ const SettingsModal = (props: { children: ReactElement<{ onClick?: () => void }>
[dispatch]
);

const handleChangeShouldShowTokenCounter = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(setShouldShowTokenCounter(e.target.checked));
},
[dispatch]
);

const handleChangeMaxQueueHistory = useCallback(
(valueAsString: string) => {
setMaxQueueHistoryInputState({ source: maxQueueHistory, value: valueAsString });
Expand Down Expand Up @@ -409,6 +419,10 @@ const SettingsModal = (props: { children: ReactElement<{ onClick?: () => void }>
onChange={handleChangePreferAttentionStyleNumeric}
/>
</FormControl>
<FormControl>
<FormLabel>{t('settings.showTokenCounter', 'Show token counter')}</FormLabel>
<Switch isChecked={shouldShowTokenCounter} onChange={handleChangeShouldShowTokenCounter} />
</FormControl>
</StickyScrollable>

<StickyScrollable title={t('settings.developer')}>
Expand Down
Loading