diff --git a/.env.example b/.env.example index 876d8e99..1510b08f 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,7 @@ NEARAI_AUTH_URL=https://private.near.ai # LLM_BACKEND=openai_compatible # LLM_BASE_URL=https://openrouter.ai/api/v1 # LLM_API_KEY=sk-or-... +# LLM_EXTRA_HEADERS=HTTP-Referer:https://myapp.com,X-Title:MyApp # Channel Configuration diff --git a/CLAUDE.md b/CLAUDE.md index 38e96deb..cd3c4352 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -408,6 +408,10 @@ IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` **NEAR AI** -- Uses the Chat Completions API with dual auth support. Session token auth (default): authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google), base URL defaults to `https://private.near.ai`. API key auth: set `NEARAI_API_KEY` (from `cloud.near.ai`), base URL defaults to `https://cloud-api.near.ai`. Both modes use the same Chat Completions endpoint. Tool messages are flattened to plain text for compatibility. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment. +**NEAR AI Cloud** -- Uses the OpenAI-compatible Chat Completions API (`https://cloud-api.near.ai/v1/chat/completions`). Authenticates with API keys from `cloud.near.ai`. Auto-selected when `NEARAI_API_KEY` is set (or explicitly via `NEARAI_API_MODE=chat_completions`). Tool messages are flattened to plain text for compatibility. Configure with `NEARAI_API_KEY` and `NEARAI_BASE_URL` (default: `https://cloud-api.near.ai`). + +**OpenAI-compatible** -- Any endpoint that speaks the OpenAI API (vLLM, LiteLLM, OpenRouter, etc.). Configure with `LLM_BASE_URL`, `LLM_API_KEY` (optional), `LLM_MODEL`. Set `LLM_EXTRA_HEADERS` to inject custom HTTP headers into every request (format: `Key:Value,Key2:Value2`), useful for OpenRouter attribution headers like `HTTP-Referer` and `X-Title`. + **Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API. Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`). ## Database diff --git a/src/config/llm.rs b/src/config/llm.rs index 35e4ae48..bb49a7b0 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -90,6 +90,9 @@ pub struct OpenAiCompatibleConfig { pub base_url: String, pub api_key: Option, pub model: String, + /// Extra HTTP headers injected into every LLM request. + /// Parsed from `LLM_EXTRA_HEADERS` env var (format: `Key:Value,Key2:Value2`). + pub extra_headers: Vec<(String, String)>, } /// Configuration for Tinfoil private inference. @@ -298,10 +301,15 @@ impl LlmConfig { let model = optional_env("LLM_MODEL")? .or_else(|| settings.selected_model.clone()) .unwrap_or_else(|| "default".to_string()); + let extra_headers = optional_env("LLM_EXTRA_HEADERS")? + .map(|val| parse_extra_headers(&val)) + .transpose()? + .unwrap_or_default(); Some(OpenAiCompatibleConfig { base_url, api_key, model, + extra_headers, }) } else { None @@ -332,6 +340,40 @@ impl LlmConfig { } } +/// Parse `LLM_EXTRA_HEADERS` value into a list of (key, value) pairs. +/// +/// Format: `Key1:Value1,Key2:Value2` — colon-separated key:value, comma-separated pairs. +/// Colon is used as the separator (not `=`) because header values often contain `=` +/// (e.g., base64 tokens). +fn parse_extra_headers(val: &str) -> Result, ConfigError> { + if val.trim().is_empty() { + return Ok(Vec::new()); + } + + let mut headers = Vec::new(); + for pair in val.split(',') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + let Some((key, value)) = pair.split_once(':') else { + return Err(ConfigError::InvalidValue { + key: "LLM_EXTRA_HEADERS".to_string(), + message: format!("malformed header entry '{}', expected Key:Value", pair), + }); + }; + let key = key.trim(); + if key.is_empty() { + return Err(ConfigError::InvalidValue { + key: "LLM_EXTRA_HEADERS".to_string(), + message: format!("empty header name in entry '{}'", pair), + }); + } + headers.push((key.to_string(), value.trim().to_string())); + } + Ok(headers) +} + /// Get the default session file path (~/.ironclaw/session.json). fn default_session_path() -> PathBuf { dirs::home_dir() @@ -404,4 +446,69 @@ mod tests { std::env::remove_var("LLM_MODEL"); } } + + #[test] + fn test_extra_headers_parsed() { + let result = parse_extra_headers("HTTP-Referer:https://myapp.com,X-Title:MyApp").unwrap(); + assert_eq!( + result, + vec![ + ("HTTP-Referer".to_string(), "https://myapp.com".to_string()), + ("X-Title".to_string(), "MyApp".to_string()), + ] + ); + } + + #[test] + fn test_extra_headers_empty_string() { + let result = parse_extra_headers("").unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_extra_headers_whitespace_only() { + let result = parse_extra_headers(" ").unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_extra_headers_malformed() { + let result = parse_extra_headers("NoColonHere"); + assert!(result.is_err()); + } + + #[test] + fn test_extra_headers_empty_key() { + let result = parse_extra_headers(":value"); + assert!(result.is_err()); + } + + #[test] + fn test_extra_headers_value_with_colons() { + // Values can contain colons (e.g., URLs) + let result = parse_extra_headers("Authorization:Bearer abc:def").unwrap(); + assert_eq!( + result, + vec![("Authorization".to_string(), "Bearer abc:def".to_string())] + ); + } + + #[test] + fn test_extra_headers_trailing_comma() { + let result = parse_extra_headers("X-Title:MyApp,").unwrap(); + assert_eq!(result, vec![("X-Title".to_string(), "MyApp".to_string())]); + } + + #[test] + fn test_extra_headers_with_spaces() { + let result = + parse_extra_headers(" HTTP-Referer : https://myapp.com , X-Title : MyApp ").unwrap(); + assert_eq!( + result, + vec![ + ("HTTP-Referer".to_string(), "https://myapp.com".to_string()), + ("X-Title".to_string(), "MyApp".to_string()), + ] + ); + } } diff --git a/src/llm/mod.rs b/src/llm/mod.rs index efd550e2..f6251442 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -220,6 +220,25 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result n, + Err(e) => { + tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header name"); + continue; + } + }; + let val = match reqwest::header::HeaderValue::from_str(value) { + Ok(v) => v, + Err(e) => { + tracing::warn!(header = %key, error = %e, "Skipping LLM_EXTRA_HEADERS entry: invalid header value"); + continue; + } + }; + extra_headers.insert(name, val); + } + let client: openai::CompletionsClient = openai::Client::builder() .base_url(&compat.base_url) .api_key( @@ -229,6 +248,7 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result