feat(config): support custom HTTP headers (#914)

Integrates the useful custom HTTP header support from #881 onto current main.

- support root, provider-specific, and DEEPSEEK_HTTP_HEADERS overrides
- apply validated extra headers to model API requests while preserving protected Authorization and Content-Type defaults
- document the config shape in README, config.example.toml, and docs/CONFIGURATION.md

Co-authored-by: Desheng <8596814+dst1213@users.noreply.github.com>
This commit is contained in:
Hunter Bown
2026-05-06 18:13:18 -05:00
committed by GitHub
parent 8b9590b82b
commit 633092167c
7 changed files with 499 additions and 11 deletions
+1
View File
@@ -303,6 +303,7 @@ Key environment variables:
|---|---|
| `DEEPSEEK_API_KEY` | API key |
| `DEEPSEEK_BASE_URL` | API base URL |
| `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` |
| `DEEPSEEK_MODEL` | Default model |
| `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `fireworks`, `sglang`, `vllm` |
| `DEEPSEEK_PROFILE` | Config profile name |
+4
View File
@@ -20,6 +20,9 @@ api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
base_url = "https://api.deepseek.com"
# base_url = "https://api.deepseeki.com" # China users
# base_url = "https://api.deepseek.com/beta" # DeepSeek beta features such as strict tool mode
# Optional custom model request headers for OpenAI-compatible gateways.
# Authorization and Content-Type are managed by the client and cannot be overridden here.
# http_headers = { "X-Model-Provider-Id" = "your-model-provider" }
# ─────────────────────────────────────────────────────────────────────────────────
# Default Models
@@ -161,6 +164,7 @@ max_subagents = 10 # optional (1-20)
# api_key = "YOUR_DEEPSEEK_API_KEY"
# base_url = "https://api.deepseek.com"
# model = "deepseek-v4-pro"
# http_headers = { "X-Model-Provider-Id" = "your-model-provider" } # optional custom request headers
# NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com)
[providers.nvidia_nim]
+9
View File
@@ -1294,6 +1294,15 @@ fn build_tui_command(
cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
if !resolved_runtime.http_headers.is_empty() {
let encoded = resolved_runtime
.http_headers
.iter()
.map(|(name, value)| format!("{}={}", name.trim(), value.trim()))
.collect::<Vec<_>>()
.join(",");
cmd.env("DEEPSEEK_HTTP_HEADERS", encoded);
}
if let Some(api_key) = resolved_runtime.api_key.as_ref() {
cmd.env("DEEPSEEK_API_KEY", api_key);
let source = resolved_runtime
+225
View File
@@ -86,6 +86,8 @@ pub struct ProviderConfigToml {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub model: Option<String>,
#[serde(default)]
pub http_headers: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -144,6 +146,9 @@ pub struct ConfigToml {
pub api_key: Option<String>,
/// TUI-compatible DeepSeek base URL.
pub base_url: Option<String>,
/// Optional extra HTTP headers forwarded to model API requests.
#[serde(default)]
pub http_headers: BTreeMap<String, String>,
/// TUI-compatible default DeepSeek model.
pub default_text_model: Option<String>,
#[serde(default)]
@@ -294,6 +299,9 @@ impl ConfigToml {
if project.base_url.is_some() {
self.base_url = project.base_url;
}
if !project.http_headers.is_empty() {
self.http_headers = project.http_headers;
}
if project.default_text_model.is_some() {
self.default_text_model = project.default_text_model;
}
@@ -359,6 +367,7 @@ impl ConfigToml {
"provider" => Some(self.provider.as_str().to_string()),
"api_key" => self.api_key.clone(),
"base_url" => self.base_url.clone(),
"http_headers" => serialize_http_headers(&self.http_headers),
"default_text_model" => self.default_text_model.clone(),
"model" => self.model.clone(),
"auth.mode" => self.auth_mode.clone(),
@@ -372,27 +381,51 @@ impl ConfigToml {
"providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
"providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
"providers.deepseek.model" => self.providers.deepseek.model.clone(),
"providers.deepseek.http_headers" => {
serialize_http_headers(&self.providers.deepseek.http_headers)
}
"providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(),
"providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(),
"providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(),
"providers.nvidia_nim.http_headers" => {
serialize_http_headers(&self.providers.nvidia_nim.http_headers)
}
"providers.openai.api_key" => self.providers.openai.api_key.clone(),
"providers.openai.base_url" => self.providers.openai.base_url.clone(),
"providers.openai.model" => self.providers.openai.model.clone(),
"providers.openai.http_headers" => {
serialize_http_headers(&self.providers.openai.http_headers)
}
"providers.openrouter.api_key" => self.providers.openrouter.api_key.clone(),
"providers.openrouter.base_url" => self.providers.openrouter.base_url.clone(),
"providers.openrouter.model" => self.providers.openrouter.model.clone(),
"providers.openrouter.http_headers" => {
serialize_http_headers(&self.providers.openrouter.http_headers)
}
"providers.novita.api_key" => self.providers.novita.api_key.clone(),
"providers.novita.base_url" => self.providers.novita.base_url.clone(),
"providers.novita.model" => self.providers.novita.model.clone(),
"providers.novita.http_headers" => {
serialize_http_headers(&self.providers.novita.http_headers)
}
"providers.fireworks.api_key" => self.providers.fireworks.api_key.clone(),
"providers.fireworks.base_url" => self.providers.fireworks.base_url.clone(),
"providers.fireworks.model" => self.providers.fireworks.model.clone(),
"providers.fireworks.http_headers" => {
serialize_http_headers(&self.providers.fireworks.http_headers)
}
"providers.sglang.api_key" => self.providers.sglang.api_key.clone(),
"providers.sglang.base_url" => self.providers.sglang.base_url.clone(),
"providers.sglang.model" => self.providers.sglang.model.clone(),
"providers.sglang.http_headers" => {
serialize_http_headers(&self.providers.sglang.http_headers)
}
"providers.vllm.api_key" => self.providers.vllm.api_key.clone(),
"providers.vllm.base_url" => self.providers.vllm.base_url.clone(),
"providers.vllm.model" => self.providers.vllm.model.clone(),
"providers.vllm.http_headers" => {
serialize_http_headers(&self.providers.vllm.http_headers)
}
_ => self.extras.get(key).map(toml::Value::to_string),
}
}
@@ -405,6 +438,7 @@ impl ConfigToml {
}
"api_key" => self.api_key = Some(value.to_string()),
"base_url" => self.base_url = Some(value.to_string()),
"http_headers" => self.http_headers = parse_http_headers(value)?,
"default_text_model" => self.default_text_model = Some(value.to_string()),
"model" => self.model = Some(value.to_string()),
"auth.mode" => self.auth_mode = Some(value.to_string()),
@@ -432,9 +466,17 @@ impl ConfigToml {
self.providers.deepseek.model = Some(value.clone());
self.default_text_model = Some(value);
}
"providers.deepseek.http_headers" => {
let headers = parse_http_headers(value)?;
self.providers.deepseek.http_headers = headers.clone();
self.http_headers = headers;
}
"providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
"providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
"providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
"providers.openai.http_headers" => {
self.providers.openai.http_headers = parse_http_headers(value)?;
}
"providers.nvidia_nim.api_key" => {
self.providers.nvidia_nim.api_key = Some(value.to_string());
}
@@ -444,6 +486,9 @@ impl ConfigToml {
"providers.nvidia_nim.model" => {
self.providers.nvidia_nim.model = Some(value.to_string());
}
"providers.nvidia_nim.http_headers" => {
self.providers.nvidia_nim.http_headers = parse_http_headers(value)?;
}
"providers.openrouter.api_key" => {
self.providers.openrouter.api_key = Some(value.to_string());
}
@@ -453,6 +498,9 @@ impl ConfigToml {
"providers.openrouter.model" => {
self.providers.openrouter.model = Some(value.to_string());
}
"providers.openrouter.http_headers" => {
self.providers.openrouter.http_headers = parse_http_headers(value)?;
}
"providers.novita.api_key" => {
self.providers.novita.api_key = Some(value.to_string());
}
@@ -462,6 +510,9 @@ impl ConfigToml {
"providers.novita.model" => {
self.providers.novita.model = Some(value.to_string());
}
"providers.novita.http_headers" => {
self.providers.novita.http_headers = parse_http_headers(value)?;
}
"providers.fireworks.api_key" => {
self.providers.fireworks.api_key = Some(value.to_string());
}
@@ -471,6 +522,9 @@ impl ConfigToml {
"providers.fireworks.model" => {
self.providers.fireworks.model = Some(value.to_string());
}
"providers.fireworks.http_headers" => {
self.providers.fireworks.http_headers = parse_http_headers(value)?;
}
"providers.sglang.api_key" => {
self.providers.sglang.api_key = Some(value.to_string());
}
@@ -480,6 +534,9 @@ impl ConfigToml {
"providers.sglang.model" => {
self.providers.sglang.model = Some(value.to_string());
}
"providers.sglang.http_headers" => {
self.providers.sglang.http_headers = parse_http_headers(value)?;
}
"providers.vllm.api_key" => {
self.providers.vllm.api_key = Some(value.to_string());
}
@@ -489,6 +546,9 @@ impl ConfigToml {
"providers.vllm.model" => {
self.providers.vllm.model = Some(value.to_string());
}
"providers.vllm.http_headers" => {
self.providers.vllm.http_headers = parse_http_headers(value)?;
}
_ => {
self.extras
.insert(key.to_string(), toml::Value::String(value.to_string()));
@@ -502,6 +562,7 @@ impl ConfigToml {
"provider" => self.provider = ProviderKind::Deepseek,
"api_key" => self.api_key = None,
"base_url" => self.base_url = None,
"http_headers" => self.http_headers.clear(),
"default_text_model" => self.default_text_model = None,
"model" => self.model = None,
"auth.mode" => self.auth_mode = None,
@@ -524,27 +585,38 @@ impl ConfigToml {
self.providers.deepseek.model = None;
self.default_text_model = None;
}
"providers.deepseek.http_headers" => {
self.providers.deepseek.http_headers.clear();
self.http_headers.clear();
}
"providers.openai.api_key" => self.providers.openai.api_key = None,
"providers.openai.base_url" => self.providers.openai.base_url = None,
"providers.openai.model" => self.providers.openai.model = None,
"providers.openai.http_headers" => self.providers.openai.http_headers.clear(),
"providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None,
"providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None,
"providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None,
"providers.nvidia_nim.http_headers" => self.providers.nvidia_nim.http_headers.clear(),
"providers.openrouter.api_key" => self.providers.openrouter.api_key = None,
"providers.openrouter.base_url" => self.providers.openrouter.base_url = None,
"providers.openrouter.model" => self.providers.openrouter.model = None,
"providers.openrouter.http_headers" => self.providers.openrouter.http_headers.clear(),
"providers.novita.api_key" => self.providers.novita.api_key = None,
"providers.novita.base_url" => self.providers.novita.base_url = None,
"providers.novita.model" => self.providers.novita.model = None,
"providers.novita.http_headers" => self.providers.novita.http_headers.clear(),
"providers.fireworks.api_key" => self.providers.fireworks.api_key = None,
"providers.fireworks.base_url" => self.providers.fireworks.base_url = None,
"providers.fireworks.model" => self.providers.fireworks.model = None,
"providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(),
"providers.sglang.api_key" => self.providers.sglang.api_key = None,
"providers.sglang.base_url" => self.providers.sglang.base_url = None,
"providers.sglang.model" => self.providers.sglang.model = None,
"providers.sglang.http_headers" => self.providers.sglang.http_headers.clear(),
"providers.vllm.api_key" => self.providers.vllm.api_key = None,
"providers.vllm.base_url" => self.providers.vllm.base_url = None,
"providers.vllm.model" => self.providers.vllm.model = None,
"providers.vllm.http_headers" => self.providers.vllm.http_headers.clear(),
_ => {
self.extras.remove(key);
}
@@ -563,6 +635,9 @@ impl ConfigToml {
if let Some(v) = self.base_url.as_ref() {
out.insert("base_url".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.http_headers) {
out.insert("http_headers".to_string(), v);
}
if let Some(v) = self.default_text_model.as_ref() {
out.insert("default_text_model".to_string(), v.clone());
}
@@ -602,6 +677,9 @@ impl ConfigToml {
if let Some(v) = self.providers.deepseek.model.as_ref() {
out.insert("providers.deepseek.model".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.providers.deepseek.http_headers) {
out.insert("providers.deepseek.http_headers".to_string(), v);
}
if let Some(v) = self.providers.openai.api_key.as_ref() {
out.insert("providers.openai.api_key".to_string(), redact_secret(v));
}
@@ -611,6 +689,9 @@ impl ConfigToml {
if let Some(v) = self.providers.openai.model.as_ref() {
out.insert("providers.openai.model".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.providers.openai.http_headers) {
out.insert("providers.openai.http_headers".to_string(), v);
}
if let Some(v) = self.providers.nvidia_nim.api_key.as_ref() {
out.insert("providers.nvidia_nim.api_key".to_string(), redact_secret(v));
}
@@ -620,6 +701,9 @@ impl ConfigToml {
if let Some(v) = self.providers.nvidia_nim.model.as_ref() {
out.insert("providers.nvidia_nim.model".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.providers.nvidia_nim.http_headers) {
out.insert("providers.nvidia_nim.http_headers".to_string(), v);
}
if let Some(v) = self.providers.openrouter.api_key.as_ref() {
out.insert("providers.openrouter.api_key".to_string(), redact_secret(v));
}
@@ -629,6 +713,9 @@ impl ConfigToml {
if let Some(v) = self.providers.openrouter.model.as_ref() {
out.insert("providers.openrouter.model".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.providers.openrouter.http_headers) {
out.insert("providers.openrouter.http_headers".to_string(), v);
}
if let Some(v) = self.providers.novita.api_key.as_ref() {
out.insert("providers.novita.api_key".to_string(), redact_secret(v));
}
@@ -638,6 +725,9 @@ impl ConfigToml {
if let Some(v) = self.providers.novita.model.as_ref() {
out.insert("providers.novita.model".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.providers.novita.http_headers) {
out.insert("providers.novita.http_headers".to_string(), v);
}
if let Some(v) = self.providers.fireworks.api_key.as_ref() {
out.insert("providers.fireworks.api_key".to_string(), redact_secret(v));
}
@@ -647,6 +737,9 @@ impl ConfigToml {
if let Some(v) = self.providers.fireworks.model.as_ref() {
out.insert("providers.fireworks.model".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.providers.fireworks.http_headers) {
out.insert("providers.fireworks.http_headers".to_string(), v);
}
if let Some(v) = self.providers.sglang.api_key.as_ref() {
out.insert("providers.sglang.api_key".to_string(), redact_secret(v));
}
@@ -656,6 +749,9 @@ impl ConfigToml {
if let Some(v) = self.providers.sglang.model.as_ref() {
out.insert("providers.sglang.model".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.providers.sglang.http_headers) {
out.insert("providers.sglang.http_headers".to_string(), v);
}
if let Some(v) = self.providers.vllm.api_key.as_ref() {
out.insert("providers.vllm.api_key".to_string(), redact_secret(v));
}
@@ -665,6 +761,9 @@ impl ConfigToml {
if let Some(v) = self.providers.vllm.model.as_ref() {
out.insert("providers.vllm.model".to_string(), v.clone());
}
if let Some(v) = serialize_http_headers(&self.providers.vllm.http_headers) {
out.insert("providers.vllm.http_headers".to_string(), v);
}
for (k, v) in &self.extras {
out.insert(k.clone(), v.to_string());
@@ -763,6 +862,13 @@ impl ConfigToml {
});
let model = normalize_model_for_provider(provider, &model);
let mut http_headers = self.http_headers.clone();
http_headers.extend(provider_cfg.http_headers.clone());
if let Some(env_headers) = env.http_headers {
http_headers.extend(env_headers);
}
http_headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
let output_mode = cli
.output_mode
.clone()
@@ -806,6 +912,7 @@ impl ConfigToml {
telemetry,
approval_policy,
sandbox_mode,
http_headers,
}
}
}
@@ -820,6 +927,9 @@ fn merge_provider_config(target: &mut ProviderConfigToml, source: &ProviderConfi
if source.model.is_some() {
target.model = source.model.clone();
}
if !source.http_headers.is_empty() {
target.http_headers = source.http_headers.clone();
}
}
/// Load a project-level config from `$WORKSPACE/.deepseek/config.toml`.
@@ -930,6 +1040,7 @@ pub struct ResolvedRuntimeOptions {
pub telemetry: bool,
pub approval_policy: Option<String>,
pub sandbox_mode: Option<String>,
pub http_headers: BTreeMap<String, String>,
}
#[derive(Debug, Clone)]
@@ -1049,6 +1160,42 @@ fn parse_bool(raw: &str) -> Result<bool> {
}
}
fn parse_http_headers(raw: &str) -> Result<BTreeMap<String, String>> {
let mut headers = BTreeMap::new();
for pair in raw.trim().split(',') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let Some((name, value)) = pair.split_once('=') else {
bail!("invalid header pair '{pair}', expected name=value");
};
let name = name.trim();
let value = value.trim();
if name.is_empty() {
bail!("header name cannot be empty");
}
if value.is_empty() {
continue;
}
headers.insert(name.to_string(), value.to_string());
}
Ok(headers)
}
fn serialize_http_headers(headers: &BTreeMap<String, String>) -> Option<String> {
if headers.is_empty() {
return None;
}
Some(
headers
.iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join(","),
)
}
fn redact_secret(secret: &str) -> String {
if secret.len() <= 16 {
return "********".to_string();
@@ -1066,6 +1213,7 @@ struct EnvRuntimeOverrides {
telemetry: Option<bool>,
approval_policy: Option<String>,
sandbox_mode: Option<String>,
http_headers: Option<BTreeMap<String, String>>,
deepseek_base_url: Option<String>,
nvidia_base_url: Option<String>,
openai_base_url: Option<String>,
@@ -1091,6 +1239,10 @@ impl EnvRuntimeOverrides {
.and_then(|v| parse_bool(&v).ok()),
approval_policy: std::env::var("DEEPSEEK_APPROVAL_POLICY").ok(),
sandbox_mode: std::env::var("DEEPSEEK_SANDBOX_MODE").ok(),
http_headers: std::env::var("DEEPSEEK_HTTP_HEADERS")
.ok()
.and_then(|value| parse_http_headers(&value).ok())
.filter(|headers| !headers.is_empty()),
deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
@@ -1151,6 +1303,7 @@ mod tests {
struct EnvGuard {
deepseek_api_key: Option<OsString>,
deepseek_base_url: Option<OsString>,
deepseek_http_headers: Option<OsString>,
deepseek_model: Option<OsString>,
deepseek_provider: Option<OsString>,
nvidia_api_key: Option<OsString>,
@@ -1175,6 +1328,7 @@ mod tests {
let guard = Self {
deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"),
deepseek_model: env::var_os("DEEPSEEK_MODEL"),
deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
@@ -1197,6 +1351,7 @@ mod tests {
unsafe {
env::remove_var("DEEPSEEK_API_KEY");
env::remove_var("DEEPSEEK_BASE_URL");
env::remove_var("DEEPSEEK_HTTP_HEADERS");
env::remove_var("DEEPSEEK_MODEL");
env::remove_var("DEEPSEEK_PROVIDER");
env::remove_var("NVIDIA_API_KEY");
@@ -1233,6 +1388,7 @@ mod tests {
unsafe {
Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
@@ -1294,6 +1450,75 @@ mod tests {
assert_eq!(resolved.model, "deepseek-v4-flash");
}
#[test]
fn provider_http_headers_override_root_headers() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let mut config = ConfigToml {
api_key: Some("root-key".to_string()),
base_url: Some("https://api.deepseek.com".to_string()),
default_text_model: Some("deepseek-v4-pro".to_string()),
..ConfigToml::default()
};
config.providers.deepseek.api_key = Some("provider-key".to_string());
config.providers.deepseek.base_url = Some("https://api.deepseeki.com".to_string());
config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
config
.http_headers
.insert("X-Shared".to_string(), "root".to_string());
config
.providers
.deepseek
.http_headers
.insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
config
.providers
.deepseek
.http_headers
.insert("X-Shared".to_string(), "provider".to_string());
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
assert_eq!(resolved.base_url, "https://api.deepseeki.com");
assert_eq!(resolved.model, "deepseek-v4-flash");
assert_eq!(
resolved
.http_headers
.get("X-Model-Provider-Id")
.map(String::as_str),
Some("tongyi")
);
assert_eq!(
resolved.http_headers.get("X-Shared").map(String::as_str),
Some("provider")
);
}
#[test]
fn http_headers_env_overrides_config() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let mut config = ConfigToml::default();
config
.http_headers
.insert("X-Model-Provider-Id".to_string(), "from-file".to_string());
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
}
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(
resolved
.http_headers
.get("X-Model-Provider-Id")
.map(String::as_str),
Some("from-env")
);
}
#[test]
fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
let _lock = env_lock();
+73 -11
View File
@@ -3,11 +3,12 @@
//! DeepSeek documents `/chat/completions` as the primary endpoint, and this
//! client now routes all normal traffic through that surface.
use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::Mutex as AsyncMutex;
@@ -440,15 +441,22 @@ impl DeepSeekClient {
validate_base_url_security(&base_url)?;
let retry = config.retry_policy();
let default_model = config.default_model();
let http_headers = config.http_headers();
logging::info(format!("API provider: {}", api_provider.as_str()));
logging::info(format!("API base URL: {base_url}"));
if !http_headers.is_empty() {
logging::info(format!(
"{} custom HTTP header(s) configured",
http_headers.len()
));
}
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)?;
let http_client = Self::build_http_client(&api_key, &http_headers)?;
Ok(Self {
http_client,
@@ -462,15 +470,11 @@ impl DeepSeekClient {
})
}
fn build_http_client(api_key: &str) -> Result<reqwest::Client> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if !api_key.trim().is_empty() {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {api_key}"))?,
);
}
fn build_http_client(
api_key: &str,
extra_headers: &HashMap<String, String>,
) -> Result<reqwest::Client> {
let headers = build_default_headers(api_key, extra_headers)?;
let mut builder = reqwest::Client::builder()
.default_headers(headers)
.connect_timeout(Duration::from_secs(30))
@@ -490,6 +494,43 @@ impl DeepSeekClient {
builder.build().map_err(Into::into)
}
#[cfg(test)]
fn default_headers(
api_key: &str,
extra_headers: &HashMap<String, String>,
) -> Result<HeaderMap> {
build_default_headers(api_key, extra_headers)
}
}
fn build_default_headers(
api_key: &str,
extra_headers: &HashMap<String, String>,
) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if !api_key.trim().is_empty() {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {api_key}"))?,
);
}
for (name, value) in extra_headers {
let name = name.trim();
let value = value.trim();
if name.is_empty() || value.is_empty() {
continue;
}
let header_name = HeaderName::from_bytes(name.as_bytes())?;
if header_name == AUTHORIZATION || header_name == CONTENT_TYPE {
continue;
}
headers.insert(header_name, HeaderValue::from_str(value)?);
}
Ok(headers)
}
impl DeepSeekClient {
/// List available models from the provider.
pub async fn list_models(&self) -> Result<Vec<AvailableModel>> {
let url = api_url(&self.base_url, "models");
@@ -977,6 +1018,27 @@ mod tests {
);
}
#[test]
fn default_headers_include_custom_headers_when_configured() {
let mut extra = HashMap::new();
extra.insert("X-Model-Provider-Id".to_string(), "tongyi".to_string());
let headers = DeepSeekClient::default_headers("sk-test", &extra).expect("headers");
assert_eq!(
headers
.get("x-model-provider-id")
.and_then(|value| value.to_str().ok()),
Some("tongyi")
);
}
#[test]
fn default_headers_ignore_blank_custom_headers() {
let mut extra = HashMap::new();
extra.insert("X-Blank".to_string(), " ".to_string());
let headers = DeepSeekClient::default_headers("sk-test", &extra).expect("headers");
assert!(headers.get("x-blank").is_none());
}
#[test]
fn chat_messages_keep_reasoning_content_on_all_assistant_messages() {
let message = Message {
+176
View File
@@ -645,6 +645,8 @@ pub struct Config {
pub provider: Option<String>,
pub api_key: Option<String>,
pub base_url: Option<String>,
/// Optional extra HTTP headers sent to model API requests.
pub http_headers: Option<HashMap<String, String>>,
pub default_text_model: Option<String>,
/// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
/// Defaults to `"max"` at runtime if unset.
@@ -896,6 +898,7 @@ pub struct ProviderConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub model: Option<String>,
pub http_headers: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -1101,6 +1104,19 @@ impl Config {
self.provider_config_for(self.api_provider())
}
#[must_use]
pub fn http_headers(&self) -> HashMap<String, String> {
let mut headers = self.http_headers.clone().unwrap_or_default();
if let Some(provider_headers) = self
.provider_config()
.and_then(|provider| provider.http_headers.as_ref())
{
headers.extend(provider_headers.clone());
}
headers.retain(|name, value| !name.trim().is_empty() && !value.trim().is_empty());
headers
}
#[must_use]
pub fn default_model(&self) -> String {
let provider = self.api_provider();
@@ -1784,6 +1800,32 @@ fn apply_env_overrides(config: &mut Config) {
.vllm
.base_url = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_HTTP_HEADERS")
&& let Ok(headers) = parse_http_headers(&value)
&& !headers.is_empty()
{
let mut root_headers = config.http_headers.clone().unwrap_or_default();
root_headers.extend(headers.clone());
config.http_headers = Some(root_headers);
let provider = config.api_provider();
let providers = config
.providers
.get_or_insert_with(ProvidersConfig::default);
let entry = match provider {
ApiProvider::Deepseek => &mut providers.deepseek,
ApiProvider::DeepseekCN => &mut providers.deepseek_cn,
ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
ApiProvider::Openrouter => &mut providers.openrouter,
ApiProvider::Novita => &mut providers.novita,
ApiProvider::Fireworks => &mut providers.fireworks,
ApiProvider::Sglang => &mut providers.sglang,
ApiProvider::Vllm => &mut providers.vllm,
};
let mut provider_headers = entry.http_headers.clone().unwrap_or_default();
provider_headers.extend(headers);
entry.http_headers = Some(provider_headers);
}
if matches!(config.api_provider(), ApiProvider::Sglang)
&& let Ok(value) = std::env::var("SGLANG_MODEL")
{
@@ -2061,6 +2103,29 @@ fn normalize_base_url(base: &str) -> String {
trimmed.to_string()
}
fn parse_http_headers(raw: &str) -> Result<HashMap<String, String>> {
let mut headers = HashMap::new();
for pair in raw.trim().split(',') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
let Some((name, value)) = pair.split_once('=') else {
anyhow::bail!("invalid header pair '{pair}', expected name=value");
};
let name = name.trim();
let value = value.trim();
if name.is_empty() {
anyhow::bail!("header name cannot be empty");
}
if value.is_empty() {
continue;
}
headers.insert(name.to_string(), value.to_string());
}
Ok(headers)
}
fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
if let Some(profile_name) = profile {
let profiles = config.profiles.as_ref();
@@ -2095,6 +2160,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
provider: override_cfg.provider.or(base.provider),
api_key: override_cfg.api_key.or(base.api_key),
base_url: override_cfg.base_url.or(base.base_url),
http_headers: override_cfg.http_headers.or(base.http_headers),
default_text_model: override_cfg.default_text_model.or(base.default_text_model),
reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),
tools_file: override_cfg.tools_file.or(base.tools_file),
@@ -2166,6 +2232,7 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) ->
api_key: override_cfg.api_key.or(base.api_key),
base_url: override_cfg.base_url.or(base.base_url),
model: override_cfg.model.or(base.model),
http_headers: override_cfg.http_headers.or(base.http_headers),
}
}
@@ -2818,6 +2885,7 @@ mod tests {
deepseek_provider: Option<OsString>,
deepseek_api_key: Option<OsString>,
deepseek_base_url: Option<OsString>,
deepseek_http_headers: Option<OsString>,
deepseek_model: Option<OsString>,
deepseek_default_text_model: Option<OsString>,
nvidia_api_key: Option<OsString>,
@@ -2851,6 +2919,7 @@ mod tests {
let deepseek_provider_prev = env::var_os("DEEPSEEK_PROVIDER");
let api_key_prev = env::var_os("DEEPSEEK_API_KEY");
let base_url_prev = env::var_os("DEEPSEEK_BASE_URL");
let http_headers_prev = env::var_os("DEEPSEEK_HTTP_HEADERS");
let model_prev = env::var_os("DEEPSEEK_MODEL");
let default_text_model_prev = env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL");
let nvidia_api_key_prev = env::var_os("NVIDIA_API_KEY");
@@ -2879,6 +2948,7 @@ mod tests {
env::remove_var("DEEPSEEK_PROVIDER");
env::remove_var("DEEPSEEK_API_KEY");
env::remove_var("DEEPSEEK_BASE_URL");
env::remove_var("DEEPSEEK_HTTP_HEADERS");
env::remove_var("DEEPSEEK_MODEL");
env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
env::remove_var("NVIDIA_API_KEY");
@@ -2907,6 +2977,7 @@ mod tests {
deepseek_provider: deepseek_provider_prev,
deepseek_api_key: api_key_prev,
deepseek_base_url: base_url_prev,
deepseek_http_headers: http_headers_prev,
deepseek_model: model_prev,
deepseek_default_text_model: default_text_model_prev,
nvidia_api_key: nvidia_api_key_prev,
@@ -2941,6 +3012,7 @@ mod tests {
Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
Self::restore_var(
"DEEPSEEK_DEFAULT_TEXT_MODEL",
@@ -3774,6 +3846,110 @@ api_key = "old-openrouter-key"
Ok(())
}
#[test]
fn http_headers_load_from_root_config() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-http-headers-root-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
ensure_parent_dir(&config_path)?;
fs::write(
&config_path,
r#"
api_key = "test-key"
http_headers = { "X-Model-Provider-Id" = "tongyi" }
"#,
)?;
let config = Config::load(None, None)?;
assert_eq!(
config
.http_headers()
.get("X-Model-Provider-Id")
.map(String::as_str),
Some("tongyi")
);
Ok(())
}
#[test]
fn provider_http_headers_extend_and_override_root_config() {
let mut providers = ProvidersConfig::default();
providers.deepseek.http_headers = Some(HashMap::from([
("X-Model-Provider-Id".to_string(), "tongyi".to_string()),
("X-Shared".to_string(), "provider".to_string()),
]));
let config = Config {
http_headers: Some(HashMap::from([
("X-Root".to_string(), "root".to_string()),
("X-Shared".to_string(), "root".to_string()),
])),
providers: Some(providers),
..Default::default()
};
let headers = config.http_headers();
assert_eq!(
headers.get("X-Model-Provider-Id").map(String::as_str),
Some("tongyi")
);
assert_eq!(headers.get("X-Root").map(String::as_str), Some("root"));
assert_eq!(
headers.get("X-Shared").map(String::as_str),
Some("provider")
);
}
#[test]
fn http_headers_env_overrides_config() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-http-headers-env-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
ensure_parent_dir(&config_path)?;
fs::write(
&config_path,
r#"
api_key = "test-key"
http_headers = { "X-Model-Provider-Id" = "from-file" }
"#,
)?;
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Model-Provider-Id=from-env");
}
let config = Config::load(None, None)?;
assert_eq!(
config
.http_headers()
.get("X-Model-Provider-Id")
.map(String::as_str),
Some("from-env")
);
Ok(())
}
#[test]
fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {
let config = Config {
+11
View File
@@ -64,6 +64,16 @@ key, base URL, provider, and model to the TUI process. Use
save hosted-provider keys through the facade. SGLang and vLLM are self-hosted and can run
without an API key by default.
Third-party OpenAI-compatible gateways that need extra request headers can set
`http_headers = { "X-Model-Provider-Id" = "your-model-provider" }` at the top
level or under a provider table such as `[providers.deepseek]`. When configured,
DeepSeek TUI sends those custom headers on model API requests. The equivalent
environment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated
`name=value` pairs such as
`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`
and `Content-Type` are managed by the client and are not overridden by this
setting.
To bootstrap MCP and skills directories at their resolved paths, run `deepseek-tui setup`.
To only scaffold MCP, run `deepseek-tui mcp init`.
@@ -119,6 +129,7 @@ These override config values:
- `DEEPSEEK_API_KEY`
- `DEEPSEEK_BASE_URL`
- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)
- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim|openrouter|novita|fireworks|sglang|vllm`)
- `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL`
- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`)