diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index c1788747..da44de6b 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -176,7 +176,7 @@ func unsetActiveProvider(configPath string) error { } func legacyLLMShadowWarning(provider, key string) string { - if provider == "" || !strings.HasPrefix(key, "llm.") { + if provider == "" || !strings.HasPrefix(key, "llm.") || key == "llm.prompt_caching" || key == "llm.PromptCaching" { return "" } section := "custom_providers" @@ -309,6 +309,8 @@ type LlmConfig struct { TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds ExtraBody map[string]any `json:"extra_body,omitempty"` ExtraHeaders map[string]string `json:"extra_headers,omitempty"` + // PromptCaching is the global Anthropic caching toggle for the active provider. + PromptCaching *bool `json:"prompt_caching,omitempty"` } // TelemetryConfig holds telemetry-specific settings. @@ -365,6 +367,7 @@ var supportedConfigKeys = []string{ "llm.model", "llm.protocol", "llm.use_anthropic", + "llm.prompt_caching", "llm.extra_body", "llm.extra_headers", "language", @@ -476,6 +479,12 @@ func setConfigValue(cfg *Config, key, value string) error { } else if cfg.Llm.Protocol == "" || cfg.Llm.Protocol == llm.ProtocolAnthropic || cfg.Llm.Protocol == llm.ProtocolOpenAIChatCompletions { cfg.Llm.Protocol = llm.ProtocolOpenAIChatCompletions } + case "llm.prompt_caching", "llm.PromptCaching": + b, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("invalid boolean for llm.prompt_caching: %w", err) + } + cfg.Llm.PromptCaching = &b case "language", "Language": cfg.Language = value case "telemetry.enabled", "telemetry.Enabled": diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go index 19ec5f5f..39946ed3 100644 --- a/cmd/opencodereview/config_cmd_test.go +++ b/cmd/opencodereview/config_cmd_test.go @@ -830,6 +830,22 @@ func TestSetConfigValueLlmUseAnthropicInvalid(t *testing.T) { } } +func TestSetConfigValueLlmPromptCaching(t *testing.T) { + cfg := &Config{} + if err := setConfigValue(cfg, "llm.prompt_caching", "false"); err != nil { + t.Fatalf("setConfigValue: %v", err) + } + if cfg.Llm.PromptCaching == nil || *cfg.Llm.PromptCaching { + t.Fatalf("PromptCaching = %v, want false", cfg.Llm.PromptCaching) + } +} + +func TestSetConfigValueLlmPromptCachingInvalid(t *testing.T) { + if err := setConfigValue(&Config{}, "llm.prompt_caching", "sometimes"); err == nil { + t.Fatal("expected error for invalid boolean") + } +} + func TestSetConfigValueLanguage(t *testing.T) { cfg := &Config{} if err := setConfigValue(cfg, "language", "English"); err != nil { @@ -929,7 +945,7 @@ func TestSetConfigValueUnknownKeyMessage(t *testing.T) { t.Fatal("expected error for unknown key") } want := "unknown config key: bogus.key\n" + - "Supported keys: provider, model, providers.., custom_providers.., mcp_servers.., llm.url, llm.auth_token, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\n" + + "Supported keys: provider, model, providers.., custom_providers.., mcp_servers.., llm.url, llm.auth_token, llm.auth_header, llm.model, llm.protocol, llm.use_anthropic, llm.prompt_caching, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\n" + "Provider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\n" + "Protocol values: anthropic, openai, openai-responses\n" + "MCP server fields: type, command, args, env, url, headers, tools, setup" @@ -998,6 +1014,9 @@ func TestLegacyLLMShadowWarning(t *testing.T) { if got := legacyLLMShadowWarning("dashscope", "Llm.model"); got != "" { t.Errorf("warning for invalid mixed-case legacy key = %q", got) } + if got := legacyLLMShadowWarning("dashscope", "llm.prompt_caching"); got != "" { + t.Errorf("warning for global prompt caching setting = %q", got) + } if got := legacyLLMShadowWarning("dashscope", "llm.model"); !strings.Contains(got, "providers.dashscope.") { t.Errorf("preset-provider warning = %q", got) } diff --git a/cmd/opencodereview/provider_cmd.go b/cmd/opencodereview/provider_cmd.go index f67da930..9bd78cb0 100644 --- a/cmd/opencodereview/provider_cmd.go +++ b/cmd/opencodereview/provider_cmd.go @@ -95,6 +95,16 @@ func removeModels(existing, toRemove []string) []string { return result } +func applyPromptCachingConfig(cfg *Config, protocol string, promptCaching *bool) { + if llm.NormalizeProtocol(protocol) != llm.ProtocolAnthropic { + cfg.Llm.PromptCaching = nil + return + } + if promptCaching != nil { + cfg.Llm.PromptCaching = promptCaching + } +} + func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) error { if result.url == "" { return fmt.Errorf("URL is required for manual configuration") @@ -120,6 +130,7 @@ func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) // older binaries pick the OpenAI auth header/endpoint instead of wrongly // defaulting to anthropic. protocol := llm.NormalizeProtocol(result.protocol) + applyPromptCachingConfig(cfg, protocol, result.promptCaching) cfg.Llm.Protocol = protocol switch protocol { case llm.ProtocolAnthropic: @@ -186,6 +197,9 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU } else { entry.APIKey = "" } + if !result.isEdit || cfg.Provider == result.provider || result.promptCaching != nil { + applyPromptCachingConfig(cfg, entry.Protocol, result.promptCaching) + } cfg.CustomProviders[result.provider] = entry if !result.isEdit { @@ -234,6 +248,10 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider } preset, isPreset := llm.LookupProvider(result.provider) + protocol := result.protocol + if isPreset { + protocol = preset.Protocol + } if result.apiKey == "" { if isPreset && preset.EnvVar != "" { @@ -260,6 +278,7 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider // Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR. entry.APIKey = "" } + applyPromptCachingConfig(cfg, protocol, result.promptCaching) cfg.Providers[result.provider] = entry if cfg.Provider != result.provider { diff --git a/cmd/opencodereview/provider_cmd_test.go b/cmd/opencodereview/provider_cmd_test.go index d1f03bba..848844c3 100644 --- a/cmd/opencodereview/provider_cmd_test.go +++ b/cmd/opencodereview/provider_cmd_test.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/alibaba/open-code-review/internal/llm" ) func TestMaskKey(t *testing.T) { @@ -274,6 +276,45 @@ func TestApplyCustomProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) { } } +func TestApplyCustomProviderConfig_PersistsPromptCaching(t *testing.T) { + disabled := false + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := &Config{ + Provider: "gateway", + Model: "claude-test", + CustomProviders: map[string]ProviderEntry{ + "gateway": { + URL: "https://gateway.example/v1", + Protocol: llm.ProtocolAnthropic, + APIKey: "test-key", + Model: "claude-test", + Models: []string{"claude-test"}, + }, + }, + } + + err := applyCustomProviderConfig(configPath, cfg, providerTUIResult{ + provider: "gateway", + model: "claude-test", + models: []string{"claude-test"}, + apiKey: "test-key", + isCustom: true, + url: "https://gateway.example/v1", + protocol: llm.ProtocolAnthropic, + promptCaching: &disabled, + }) + if err != nil { + t.Fatalf("applyCustomProviderConfig: %v", err) + } + diskCfg, err := loadOrCreateConfig(configPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if diskCfg.Llm.PromptCaching == nil || *diskCfg.Llm.PromptCaching { + t.Fatalf("PromptCaching = %v, want false", diskCfg.Llm.PromptCaching) + } +} + func TestProviderTUIResult_ResolvedModel(t *testing.T) { r := providerTUIResult{ provider: "baidu-qianfan", diff --git a/cmd/opencodereview/provider_tui.go b/cmd/opencodereview/provider_tui.go index 31fa9e55..a5006191 100644 --- a/cmd/opencodereview/provider_tui.go +++ b/cmd/opencodereview/provider_tui.go @@ -19,6 +19,7 @@ const ( stepProvider tuiStep = iota stepModel stepAPIKey + stepPromptCaching ) type providerTab int @@ -48,6 +49,7 @@ const ( manualStepModel manualStepAuthToken manualStepAuthHeader + manualStepPromptCaching ) // cpProtocols lists the protocol options offered in the Custom and Manual @@ -77,6 +79,7 @@ type providerTUIResult struct { url string protocol string authHeader string + promptCaching *bool sessionModelPick map[string]string } @@ -145,6 +148,7 @@ type providerTUIModel struct { manualTokenInput textinput.Model manualTokenMasked bool manualTokenOriginal string + promptCaching bool // --- shared model/api-key steps (official + existing custom) --- modelIdx int @@ -295,6 +299,7 @@ func newProviderTUI(cfg *Config, configPath string) providerTUIModel { activeTab: tabOfficial, customProviders: collectCustomProviders(cfg), configPath: configPath, + promptCaching: configuredPromptCaching(cfg), } providerFound := false @@ -616,6 +621,9 @@ func (m providerTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.step == stepAPIKey { return m.updateAPIKeyInput(key, msg) } + if m.step == stepPromptCaching { + return m.updatePromptCaching(key) + } if m.step == stepProvider && (m.creatingCustom || m.editingCustom) { return m.updateCustomProviderForm(key, msg) @@ -941,6 +949,11 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te return m, nil } m.formError = "" + if m.selectedProviderProtocol() == llm.ProtocolAnthropic { + m.apiKeyInput.Blur() + m.step = stepPromptCaching + return m, nil + } m.confirmed = true return m, tea.Quit case "ctrl+c": @@ -974,6 +987,7 @@ func (m providerTUIModel) updateCustomProviderForm(key string, msg tea.KeyPressM m.apiKeyInput.SetValue("") m.apiKeyMasked = false m.apiKeyOriginal = "" + m.promptCaching = configuredPromptCaching(m.existingCfg) m.formError = "" return m, nil } @@ -1021,6 +1035,7 @@ func (m *providerTUIModel) enterEditCustomProvider() { m.editTargetName = cp.name m.cpStep = cpStepName m.formError = "" + m.promptCaching = configuredPromptCaching(m.existingCfg) m.cpProtocolIdx = cpProtocolIndex(entry.Protocol) m.cpNameInput.SetValue(cp.name) m.cpURLInput.SetValue(entry.URL) @@ -1089,33 +1104,77 @@ func (m providerTUIModel) handleCustomFormEnter() (tea.Model, tea.Cmd) { return m, nil } m.cpAuthInput.Blur() - if m.editingCustom { - r := m.result() - if err := m.applyEditCustomProviderSave(); err != nil { - return m, nil - } - // Edit succeeded — drop the user into the model list for this provider. - m.editingCustom = false - m.editTargetName = "" - m.apiKeyInput.SetValue("") - m.apiKeyMasked = false - m.apiKeyOriginal = "" - if idx := m.findCustomIdx(r.provider); idx >= 0 { - m.customIdx = idx - } - m.step = stepModel - m.prepareModelSelection(r.provider, m.customProviderEntry(r.provider, ProviderEntry{}).Model) - return m, nil - } - if m.creatingCustom { - return m.applyCreateCustomProvider() + return m.finishCustomProviderForm() + } + return m, nil +} + +func (m providerTUIModel) selectedProviderProtocol() string { + switch m.activeTab { + case tabOfficial: + return llm.NormalizeProtocol(m.currentProvider().Protocol) + case tabCustom: + if cp, ok := m.selectedCustomProvider(); ok { + return llm.NormalizeProtocol(cp.entry.Protocol) } + } + return "" +} + +func (m providerTUIModel) updatePromptCaching(key string) (tea.Model, tea.Cmd) { + switch key { + case "ctrl+c": + m.cancelled = true + return m, tea.Quit + case "esc": + m.step = stepAPIKey + return m, m.apiKeyInput.Focus() + case "enter": m.confirmed = true return m, tea.Quit + case "up", "down", "left", "right", "h", "j", "k", "l", " ": + m.promptCaching = !m.promptCaching } return m, nil } +func promptCachingForProtocol(protocol string, enabled bool) *bool { + if llm.NormalizeProtocol(protocol) != llm.ProtocolAnthropic { + return nil + } + value := enabled + return &value +} + +func configuredPromptCaching(cfg *Config) bool { + return cfg == nil || cfg.Llm.PromptCaching == nil || *cfg.Llm.PromptCaching +} + +func (m providerTUIModel) finishCustomProviderForm() (tea.Model, tea.Cmd) { + if m.editingCustom { + r := m.result() + if err := m.applyEditCustomProviderSave(); err != nil { + return m, nil + } + m.editingCustom = false + m.editTargetName = "" + m.apiKeyInput.SetValue("") + m.apiKeyMasked = false + m.apiKeyOriginal = "" + if idx := m.findCustomIdx(r.provider); idx >= 0 { + m.customIdx = idx + } + m.step = stepModel + m.prepareModelSelection(r.provider, m.customProviderEntry(r.provider, ProviderEntry{}).Model) + return m, nil + } + if m.creatingCustom { + return m.applyCreateCustomProvider() + } + m.confirmed = true + return m, tea.Quit +} + func (m providerTUIModel) applyCreateCustomProvider() (tea.Model, tea.Cmd) { if m.existingCfg == nil { m.formError = "failed to save: config not loaded" @@ -1373,6 +1432,7 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea m.manualTokenMasked = false m.manualTokenOriginal = "" } + m.promptCaching = configuredPromptCaching(m.existingCfg) m.formError = "" return m, nil } @@ -1397,6 +1457,13 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea return m, nil } } + if m.manualStep == manualStepPromptCaching { + switch key { + case "up", "down", "left", "right", "h", "j", "k", "l", " ": + m.promptCaching = !m.promptCaching + } + return m, nil + } if m.manualStep == manualStepAuthToken && m.manualTokenMasked { m.beginManualTokenReplace() } @@ -1583,6 +1650,7 @@ func (m *providerTUIModel) reloadConfigAfterSaveFailure() bool { } m.existingCfg = reloaded m.customProviders = collectCustomProviders(reloaded) + m.promptCaching = configuredPromptCaching(reloaded) return true } @@ -1621,6 +1689,13 @@ func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) { return m, nil } m.manualAuthHeaderInput.Blur() + if cpProtocols[m.manualProtocolIdx] == llm.ProtocolAnthropic { + m.manualStep = manualStepPromptCaching + return m, nil + } + m.confirmed = true + return m, tea.Quit + case manualStepPromptCaching: m.confirmed = true return m, tea.Quit } @@ -1639,6 +1714,8 @@ func (m *providerTUIModel) blurManualStep() { m.manualTokenInput.Blur() case manualStepAuthHeader: m.manualAuthHeaderInput.Blur() + case manualStepPromptCaching: + // no input to blur } } @@ -1654,6 +1731,8 @@ func (m *providerTUIModel) focusManualStep() tea.Cmd { return m.manualTokenInput.Focus() case manualStepAuthHeader: return m.manualAuthHeaderInput.Focus() + case manualStepPromptCaching: + return nil } return nil } @@ -1674,6 +1753,8 @@ func (m providerTUIModel) passThroughManualInput(msg tea.Msg) (tea.Model, tea.Cm m.manualTokenInput, cmd = m.manualTokenInput.Update(msg) case manualStepAuthHeader: m.manualAuthHeaderInput, cmd = m.manualAuthHeaderInput.Update(msg) + case manualStepPromptCaching: + return m, nil } if _, ok := msg.(tea.KeyPressMsg); ok { m.formError = "" @@ -1708,6 +1789,7 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) { m.cpAuthInput.SetValue("") m.apiKeyInput.SetValue("") m.apiKeyMasked = false + m.promptCaching = configuredPromptCaching(m.existingCfg) return m, m.cpNameInput.Focus() } cp := m.customProviders[m.customIdx] @@ -1847,6 +1929,7 @@ func (m providerTUIModel) result() providerTUIResult { provider: p.Name, model: model, apiKey: apiKey, + promptCaching: promptCachingForProtocol(p.Protocol, m.promptCaching), sessionModelPick: m.sessionModelPickSnapshot(), } @@ -1902,6 +1985,7 @@ func (m providerTUIModel) result() providerTUIResult { url: cp.entry.URL, protocol: cp.entry.Protocol, authHeader: cp.entry.AuthHeader, + promptCaching: promptCachingForProtocol(cp.entry.Protocol, m.promptCaching), sessionModelPick: m.sessionModelPickSnapshot(), } } @@ -1914,12 +1998,13 @@ func (m providerTUIModel) result() providerTUIResult { } authHeader, _ := llm.NormalizeAuthHeader(m.manualAuthHeaderInput.Value()) return providerTUIResult{ - isManual: true, - url: m.manualURLInput.Value(), - model: m.manualModelInput.Value(), - apiKey: apiKey, - protocol: cpProtocols[m.manualProtocolIdx], - authHeader: authHeader, + isManual: true, + url: m.manualURLInput.Value(), + model: m.manualModelInput.Value(), + apiKey: apiKey, + protocol: cpProtocols[m.manualProtocolIdx], + authHeader: authHeader, + promptCaching: promptCachingForProtocol(cpProtocols[m.manualProtocolIdx], m.promptCaching), } } @@ -1957,6 +2042,29 @@ func renderModelName(name string, isCursor, userAdded bool) string { return renderListName(name, isCursor) } +func promptCachingLabel(enabled bool) string { + if enabled { + return "Enabled" + } + return "Disabled" +} + +func viewPromptCachingChoices(s *strings.Builder, enabled bool) { + for _, choice := range []struct { + label string + selected bool + }{ + {"Enabled", enabled}, + {"Disabled", !enabled}, + } { + if choice.selected { + s.WriteString(" " + tuiCursorStyle.Render(tuiCursor) + " " + tuiSelectedItemStyle.Render(choice.label) + "\n") + } else { + s.WriteString(" " + tuiItemStyle.Render(choice.label) + "\n") + } + } +} + // --- View --- func (m providerTUIModel) View() tea.View { @@ -1970,6 +2078,8 @@ func (m providerTUIModel) View() tea.View { m.viewModel(&s) case stepAPIKey: m.viewAPIKey(&s) + case stepPromptCaching: + m.viewPromptCaching(&s) } v := tea.NewView(s.String()) @@ -2189,6 +2299,9 @@ func (m providerTUIModel) viewManualTab(s *strings.Builder) { {"Auth Token", strings.Repeat("*", len(m.manualTokenInput.Value())), m.manualStep == manualStepAuthToken}, {"Auth Header", m.manualAuthHeaderInput.Value(), m.manualStep == manualStepAuthHeader}, } + if cpProtocols[m.manualProtocolIdx] == llm.ProtocolAnthropic { + fields = append(fields, field{"Prompt caching", promptCachingLabel(m.promptCaching), m.manualStep == manualStepPromptCaching}) + } for _, f := range fields { if f.active { @@ -2215,6 +2328,8 @@ func (m providerTUIModel) viewManualTab(s *strings.Builder) { } case manualStepAuthHeader: s.WriteString(" " + m.manualAuthHeaderInput.View() + "\n") + case manualStepPromptCaching: + viewPromptCachingChoices(s, m.promptCaching) } } else { display := f.value @@ -2340,6 +2455,15 @@ func (m providerTUIModel) viewAPIKey(s *strings.Builder) { s.WriteString("\n") } +func (m providerTUIModel) viewPromptCaching(s *strings.Builder) { + s.WriteString(tuiTitleStyle.Render(" Prompt caching")) + s.WriteString("\n\n") + viewPromptCachingChoices(s, m.promptCaching) + s.WriteString("\n") + s.WriteString(tuiHelpStyle.Render(" Arrow keys Select Enter Confirm Esc Back")) + s.WriteString("\n") +} + // savedSecretFingerprintMinHiddenLen is the minimum number of runes that must // sit between the visible prefix and suffix so the fingerprint does not expose // the entire key (e.g. a 10-rune key with prefix 6 + suffix 4). diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go index e15dd4d3..b30a6280 100644 --- a/cmd/opencodereview/provider_tui_test.go +++ b/cmd/opencodereview/provider_tui_test.go @@ -40,6 +40,17 @@ func charKey(c rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: c, Text: string(c)} } +func selectOfficialProvider(t *testing.T, m *providerTUIModel, name string) { + t.Helper() + for i, provider := range m.providers { + if provider.Name == name { + m.officialIdx = i + return + } + } + t.Fatalf("provider %q not found", name) +} + // --- Tab switching tests --- func TestProviderTUI_TabSwitchRight(t *testing.T) { @@ -195,6 +206,53 @@ func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) { } } +func TestProviderTUI_AnthropicAPIKeyAdvancesToPromptCaching(t *testing.T) { + m := newProviderTUI(&Config{}, "") + selectOfficialProvider(t, &m, "anthropic") + m.step = stepAPIKey + m.apiKeyInput.SetValue("sk-ant-test") + + result, cmd := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if cmd != nil { + t.Fatal("API key confirmation should not quit before prompt caching is configured") + } + if m2.step != stepPromptCaching { + t.Fatalf("step = %d, want stepPromptCaching", m2.step) + } + if !m2.promptCaching { + t.Fatal("prompt caching should default to enabled") + } + + result, _ = m2.Update(downKey()) + m3 := result.(providerTUIModel) + if m3.promptCaching { + t.Fatal("down should select disabled prompt caching") + } + result, cmd = m3.Update(enterKey()) + m4 := result.(providerTUIModel) + if cmd == nil || !m4.confirmed { + t.Fatal("prompt caching confirmation should finish the wizard") + } + got := m4.result().promptCaching + if got == nil || *got { + t.Fatalf("result promptCaching = %v, want false", got) + } +} + +func TestProviderTUI_EscFromPromptCachingReturnsToAPIKey(t *testing.T) { + m := newProviderTUI(&Config{}, "") + m.step = stepPromptCaching + result, cmd := m.Update(escKey()) + m2 := result.(providerTUIModel) + if cmd == nil { + t.Fatal("API key input should be focused when returning") + } + if m2.step != stepAPIKey { + t.Fatalf("step = %d, want stepAPIKey", m2.step) + } +} + func TestProviderTUI_EscFromProviderCancels(t *testing.T) { m := newProviderTUI(&Config{}, "") @@ -418,6 +476,37 @@ func TestProviderTUI_ManualResult(t *testing.T) { } } +func TestProviderTUI_ManualAnthropicConfiguresPromptCaching(t *testing.T) { + disabled := false + m := newProviderTUI(&Config{Llm: LlmConfig{PromptCaching: &disabled}}, "") + m.activeTab = tabManual + m.inManualForm = true + m.manualProtocolIdx = cpProtocolIndex(llm.ProtocolAnthropic) + m.manualStep = manualStepAuthHeader + + result, cmd := m.Update(enterKey()) + m2 := result.(providerTUIModel) + if cmd != nil { + t.Fatal("auth header confirmation should not quit before prompt caching is configured") + } + if m2.manualStep != manualStepPromptCaching { + t.Fatalf("manualStep = %d, want manualStepPromptCaching", m2.manualStep) + } + if m2.promptCaching { + t.Fatal("prompt caching should retain the configured disabled value") + } + + result, _ = m2.Update(rightKey()) + m3 := result.(providerTUIModel) + if !m3.promptCaching { + t.Fatal("right should select enabled prompt caching") + } + got := m3.result().promptCaching + if got == nil || !*got { + t.Fatalf("result promptCaching = %v, want true", got) + } +} + func TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) { cfg := &Config{ Provider: "my-gateway", @@ -2749,6 +2838,95 @@ func TestApplyCustomProviderConfigNormalizesAuthHeader(t *testing.T) { } } +func TestApplyProviderConfigClearsPromptCachingForNonAnthropic(t *testing.T) { + tests := []struct { + name string + apply func(string, *Config, providerTUIResult) error + result providerTUIResult + }{ + { + name: "manual", + apply: applyManualConfig, + result: providerTUIResult{ + isManual: true, + url: "https://api.example.com/v1", + model: "gpt-test", + apiKey: "test-key", + protocol: llm.ProtocolOpenAIChatCompletions, + }, + }, + { + name: "custom", + apply: applyCustomProviderConfig, + result: providerTUIResult{ + provider: "gateway", + model: "gpt-test", + url: "https://api.example.com/v1", + apiKey: "test-key", + protocol: llm.ProtocolOpenAIChatCompletions, + isCustom: true, + }, + }, + { + name: "official", + apply: applyOfficialProviderConfig, + result: providerTUIResult{ + provider: "openai", + model: "gpt-4o", + apiKey: "test-key", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + enabled := true + cfg := &Config{Llm: LlmConfig{PromptCaching: &enabled}} + configPath := filepath.Join(t.TempDir(), "config.json") + + if err := tt.apply(configPath, cfg, tt.result); err != nil { + t.Fatalf("apply config: %v", err) + } + if cfg.Llm.PromptCaching != nil { + t.Fatalf("PromptCaching = %v, want nil", *cfg.Llm.PromptCaching) + } + }) + } +} + +func TestApplyInactiveOpenAIProviderPreservesPromptCaching(t *testing.T) { + enabled := true + cfg := &Config{ + Provider: "anthropic", + Llm: LlmConfig{PromptCaching: &enabled}, + CustomProviders: map[string]ProviderEntry{ + "gateway": { + URL: "https://api.example.com/v1", + Protocol: llm.ProtocolOpenAIChatCompletions, + Model: "gpt-test", + }, + }, + } + result := providerTUIResult{ + provider: "gateway", + model: "gpt-test", + url: "https://api.example.com/v1", + apiKey: "test-key", + protocol: llm.ProtocolOpenAIChatCompletions, + isCustom: true, + isEdit: true, + } + + if err := applyCustomProviderConfig( + filepath.Join(t.TempDir(), "config.json"), cfg, result, + ); err != nil { + t.Fatalf("apply config: %v", err) + } + if cfg.Llm.PromptCaching == nil || !*cfg.Llm.PromptCaching { + t.Fatalf("PromptCaching = %v, want true", cfg.Llm.PromptCaching) + } +} + // --- protocol normalization / openai-responses support --- func TestCpProtocols_ContainsAllCanonicalNames(t *testing.T) { diff --git a/internal/llm/client.go b/internal/llm/client.go index 7ffd4d3c..afb7eafe 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -184,13 +184,14 @@ type FunctionDef struct { // ClientConfig holds configuration for connecting to an LLM service. type ClientConfig struct { - URL string // Full API endpoint URL - APIKey string // Bearer token / API key - Model string // Default model override - AuthHeader string // Auth header name: "x-api-key", "authorization", or empty for protocol default - Timeout time.Duration // Request timeout - ExtraBody map[string]any // Vendor-specific fields merged into every request body - ExtraHeaders map[string]string // Extra HTTP headers sent with every request + URL string // Full API endpoint URL + APIKey string // Bearer token / API key + Model string // Default model override + AuthHeader string // Auth header name: "x-api-key", "authorization", or empty for protocol default + Timeout time.Duration // Request timeout + ExtraBody map[string]any // Vendor-specific fields merged into every request body + ExtraHeaders map[string]string // Extra HTTP headers sent with every request + PromptCaching *bool // Anthropic cache_control markers; nil defaults to enabled } // --- Factory --- @@ -206,13 +207,14 @@ type ClientConfig struct { // protocol). func NewLLMClient(ep ResolvedEndpoint) LLMClient { cfg := ClientConfig{ - URL: ep.URL, - APIKey: ep.Token, - Model: ep.Model, - AuthHeader: ep.AuthHeader, - Timeout: ep.Timeout, - ExtraBody: ep.ExtraBody, - ExtraHeaders: ep.ExtraHeaders, + URL: ep.URL, + APIKey: ep.Token, + Model: ep.Model, + AuthHeader: ep.AuthHeader, + Timeout: ep.Timeout, + ExtraBody: ep.ExtraBody, + ExtraHeaders: ep.ExtraHeaders, + PromptCaching: ep.PromptCaching, } switch ep.Protocol { case ProtocolAnthropic: @@ -779,12 +781,17 @@ func (c *AnthropicClient) buildAnthropicParams(model string, req ChatRequest) (a Messages: messages, } + promptCaching := c.cfg.PromptCaching == nil || *c.cfg.PromptCaching if len(systemBlocks) > 0 { - systemBlocks[len(systemBlocks)-1].CacheControl = anthropic.NewCacheControlEphemeralParam() + if promptCaching { + systemBlocks[len(systemBlocks)-1].CacheControl = anthropic.NewCacheControlEphemeralParam() + } params.System = systemBlocks } if len(tools) > 0 { - tools[len(tools)-1].OfTool.CacheControl = anthropic.NewCacheControlEphemeralParam() + if promptCaching { + tools[len(tools)-1].OfTool.CacheControl = anthropic.NewCacheControlEphemeralParam() + } params.Tools = tools } if req.Temperature != nil { diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 8ca60e21..c04f0872 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -169,6 +169,88 @@ func TestBuildAnthropicParams_CacheControl(t *testing.T) { }) } +func TestBuildAnthropicParams_CacheControlDisabled(t *testing.T) { + disabled := false + client := NewAnthropicClient(ClientConfig{ + URL: "https://api.anthropic.com", + PromptCaching: &disabled, + }) + req := ChatRequest{ + Messages: []Message{ + {Role: "system", Content: "You are a code reviewer."}, + {Role: "user", Content: "Review this code."}, + }, + Tools: []ToolDef{{ + Type: "function", + Function: FunctionDef{ + Name: "read_file", + Parameters: map[string]any{"type": "object"}, + }, + }}, + } + + params, err := client.buildAnthropicParams("claude-sonnet-4-20250514", req) + if err != nil { + t.Fatalf("buildAnthropicParams: %v", err) + } + for i, block := range params.System { + if block.CacheControl.Type != "" { + t.Errorf("system block %d cache control = %q, want empty", i, block.CacheControl.Type) + } + } + for i, tool := range params.Tools { + if tool.OfTool != nil && tool.OfTool.CacheControl.Type != "" { + t.Errorf("tool %d cache control = %q, want empty", i, tool.OfTool.CacheControl.Type) + } + } +} + +func TestAnthropicClient_CacheControlDisabledOmitsRequestField(t *testing.T) { + disabled := false + var requestBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read request body: %v", err) + } + requestBody = string(body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"msg_test","type":"message","role":"assistant","model":"claude-test", + "content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn", + "usage":{"input_tokens":1,"output_tokens":1} + }`)) + })) + defer server.Close() + + client := NewAnthropicClient(ClientConfig{ + URL: server.URL + "/v1/messages", + APIKey: "test-key", + Model: "claude-test", + PromptCaching: &disabled, + }) + _, err := client.CompletionsWithCtx(context.Background(), ChatRequest{ + Messages: []Message{ + {Role: "system", Content: "Review code."}, + {Role: "user", Content: "Start."}, + }, + Tools: []ToolDef{{ + Type: "function", + Function: FunctionDef{ + Name: "read_file", + Parameters: map[string]any{"type": "object"}, + }, + }}, + MaxTokens: 64, + }) + if err != nil { + t.Fatalf("CompletionsWithCtx: %v", err) + } + if strings.Contains(requestBody, `"cache_control"`) { + t.Fatalf("request includes cache_control while prompt caching is disabled: %s", requestBody) + } +} + func TestBuildAnthropicParams_CacheControl_NoTools(t *testing.T) { client := NewAnthropicClient(ClientConfig{URL: "https://api.anthropic.com"}) diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go index 27014f6b..0709d48c 100644 --- a/internal/llm/resolver.go +++ b/internal/llm/resolver.go @@ -23,6 +23,9 @@ type ResolvedEndpoint struct { Source string // human-readable config source label ExtraBody map[string]any // vendor-specific request body fields ExtraHeaders map[string]string // extra HTTP headers for the LLM request + // PromptCaching controls Anthropic cache_control markers. Nil preserves the + // default behavior (enabled); false disables them for incompatible gateways. + PromptCaching *bool // Timeout is the per-request HTTP timeout; 0 means use the client default (5 min). // Only config file (llm/provider sections) and OCR_LLM_TIMEOUT env var can set this. // tryCCEnv and tryShellRC always leave it at 0 since those sources have no timeout @@ -232,15 +235,16 @@ func tryOCREnv(modelOverride string) (ResolvedEndpoint, bool, error) { // llmFileConfig represents the llm section in config.json. type llmFileConfig struct { - URL string `json:"url,omitempty"` - AuthToken string `json:"auth_token,omitempty"` - AuthHeader string `json:"auth_header,omitempty"` - Model string `json:"model,omitempty"` - Protocol string `json:"protocol,omitempty"` // anthropic|openai|openai-responses; takes priority over use_anthropic - UseAnthropic *bool `json:"use_anthropic,omitempty"` // pointer to distinguish unset from false; legacy fallback when protocol is empty - TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds - ExtraBody map[string]any `json:"extra_body,omitempty"` - ExtraHeaders map[string]string `json:"extra_headers,omitempty"` + URL string `json:"url,omitempty"` + AuthToken string `json:"auth_token,omitempty"` + AuthHeader string `json:"auth_header,omitempty"` + Model string `json:"model,omitempty"` + Protocol string `json:"protocol,omitempty"` // anthropic|openai|openai-responses; takes priority over use_anthropic + UseAnthropic *bool `json:"use_anthropic,omitempty"` // pointer to distinguish unset from false; legacy fallback when protocol is empty + TimeoutSec int `json:"timeout_sec,omitempty"` // per-request HTTP timeout in seconds + ExtraBody map[string]any `json:"extra_body,omitempty"` + ExtraHeaders map[string]string `json:"extra_headers,omitempty"` + PromptCaching *bool `json:"prompt_caching,omitempty"` } // providerEntryConfig represents a single provider entry in config.json. @@ -419,17 +423,19 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, url = ensureMessagesSuffix(url) } + // Prompt caching is a global behavior toggle shared by Anthropic providers. return ResolvedEndpoint{ - URL: url, - Token: apiKey, - Model: model, - Provider: cfg.Provider, - Protocol: protocol, - AuthHeader: authHeader, - Source: "provider:" + cfg.Provider, - ExtraBody: extraBody, - ExtraHeaders: extraHeaders, - Timeout: timeout, + URL: url, + Token: apiKey, + Model: model, + Provider: cfg.Provider, + Protocol: protocol, + AuthHeader: authHeader, + Source: "provider:" + cfg.Provider, + ExtraBody: extraBody, + ExtraHeaders: extraHeaders, + PromptCaching: cfg.Llm.PromptCaching, + Timeout: timeout, }, true, nil } @@ -480,7 +486,7 @@ func tryLegacyLlmConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, return ResolvedEndpoint{}, false, fmt.Errorf("OCR config file: %w", err) } - return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody, ExtraHeaders: cfg.Llm.ExtraHeaders, Timeout: timeout}, true, nil + return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody, ExtraHeaders: cfg.Llm.ExtraHeaders, PromptCaching: cfg.Llm.PromptCaching, Timeout: timeout}, true, nil } // tryCCEnv reads Claude Code environment variables. diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index a81e0f1d..273d7c6b 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -1003,6 +1003,38 @@ func TestResolveEndpoint_ProviderExtraBody(t *testing.T) { } } +func TestResolveEndpoint_ProviderUsesGlobalPromptCaching(t *testing.T) { + clearAllEnv(t) + disabled := false + cfg := configFile{ + Provider: "anthropic", + Providers: map[string]providerEntryConfig{ + "anthropic": {APIKey: "sk-ant-test", Model: "claude-sonnet-4-6"}, + }, + Llm: llmFileConfig{PromptCaching: &disabled}, + } + data, _ := json.Marshal(cfg) + cfgPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + ep, err := ResolveEndpoint(cfgPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.PromptCaching == nil || *ep.PromptCaching { + t.Fatalf("PromptCaching = %v, want false", ep.PromptCaching) + } + client, ok := NewLLMClient(ep).(*AnthropicClient) + if !ok { + t.Fatal("expected AnthropicClient") + } + if client.cfg.PromptCaching == nil || *client.cfg.PromptCaching { + t.Fatalf("client PromptCaching = %v, want false", client.cfg.PromptCaching) + } +} + func TestResolveEndpointWithModelOverride_ValidModelInPresetList(t *testing.T) { clearAllEnv(t) diff --git a/pages/src/content/docs/en/configuration.md b/pages/src/content/docs/en/configuration.md index 52e01b0d..8bfd3452 100644 --- a/pages/src/content/docs/en/configuration.md +++ b/pages/src/content/docs/en/configuration.md @@ -167,6 +167,19 @@ without patching the source: ocr config set providers.anthropic.extra_body '{"thinking":{"type":"disabled"}}' ``` +### Disable Anthropic prompt caching + +Anthropic requests mark the final system block and tool definition with +`cache_control` by default. If an Anthropic-compatible gateway rejects that +field, disable the markers globally: + +```bash +ocr config set llm.prompt_caching false +``` + +The interactive provider setup exposes the same option for Anthropic +providers. Re-enable it with `ocr config set llm.prompt_caching true`. + ## Configuring the review language `language` determines which language review comments are written in;