feat(api): expose cache metrics in REPL + normalize across providers (#813)

* feat(api): expose cache metrics in REPL + /cache-stats command

* fix(api): normalize Kimi/DeepSeek/Gemini cache fields through shim layer

* test(api): cover /cache-stats rendering + fix CacheMetrics docstring drift

* fix(api): always reset cache turn counter + include date in /cache-stats rows

* refactor(api): unify shim usage builder + add cost-tracker wiring test

* fix(api): classify private-IP/self-hosted OpenAI endpoints as N/A instead of cold

* fix(api): require colon guard on IPv6 ULA prefix to avoid public-host over-match

* perf(api): ring buffer for cache history + hit rate clamp + .localhost TLD

* fix(api): null guards on formatters + document Codex Responses API shape

* fix(api): defensive start-of-turn reset + config gate fallback + env var docs

* fix(api): trust forwarded cache data on self-hosted URLs (data-driven)

* refactor(api): delegate streaming Responses usage to shared makeUsage helper
This commit is contained in:
viudes
2026-04-25 01:38:25 -03:00
committed by GitHub
parent 9070220292
commit 9e23c2bec4
20 changed files with 2749 additions and 46 deletions

View File

@@ -0,0 +1,126 @@
import { expect, test, describe } from 'bun:test'
import { z } from 'zod'
import {
DEFAULT_GLOBAL_CONFIG,
GLOBAL_CONFIG_KEYS,
isGlobalConfigKey,
SHOW_CACHE_STATS_MODES,
type GlobalConfig,
} from './config.js'
// Standalone Zod schema mirroring the runtime contract for showCacheStats.
// The config file does not carry a Zod schema per field (GlobalConfig is a
// plain TS type with defaults), so we exercise validation here so that any
// future drift — e.g. adding a mode without updating the UI — is caught at
// test time rather than silently rendered in /config.
const ShowCacheStatsSchema = z.enum(SHOW_CACHE_STATS_MODES)
describe('GlobalConfig — showCacheStats registration', () => {
test('default is "compact"', () => {
expect(DEFAULT_GLOBAL_CONFIG.showCacheStats).toBe('compact')
})
test('is listed in GLOBAL_CONFIG_KEYS (exposed via /config and ConfigTool)', () => {
expect(GLOBAL_CONFIG_KEYS).toContain('showCacheStats')
expect(isGlobalConfigKey('showCacheStats')).toBe(true)
})
test('SHOW_CACHE_STATS_MODES is the single source of truth', () => {
expect(SHOW_CACHE_STATS_MODES).toEqual(['off', 'compact', 'full'])
})
})
describe('showCacheStats — Zod validation', () => {
test('accepts "off"', () => {
expect(ShowCacheStatsSchema.parse('off')).toBe('off')
})
test('accepts "compact"', () => {
expect(ShowCacheStatsSchema.parse('compact')).toBe('compact')
})
test('accepts "full"', () => {
expect(ShowCacheStatsSchema.parse('full')).toBe('full')
})
test('rejects arbitrary strings', () => {
expect(() => ShowCacheStatsSchema.parse('verbose')).toThrow()
expect(() => ShowCacheStatsSchema.parse('')).toThrow()
expect(() => ShowCacheStatsSchema.parse('ON')).toThrow()
})
test('rejects non-string values', () => {
expect(() => ShowCacheStatsSchema.parse(true)).toThrow()
expect(() => ShowCacheStatsSchema.parse(1)).toThrow()
expect(() => ShowCacheStatsSchema.parse(null)).toThrow()
expect(() => ShowCacheStatsSchema.parse(undefined)).toThrow()
})
})
describe('showCacheStats — GlobalConfig type surface', () => {
test('assignable to each accepted mode without casting', () => {
const a: Pick<GlobalConfig, 'showCacheStats'> = { showCacheStats: 'off' }
const b: Pick<GlobalConfig, 'showCacheStats'> = { showCacheStats: 'compact' }
const c: Pick<GlobalConfig, 'showCacheStats'> = { showCacheStats: 'full' }
expect([a.showCacheStats, b.showCacheStats, c.showCacheStats]).toEqual([
'off',
'compact',
'full',
])
})
})
describe('showCacheStats — default applies to pre-existing configs', () => {
// Review feedback (P2 #7): "ensure the schema explicitly sets
// showCacheStats: 'compact' as the default value, not relying on the
// REPL gate's undefined handling."
//
// Config layer at src/utils/config.ts:1494 already does
// { ...createDefault(), ...parsedConfig }
// so a user who had a config file from before this PR gets the
// 'compact' default automatically on first load. These tests pin that
// behavior so a future refactor of the merge pattern surfaces the
// regression loudly.
test('legacy config without showCacheStats field merges to default', () => {
// Simulate what getConfig() produces for an old config.json that
// predates this PR: spread default first, then spread the loaded
// (incomplete) object on top.
const legacyLoadedConfig = {
// Fields typical of a pre-PR config — anything real but no
// showCacheStats. The exact shape doesn't matter; we're testing
// the merge semantics.
theme: 'dark' as const,
}
const merged = {
...DEFAULT_GLOBAL_CONFIG,
...legacyLoadedConfig,
}
expect(merged.showCacheStats).toBe('compact')
})
test('user-set value overrides default via merge', () => {
// Counterpart: if the user has explicitly set a value, the merge
// must preserve it (defaults must NOT clobber user intent).
const userConfig = { showCacheStats: 'off' as const }
const merged = {
...DEFAULT_GLOBAL_CONFIG,
...userConfig,
}
expect(merged.showCacheStats).toBe('off')
})
test('REPL gate fallback kicks in only when mode is undefined', () => {
// Belt-and-suspenders from REPL.tsx:3031 — `?? 'compact'` after the
// config read. Simulates the code path in case a pathological config
// read returns an empty object and skips the merge entirely.
const corruptConfigRead: Partial<GlobalConfig> = {}
const mode = corruptConfigRead.showCacheStats ?? 'compact'
expect(mode).toBe('compact')
// Explicit 'off' is preserved — fallback must not clobber user intent.
const explicitOff: Partial<GlobalConfig> = { showCacheStats: 'off' }
const modeOff = explicitOff.showCacheStats ?? 'compact'
expect(modeOff).toBe('off')
})
})

View File

@@ -179,6 +179,9 @@ export type EditorMode = 'emacs' | (typeof EDITOR_MODES)[number]
export type DiffTool = 'terminal' | 'auto'
export type ShowCacheStatsMode = 'off' | 'compact' | 'full'
export const SHOW_CACHE_STATS_MODES = ['off', 'compact', 'full'] as const satisfies readonly ShowCacheStatsMode[]
export type OutputStyle = string
export type Providers = typeof PROVIDERS[number]
@@ -246,6 +249,11 @@ export type GlobalConfig = {
autoCompactEnabled: boolean // Controls whether auto-compact is enabled
toolHistoryCompressionEnabled: boolean // Compress old tool_result content for small-context providers
showTurnDuration: boolean // Controls whether to show turn duration message (e.g., "Cooked for 1m 6s")
// Controls whether to show per-query cache hit/miss stats at the end of each turn.
// 'off' — no display
// 'compact' — one-line summary (e.g. "[Cache: 1.2k read • hit 12%]")
// 'full' — breakdown (read / created / hit-rate) per query
showCacheStats: ShowCacheStatsMode
/**
* @deprecated Use settings.env instead.
*/
@@ -628,6 +636,7 @@ function createDefaultGlobalConfig(): GlobalConfig {
autoCompactEnabled: true,
toolHistoryCompressionEnabled: true,
showTurnDuration: true,
showCacheStats: 'compact',
hasSeenTasksHint: false,
hasUsedStash: false,
hasUsedBackgroundTask: false,
@@ -677,6 +686,7 @@ export const GLOBAL_CONFIG_KEYS = [
'autoCompactEnabled',
'toolHistoryCompressionEnabled',
'showTurnDuration',
'showCacheStats',
'diffTool',
'env',
'tipsHistory',