feat: enhance token usage visualization and matching logic in TokenUsageIndicator and tokens module

This commit is contained in:
Marco Beretta 2025-12-16 00:20:57 +01:00
parent 71b94cdcaa
commit 9c61d73076
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 206 additions and 39 deletions

View file

@ -4,13 +4,10 @@ import { useLocalize, useTokenUsage } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
function formatTokens(n: number): string { function formatTokens(n: number): string {
if (n >= 1000000) { return new Intl.NumberFormat(undefined, {
return `${(n / 1000000).toFixed(1).replace(/\.0$/, '')}M`; notation: 'compact',
} maximumFractionDigits: 1,
if (n >= 1000) { }).format(n);
return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K`;
}
return n.toString();
} }
interface ProgressBarProps { interface ProgressBarProps {
@ -19,30 +16,48 @@ interface ProgressBarProps {
colorClass: string; colorClass: string;
label: string; label: string;
showPercentage?: boolean; showPercentage?: boolean;
indeterminate?: boolean;
} }
function ProgressBar({ value, max, colorClass, label, showPercentage = false }: ProgressBarProps) { function ProgressBar({
value,
max,
colorClass,
label,
showPercentage = false,
indeterminate = false,
}: ProgressBarProps) {
const percentage = max > 0 ? Math.min((value / max) * 100, 100) : 0; const percentage = max > 0 ? Math.min((value / max) * 100, 100) : 0;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
role="progressbar" role="progressbar"
aria-valuenow={Math.round(percentage)} aria-valuenow={indeterminate ? undefined : Math.round(percentage)}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-label={label} aria-label={label}
className="h-2 flex-1 overflow-hidden rounded-full bg-surface-secondary" className="h-2 flex-1 overflow-hidden rounded-full bg-surface-secondary"
> >
<div className="flex h-full rounded-full"> {indeterminate ? (
<div <div
className={cn('rounded-full transition-all duration-300', colorClass)} className="h-full w-full rounded-full"
style={{ width: `${percentage}%` }} style={{
background:
'repeating-linear-gradient(-45deg, var(--border-medium), var(--border-medium) 4px, var(--surface-tertiary) 4px, var(--surface-tertiary) 8px)',
}}
/> />
<div className="flex-1 bg-surface-hover" /> ) : (
</div> <div className="flex h-full rounded-full">
<div
className={cn('rounded-full transition-all duration-300', colorClass)}
style={{ width: `${percentage}%` }}
/>
<div className="flex-1 bg-surface-hover" />
</div>
)}
</div> </div>
{showPercentage && ( {showPercentage && !indeterminate && (
<span className="min-w-[3rem] text-right text-xs text-text-secondary" aria-hidden="true"> <span className="min-w-[3rem] text-right text-xs text-text-secondary" aria-hidden="true">
{Math.round(percentage)}% {Math.round(percentage)}%
</span> </span>
@ -80,7 +95,7 @@ function TokenRow({ label, value, total, colorClass, ariaLabel }: TokenRowProps)
function TokenUsageContent() { function TokenUsageContent() {
const localize = useLocalize(); const localize = useLocalize();
const { inputTokens, outputTokens, maxContext } = useTokenUsage(); const { inputTokens = 0, outputTokens = 0, maxContext = null } = useTokenUsage() ?? {};
const totalUsed = inputTokens + outputTokens; const totalUsed = inputTokens + outputTokens;
const hasMaxContext = maxContext !== null && maxContext > 0; const hasMaxContext = maxContext !== null && maxContext > 0;
@ -127,20 +142,23 @@ function TokenUsageContent() {
</div> </div>
{/* Main Progress Bar */} {/* Main Progress Bar */}
{hasMaxContext && ( <div className="space-y-1">
<div className="space-y-1"> <ProgressBar
<ProgressBar value={totalUsed}
value={totalUsed} max={hasMaxContext ? maxContext : 0}
max={maxContext} colorClass={getMainProgressColor()}
colorClass={getMainProgressColor()} label={
label={`${localize('com_ui_token_usage_context')}: ${formatTokens(totalUsed)} of ${formatTokens(maxContext)}, ${Math.round(percentage)}%`} hasMaxContext
/> ? `${localize('com_ui_token_usage_context')}: ${formatTokens(totalUsed)} of ${formatTokens(maxContext)}, ${Math.round(percentage)}%`
<div className="flex justify-between text-xs text-text-secondary" aria-hidden="true"> : `${localize('com_ui_token_usage_context')}: ${formatTokens(totalUsed)} tokens used, max context unknown`
<span>{formatTokens(totalUsed)}</span> }
<span>{formatTokens(maxContext)}</span> indeterminate={!hasMaxContext}
</div> />
<div className="flex justify-between text-xs text-text-secondary" aria-hidden="true">
<span>{formatTokens(totalUsed)}</span>
<span>{hasMaxContext ? formatTokens(maxContext) : 'N/A'}</span>
</div> </div>
)} </div>
{/* Divider */} {/* Divider */}
<div className="border-t border-border-light" role="separator" /> <div className="border-t border-border-light" role="separator" />
@ -168,7 +186,7 @@ function TokenUsageContent() {
const TokenUsageIndicator = memo(function TokenUsageIndicator() { const TokenUsageIndicator = memo(function TokenUsageIndicator() {
const localize = useLocalize(); const localize = useLocalize();
const { inputTokens, outputTokens, maxContext } = useTokenUsage(); const { inputTokens = 0, outputTokens = 0, maxContext = null } = useTokenUsage() ?? {};
const totalUsed = inputTokens + outputTokens; const totalUsed = inputTokens + outputTokens;
const hasMaxContext = maxContext !== null && maxContext > 0; const hasMaxContext = maxContext !== null && maxContext > 0;
@ -249,13 +267,7 @@ const TokenUsageIndicator = memo(function TokenUsageIndicator() {
</button> </button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardPortal> <HoverCardPortal>
<HoverCardContent <HoverCardContent side="top" align="end" className="p-3">
side="top"
align="end"
className="p-3"
role="dialog"
aria-label={localize('com_ui_token_usage_context')}
>
<TokenUsageContent /> <TokenUsageContent />
</HoverCardContent> </HoverCardContent>
</HoverCardPortal> </HoverCardPortal>

View file

@ -0,0 +1,152 @@
import {
findMatchingPattern,
getModelMaxTokens,
getModelMaxOutputTokens,
matchModelName,
maxTokensMap,
} from '../src/tokens';
import { EModelEndpoint } from '../src/schemas';
describe('Token Pattern Matching', () => {
describe('findMatchingPattern', () => {
const testMap: Record<string, number> = {
'claude-': 100000,
'claude-3': 200000,
'claude-3-opus': 200000,
'gpt-4': 8000,
'gpt-4-turbo': 128000,
};
it('should match exact model names', () => {
expect(findMatchingPattern('claude-3-opus', testMap)).toBe('claude-3-opus');
expect(findMatchingPattern('gpt-4-turbo', testMap)).toBe('gpt-4-turbo');
});
it('should match more specific patterns first (reverse order)', () => {
// claude-3-opus-20240229 should match 'claude-3-opus' not 'claude-3' or 'claude-'
expect(findMatchingPattern('claude-3-opus-20240229', testMap)).toBe('claude-3-opus');
});
it('should fall back to broader patterns when no specific match', () => {
// claude-3-haiku should match 'claude-3' (not 'claude-3-opus')
expect(findMatchingPattern('claude-3-haiku', testMap)).toBe('claude-3');
});
it('should be case-insensitive', () => {
expect(findMatchingPattern('Claude-3-Opus', testMap)).toBe('claude-3-opus');
expect(findMatchingPattern('GPT-4-TURBO', testMap)).toBe('gpt-4-turbo');
});
it('should return null for unmatched models', () => {
expect(findMatchingPattern('unknown-model', testMap)).toBeNull();
expect(findMatchingPattern('llama-2', testMap)).toBeNull();
});
it('should NOT match when pattern appears in middle of model name (startsWith behavior)', () => {
// This is the key fix: "my-claude-wrapper" should NOT match "claude-"
expect(findMatchingPattern('my-claude-wrapper', testMap)).toBeNull();
expect(findMatchingPattern('openai-gpt-4-proxy', testMap)).toBeNull();
expect(findMatchingPattern('custom-claude-3-service', testMap)).toBeNull();
});
it('should handle empty string model name', () => {
expect(findMatchingPattern('', testMap)).toBeNull();
});
it('should handle empty tokens map', () => {
expect(findMatchingPattern('claude-3', {})).toBeNull();
});
});
describe('getModelMaxTokens', () => {
it('should return exact match tokens', () => {
expect(getModelMaxTokens('gpt-4o', EModelEndpoint.openAI)).toBe(127500);
expect(getModelMaxTokens('claude-3-opus', EModelEndpoint.anthropic)).toBe(200000);
});
it('should return pattern-matched tokens', () => {
// claude-3-opus-20240229 should match claude-3-opus pattern
expect(getModelMaxTokens('claude-3-opus-20240229', EModelEndpoint.anthropic)).toBe(200000);
});
it('should return undefined for unknown models', () => {
expect(getModelMaxTokens('completely-unknown-model', EModelEndpoint.openAI)).toBeUndefined();
});
it('should fall back to openAI for unknown endpoints', () => {
const result = getModelMaxTokens('gpt-4o', 'unknown-endpoint');
expect(result).toBe(127500);
});
it('should handle non-string input gracefully', () => {
expect(getModelMaxTokens(null as unknown as string)).toBeUndefined();
expect(getModelMaxTokens(undefined as unknown as string)).toBeUndefined();
expect(getModelMaxTokens(123 as unknown as string)).toBeUndefined();
});
it('should NOT match model names with pattern in middle', () => {
// A model like "my-gpt-4-wrapper" should not match "gpt-4"
expect(getModelMaxTokens('my-gpt-4-wrapper', EModelEndpoint.openAI)).toBeUndefined();
});
});
describe('getModelMaxOutputTokens', () => {
it('should return exact match output tokens', () => {
expect(getModelMaxOutputTokens('o1', EModelEndpoint.openAI)).toBe(32268);
expect(getModelMaxOutputTokens('claude-3-opus', EModelEndpoint.anthropic)).toBe(4096);
});
it('should return pattern-matched output tokens', () => {
expect(getModelMaxOutputTokens('claude-3-opus-20240229', EModelEndpoint.anthropic)).toBe(
4096,
);
});
it('should return system_default for unknown models (openAI endpoint)', () => {
expect(getModelMaxOutputTokens('unknown-model', EModelEndpoint.openAI)).toBe(32000);
});
it('should handle non-string input gracefully', () => {
expect(getModelMaxOutputTokens(null as unknown as string)).toBeUndefined();
expect(getModelMaxOutputTokens(undefined as unknown as string)).toBeUndefined();
});
});
describe('matchModelName', () => {
it('should return exact match model name', () => {
expect(matchModelName('gpt-4o', EModelEndpoint.openAI)).toBe('gpt-4o');
});
it('should return pattern key for pattern matches', () => {
expect(matchModelName('claude-3-opus-20240229', EModelEndpoint.anthropic)).toBe(
'claude-3-opus',
);
});
it('should return input for unknown models', () => {
expect(matchModelName('unknown-model', EModelEndpoint.openAI)).toBe('unknown-model');
});
it('should handle non-string input gracefully', () => {
expect(matchModelName(null as unknown as string)).toBeUndefined();
});
});
describe('maxTokensMap structure', () => {
it('should have entries for all major endpoints', () => {
expect(maxTokensMap[EModelEndpoint.openAI]).toBeDefined();
expect(maxTokensMap[EModelEndpoint.anthropic]).toBeDefined();
expect(maxTokensMap[EModelEndpoint.google]).toBeDefined();
expect(maxTokensMap[EModelEndpoint.azureOpenAI]).toBeDefined();
expect(maxTokensMap[EModelEndpoint.bedrock]).toBeDefined();
});
it('should have positive token values', () => {
Object.values(maxTokensMap).forEach((endpointMap) => {
Object.entries(endpointMap).forEach(([model, tokens]) => {
expect(tokens).toBeGreaterThan(0);
});
});
});
});
});

View file

@ -313,6 +313,10 @@ export const maxTokensMap: Record<string, Record<string, number>> = {
/** /**
* Finds the first matching pattern in the tokens map. * Finds the first matching pattern in the tokens map.
* Searches in reverse order to match more specific patterns first. * Searches in reverse order to match more specific patterns first.
*
* Note: This relies on the insertion order of keys in the tokensMap object.
* More specific patterns must be defined later in the object to be matched first.
* If the order of keys is changed, the matching behavior may be affected.
*/ */
export function findMatchingPattern( export function findMatchingPattern(
modelName: string, modelName: string,
@ -322,7 +326,7 @@ export function findMatchingPattern(
const lowerModelName = modelName.toLowerCase(); const lowerModelName = modelName.toLowerCase();
for (let i = keys.length - 1; i >= 0; i--) { for (let i = keys.length - 1; i >= 0; i--) {
const modelKey = keys[i]; const modelKey = keys[i];
if (lowerModelName.includes(modelKey)) { if (lowerModelName.startsWith(modelKey)) {
return modelKey; return modelKey;
} }
} }
@ -510,7 +514,6 @@ export function getModelMaxOutputTokens(
/** /**
* Centralized token-related default values. * Centralized token-related default values.
* These replace hardcoded magic numbers throughout the codebase.
*/ */
export const TOKEN_DEFAULTS = { export const TOKEN_DEFAULTS = {
/** Fallback context window for agents when model lookup fails */ /** Fallback context window for agents when model lookup fails */