feat(config): add provider TLS skip verify

Harvests provider-scoped TLS skip-verify from #1893 by @wavezhang. Disabled by default, active-provider-only, doctor-reported, and keeps SSL_CERT_FILE as the preferred custom CA path.
This commit is contained in:
Hunter Bown
2026-06-05 22:37:14 -07:00
committed by GitHub
parent 85b5ca5560
commit 190e9f35e4
11 changed files with 202 additions and 5 deletions
+6
View File
@@ -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
+1 -1
View File
@@ -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).
+2
View File
@@ -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]
+27
View File
@@ -233,6 +233,7 @@ pub struct ProviderConfigToml {
pub model: Option<String>,
pub mode: Option<String>,
pub auth_mode: Option<String>,
pub insecure_skip_tls_verify: Option<bool>,
#[serde(default)]
pub http_headers: BTreeMap<String, String>,
pub path_suffix: Option<String>,
@@ -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<RuntimeApiKeySource>,
pub base_url: String,
pub auth_mode: Option<String>,
pub insecure_skip_tls_verify: bool,
pub output_mode: Option<String>,
pub log_level: Option<String>,
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")
+6
View File
@@ -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
+44 -2
View File
@@ -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<String, String>,
api_provider: ApiProvider,
base_url: &str,
insecure_skip_tls_verify: bool,
) -> Result<reqwest::Client> {
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 {
+39
View File
@@ -1888,6 +1888,7 @@ pub struct ProviderConfig {
pub model: Option<String>,
pub mode: Option<String>,
pub auth_mode: Option<String>,
pub insecure_skip_tls_verify: Option<bool>,
pub http_headers: Option<HashMap<String, String>>,
pub path_suffix: Option<String>,
}
@@ -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<String, String> {
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();
+63
View File
@@ -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 {
+1 -2
View File
@@ -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,
+7
View File
@@ -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.
+6
View File
@@ -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