mirror of
https://github.com/0xMassi/webclaw.git
synced 2026-05-30 20:55:12 +02:00
chore: rebrand webclaw to noxa
This commit is contained in:
parent
a4c351d5ae
commit
8674b60b4e
86 changed files with 781 additions and 2121 deletions
170
crates/noxa-llm/src/providers/anthropic.rs
Normal file
170
crates/noxa-llm/src/providers/anthropic.rs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/// Anthropic provider — Claude models via api.anthropic.com.
|
||||
/// Anthropic's API differs from OpenAI: system message is a top-level param,
|
||||
/// not part of the messages array.
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::clean::strip_thinking_tags;
|
||||
use crate::error::LlmError;
|
||||
use crate::provider::{CompletionRequest, LlmProvider};
|
||||
|
||||
use super::load_api_key;
|
||||
|
||||
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
||||
pub struct AnthropicProvider {
|
||||
client: reqwest::Client,
|
||||
key: String,
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
/// Returns `None` if no API key is available (param or env).
|
||||
pub fn new(key_override: Option<String>, model: Option<String>) -> Option<Self> {
|
||||
let key = load_api_key(key_override, "ANTHROPIC_API_KEY")?;
|
||||
|
||||
Some(Self {
|
||||
client: reqwest::Client::new(),
|
||||
key,
|
||||
default_model: model.unwrap_or_else(|| "claude-sonnet-4-20250514".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_model(&self) -> &str {
|
||||
&self.default_model
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for AnthropicProvider {
|
||||
async fn complete(&self, request: &CompletionRequest) -> Result<String, LlmError> {
|
||||
let model = if request.model.is_empty() {
|
||||
&self.default_model
|
||||
} else {
|
||||
&request.model
|
||||
};
|
||||
|
||||
// Anthropic separates system from messages. Extract the system message if present.
|
||||
let system_content: Option<String> = request
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
.map(|m| m.content.clone());
|
||||
|
||||
let messages: Vec<serde_json::Value> = request
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.role != "system")
|
||||
.map(|m| json!({ "role": m.role, "content": m.content }))
|
||||
.collect();
|
||||
|
||||
let mut body = json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": request.max_tokens.unwrap_or(4096),
|
||||
});
|
||||
|
||||
if let Some(system) = &system_content {
|
||||
body["system"] = json!(system);
|
||||
}
|
||||
if let Some(temp) = request.temperature {
|
||||
body["temperature"] = json!(temp);
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(ANTHROPIC_API_URL)
|
||||
.header("x-api-key", &self.key)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let safe_text = if text.len() > 500 {
|
||||
&text[..500]
|
||||
} else {
|
||||
&text
|
||||
};
|
||||
return Err(LlmError::ProviderError(format!(
|
||||
"anthropic returned {status}: {safe_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
|
||||
// Anthropic response: {"content": [{"type": "text", "text": "..."}]}
|
||||
let raw = json["content"][0]["text"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.ok_or_else(|| {
|
||||
LlmError::InvalidJson("missing content[0].text in anthropic response".into())
|
||||
})?;
|
||||
|
||||
Ok(strip_thinking_tags(&raw))
|
||||
}
|
||||
|
||||
async fn is_available(&self) -> bool {
|
||||
!self.key.is_empty()
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"anthropic"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_key_returns_none() {
|
||||
assert!(AnthropicProvider::new(Some(String::new()), None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_key_constructs() {
|
||||
let provider =
|
||||
AnthropicProvider::new(Some("sk-ant-test".into()), None).expect("should construct");
|
||||
assert_eq!(provider.name(), "anthropic");
|
||||
assert_eq!(provider.default_model, "claude-sonnet-4-20250514");
|
||||
assert_eq!(provider.key, "sk-ant-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_model() {
|
||||
let provider =
|
||||
AnthropicProvider::new(Some("sk-ant-test".into()), Some("claude-3-haiku".into()))
|
||||
.unwrap();
|
||||
assert_eq!(provider.default_model, "claude-3-haiku");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_model_accessor() {
|
||||
let provider = AnthropicProvider::new(Some("sk-ant-test".into()), None).unwrap();
|
||||
assert_eq!(provider.default_model(), "claude-sonnet-4-20250514");
|
||||
}
|
||||
|
||||
// Env var fallback tests mutate process-global state and race with parallel tests.
|
||||
// The code path is trivial (load_api_key -> env::var().ok()). Run in isolation if needed:
|
||||
// cargo test -p noxa-llm env_var -- --ignored --test-threads=1
|
||||
#[test]
|
||||
#[ignore = "mutates process env; run with --test-threads=1"]
|
||||
fn env_var_key_fallback() {
|
||||
unsafe { std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-env") };
|
||||
let provider = AnthropicProvider::new(None, None).expect("should construct from env");
|
||||
assert_eq!(provider.key, "sk-ant-env");
|
||||
unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mutates process env; run with --test-threads=1"]
|
||||
fn no_key_returns_none() {
|
||||
unsafe { std::env::remove_var("ANTHROPIC_API_KEY") };
|
||||
assert!(AnthropicProvider::new(None, None).is_none());
|
||||
}
|
||||
}
|
||||
36
crates/noxa-llm/src/providers/mod.rs
Normal file
36
crates/noxa-llm/src/providers/mod.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
pub mod anthropic;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
|
||||
/// Load an API key from an explicit override or an environment variable.
|
||||
/// Returns `None` if neither is set or the value is empty.
|
||||
pub(crate) fn load_api_key(override_key: Option<String>, env_var: &str) -> Option<String> {
|
||||
let key = override_key.or_else(|| std::env::var(env_var).ok())?;
|
||||
if key.is_empty() { None } else { Some(key) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn override_key_takes_precedence() {
|
||||
assert_eq!(
|
||||
load_api_key(Some("explicit".into()), "NONEXISTENT_VAR"),
|
||||
Some("explicit".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_returns_none() {
|
||||
assert_eq!(load_api_key(Some(String::new()), "NONEXISTENT_VAR"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_override_with_no_env_returns_none() {
|
||||
assert_eq!(
|
||||
load_api_key(None, "NOXA_TEST_NONEXISTENT_KEY_12345"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
161
crates/noxa-llm/src/providers/ollama.rs
Normal file
161
crates/noxa-llm/src/providers/ollama.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/// Ollama provider — talks to a local Ollama instance (default localhost:11434).
|
||||
/// First choice in the provider chain: free, private, fast on Apple Silicon.
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::clean::strip_thinking_tags;
|
||||
use crate::error::LlmError;
|
||||
use crate::provider::{CompletionRequest, LlmProvider};
|
||||
|
||||
pub struct OllamaProvider {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
impl OllamaProvider {
|
||||
pub fn new(base_url: Option<String>, model: Option<String>) -> Self {
|
||||
let base_url = base_url
|
||||
.or_else(|| std::env::var("OLLAMA_HOST").ok())
|
||||
.unwrap_or_else(|| "http://localhost:11434".into());
|
||||
|
||||
let default_model = model
|
||||
.or_else(|| std::env::var("OLLAMA_MODEL").ok())
|
||||
.unwrap_or_else(|| "qwen3:8b".into());
|
||||
|
||||
Self {
|
||||
client: reqwest::Client::new(),
|
||||
base_url,
|
||||
default_model,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_model(&self) -> &str {
|
||||
&self.default_model
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OllamaProvider {
|
||||
async fn complete(&self, request: &CompletionRequest) -> Result<String, LlmError> {
|
||||
let model = if request.model.is_empty() {
|
||||
&self.default_model
|
||||
} else {
|
||||
&request.model
|
||||
};
|
||||
|
||||
let messages: Vec<serde_json::Value> = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| json!({ "role": m.role, "content": m.content }))
|
||||
.collect();
|
||||
|
||||
let mut body = json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
"think": false,
|
||||
});
|
||||
|
||||
if request.json_mode {
|
||||
body["format"] = json!("json");
|
||||
}
|
||||
if let Some(temp) = request.temperature {
|
||||
body["options"] = json!({ "temperature": temp });
|
||||
}
|
||||
|
||||
let url = format!("{}/api/chat", self.base_url);
|
||||
let resp = self.client.post(&url).json(&body).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let safe_text = if text.len() > 500 {
|
||||
&text[..500]
|
||||
} else {
|
||||
&text
|
||||
};
|
||||
return Err(LlmError::ProviderError(format!(
|
||||
"ollama returned {status}: {safe_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
|
||||
let raw = json["message"]["content"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.ok_or_else(|| {
|
||||
LlmError::InvalidJson(format!(
|
||||
"missing message.content in ollama response: {json}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(strip_thinking_tags(&raw))
|
||||
}
|
||||
|
||||
async fn is_available(&self) -> bool {
|
||||
let url = format!("{}/api/tags", self.base_url);
|
||||
matches!(self.client.get(&url).send().await, Ok(r) if r.status().is_success())
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"ollama"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_params_used() {
|
||||
let provider = OllamaProvider::new(
|
||||
Some("http://gpu-box:11434".into()),
|
||||
Some("llama3:70b".into()),
|
||||
);
|
||||
assert_eq!(provider.base_url, "http://gpu-box:11434");
|
||||
assert_eq!(provider.default_model, "llama3:70b");
|
||||
assert_eq!(provider.name(), "ollama");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_model_overrides_any_env() {
|
||||
// Passing Some(...) bypasses env vars entirely -- no race possible
|
||||
let provider = OllamaProvider::new(None, Some("mistral:7b".into()));
|
||||
assert_eq!(provider.default_model, "mistral:7b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_url_overrides_any_env() {
|
||||
let provider = OllamaProvider::new(Some("http://local:11434".into()), None);
|
||||
assert_eq!(provider.base_url, "http://local:11434");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_model_accessor() {
|
||||
let provider = OllamaProvider::new(None, Some("phi3:mini".into()));
|
||||
assert_eq!(provider.default_model(), "phi3:mini");
|
||||
}
|
||||
|
||||
// Env var fallback is a trivial `env::var().ok()` -- not worth the flakiness
|
||||
// of manipulating process-global state. Run in isolation if needed:
|
||||
// cargo test -p noxa-llm env_var_fallback -- --ignored --test-threads=1
|
||||
#[test]
|
||||
#[ignore = "mutates process env; run with --test-threads=1"]
|
||||
fn env_var_fallback() {
|
||||
unsafe {
|
||||
std::env::set_var("OLLAMA_HOST", "http://remote:11434");
|
||||
std::env::set_var("OLLAMA_MODEL", "mistral:7b");
|
||||
}
|
||||
|
||||
let provider = OllamaProvider::new(None, None);
|
||||
assert_eq!(provider.base_url, "http://remote:11434");
|
||||
assert_eq!(provider.default_model, "mistral:7b");
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("OLLAMA_HOST");
|
||||
std::env::remove_var("OLLAMA_MODEL");
|
||||
}
|
||||
}
|
||||
}
|
||||
181
crates/noxa-llm/src/providers/openai.rs
Normal file
181
crates/noxa-llm/src/providers/openai.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/// OpenAI provider — works with api.openai.com and any OpenAI-compatible endpoint.
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::clean::strip_thinking_tags;
|
||||
use crate::error::LlmError;
|
||||
use crate::provider::{CompletionRequest, LlmProvider};
|
||||
|
||||
use super::load_api_key;
|
||||
|
||||
pub struct OpenAiProvider {
|
||||
client: reqwest::Client,
|
||||
key: String,
|
||||
base_url: String,
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
impl OpenAiProvider {
|
||||
/// Returns `None` if no API key is available (param or env).
|
||||
pub fn new(
|
||||
key_override: Option<String>,
|
||||
base_url: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Option<Self> {
|
||||
let key = load_api_key(key_override, "OPENAI_API_KEY")?;
|
||||
|
||||
Some(Self {
|
||||
client: reqwest::Client::new(),
|
||||
key,
|
||||
base_url: base_url
|
||||
.or_else(|| std::env::var("OPENAI_BASE_URL").ok())
|
||||
.unwrap_or_else(|| "https://api.openai.com/v1".into()),
|
||||
default_model: model.unwrap_or_else(|| "gpt-4o-mini".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_model(&self) -> &str {
|
||||
&self.default_model
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OpenAiProvider {
|
||||
async fn complete(&self, request: &CompletionRequest) -> Result<String, LlmError> {
|
||||
let model = if request.model.is_empty() {
|
||||
&self.default_model
|
||||
} else {
|
||||
&request.model
|
||||
};
|
||||
|
||||
let messages: Vec<serde_json::Value> = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| json!({ "role": m.role, "content": m.content }))
|
||||
.collect();
|
||||
|
||||
let mut body = json!({
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
});
|
||||
|
||||
if request.json_mode {
|
||||
body["response_format"] = json!({ "type": "json_object" });
|
||||
}
|
||||
if let Some(temp) = request.temperature {
|
||||
body["temperature"] = json!(temp);
|
||||
}
|
||||
if let Some(max) = request.max_tokens {
|
||||
body["max_tokens"] = json!(max);
|
||||
}
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.key))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
let safe_text = if text.len() > 500 {
|
||||
&text[..500]
|
||||
} else {
|
||||
&text
|
||||
};
|
||||
return Err(LlmError::ProviderError(format!(
|
||||
"openai returned {status}: {safe_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
|
||||
let raw = json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.ok_or_else(|| {
|
||||
LlmError::InvalidJson(
|
||||
"missing choices[0].message.content in openai response".into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(strip_thinking_tags(&raw))
|
||||
}
|
||||
|
||||
async fn is_available(&self) -> bool {
|
||||
!self.key.is_empty()
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"openai"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_key_returns_none() {
|
||||
assert!(OpenAiProvider::new(Some(String::new()), None, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_key_constructs() {
|
||||
let provider = OpenAiProvider::new(
|
||||
Some("test-key-123".into()),
|
||||
Some("https://api.openai.com/v1".into()),
|
||||
Some("gpt-4o-mini".into()),
|
||||
)
|
||||
.expect("should construct");
|
||||
assert_eq!(provider.name(), "openai");
|
||||
assert_eq!(provider.default_model, "gpt-4o-mini");
|
||||
assert_eq!(provider.base_url, "https://api.openai.com/v1");
|
||||
assert_eq!(provider.key, "test-key-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_base_url_and_model() {
|
||||
let provider = OpenAiProvider::new(
|
||||
Some("test-key".into()),
|
||||
Some("http://localhost:8080/v1".into()),
|
||||
Some("gpt-3.5-turbo".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(provider.base_url, "http://localhost:8080/v1");
|
||||
assert_eq!(provider.default_model, "gpt-3.5-turbo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_model_accessor() {
|
||||
let provider = OpenAiProvider::new(
|
||||
Some("test-key".into()),
|
||||
Some("https://api.openai.com/v1".into()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(provider.default_model(), "gpt-4o-mini");
|
||||
}
|
||||
|
||||
// Env var fallback tests mutate process-global state and race with parallel tests.
|
||||
// The code path is trivial (load_api_key -> env::var().ok()). Run in isolation if needed:
|
||||
// cargo test -p noxa-llm env_var -- --ignored --test-threads=1
|
||||
#[test]
|
||||
#[ignore = "mutates process env; run with --test-threads=1"]
|
||||
fn env_var_key_fallback() {
|
||||
unsafe { std::env::set_var("OPENAI_API_KEY", "sk-env-key") };
|
||||
let provider = OpenAiProvider::new(None, None, None).expect("should construct from env");
|
||||
assert_eq!(provider.key, "sk-env-key");
|
||||
unsafe { std::env::remove_var("OPENAI_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "mutates process env; run with --test-threads=1"]
|
||||
fn no_key_returns_none() {
|
||||
unsafe { std::env::remove_var("OPENAI_API_KEY") };
|
||||
assert!(OpenAiProvider::new(None, None, None).is_none());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue