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/. func newLLMManager(t *testing.T, clientType ClientApp, mode, dir string, ptrs, vals []string) (*ClientManager, string) { diff --git a/pkg/llm/config.go b/pkg/llm/config.go index fd3631c2d6..5c1c0ff32b 100644 --- a/pkg/llm/config.go +++ b/pkg/llm/config.go @@ -25,11 +25,14 @@ type OIDCConfig = pkgoidc.ClientConfig // Config holds all LLM gateway settings persisted under the llm: key in // ToolHive's config.yaml. type Config struct { - GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` - TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` - OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` - Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` - Bedrock BedrockConfig `yaml:"bedrock,omitempty" json:"bedrock,omitempty"` + GatewayURL string `yaml:"gateway_url,omitempty" json:"gateway_url,omitempty"` + TLSSkipVerify bool `yaml:"tls_skip_verify,omitempty" json:"tls_skip_verify,omitempty"` + // ExtendedTTLCache requests one-hour caching from supported clients. + // Its JSON field stays visible when false for config inspection. + ExtendedTTLCache bool `yaml:"extended_ttl_cache,omitempty" json:"extended_ttl_cache"` + OIDC OIDCConfig `yaml:"oidc,omitempty" json:"oidc,omitempty"` + Proxy ProxyConfig `yaml:"proxy,omitempty" json:"proxy,omitempty"` + Bedrock BedrockConfig `yaml:"bedrock,omitempty" json:"bedrock,omitempty"` // Models is the persisted, single source of truth for the model IDs applied // during setup. It feeds two consumers: credential-helper clients (Claude // Desktop) write it verbatim as inferenceModels, and — when Bedrock compat is diff --git a/pkg/llm/manage.go b/pkg/llm/manage.go index eef74295cf..38ce069793 100644 --- a/pkg/llm/manage.go +++ b/pkg/llm/manage.go @@ -38,6 +38,9 @@ func (c *Config) SetFields(opts SetOptions) error { if opts.TLSSkipVerify != nil { c.TLSSkipVerify = *opts.TLSSkipVerify } + if opts.ExtendedTTLCache != nil { + c.ExtendedTTLCache = *opts.ExtendedTTLCache + } if opts.BedrockCompat != nil { c.Bedrock.Compat = *opts.BedrockCompat } @@ -59,13 +62,14 @@ func (c *Config) SetFields(opts SetOptions) error { // field unchanged. TLSSkipVerify uses a pointer so that false can be // distinguished from "not provided" (enabling explicit clear via config set). type SetOptions struct { - GatewayURL string - Issuer string - ClientID string - Audience string - ProxyPort int - CallbackPort int - TLSSkipVerify *bool // nil = not provided; &false = explicitly disable + GatewayURL string + Issuer string + ClientID string + Audience string + ProxyPort int + CallbackPort int + TLSSkipVerify *bool // nil = not provided; &false = explicitly disable + ExtendedTTLCache *bool // nil = not provided; &false = explicitly disable // BedrockCompat and Enable1M use pointers so false can be distinguished from // "not provided" (enabling explicit clear via config set). See BedrockConfig. BedrockCompat *bool @@ -122,6 +126,7 @@ func (c *Config) Show(w io.Writer) error { } writef("Proxy Port: %d\n", c.EffectiveProxyPort()) writef("Scopes: %v\n", c.OIDC.EffectiveScopes()) + writef("Extended TTL cache: %t\n", c.ExtendedTTLCache) if c.TLSSkipVerify { writef("TLS Skip Verify: true (WARNING: certificate verification disabled)\n") } diff --git a/pkg/llm/manage_test.go b/pkg/llm/manage_test.go index b253f32b18..d8a246157a 100644 --- a/pkg/llm/manage_test.go +++ b/pkg/llm/manage_test.go @@ -6,13 +6,16 @@ package llm import ( "bytes" "context" + "encoding/json" "errors" + "fmt" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "gopkg.in/yaml.v3" "github.com/stacklok/toolhive/pkg/secrets" secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks" @@ -121,6 +124,23 @@ func TestConfig_SetFields(t *testing.T) { opts: SetOptions{}, want: Config{GatewayURL: "https://gw.example.com", TLSSkipVerify: true}, }, + { + name: "ExtendedTTLCache pointer true sets field", + opts: SetOptions{ExtendedTTLCache: boolPtr(true)}, + want: Config{ExtendedTTLCache: true}, + }, + { + name: "ExtendedTTLCache pointer false clears field", + base: Config{ExtendedTTLCache: true}, + opts: SetOptions{ExtendedTTLCache: boolPtr(false)}, + want: Config{}, + }, + { + name: "nil ExtendedTTLCache pointer leaves existing value unchanged", + base: Config{ExtendedTTLCache: true}, + opts: SetOptions{}, + want: Config{ExtendedTTLCache: true}, + }, } for _, tt := range tests { @@ -156,6 +176,9 @@ func TestConfig_SetFields(t *testing.T) { if cfg.TLSSkipVerify != tt.want.TLSSkipVerify { t.Errorf("TLSSkipVerify = %v, want %v", cfg.TLSSkipVerify, tt.want.TLSSkipVerify) } + if cfg.ExtendedTTLCache != tt.want.ExtendedTTLCache { + t.Errorf("ExtendedTTLCache = %v, want %v", cfg.ExtendedTTLCache, tt.want.ExtendedTTLCache) + } }) } } @@ -365,6 +388,23 @@ func TestConfig_Show(t *testing.T) { }, absent: []string{"TLS Skip Verify"}, }, + { + name: "extended TTL cache true state is visible", + cfg: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + ExtendedTTLCache: true, + }, + contains: []string{"Extended TTL cache: true"}, + }, + { + name: "extended TTL cache false state is visible", + cfg: Config{ + GatewayURL: "https://gw.example.com", + OIDC: OIDCConfig{Issuer: "https://auth.example.com", ClientID: "client1"}, + }, + contains: []string{"Extended TTL cache: false"}, + }, } for _, tt := range tests { @@ -388,3 +428,31 @@ func TestConfig_Show(t *testing.T) { }) } } + +func TestConfig_JSONShowsExtendedTTLCacheState(t *testing.T) { + t.Parallel() + + for _, enabled := range []bool{false, true} { + t.Run(fmt.Sprintf("enabled=%t", enabled), func(t *testing.T) { + t.Parallel() + data, err := json.Marshal(Config{ExtendedTTLCache: enabled}) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(data, &got)) + assert.Equal(t, enabled, got["extended_ttl_cache"]) + }) + } +} + +func TestConfig_ExtendedTTLCacheYAMLRoundTrip(t *testing.T) { + t.Parallel() + + data, err := yaml.Marshal(Config{ExtendedTTLCache: true}) + require.NoError(t, err) + assert.Contains(t, string(data), "extended_ttl_cache: true") + + var got Config + require.NoError(t, yaml.Unmarshal(data, &got)) + assert.True(t, got.ExtendedTTLCache) +} diff --git a/pkg/llm/setup.go b/pkg/llm/setup.go index 563bc7a2b2..5d7a3a3de7 100644 --- a/pkg/llm/setup.go +++ b/pkg/llm/setup.go @@ -35,6 +35,11 @@ type GatewayManager interface { ConfigureLLMGateway(clientType string, cfg llmgateway.ApplyConfig) (string, error) // LLMGatewayModeFor returns "direct", "proxy", or "" for the given client. LLMGatewayModeFor(clientType string) string + // SupportsExtendedTTLCache reports client prompt-cache control support. + SupportsExtendedTTLCache(clientType string) bool + // ExtendedTTLCacheConflict names an overriding five-minute setting. + ExtendedTTLCacheConflict(clientType string) (string, error) + ClearExtendedTTLCache(clientType, configPath string) error // IsManaged reports whether a managed-preferences profile overrides the // client's local config (so the config setup writes would be ignored). IsManaged(clientType string) bool @@ -81,6 +86,10 @@ func Setup( inlineOpts SetOptions, anthropicPathPrefix string, anthropicPathPrefixSet bool, targetClient string, lazy bool, ) error { + if err := clearExplicitlyDisabledExtendedTTLCache(gm, provider, inlineOpts); err != nil { + return err + } + llmCfg, err := prepareSetupConfig(provider, inlineOpts) if err != nil { return err @@ -145,7 +154,8 @@ func Setup( configured, err := configureDetectedToolsWithDiscovery( out, errOut, gm, detected, llmCfg.GatewayURL, proxyBaseURL, - tokenHelperPath, tokenHelperArgs, llmCfg.TLSSkipVerify, anthropicPrefix, llmCfg.Models, discoveredModels, llmCfg.Bedrock, + tokenHelperPath, tokenHelperArgs, llmCfg.TLSSkipVerify, anthropicPrefix, + llmCfg.Models, discoveredModels, llmCfg.ExtendedTTLCache, llmCfg.Bedrock, ) if err != nil { return err @@ -186,6 +196,31 @@ func Setup( return nil } +func clearExplicitlyDisabledExtendedTTLCache( + gm GatewayManager, provider ConfigUpdater, inlineOpts SetOptions, +) error { + if inlineOpts.ExtendedTTLCache == nil || *inlineOpts.ExtendedTTLCache { + return nil + } + + for _, tool := range provider.GetLLMConfig().ConfiguredTools { + if !gm.SupportsExtendedTTLCache(tool.Tool) { + continue + } + if err := gm.ClearExtendedTTLCache(tool.Tool, tool.ConfigPath); err != nil { + return fmt.Errorf("clearing extended prompt-cache TTL from %s: %w", tool.Tool, err) + } + } + + if err := provider.UpdateLLMConfig(func(c *Config) error { + c.ExtendedTTLCache = false + return nil + }); err != nil { + return fmt.Errorf("persisting disabled extended prompt-cache TTL: %w", err) + } + return nil +} + func prepareSetupConfig(provider ConfigUpdater, inlineOpts SetOptions) (Config, error) { llmCfg := provider.GetLLMConfig() // Apply inline flags in-memory so login and tool detection use the merged @@ -458,6 +493,13 @@ func setupClients( const ( vsCodeClient = "vscode" vsCodeInsiderClient = "vscode-insider" + // claudeCodeClient is the canonical client identifier for Claude Code. + // Declared here as a string literal because pkg/llm does not import + // pkg/client (which owns the ClientApp constant) to avoid an import cycle. + claudeCodeClient = "claude-code" + // promptCacheVerificationCommand reports which TTL Claude Code used under + // usage.cache_creation in the command's JSON output. + promptCacheVerificationCommand = `claude -p "hello" --output-format json` ) func isVSCodeClient(clientType string) bool { @@ -580,11 +622,6 @@ func discoverGatewayModels(ctx context.Context, cfg Config) ([]string, error) { return models, nil } -// claudeCodeClient is the canonical client identifier for Claude Code. Declared -// here as a string literal because pkg/llm does not import pkg/client (which -// owns the ClientApp constant) to avoid an import cycle. -const claudeCodeClient = "claude-code" - // Default Bedrock inference-profile model IDs written for Claude Code in // bedrock-compat mode when --models does not override a tier. These track the // current generation and are expected to be bumped periodically; users override @@ -682,7 +719,8 @@ func warnBedrockNoEffect(errOut io.Writer, opts SetOptions, effectiveCompat bool func configureDetectedToolsWithDiscovery( out, errOut io.Writer, gm GatewayManager, detected []string, gatewayURL, proxyBaseURL, tokenHelperPath string, tokenHelperArgs []string, - tlsSkipVerify bool, anthropicPathPrefix string, models, discoveredModels []string, bedrock BedrockConfig, + tlsSkipVerify bool, anthropicPathPrefix string, + models, discoveredModels []string, extendedTTLCache bool, bedrock BedrockConfig, ) ([]ToolConfig, error) { var configured []ToolConfig for _, clientType := range detected { @@ -713,6 +751,9 @@ func configureDetectedToolsWithDiscovery( DiscoveredModels: discoveredModels, } + extendedTTLResult := resolveExtendedTTLCache(errOut, gm, clientType, extendedTTLCache) + applyCfg.ExtendedTTLCache = extendedTTLResult.applied + // Bedrock-compat applies only to Claude Code: it disables the experimental // anthropic-beta headers Bedrock rejects and pins per-tier Bedrock model // IDs. Resolve defaults, tier mapping, and the optional [1m] suffix here so @@ -749,6 +790,7 @@ func configureDetectedToolsWithDiscovery( EnvFilePath: envFilePath, }) _, _ = fmt.Fprintf(out, "Configured %s (%s mode) → %s\n", clientType, mode, configPath) + reportExtendedTTLCache(out, errOut, clientType, extendedTTLCache, bedrock.Compat, extendedTTLResult) } if len(configured) == 0 { return nil, fmt.Errorf("failed to configure any detected tools") @@ -756,6 +798,66 @@ func configureDetectedToolsWithDiscovery( return configured, nil } +type extendedTTLCacheResult struct { + supported bool + applied bool +} + +// resolveExtendedTTLCache decides whether the one-hour lifetime can be +// requested for a client. Conflict inspection failures and higher-precedence +// five-minute settings are warnings rather than setup failures. +func resolveExtendedTTLCache( + errOut io.Writer, gm GatewayManager, clientType string, requested bool, +) extendedTTLCacheResult { + if !requested { + return extendedTTLCacheResult{} + } + if !gm.SupportsExtendedTTLCache(clientType) { + return extendedTTLCacheResult{} + } + + conflict, err := gm.ExtendedTTLCacheConflict(clientType) + if err != nil { + _, _ = fmt.Fprintf(errOut, + "Warning: could not inspect %s for prompt-cache TTL conflicts: %v; "+ + "the one-hour lifetime was not applied.\n", clientType, err) + return extendedTTLCacheResult{supported: true} + } + if conflict != "" { + _, _ = fmt.Fprintf(errOut, + "Warning: extended prompt-cache TTL was not applied to %s because %s forces the five-minute lifetime. "+ + "Remove that setting and re-run setup. Verify the effective lifetime with: %s\n", + clientType, conflict, promptCacheVerificationCommand) + return extendedTTLCacheResult{supported: true} + } + return extendedTTLCacheResult{supported: true, applied: true} +} + +func reportExtendedTTLCache( + out, errOut io.Writer, clientType string, requested, bedrockCompat bool, result extendedTTLCacheResult, +) { + if !requested { + return + } + if !result.supported { + _, _ = fmt.Fprintf(out, + "Extended prompt-cache TTL not applied to %s: this client does not expose a prompt-cache lifetime control.\n", + clientType) + return + } + if !result.applied { + return + } + + _, _ = fmt.Fprintf(out, "Enabled one-hour prompt-cache lifetime for %s.\n", clientType) + if bedrockCompat && clientType == claudeCodeClient { + _, _ = fmt.Fprintf(errOut, + "Warning: Claude Code's one-hour prompt-cache lifetime may not take effect with Bedrock compatibility: "+ + "the gateway may reject or strip the required beta header, and Bedrock support varies by model. "+ + "Verify the effective lifetime with: %s\n", promptCacheVerificationCommand) + } +} + // resolveAnthropicPrefix returns the effective Anthropic path prefix. When the // caller explicitly set the flag (anthropicPathPrefixSet), the provided value is // returned as-is (including empty string, which disables the prefix). Otherwise diff --git a/pkg/llm/setup_test.go b/pkg/llm/setup_test.go index 092d8e32e3..65abcc479b 100644 --- a/pkg/llm/setup_test.go +++ b/pkg/llm/setup_test.go @@ -189,7 +189,7 @@ func TestConfigureDetectedTools_BedrockClaudeCode(t *testing.T) { []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, - false, "/anthropic", nil, nil, + false, "/anthropic", nil, nil, false, BedrockConfig{Compat: true, Enable1M: true}, ) require.NoError(t, err) @@ -213,7 +213,7 @@ func TestConfigureDetectedTools_BedrockSkippedForNonClaudeCode(t *testing.T) { []string{"cursor"}, "https://gw.example.com", "http://localhost:14000/v1", "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, - false, "", nil, nil, + false, "", nil, nil, false, BedrockConfig{Compat: true}, ) require.NoError(t, err) @@ -222,6 +222,98 @@ func TestConfigureDetectedTools_BedrockSkippedForNonClaudeCode(t *testing.T) { assert.Empty(t, gm.applied[0].BedrockOpusModel) } +func TestConfigureDetectedTools_ExtendedTTLCacheReporting(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + clientType string + mode string + supports bool + conflict string + conflictErr error + bedrock BedrockConfig + wantApplied bool + wantStdout []string + wantStderr []string + wantStderrAbsent []string + }{ + { + name: "supported client applies one-hour lifetime", + clientType: claudeCodeClient, + mode: llmgateway.ModeDirect, + supports: true, + wantApplied: true, + wantStdout: []string{"Enabled one-hour prompt-cache lifetime", claudeCodeClient}, + }, + { + name: "unsupported targeted client is informational", + clientType: "cursor", + mode: llmgateway.ModeProxy, + wantStdout: []string{"Extended prompt-cache TTL not applied to cursor", "does not expose"}, + wantStderrAbsent: []string{"Warning:"}, + }, + { + name: "higher-precedence five-minute setting blocks application", + clientType: claudeCodeClient, + mode: llmgateway.ModeDirect, + supports: true, + conflict: "CLAUDE_CODE_PROMPT_CACHE_TTL=5m", + wantStderr: []string{"CLAUDE_CODE_PROMPT_CACHE_TTL=5m", promptCacheVerificationCommand}, + }, + { + name: "conflict inspection failure prevents ineffective configuration", + clientType: claudeCodeClient, + mode: llmgateway.ModeDirect, + supports: true, + conflictErr: errors.New("invalid settings"), + wantStderr: []string{"could not inspect claude-code", "one-hour lifetime was not applied"}, + }, + { + name: "Bedrock combination applies and warns with verification command", + clientType: claudeCodeClient, + mode: llmgateway.ModeDirect, + supports: true, + bedrock: BedrockConfig{Compat: true}, + wantApplied: true, + wantStderr: []string{"may not take effect with Bedrock compatibility", promptCacheVerificationCommand}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gm := &capturingGatewayManager{ + mode: tt.mode, + supportsExtended: tt.supports, + cacheConflict: tt.conflict, + cacheConflictErr: tt.conflictErr, + } + var out, errOut bytes.Buffer + + configured, err := configureDetectedToolsWithDiscovery( + &out, &errOut, gm, []string{tt.clientType}, + "https://gw.example.com", "http://localhost:14000/v1", + "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, + false, "", nil, nil, true, tt.bedrock, + ) + require.NoError(t, err) + require.Len(t, configured, 1) + require.Len(t, gm.applied, 1) + assert.Equal(t, tt.wantApplied, gm.applied[0].ExtendedTTLCache) + for _, want := range tt.wantStdout { + assert.Contains(t, out.String(), want) + } + for _, want := range tt.wantStderr { + assert.Contains(t, errOut.String(), want) + } + for _, absent := range tt.wantStderrAbsent { + assert.NotContains(t, errOut.String(), absent) + } + }) + } +} + // ── mergeToolConfigs ────────────────────────────────────────────────────────── func TestMergeToolConfigs_EmptyExisting(t *testing.T) { @@ -299,6 +391,13 @@ func TestIsTarget(t *testing.T) { // stubGatewayManager is a minimal GatewayManager for Teardown tests. type stubGatewayManager struct { reverted []string + cleared []extendedTTLCacheClearCall + clearErr error +} + +type extendedTTLCacheClearCall struct { + clientType string + configPath string } func (*stubGatewayManager) DetectedLLMGatewayClients() []string { return nil } @@ -306,6 +405,14 @@ func (*stubGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyConfi return "", nil } func (*stubGatewayManager) LLMGatewayModeFor(_ string) string { return "" } +func (*stubGatewayManager) SupportsExtendedTTLCache(_ string) bool { return false } +func (*stubGatewayManager) ExtendedTTLCacheConflict(_ string) (string, error) { + return "", nil +} +func (s *stubGatewayManager) ClearExtendedTTLCache(clientType, configPath string) error { + s.cleared = append(s.cleared, extendedTTLCacheClearCall{clientType: clientType, configPath: configPath}) + return s.clearErr +} func (*stubGatewayManager) IsManaged(_ string) bool { return false } func (*stubGatewayManager) LLMClientDetectionHint(_ string) string { return "" } func (*stubGatewayManager) ConfigureEnvFile(_ string, _ llmgateway.ApplyConfig) (string, error) { @@ -515,8 +622,13 @@ func (g *setupGatewayManager) ConfigureLLMGateway(client string, cfg llmgateway. g.applied = append(g.applied, cfg) return "/tmp/settings.json", nil } -func (g *setupGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } -func (*setupGatewayManager) IsManaged(_ string) bool { return false } +func (g *setupGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } +func (*setupGatewayManager) SupportsExtendedTTLCache(_ string) bool { return false } +func (*setupGatewayManager) ExtendedTTLCacheConflict(_ string) (string, error) { + return "", nil +} +func (*setupGatewayManager) ClearExtendedTTLCache(_, _ string) error { return nil } +func (*setupGatewayManager) IsManaged(_ string) bool { return false } func (g *setupGatewayManager) LLMClientDetectionHint(_ string) string { return g.hint } @@ -740,6 +852,137 @@ func TestSetup_VSCodeDiscoveryFailureSelectionSemantics(t *testing.T) { }) } +func TestSetup_ReappliesPersistedExtendedTTLCache(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{ + detected: []string{claudeCodeClient}, + mode: llmgateway.ModeDirect, + supportsExtended: true, + } + provider := configuredSetupProvider() + provider.cfg.ExtendedTTLCache = true + + var stdout, stderr bytes.Buffer + err := Setup( + context.Background(), &stdout, &stderr, gm, provider, + func(context.Context, *Config) error { return nil }, + SetOptions{}, "", true, "", true, + ) + require.NoError(t, err) + require.Len(t, gm.applied, 1) + assert.True(t, gm.applied[0].ExtendedTTLCache, + "a plain setup must apply the persisted extended TTL setting") + assert.True(t, provider.cfg.ExtendedTTLCache) +} + +func TestSetup_TargetedUnsupportedClientReportsInformation(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{ + detected: []string{"cursor"}, + mode: llmgateway.ModeProxy, + } + provider := configuredSetupProvider() + + var stdout, stderr bytes.Buffer + err := Setup( + context.Background(), &stdout, &stderr, gm, provider, + func(context.Context, *Config) error { return nil }, + SetOptions{ExtendedTTLCache: boolPtr(true)}, "", true, "cursor", true, + ) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Extended prompt-cache TTL not applied to cursor") + assert.NotContains(t, stderr.String(), "does not expose") + assert.True(t, provider.cfg.ExtendedTTLCache, + "the global preference stays persisted for clients that gain support later") +} + +func TestSetup_ExplicitFalseClearsRecordedExtendedTTLCacheBeforeDetection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + detected []string + targetClient string + wantApplied int + }{ + { + name: "targeting unsupported cursor still clears recorded Claude Code", + detected: []string{"cursor"}, + targetClient: "cursor", + wantApplied: 1, + }, + { + name: "no currently detected clients still clears recorded Claude Code", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gm := &capturingGatewayManager{ + detected: tt.detected, + mode: llmgateway.ModeProxy, + extendedSupport: map[string]bool{ + claudeCodeClient: true, + }, + } + provider := configuredSetupProvider() + provider.cfg.ExtendedTTLCache = true + provider.cfg.ConfiguredTools = []ToolConfig{{ + Tool: claudeCodeClient, + ConfigPath: "/home/test/.claude/settings.json", + }} + + var stdout, stderr bytes.Buffer + err := Setup( + context.Background(), &stdout, &stderr, gm, provider, + func(context.Context, *Config) error { return nil }, + SetOptions{ExtendedTTLCache: boolPtr(false)}, "", true, tt.targetClient, true, + ) + require.NoError(t, err) + assert.Equal(t, []extendedTTLCacheClearCall{{ + clientType: claudeCodeClient, + configPath: "/home/test/.claude/settings.json", + }}, gm.clearCalls) + assert.False(t, provider.cfg.ExtendedTTLCache) + assert.Len(t, gm.applied, tt.wantApplied) + }) + } +} + +func TestSetup_ExplicitFalseCleanupFailureLeavesPreferenceEnabled(t *testing.T) { + t.Parallel() + + gm := &capturingGatewayManager{ + detected: []string{"cursor"}, + mode: llmgateway.ModeProxy, + extendedSupport: map[string]bool{ + claudeCodeClient: true, + }, + clearErr: errors.New("settings are read-only"), + } + provider := configuredSetupProvider() + provider.cfg.ExtendedTTLCache = true + provider.cfg.ConfiguredTools = []ToolConfig{{ + Tool: claudeCodeClient, + ConfigPath: "/home/test/.claude/settings.json", + }} + + var stdout, stderr bytes.Buffer + err := Setup( + context.Background(), &stdout, &stderr, gm, provider, + func(context.Context, *Config) error { return nil }, + SetOptions{ExtendedTTLCache: boolPtr(false)}, "", true, "cursor", true, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "settings are read-only") + assert.True(t, provider.cfg.ExtendedTTLCache) + assert.Zero(t, provider.updateCalls) + assert.Empty(t, gm.applied) +} + func TestFilterDetectedClients_LeftoverDirHint(t *testing.T) { t.Parallel() gm := &setupGatewayManager{ @@ -869,17 +1112,37 @@ func TestSetup_CallbackPortInUseBeforeLogin(t *testing.T) { // capturingGatewayManager records the ApplyConfig passed to ConfigureLLMGateway. type capturingGatewayManager struct { - mode string // returned by LLMGatewayModeFor - applied []llmgateway.ApplyConfig + mode string // returned by LLMGatewayModeFor + detected []string + supportsExtended bool + extendedSupport map[string]bool + cacheConflict string + cacheConflictErr error + clearCalls []extendedTTLCacheClearCall + clearErr error + applied []llmgateway.ApplyConfig } -func (*capturingGatewayManager) DetectedLLMGatewayClients() []string { return nil } +func (g *capturingGatewayManager) DetectedLLMGatewayClients() []string { return g.detected } func (g *capturingGatewayManager) ConfigureLLMGateway(_ string, cfg llmgateway.ApplyConfig) (string, error) { g.applied = append(g.applied, cfg) return "/path/to/settings.json", nil } func (g *capturingGatewayManager) LLMGatewayModeFor(_ string) string { return g.mode } -func (*capturingGatewayManager) IsManaged(_ string) bool { return false } +func (g *capturingGatewayManager) SupportsExtendedTTLCache(clientType string) bool { + if g.extendedSupport != nil { + return g.extendedSupport[clientType] + } + return g.supportsExtended +} +func (g *capturingGatewayManager) ExtendedTTLCacheConflict(_ string) (string, error) { + return g.cacheConflict, g.cacheConflictErr +} +func (g *capturingGatewayManager) ClearExtendedTTLCache(clientType, configPath string) error { + g.clearCalls = append(g.clearCalls, extendedTTLCacheClearCall{clientType: clientType, configPath: configPath}) + return g.clearErr +} +func (*capturingGatewayManager) IsManaged(_ string) bool { return false } func (*capturingGatewayManager) LLMClientDetectionHint(_ string) string { return "" } @@ -901,7 +1164,7 @@ func TestConfigureDetectedTools_PathPrefixAppendedForDirectMode(t *testing.T) { []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, - false, "/anthropic", nil, nil, + false, "/anthropic", nil, nil, false, BedrockConfig{}, ) require.NoError(t, err) @@ -923,7 +1186,7 @@ func TestConfigureDetectedTools_NoPrefixWhenEmpty(t *testing.T) { []string{"claude-code"}, "https://gw.example.com", "http://localhost:14000/v1", "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, - false, "", nil, nil, // no prefix + false, "", nil, nil, false, // no prefix BedrockConfig{}, ) require.NoError(t, err) @@ -944,7 +1207,7 @@ func TestConfigureDetectedTools_PrefixNotAppliedForProxyMode(t *testing.T) { []string{"cursor"}, "https://gw.example.com", "http://localhost:14000/v1", "/usr/local/bin/thv", []string{"llm", "token", "--skip-browser"}, - false, "/anthropic", nil, nil, + false, "/anthropic", nil, nil, false, BedrockConfig{}, ) require.NoError(t, err) @@ -1036,7 +1299,12 @@ func (*managedGatewayManager) ConfigureLLMGateway(_ string, _ llmgateway.ApplyCo func (*managedGatewayManager) LLMGatewayModeFor(_ string) string { return llmgateway.ModeCredentialHelper } -func (g *managedGatewayManager) IsManaged(c string) bool { return g.managed[c] } +func (*managedGatewayManager) SupportsExtendedTTLCache(_ string) bool { return false } +func (*managedGatewayManager) ExtendedTTLCacheConflict(_ string) (string, error) { + return "", nil +} +func (*managedGatewayManager) ClearExtendedTTLCache(_, _ string) error { return nil } +func (g *managedGatewayManager) IsManaged(c string) bool { return g.managed[c] } func (*managedGatewayManager) LLMClientDetectionHint(_ string) string { return "" } diff --git a/pkg/llmgateway/config.go b/pkg/llmgateway/config.go index c88a0ce406..2ca6271b7a 100644 --- a/pkg/llmgateway/config.go +++ b/pkg/llmgateway/config.go @@ -114,6 +114,8 @@ type ApplyConfig struct { // discovery request. It is used only by integrations that require an explicit // model catalogue, such as VS Code's customendpoint provider. DiscoveredModels []string + // ExtendedTTLCache requests one-hour caching from supported clients. + ExtendedTTLCache bool // BedrockCompat and the per-tier Bedrock model IDs configure Claude Code for a // gateway that forwards to AWS Bedrock. When BedrockCompat is true, Claude Code // is configured with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 (Bedrock rejects diff --git a/test/e2e/cli_llm_setup_test.go b/test/e2e/cli_llm_setup_test.go index 56d6de13c6..28b07b9e47 100644 --- a/test/e2e/cli_llm_setup_test.go +++ b/test/e2e/cli_llm_setup_test.go @@ -472,4 +472,71 @@ var _ = Describe("thv llm setup / teardown", Label("cli", "llm", "setup", "e2e") "a fresh token should be printed to stdout after deferred login") }) }) + + Describe("thv llm setup --extended-ttl-cache", func() { + It("persists, reapplies, clears, and tears down Claude Code cache settings", func() { + claudeDir := filepath.Join(tempDir, ".claude") + Expect(os.MkdirAll(claudeDir, 0750)).To(Succeed()) + Expect(createFakeBinary(binDir, "claude")).To(Succeed()) + + issuerURL := fmt.Sprintf("http://localhost:%d", oidcPort) + setupArgs := []string{ + "llm", "setup", "--lazy", "--client", "claude-code", + "--anthropic-path-prefix", "", + } + + By("Enabling the extended cache lifetime") + stdout, stderr, err := thvCmd(append(setupArgs, + "--gateway-url", gatewayURL, + "--issuer", issuerURL, + "--client-id", clientID, + "--extended-ttl-cache", + )...).RunWithTimeout(30 * time.Second) + Expect(err).ToNot(HaveOccurred(), + "setup should succeed; stdout=%q stderr=%q", stdout, stderr) + Expect(stdout).To(ContainSubstring("Enabled one-hour prompt-cache lifetime for claude-code")) + + settingsPath := filepath.Join(claudeDir, "settings.json") + expectExtendedTTLSettings := func(present bool) { + By("Reading Claude Code settings") + data, readErr := os.ReadFile(settingsPath) + Expect(readErr).ToNot(HaveOccurred()) + var settings map[string]any + Expect(json.Unmarshal(data, &settings)).To(Succeed()) + + for pointer, expected := range map[string]string{ + "/promptCacheTtl": "1h", + "/subagentPromptCacheTtl": "1h", + "/env/ENABLE_PROMPT_CACHING_1H": "1", + } { + actual, found := jsonPointerGet(settings, pointer) + Expect(found).To(Equal(present), "unexpected presence for %s", pointer) + if present { + Expect(actual).To(Equal(expected), "unexpected value for %s", pointer) + } + } + } + expectExtendedTTLSettings(true) + + By("Verifying the preference is persisted") + showOut, _ := thvCmd("llm", "config", "show", "--format", "json").ExpectSuccess() + var cfg llm.Config + Expect(json.Unmarshal([]byte(showOut), &cfg)).To(Succeed()) + Expect(cfg.ExtendedTTLCache).To(BeTrue()) + + By("Reapplying the persisted preference with a plain setup") + thvCmd(setupArgs...).ExpectSuccess() + expectExtendedTTLSettings(true) + + By("Explicitly returning Claude Code to its default cache lifetime") + thvCmd(append(setupArgs, "--extended-ttl-cache=false")...).ExpectSuccess() + expectExtendedTTLSettings(false) + + By("Re-enabling the preference and verifying teardown removes it") + thvCmd(append(setupArgs, "--extended-ttl-cache")...).ExpectSuccess() + expectExtendedTTLSettings(true) + thvCmd("llm", "teardown", "claude-code").ExpectSuccess() + expectExtendedTTLSettings(false) + }) + }) })