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 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-02 21:26:39 -08:00
co-authored by Claude Opus 4.5
parent e30db26bfe
commit f29892b3fb
5 changed files with 437 additions and 6 deletions
+12 -2
View File
@@ -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
+26
View File
@@ -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:
+50 -3
View File
@@ -66,12 +66,14 @@ pub struct LlmConfig {
pub provider: LlmProvider,
pub openai: Option<OpenAiConfig>,
pub anthropic: Option<AnthropicConfig>,
pub nearai: Option<NearAiConfig>,
}
#[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<String>,
}
/// 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<Self, ConfigError> {
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::<LlmProvider>().unwrap(),
LlmProvider::OpenAi
);
assert_eq!(
"nearai".parse::<LlmProvider>().unwrap(),
LlmProvider::NearAi
);
assert_eq!(
"near-ai".parse::<LlmProvider>().unwrap(),
LlmProvider::NearAi
);
assert_eq!(
"near_ai".parse::<LlmProvider>().unwrap(),
LlmProvider::NearAi
);
assert!("invalid".parse::<LlmProvider>().is_err());
}
}
+9 -1
View File
@@ -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<Arc<dyn LlmProvider>, 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())))
}
}
}
+340
View File
@@ -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<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
path: &str,
body: &T,
) -> Result<R, LlmError> {
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::<NearAiErrorResponse>(&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<CompletionResponse, LlmError> {
let messages: Vec<NearAiMessage> = 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::<Vec<_>>()
.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<ToolCompletionResponse, LlmError> {
let messages: Vec<NearAiMessage> = req.messages.into_iter().map(Into::into).collect();
let tools: Vec<NearAiTool> = 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<NearAiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_output_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<NearAiTool>>,
}
#[derive(Debug, Serialize, Deserialize)]
struct NearAiMessage {
role: String,
content: String,
}
impl From<ChatMessage> 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct NearAiResponse {
#[allow(dead_code)]
id: String,
output: Vec<NearAiOutputItem>,
usage: NearAiUsage,
}
#[derive(Debug, Deserialize)]
struct NearAiOutputItem {
#[serde(rename = "type")]
item_type: String,
#[serde(default)]
content: Option<Vec<NearAiContent>>,
// For function calls
#[serde(default)]
name: Option<String>,
#[serde(default)]
call_id: Option<String>,
#[serde(default)]
arguments: Option<String>,
}
#[derive(Debug, Deserialize)]
struct NearAiContent {
#[serde(rename = "type")]
content_type: String,
#[serde(default)]
text: Option<String>,
}
#[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");
}
}