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:
claude-code-best
2026-05-09 23:04:35 +08:00
parent fdddb6dbe8
commit efaf4afd9c
28 changed files with 3613 additions and 219 deletions

View 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')
})
})

View File

@@ -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
View 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 (0100) 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
}
}

View 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
}

View File

@@ -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