From f29892b3fb1eadfb31baf3131bb2356b116d8427 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Mon, 2 Feb 2026 21:26:39 -0800 Subject: [PATCH] Add NEAR AI chat-api as default LLM provider Adds NearAiProvider that uses the NEAR AI unified API at api.near.ai/v1/responses with session token authentication. This provides access to multiple models (OpenAI, Anthropic, etc.) through a single endpoint with user auth and usage tracking. - Add src/llm/nearai.rs with complete provider implementation - Add NearAiConfig to config.rs with session_token, model, base_url - Add NearAi variant to LlmProvider enum (accepts nearai/near-ai/near_ai) - Change default provider from OpenAi to NearAi - Update .env.example with NEAR AI configuration - Update CLAUDE.md documentation Co-Authored-By: Claude Opus 4.5 --- .env.example | 14 +- CLAUDE.md | 26 ++++ src/config.rs | 53 +++++++- src/llm/mod.rs | 10 +- src/llm/nearai.rs | 340 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+), 6 deletions(-) create mode 100644 src/llm/nearai.rs diff --git a/.env.example b/.env.example index c9283388..e08f40d3 100644 --- a/.env.example +++ b/.env.example @@ -3,13 +3,23 @@ DATABASE_URL=postgres://near_agent:password@localhost:5432/near_agent DATABASE_POOL_SIZE=10 # LLM Providers +# Default is NEAR AI which provides a unified interface to all models + +# NEAR AI (recommended - unified API with user authentication) +NEARAI_SESSION_TOKEN=sess_... +NEARAI_MODEL=claude-3-5-sonnet-20241022 +NEARAI_BASE_URL=https://api.near.ai + +# OpenAI (alternative) OPENAI_API_KEY=sk-... OPENAI_MODEL=gpt-4-turbo-preview + +# Anthropic (alternative) ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-3-opus-20240229 -# Default LLM provider: openai or anthropic -LLM_PROVIDER=openai +# Default LLM provider: nearai, openai, or anthropic +LLM_PROVIDER=nearai # Channel Configuration # CLI is always enabled diff --git a/CLAUDE.md b/CLAUDE.md index 573ee615..577e6133 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ src/ │ ├── llm/ # LLM integration │ ├── provider.rs # LlmProvider trait, message types +│ ├── nearai.rs # NEAR AI chat-api (default, unified interface) │ ├── openai.rs # OpenAI API implementation │ ├── anthropic.rs # Anthropic API implementation │ └── reasoning.rs # Planning, tool selection, evaluation @@ -162,12 +163,37 @@ Pending -> InProgress -> Completed -> Submitted -> Accepted Environment variables (see `.env.example`): ```bash DATABASE_URL=postgres://user:pass@localhost/near_agent + +# LLM Provider (default: nearai) +LLM_PROVIDER=nearai # Options: nearai, openai, anthropic + +# NEAR AI (recommended - unified API with user auth) +NEARAI_SESSION_TOKEN=sess_... +NEARAI_MODEL=claude-3-5-sonnet-20241022 +NEARAI_BASE_URL=https://api.near.ai + +# OpenAI (alternative) OPENAI_API_KEY=sk-... +OPENAI_MODEL=gpt-4-turbo + +# Anthropic (alternative) ANTHROPIC_API_KEY=sk-ant-... +ANTHROPIC_MODEL=claude-3-opus-20240229 + +# Agent settings AGENT_NAME=near-agent MAX_PARALLEL_JOBS=5 ``` +### NEAR AI Provider + +The default provider uses the NEAR AI chat-api (`https://api.near.ai/v1/responses`) which provides: +- Unified access to multiple models (OpenAI, Anthropic, etc.) +- User authentication via session tokens +- Usage tracking and billing through NEAR AI + +Session tokens have the format `sess_xxx` (37 characters). They are authenticated against the NEAR AI auth service. + ## Database Migrations in `migrations/`. Tables: diff --git a/src/config.rs b/src/config.rs index 7421c5c1..6fb6f614 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,12 +66,14 @@ pub struct LlmConfig { pub provider: LlmProvider, pub openai: Option, pub anthropic: Option, + pub nearai: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LlmProvider { OpenAi, Anthropic, + NearAi, } impl std::str::FromStr for LlmProvider { @@ -81,9 +83,12 @@ impl std::str::FromStr for LlmProvider { match s.to_lowercase().as_str() { "openai" => Ok(Self::OpenAi), "anthropic" => Ok(Self::Anthropic), + "nearai" | "near-ai" | "near_ai" => Ok(Self::NearAi), _ => Err(ConfigError::InvalidValue { key: "LLM_PROVIDER".to_string(), - message: format!("unknown provider: {s}, expected 'openai' or 'anthropic'"), + message: format!( + "unknown provider: {s}, expected 'openai', 'anthropic', or 'nearai'" + ), }), } } @@ -103,12 +108,23 @@ pub struct AnthropicConfig { pub base_url: Option, } +/// NEAR AI chat-api configuration. +#[derive(Debug, Clone)] +pub struct NearAiConfig { + /// Session token for authentication (format: sess_xxx) + pub session_token: SecretString, + /// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o") + pub model: String, + /// Base URL for the NEAR AI chat-api (default: https://api.near.ai) + pub base_url: String, +} + impl LlmConfig { fn from_env() -> Result { let provider: LlmProvider = optional_env("LLM_PROVIDER")? .map(|s| s.parse()) .transpose()? - .unwrap_or(LlmProvider::OpenAi); + .unwrap_or(LlmProvider::NearAi); let openai = if let Some(api_key) = optional_env("OPENAI_API_KEY")? { Some(OpenAiConfig { @@ -131,6 +147,18 @@ impl LlmConfig { None }; + let nearai = if let Some(session_token) = optional_env("NEARAI_SESSION_TOKEN")? { + Some(NearAiConfig { + session_token: SecretString::from(session_token), + model: optional_env("NEARAI_MODEL")? + .unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string()), + base_url: optional_env("NEARAI_BASE_URL")? + .unwrap_or_else(|| "https://api.near.ai".to_string()), + }) + } else { + None + }; + // Validate that the selected provider has configuration match provider { LlmProvider::OpenAi if openai.is_none() => { @@ -139,13 +167,20 @@ impl LlmConfig { LlmProvider::Anthropic if anthropic.is_none() => { return Err(ConfigError::MissingEnvVar("ANTHROPIC_API_KEY".to_string())); } - _ => {} + LlmProvider::NearAi if nearai.is_none() => { + return Err(ConfigError::MissingEnvVar( + "NEARAI_SESSION_TOKEN".to_string(), + )); + } + // Provider has valid configuration + LlmProvider::OpenAi | LlmProvider::Anthropic | LlmProvider::NearAi => {} } Ok(Self { provider, openai, anthropic, + nearai, }) } } @@ -338,6 +373,18 @@ mod tests { "OpenAI".parse::().unwrap(), LlmProvider::OpenAi ); + assert_eq!( + "nearai".parse::().unwrap(), + LlmProvider::NearAi + ); + assert_eq!( + "near-ai".parse::().unwrap(), + LlmProvider::NearAi + ); + assert_eq!( + "near_ai".parse::().unwrap(), + LlmProvider::NearAi + ); assert!("invalid".parse::().is_err()); } } diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 7f129a42..f3643f90 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -1,14 +1,16 @@ //! LLM integration for the agent. //! -//! Provides a unified interface to different LLM providers (OpenAI, Anthropic) +//! Provides a unified interface to different LLM providers (OpenAI, Anthropic, NEAR AI) //! and implements reasoning capabilities for planning, tool selection, and evaluation. mod anthropic; +mod nearai; mod openai; mod provider; mod reasoning; pub use anthropic::AnthropicProvider; +pub use nearai::NearAiProvider; pub use openai::OpenAiProvider; pub use provider::{ ChatMessage, CompletionRequest, CompletionResponse, LlmProvider, Role, ToolCall, @@ -40,5 +42,11 @@ pub fn create_llm_provider(config: &LlmConfig) -> Result, L })?; Ok(Arc::new(AnthropicProvider::new(anthropic_config.clone()))) } + LlmProviderType::NearAi => { + let nearai_config = config.nearai.as_ref().ok_or_else(|| LlmError::AuthFailed { + provider: "nearai".to_string(), + })?; + Ok(Arc::new(NearAiProvider::new(nearai_config.clone()))) + } } } diff --git a/src/llm/nearai.rs b/src/llm/nearai.rs new file mode 100644 index 00000000..dfa6c96e --- /dev/null +++ b/src/llm/nearai.rs @@ -0,0 +1,340 @@ +//! NEAR AI Chat API provider implementation. +//! +//! This provider uses the NEAR AI chat-api which provides a unified interface +//! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication. + +use async_trait::async_trait; +use reqwest::Client; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; + +use crate::config::NearAiConfig; +use crate::error::LlmError; +use crate::llm::provider::{ + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, + ToolCompletionRequest, ToolCompletionResponse, +}; + +/// NEAR AI Chat API provider. +pub struct NearAiProvider { + client: Client, + config: NearAiConfig, +} + +impl NearAiProvider { + /// Create a new NEAR AI provider. + pub fn new(config: NearAiConfig) -> Self { + Self { + client: Client::new(), + config, + } + } + + fn api_url(&self, path: &str) -> String { + format!( + "{}/v1/{}", + self.config.base_url, + path.trim_start_matches('/') + ) + } + + async fn send_request Deserialize<'de>>( + &self, + path: &str, + body: &T, + ) -> Result { + let url = self.api_url(path); + + let response = self + .client + .post(&url) + .header( + "Authorization", + format!("Bearer {}", self.config.session_token.expose_secret()), + ) + .header("Content-Type", "application/json") + .json(body) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().await.unwrap_or_default(); + + // Try to parse as JSON error + if let Ok(error) = serde_json::from_str::(&error_text) { + if status.as_u16() == 429 { + return Err(LlmError::RateLimited { + provider: "nearai".to_string(), + retry_after: None, + }); + } + return Err(LlmError::RequestFailed { + provider: "nearai".to_string(), + reason: error.error, + }); + } + + return Err(LlmError::RequestFailed { + provider: "nearai".to_string(), + reason: format!("HTTP {}: {}", status, error_text), + }); + } + + response + .json() + .await + .map_err(|e| LlmError::InvalidResponse { + provider: "nearai".to_string(), + reason: e.to_string(), + }) + } +} + +#[async_trait] +impl LlmProvider for NearAiProvider { + async fn complete(&self, req: CompletionRequest) -> Result { + let messages: Vec = req.messages.into_iter().map(Into::into).collect(); + + let request = NearAiRequest { + model: self.config.model.clone(), + input: messages, + temperature: req.temperature, + max_output_tokens: req.max_tokens, + stream: Some(false), + tools: None, + }; + + let response: NearAiResponse = self.send_request("responses", &request).await?; + + // Extract text from response output + let text = response + .output + .iter() + .filter_map(|item| { + if item.item_type == "message" { + item.content.as_ref().and_then(|contents| { + contents + .iter() + .filter_map(|c| { + if c.content_type == "output_text" { + c.text.clone() + } else { + None + } + }) + .next() + }) + } else { + None + } + }) + .collect::>() + .join(""); + + Ok(CompletionResponse { + content: text, + finish_reason: FinishReason::Stop, + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + }) + } + + async fn complete_with_tools( + &self, + req: ToolCompletionRequest, + ) -> Result { + let messages: Vec = req.messages.into_iter().map(Into::into).collect(); + + let tools: Vec = req + .tools + .into_iter() + .map(|t| NearAiTool { + tool_type: "function".to_string(), + name: t.name, + description: Some(t.description), + parameters: Some(t.parameters), + }) + .collect(); + + let request = NearAiRequest { + model: self.config.model.clone(), + input: messages, + temperature: req.temperature, + max_output_tokens: req.max_tokens, + stream: Some(false), + tools: if tools.is_empty() { None } else { Some(tools) }, + }; + + let response: NearAiResponse = self.send_request("responses", &request).await?; + + // Extract text and tool calls from response + let mut text = String::new(); + let mut tool_calls = Vec::new(); + + for item in &response.output { + if item.item_type == "message" { + if let Some(contents) = &item.content { + for content in contents { + if content.content_type == "output_text" { + if let Some(t) = &content.text { + text.push_str(t); + } + } + } + } + } else if item.item_type == "function_call" { + if let (Some(name), Some(call_id)) = (&item.name, &item.call_id) { + // Parse arguments JSON string into Value + let arguments = item + .arguments + .as_ref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(serde_json::Value::Object(Default::default())); + + tool_calls.push(ToolCall { + id: call_id.clone(), + name: name.clone(), + arguments, + }); + } + } + } + + let finish_reason = if tool_calls.is_empty() { + FinishReason::Stop + } else { + FinishReason::ToolUse + }; + + Ok(ToolCompletionResponse { + content: if text.is_empty() { None } else { Some(text) }, + tool_calls, + finish_reason, + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + }) + } + + fn model_name(&self) -> &str { + &self.config.model + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + // Default costs - could be model-specific in the future + // These are approximate and may vary by model + (dec!(0.000003), dec!(0.000015)) + } +} + +// NEAR AI API types + +#[derive(Debug, Serialize)] +struct NearAiRequest { + model: String, + input: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +struct NearAiMessage { + role: String, + content: String, +} + +impl From for NearAiMessage { + fn from(msg: ChatMessage) -> Self { + let role = match msg.role { + Role::System => "system", + Role::User => "user", + Role::Assistant => "assistant", + Role::Tool => "tool", + }; + Self { + role: role.to_string(), + content: msg.content, + } + } +} + +#[derive(Debug, Serialize)] +struct NearAiTool { + #[serde(rename = "type")] + tool_type: String, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + parameters: Option, +} + +#[derive(Debug, Deserialize)] +struct NearAiResponse { + #[allow(dead_code)] + id: String, + output: Vec, + usage: NearAiUsage, +} + +#[derive(Debug, Deserialize)] +struct NearAiOutputItem { + #[serde(rename = "type")] + item_type: String, + #[serde(default)] + content: Option>, + // For function calls + #[serde(default)] + name: Option, + #[serde(default)] + call_id: Option, + #[serde(default)] + arguments: Option, +} + +#[derive(Debug, Deserialize)] +struct NearAiContent { + #[serde(rename = "type")] + content_type: String, + #[serde(default)] + text: Option, +} + +#[derive(Debug, Deserialize)] +struct NearAiUsage { + input_tokens: u32, + output_tokens: u32, +} + +#[derive(Debug, Deserialize)] +struct NearAiErrorResponse { + error: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_message_conversion() { + let msg = ChatMessage::user("Hello"); + let nearai_msg: NearAiMessage = msg.into(); + assert_eq!(nearai_msg.role, "user"); + assert_eq!(nearai_msg.content, "Hello"); + } + + #[test] + fn test_system_message_conversion() { + let msg = ChatMessage::system("You are helpful"); + let nearai_msg: NearAiMessage = msg.into(); + assert_eq!(nearai_msg.role, "system"); + } +}