mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
feat: 添加 Provider Registry、StatusLine、Cache Stats 和其他增强
- providerRegistry: OpenAI 兼容 provider 切换(Cerebras/Groq/DeepSeek/Qwen) - StatusLine: 增强状态栏(缓存命中率、TTL 倒计时、自定义 shell 命令) - cacheStats: 缓存命中率和 token 签名追踪 - ultrareviewPreflight: 代码审查预检服务 - SkillsMenu/filterSkills: 技能菜单过滤增强 - MagicDocs/langfuse prompts: 提示词更新 - claude.ts: API 客户端更新 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
465
src/utils/__tests__/cacheStats.test.ts
Normal file
465
src/utils/__tests__/cacheStats.test.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
import {
|
||||
afterAll,
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
mock,
|
||||
} from 'bun:test'
|
||||
import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as fsp from 'node:fs/promises'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock envUtils so getClaudeConfigHomeDir returns a temp dir while THIS
|
||||
// suite runs. After it finishes, getClaudeConfigHomeDir falls back to the
|
||||
// real semantics (process.env.CLAUDE_CONFIG_DIR ?? ~/.claude) so other
|
||||
// tests in the same process (envUtils.test.ts in particular) don't see
|
||||
// the test's tmpDir leaked as the user config home.
|
||||
// ---------------------------------------------------------------------------
|
||||
let tmpDir = ''
|
||||
let useMockForCacheStats = true
|
||||
afterAll(() => {
|
||||
useMockForCacheStats = false
|
||||
})
|
||||
|
||||
// Provide REAL semantics for every other envUtils export — this mock is
|
||||
// process-global, so envUtils.test.ts and other consumers (providers,
|
||||
// model, etc.) running in the same process see real behavior for
|
||||
// hasNodeOption, isEnvTruthy, isBareMode, parseEnvVars, etc. Only
|
||||
// getClaudeConfigHomeDir is overridden (to point at the test temp dir).
|
||||
const VERTEX_REGION_OVERRIDES: ReadonlyArray<[string, string]> = [
|
||||
['claude-haiku-4-5', 'VERTEX_REGION_CLAUDE_HAIKU_4_5'],
|
||||
['claude-3-5-haiku', 'VERTEX_REGION_CLAUDE_3_5_HAIKU'],
|
||||
['claude-3-5-sonnet', 'VERTEX_REGION_CLAUDE_3_5_SONNET'],
|
||||
['claude-3-7-sonnet', 'VERTEX_REGION_CLAUDE_3_7_SONNET'],
|
||||
['claude-opus-4-1', 'VERTEX_REGION_CLAUDE_4_1_OPUS'],
|
||||
['claude-opus-4', 'VERTEX_REGION_CLAUDE_4_0_OPUS'],
|
||||
['claude-sonnet-4-6', 'VERTEX_REGION_CLAUDE_4_6_SONNET'],
|
||||
['claude-sonnet-4-5', 'VERTEX_REGION_CLAUDE_4_5_SONNET'],
|
||||
['claude-sonnet-4', 'VERTEX_REGION_CLAUDE_4_0_SONNET'],
|
||||
]
|
||||
|
||||
const realIsEnvTruthy = (v: string | boolean | undefined): boolean => {
|
||||
if (!v) return false
|
||||
if (typeof v === 'boolean') return v
|
||||
return ['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim())
|
||||
}
|
||||
const realIsEnvDefinedFalsy = (v: string | boolean | undefined): boolean => {
|
||||
if (v === undefined) return false
|
||||
if (typeof v === 'boolean') return !v
|
||||
if (!v) return false
|
||||
return ['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim())
|
||||
}
|
||||
const realDefaultVertexRegion = (): string =>
|
||||
process.env.CLOUD_ML_REGION || 'us-east5'
|
||||
|
||||
// Real getClaudeConfigHomeDir is memoized via lodash, so consumers may call
|
||||
// `.cache.clear()` on it (see tasks.test.ts). Provide a no-op .cache stub.
|
||||
const mockedGetClaudeConfigHomeDir: (() => string) & {
|
||||
cache: { clear: () => void; get: (k: unknown) => unknown }
|
||||
} = Object.assign(
|
||||
() =>
|
||||
useMockForCacheStats
|
||||
? tmpDir
|
||||
: (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')).normalize(
|
||||
'NFC',
|
||||
),
|
||||
{
|
||||
cache: {
|
||||
clear: () => {},
|
||||
get: (_k: unknown) => undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
mock.module('src/utils/envUtils.js', () => ({
|
||||
getClaudeConfigHomeDir: mockedGetClaudeConfigHomeDir,
|
||||
isEnvTruthy: realIsEnvTruthy,
|
||||
hasNodeOption: (flag: string) => {
|
||||
const opts = process.env.NODE_OPTIONS
|
||||
return !!opts && opts.split(/\s+/).includes(flag)
|
||||
},
|
||||
isEnvDefinedFalsy: realIsEnvDefinedFalsy,
|
||||
isBareMode: () =>
|
||||
realIsEnvTruthy(process.env.CLAUDE_CODE_SIMPLE) ||
|
||||
process.argv.includes('--bare'),
|
||||
parseEnvVars: (rawEnvArgs: string[] | undefined) => {
|
||||
const parsed: Record<string, string> = {}
|
||||
if (rawEnvArgs) {
|
||||
for (const envStr of rawEnvArgs) {
|
||||
const [key, ...valueParts] = envStr.split('=')
|
||||
if (!key || valueParts.length === 0) {
|
||||
throw new Error(
|
||||
`Invalid environment variable format: ${envStr}, environment variables should be added as: -e KEY1=value1 -e KEY2=value2`,
|
||||
)
|
||||
}
|
||||
parsed[key] = valueParts.join('=')
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
},
|
||||
getAWSRegion: () =>
|
||||
process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1',
|
||||
getDefaultVertexRegion: realDefaultVertexRegion,
|
||||
shouldMaintainProjectWorkingDir: () =>
|
||||
realIsEnvTruthy(process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR),
|
||||
isRunningOnHomespace: () =>
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
realIsEnvTruthy(process.env.COO_RUNNING_ON_HOMESPACE),
|
||||
isInProtectedNamespace: () => false,
|
||||
getTeamsDir: () =>
|
||||
useMockForCacheStats
|
||||
? `${tmpDir}/teams`
|
||||
: join(
|
||||
(
|
||||
process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')
|
||||
).normalize('NFC'),
|
||||
'teams',
|
||||
),
|
||||
getEnvBool: () => false,
|
||||
getEnvNumber: () => undefined,
|
||||
getVertexRegionForModel: (model: string | undefined) => {
|
||||
if (model) {
|
||||
const match = VERTEX_REGION_OVERRIDES.find(([prefix]) =>
|
||||
model.startsWith(prefix),
|
||||
)
|
||||
if (match) {
|
||||
return process.env[match[1]] || realDefaultVertexRegion()
|
||||
}
|
||||
}
|
||||
return realDefaultVertexRegion()
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
computeHitRate,
|
||||
tokenSignature,
|
||||
getStateFilePath,
|
||||
readState,
|
||||
writeStateAtomic,
|
||||
type CacheUsage,
|
||||
type CacheStatsState,
|
||||
} from '../cacheStats.js'
|
||||
|
||||
import {
|
||||
onResponse,
|
||||
getCacheStatsState,
|
||||
initCacheStatsState,
|
||||
_resetCacheStatsStateForTest,
|
||||
} from '../cacheStatsState.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function usage(input: number, create: number, read: number): CacheUsage {
|
||||
return {
|
||||
input_tokens: input,
|
||||
cache_creation_input_tokens: create,
|
||||
cache_read_input_tokens: read,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeHitRate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('computeHitRate', () => {
|
||||
test('returns null for null input', () => {
|
||||
expect(computeHitRate(null)).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null when all fields are 0 (denominator = 0)', () => {
|
||||
expect(computeHitRate(usage(0, 0, 0))).toBeNull()
|
||||
})
|
||||
|
||||
test('100% when all tokens are cache reads', () => {
|
||||
expect(computeHitRate(usage(0, 0, 1000))).toBe(100)
|
||||
})
|
||||
|
||||
test('0% when no cache reads', () => {
|
||||
expect(computeHitRate(usage(1000, 0, 0))).toBe(0)
|
||||
})
|
||||
|
||||
test('rounds to integer (50%)', () => {
|
||||
expect(computeHitRate(usage(500, 0, 500))).toBe(50)
|
||||
})
|
||||
|
||||
test('rounds fractional values', () => {
|
||||
// read=1, total=3 → 33.33... → rounds to 33
|
||||
expect(computeHitRate(usage(2, 0, 1))).toBe(33)
|
||||
})
|
||||
|
||||
test('handles large numbers without overflow', () => {
|
||||
const big = 1_000_000_000
|
||||
expect(computeHitRate(usage(big, big, big))).toBe(33)
|
||||
})
|
||||
|
||||
test('cache_creation does not count as reads', () => {
|
||||
// Only cache_read_input_tokens in numerator
|
||||
expect(computeHitRate(usage(0, 1000, 0))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tokenSignature
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('tokenSignature', () => {
|
||||
test('produces deterministic string', () => {
|
||||
const u = usage(100, 200, 300)
|
||||
expect(tokenSignature(u)).toBe('100|200|300')
|
||||
})
|
||||
|
||||
test('changes when input_tokens changes', () => {
|
||||
expect(tokenSignature(usage(1, 2, 3))).not.toBe(
|
||||
tokenSignature(usage(9, 2, 3)),
|
||||
)
|
||||
})
|
||||
|
||||
test('changes when cache_creation changes', () => {
|
||||
expect(tokenSignature(usage(1, 2, 3))).not.toBe(
|
||||
tokenSignature(usage(1, 9, 3)),
|
||||
)
|
||||
})
|
||||
|
||||
test('changes when cache_read changes', () => {
|
||||
expect(tokenSignature(usage(1, 2, 3))).not.toBe(
|
||||
tokenSignature(usage(1, 2, 9)),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State file: getStateFilePath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('getStateFilePath', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cache-stats-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('returns path inside config home dir', () => {
|
||||
const p = getStateFilePath('session-abc')
|
||||
expect(p).toContain('cache-stats')
|
||||
expect(p.startsWith(tmpDir)).toBe(true)
|
||||
})
|
||||
|
||||
test('different sessionIds produce different paths', () => {
|
||||
const p1 = getStateFilePath('session-one')
|
||||
const p2 = getStateFilePath('session-two')
|
||||
expect(p1).not.toBe(p2)
|
||||
})
|
||||
|
||||
test('same sessionId always produces same path (deterministic)', () => {
|
||||
expect(getStateFilePath('s1')).toBe(getStateFilePath('s1'))
|
||||
})
|
||||
|
||||
test('file name is 16 hex chars + .json', () => {
|
||||
const p = getStateFilePath('any-session-id')
|
||||
const base = path.basename(p)
|
||||
expect(base).toMatch(/^[0-9a-f]{16}\.json$/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State file: readState / writeStateAtomic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('readState / writeStateAtomic', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cache-stats-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('readState returns init defaults when file is missing', async () => {
|
||||
const p = path.join(tmpDir, 'cache-stats', 'nonexistent.json')
|
||||
const s = await readState(p)
|
||||
expect(s.version).toBe(1)
|
||||
expect(s.signature).toBeNull()
|
||||
expect(s.lastResetAt).toBeNull()
|
||||
expect(s.lastHitRate).toBeNull()
|
||||
})
|
||||
|
||||
test('readState returns init defaults on corrupt JSON', async () => {
|
||||
const p = path.join(tmpDir, 'bad.json')
|
||||
await fsp.writeFile(p, 'not-json!!!', 'utf8')
|
||||
const s = await readState(p)
|
||||
expect(s.signature).toBeNull()
|
||||
})
|
||||
|
||||
test('readState returns init defaults on invalid shape', async () => {
|
||||
const p = path.join(tmpDir, 'bad-shape.json')
|
||||
await fsp.writeFile(p, JSON.stringify({ version: 2, foo: 'bar' }), 'utf8')
|
||||
const s = await readState(p)
|
||||
expect(s.signature).toBeNull()
|
||||
})
|
||||
|
||||
test('round-trip: writeStateAtomic then readState', async () => {
|
||||
const p = getStateFilePath('round-trip-session')
|
||||
const state: CacheStatsState = {
|
||||
version: 1,
|
||||
signature: '100|200|300',
|
||||
lastResetAt: 1_700_000_000_000,
|
||||
lastHitRate: 75,
|
||||
}
|
||||
await writeStateAtomic(p, state)
|
||||
const read = await readState(p)
|
||||
expect(read).toEqual(state)
|
||||
})
|
||||
|
||||
test('writeStateAtomic creates parent directory if missing', async () => {
|
||||
const p = path.join(tmpDir, 'deep', 'nested', 'state.json')
|
||||
const state: CacheStatsState = {
|
||||
version: 1,
|
||||
signature: null,
|
||||
lastResetAt: null,
|
||||
lastHitRate: null,
|
||||
}
|
||||
await writeStateAtomic(p, state)
|
||||
const read = await readState(p)
|
||||
expect(read.version).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onResponse / getCacheStatsState (in-memory singleton)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('onResponse', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cache-stats-test-'))
|
||||
_resetCacheStatsStateForTest()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('initial state has null signature and lastResetAt', () => {
|
||||
const s = getCacheStatsState()
|
||||
expect(s.signature).toBeNull()
|
||||
expect(s.lastResetAt).toBeNull()
|
||||
})
|
||||
|
||||
test('first onResponse sets lastResetAt and signature', () => {
|
||||
const u = usage(100, 0, 50)
|
||||
const before = Date.now()
|
||||
const s = onResponse(u)
|
||||
const after = Date.now()
|
||||
expect(s.signature).toBe(tokenSignature(u))
|
||||
expect(s.lastResetAt).toBeGreaterThanOrEqual(before)
|
||||
expect(s.lastResetAt).toBeLessThanOrEqual(after)
|
||||
expect(s.lastHitRate).toBe(33) // 50/(100+50) ≈ 33
|
||||
})
|
||||
|
||||
test('same signature does NOT reset lastResetAt', async () => {
|
||||
const u = usage(100, 0, 50)
|
||||
onResponse(u)
|
||||
const firstState = getCacheStatsState()
|
||||
const firstResetAt = firstState.lastResetAt
|
||||
|
||||
// Wait a tick to ensure Date.now() would differ
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
|
||||
onResponse(u) // same signature
|
||||
const secondState = getCacheStatsState()
|
||||
expect(secondState.lastResetAt).toBe(firstResetAt)
|
||||
})
|
||||
|
||||
test('different signature RESETS lastResetAt', async () => {
|
||||
const u1 = usage(100, 0, 50)
|
||||
onResponse(u1)
|
||||
const firstState = getCacheStatsState()
|
||||
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
|
||||
const u2 = usage(200, 0, 100) // different signature
|
||||
onResponse(u2)
|
||||
const secondState = getCacheStatsState()
|
||||
expect(secondState.lastResetAt).toBeGreaterThan(firstState.lastResetAt!)
|
||||
})
|
||||
|
||||
test('lastHitRate is updated on signature change', () => {
|
||||
onResponse(usage(1000, 0, 0)) // 0% hit rate
|
||||
const s1 = getCacheStatsState()
|
||||
expect(s1.lastHitRate).toBe(0)
|
||||
|
||||
onResponse(usage(0, 0, 1000)) // 100% hit rate — different sig
|
||||
const s2 = getCacheStatsState()
|
||||
expect(s2.lastHitRate).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-session isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('multi-session file isolation', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'cache-stats-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('different session IDs produce different state files', async () => {
|
||||
const p1 = getStateFilePath('session-alpha')
|
||||
const p2 = getStateFilePath('session-beta')
|
||||
|
||||
const s1: CacheStatsState = {
|
||||
version: 1,
|
||||
signature: 'sig-alpha',
|
||||
lastResetAt: 1000,
|
||||
lastHitRate: 90,
|
||||
}
|
||||
const s2: CacheStatsState = {
|
||||
version: 1,
|
||||
signature: 'sig-beta',
|
||||
lastResetAt: 2000,
|
||||
lastHitRate: 10,
|
||||
}
|
||||
|
||||
await writeStateAtomic(p1, s1)
|
||||
await writeStateAtomic(p2, s2)
|
||||
|
||||
const r1 = await readState(p1)
|
||||
const r2 = await readState(p2)
|
||||
|
||||
expect(r1.signature).toBe('sig-alpha')
|
||||
expect(r2.signature).toBe('sig-beta')
|
||||
expect(r1.lastHitRate).toBe(90)
|
||||
expect(r2.lastHitRate).toBe(10)
|
||||
})
|
||||
|
||||
test('initCacheStatsState loads persisted fallback values', async () => {
|
||||
_resetCacheStatsStateForTest()
|
||||
const sid = 'test-session-init'
|
||||
const p = getStateFilePath(sid)
|
||||
const persisted: CacheStatsState = {
|
||||
version: 1,
|
||||
signature: '500|100|400',
|
||||
lastResetAt: 1_700_000_000_000,
|
||||
lastHitRate: 40,
|
||||
}
|
||||
await writeStateAtomic(p, persisted)
|
||||
|
||||
await initCacheStatsState(sid)
|
||||
const s = getCacheStatsState()
|
||||
expect(s.lastHitRate).toBe(40)
|
||||
expect(s.lastResetAt).toBe(1_700_000_000_000)
|
||||
expect(s.signature).toBe('500|100|400')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,13 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
|
||||
// Mock dgram before importing LanBeacon
|
||||
const mockSocket = {
|
||||
@@ -13,9 +22,32 @@ const mockSocket = {
|
||||
close: mock(() => {}),
|
||||
}
|
||||
|
||||
mock.module('dgram', () => ({
|
||||
createSocket: () => mockSocket,
|
||||
}))
|
||||
// Spread+flag pattern: previously this was a bare `mock.module('dgram', ...)`
|
||||
// which leaked the stub createSocket into every later test file in the
|
||||
// process via Bun's last-write-wins module mock cache. Spread real dgram
|
||||
// + gate the stub behind useLanBeaconDgramStubs so other tests see real UDP.
|
||||
let useLanBeaconDgramStubs = false
|
||||
mock.module('dgram', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const real = require('dgram') as Record<string, unknown>
|
||||
return {
|
||||
...real,
|
||||
default: real,
|
||||
createSocket: ((...args: unknown[]) =>
|
||||
useLanBeaconDgramStubs
|
||||
? mockSocket
|
||||
: (real.createSocket as (...a: unknown[]) => unknown)(
|
||||
...args,
|
||||
)) as typeof real.createSocket,
|
||||
}
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
useLanBeaconDgramStubs = true
|
||||
})
|
||||
afterAll(() => {
|
||||
useLanBeaconDgramStubs = false
|
||||
})
|
||||
|
||||
const { LanBeacon } = await import('../lanBeacon.js')
|
||||
|
||||
|
||||
109
src/utils/cacheStats.ts
Normal file
109
src/utils/cacheStats.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getClaudeConfigHomeDir } from './envUtils.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CacheUsage {
|
||||
input_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
}
|
||||
|
||||
export interface CacheStatsState {
|
||||
version: 1
|
||||
signature: string | null
|
||||
lastResetAt: number | null // ms epoch; reset when signature changes
|
||||
lastHitRate: number | null // persisted fallback
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute integer hit rate (0–100) or null if denominator is zero / input null.
|
||||
*/
|
||||
export function computeHitRate(u: CacheUsage | null): number | null {
|
||||
if (!u) return null
|
||||
const denom =
|
||||
u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
|
||||
if (denom === 0) return null
|
||||
return Math.round((u.cache_read_input_tokens / denom) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable string that uniquely identifies a usage snapshot.
|
||||
* A change in signature means a new API response arrived — reset the TTL clock.
|
||||
*/
|
||||
export function tokenSignature(u: CacheUsage): string {
|
||||
return `${u.input_tokens}|${u.cache_creation_input_tokens}|${u.cache_read_input_tokens}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State file I/O
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deterministic, short file name derived from sessionId so that:
|
||||
* - Different sessions never collide.
|
||||
* - The raw session id is never written to disk.
|
||||
*/
|
||||
export function getStateFilePath(sessionId: string): string {
|
||||
const hash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16)
|
||||
return join(getClaudeConfigHomeDir(), 'cache-stats', `${hash}.json`)
|
||||
}
|
||||
|
||||
const INIT_STATE: CacheStatsState = {
|
||||
version: 1,
|
||||
signature: null,
|
||||
lastResetAt: null,
|
||||
lastHitRate: null,
|
||||
}
|
||||
|
||||
function isValidState(obj: unknown): obj is CacheStatsState {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
const s = obj as Record<string, unknown>
|
||||
return (
|
||||
s['version'] === 1 &&
|
||||
(s['signature'] === null || typeof s['signature'] === 'string') &&
|
||||
(s['lastResetAt'] === null || typeof s['lastResetAt'] === 'number') &&
|
||||
(s['lastHitRate'] === null || typeof s['lastHitRate'] === 'number')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read state file. Returns init defaults on any error (corrupt, missing, etc.).
|
||||
*/
|
||||
export async function readState(filePath: string): Promise<CacheStatsState> {
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8')
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (isValidState(parsed)) return parsed
|
||||
return { ...INIT_STATE }
|
||||
} catch {
|
||||
return { ...INIT_STATE }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write state atomically: write to a tmp file then rename — safe against
|
||||
* partial-write corruption and concurrent reads.
|
||||
*/
|
||||
export async function writeStateAtomic(
|
||||
filePath: string,
|
||||
state: CacheStatsState,
|
||||
): Promise<void> {
|
||||
const dir = dirname(filePath)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const tmp = `${filePath}.${process.pid}.tmp`
|
||||
try {
|
||||
await writeFile(tmp, JSON.stringify(state), 'utf8')
|
||||
await rename(tmp, filePath)
|
||||
} catch {
|
||||
// Best-effort; silently ignore errors so the UI never crashes
|
||||
}
|
||||
}
|
||||
92
src/utils/cacheStatsState.ts
Normal file
92
src/utils/cacheStatsState.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* In-memory singleton that tracks cache hit-rate state for the current session.
|
||||
*
|
||||
* Call `onResponse(usage)` every time a new API response arrives.
|
||||
* The singleton compares the token signature of the new response against the
|
||||
* previously seen signature. When it changes (= a new API call completed),
|
||||
* it resets `lastResetAt` to Date.now() and asynchronously persists state so
|
||||
* that a future session can show the TTL countdown immediately on startup.
|
||||
*/
|
||||
|
||||
import type { CacheUsage, CacheStatsState } from './cacheStats.js'
|
||||
import {
|
||||
computeHitRate,
|
||||
tokenSignature,
|
||||
getStateFilePath,
|
||||
readState,
|
||||
writeStateAtomic,
|
||||
} from './cacheStats.js'
|
||||
|
||||
interface MemState {
|
||||
signature: string | null
|
||||
lastResetAt: number | null
|
||||
lastHitRate: number | null
|
||||
}
|
||||
|
||||
let memState: MemState = {
|
||||
signature: null,
|
||||
lastResetAt: null,
|
||||
lastHitRate: null,
|
||||
}
|
||||
|
||||
let sessionId: string | null = null
|
||||
|
||||
/**
|
||||
* Must be called once at session start so the singleton knows which state file
|
||||
* to persist to and can pre-load the last known state.
|
||||
*/
|
||||
export async function initCacheStatsState(sid: string): Promise<void> {
|
||||
sessionId = sid
|
||||
const filePath = getStateFilePath(sid)
|
||||
const persisted = await readState(filePath)
|
||||
// Pre-load persisted values so the UI can show fallback immediately
|
||||
memState = {
|
||||
signature: persisted.signature,
|
||||
lastResetAt: persisted.lastResetAt,
|
||||
lastHitRate: persisted.lastHitRate,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever a new assistant response is received with usage data.
|
||||
* Returns the updated in-memory state.
|
||||
*/
|
||||
export function onResponse(usage: CacheUsage): MemState {
|
||||
const sig = tokenSignature(usage)
|
||||
const hitRate = computeHitRate(usage)
|
||||
|
||||
if (sig !== memState.signature) {
|
||||
// New API response — reset the TTL clock
|
||||
memState = {
|
||||
signature: sig,
|
||||
lastResetAt: Date.now(),
|
||||
lastHitRate: hitRate,
|
||||
}
|
||||
// Persist asynchronously; intentionally fire-and-forget
|
||||
if (sessionId !== null) {
|
||||
const filePath = getStateFilePath(sessionId)
|
||||
const toWrite: CacheStatsState = {
|
||||
version: 1,
|
||||
signature: sig,
|
||||
lastResetAt: memState.lastResetAt,
|
||||
lastHitRate: hitRate,
|
||||
}
|
||||
void writeStateAtomic(filePath, toWrite)
|
||||
}
|
||||
}
|
||||
|
||||
return { ...memState }
|
||||
}
|
||||
|
||||
/** Read current in-memory state without triggering a response update. */
|
||||
export function getCacheStatsState(): MemState {
|
||||
return { ...memState }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset singleton — used in tests to isolate test runs.
|
||||
*/
|
||||
export function _resetCacheStatsStateForTest(): void {
|
||||
memState = { signature: null, lastResetAt: null, lastHitRate: null }
|
||||
sessionId = null
|
||||
}
|
||||
@@ -222,6 +222,12 @@ export type GlobalConfig = {
|
||||
rejected?: string[]
|
||||
}
|
||||
primaryApiKey?: string // Primary API key for the user when no environment variable is set, set via oauth (TODO: rename)
|
||||
/**
|
||||
* Workspace API key saved via /login UI (sk-ant-api03-*).
|
||||
* Stored in plaintext — file should be gitignored and chmod 600.
|
||||
* ANTHROPIC_API_KEY env var takes precedence when both are present.
|
||||
*/
|
||||
workspaceApiKey?: string
|
||||
hasAcknowledgedCostThreshold?: boolean
|
||||
hasSeenUndercoverAutoNotice?: boolean // ant-only: whether the one-time auto-undercover explainer has been shown
|
||||
hasSeenUltraplanTerms?: boolean // ant-only: whether the one-time CCR terms notice has been shown in the ultraplan launch dialog
|
||||
|
||||
Reference in New Issue
Block a user