//! OpenRouter adapter — free-tier rescue rung for /v1/chat. //! //! Direct HTTPS call to `https://openrouter.ai/api/v1/chat/completions` //! with Bearer auth. Mirrors the OpenAI-compatible shape so the model //! list can be expanded without code changes. Added 2026-04-24 after //! iter 5 hit repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter //! free-tier models give us a different provider backbone as fallback. //! //! Key sourcing priority: //! 1. Env var `OPENROUTER_API_KEY` //! 2. `/home/profit/.env` (LLM Team convention) //! 3. `/root/llm_team_config.json` → providers.openrouter.api_key //! //! First hit wins. Key is resolved once at gateway startup and stored //! on `V1State.openrouter_key`. use std::time::Duration; use serde::{Deserialize, Serialize}; use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock}; const OR_BASE_URL: &str = "https://openrouter.ai/api/v1"; const OR_TIMEOUT_SECS: u64 = 180; pub fn resolve_openrouter_key() -> Option { if let Ok(k) = std::env::var("OPENROUTER_API_KEY") { if !k.trim().is_empty() { return Some(k.trim().to_string()); } } // LLM Team UI writes its key to ~/.env on the host user — pick it up // from the same source so the free-tier rescue path works without // an explicit systemd Environment= line. for path in ["/home/profit/.env", "/root/.env"] { if let Ok(raw) = std::fs::read_to_string(path) { for line in raw.lines() { if let Some(rest) = line.strip_prefix("OPENROUTER_API_KEY=") { let k = rest.trim().trim_matches('"').trim_matches('\''); if !k.is_empty() { return Some(k.to_string()); } } } } } if let Ok(raw) = std::fs::read_to_string("/root/llm_team_config.json") { if let Ok(v) = serde_json::from_str::(&raw) { if let Some(k) = v.pointer("/providers/openrouter/api_key").and_then(|x| x.as_str()) { if !k.trim().is_empty() { return Some(k.trim().to_string()); } } } } None } pub async fn chat( key: &str, req: &ChatRequest, ) -> Result { // Strip the "openrouter/" prefix if the caller used the namespaced // form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free"). let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string(); let body = ORChatBody { model: model.clone(), messages: req.messages.iter().map(|m| ORMessage { role: m.role.clone(), content: m.content.clone(), }).collect(), max_tokens: req.max_tokens.unwrap_or(800), temperature: req.temperature.unwrap_or(0.3), stream: false, }; let client = reqwest::Client::builder() .timeout(Duration::from_secs(OR_TIMEOUT_SECS)) .build() .map_err(|e| format!("build client: {e}"))?; let t0 = std::time::Instant::now(); let resp = client .post(format!("{}/chat/completions", OR_BASE_URL)) .bearer_auth(key) // OpenRouter recommends Referer + Title for attribution; absent // headers do not fail the call but help us see our traffic in // their dashboard. .header("HTTP-Referer", "https://vcp.devop.live") .header("X-Title", "Lakehouse Scrum") .json(&body) .send() .await .map_err(|e| format!("openrouter.ai unreachable: {e}"))?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_else(|_| "?".into()); return Err(format!("openrouter.ai {}: {}", status, body)); } let parsed: ORChatResponse = resp.json().await .map_err(|e| format!("invalid openrouter response: {e}"))?; let latency_ms = t0.elapsed().as_millis(); let choice = parsed.choices.into_iter().next() .ok_or_else(|| "openrouter returned no choices".to_string())?; let text = choice.message.content; let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| { let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum(); ((chars + 3) / 4) as u32 }); let completion_tokens = parsed.usage.as_ref().map(|u| u.completion_tokens).unwrap_or_else(|| { ((text.chars().count() + 3) / 4) as u32 }); tracing::info!( target: "v1.chat", provider = "openrouter", model = %model, prompt_tokens, completion_tokens, latency_ms = latency_ms as u64, "openrouter chat completed", ); Ok(ChatResponse { id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)), object: "chat.completion", created: chrono::Utc::now().timestamp(), model, choices: vec![Choice { index: 0, message: Message { role: "assistant".into(), content: text }, finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()), }], usage: UsageBlock { prompt_tokens, completion_tokens, total_tokens: prompt_tokens + completion_tokens, }, }) } // -- OpenRouter wire shapes (OpenAI-compatible) -- #[derive(Serialize)] struct ORChatBody { model: String, messages: Vec, max_tokens: u32, temperature: f64, stream: bool, } #[derive(Serialize)] struct ORMessage { role: String, content: String } #[derive(Deserialize)] struct ORChatResponse { choices: Vec, #[serde(default)] usage: Option, } #[derive(Deserialize)] struct ORChoice { message: ORMessageResp, #[serde(default)] finish_reason: Option, } #[derive(Deserialize)] struct ORMessageResp { content: String } #[derive(Deserialize)] struct ORUsage { prompt_tokens: u32, completion_tokens: u32 } #[cfg(test)] mod tests { use super::*; #[test] fn resolve_openrouter_key_does_not_panic() { // Smoke test — all three sources may or may not be set depending // on environment; just confirm the call returns cleanly. let _ = resolve_openrouter_key(); } #[test] fn chat_body_serializes_to_openai_shape() { let body = ORChatBody { model: "openai/gpt-oss-120b:free".into(), messages: vec![ ORMessage { role: "user".into(), content: "review this".into() }, ], max_tokens: 800, temperature: 0.3, stream: false, }; let json = serde_json::to_string(&body).unwrap(); assert!(json.contains("\"model\":\"openai/gpt-oss-120b:free\"")); assert!(json.contains("\"messages\"")); assert!(json.contains("\"max_tokens\":800")); assert!(json.contains("\"stream\":false")); } #[test] fn model_prefix_strip_preserves_unprefixed() { // If caller passes "openrouter/openai/gpt-oss-120b:free" we strip. // If caller passes "openai/gpt-oss-120b:free" unchanged, we keep. let cases = [ ("openrouter/openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"), ("openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"), ("google/gemma-3-27b-it:free", "google/gemma-3-27b-it:free"), ]; for (input, expected) in cases { let out = input.strip_prefix("openrouter/").unwrap_or(input); assert_eq!(out, expected, "{input} should become {expected}"); } } }