From adb0fc497a37f309f17ff8f79f5be9c4932fd2a7 Mon Sep 17 00:00:00 2001 From: Stefan Obradovic Date: Sun, 1 Mar 2026 01:56:49 +1000 Subject: [PATCH 1/4] feat: add OpenRouter app attribution headers Add extra_headers field to ProviderConfig and populate it for OpenRouter with HTTP-Referer, X-OpenRouter-Title, and X-OpenRouter-Categories headers per OpenRouter's app attribution spec. Headers are applied in call_openai() for every request. --- src/config.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++++ src/llm/manager.rs | 2 ++ src/llm/model.rs | 5 +++ 3 files changed, 96 insertions(+) diff --git a/src/config.rs b/src/config.rs index 38c2f987e..dfdc76209 100644 --- a/src/config.rs +++ b/src/config.rs @@ -183,6 +183,8 @@ pub struct ProviderConfig { /// Anthropic requests. Set automatically when the key originates from /// `ANTHROPIC_AUTH_TOKEN` (proxy-compatible auth). pub use_bearer_auth: bool, + /// Additional HTTP headers to include in every request to this provider. + pub extra_headers: Vec<(String, String)>, } impl std::fmt::Debug for ProviderConfig { @@ -193,6 +195,7 @@ impl std::fmt::Debug for ProviderConfig { .field("api_key", &"[REDACTED]") .field("name", &self.name) .field("use_bearer_auth", &self.use_bearer_auth) + .field("extra_headers", &self.extra_headers) .finish() } } @@ -348,6 +351,19 @@ const FIREWORKS_PROVIDER_BASE_URL: &str = "https://api.fireworks.ai/inference"; pub(crate) const GEMINI_PROVIDER_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai"; +/// App attribution headers sent with every OpenRouter API request. +/// See . +fn openrouter_extra_headers() -> Vec<(String, String)> { + vec![ + ("HTTP-Referer".into(), "https://spacebot.sh/".into()), + ("X-OpenRouter-Title".into(), "Spacebot".into()), + ( + "X-OpenRouter-Categories".into(), + "cloud-agent,cli-agent".into(), + ), + ] +} + /// Returns the default ProviderConfig for a provider ID and API key. /// Used by API tests and other code that needs provider configs without duplicating metadata. pub(crate) fn default_provider_config( @@ -362,6 +378,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "openai" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -369,6 +386,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "openrouter" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -376,6 +394,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: openrouter_extra_headers(), }, "kilo" => ProviderConfig { api_type: ApiType::KiloGateway, @@ -383,6 +402,7 @@ pub(crate) fn default_provider_config( api_key, name: Some("Kilo Gateway".to_string()), use_bearer_auth: false, + extra_headers: vec![], }, "zhipu" => ProviderConfig { api_type: ApiType::OpenAiChatCompletions, @@ -390,6 +410,7 @@ pub(crate) fn default_provider_config( api_key, name: Some("Z.AI (GLM)".to_string()), use_bearer_auth: false, + extra_headers: vec![], }, "groq" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -397,6 +418,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "together" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -404,6 +426,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "fireworks" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -411,6 +434,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "deepseek" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -418,6 +442,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "xai" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -425,6 +450,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "mistral" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -432,6 +458,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "gemini" => ProviderConfig { api_type: ApiType::Gemini, @@ -439,6 +466,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "ollama" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -446,6 +474,7 @@ pub(crate) fn default_provider_config( api_key: String::new(), name: None, use_bearer_auth: false, + extra_headers: vec![], }, "opencode-zen" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -453,6 +482,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "opencode-go" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -460,6 +490,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "nvidia" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -467,6 +498,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "minimax" => ProviderConfig { api_type: ApiType::Anthropic, @@ -474,6 +506,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "minimax-cn" => ProviderConfig { api_type: ApiType::Anthropic, @@ -481,6 +514,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "moonshot" => ProviderConfig { api_type: ApiType::OpenAiCompletions, @@ -488,6 +522,7 @@ pub(crate) fn default_provider_config( api_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }, "zai-coding-plan" => ProviderConfig { api_type: ApiType::OpenAiChatCompletions, @@ -495,6 +530,7 @@ pub(crate) fn default_provider_config( api_key, name: Some("Z.AI Coding Plan".to_string()), use_bearer_auth: false, + extra_headers: vec![], }, _ => return None, }) @@ -518,6 +554,7 @@ fn add_shorthand_provider( api_key, name: name.map(str::to_string), use_bearer_auth, + extra_headers: vec![], }); } } @@ -3352,6 +3389,7 @@ impl Config { api_key: anthropic_key, name: None, use_bearer_auth: anthropic_from_auth_token, + extra_headers: vec![], }); } @@ -3364,6 +3402,7 @@ impl Config { api_key: openrouter_key, name: None, use_bearer_auth: false, + extra_headers: openrouter_extra_headers(), }); } @@ -3424,6 +3463,7 @@ impl Config { api_key: minimax_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3436,6 +3476,7 @@ impl Config { api_key: minimax_cn_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3448,6 +3489,7 @@ impl Config { api_key: openai_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3460,6 +3502,7 @@ impl Config { api_key: openrouter_key, name: None, use_bearer_auth: false, + extra_headers: openrouter_extra_headers(), }); } @@ -3500,6 +3543,7 @@ impl Config { api_key: opencode_zen_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3512,6 +3556,7 @@ impl Config { api_key: opencode_go_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3524,6 +3569,7 @@ impl Config { api_key: minimax_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3536,6 +3582,7 @@ impl Config { api_key: minimax_cn_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3548,6 +3595,7 @@ impl Config { api_key: moonshot_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3560,6 +3608,7 @@ impl Config { api_key: nvidia_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3572,6 +3621,7 @@ impl Config { api_key: fireworks_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3584,6 +3634,7 @@ impl Config { api_key: deepseek_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3596,6 +3647,7 @@ impl Config { api_key: gemini_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3608,6 +3660,7 @@ impl Config { api_key: groq_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3620,6 +3673,7 @@ impl Config { api_key: together_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3632,6 +3686,7 @@ impl Config { api_key: xai_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3644,6 +3699,7 @@ impl Config { api_key: mistral_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3659,6 +3715,7 @@ impl Config { api_key: llm.ollama_key.clone().unwrap_or_default(), name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3944,6 +4001,7 @@ impl Config { api_key, name: config.name, use_bearer_auth: false, + extra_headers: vec![], }, )) }) @@ -3969,6 +4027,7 @@ impl Config { api_key: anthropic_key, name: None, use_bearer_auth: anthropic_from_auth_token, + extra_headers: vec![], }); } @@ -3981,6 +4040,7 @@ impl Config { api_key: openai_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -3993,6 +4053,7 @@ impl Config { api_key: openrouter_key, name: None, use_bearer_auth: false, + extra_headers: openrouter_extra_headers(), }); } @@ -4053,6 +4114,7 @@ impl Config { api_key: minimax_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4065,6 +4127,7 @@ impl Config { api_key: minimax_cn_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4077,6 +4140,7 @@ impl Config { api_key: moonshot_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4089,6 +4153,7 @@ impl Config { api_key: nvidia_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4101,6 +4166,7 @@ impl Config { api_key: fireworks_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4113,6 +4179,7 @@ impl Config { api_key: deepseek_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4125,6 +4192,7 @@ impl Config { api_key: gemini_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4137,6 +4205,7 @@ impl Config { api_key: groq_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4149,6 +4218,7 @@ impl Config { api_key: together_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4161,6 +4231,7 @@ impl Config { api_key: xai_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4173,6 +4244,7 @@ impl Config { api_key: mistral_key, name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -4188,6 +4260,7 @@ impl Config { api_key: llm.ollama_key.clone().unwrap_or_default(), name: None, use_bearer_auth: false, + extra_headers: vec![], }); } @@ -6427,6 +6500,22 @@ openrouter_key = "legacy-openrouter-key" assert_eq!(openrouter_provider.api_type, ApiType::OpenAiCompletions); assert_eq!(openrouter_provider.base_url, OPENROUTER_PROVIDER_BASE_URL); assert_eq!(openrouter_provider.api_key, "legacy-openrouter-key"); + assert_eq!(openrouter_provider.extra_headers.len(), 3); + assert_eq!(openrouter_provider.extra_headers[0].0, "HTTP-Referer"); + assert_eq!( + openrouter_provider.extra_headers[0].1, + "https://spacebot.sh/" + ); + assert_eq!(openrouter_provider.extra_headers[1].0, "X-OpenRouter-Title"); + assert_eq!(openrouter_provider.extra_headers[1].1, "Spacebot"); + assert_eq!( + openrouter_provider.extra_headers[2].0, + "X-OpenRouter-Categories" + ); + assert_eq!( + openrouter_provider.extra_headers[2].1, + "cloud-agent,cli-agent" + ); } #[test] diff --git a/src/llm/manager.rs b/src/llm/manager.rs index 56693c560..bd0044d49 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -186,6 +186,7 @@ impl LlmManager { api_key: token, name: None, use_bearer_auth: false, + extra_headers: vec![], }), (None, None) => Err(LlmError::UnknownProvider("anthropic".to_string()).into()), } @@ -255,6 +256,7 @@ impl LlmManager { api_key: token, name: None, use_bearer_auth: false, + extra_headers: vec![], }), None => Err(LlmError::UnknownProvider("openai-chatgpt".to_string()).into()), } diff --git a/src/llm/model.rs b/src/llm/model.rs index 7eaa5780e..bd0a38ab4 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -586,6 +586,11 @@ impl SpacebotModel { request_builder = request_builder.header("user-agent", "KimiCLI/1.3"); } + // Apply provider-specific extra headers (e.g. OpenRouter app attribution). + for (key, value) in &provider_config.extra_headers { + request_builder = request_builder.header(key.as_str(), value.as_str()); + } + let response = request_builder .json(&body) .send() From 01b5ad93d4cdc57812eb7995f510b2c3f9815a2c Mon Sep 17 00:00:00 2001 From: Stefan Obradovic Date: Sun, 1 Mar 2026 02:04:49 +1000 Subject: [PATCH 2/4] fix: apply OpenRouter attribution headers for explicit TOML config --- src/config.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index dfdc76209..4d3571b43 100644 --- a/src/config.rs +++ b/src/config.rs @@ -183,7 +183,8 @@ pub struct ProviderConfig { /// Anthropic requests. Set automatically when the key originates from /// `ANTHROPIC_AUTH_TOKEN` (proxy-compatible auth). pub use_bearer_auth: bool, - /// Additional HTTP headers to include in every request to this provider. + /// Additional HTTP headers included in requests to this provider. + /// Currently applied in `call_openai()` (the `OpenAiCompletions` path). pub extra_headers: Vec<(String, String)>, } @@ -4001,7 +4002,11 @@ impl Config { api_key, name: config.name, use_bearer_auth: false, - extra_headers: vec![], + extra_headers: if provider_id == "openrouter" { + openrouter_extra_headers() + } else { + vec![] + }, }, )) }) From 9ae5ac09fbad580feb362e2c01ac13abd876ff81 Mon Sep 17 00:00:00 2001 From: Stefan Obradovic Date: Sun, 1 Mar 2026 02:22:24 +1000 Subject: [PATCH 3/4] test: add comprehensive extra_headers coverage - Make OpenRouter header assertions order-independent (M-3) - Verify non-OpenRouter providers have empty extra_headers (M-4) - Test explicit TOML provider config path injects headers (I-1) --- src/config.rs | 72 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 4d3571b43..035b19cd8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6487,6 +6487,10 @@ openrouter_key = "legacy-openrouter-key" assert_eq!(anthropic_provider.api_type, ApiType::Anthropic); assert_eq!(anthropic_provider.base_url, ANTHROPIC_PROVIDER_BASE_URL); assert_eq!(anthropic_provider.api_key, "legacy-anthropic-key"); + assert!( + anthropic_provider.extra_headers.is_empty(), + "anthropic provider should have no extra_headers" + ); let openai_provider = config .llm @@ -6496,6 +6500,10 @@ openrouter_key = "legacy-openrouter-key" assert_eq!(openai_provider.api_type, ApiType::OpenAiCompletions); assert_eq!(openai_provider.base_url, OPENAI_PROVIDER_BASE_URL); assert_eq!(openai_provider.api_key, "legacy-openai-key"); + assert!( + openai_provider.extra_headers.is_empty(), + "openai provider should have no extra_headers" + ); let openrouter_provider = config .llm @@ -6506,20 +6514,18 @@ openrouter_key = "legacy-openrouter-key" assert_eq!(openrouter_provider.base_url, OPENROUTER_PROVIDER_BASE_URL); assert_eq!(openrouter_provider.api_key, "legacy-openrouter-key"); assert_eq!(openrouter_provider.extra_headers.len(), 3); - assert_eq!(openrouter_provider.extra_headers[0].0, "HTTP-Referer"); - assert_eq!( - openrouter_provider.extra_headers[0].1, - "https://spacebot.sh/" - ); - assert_eq!(openrouter_provider.extra_headers[1].0, "X-OpenRouter-Title"); - assert_eq!(openrouter_provider.extra_headers[1].1, "Spacebot"); - assert_eq!( - openrouter_provider.extra_headers[2].0, - "X-OpenRouter-Categories" - ); + let find_header = |name: &str| -> Option<&str> { + openrouter_provider + .extra_headers + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(find_header("HTTP-Referer"), Some("https://spacebot.sh/")); + assert_eq!(find_header("X-OpenRouter-Title"), Some("Spacebot")); assert_eq!( - openrouter_provider.extra_headers[2].1, - "cloud-agent,cli-agent" + find_header("X-OpenRouter-Categories"), + Some("cloud-agent,cli-agent") ); } @@ -6551,6 +6557,46 @@ name = "Custom OpenAI" assert_eq!(config.llm.openai_key.as_deref(), Some("legacy-openai-key")); } + #[test] + fn test_explicit_openrouter_provider_toml_injects_extra_headers() { + let toml = r#" +[llm.provider.openrouter] +api_type = "openai_completions" +base_url = "https://openrouter.ai/api/v1" +api_key = "explicit-openrouter-key" +name = "My OpenRouter" +"#; + + let parsed: TomlConfig = toml::from_str(toml).expect("failed to parse test TOML"); + let config = Config::from_toml(parsed, PathBuf::from(".")).expect("failed to build Config"); + + let openrouter_provider = config + .llm + .providers + .get("openrouter") + .expect("openrouter provider missing"); + assert_eq!(openrouter_provider.api_type, ApiType::OpenAiCompletions); + assert_eq!(openrouter_provider.base_url, "https://openrouter.ai/api/v1"); + assert_eq!(openrouter_provider.api_key, "explicit-openrouter-key"); + assert_eq!(openrouter_provider.name.as_deref(), Some("My OpenRouter")); + + // Verify attribution headers are injected even for explicit TOML config + assert_eq!(openrouter_provider.extra_headers.len(), 3); + let find_header = |name: &str| -> Option<&str> { + openrouter_provider + .extra_headers + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(find_header("HTTP-Referer"), Some("https://spacebot.sh/")); + assert_eq!(find_header("X-OpenRouter-Title"), Some("Spacebot")); + assert_eq!( + find_header("X-OpenRouter-Categories"), + Some("cloud-agent,cli-agent") + ); + } + #[test] fn test_needs_onboarding_without_config_or_env() { let _lock = env_test_lock() From f58d077597588789a1f5216ced9833c74d33cf92 Mon Sep 17 00:00:00 2001 From: Stefan Obradovic Date: Sun, 1 Mar 2026 02:36:33 +1000 Subject: [PATCH 4/4] fix: address PR review feedback from CodeRabbit and Tembo - Normalize provider ID before OpenRouter header check (case-insensitive) - Redact extra_headers values in Debug impl (log names only) - Propagate extra_headers to OpenAiChatCompletions call path - Use descriptive variable names in test closures --- src/config.rs | 31 ++++++++++++++++++++----------- src/llm/model.rs | 7 ++++++- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/config.rs b/src/config.rs index 035b19cd8..fd54d1691 100644 --- a/src/config.rs +++ b/src/config.rs @@ -196,7 +196,14 @@ impl std::fmt::Debug for ProviderConfig { .field("api_key", &"[REDACTED]") .field("name", &self.name) .field("use_bearer_auth", &self.use_bearer_auth) - .field("extra_headers", &self.extra_headers) + .field( + "extra_headers", + &self + .extra_headers + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + ) .finish() } } @@ -3994,19 +4001,21 @@ impl Config { let api_key = resolve_env_value(&config.api_key).ok_or_else(|| { anyhow::anyhow!("failed to resolve API key for provider '{}'", provider_id) })?; + let normalized_id = provider_id.to_lowercase(); + let extra_headers = if normalized_id == "openrouter" { + openrouter_extra_headers() + } else { + vec![] + }; Ok(( - provider_id.to_lowercase(), + normalized_id, ProviderConfig { api_type: config.api_type, base_url: config.base_url, api_key, name: config.name, use_bearer_auth: false, - extra_headers: if provider_id == "openrouter" { - openrouter_extra_headers() - } else { - vec![] - }, + extra_headers, }, )) }) @@ -6518,8 +6527,8 @@ openrouter_key = "legacy-openrouter-key" openrouter_provider .extra_headers .iter() - .find(|(k, _)| k == name) - .map(|(_, v)| v.as_str()) + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) }; assert_eq!(find_header("HTTP-Referer"), Some("https://spacebot.sh/")); assert_eq!(find_header("X-OpenRouter-Title"), Some("Spacebot")); @@ -6586,8 +6595,8 @@ name = "My OpenRouter" openrouter_provider .extra_headers .iter() - .find(|(k, _)| k == name) - .map(|(_, v)| v.as_str()) + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) }; assert_eq!(find_header("HTTP-Referer"), Some("https://spacebot.sh/")); assert_eq!(find_header("X-OpenRouter-Title"), Some("Spacebot")); diff --git a/src/llm/model.rs b/src/llm/model.rs index bd0a38ab4..a37fc8823 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -123,12 +123,17 @@ impl SpacebotModel { .name .as_deref() .unwrap_or("OpenAI-compatible provider"); + let headers: Vec<(&str, &str)> = provider_config + .extra_headers + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); self.call_openai_compatible_with_optional_auth( request, display_name, &endpoint, Some(provider_config.api_key.clone()), - &[], + &headers, ) .await }