diff --git a/README.md b/README.md index 1f20ffde..f46f34b1 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ ocr config model # Pick a model for the active provider ![Provider setup](imgs/providers.jpg) -The interactive UI guides you through provider selection, API key entry, and model configuration, then automatically tests connectivity. +The interactive UI guides you through provider selection, model-specific reasoning effort, and API key entry, then automatically tests connectivity. Choose **Provider default** to omit the effort field. For CLI setup, environment variables, custom providers, and other advanced configuration, see [Configuration](https://open-codereview.ai/docs/configuration). diff --git a/README.zh-CN.md b/README.zh-CN.md index 7a3241af..cc934796 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -123,7 +123,7 @@ ocr config model # 为当前供应商选择模型 ![Provider setup](imgs/providers.jpg) -交互式界面会引导你完成供应商选择、API Key 输入和模型配置,完成后自动测试连通性。 +交互式界面会引导你完成供应商选择、模型专属的 reasoning effort 和 API Key 输入,完成后自动测试连通性。选择 **Provider 默认值** 时不会发送 effort 字段。 命令行设置、环境变量、自定义供应商等高级配置,详见[配置指南](https://open-codereview.ai/docs/configuration)。 diff --git a/action.yml b/action.yml index 8812e295..fd325ea7 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,13 @@ inputs: llm_model: description: Model name (mapped to env OCR_LLM_MODEL). required: true + llm_reasoning_effort: + description: >- + Optional model reasoning effort (mapped to env + OCR_LLM_REASONING_EFFORT). Supported values depend on the selected + protocol and model. Anthropic xhigh/max may require overriding + llm_extra_body to remove disabled thinking. + required: false llm_use_anthropic: description: "'true' for Anthropic Claude, 'false' for OpenAI-compatible APIs (mapped to env OCR_USE_ANTHROPIC). Required to force an explicit choice." @@ -267,6 +274,7 @@ runs: OCR_LLM_URL: ${{ inputs.llm_url }} OCR_LLM_TOKEN: ${{ inputs.llm_auth_token }} OCR_LLM_MODEL: ${{ inputs.llm_model }} + OCR_LLM_REASONING_EFFORT: ${{ inputs.llm_reasoning_effort }} OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }} OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }} OCR_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }} diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index ef86b901..39c3e10f 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -26,6 +26,7 @@ Examples: # Provider setup (non-interactive) ocr config set provider anthropic ocr config set model claude-opus-4-6 + ocr config set reasoning_effort high ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY" # Custom provider @@ -37,7 +38,7 @@ Examples: var configSetCmd = &cobra.Command{ Use: "set ", Short: "Set a configuration value", - Example: " ocr config set llm.model claude-opus-4-6\n ocr config set provider anthropic", + Example: " ocr config set llm.model claude-opus-4-6\n ocr config set provider anthropic\n ocr config set reasoning_effort high", Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { return runConfigSet(args[0], args[1]) @@ -47,8 +48,8 @@ var configSetCmd = &cobra.Command{ var configUnsetCmd = &cobra.Command{ Use: "unset ", Short: "Remove a configuration value", - Long: "Remove a provider, custom_providers., or mcp_servers..", - Example: " ocr config unset provider\n ocr config unset custom_providers.my-provider\n ocr config unset mcp_servers.github", + Long: "Remove a provider, the active model's reasoning_effort, custom_providers., or mcp_servers..", + Example: " ocr config unset provider\n ocr config unset reasoning_effort\n ocr config unset custom_providers.my-provider\n ocr config unset mcp_servers.github", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runConfigUnset(args[0]) @@ -66,7 +67,7 @@ var configProviderCmd = &cobra.Command{ var configModelCmd = &cobra.Command{ Use: "model", - Short: "Interactive model selection", + Short: "Interactive model and reasoning effort selection", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runConfigModel() @@ -139,10 +140,24 @@ func runConfigUnset(key string) error { if key == "provider" { return unsetActiveProvider(configPath) } + if key == "reasoning_effort" { + cfg, err := loadOrCreateConfig(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + if err := setConfigValue(cfg, key, "default"); err != nil { + return err + } + if err := saveConfig(configPath, cfg); err != nil { + return err + } + fmt.Println("Unset reasoning_effort for the active model.") + return nil + } parts := strings.SplitN(key, ".", 2) if len(parts) != 2 || parts[1] == "" { - return fmt.Errorf("unset supports provider, custom_providers., and mcp_servers.") + return fmt.Errorf("unset supports provider, reasoning_effort, custom_providers., and mcp_servers.") } switch parts[0] { @@ -151,7 +166,7 @@ func runConfigUnset(key string) error { case "mcp_servers": return unsetMCPServer(configPath, parts[1]) default: - return fmt.Errorf("unset supports provider, custom_providers., and mcp_servers.") + return fmt.Errorf("unset supports provider, reasoning_effort, custom_providers., and mcp_servers.") } } @@ -259,15 +274,20 @@ func deleteCustomProvider(cfg *Config, name string) (bool, error) { // ProviderEntry holds per-provider configuration in the providers map. type ProviderEntry struct { - APIKey string `json:"api_key,omitempty"` - URL string `json:"url,omitempty"` - Protocol string `json:"protocol,omitempty"` - Model string `json:"model,omitempty"` - Models []string `json:"models,omitempty"` - AuthHeader string `json:"auth_header,omitempty"` - 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"` + APIKey string `json:"api_key,omitempty"` + URL string `json:"url,omitempty"` + Protocol string `json:"protocol,omitempty"` + Model string `json:"model,omitempty"` + Models []string `json:"models,omitempty"` + AuthHeader string `json:"auth_header,omitempty"` + 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"` + ModelSettings map[string]ModelSettings `json:"model_settings,omitempty"` +} + +type ModelSettings struct { + ReasoningEffort string `json:"reasoning_effort,omitempty"` } // MCPServerConfig holds configuration for a single MCP server. @@ -296,15 +316,16 @@ type Config struct { } type LlmConfig 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"` // canonical protocol name; takes priority over UseAnthropic - UseAnthropic *bool `json:"use_anthropic,omitempty"` // nil = default true; false = OpenAI protocol (legacy fallback) - 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"` // canonical protocol name; takes priority over UseAnthropic + UseAnthropic *bool `json:"use_anthropic,omitempty"` // nil = default true; false = OpenAI protocol (legacy fallback) + 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"` + ModelSettings map[string]ModelSettings `json:"model_settings,omitempty"` } // TelemetryConfig holds telemetry-specific settings. @@ -352,6 +373,7 @@ func LoadAppConfig(path string) (*Config, error) { var supportedConfigKeys = []string{ "provider", "model", + "reasoning_effort", "providers..", "custom_providers..", "mcp_servers..", @@ -363,6 +385,7 @@ var supportedConfigKeys = []string{ "llm.use_anthropic", "llm.extra_body", "llm.extra_headers", + "llm.reasoning_effort", "language", "telemetry.enabled", "telemetry.exporter", @@ -423,6 +446,8 @@ func setConfigValue(cfg *Config, key, value string) error { } else { cfg.Model = value } + case "reasoning_effort": + return setActiveModelReasoningEffort(cfg, value) case "llm.url", "llm.URL": cfg.Llm.URL = value case "llm.auth_token", "llm.AuthToken": @@ -441,6 +466,19 @@ func setConfigValue(cfg *Config, key, value string) error { cfg.Llm.ExtraHeaders = parsed case "llm.model", "llm.Model": cfg.Llm.Model = value + case "llm.reasoning_effort", "llm.ReasoningEffort": + protocol := cfg.Llm.Protocol + if protocol == "" { + protocol = llm.ProtocolAnthropic + if cfg.Llm.UseAnthropic != nil && !*cfg.Llm.UseAnthropic { + protocol = llm.ProtocolOpenAIChatCompletions + } + } + settings, err := updateReasoningEffort(cfg.Llm.ModelSettings, cfg.Llm.Model, protocol, value) + if err != nil { + return err + } + cfg.Llm.ModelSettings = settings case "llm.protocol", "llm.Protocol": normalized := llm.NormalizeProtocol(value) if err := llm.ValidateProtocol(normalized); err != nil { @@ -501,7 +539,7 @@ func setConfigValue(cfg *Config, key, value string) error { } cfg.Llm.ExtraBody = m default: - return fmt.Errorf("unknown config key: %s\nSupported keys: %s\nProvider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\nProtocol values: anthropic, openai, openai-responses\nMCP server fields: type, command, args, env, url, headers, tools, setup", key, strings.Join(supportedConfigKeys, ", ")) + return fmt.Errorf("unknown config key: %s\nSupported keys: %s\nProvider fields: api_key, url, protocol, model, models, model_settings, auth_header, extra_body, extra_headers\nProtocol values: anthropic, openai, openai-responses\nMCP server fields: type, command, args, env, url, headers, tools, setup", key, strings.Join(supportedConfigKeys, ", ")) } return nil } @@ -526,6 +564,12 @@ func applyProviderField(entry *ProviderEntry, field, key, value string) error { return fmt.Errorf("invalid model list for %s: %w", key, err) } entry.Models = models + case "model_settings": + var settings map[string]ModelSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return fmt.Errorf("invalid JSON for %s: %w", key, err) + } + entry.ModelSettings = settings case "auth_header": normalized, err := llm.NormalizeAuthHeader(value) if err != nil { @@ -545,11 +589,87 @@ func applyProviderField(entry *ProviderEntry, field, key, value string) error { } entry.ExtraHeaders = parsed default: - return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, models, auth_header, extra_body, extra_headers", field) + return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, models, model_settings, auth_header, extra_body, extra_headers", field) + } + return nil +} + +func setActiveModelReasoningEffort(cfg *Config, value string) error { + if cfg.Provider == "" { + protocol := cfg.Llm.Protocol + if protocol == "" { + protocol = llm.ProtocolAnthropic + if cfg.Llm.UseAnthropic != nil && !*cfg.Llm.UseAnthropic { + protocol = llm.ProtocolOpenAIChatCompletions + } + } + settings, err := updateReasoningEffort(cfg.Llm.ModelSettings, cfg.Llm.Model, protocol, value) + if err != nil { + return err + } + cfg.Llm.ModelSettings = settings + return nil + } + + if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset { + if cfg.Providers == nil { + cfg.Providers = make(map[string]ProviderEntry) + } + entry := cfg.Providers[cfg.Provider] + model := activeModelForProvider(cfg, cfg.Provider, entry) + protocol := preset.Protocol + if entry.Protocol != "" { + protocol = entry.Protocol + } + settings, err := updateReasoningEffort(entry.ModelSettings, model, protocol, value) + if err != nil { + return err + } + entry.ModelSettings = settings + cfg.Providers[cfg.Provider] = entry + return nil + } + + entry, ok := cfg.CustomProviders[cfg.Provider] + if !ok { + return fmt.Errorf("provider %q is not configured in custom_providers", cfg.Provider) + } + model := activeModelForProvider(cfg, cfg.Provider, entry) + settings, err := updateReasoningEffort(entry.ModelSettings, model, entry.Protocol, value) + if err != nil { + return err } + entry.ModelSettings = settings + cfg.CustomProviders[cfg.Provider] = entry return nil } +func updateReasoningEffort(settings map[string]ModelSettings, model, protocol, value string) (map[string]ModelSettings, error) { + effort := llm.NormalizeReasoningEffort(value) + model = strings.TrimSpace(model) + if model == "" { + if effort == llm.ReasoningEffortDefault { + return settings, nil + } + return settings, fmt.Errorf("no active model configured; select a model before setting reasoning_effort") + } + if err := llm.ValidateReasoningEffort(protocol, effort); err != nil { + return settings, err + } + if effort == llm.ReasoningEffortDefault { + delete(settings, model) + if len(settings) == 0 { + return nil, nil + } + return settings, nil + } + if settings == nil { + settings = make(map[string]ModelSettings) + } + settings[model] = ModelSettings{ReasoningEffort: effort} + return settings, nil +} + func parseModelListValue(value string) ([]string, error) { value = strings.TrimSpace(value) if value == "" { diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go index 19ec5f5f..f7006e12 100644 --- a/cmd/opencodereview/config_cmd_test.go +++ b/cmd/opencodereview/config_cmd_test.go @@ -71,6 +71,64 @@ func TestSetConfigValueModelWithProvider(t *testing.T) { } } +func TestSetConfigValueReasoningEffortIsPerModel(t *testing.T) { + cfg := &Config{ + Provider: "anthropic", + Model: "claude-opus-4-8", + Providers: map[string]ProviderEntry{ + "anthropic": { + ModelSettings: map[string]ModelSettings{ + "claude-opus-4-7": {ReasoningEffort: "low"}, + }, + }, + }, + } + if err := setConfigValue(cfg, "reasoning_effort", " XHIGH "); err != nil { + t.Fatal(err) + } + settings := cfg.Providers["anthropic"].ModelSettings + if got := settings["claude-opus-4-8"].ReasoningEffort; got != "xhigh" { + t.Errorf("active model effort = %q, want xhigh", got) + } + if got := settings["claude-opus-4-7"].ReasoningEffort; got != "low" { + t.Errorf("other model effort = %q, want low", got) + } + + if err := setConfigValue(cfg, "reasoning_effort", "default"); err != nil { + t.Fatal(err) + } + settings = cfg.Providers["anthropic"].ModelSettings + if _, ok := settings["claude-opus-4-8"]; ok { + t.Error("default should remove only the active model setting") + } + if got := settings["claude-opus-4-7"].ReasoningEffort; got != "low" { + t.Errorf("other model effort after default = %q, want low", got) + } +} + +func TestSetConfigValueReasoningEffortValidatesProtocol(t *testing.T) { + cfg := &Config{ + Provider: "my-anthropic", + Model: "claude-test", + CustomProviders: map[string]ProviderEntry{ + "my-anthropic": {Protocol: llm.ProtocolAnthropic, Model: "claude-test"}, + }, + } + if err := setConfigValue(cfg, "reasoning_effort", "minimal"); err == nil { + t.Fatal("expected Anthropic to reject OpenAI-only minimal effort") + } +} + +func TestUnsetReasoningEffortWithoutActiveModelIsNoOp(t *testing.T) { + cfg := &Config{} + if err := setConfigValue(cfg, "reasoning_effort", "default"); err != nil { + t.Fatalf("unset without active model: %v", err) + } + if cfg.Llm.ModelSettings != nil { + t.Errorf("ModelSettings = %v, want nil", cfg.Llm.ModelSettings) + } +} + func TestSetConfigValueProviderEntry(t *testing.T) { cfg := &Config{} @@ -929,8 +987,8 @@ 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" + - "Provider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\n" + + "Supported keys: provider, model, reasoning_effort, 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, llm.reasoning_effort, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\n" + + "Provider fields: api_key, url, protocol, model, models, model_settings, 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" if err.Error() != want { diff --git a/cmd/opencodereview/llm_cmd.go b/cmd/opencodereview/llm_cmd.go index 692b6946..424d6c90 100644 --- a/cmd/opencodereview/llm_cmd.go +++ b/cmd/opencodereview/llm_cmd.go @@ -99,6 +99,11 @@ func runLLMTest() error { fmt.Printf("Source: %s\n", ep.Source) fmt.Printf("URL: %s\n", ep.URL) fmt.Printf("Model: %s\n", model) + if ep.ReasoningEffort != "" { + fmt.Printf("Reasoning effort: %s\n", ep.ReasoningEffort) + } else { + fmt.Println("Reasoning effort: provider default") + } content := resp.Content() if content == "" { diff --git a/cmd/opencodereview/provider_cmd.go b/cmd/opencodereview/provider_cmd.go index f67da930..b8645bc7 100644 --- a/cmd/opencodereview/provider_cmd.go +++ b/cmd/opencodereview/provider_cmd.go @@ -129,6 +129,13 @@ func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) f := false cfg.Llm.UseAnthropic = &f } + if result.reasoningEffortSet { + settings, err := updateReasoningEffort(cfg.Llm.ModelSettings, result.model, protocol, result.reasoningEffort) + if err != nil { + return err + } + cfg.Llm.ModelSettings = settings + } if err := saveConfig(configPath, cfg); err != nil { return err @@ -138,6 +145,7 @@ func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) fmt.Printf("URL: %s\n", result.url) fmt.Printf("Protocol: %s\n", protocol) fmt.Printf("Model: %s\n", result.model) + printReasoningEffortResult(result) fmt.Println("\nTesting connection...") if err := runLLMTest(); err != nil { @@ -186,6 +194,13 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU } else { entry.APIKey = "" } + if result.reasoningEffortSet { + settings, err := updateReasoningEffort(entry.ModelSettings, model, entry.Protocol, result.reasoningEffort) + if err != nil { + return err + } + entry.ModelSettings = settings + } cfg.CustomProviders[result.provider] = entry if !result.isEdit { @@ -206,12 +221,14 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider) } fmt.Printf("Model: %s\n", model) + printReasoningEffortResult(result) fmt.Println("\nTip: run 'ocr config model' to switch model later.") return nil } fmt.Printf("\nProvider set to: %s (custom)\n", result.provider) fmt.Printf("Model: %s\n", model) + printReasoningEffortResult(result) fmt.Println("\nTesting connection...") if err := runLLMTest(); err != nil { @@ -260,6 +277,17 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider // Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR. entry.APIKey = "" } + if result.reasoningEffortSet { + protocol := preset.Protocol + if entry.Protocol != "" { + protocol = entry.Protocol + } + settings, err := updateReasoningEffort(entry.ModelSettings, model, protocol, result.reasoningEffort) + if err != nil { + return err + } + entry.ModelSettings = settings + } cfg.Providers[result.provider] = entry if cfg.Provider != result.provider { @@ -274,6 +302,7 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider fmt.Printf("\nProvider set to: %s\n", result.provider) fmt.Printf("Model: %s\n", model) + printReasoningEffortResult(result) fmt.Println("\nTesting connection...") if err := runLLMTest(); err != nil { @@ -286,6 +315,17 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider return nil } +func printReasoningEffortResult(result providerTUIResult) { + if !result.reasoningEffortSet { + return + } + if result.reasoningEffort == llm.ReasoningEffortDefault { + fmt.Println("Reasoning effort: provider default") + return + } + fmt.Printf("Reasoning effort: %s\n", result.reasoningEffort) +} + func runConfigModel() error { configPath, err := defaultConfigPath() if err != nil { @@ -358,6 +398,11 @@ func runConfigModel() error { entry := cfg.CustomProviders[cfg.Provider] entry.Model = selectedModel entry.Models = ensureModelInList(entry.Models, selectedModel) + settings, err := updateReasoningEffort(entry.ModelSettings, selectedModel, provider.Protocol, final.selectedReasoningEffort()) + if err != nil { + return err + } + entry.ModelSettings = settings cfg.CustomProviders[cfg.Provider] = entry } else { if cfg.Providers == nil { @@ -365,6 +410,11 @@ func runConfigModel() error { } entry := cfg.Providers[cfg.Provider] entry.Model = selectedModel + settings, err := updateReasoningEffort(entry.ModelSettings, selectedModel, provider.Protocol, final.selectedReasoningEffort()) + if err != nil { + return err + } + entry.ModelSettings = settings // Use registry-only list: provider.Models was captured before the TUI and // may include stale entry.Models from add/delete during the session. if !llm.ModelListContains(registryModels, selectedModel) { @@ -379,6 +429,11 @@ func runConfigModel() error { } fmt.Printf("\nModel set to: %s\n", selectedModel) + if effort := final.selectedReasoningEffort(); effort != "" { + fmt.Printf("Reasoning effort: %s\n", effort) + } else { + fmt.Println("Reasoning effort: provider default") + } return nil } diff --git a/cmd/opencodereview/provider_tui.go b/cmd/opencodereview/provider_tui.go index 31fa9e55..8763ad07 100644 --- a/cmd/opencodereview/provider_tui.go +++ b/cmd/opencodereview/provider_tui.go @@ -18,6 +18,7 @@ type tuiStep int const ( stepProvider tuiStep = iota stepModel + stepEffort stepAPIKey ) @@ -46,6 +47,7 @@ const ( manualStepURL manualStep = iota manualStepProtocol manualStepModel + manualStepEffort manualStepAuthToken manualStepAuthHeader ) @@ -66,18 +68,20 @@ type customProviderListItem struct { } type providerTUIResult struct { - provider string - model string - models []string - apiKey string - isCustom bool - isEdit bool - editTargetName string - isManual bool - url string - protocol string - authHeader string - sessionModelPick map[string]string + provider string + model string + models []string + apiKey string + isCustom bool + isEdit bool + editTargetName string + isManual bool + url string + protocol string + authHeader string + reasoningEffort string + reasoningEffortSet bool + sessionModelPick map[string]string } // resolvedModel returns the model to persist, falling back to the in-session pick @@ -147,9 +151,15 @@ type providerTUIModel struct { manualTokenOriginal string // --- shared model/api-key steps (official + existing custom) --- - modelIdx int - customModel bool - modelInput textinput.Model + modelIdx int + customModel bool + modelInput textinput.Model + pendingModel string + addingModel bool + effortOptions []string + effortIdx int + reasoningEffort string + reasoningEffortSet bool apiKeyInput textinput.Model apiKeyMasked bool @@ -430,6 +440,10 @@ func registryModelsForProvider(name string, fallback []string) []string { func applyModelDeleteToEntry(entry ProviderEntry, name string) ProviderEntry { entry.Models = removeModels(entry.Models, []string{name}) + delete(entry.ModelSettings, name) + if len(entry.ModelSettings) == 0 { + entry.ModelSettings = nil + } if entry.Model == name { entry.Model = "" } @@ -643,6 +657,14 @@ func (m providerTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancelled = true return m, tea.Quit } + if m.step == stepEffort && m.addingModel { + m.step = stepModel + m.addingModel = false + m.pendingModel = "" + m.customModel = true + m.formError = "" + return m, m.modelInput.Focus() + } m.step-- m.formError = "" return m, nil @@ -746,22 +768,9 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg return m, nil } m.formError = "" - persisted, err := m.persistCustomModelName(name) - if err != nil { - m.formError = err.Error() - return m, nil - } - if !persisted { - // No active provider context — refuse with an error message. - m.formError = "no active provider to attach this model to" - return m, nil - } m.customModel = false m.modelInput.Blur() - m.modelInput.SetValue("") - // Reposition the cursor on the first newly-added model so the user - // can see what just landed. - m.refreshModelSelectionForCustom() + m.beginProviderEffortSelection(name, true) return m, nil default: var cmd tea.Cmd @@ -777,7 +786,7 @@ func (m providerTUIModel) updateCustomModelInput(key string, msg tea.KeyPressMsg // // Returns (persisted, error). When no provider is active (neither official // nor custom), persisted is false and the caller decides how to handle it. -func (m *providerTUIModel) persistCustomModelName(name string) (bool, error) { +func (m *providerTUIModel) persistCustomModelName(name, reasoningEffort string) (bool, error) { if name == "" { return false, fmt.Errorf("model name must not be empty") } @@ -793,6 +802,11 @@ func (m *providerTUIModel) persistCustomModelName(name string) (bool, error) { entry := m.customProviderEntry(cp.name, cp.entry) prevEntry := cloneProviderEntry(entry) entry.Models = append(entry.Models, name) + settings, err := updateReasoningEffort(entry.ModelSettings, name, entry.Protocol, reasoningEffort) + if err != nil { + return false, err + } + entry.ModelSettings = settings if m.existingCfg.CustomProviders == nil { m.existingCfg.CustomProviders = make(map[string]ProviderEntry) } @@ -822,6 +836,11 @@ func (m *providerTUIModel) persistCustomModelName(name string) (bool, error) { entry := m.existingCfg.Providers[provider.Name] prevEntry := cloneProviderEntry(entry) entry.Models = append(entry.Models, name) + settings, err := updateReasoningEffort(entry.ModelSettings, name, provider.Protocol, reasoningEffort) + if err != nil { + return false, err + } + entry.ModelSettings = settings m.existingCfg.Providers[provider.Name] = entry // Intentionally do not mutate m.providers[officialIdx].Models: that slice // is a read-only snapshot from the provider registry (llm.ListProviders). @@ -932,7 +951,7 @@ func (m providerTUIModel) updateAPIKeyInput(key string, msg tea.KeyPressMsg) (te switch key { case "esc": m.apiKeyInput.Blur() - m.step = stepModel + m.step = stepEffort m.formError = "" return m, nil case "enter": @@ -1187,6 +1206,12 @@ func cloneProviderEntry(v ProviderEntry) ProviderEntry { Models: append([]string(nil), v.Models...), AuthHeader: v.AuthHeader, } + if v.ModelSettings != nil { + out.ModelSettings = make(map[string]ModelSettings, len(v.ModelSettings)) + for name, settings := range v.ModelSettings { + out.ModelSettings[name] = settings + } + } if v.ExtraBody != nil { out.ExtraBody = make(map[string]any, len(v.ExtraBody)) for k, val := range v.ExtraBody { @@ -1397,6 +1422,24 @@ func (m providerTUIModel) updateManualForm(key string, msg tea.KeyPressMsg) (tea return m, nil } } + if m.manualStep == manualStepEffort { + switch key { + case "up", "k": + if m.effortIdx > 0 { + m.effortIdx-- + } else if len(m.effortOptions) > 0 { + m.effortIdx = len(m.effortOptions) - 1 + } + return m, nil + case "down", "j": + if m.effortIdx < len(m.effortOptions)-1 { + m.effortIdx++ + } else { + m.effortIdx = 0 + } + return m, nil + } + } if m.manualStep == manualStepAuthToken && m.manualTokenMasked { m.beginManualTokenReplace() } @@ -1603,6 +1646,14 @@ func (m providerTUIModel) handleManualFormEnter() (tea.Model, tea.Cmd) { return m, nil } m.manualModelInput.Blur() + m.beginManualEffortSelection() + return m, nil + case manualStepEffort: + if len(m.effortOptions) == 0 { + return m, nil + } + m.reasoningEffort = m.effortOptions[m.effortIdx] + m.reasoningEffortSet = true m.manualStep = manualStepAuthToken return m, m.manualTokenInput.Focus() case manualStepAuthToken: @@ -1719,6 +1770,8 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) { case tabManual: m.inManualForm = true m.manualStep = manualStepURL + m.reasoningEffort = llm.ReasoningEffortDefault + m.reasoningEffortSet = false return m, m.manualURLInput.Focus() } @@ -1731,10 +1784,11 @@ func (m providerTUIModel) handleEnter() (tea.Model, tea.Cmd) { m.formError = err.Error() return m, nil } - m.step = stepAPIKey - m.formError = "" - m.loadExistingAPIKey() - return m, m.apiKeyInput.Focus() + m.beginProviderEffortSelection(m.selectedModelFromState(), false) + return m, nil + + case stepEffort: + return m.confirmProviderEffort() } return m, nil } @@ -1762,6 +1816,12 @@ func (m providerTUIModel) handleUp() (tea.Model, tea.Cmd) { } else { m.modelIdx = m.modelCount() - 1 } + case stepEffort: + if m.effortIdx > 0 { + m.effortIdx-- + } else if len(m.effortOptions) > 0 { + m.effortIdx = len(m.effortOptions) - 1 + } } return m, nil } @@ -1789,10 +1849,103 @@ func (m providerTUIModel) handleDown() (tea.Model, tea.Cmd) { } else { m.modelIdx = 0 } + case stepEffort: + if m.effortIdx < len(m.effortOptions)-1 { + m.effortIdx++ + } else { + m.effortIdx = 0 + } } return m, nil } +func (m *providerTUIModel) beginManualEffortSelection() { + protocol := cpProtocols[m.manualProtocolIdx] + m.effortOptions = append([]string{llm.ReasoningEffortDefault}, llm.ReasoningEffortOptions(protocol)...) + m.effortIdx = 0 + if m.existingCfg != nil && m.existingCfg.Llm.Model == m.manualModelInput.Value() { + current := llm.NormalizeReasoningEffort(m.existingCfg.Llm.ModelSettings[m.manualModelInput.Value()].ReasoningEffort) + for i, effort := range m.effortOptions { + if effort == current { + m.effortIdx = i + break + } + } + } + m.manualStep = manualStepEffort +} + +func (m *providerTUIModel) beginProviderEffortSelection(model string, adding bool) { + m.pendingModel = model + m.addingModel = adding + m.effortOptions = append([]string{llm.ReasoningEffortDefault}, llm.ReasoningEffortOptions(m.modelProtocol())...) + m.effortIdx = 0 + current := m.reasoningEffortForSelectedProvider(model) + for i, effort := range m.effortOptions { + if effort == current { + m.effortIdx = i + break + } + } + m.step = stepEffort +} + +func (m providerTUIModel) modelProtocol() string { + if m.activeTab == tabCustom { + if cp, ok := m.selectedCustomProvider(); ok { + return cp.entry.Protocol + } + return "" + } + return m.currentProvider().Protocol +} + +func (m providerTUIModel) reasoningEffortForSelectedProvider(model string) string { + if m.existingCfg == nil || model == "" { + return llm.ReasoningEffortDefault + } + var settings map[string]ModelSettings + if m.activeTab == tabCustom { + if cp, ok := m.selectedCustomProvider(); ok { + settings = m.customProviderEntry(cp.name, cp.entry).ModelSettings + } + } else { + settings = m.existingCfg.Providers[m.currentProvider().Name].ModelSettings + } + return llm.NormalizeReasoningEffort(settings[model].ReasoningEffort) +} + +func (m providerTUIModel) confirmProviderEffort() (tea.Model, tea.Cmd) { + if len(m.effortOptions) == 0 { + return m, nil + } + effort := m.effortOptions[m.effortIdx] + if m.addingModel { + name := m.pendingModel + persisted, err := m.persistCustomModelName(name, effort) + if err != nil { + m.formError = err.Error() + return m, nil + } + if !persisted { + m.formError = "no active provider to attach this model to" + return m, nil + } + m.addingModel = false + m.pendingModel = "" + m.modelInput.SetValue("") + m.refreshModelSelectionForCustom() + m.step = stepModel + return m, nil + } + m.reasoningEffort = effort + m.reasoningEffortSet = true + m.step = stepAPIKey + m.formError = "" + m.loadExistingAPIKey() + return m, m.apiKeyInput.Focus() +} + func (m *providerTUIModel) loadExistingAPIKey() { m.apiKeyMasked = false m.apiKeyOriginal = "" @@ -1844,10 +1997,12 @@ func (m providerTUIModel) result() providerTUIResult { } return providerTUIResult{ - provider: p.Name, - model: model, - apiKey: apiKey, - sessionModelPick: m.sessionModelPickSnapshot(), + provider: p.Name, + model: model, + apiKey: apiKey, + reasoningEffort: m.reasoningEffort, + reasoningEffortSet: m.reasoningEffortSet, + sessionModelPick: m.sessionModelPickSnapshot(), } case tabCustom: @@ -1894,15 +2049,17 @@ func (m providerTUIModel) result() providerTUIResult { apiKey = strings.TrimSpace(m.apiKeyInput.Value()) } return providerTUIResult{ - provider: cp.name, - model: model, - models: append([]string(nil), cp.entry.Models...), - apiKey: apiKey, - isCustom: true, - url: cp.entry.URL, - protocol: cp.entry.Protocol, - authHeader: cp.entry.AuthHeader, - sessionModelPick: m.sessionModelPickSnapshot(), + provider: cp.name, + model: model, + models: append([]string(nil), cp.entry.Models...), + apiKey: apiKey, + isCustom: true, + url: cp.entry.URL, + protocol: cp.entry.Protocol, + authHeader: cp.entry.AuthHeader, + reasoningEffort: m.reasoningEffort, + reasoningEffortSet: m.reasoningEffortSet, + sessionModelPick: m.sessionModelPickSnapshot(), } } return providerTUIResult{} @@ -1914,12 +2071,14 @@ 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, + reasoningEffort: m.reasoningEffort, + reasoningEffortSet: m.reasoningEffortSet, } } @@ -1957,6 +2116,13 @@ func renderModelName(name string, isCursor, userAdded bool) string { return renderListName(name, isCursor) } +func reasoningEffortLabel(effort string) string { + if effort == llm.ReasoningEffortDefault { + return "Provider default" + } + return effort +} + // --- View --- func (m providerTUIModel) View() tea.View { @@ -1968,6 +2134,8 @@ func (m providerTUIModel) View() tea.View { m.viewProvider(&s) case stepModel: m.viewModel(&s) + case stepEffort: + m.viewProviderEffort(&s) case stepAPIKey: m.viewAPIKey(&s) } @@ -2186,6 +2354,7 @@ func (m providerTUIModel) viewManualTab(s *strings.Builder) { {"URL", m.manualURLInput.Value(), m.manualStep == manualStepURL}, {"Protocol", cpProtocols[m.manualProtocolIdx], m.manualStep == manualStepProtocol}, {"Model", m.manualModelInput.Value(), m.manualStep == manualStepModel}, + {"Reasoning effort", reasoningEffortLabel(m.reasoningEffort), m.manualStep == manualStepEffort}, {"Auth Token", strings.Repeat("*", len(m.manualTokenInput.Value())), m.manualStep == manualStepAuthToken}, {"Auth Header", m.manualAuthHeaderInput.Value(), m.manualStep == manualStepAuthHeader}, } @@ -2208,6 +2377,12 @@ func (m providerTUIModel) viewManualTab(s *strings.Builder) { } case manualStepModel: s.WriteString(" " + m.manualModelInput.View() + "\n") + case manualStepEffort: + for i, effort := range m.effortOptions { + label := reasoningEffortLabel(effort) + isCursor := i == m.effortIdx + s.WriteString(" " + listCursorPrefix(isCursor) + renderListName(label, isCursor) + "\n") + } case manualStepAuthToken: s.WriteString(" " + m.manualTokenInput.View() + "\n") if m.manualTokenMasked && m.manualTokenOriginal != "" { @@ -2247,12 +2422,12 @@ func (m providerTUIModel) viewModel(s *strings.Builder) { if m.activeTab == tabOfficial { userAdded := m.isUserAddedOfficialModel(model) s.WriteString(listCursorPrefixForModel(isCursor, userAdded)) - s.WriteString(renderModelName(model, isCursor, userAdded)) + s.WriteString(renderModelName(m.providerModelLabel(model), isCursor, userAdded)) } else { // Custom tab: all models are user-managed; pass isCursor as userAdded // so green highlight applies only to the selected row (not registry semantics). s.WriteString(listCursorPrefixForModel(isCursor, isCursor)) - s.WriteString(renderModelName(model, isCursor, isCursor)) + s.WriteString(renderModelName(m.providerModelLabel(model), isCursor, isCursor)) } s.WriteString("\n") } @@ -2291,6 +2466,35 @@ func (m providerTUIModel) viewModel(s *strings.Builder) { s.WriteString("\n") } +func (m providerTUIModel) providerModelLabel(model string) string { + effort := m.reasoningEffortForSelectedProvider(model) + if effort == llm.ReasoningEffortDefault { + return model + } + return fmt.Sprintf("%s [effort: %s]", model, effort) +} + +func (m providerTUIModel) viewProviderEffort(s *strings.Builder) { + s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select reasoning effort (%s)", m.pendingModel))) + s.WriteString("\n\n") + for i, effort := range m.effortOptions { + label := effort + if effort == llm.ReasoningEffortDefault { + label = "Provider default" + } + isCursor := i == m.effortIdx + s.WriteString(listCursorPrefix(isCursor) + renderListName(label, isCursor) + "\n") + } + if m.formError != "" { + s.WriteString("\n") + s.WriteString(tuiErrorStyle.Render(" " + m.formError)) + s.WriteString("\n") + } + s.WriteString("\n") + s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Back")) + s.WriteString("\n") +} + func (m providerTUIModel) viewAPIKey(s *strings.Builder) { var title string if m.activeTab == tabCustom && m.customIdx < len(m.customProviders) { @@ -2451,12 +2655,18 @@ type modelTUIModel struct { width int height int - provider llm.Provider - models []string - modelIdx int - customModel bool - modelInput textinput.Model - activeModel string + provider llm.Provider + models []string + modelIdx int + customModel bool + modelInput textinput.Model + activeModel string + selectingEffort bool + effortOptions []string + effortIdx int + pendingModel string + addingModel bool + selectedEffort string registryModels []string existingCfg *Config @@ -2626,7 +2836,7 @@ func (m *modelTUIModel) refreshModelSelectionAfterAdd(name string) { // persistAddedModelName appends a model to the provider's Models list in config // and saves to disk. It does not change the active model. -func (m *modelTUIModel) persistAddedModelName(name string) error { +func (m *modelTUIModel) persistAddedModelName(name, reasoningEffort string) error { if name == "" { return fmt.Errorf("model name must not be empty") } @@ -2640,6 +2850,11 @@ func (m *modelTUIModel) persistAddedModelName(name string) error { entry := m.existingCfg.CustomProviders[m.providerName] prevEntry := cloneProviderEntry(entry) entry.Models = ensureModelInList(entry.Models, name) + settings, err := updateReasoningEffort(entry.ModelSettings, name, m.provider.Protocol, reasoningEffort) + if err != nil { + return err + } + entry.ModelSettings = settings m.existingCfg.CustomProviders[m.providerName] = entry if m.configPath != "" { if err := saveConfig(m.configPath, m.existingCfg); err != nil { @@ -2660,6 +2875,11 @@ func (m *modelTUIModel) persistAddedModelName(name string) error { entry := m.existingCfg.Providers[m.providerName] prevEntry := cloneProviderEntry(entry) entry.Models = ensureModelInList(entry.Models, name) + settings, err := updateReasoningEffort(entry.ModelSettings, name, m.provider.Protocol, reasoningEffort) + if err != nil { + return err + } + entry.ModelSettings = settings m.existingCfg.Providers[m.providerName] = entry if m.configPath != "" { if err := saveConfig(m.configPath, m.existingCfg); err != nil { @@ -2698,6 +2918,9 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.confirmingDeleteModel { return m.updateDeleteModelConfirm(key) } + if m.selectingEffort { + return m.updateEffortSelection(key) + } if m.customModel { switch key { @@ -2716,14 +2939,9 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.formError = "" - if err := m.persistAddedModelName(name); err != nil { - m.formError = err.Error() - return m, nil - } m.customModel = false m.modelInput.Blur() - m.modelInput.SetValue("") - m.refreshModelSelectionAfterAdd(name) + m.beginEffortSelection(name, true) return m, nil default: var cmd tea.Cmd @@ -2742,8 +2960,11 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.customModel = true return m, m.modelInput.Focus() } - m.confirmed = true - return m, tea.Quit + models := m.displayModels() + if m.modelIdx < len(models) { + m.beginEffortSelection(models[m.modelIdx], false) + } + return m, nil case "up", "k": if m.modelIdx > 0 { m.modelIdx-- @@ -2777,6 +2998,86 @@ func (m modelTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *modelTUIModel) beginEffortSelection(model string, adding bool) { + m.selectingEffort = true + m.pendingModel = model + m.addingModel = adding + m.effortOptions = append([]string{llm.ReasoningEffortDefault}, llm.ReasoningEffortOptions(m.provider.Protocol)...) + m.effortIdx = 0 + current := m.reasoningEffortForModel(model) + for i, effort := range m.effortOptions { + if effort == current { + m.effortIdx = i + break + } + } +} + +func (m modelTUIModel) reasoningEffortForModel(model string) string { + if m.existingCfg == nil || m.providerName == "" { + return llm.ReasoningEffortDefault + } + var settings map[string]ModelSettings + if m.isCustomProvider { + settings = m.existingCfg.CustomProviders[m.providerName].ModelSettings + } else { + settings = m.existingCfg.Providers[m.providerName].ModelSettings + } + return llm.NormalizeReasoningEffort(settings[model].ReasoningEffort) +} + +func (m modelTUIModel) updateEffortSelection(key string) (tea.Model, tea.Cmd) { + switch key { + case "ctrl+c": + m.cancelled = true + return m, tea.Quit + case "esc": + m.selectingEffort = false + m.pendingModel = "" + m.formError = "" + if m.addingModel { + m.addingModel = false + m.customModel = true + return m, m.modelInput.Focus() + } + return m, nil + case "up", "k": + if m.effortIdx > 0 { + m.effortIdx-- + } else { + m.effortIdx = len(m.effortOptions) - 1 + } + return m, nil + case "down", "j": + if m.effortIdx < len(m.effortOptions)-1 { + m.effortIdx++ + } else { + m.effortIdx = 0 + } + return m, nil + case "enter": + effort := m.effortOptions[m.effortIdx] + if m.addingModel { + name := m.pendingModel + if err := m.persistAddedModelName(name, effort); err != nil { + m.formError = err.Error() + return m, nil + } + m.selectingEffort = false + m.addingModel = false + m.pendingModel = "" + m.modelInput.SetValue("") + m.refreshModelSelectionAfterAdd(name) + return m, nil + } + m.selectedEffort = effort + m.selectingEffort = false + m.confirmed = true + return m, tea.Quit + } + return m, nil +} + func (m *modelTUIModel) updateDeleteModelConfirm(key string) (tea.Model, tea.Cmd) { switch key { case "y", "Y": @@ -2881,6 +3182,9 @@ func (m *modelTUIModel) resetCustomModelInput() { } func (m modelTUIModel) selectedModel() string { + if m.pendingModel != "" && m.confirmed { + return m.pendingModel + } if m.customModel || m.isCustomItem(m.modelIdx) { return m.modelInput.Value() } @@ -2891,9 +3195,36 @@ func (m modelTUIModel) selectedModel() string { return "" } +func (m modelTUIModel) selectedReasoningEffort() string { + return m.selectedEffort +} + func (m modelTUIModel) View() tea.View { var s strings.Builder s.WriteString("\n") + if m.selectingEffort { + s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select reasoning effort (%s)", m.pendingModel))) + s.WriteString("\n\n") + for i, effort := range m.effortOptions { + label := effort + if effort == llm.ReasoningEffortDefault { + label = "Provider default" + } + isCursor := i == m.effortIdx + s.WriteString(listCursorPrefix(isCursor) + renderListName(label, isCursor) + "\n") + } + if m.formError != "" { + s.WriteString("\n") + s.WriteString(tuiErrorStyle.Render(" " + m.formError)) + s.WriteString("\n") + } + s.WriteString("\n") + s.WriteString(tuiHelpStyle.Render(" ↑/↓ Select Enter Confirm Esc Back")) + s.WriteString("\n") + v := tea.NewView(s.String()) + v.AltScreen = true + return v + } s.WriteString(tuiTitleStyle.Render(fmt.Sprintf(" Select a model (%s)", m.provider.DisplayName))) s.WriteString("\n\n") @@ -2903,11 +3234,11 @@ func (m modelTUIModel) View() tea.View { if m.isCustomProvider { // All models are user-managed; isCursor drives green highlight on selection. s.WriteString(listCursorPrefixForModel(isCursor, isCursor)) - s.WriteString(renderModelName(model, isCursor, isCursor)) + s.WriteString(renderModelName(m.modelLabel(model), isCursor, isCursor)) } else { userAdded := m.isUserAddedModel(model) s.WriteString(listCursorPrefixForModel(isCursor, userAdded)) - s.WriteString(renderModelName(model, isCursor, userAdded)) + s.WriteString(renderModelName(m.modelLabel(model), isCursor, userAdded)) } s.WriteString("\n") } @@ -2951,3 +3282,11 @@ func (m modelTUIModel) View() tea.View { v.AltScreen = true return v } + +func (m modelTUIModel) modelLabel(model string) string { + effort := m.reasoningEffortForModel(model) + if effort == llm.ReasoningEffortDefault { + return model + } + return fmt.Sprintf("%s [effort: %s]", model, effort) +} diff --git a/cmd/opencodereview/provider_tui_funcs_test.go b/cmd/opencodereview/provider_tui_funcs_test.go index 1462b3a7..84fb9500 100644 --- a/cmd/opencodereview/provider_tui_funcs_test.go +++ b/cmd/opencodereview/provider_tui_funcs_test.go @@ -1018,6 +1018,11 @@ func modelTUIEnterCustomModelName(t *testing.T, m modelTUIModel, name string) mo } m2.modelInput.SetValue(name) result, _ = m2.Update(enterKey()) + m3 := result.(modelTUIModel) + if !m3.selectingEffort { + t.Fatal("expected reasoning effort selection after entering model name") + } + result, _ = m3.Update(enterKey()) // provider default return result.(modelTUIModel) } @@ -1066,6 +1071,11 @@ func TestModelTUI_Official_ListEnterConfirmsSelection(t *testing.T) { m2.modelIdx = modelTUIIdxForName(t, m2, "picked-model") result, _ := m2.Update(enterKey()) m3 := result.(modelTUIModel) + if !m3.selectingEffort { + t.Fatal("enter on list item should open reasoning effort selection") + } + result, _ = m3.Update(enterKey()) // keep provider default + m3 = result.(modelTUIModel) if !m3.confirmed { t.Error("enter on list item should confirm selection") } @@ -1094,6 +1104,44 @@ func TestModelTUI_CustomProvider_AddCustomModelStaysOnList(t *testing.T) { } } +func TestModelTUI_AddCustomModelStoresSelectedReasoningEffort(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + m := customConfigModelTUI(t, configPath, []string{"m1"}) + m.provider.Protocol = llm.ProtocolOpenAIChatCompletions + m.modelIdx = len(m.displayModels()) + result, _ := m.Update(enterKey()) + m2 := result.(modelTUIModel) + m2.modelInput.SetValue("reasoning-model") + result, _ = m2.Update(enterKey()) + m3 := result.(modelTUIModel) + if !m3.selectingEffort { + t.Fatal("expected effort selection before persisting custom model") + } + for i, effort := range m3.effortOptions { + if effort == "high" { + m3.effortIdx = i + } + } + result, _ = m3.Update(enterKey()) + m4 := result.(modelTUIModel) + setting := m4.existingCfg.CustomProviders["my-llm"].ModelSettings["reasoning-model"] + if setting.ReasoningEffort != "high" { + t.Errorf("stored effort = %q, want high", setting.ReasoningEffort) + } +} + +func TestModelTUI_AnthropicEffortOptionsExcludeOpenAIOnlyValues(t *testing.T) { + m := customConfigModelTUI(t, "", []string{"m1"}) + m.provider.Protocol = llm.ProtocolAnthropic + m.beginEffortSelection("m1", false) + for _, effort := range m.effortOptions { + if effort == "none" || effort == "minimal" { + t.Errorf("Anthropic effort options include OpenAI-only value %q", effort) + } + } +} + func TestModelTUI_EscCancelWithoutChangesNoSavedInSession(t *testing.T) { m := officialConfigModelTUI(t, "", nil) result, _ := m.Update(escKey()) diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go index e15dd4d3..1546bfd5 100644 --- a/cmd/opencodereview/provider_tui_test.go +++ b/cmd/opencodereview/provider_tui_test.go @@ -176,7 +176,7 @@ func TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) { } } -func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) { +func TestProviderTUI_EscFromAPIKeyGoesBackThroughEffortToModel(t *testing.T) { m := newProviderTUI(&Config{}, "") result, _ := m.Update(enterKey()) @@ -184,14 +184,25 @@ func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) { result, _ = m2.Update(enterKey()) m3 := result.(providerTUIModel) - if m3.step != stepAPIKey { - t.Fatalf("after 2x Enter, step = %d, want %d (stepAPIKey)", m3.step, stepAPIKey) + if m3.step != stepEffort { + t.Fatalf("after 2x Enter, step = %d, want %d (stepEffort)", m3.step, stepEffort) } - result, _ = m3.Update(escKey()) + result, _ = m3.Update(enterKey()) m4 := result.(providerTUIModel) - if m4.step != stepModel { - t.Errorf("after Esc on stepAPIKey, step = %d, want %d (stepModel)", m4.step, stepModel) + if m4.step != stepAPIKey { + t.Fatalf("after effort confirm, step = %d, want %d (stepAPIKey)", m4.step, stepAPIKey) + } + + result, _ = m4.Update(escKey()) + m5 := result.(providerTUIModel) + if m5.step != stepEffort { + t.Errorf("after Esc on stepAPIKey, step = %d, want %d (stepEffort)", m5.step, stepEffort) + } + result, _ = m5.Update(escKey()) + m6 := result.(providerTUIModel) + if m6.step != stepModel { + t.Errorf("after Esc on stepEffort, step = %d, want %d (stepModel)", m6.step, stepModel) } } @@ -281,12 +292,18 @@ func TestProviderTUI_ManualFormEscRefocusesPreviousInput(t *testing.T) { value: func(m providerTUIModel) string { return m.manualURLInput.Value() }, }, { - name: "auth token back to model", - fromStep: manualStepAuthToken, + name: "effort back to model", + fromStep: manualStepEffort, wantStep: manualStepModel, focused: func(m providerTUIModel) bool { return m.manualModelInput.Focused() }, value: func(m providerTUIModel) string { return m.manualModelInput.Value() }, }, + { + name: "auth token back to effort", + fromStep: manualStepAuthToken, + wantStep: manualStepEffort, + focused: func(m providerTUIModel) bool { return true }, + }, { name: "auth header back to auth token", fromStep: manualStepAuthHeader, @@ -312,10 +329,12 @@ func TestProviderTUI_ManualFormEscRefocusesPreviousInput(t *testing.T) { t.Fatal("previous step input should be focused after Esc") } - result, _ = m2.Update(charKey('x')) - m3 := result.(providerTUIModel) - if got := tt.value(m3); !strings.Contains(got, "x") { - t.Errorf("input should accept typing after Esc: value = %q", got) + if tt.value != nil { + result, _ = m2.Update(charKey('x')) + m3 := result.(providerTUIModel) + if got := tt.value(m3); !strings.Contains(got, "x") { + t.Errorf("input should accept typing after Esc: value = %q", got) + } } }) } @@ -908,13 +927,18 @@ func TestProviderTUI_EditCustomClearKey_NoMaskedOnStepAPIKey(t *testing.T) { m2.modelIdx = modelIdxForName(t, m2, "test") result, _ = m2.Update(enterKey()) m3 := result.(providerTUIModel) - if m3.step != stepAPIKey { - t.Fatalf("step = %d, want stepAPIKey", m3.step) + if m3.step != stepEffort { + t.Fatalf("step = %d, want stepEffort", m3.step) } - if m3.apiKeyMasked { + result, _ = m3.Update(enterKey()) + m4 := result.(providerTUIModel) + if m4.step != stepAPIKey { + t.Fatalf("step = %d, want stepAPIKey", m4.step) + } + if m4.apiKeyMasked { t.Error("apiKeyMasked should be false after clearing key in edit") } - got := stripANSI(m3.View().Content) + got := stripANSI(m4.View().Content) if strings.Contains(got, "Type or paste to replace the saved key") { t.Errorf("view should not show replace hint; got:\n%s", got) } @@ -1337,6 +1361,11 @@ func TestProviderTUI_CustomModelInput_AddsSingleName(t *testing.T) { result, _ := m.Update(enterKey()) m2 := result.(providerTUIModel) + if m2.step != stepEffort { + t.Fatalf("step = %d, want stepEffort before model is persisted", m2.step) + } + result, _ = m2.Update(enterKey()) // provider default + m2 = result.(providerTUIModel) if m2.customModel { t.Error("customModel should be cleared after Enter") @@ -1440,6 +1469,11 @@ func TestProviderTUI_OfficialTab_CustomModelInput_PersistsName(t *testing.T) { result, _ := m.Update(enterKey()) m2 := result.(providerTUIModel) + if m2.step != stepEffort { + t.Fatalf("step = %d, want stepEffort before model is persisted", m2.step) + } + result, _ = m2.Update(enterKey()) // provider default + m2 = result.(providerTUIModel) if m2.customModel { t.Error("customModel should be cleared after Enter") @@ -1866,6 +1900,8 @@ func TestProviderTUI_PersistCustomModelName_SaveFailureRollsBack(t *testing.T) { result, _ := m.Update(enterKey()) m2 := result.(providerTUIModel) + result, _ = m2.Update(enterKey()) // trigger persistence after effort selection + m2 = result.(providerTUIModel) if m2.formError == "" { t.Fatal("expected formError on save failure") } @@ -2489,8 +2525,8 @@ func TestProviderTUI_CancelIncompleteOfficialProviderSwitch_NoPersistedChanges(t if m2.savedInSession { t.Error("savedInSession should be false for cross-provider navigation") } - if m2.step != stepAPIKey { - t.Fatalf("step = %d, want stepAPIKey", m2.step) + if m2.step != stepEffort { + t.Fatalf("step = %d, want stepEffort", m2.step) } result, _ = m2.Update(escKey()) @@ -2542,8 +2578,8 @@ func TestProviderTUI_SameOfficialProviderModelChange_DefersPersistUntilConfirm(t if m2.savedInSession { t.Error("savedInSession should be false before API key confirm") } - if m2.step != stepAPIKey { - t.Fatalf("step = %d, want stepAPIKey", m2.step) + if m2.step != stepEffort { + t.Fatalf("step = %d, want stepEffort", m2.step) } if _, err := os.Stat(configPath); err == nil { t.Fatal("config should not be written before wizard confirm") @@ -2580,6 +2616,8 @@ func TestProviderTUI_OfficialModelChangeBlockedAtAPIKey_KeepsGlobalModel(t *test result, _ := m.Update(enterKey()) m2 := result.(providerTUIModel) + result, _ = m2.Update(enterKey()) // provider default + m2 = result.(providerTUIModel) m2.beginAPIKeyReplace() result, cmd := m2.Update(enterKey()) diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 268218a9..0c12ea4e 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -34,6 +34,7 @@ type reviewOptions struct { background string backgroundFile string model string + reasoningEffort string concurrency int perFileTimeout int maxTools int @@ -134,7 +135,7 @@ func executeReview(opts reviewOptions) error { return err } - rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model) + rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model, opts.reasoningEffort) if err != nil { return err } diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go index a07e5e41..7215fad9 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -37,6 +37,7 @@ type scanOptions struct { batch string maxTokensBudget int model string + reasoningEffort string } var scanOpts scanOptions @@ -128,7 +129,7 @@ func executeScan(opts scanOptions) error { return runScanPreview(cc, scanTpl, scanPaths) } - rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model) + rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model, opts.reasoningEffort) if err != nil { return err } diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go index 071b5184..7f16be52 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -149,7 +149,7 @@ type llmRuntime struct { // tpl — defaulting when the config file is absent), resolves the LLM // endpoint (honoring modelOverride from --model when non-empty), and // returns the runtime bundle. tpl is mutated in place. -func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string) (*llmRuntime, error) { +func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride, reasoningEffortOverride string) (*llmRuntime, error) { toolEntries, err := toolsconfig.Load(toolConfigPath) if err != nil { return nil, fmt.Errorf("load tools: %w", err) @@ -174,7 +174,7 @@ func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string } tpl.ApplyLanguage(lang) - ep, err := llm.ResolveEndpointWithModelOverride(cfgPath, modelOverride) + ep, err := llm.ResolveEndpointWithOverrides(cfgPath, modelOverride, reasoningEffortOverride) if err != nil { return nil, fmt.Errorf("resolve LLM endpoint: %w", err) } @@ -188,10 +188,11 @@ func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string Collector: tool.NewCommentCollector(), AppCfg: appCfg, RuntimeConfig: agent.RuntimeConfig{ - Protocol: ep.Protocol, - EndpointHost: sanitizeEndpointHost(ep.URL), - Language: lang, - Timeout: ep.Timeout, + Protocol: ep.Protocol, + EndpointHost: sanitizeEndpointHost(ep.URL), + Language: lang, + Timeout: ep.Timeout, + ReasoningEffort: ep.ReasoningEffort, }, }, nil } diff --git a/cmd/opencodereview/shared_flags.go b/cmd/opencodereview/shared_flags.go index f2eac76d..135913a4 100644 --- a/cmd/opencodereview/shared_flags.go +++ b/cmd/opencodereview/shared_flags.go @@ -49,6 +49,11 @@ func addModelFlag(cmd *cobra.Command, target *string) { cmd.Flags().StringVar(target, "model", "", "override LLM model for this run (e.g., claude-opus-4-6)") } +func addReasoningEffortFlag(cmd *cobra.Command, target *string) { + cmd.Flags().StringVar(target, "reasoning-effort", "", "override reasoning effort for this run (provider-dependent)") + cmd.RegisterFlagCompletionFunc("reasoning-effort", completeEnum("default", "none", "minimal", "low", "medium", "high", "xhigh", "max")) +} + func addToolsFlag(cmd *cobra.Command, target *string) { cmd.Flags().StringVar(target, "tools", "", "path to JSON tools config file (default: embedded)") } @@ -153,6 +158,7 @@ func registerReviewFlags(cmd *cobra.Command, opts *reviewOptions) { addConcurrencyFlags(cmd, &opts.concurrency, &opts.perFileTimeout, &opts.maxTools, &opts.maxGitProcs, &opts.maxTokensBudget) addBackgroundFlags(cmd, &opts.background, &opts.backgroundFile) addModelFlag(cmd, &opts.model) + addReasoningEffortFlag(cmd, &opts.reasoningEffort) addPreviewFlag(cmd, &opts.preview) } @@ -176,6 +182,7 @@ func registerScanFlags(cmd *cobra.Command, opts *scanOptions) { cmd.Flags().BoolVar(&opts.noSummary, "no-summary", false, "skip the post-run PROJECT_SUMMARY_TASK") cmd.Flags().StringVar(&opts.batch, "batch", "", "override BATCH_STRATEGY: none | by-language | by-directory") addModelFlag(cmd, &opts.model) + addReasoningEffortFlag(cmd, &opts.reasoningEffort) cmd.RegisterFlagCompletionFunc("batch", completeEnum("none", "by-language", "by-directory")) } diff --git a/examples/github_actions/README.md b/examples/github_actions/README.md index 0236505f..b5464920 100644 --- a/examples/github_actions/README.md +++ b/examples/github_actions/README.md @@ -19,9 +19,13 @@ The core of the demo is a single action step: llm_url: ${{ secrets.OCR_LLM_URL }} llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_reasoning_effort: ${{ vars.OCR_LLM_REASONING_EFFORT }} llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} ``` +`llm_reasoning_effort` is optional. It maps to +`OCR_LLM_REASONING_EFFORT`; leave it unset to use the provider default. + See [`action.yml`](../../action.yml) for the full list of inputs, outputs, security guidance, and the four comment-posting modes (sticky summary + incremental). ## Running on a self-hosted runner @@ -67,9 +71,10 @@ Go to your repository's **Settings → Secrets and variables → Actions**. | Variable | Required | Description | |----------|----------|-------------| | `OCR_LLM_MODEL` | Yes | Model name | +| `OCR_LLM_REASONING_EFFORT` | No | Per-run reasoning effort; unset uses the provider default | | `OCR_LLM_USE_ANTHROPIC` | Yes | `true` for Anthropic Claude, `false` for OpenAI-compatible | -> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. The action also sets `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. +> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. The action also sets `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. Some Anthropic models reject disabled thinking with `xhigh`/`max`; set `llm_extra_body: '{}'` when that model requires thinking to remain enabled. ## Customization @@ -297,6 +302,7 @@ Mint a token with `actions/create-github-app-token` and pass it via the `github_ llm_url: ${{ secrets.OCR_LLM_URL }} llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_reasoning_effort: ${{ vars.OCR_LLM_REASONING_EFFORT }} llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} ``` diff --git a/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts b/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts index 29bc127e..a6d4cbd0 100644 --- a/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/configDraft.test.ts @@ -14,6 +14,31 @@ describe('applyConfigEntries', () => { }); }); + it('按当前模型合并 reasoning effort', () => { + const draft = applyConfigEntries({ + provider: 'anthropic', + providers: { + anthropic: { + model: 'claude-opus-4-8', + model_settings: { 'claude-opus-4-7': { reasoning_effort: 'low' } }, + }, + }, + }, [ + { key: 'reasoning_effort', value: 'high' }, + ]); + expect(draft.providers?.anthropic.model_settings).toEqual({ + 'claude-opus-4-7': { reasoning_effort: 'low' }, + 'claude-opus-4-8': { reasoning_effort: 'high' }, + }); + + const cleared = applyConfigEntries(draft, [ + { key: 'reasoning_effort', value: 'default' }, + ]); + expect(cleared.providers?.anthropic.model_settings).toEqual({ + 'claude-opus-4-7': { reasoning_effort: 'low' }, + }); + }); + it('合并自定义 provider 条目', () => { const draft = applyConfigEntries({}, [ { key: 'custom_providers.my-llm.protocol', value: 'openai' }, diff --git a/extensions/vscode/src/extension/services/__tests__/configParse.test.ts b/extensions/vscode/src/extension/services/__tests__/configParse.test.ts index 54733d76..0bada0d5 100644 --- a/extensions/vscode/src/extension/services/__tests__/configParse.test.ts +++ b/extensions/vscode/src/extension/services/__tests__/configParse.test.ts @@ -6,24 +6,38 @@ describe('parseConfig', () => { const raw = JSON.stringify({ provider: 'anthropic', providers: { - anthropic: { api_key: 'k', model: 'claude-opus-4-6', models: ['claude-opus-4-6'] }, + anthropic: { + api_key: 'k', + model: 'claude-opus-4-6', + models: ['claude-opus-4-6'], + model_settings: { 'claude-opus-4-6': { reasoning_effort: 'high' } }, + }, }, custom_providers: { 'my-llm': { url: 'https://x', protocol: 'openai', model: 'm', api_key: 'k2' }, }, - llm: { url: 'u', auth_token: 't', model: 'm', use_anthropic: true, auth_header: 'x-api-key' }, + llm: { + url: 'u', auth_token: 't', model: 'm', use_anthropic: true, auth_header: 'x-api-key', + model_settings: { m: { reasoning_effort: 'low' } }, + }, language: 'Chinese', }); expect(parseConfig(raw)).toEqual({ provider: 'anthropic', model: '', providers: { - anthropic: { apiKey: 'k', url: '', protocol: '', model: 'claude-opus-4-6', models: ['claude-opus-4-6'], authHeader: '' }, + anthropic: { + apiKey: 'k', url: '', protocol: '', model: 'claude-opus-4-6', models: ['claude-opus-4-6'], authHeader: '', + modelSettings: { 'claude-opus-4-6': { reasoningEffort: 'high' } }, + }, }, customProviders: { 'my-llm': { apiKey: 'k2', url: 'https://x', protocol: 'openai', model: 'm', authHeader: '' }, }, - llm: { url: 'u', authToken: 't', model: 'm', useAnthropic: true, authHeader: 'x-api-key' }, + llm: { + url: 'u', authToken: 't', model: 'm', useAnthropic: true, authHeader: 'x-api-key', + modelSettings: { m: { reasoningEffort: 'low' } }, + }, language: 'Chinese', }); }); diff --git a/extensions/vscode/src/extension/services/configDraft.ts b/extensions/vscode/src/extension/services/configDraft.ts index bbf2ede5..c7a4cfa4 100644 --- a/extensions/vscode/src/extension/services/configDraft.ts +++ b/extensions/vscode/src/extension/services/configDraft.ts @@ -75,6 +75,17 @@ function setProviderValue(cfg: RawConfig, key: string, value: string): void { setCustomProviderField(cfg, name, field, value); } +function applyReasoningEffort(target: Record, model: string, effort: string): void { + const raw = target.model_settings; + const current = raw && typeof raw === 'object' && !Array.isArray(raw) + ? { ...(raw as Record) } + : {}; + if (effort) current[model] = { reasoning_effort: effort }; + else delete current[model]; + if (Object.keys(current).length > 0) target.model_settings = current; + else delete target.model_settings; +} + function setConfigValue(cfg: RawConfig, key: string, value: string): void { if (key.startsWith('providers.')) { setProviderValue(cfg, key, value); @@ -112,6 +123,19 @@ function setConfigValue(cfg: RawConfig, key: string, value: string): void { cfg.model = value; } break; + case 'reasoning_effort': { + const normalized = value.trim().toLowerCase() === 'default' ? '' : value.trim().toLowerCase(); + if (cfg.provider) { + const collection = isPresetProvider(cfg.provider) ? cfg.providers : cfg.custom_providers; + const entry = collection?.[cfg.provider]; + const model = typeof entry?.model === 'string' ? entry.model : cfg.model; + if (!entry || !model) break; + applyReasoningEffort(entry, model, normalized); + } else if (cfg.llm && typeof cfg.llm.model === 'string' && cfg.llm.model) { + applyReasoningEffort(cfg.llm, cfg.llm.model, normalized); + } + break; + } case 'llm.url': if (!cfg.llm) cfg.llm = {}; cfg.llm.url = value; diff --git a/extensions/vscode/src/extension/services/configParse.ts b/extensions/vscode/src/extension/services/configParse.ts index def07a66..ea1581ed 100644 --- a/extensions/vscode/src/extension/services/configParse.ts +++ b/extensions/vscode/src/extension/services/configParse.ts @@ -1,4 +1,14 @@ -import { OcrConfig, ProviderEntry } from '../../shared/types'; +import { ModelSettings, OcrConfig, ProviderEntry } from '../../shared/types'; + +function parseModelSettings(raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + return Object.fromEntries(Object.entries(raw as Record).map(([model, value]) => { + const setting = value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; + return [model, { reasoningEffort: typeof setting.reasoning_effort === 'string' ? setting.reasoning_effort : '' }]; + })); +} function parseProviderEntry(raw: Record | undefined): ProviderEntry { if (!raw) return {}; @@ -12,6 +22,7 @@ function parseProviderEntry(raw: Record | undefined): ProviderE model: typeof raw.model === 'string' ? raw.model : '', models, authHeader: typeof raw.auth_header === 'string' ? raw.auth_header : '', + modelSettings: parseModelSettings(raw.model_settings), }; } @@ -39,6 +50,7 @@ export function parseConfig(raw: string): OcrConfig | null { model: llm.model || '', useAnthropic: llm.use_anthropic !== false, authHeader: llm.auth_header || '', + modelSettings: parseModelSettings(llm.model_settings), }, language: j.language || 'Chinese', }; diff --git a/extensions/vscode/src/shared/__tests__/configUtils.test.ts b/extensions/vscode/src/shared/__tests__/configUtils.test.ts new file mode 100644 index 00000000..c061be15 --- /dev/null +++ b/extensions/vscode/src/shared/__tests__/configUtils.test.ts @@ -0,0 +1,25 @@ +import { + buildOfficialSaveEntries, + reasoningEffortOptions, + savedModelReasoningEffort, +} from '../configUtils'; + +describe('reasoning effort config helpers', () => { + it('uses protocol-specific effort options', () => { + expect(reasoningEffortOptions('anthropic')).toEqual(['', 'low', 'medium', 'high', 'xhigh', 'max']); + expect(reasoningEffortOptions('openai')).toEqual(['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']); + }); + + it('saves provider default as an explicit reset', () => { + expect(buildOfficialSaveEntries('anthropic', 'claude-opus-4-8', '', false, '')).toContainEqual({ + key: 'reasoning_effort', + value: 'default', + }); + }); + + it('distinguishes a saved effort from an unsaved model', () => { + const entry = { modelSettings: { saved: { reasoningEffort: 'high' } } }; + expect(savedModelReasoningEffort(entry, 'saved')).toBe('high'); + expect(savedModelReasoningEffort(entry, 'new-model')).toBeUndefined(); + }); +}); diff --git a/extensions/vscode/src/shared/configUtils.ts b/extensions/vscode/src/shared/configUtils.ts index d05b1f30..14dce823 100644 --- a/extensions/vscode/src/shared/configUtils.ts +++ b/extensions/vscode/src/shared/configUtils.ts @@ -1,5 +1,5 @@ import { isPresetProvider, lookupPreset } from './providers'; -import { OcrConfig } from './types'; +import { OcrConfig, ProviderEntry } from './types'; export type ProviderTab = 'official' | 'custom'; @@ -15,6 +15,20 @@ export interface ConfigEntry { value: string; } +export function reasoningEffortOptions(protocol: string): string[] { + return protocol === 'anthropic' + ? ['', 'low', 'medium', 'high', 'xhigh', 'max'] + : ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']; +} + +export function modelReasoningEffort(entry: ProviderEntry | undefined, model: string): string { + return savedModelReasoningEffort(entry, model) ?? ''; +} + +export function savedModelReasoningEffort(entry: ProviderEntry | undefined, model: string): string | undefined { + return entry?.modelSettings?.[model]?.reasoningEffort; +} + export function detectInitialTab(config: OcrConfig | null): ProviderTab { if (!config) return 'official'; if (config.provider) { @@ -96,10 +110,12 @@ export function buildOfficialSaveEntries( model: string, apiKey: string, apiKeyChanged: boolean, + reasoningEffort: string, ): ConfigEntry[] { const entries: ConfigEntry[] = [ { key: 'provider', value: providerName }, { key: `providers.${providerName}.model`, value: model }, + { key: 'reasoning_effort', value: reasoningEffort || 'default' }, ]; if (apiKeyChanged && apiKey.trim()) { entries.push({ key: `providers.${providerName}.api_key`, value: apiKey.trim() }); @@ -115,6 +131,7 @@ export function buildCustomCreateSaveEntries(params: { models: string; apiKey: string; authHeader: string; + reasoningEffort: string; }): ConfigEntry[] { const entries: ConfigEntry[] = [ { key: `custom_providers.${params.name}.protocol`, value: params.protocol }, @@ -122,6 +139,7 @@ export function buildCustomCreateSaveEntries(params: { { key: `custom_providers.${params.name}.model`, value: params.model.trim() }, { key: `custom_providers.${params.name}.api_key`, value: params.apiKey.trim() }, { key: 'provider', value: params.name.trim() }, + { key: 'reasoning_effort', value: params.reasoningEffort || 'default' }, ]; const models = params.models.trim(); if (models) { @@ -142,12 +160,14 @@ export function buildCustomUpdateSaveEntries(params: { apiKey: string; apiKeyChanged: boolean; authHeader: string; + reasoningEffort: string; }): ConfigEntry[] { const entries: ConfigEntry[] = [ { key: `custom_providers.${params.name}.protocol`, value: params.protocol }, { key: `custom_providers.${params.name}.url`, value: params.url.trim() }, { key: `custom_providers.${params.name}.model`, value: params.model.trim() }, { key: 'provider', value: params.name }, + { key: 'reasoning_effort', value: params.reasoningEffort || 'default' }, ]; const models = params.models.trim(); if (models) { diff --git a/extensions/vscode/src/shared/i18n.ts b/extensions/vscode/src/shared/i18n.ts index 60865664..1d4b815e 100644 --- a/extensions/vscode/src/shared/i18n.ts +++ b/extensions/vscode/src/shared/i18n.ts @@ -66,6 +66,9 @@ const messages: Record> = { 'view.config.legacyLabel': 'Legacy', 'view.config.model': 'Model', 'view.config.customModel': 'Enter custom model…', + 'view.config.reasoningEffort': 'Reasoning effort', + 'view.config.reasoningEffortHint': 'Saved for this model. Supported values depend on the model.', + 'view.config.providerDefault': 'Provider default', 'view.config.apiKey': 'API Key', 'view.config.apiKeyEnvHint': 'Also available via env var', 'view.config.apiKeySaved': 'Saved (leave blank to keep)', @@ -219,6 +222,9 @@ const messages: Record> = { 'view.config.legacyLabel': 'Legacy', 'view.config.model': '模型', 'view.config.customModel': '输入自定义模型…', + 'view.config.reasoningEffort': '推理强度', + 'view.config.reasoningEffortHint': '按模型保存;具体支持的取值由模型决定。', + 'view.config.providerDefault': 'Provider 默认值', 'view.config.apiKey': 'API 密钥', 'view.config.apiKeyEnvHint': '也可通过环境变量', 'view.config.apiKeySaved': '已保存(留空保持不变)', diff --git a/extensions/vscode/src/shared/types.ts b/extensions/vscode/src/shared/types.ts index 8219bbd9..da6fac7c 100644 --- a/extensions/vscode/src/shared/types.ts +++ b/extensions/vscode/src/shared/types.ts @@ -49,6 +49,11 @@ export interface ProviderEntry { model?: string; models?: string[]; authHeader?: string; + modelSettings?: Record; +} + +export interface ModelSettings { + reasoningEffort?: string; } export interface OcrConfig { @@ -62,6 +67,7 @@ export interface OcrConfig { model: string; useAnthropic: boolean; authHeader?: string; + modelSettings?: Record; }; language: string; } diff --git a/extensions/vscode/src/webview/views/ConfigView.tsx b/extensions/vscode/src/webview/views/ConfigView.tsx index 587405bc..c520bfc2 100644 --- a/extensions/vscode/src/webview/views/ConfigView.tsx +++ b/extensions/vscode/src/webview/views/ConfigView.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; -import { ConfigEntry, ConfigPanelFocus, ProviderTab, buildCustomCreateSaveEntries, buildCustomUpdateSaveEntries, buildOfficialSaveEntries, describeActiveProvider, detectInitialTab, isConfigReady, listCustomProviderNames } from '../../shared/configUtils'; +import { ConfigEntry, ConfigPanelFocus, ProviderTab, buildCustomCreateSaveEntries, buildCustomUpdateSaveEntries, buildOfficialSaveEntries, describeActiveProvider, detectInitialTab, isConfigReady, listCustomProviderNames, modelReasoningEffort, reasoningEffortOptions, savedModelReasoningEffort } from '../../shared/configUtils'; import { mergeModelLists, PROVIDER_PRESETS } from '../../shared/providers'; import { EnvCheckResult, LogLine, OcrConfig } from '../../shared/types'; import { CliStatus, ConnTest } from '../configStore'; @@ -35,6 +35,13 @@ interface Props { const CUSTOM_NEW = '__new__'; const MODEL_CUSTOM = '__custom__'; +function effortSelectOptions(protocol: string, providerDefaultLabel: string) { + return reasoningEffortOptions(protocol).map((value) => ({ + value, + label: value || providerDefaultLabel, + })); +} + function resolvePanelState(config: OcrConfig | null, panelFocus?: ConfigPanelFocus | null) { const tab = panelFocus?.tab ?? detectInitialTab(config); const step = panelFocus?.step ?? (isConfigReady(config) ? 2 : 1); @@ -344,6 +351,7 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr const [apiKey, setApiKey] = useState(''); const [apiKeyTouched, setApiKeyTouched] = useState(false); const hasStoredKey = Boolean(savedEntry?.apiKey); + const [reasoningEffort, setReasoningEffort] = useState(modelReasoningEffort(savedEntry, initialModel)); const resolvedModel = modelChoice === MODEL_CUSTOM ? customModel.trim() : modelChoice; const canSave = resolvedModel !== ''; @@ -353,6 +361,7 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr resolvedModel, apiKey, apiKeyTouched || !hasStoredKey, + reasoningEffort, ); const save = () => { @@ -378,6 +387,7 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr const m = entry?.model || models[0] || ''; setModelChoice(models.includes(m) ? m : MODEL_CUSTOM); setCustomModel(models.includes(m) ? '' : m); + setReasoningEffort(modelReasoningEffort(entry, m)); setApiKey(''); setApiKeyTouched(false); }} @@ -388,7 +398,11 @@ function OfficialForm({ wide, config, connTest, onBack, onTest, onSave }: FormPr setCustomModel((e.target as HTMLInputElement).value)} + onInput={(e) => { + const nextModel = (e.target as HTMLInputElement).value; + setCustomModel(nextModel); + const savedEffort = savedModelReasoningEffort(savedEntry, nextModel.trim()); + if (savedEffort !== undefined) setReasoningEffort(savedEffort); + }} placeholder="model name" /> )} + + setProtocol(v as 'anthropic' | 'openai')} + onChange={(v) => { + const next = v as 'anthropic' | 'openai'; + setProtocol(next); + if (!reasoningEffortOptions(next).includes(reasoningEffort)) setReasoningEffort(''); + }} options={[ { value: 'anthropic', label: 'anthropic' }, { value: 'openai', label: 'openai' }, @@ -510,11 +544,23 @@ function CustomForm({ setUrl((e.target as HTMLInputElement).value)} placeholder="https://api.example.com/v1" /> - setModel((e.target as HTMLInputElement).value)} placeholder="model name" /> + { + const nextModel = (e.target as HTMLInputElement).value; + setModel(nextModel); + const savedEffort = savedModelReasoningEffort(entry, nextModel.trim()); + if (savedEffort !== undefined) setReasoningEffort(savedEffort); + }} placeholder="model name" /> setModels((e.target as HTMLInputElement).value)} placeholder={t('view.config.modelListPlaceholder')} /> + +