diff --git a/cmd/thv/app/llm.go b/cmd/thv/app/llm.go index 13b41341e3..c0d0978cca 100644 --- a/cmd/thv/app/llm.go +++ b/cmd/thv/app/llm.go @@ -93,11 +93,15 @@ func addLLMConnectionFlags(cmd *cobra.Command, opts *llm.SetOptions) { // field unchanged (nil pointer = "not provided"). Shared by "config set" and // "setup" so both commands treat these flags identically. func applyChangedLLMFlags( - cmd *cobra.Command, opts *llm.SetOptions, tlsSkipVerify, bedrockCompat, enable1M bool, models []string, + cmd *cobra.Command, opts *llm.SetOptions, + tlsSkipVerify, extendedTTLCache, bedrockCompat, enable1M bool, models []string, ) { if cmd.Flags().Changed("tls-skip-verify") { opts.TLSSkipVerify = &tlsSkipVerify } + if cmd.Flags().Changed("extended-ttl-cache") { + opts.ExtendedTTLCache = &extendedTTLCache + } if cmd.Flags().Changed("bedrock-compat") { opts.BedrockCompat = &bedrockCompat } @@ -111,11 +115,12 @@ func applyChangedLLMFlags( func newConfigSetCommand() *cobra.Command { var ( - opts llm.SetOptions - tlsSkipVerify bool - bedrockCompat bool - enable1M bool - models []string + opts llm.SetOptions + tlsSkipVerify bool + extendedTTLCache bool + bedrockCompat bool + enable1M bool + models []string ) cmd := &cobra.Command{ @@ -130,7 +135,7 @@ Example: --client-id my-client-id`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - applyChangedLLMFlags(cmd, &opts, tlsSkipVerify, bedrockCompat, enable1M, models) + applyChangedLLMFlags(cmd, &opts, tlsSkipVerify, extendedTTLCache, bedrockCompat, enable1M, models) return config.UpdateConfig(func(c *config.Config) error { return c.LLM.SetFields(opts) }) @@ -140,6 +145,9 @@ Example: addLLMConnectionFlags(cmd, &opts) cmd.Flags().BoolVar(&tlsSkipVerify, "tls-skip-verify", false, "Skip TLS certificate verification for the upstream gateway (local dev only; use --tls-skip-verify=false to clear)") + cmd.Flags().BoolVar(&extendedTTLCache, "extended-ttl-cache", false, + "Persist the one-hour prompt-cache lifetime for clients that support it. Applied by \"thv llm setup\". "+ + "Use --extended-ttl-cache=false to clear.") cmd.Flags().BoolVar(&bedrockCompat, "bedrock-compat", false, "Persist Bedrock compatibility for Claude Code (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 + per-tier "+ "Bedrock model IDs). Applied by \"thv llm setup\". Use --bedrock-compat=false to clear.") @@ -263,6 +271,7 @@ func newLLMSetupCommand() *cobra.Command { var ( opts llm.SetOptions tlsSkipVerify bool + extendedTTLCache bool bedrockCompat bool enable1M bool targetClient string @@ -318,7 +327,7 @@ Re-running is idempotent and uses the cached token (no browser prompt). Run "thv llm teardown" to revert all changes.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - applyChangedLLMFlags(cmd, &opts, tlsSkipVerify, bedrockCompat, enable1M, models) + applyChangedLLMFlags(cmd, &opts, tlsSkipVerify, extendedTTLCache, bedrockCompat, enable1M, models) cm, err := client.NewClientManager() if err != nil { return fmt.Errorf("initializing client manager: %w", err) @@ -340,6 +349,9 @@ Run "thv llm teardown" to revert all changes.`, "For direct-mode tools (Claude Code, Gemini CLI) this sets NODE_TLS_REJECT_UNAUTHORIZED=0, "+ "disabling TLS for ALL of that tool's outbound connections. "+ "For proxy-mode tools only the proxy-to-gateway connection is affected.") + cmd.Flags().BoolVar(&extendedTTLCache, "extended-ttl-cache", false, + "Request the one-hour prompt-cache lifetime from each client that supports it. Persisted, so a later plain "+ + "\"thv llm setup\" re-applies it; clear with --extended-ttl-cache=false.") cmd.Flags().StringVar(&anthropicPathPrefix, "anthropic-path-prefix", "", "Path prefix appended to the gateway URL when writing ANTHROPIC_BASE_URL for direct-mode tools "+ "(e.g. /anthropic). When omitted, the gateway is probed automatically.") @@ -480,6 +492,18 @@ func (a *clientManagerAdapter) LLMGatewayModeFor(clientType string) string { return a.cm.LLMGatewayModeFor(client.ClientApp(clientType)) } +func (a *clientManagerAdapter) SupportsExtendedTTLCache(clientType string) bool { + return a.cm.SupportsExtendedTTLCache(client.ClientApp(clientType)) +} + +func (a *clientManagerAdapter) ExtendedTTLCacheConflict(clientType string) (string, error) { + return a.cm.ExtendedTTLCacheConflict(client.ClientApp(clientType)) +} + +func (a *clientManagerAdapter) ClearExtendedTTLCache(clientType, configPath string) error { + return a.cm.ClearExtendedTTLCache(client.ClientApp(clientType), configPath) +} + func (a *clientManagerAdapter) IsManaged(clientType string) bool { return a.cm.IsManaged(client.ClientApp(clientType)) } diff --git a/cmd/thv/app/llm_test.go b/cmd/thv/app/llm_test.go index 534382f268..b55f7a6e74 100644 --- a/cmd/thv/app/llm_test.go +++ b/cmd/thv/app/llm_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "errors" + "fmt" "os" "path/filepath" "runtime" @@ -47,6 +48,28 @@ func llmProvider(t *testing.T, llmCfg llm.Config) config.Provider { // Use it in tests that don't exercise the authentication path. var noopLogin llm.LoginFunc = func(context.Context, *llm.Config) error { return nil } +func TestConfigSetCommand_ExtendedTTLCacheFlagWiring(t *testing.T) { + t.Parallel() + + for _, enabled := range []bool{true, false} { + t.Run(fmt.Sprintf("enabled=%t", enabled), func(t *testing.T) { + t.Parallel() + cmd := newConfigSetCommand() + flag := cmd.Flags().Lookup("extended-ttl-cache") + require.NotNil(t, flag) + require.NoError(t, cmd.Flags().Set("extended-ttl-cache", fmt.Sprintf("%t", enabled))) + + parsed, err := cmd.Flags().GetBool("extended-ttl-cache") + require.NoError(t, err) + var opts llm.SetOptions + applyChangedLLMFlags(cmd, &opts, false, parsed, false, false, nil) + + require.NotNil(t, opts.ExtendedTTLCache) + assert.Equal(t, enabled, *opts.ExtendedTTLCache) + }) + } +} + // errOnUpdateProvider wraps a base Provider but returns a fixed error from // UpdateConfig. Used to inject deterministic failures without relying on // filesystem permission tricks that are unreliable on Windows. diff --git a/docs/cli/thv_llm_config_set.md b/docs/cli/thv_llm_config_set.md index c732df1cbd..33e68bb644 100644 --- a/docs/cli/thv_llm_config_set.md +++ b/docs/cli/thv_llm_config_set.md @@ -35,6 +35,7 @@ thv llm config set [flags] --callback-port int OIDC callback port (omit to keep current; default: ephemeral) --client-id string OIDC client ID --enable-1m With Bedrock compat, opt into the 1M context window by appending [1m] to opus/sonnet model IDs. + --extended-ttl-cache Persist the one-hour prompt-cache lifetime for clients that support it. Applied by "thv llm setup". Use --extended-ttl-cache=false to clear. --gateway-url string LLM gateway base URL (must use HTTPS) -h, --help help for set --issuer string OIDC issuer URL diff --git a/docs/cli/thv_llm_setup.md b/docs/cli/thv_llm_setup.md index c2c0e50d45..a2f24df4a8 100644 --- a/docs/cli/thv_llm_setup.md +++ b/docs/cli/thv_llm_setup.md @@ -71,6 +71,7 @@ thv llm setup [flags] --client string Configure only this AI tool by name (e.g. claude-code, cursor, codex). Omit to configure all detected tools. --client-id string OIDC client ID --enable-1m With --bedrock-compat, append the [1m] suffix to the opus and sonnet model IDs to opt into the 1M-token context window on Bedrock (never haiku, which is 200K). Off by default. + --extended-ttl-cache Request the one-hour prompt-cache lifetime from each client that supports it. Persisted, so a later plain "thv llm setup" re-applies it; clear with --extended-ttl-cache=false. --gateway-url string LLM gateway base URL (must use HTTPS) -h, --help help for setup --issuer string OIDC issuer URL diff --git a/pkg/client/config.go b/pkg/client/config.go index 2505339c29..491a9bdaf5 100644 --- a/pkg/client/config.go +++ b/pkg/client/config.go @@ -167,10 +167,11 @@ const ( // - ValueField names which ApplyConfig field to write. Valid values: // "GatewayURL", "AnthropicBaseURL", "ProxyBaseURL", "ProxyOrigin", // "TokenHelperCommand", "PlaceholderAPIKey", "ClaudeCodeHelperTTLMillis", -// "NodeTLSRejectUnauthorized", "BedrockDisableExperimentalBetas", -// "BedrockHaikuModel", "BedrockOpusModel", "BedrockSonnetModel". An -// unrecognised ValueField is a programming error and causes -// ConfigureLLMGateway to return an error. +// "NodeTLSRejectUnauthorized", "ExtendedTTLCache", +// "ExtendedTTLCacheLegacy", +// "BedrockDisableExperimentalBetas", "BedrockHaikuModel", +// "BedrockOpusModel", "BedrockSonnetModel". An unrecognised ValueField is +// a programming error and causes ConfigureLLMGateway to return an error. // - Literal is written verbatim into the settings key (e.g. a fixed auth // type string). Use Literal instead of ValueField for constant values so // that typos in ValueField are caught as errors rather than silently @@ -184,8 +185,9 @@ type LLMGatewayKeySpec struct { JSONPointer string // RFC 6901 path // ValueField: "GatewayURL" | "AnthropicBaseURL" | "ProxyBaseURL" | "ProxyOrigin" | // "TokenHelperCommand" | "PlaceholderAPIKey" | "ClaudeCodeHelperTTLMillis" | - // "NodeTLSRejectUnauthorized" | "BedrockDisableExperimentalBetas" | - // "BedrockHaikuModel" | "BedrockOpusModel" | "BedrockSonnetModel" + // "NodeTLSRejectUnauthorized" | "ExtendedTTLCache" | "ExtendedTTLCacheLegacy" | + // "BedrockDisableExperimentalBetas" | "BedrockHaikuModel" | + // "BedrockOpusModel" | "BedrockSonnetModel" ValueField string Literal string // constant value written verbatim; mutually exclusive with ValueField ClearWhenEmpty bool // remove the key when the resolved value is empty (ignored for Literal) @@ -253,6 +255,8 @@ type clientAppConfig struct { // LLMGatewayMode identifies the gateway integration strategy (direct token // helper, proxy, credential helper, or Codex auth), or "" when unsupported. LLMGatewayMode string + // SupportsExtendedTTLCache declares support for the conditional TTL keys. + SupportsExtendedTTLCache bool // LLMBinaryName is the executable name looked up via exec.LookPath to // confirm the tool is actually installed (not just a leftover config // directory). Leave empty for tools that are not on $PATH (e.g. macOS @@ -535,10 +539,11 @@ var supportedClientIntegrations = []clientAppConfig{ PluginsGlobalPath: []string{".claude", "plugins"}, PluginsProjectPath: []string{".claude", "plugins"}, // LLM gateway: patches ~/.claude/settings.json (different from the MCP .claude.json) - LLMGatewayMode: llmgateway.ModeDirect, - LLMBinaryName: "claude", - LLMSettingsFile: "settings.json", - LLMSettingsRelPath: []string{".claude"}, + LLMGatewayMode: llmgateway.ModeDirect, + SupportsExtendedTTLCache: true, + LLMBinaryName: "claude", + LLMSettingsFile: "settings.json", + LLMSettingsRelPath: []string{".claude"}, LLMGatewayKeys: []LLMGatewayKeySpec{ {JSONPointer: "/apiKeyHelper", ValueField: "TokenHelperCommand"}, {JSONPointer: "/env/ANTHROPIC_BASE_URL", ValueField: "AnthropicBaseURL"}, @@ -549,6 +554,12 @@ var supportedClientIntegrations = []clientAppConfig{ // NODE_TLS_REJECT_UNAUTHORIZED is only written when --tls-skip-verify is set. // ClearWhenEmpty ensures it is removed when the flag is later cleared. {JSONPointer: "/env/NODE_TLS_REJECT_UNAUTHORIZED", ValueField: "NodeTLSRejectUnauthorized", ClearWhenEmpty: true}, + // Current Claude Code versions expose separate controls for the main + // conversation and auxiliary requests. ENABLE_PROMPT_CACHING_1H is the + // fallback for versions that predate those per-bucket settings. + {JSONPointer: "/promptCacheTtl", ValueField: "ExtendedTTLCache", ClearWhenEmpty: true}, + {JSONPointer: "/subagentPromptCacheTtl", ValueField: "ExtendedTTLCache", ClearWhenEmpty: true}, + {JSONPointer: "/env/ENABLE_PROMPT_CACHING_1H", ValueField: "ExtendedTTLCacheLegacy", ClearWhenEmpty: true}, // Bedrock-compat keys (written only with --bedrock-compat). Bedrock rejects // Claude Code's experimental anthropic-beta headers, so betas are disabled; // the per-tier model IDs pin Bedrock inference-profile IDs. All use diff --git a/pkg/client/llm_gateway.go b/pkg/client/llm_gateway.go index a5cdd093fd..f138447445 100644 --- a/pkg/client/llm_gateway.go +++ b/pkg/client/llm_gateway.go @@ -9,6 +9,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "strconv" "strings" @@ -202,6 +203,29 @@ func (cm *ClientManager) RevertLLMGateway(clientType ClientApp, configPath strin // JSON-Pointer-based revert path shared by every LLM-gateway mode except // ModeCredentialHelper and ModeCodexAuth (which use dedicated writers). func revertJSONPointerGateway(appCfg *clientAppConfig, configPath string) error { + return revertJSONPointerSpecs(configPath, appCfg.LLMGatewayKeys) +} + +// ClearExtendedTTLCache removes ToolHive-managed extended-TTL keys. +func (cm *ClientManager) ClearExtendedTTLCache(clientType ClientApp, configPath string) error { + appCfg := cm.lookupClientAppConfig(clientType) + if appCfg == nil { + return fmt.Errorf("unknown client %q", clientType) + } + if !appCfg.SupportsExtendedTTLCache { + return nil + } + + specs := make([]LLMGatewayKeySpec, 0, len(appCfg.LLMGatewayKeys)) + for _, spec := range appCfg.LLMGatewayKeys { + if spec.ValueField == "ExtendedTTLCache" || spec.ValueField == "ExtendedTTLCacheLegacy" { + specs = append(specs, spec) + } + } + return revertJSONPointerSpecs(configPath, specs) +} + +func revertJSONPointerSpecs(configPath string, specs []LLMGatewayKeySpec) error { // Guard against a missing file (or deleted parent directory) before trying // to acquire the lock — WithFileLock creates configPath+".lock", which // fails when the directory no longer exists. @@ -232,7 +256,7 @@ func revertJSONPointerGateway(appCfg *clientAppConfig, configPath string) error return fmt.Errorf("standardizing %s: %w", configPath, err) } - for _, spec := range appCfg.LLMGatewayKeys { + for _, spec := range specs { // Skip keys that are already absent — avoids brittle error-string matching. if !jsonPointerExists(standardized, spec.JSONPointer) { continue @@ -260,6 +284,187 @@ func (cm *ClientManager) IsLLMGatewaySupported(clientType ClientApp) bool { return cfg != nil && cfg.LLMGatewayMode != "" } +// SupportsExtendedTTLCache reports whether clientType declares a prompt-cache +// lifetime control in the client registry. +func (cm *ClientManager) SupportsExtendedTTLCache(clientType ClientApp) bool { + cfg := cm.lookupClientAppConfig(clientType) + return cfg != nil && cfg.SupportsExtendedTTLCache +} + +var promptCacheEnvironmentControls = [...]string{ + "FORCE_PROMPT_CACHING_5M", + "CLAUDE_CODE_PROMPT_CACHE_TTL", + "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL", +} + +// ExtendedTTLCacheConflict reports a locally discoverable five-minute override. +func (cm *ClientManager) ExtendedTTLCacheConflict(clientType ClientApp) (string, error) { + cfg := cm.lookupClientAppConfig(clientType) + if cfg == nil || !cfg.SupportsExtendedTTLCache { + return "", nil + } + + controls := make(map[string]promptCacheControl, 5) + workingDir, _ := os.Getwd() // Failure only skips project-scope inspection. + settingsPaths, err := extendedTTLCacheSettingsPaths( + cm.buildLLMSettingsPath(cfg), workingDir, claudeCodeManagedSettingsDir(runtime.GOOS), + ) + if err != nil { + return "", err + } + for _, path := range settingsPaths { + if err := mergePromptCacheControls(path, controls); err != nil { + return "", err + } + } + for _, name := range promptCacheEnvironmentControls { + if value, ok := os.LookupEnv(name); ok { + controls[name] = promptCacheControl{strings.TrimSpace(value), "the process environment"} + } + } + + return promptCacheConflictDescription(controls), nil +} + +type promptCacheControl struct { + value string + source string +} + +func extendedTTLCacheSettingsPaths(userSettings, workingDir, managedDir string) ([]string, error) { + paths := []string{userSettings} + if workingDir != "" { + workingDir = gitProjectRoot(workingDir) + paths = append(paths, + filepath.Join(workingDir, ".claude", "settings.json"), + filepath.Join(workingDir, ".claude", "settings.local.json"), + ) + } + if managedDir == "" { + return paths, nil + } + + paths = append(paths, filepath.Join(managedDir, "managed-settings.json")) + dropInDir := filepath.Join(managedDir, "managed-settings.d") + dropIns, err := os.ReadDir(dropInDir) + if err != nil { + if os.IsNotExist(err) { + return paths, nil + } + return nil, fmt.Errorf("reading Claude Code managed settings drop-in directory: %w", err) + } + for _, entry := range dropIns { + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || filepath.Ext(entry.Name()) != ".json" { + continue + } + paths = append(paths, filepath.Join(dropInDir, entry.Name())) + } + return paths, nil +} + +func gitProjectRoot(path string) string { + fallback := path + for { + if _, err := os.Stat(filepath.Join(path, ".git")); err == nil { + return path + } + parent := filepath.Dir(path) + if parent == path { + return fallback + } + path = parent + } +} + +func mergePromptCacheControls(path string, controls map[string]promptCacheControl) error { + content, err := os.ReadFile(path) // #nosec G304 -- paths are registered client settings locations + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("reading %s: %w", path, err) + } + if len(content) == 0 { + return nil + } + + v, err := hujson.Parse(content) + if err != nil { + return fmt.Errorf("parsing %s: %w", path, err) + } + standardized, err := hujson.Standardize(v.Pack()) + if err != nil { + return fmt.Errorf("standardizing %s: %w", path, err) + } + var settings map[string]any + if err := json.Unmarshal(standardized, &settings); err != nil { + return fmt.Errorf("decoding %s: %w", path, err) + } + env, _ := settings["env"].(map[string]any) + for _, name := range promptCacheEnvironmentControls { + value, ok := env[name].(string) + if !ok { + continue + } + controls[name] = promptCacheControl{strings.TrimSpace(value), path} + } + for _, name := range []string{"promptCacheTtl", "subagentPromptCacheTtl"} { + if value, ok := settings[name].(string); ok { + controls[name] = promptCacheControl{strings.TrimSpace(value), path} + } + } + return nil +} + +func promptCacheConflictDescription(controls map[string]promptCacheControl) string { + if force, ok := controls["FORCE_PROMPT_CACHING_5M"]; ok && force.value == "1" { + return formatPromptCacheConflict("FORCE_PROMPT_CACHING_5M", force) + } + + buckets := []struct { + environmentVariable string + setting string + }{ + {environmentVariable: "CLAUDE_CODE_PROMPT_CACHE_TTL", setting: "promptCacheTtl"}, + {environmentVariable: "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL", setting: "subagentPromptCacheTtl"}, + } + for _, bucket := range buckets { + if envValue, ok := controls[bucket.environmentVariable]; ok { + switch strings.ToLower(envValue.value) { + case "5m": + return formatPromptCacheConflict(bucket.environmentVariable, envValue) + case "1h": + continue + } + } + if setting, ok := controls[bucket.setting]; ok && strings.EqualFold(setting.value, "5m") { + return formatPromptCacheConflict(bucket.setting, setting) + } + } + return "" +} + +func formatPromptCacheConflict(name string, control promptCacheControl) string { + return fmt.Sprintf("%s=%s in %s", name, control.value, control.source) +} + +func claudeCodeManagedSettingsDir(goos string) string { + switch goos { + case "darwin": + return filepath.Join(string(filepath.Separator), "Library", "Application Support", "ClaudeCode") + case "linux": + return filepath.Join(string(filepath.Separator), "etc", "claude-code") + case "windows": + programFiles := os.Getenv("ProgramFiles") + if programFiles == "" { + programFiles = `C:\Program Files` + } + return filepath.Join(programFiles, "ClaudeCode") + default: + return "" + } +} + // IsManaged reports whether an MDM/managed-preferences profile is present for // the given client. When true, the client reads config from the managed profile // and ignores the local config "thv llm setup" writes, so setup warns the user. @@ -415,6 +620,16 @@ func resolveApplyConfigField(valueField string, cfg llmgateway.ApplyConfig) (str return "0", true } return "", true + case "ExtendedTTLCache": + if cfg.ExtendedTTLCache { + return "1h", true + } + return "", true + case "ExtendedTTLCacheLegacy": + if cfg.ExtendedTTLCache { + return "1", true + } + return "", true default: return resolveBedrockField(valueField, cfg) } diff --git a/pkg/client/llm_gateway_test.go b/pkg/client/llm_gateway_test.go index 9f7b0b08f4..715af2aa15 100644 --- a/pkg/client/llm_gateway_test.go +++ b/pkg/client/llm_gateway_test.go @@ -395,6 +395,395 @@ func TestConfigureLLMGateway_ClaudeCodeBedrock(t *testing.T) { }) } +func TestConfigureLLMGateway_ClaudeCodeExtendedTTLCache(t *testing.T) { + t.Parallel() + + cachePointers := map[string]string{ + "/promptCacheTtl": "1h", + "/subagentPromptCacheTtl": "1h", + "/env/ENABLE_PROMPT_CACHING_1H": "1", + } + baseCfg := llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + TokenHelperCommand: `thv llm token`, + } + + t.Run("enabled writes both request buckets and legacy fallback", func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + + cfg := baseCfg + cfg.ExtendedTTLCache = true + path, err := cm.ConfigureLLMGateway(ClaudeCode, cfg) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + for ptr, want := range cachePointers { + got, ok := jsonPointerGet(data, ptr) + assert.True(t, ok, "pointer %q missing", ptr) + assert.Equal(t, want, got, "wrong value at %q", ptr) + } + }) + + t.Run("explicit false removes previously written keys", func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + + enabledCfg := baseCfg + enabledCfg.ExtendedTTLCache = true + path, err := cm.ConfigureLLMGateway(ClaudeCode, enabledCfg) + require.NoError(t, err) + _, err = cm.ConfigureLLMGateway(ClaudeCode, baseCfg) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + for ptr := range cachePointers { + _, ok := jsonPointerGet(data, ptr) + assert.False(t, ok, "pointer %q should be absent after disabling extended TTL", ptr) + } + }) + + t.Run("teardown removes all extended TTL keys", func(t *testing.T) { + t.Parallel() + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + + cfg := baseCfg + cfg.ExtendedTTLCache = true + path, err := cm.ConfigureLLMGateway(ClaudeCode, cfg) + require.NoError(t, err) + require.NoError(t, cm.RevertLLMGateway(ClaudeCode, path)) + + data, err := os.ReadFile(path) + require.NoError(t, err) + for ptr := range cachePointers { + _, ok := jsonPointerGet(data, ptr) + assert.False(t, ok, "pointer %q should be absent after teardown", ptr) + } + }) +} + +func TestClearExtendedTTLCache_PreservesOtherClaudeCodeSettings(t *testing.T) { + t.Parallel() + + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".claude"), 0o700)) + path, err := cm.ConfigureLLMGateway(ClaudeCode, llmgateway.ApplyConfig{ + GatewayURL: "https://gw.example.com", + TokenHelperCommand: "thv llm token", + ExtendedTTLCache: true, + }) + require.NoError(t, err) + + require.NoError(t, cm.ClearExtendedTTLCache(ClaudeCode, path)) + data, err := os.ReadFile(path) + require.NoError(t, err) + for _, ptr := range []string{ + "/promptCacheTtl", + "/subagentPromptCacheTtl", + "/env/ENABLE_PROMPT_CACHING_1H", + } { + _, ok := jsonPointerGet(data, ptr) + assert.False(t, ok, "pointer %q should be removed", ptr) + } + got, ok := jsonPointerGet(data, "/apiKeyHelper") + assert.True(t, ok) + assert.Equal(t, "thv llm token", got) + got, ok = jsonPointerGet(data, "/env/ANTHROPIC_BASE_URL") + assert.True(t, ok) + assert.Equal(t, "https://gw.example.com", got) +} + +func TestClientManager_ExtendedTTLCacheSupport(t *testing.T) { + t.Parallel() + + cm := NewTestClientManager(t.TempDir(), nil, supportedClientIntegrations, nil) + assert.True(t, cm.SupportsExtendedTTLCache(ClaudeCode)) + for _, unsupported := range []ClientApp{ + ClientApp(ClaudeDesktop), Codex, GeminiCli, Cursor, VSCode, VSCodeInsider, ClientApp(Xcode), + } { + assert.False(t, cm.SupportsExtendedTTLCache(unsupported), "%s unexpectedly supports extended TTL", unsupported) + } +} + +func TestClientManager_ExtendedTTLCacheConflict(t *testing.T) { + conflicts := []struct { + name string + variable string + value string + fromEnv bool + }{ + {name: "global five-minute override in process environment", variable: "FORCE_PROMPT_CACHING_5M", value: "1", fromEnv: true}, + {name: "main bucket five-minute override in settings", variable: "CLAUDE_CODE_PROMPT_CACHE_TTL", value: "5m"}, + {name: "auxiliary bucket five-minute override in settings", variable: "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL", value: "5m"}, + } + + for _, tt := range conflicts { + t.Run(tt.name, func(t *testing.T) { + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + for _, variable := range []string{ + "FORCE_PROMPT_CACHING_5M", + "CLAUDE_CODE_PROMPT_CACHE_TTL", + "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL", + } { + if !tt.fromEnv && variable == tt.variable { + original, existed := os.LookupEnv(variable) + require.NoError(t, os.Unsetenv(variable)) + t.Cleanup(func() { + if existed { + _ = os.Setenv(variable, original) + } + }) + continue + } + t.Setenv(variable, "non-conflicting") + } + + if tt.fromEnv { + t.Setenv(tt.variable, tt.value) + } else { + settingsDir := filepath.Join(home, ".claude") + require.NoError(t, os.MkdirAll(settingsDir, 0o700)) + settings := []byte(`{"env":{"` + tt.variable + `":"` + tt.value + `"}}`) + require.NoError(t, os.WriteFile(filepath.Join(settingsDir, "settings.json"), settings, 0o600)) + } + + conflict, err := cm.ExtendedTTLCacheConflict(ClaudeCode) + require.NoError(t, err) + assert.Contains(t, conflict, tt.variable) + }) + } + + t.Run("non-conflicting values are ignored", func(t *testing.T) { + home := t.TempDir() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + for _, variable := range []string{ + "FORCE_PROMPT_CACHING_5M", + "CLAUDE_CODE_PROMPT_CACHE_TTL", + "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL", + } { + t.Setenv(variable, "1h") + } + + conflict, err := cm.ExtendedTTLCacheConflict(ClaudeCode) + require.NoError(t, err) + assert.Empty(t, conflict) + }) +} + +func TestExtendedTTLCacheConflictSettingsScopes(t *testing.T) { + t.Parallel() + + conflicts := []struct { + name string + relativePath []string + settings string + wantControl string + }{ + { + name: "project shared top-level setting", + relativePath: []string{"project", ".claude", "settings.json"}, + settings: `{"promptCacheTtl":"5m"}`, + wantControl: "promptCacheTtl=5m", + }, + { + name: "project local top-level setting", + relativePath: []string{"project", ".claude", "settings.local.json"}, + settings: `{"subagentPromptCacheTtl":"5m"}`, + wantControl: "subagentPromptCacheTtl=5m", + }, + { + name: "managed base setting", + relativePath: []string{"managed", "managed-settings.json"}, + settings: `{"promptCacheTtl":"5m"}`, + wantControl: "promptCacheTtl=5m", + }, + { + name: "managed drop-in environment override", + relativePath: []string{"managed", "managed-settings.d", "10-cache-policy.json"}, + settings: `{"env":{"CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL":"5m"}}`, + wantControl: "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL=5m", + }, + } + + for _, tt := range conflicts { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + home := filepath.Join(root, "home") + project := filepath.Join(root, "project") + managed := filepath.Join(root, "managed") + + path := filepath.Join(append([]string{root}, tt.relativePath...)...) + writePromptCacheSettings(t, path, tt.settings) + + conflict := promptCacheConflictFromSettings(t, home, project, managed) + assert.Contains(t, conflict, tt.wantControl) + assert.Contains(t, conflict, path) + }) + } +} + +func TestPromptCacheConflictPrecedence(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + controls map[string]promptCacheControl + want string + }{ + { + name: "bucket environment one hour overrides top-level five minutes", + controls: map[string]promptCacheControl{ + "CLAUDE_CODE_PROMPT_CACHE_TTL": {value: "1h", source: "environment"}, + "promptCacheTtl": {value: "5m", source: "settings"}, + }, + }, + { + name: "bucket environment five minutes overrides top-level one hour", + controls: map[string]promptCacheControl{ + "CLAUDE_CODE_PROMPT_CACHE_TTL": {value: "5m", source: "environment"}, + "promptCacheTtl": {value: "1h", source: "settings"}, + }, + want: "CLAUDE_CODE_PROMPT_CACHE_TTL=5m in environment", + }, + { + name: "global five-minute override wins over bucket one-hour controls", + controls: map[string]promptCacheControl{ + "FORCE_PROMPT_CACHING_5M": {value: "1", source: "managed settings"}, + "CLAUDE_CODE_PROMPT_CACHE_TTL": {value: "1h", source: "environment"}, + "CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL": {value: "1h", source: "environment"}, + }, + want: "FORCE_PROMPT_CACHING_5M=1 in managed settings", + }, + { + name: "one-hour settings are non-conflicting", + controls: map[string]promptCacheControl{ + "promptCacheTtl": {value: "1h", source: "settings"}, + "subagentPromptCacheTtl": {value: "1h", source: "settings"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, promptCacheConflictDescription(tt.controls)) + }) + } +} + +func TestPromptCacheSettingsFilePrecedence(t *testing.T) { + t.Parallel() + + root := t.TempDir() + home := filepath.Join(root, "home") + project := filepath.Join(root, "project") + managed := filepath.Join(root, "managed") + + writePromptCacheSettings(t, filepath.Join(home, ".claude", "settings.json"), `{"promptCacheTtl":"5m"}`) + writePromptCacheSettings(t, filepath.Join(project, ".claude", "settings.json"), `{"promptCacheTtl":"5m"}`) + writePromptCacheSettings(t, filepath.Join(project, ".claude", "settings.local.json"), `{"promptCacheTtl":"5m"}`) + writePromptCacheSettings(t, filepath.Join(managed, "managed-settings.json"), `{"promptCacheTtl":"5m"}`) + writePromptCacheSettings(t, filepath.Join(managed, "managed-settings.d", "10-five-minutes.json"), `{"promptCacheTtl":"5m"}`) + writePromptCacheSettings(t, filepath.Join(managed, "managed-settings.d", "20-one-hour.json"), `{"promptCacheTtl":"1h"}`) + writePromptCacheSettings(t, filepath.Join(managed, "managed-settings.d", ".hidden.json"), `{"promptCacheTtl":"5m"}`) + writePromptCacheSettings(t, filepath.Join(managed, "managed-settings.d", "README.txt"), `{"promptCacheTtl":"5m"}`) + + conflict := promptCacheConflictFromSettings(t, home, project, managed) + assert.Empty(t, conflict, + "the lexically last managed JSON drop-in must override lower-precedence five-minute settings") +} + +func TestPromptCacheSettingsScopePrecedence(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lowerPath []string + higherPath []string + }{ + { + name: "project shared overrides user", + lowerPath: []string{"home", ".claude", "settings.json"}, + higherPath: []string{"project", ".claude", "settings.json"}, + }, + { + name: "project local overrides project shared", + lowerPath: []string{"project", ".claude", "settings.json"}, + higherPath: []string{"project", ".claude", "settings.local.json"}, + }, + { + name: "managed base overrides project local", + lowerPath: []string{"project", ".claude", "settings.local.json"}, + higherPath: []string{"managed", "managed-settings.json"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + home := filepath.Join(root, "home") + project := filepath.Join(root, "project") + managed := filepath.Join(root, "managed") + writePromptCacheSettings(t, filepath.Join(append([]string{root}, tt.lowerPath...)...), + `{"promptCacheTtl":"5m"}`) + writePromptCacheSettings(t, filepath.Join(append([]string{root}, tt.higherPath...)...), + `{"promptCacheTtl":"1h"}`) + + conflict := promptCacheConflictFromSettings(t, home, project, managed) + assert.Empty(t, conflict) + }) + } +} + +func TestExtendedTTLCacheSettingsPathsUsesGitRoot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, ".git"), 0o700)) + workingDir := filepath.Join(root, "cmd", "thv") + require.NoError(t, os.MkdirAll(workingDir, 0o700)) + + paths, err := extendedTTLCacheSettingsPaths("user.json", workingDir, "") + require.NoError(t, err) + assert.Equal(t, []string{ + "user.json", + filepath.Join(root, ".claude", "settings.json"), + filepath.Join(root, ".claude", "settings.local.json"), + }, paths) +} + +func promptCacheConflictFromSettings(t *testing.T, home, workingDir, managedDir string) string { + t.Helper() + cm := NewTestClientManager(home, nil, supportedClientIntegrations, nil) + cfg := cm.lookupClientAppConfig(ClaudeCode) + require.NotNil(t, cfg) + paths, err := extendedTTLCacheSettingsPaths(cm.buildLLMSettingsPath(cfg), workingDir, managedDir) + require.NoError(t, err) + controls := make(map[string]promptCacheControl) + for _, path := range paths { + require.NoError(t, mergePromptCacheControls(path, controls)) + } + return promptCacheConflictDescription(controls) +} + +func writePromptCacheSettings(t *testing.T, path, settings string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + require.NoError(t, os.WriteFile(path, []byte(settings), 0o600)) +} + // newLLMManager builds a ClientManager with a single direct-mode LLM entry // whose settings dir is homeDir/