mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.5
parent
e30db26bfe
commit
f29892b3fb
+12
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user