| package context |
|
|
| import ( |
| "hash/fnv" |
| "strings" |
| "sync" |
| "sync/atomic" |
| "time" |
|
|
| log "github.com/sirupsen/logrus" |
| "github.com/tiktoken-go/tokenizer" |
| ) |
|
|
| |
| type TokenCountCacheConfig struct { |
| Enabled bool `yaml:"enabled" json:"enabled"` |
| MaxEntries int `yaml:"max-entries" json:"max_entries"` |
| TTLMinutes int `yaml:"ttl-minutes" json:"ttl_minutes"` |
| } |
|
|
| |
| func DefaultTokenCountCacheConfig() *TokenCountCacheConfig { |
| return &TokenCountCacheConfig{ |
| Enabled: true, |
| MaxEntries: 10000, |
| TTLMinutes: 60, |
| } |
| } |
|
|
| |
| type cachedCount struct { |
| count int |
| createdAt time.Time |
| } |
|
|
| |
| type TokenCountCache struct { |
| cache sync.Map |
| maxEntries int |
| ttl time.Duration |
| size atomic.Int64 |
|
|
| |
| tokenizers sync.Map |
|
|
| |
| hits atomic.Int64 |
| misses atomic.Int64 |
|
|
| mu sync.RWMutex |
| } |
|
|
| |
| func NewTokenCountCache(maxEntries int) *TokenCountCache { |
| if maxEntries <= 0 { |
| maxEntries = 10000 |
| } |
|
|
| cache := &TokenCountCache{ |
| maxEntries: maxEntries, |
| ttl: 60 * time.Minute, |
| } |
|
|
| log.WithField("max_entries", maxEntries).Debug("token count cache initialized") |
| return cache |
| } |
|
|
| |
| func (tc *TokenCountCache) Count(model, content string) int { |
| if content == "" { |
| return 0 |
| } |
|
|
| |
| key := tc.cacheKey(model, content) |
|
|
| |
| if val, ok := tc.cache.Load(key); ok { |
| cached := val.(*cachedCount) |
| if time.Since(cached.createdAt) < tc.ttl { |
| tc.hits.Add(1) |
| return cached.count |
| } |
| |
| tc.cache.Delete(key) |
| tc.size.Add(-1) |
| } |
|
|
| tc.misses.Add(1) |
|
|
| |
| count := tc.countTokens(model, content) |
|
|
| |
| if tc.size.Load() >= int64(tc.maxEntries) { |
| tc.evictOldest() |
| } |
|
|
| tc.cache.Store(key, &cachedCount{ |
| count: count, |
| createdAt: time.Now(), |
| }) |
| tc.size.Add(1) |
|
|
| return count |
| } |
|
|
| |
| func (tc *TokenCountCache) CountBatch(model string, contents []string) []int { |
| results := make([]int, len(contents)) |
| for i, content := range contents { |
| results[i] = tc.Count(model, content) |
| } |
| return results |
| } |
|
|
| |
| func (tc *TokenCountCache) countTokens(model, content string) int { |
| codec := tc.getTokenizer(model) |
| if codec == nil { |
| |
| return len(content) / 4 |
| } |
|
|
| count, err := codec.Count(content) |
| if err != nil { |
| |
| return len(content) / 4 |
| } |
|
|
| |
| adjustment := tc.getAdjustmentFactor(model) |
| return int(float64(count) * adjustment) |
| } |
|
|
| |
| func (tc *TokenCountCache) getTokenizer(model string) tokenizer.Codec { |
| |
| if val, ok := tc.tokenizers.Load(model); ok { |
| return val.(tokenizer.Codec) |
| } |
|
|
| |
| var codec tokenizer.Codec |
| var err error |
|
|
| normalized := strings.ToLower(model) |
|
|
| switch { |
| case strings.Contains(normalized, "claude"): |
| codec, err = tokenizer.Get(tokenizer.Cl100kBase) |
| case strings.Contains(normalized, "gpt-5"): |
| codec, err = tokenizer.ForModel(tokenizer.GPT5) |
| case strings.Contains(normalized, "gpt-4o"): |
| codec, err = tokenizer.ForModel(tokenizer.GPT4o) |
| case strings.Contains(normalized, "gpt-4"): |
| codec, err = tokenizer.ForModel(tokenizer.GPT4) |
| case strings.Contains(normalized, "o1"), strings.Contains(normalized, "o3"): |
| codec, err = tokenizer.ForModel(tokenizer.O1) |
| default: |
| codec, err = tokenizer.Get(tokenizer.O200kBase) |
| } |
|
|
| if err != nil { |
| log.WithError(err).WithField("model", model).Debug("failed to get tokenizer") |
| return nil |
| } |
|
|
| |
| actual, _ := tc.tokenizers.LoadOrStore(model, codec) |
| return actual.(tokenizer.Codec) |
| } |
|
|
| |
| func (tc *TokenCountCache) getAdjustmentFactor(model string) float64 { |
| normalized := strings.ToLower(model) |
|
|
| |
| if strings.Contains(normalized, "claude") { |
| return 1.1 |
| } |
|
|
| |
| if strings.Contains(normalized, "kiro") { |
| return 1.1 |
| } |
|
|
| return 1.0 |
| } |
|
|
| |
| func (tc *TokenCountCache) cacheKey(model, content string) uint64 { |
| h := fnv.New64a() |
| h.Write([]byte(model)) |
| h.Write([]byte{0}) |
| h.Write([]byte(content)) |
| return h.Sum64() |
| } |
|
|
| |
| func (tc *TokenCountCache) evictOldest() { |
| |
| toRemove := tc.maxEntries / 10 |
| if toRemove < 1 { |
| toRemove = 1 |
| } |
|
|
| removed := 0 |
| now := time.Now() |
|
|
| tc.cache.Range(func(key, value interface{}) bool { |
| if removed >= toRemove { |
| return false |
| } |
|
|
| cached := value.(*cachedCount) |
| |
| if now.Sub(cached.createdAt) > tc.ttl/2 { |
| tc.cache.Delete(key) |
| tc.size.Add(-1) |
| removed++ |
| } |
| return true |
| }) |
|
|
| |
| if removed < toRemove { |
| tc.cache.Range(func(key, value interface{}) bool { |
| if removed >= toRemove { |
| return false |
| } |
| tc.cache.Delete(key) |
| tc.size.Add(-1) |
| removed++ |
| return true |
| }) |
| } |
| } |
|
|
| |
| type TokenCacheStats struct { |
| Size int64 |
| Hits int64 |
| Misses int64 |
| HitRate float64 |
| } |
|
|
| |
| func (tc *TokenCountCache) Stats() *TokenCacheStats { |
| hits := tc.hits.Load() |
| misses := tc.misses.Load() |
| total := hits + misses |
|
|
| var hitRate float64 |
| if total > 0 { |
| hitRate = float64(hits) / float64(total) * 100 |
| } |
|
|
| return &TokenCacheStats{ |
| Size: tc.size.Load(), |
| Hits: hits, |
| Misses: misses, |
| HitRate: hitRate, |
| } |
| } |
|
|
| |
| func (tc *TokenCountCache) Clear() { |
| tc.cache.Range(func(key, value interface{}) bool { |
| tc.cache.Delete(key) |
| return true |
| }) |
| tc.size.Store(0) |
| tc.hits.Store(0) |
| tc.misses.Store(0) |
| } |
|
|
| |
| func (tc *TokenCountCache) Preload(model string, contents []string) { |
| for _, content := range contents { |
| tc.Count(model, content) |
| } |
| } |
|
|