From 6685648622a7197b7e7755355fcc05c961071725 Mon Sep 17 00:00:00 2001 From: rock Date: Sun, 30 Aug 2026 17:58:27 -0700 Subject: [PATCH] feat: multi-provider auth for ChatClient (OpenRouter, OpenAI, Ollama) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-detect auth mode from base URL: - openrouter.ai, api.openai.com → Bearer token - api.riotpiao.com → apikey header - localhost → no auth Explicit override via with_auth_mode() --- crates/mem-llm/src/chat.rs | 68 ++++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/mem-llm/src/chat.rs b/crates/mem-llm/src/chat.rs index c72529c..f71daac 100644 --- a/crates/mem-llm/src/chat.rs +++ b/crates/mem-llm/src/chat.rs @@ -21,7 +21,46 @@ pub struct Usage { pub total_tokens: u32, } -/// Chat client for the gateway. +/// Auth mode for different OpenAI-compatible providers. +#[derive(Debug, Clone)] +pub enum AuthMode { + /// `apikey: ` header (riotpiao gateway, Ollama) + ApiKeyHeader, + /// `Authorization: Bearer ` (OpenRouter, OpenAI, Anthropic) + Bearer, + /// No auth header + None, +} + +impl AuthMode { + /// Detect from base URL or explicit env var. + pub fn detect(base_url: &str, api_key: &str) -> Self { + if api_key.is_empty() { + return Self::None; + } + // OpenRouter, OpenAI, Anthropic, Together, etc. use Bearer + if base_url.contains("openrouter.ai") + || base_url.contains("api.openai.com") + || base_url.contains("api.anthropic.com") + || base_url.contains("api.together.xyz") + || base_url.contains("api.groq.com") + { + Self::Bearer + } else { + // Default: apikey header (riotpiao gateway) + Self::ApiKeyHeader + } + } +} + +/// Chat client for OpenAI-compatible providers. +/// +/// Supports: +/// - riotpiao gateway (apikey header) +/// - OpenRouter (Bearer token, `openrouter.ai/api/v1`) +/// - OpenAI (Bearer token) +/// - Ollama (no auth or apikey) +/// - Any OpenAI-compatible endpoint pub struct ChatClient { base_url: String, api_key: String, @@ -29,6 +68,7 @@ pub struct ChatClient { http: Client, timeout: Duration, max_retries: u32, + auth_mode: AuthMode, } #[derive(Debug, Serialize)] @@ -82,16 +122,26 @@ impl ChatClient { /// * `api_key` - Authentication key /// * `model` - Model identifier (e.g., `qwen2.5:3b-instruct`) pub fn new(base_url: impl Into, api_key: impl Into, model: impl Into) -> Result { + let base_url = base_url.into(); + let api_key = api_key.into(); + let auth_mode = AuthMode::detect(&base_url, &api_key); Ok(Self { - base_url: base_url.into(), - api_key: api_key.into(), + base_url, + api_key, model: model.into(), http: Client::new(), timeout: Duration::from_secs(300), max_retries: 3, + auth_mode, }) } + /// Create with explicit auth mode. + pub fn with_auth_mode(mut self, mode: AuthMode) -> Self { + self.auth_mode = mode; + self + } + /// Set custom timeout. pub fn with_timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; @@ -145,9 +195,15 @@ impl ChatClient { for attempt in 0..self.max_retries { let mut req = self.http.post(&url); - // Only add apikey header if it's not empty (for backward compatibility) - if !self.api_key.is_empty() && !self.api_key.starts_with("http") { - req = req.header("apikey", &self.api_key); + // Apply auth based on provider + match &self.auth_mode { + AuthMode::ApiKeyHeader => { + req = req.header("apikey", &self.api_key); + } + AuthMode::Bearer => { + req = req.header("Authorization", format!("Bearer {}", self.api_key)); + } + AuthMode::None => {} } let response = req