diff --git a/CHANGELOG.md b/CHANGELOG.md index aa88d50da..1e468e9fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 token totals are updated. Hooks receive structured JSON with status, usage, totals, duration, tool count, and queued-message count on stdin; stdout is ignored and failures are warn-only (#1364, #2578). +- Added provider-scoped `insecure_skip_tls_verify` for private + OpenAI-compatible gateways that cannot use a trusted CA bundle. The setting is + disabled by default, applies only to the active LLM provider HTTP client, and + is surfaced by `codewhale doctor`; `SSL_CERT_FILE` remains the preferred path + for corporate or private CA roots. Thanks @wavezhang for the original #1893 + direction. - Added rich PlanArtifact support to `update_plan`: Plan mode can now carry grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan diff --git a/README.md b/README.md index ca35f2317..d53f1adb5 100644 --- a/README.md +++ b/README.md @@ -524,7 +524,7 @@ Key environment variables: | `OLLAMA_MODEL` | Self-hosted Ollama model tag | | `HUGGINGFACE_API_KEY` / `HF_TOKEN` / `HUGGINGFACE_BASE_URL` / `HUGGINGFACE_MODEL` | Hugging Face endpoint and model override | | `NO_ANIMATIONS=1` | Force accessibility mode at startup | -| `SSL_CERT_FILE` | Custom CA bundle for corporate proxies | +| `SSL_CERT_FILE` | Custom CA bundle for corporate proxies; prefer this over provider-local `insecure_skip_tls_verify` | Set `locale` in `settings.toml`, use `/config locale zh-Hans`, or rely on `LC_ALL`/`LANG` to choose UI chrome and the fallback language sent to V4 models. The latest user message still wins for natural-language reasoning and replies, so Chinese user turns stay Chinese even on an English system locale. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) and [docs/MCP.md](docs/MCP.md). diff --git a/config.example.toml b/config.example.toml index d8c3c53dd..e74e021b5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -274,6 +274,7 @@ max_subagents = 10 # optional (1-20) # model = "deepseek-ai/DeepSeek-V4-Pro" # http_headers = { "X-Model-Provider-Id" = "your-model-provider" } # optional custom request headers # path_suffix = "/chat/completions" # override the API path; skips /v1 versioning when set +# insecure_skip_tls_verify = true # last resort for private gateways; prefer SSL_CERT_FILE # NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com) [providers.nvidia_nim] @@ -292,6 +293,7 @@ max_subagents = 10 # optional (1-20) # Gateway example: # base_url = "https://gateway.example/v1" # model = "your-deepseek-compatible-model" +# insecure_skip_tls_verify = true # last resort for private gateways; prefer SSL_CERT_FILE # AtlasCloud OpenAI-compatible endpoint (https://www.atlascloud.ai/docs/models/llm) [providers.atlascloud] diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2fc34fe04..ae4609b39 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -233,6 +233,7 @@ pub struct ProviderConfigToml { pub model: Option, pub mode: Option, pub auth_mode: Option, + pub insecure_skip_tls_verify: Option, #[serde(default)] pub http_headers: BTreeMap, pub path_suffix: Option, @@ -1783,6 +1784,7 @@ impl ConfigToml { api_key_source, base_url, auth_mode, + insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false), output_mode, log_level, telemetry, @@ -2401,6 +2403,7 @@ pub struct ResolvedRuntimeOptions { pub api_key_source: Option, pub base_url: String, pub auth_mode: Option, + pub insecure_skip_tls_verify: bool, pub output_mode: Option, pub log_level: Option, pub telemetry: bool, @@ -3633,6 +3636,28 @@ mod tests { ); } + #[test] + fn insecure_skip_tls_verify_resolves_only_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Openai, + ..ConfigToml::default() + }; + config.providers.deepseek.insecure_skip_tls_verify = Some(true); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openai); + assert!(!resolved.insecure_skip_tls_verify); + + config.providers.openai.insecure_skip_tls_verify = Some(true); + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openai); + assert!(resolved.insecure_skip_tls_verify); + } + #[test] fn http_headers_env_overrides_config() { let _lock = env_lock(); @@ -4058,6 +4083,7 @@ unix_socket_path = "/tmp/cw-hooks.sock" }; project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string()); project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string()); + project.providers.openrouter.insecure_skip_tls_verify = Some(true); project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string()); project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string()); project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string()); @@ -4075,6 +4101,7 @@ unix_socket_path = "/tmp/cw-hooks.sock" Some("user-openrouter-key") ); assert_eq!(base.providers.openrouter.base_url, None); + assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None); assert_eq!( base.providers.openrouter.path_suffix.as_deref(), Some("/chat/completions") diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index aa88d50da..1e468e9fb 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 token totals are updated. Hooks receive structured JSON with status, usage, totals, duration, tool count, and queued-message count on stdin; stdout is ignored and failures are warn-only (#1364, #2578). +- Added provider-scoped `insecure_skip_tls_verify` for private + OpenAI-compatible gateways that cannot use a trusted CA bundle. The setting is + disabled by default, applies only to the active LLM provider HTTP client, and + is surfaced by `codewhale doctor`; `SSL_CERT_FILE` remains the preferred path + for corporate or private CA roots. Thanks @wavezhang for the original #1893 + direction. - Added rich PlanArtifact support to `update_plan`: Plan mode can now carry grounded objectives, context, sources, critical files, constraints, verification, risks, and handoff notes through the transcript card, Plan diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 74c4713d4..76f71a528 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -585,6 +585,7 @@ impl DeepSeekClient { let default_model = config.default_model(); let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs()); let http_headers = config.http_headers(); + let insecure_skip_tls_verify = config.insecure_skip_tls_verify(); let path_suffix = config .provider_config_for(api_provider) .and_then(|p| p.path_suffix.clone()); @@ -600,13 +601,24 @@ impl DeepSeekClient { http_headers.len() )); } + if insecure_skip_tls_verify { + logging::warn(format!( + "TLS certificate verification is disabled for provider {}; prefer SSL_CERT_FILE with a trusted custom CA bundle when possible", + api_provider.as_str() + )); + } logging::info(format!( "Retry policy: enabled={}, max_retries={}, initial_delay={}s, max_delay={}s", retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay )); - let http_client = - Self::build_http_client(&api_key, &http_headers, api_provider, &base_url)?; + let http_client = Self::build_http_client( + &api_key, + &http_headers, + api_provider, + &base_url, + insecure_skip_tls_verify, + )?; Ok(Self { http_client, @@ -627,6 +639,7 @@ impl DeepSeekClient { extra_headers: &HashMap, api_provider: ApiProvider, base_url: &str, + insecure_skip_tls_verify: bool, ) -> Result { let headers = build_default_headers(api_key, extra_headers, api_provider, base_url)?; let mut builder = crate::tls::reqwest_client_builder() @@ -650,6 +663,9 @@ impl DeepSeekClient { { builder = add_extra_root_certs(builder, &cert_path); } + if insecure_skip_tls_verify { + builder = builder.danger_accept_invalid_certs(true); + } builder.build().map_err(Into::into) } @@ -1687,6 +1703,32 @@ mod tests { assert!(headers.get("x-blank").is_none()); } + #[test] + fn build_http_client_accepts_default_tls_verification() { + let client = DeepSeekClient::build_http_client( + "sk-test", + &HashMap::new(), + ApiProvider::Deepseek, + crate::config::DEFAULT_DEEPSEEK_BASE_URL, + false, + ); + + assert!(client.is_ok()); + } + + #[test] + fn build_http_client_accepts_provider_scoped_tls_skip_verify() { + let client = DeepSeekClient::build_http_client( + "sk-test", + &HashMap::new(), + ApiProvider::Openai, + crate::config::DEFAULT_OPENAI_BASE_URL, + true, + ); + + assert!(client.is_ok()); + } + #[test] fn client_stream_idle_timeout_uses_tui_config() { let client = DeepSeekClient::new(&Config { diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 4c3df3325..ccb4af18f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1888,6 +1888,7 @@ pub struct ProviderConfig { pub model: Option, pub mode: Option, pub auth_mode: Option, + pub insecure_skip_tls_verify: Option, pub http_headers: Option>, pub path_suffix: Option, } @@ -2271,6 +2272,13 @@ impl Config { self.provider_config_for(self.api_provider()) } + #[must_use] + pub fn insecure_skip_tls_verify(&self) -> bool { + self.provider_config() + .and_then(|provider| provider.insecure_skip_tls_verify) + .unwrap_or(false) + } + #[must_use] pub fn http_headers(&self) -> HashMap { let mut headers = self.http_headers.clone().unwrap_or_default(); @@ -4527,6 +4535,9 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> model: override_cfg.model.or(base.model), mode: override_cfg.mode.or(base.mode), auth_mode: override_cfg.auth_mode.or(base.auth_mode), + insecure_skip_tls_verify: override_cfg + .insecure_skip_tls_verify + .or(base.insecure_skip_tls_verify), http_headers: override_cfg.http_headers.or(base.http_headers), path_suffix: override_cfg.path_suffix.or(base.path_suffix), } @@ -8267,6 +8278,34 @@ http_headers = { "X-Model-Provider-Id" = "from-file" } Ok(()) } + #[test] + fn insecure_skip_tls_verify_is_scoped_to_active_provider() { + let mut providers = ProvidersConfig::default(); + providers.deepseek.insecure_skip_tls_verify = Some(true); + providers.openai.insecure_skip_tls_verify = Some(false); + let config = Config { + provider: Some("openai".to_string()), + providers: Some(providers), + ..Default::default() + }; + + assert_eq!(config.api_provider(), ApiProvider::Openai); + assert!(!config.insecure_skip_tls_verify()); + } + + #[test] + fn insecure_skip_tls_verify_reads_active_provider_table() { + let mut providers = ProvidersConfig::default(); + providers.openai.insecure_skip_tls_verify = Some(true); + let config = Config { + provider: Some("openai".to_string()), + providers: Some(providers), + ..Default::default() + }; + + assert!(config.insecure_skip_tls_verify()); + } + #[test] fn xiaomi_mimo_provider_uses_documented_defaults() -> Result<()> { let _lock = lock_test_env(); diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index cd2836827..a3cc4386b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2490,6 +2490,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!(" · provider: {}", api_target.provider); println!(" · base_url: {}", api_target.base_url); println!(" · model: {}", api_target.model); + let tls_status = doctor_tls_status(config); + if !tls_status.certificate_verification { + println!(" ! {}", tls_status.message); + println!(" Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible."); + } let strict_tool_mode = doctor_strict_tool_mode_status(config); let strict_icon = match strict_tool_mode.status { "ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b), @@ -3281,6 +3286,7 @@ fn run_doctor_json( }); let api_target = doctor_api_target(config); let strict_tool_mode = doctor_strict_tool_mode_status(config); + let tls_status = doctor_tls_status(config); let report = json!({ "version": env!("CARGO_PKG_VERSION"), @@ -3299,6 +3305,12 @@ fn run_doctor_json( "message": strict_tool_mode.message, "recommended_base_url": strict_tool_mode.recommended_base_url, }, + "tls": { + "certificate_verification": tls_status.certificate_verification, + "insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify, + "provider": tls_status.provider, + "message": tls_status.message, + }, "search_provider": doctor_search_provider_json(config), "memory": memory_summary, "mcp": mcp_summary, @@ -3508,6 +3520,29 @@ fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct DoctorTlsStatus { + certificate_verification: bool, + insecure_skip_tls_verify: bool, + provider: &'static str, + message: String, +} + +fn doctor_tls_status(config: &Config) -> DoctorTlsStatus { + let provider = config.api_provider().as_str(); + let insecure_skip_tls_verify = config.insecure_skip_tls_verify(); + DoctorTlsStatus { + certificate_verification: !insecure_skip_tls_verify, + insecure_skip_tls_verify, + provider, + message: if insecure_skip_tls_verify { + format!("TLS certificate verification disabled for provider {provider}") + } else { + "TLS certificate verification enabled".to_string() + }, + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DeepSeekBaseUrlKind { Beta, @@ -6297,6 +6332,34 @@ mod doctor_endpoint_tests { assert!(status.message.contains("custom endpoint")); } + #[test] + fn doctor_tls_status_reports_verification_enabled_by_default() { + let status = doctor_tls_status(&Config::default()); + + assert!(status.certificate_verification); + assert!(!status.insecure_skip_tls_verify); + assert_eq!(status.provider, "deepseek"); + assert!(status.message.contains("enabled")); + } + + #[test] + fn doctor_tls_status_warns_when_active_provider_skips_verification() { + let mut providers = crate::config::ProvidersConfig::default(); + providers.openai.insecure_skip_tls_verify = Some(true); + let config = Config { + provider: Some("openai".to_string()), + providers: Some(providers), + ..Default::default() + }; + + let status = doctor_tls_status(&config); + + assert!(!status.certificate_verification); + assert!(status.insecure_skip_tls_verify); + assert_eq!(status.provider, "openai"); + assert!(status.message.contains("disabled")); + } + #[test] fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() { let config = Config { diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 49015f5a2..d4cb68d58 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -1306,8 +1306,7 @@ mod tests { assert!(set_static_prompt_composer(&cell, first).is_ok()); let rejected = set_static_prompt_composer(&cell, second) - .err() - .expect("second composer should be rejected"); + .expect_err("second composer should be rejected"); let ctx = StaticPromptCtx { model_id: "deepseek-v4-pro", personality: Personality::Calm, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7fda78c46..5920cfd96 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -222,6 +222,12 @@ The suffix applies only to chat-completion requests. Model listing and DeepSeek beta paths keep their built-in routing so a generic gateway override does not accidentally rewrite `/models` or `/beta/completions`. +For private gateways with broken or intercepted certificates, prefer +`SSL_CERT_FILE` with a trusted CA bundle. As a last resort, a provider table can +set `insecure_skip_tls_verify = true`; this disables certificate verification +only for the active LLM provider client, leaves other HTTP clients unchanged, +and is reported by `codewhale doctor`. + Local HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default when they use localhost or loopback addresses. For a non-local `http://` gateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network: @@ -848,6 +854,7 @@ If you are upgrading from older releases: - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. - `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `base_url = "https://token-plan-cn.xiaomimimo.com/v1"` explicitly if your Xiaomi MiMo Token Plan account is provisioned in the China region. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. - `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = "/chat/completions"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing. +- `insecure_skip_tls_verify` (bool, optional provider-table key): disabled by default. When true on the active provider table, only the LLM provider HTTP client skips TLS certificate verification. Prefer `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports this setting when enabled. - `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `trinity-large-thinking` for Arcee AI, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `false`; shell tools must be explicitly enabled. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 8dcf2c54e..1a947f3b2 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -102,6 +102,12 @@ base_url = "https://gateway.example/v1" model = "your-deepseek-compatible-model" ``` +Private gateways with broken or intercepted certificates should use +`SSL_CERT_FILE` with a trusted CA bundle. As a last resort, +`insecure_skip_tls_verify = true` can be set on the active `[providers.*]` +table; it applies only to the LLM provider client and is shown by +`codewhale doctor`. + Keep `provider`, `api_key`, and `base_url` in user config or process environment. Project-local config overlays intentionally cannot set those keys, so a repository cannot silently redirect prompts or credentials to another