Add Chat Completions API support and expand REPL debugging

- Add NearAiChatProvider using /v1/chat/completions endpoint with API key auth
- Add NEARAI_API_KEY and NEARAI_API_MODE config options
- Auto-detect API mode from presence of API key
- Keep existing Responses API (NearAiProvider) for session-based auth
- Fix response parsing to accept input_text/output_text/text content types
- Expand REPL with /help, /debug toggle, colored output
- Better tool status display (dots vs verbose based on debug mode)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-03 21:55:05 -08:00
co-authored by Claude Opus 4.5
parent 74d94d33b0
commit 7ef6362d83
8 changed files with 665 additions and 42 deletions
+21 -8
View File
@@ -1,13 +1,17 @@
//! LLM integration for the agent.
//!
//! Uses the NEAR AI chat-api as the unified LLM provider.
//! Supports two API modes:
//! - **Responses API** (chat-api): Session-based auth, uses `/v1/responses` endpoint
//! - **Chat Completions API** (cloud-api): API key auth, uses `/v1/chat/completions` endpoint
mod nearai;
mod nearai_chat;
mod provider;
mod reasoning;
pub mod session;
pub use nearai::{ModelInfo, NearAiProvider};
pub use nearai_chat::NearAiChatProvider;
pub use provider::{
ChatMessage, CompletionRequest, CompletionResponse, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
@@ -17,19 +21,28 @@ pub use session::{SessionConfig, SessionManager, create_session_manager};
use std::sync::Arc;
use crate::config::LlmConfig;
use crate::config::{LlmConfig, NearAiApiMode};
use crate::error::LlmError;
/// Create an LLM provider based on configuration.
///
/// Requires a session manager for authentication. Use `create_session_manager`
/// to create one from the config.
/// - For `Responses` mode: Requires a session manager for authentication
/// - For `ChatCompletions` mode: Uses API key from config (session not needed)
pub fn create_llm_provider(
config: &LlmConfig,
session: Arc<SessionManager>,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
Ok(Arc::new(NearAiProvider::new(
config.nearai.clone(),
session,
)))
match config.nearai.api_mode {
NearAiApiMode::Responses => {
tracing::info!("Using Responses API (chat-api) with session auth");
Ok(Arc::new(NearAiProvider::new(
config.nearai.clone(),
session,
)))
}
NearAiApiMode::ChatCompletions => {
tracing::info!("Using Chat Completions API (cloud-api) with API key auth");
Ok(Arc::new(NearAiChatProvider::new(config.nearai.clone())?))
}
}
}
+24 -5
View File
@@ -149,7 +149,12 @@ impl NearAiProvider {
if let Some(entries) = resp.models.or(resp.data) {
let models: Vec<ModelInfo> = entries
.into_iter()
.filter_map(|e| e.get_name().map(|name| ModelInfo { name, provider: None }))
.filter_map(|e| {
e.get_name().map(|name| ModelInfo {
name,
provider: None,
})
})
.collect();
if !models.is_empty() {
return Ok(models);
@@ -161,7 +166,12 @@ impl NearAiProvider {
if let Ok(entries) = serde_json::from_str::<Vec<ModelEntry>>(&response_text) {
let models: Vec<ModelInfo> = entries
.into_iter()
.filter_map(|e| e.get_name().map(|name| ModelInfo { name, provider: None }))
.filter_map(|e| {
e.get_name().map(|name| ModelInfo {
name,
provider: None,
})
})
.collect();
if !models.is_empty() {
return Ok(models);
@@ -508,12 +518,21 @@ impl LlmProvider for NearAiProvider {
for item in &response.output {
if item.item_type == "message" {
// Check for direct text field first
if let Some(t) = &item.text {
text.push_str(t);
}
// Then check content array
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);
// Accept various content type names the API might return
match content.content_type.as_str() {
"output_text" | "input_text" | "text" => {
if let Some(t) = &content.text {
text.push_str(t);
}
}
_ => {}
}
}
}
+426
View File
@@ -0,0 +1,426 @@
//! NEAR AI Chat Completions API provider implementation.
//!
//! This provider uses the standard OpenAI-compatible chat completions API
//! with API key authentication (for cloud-api).
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 Completions API provider.
pub struct NearAiChatProvider {
client: Client,
config: NearAiConfig,
}
impl NearAiChatProvider {
/// Create a new NEAR AI chat completions provider with API key auth.
pub fn new(config: NearAiConfig) -> Result<Self, LlmError> {
if config.api_key.is_none() {
return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(),
});
}
let client = Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.unwrap_or_else(|_| Client::new());
Ok(Self { client, config })
}
fn api_url(&self, path: &str) -> String {
format!(
"{}/v1/{}",
self.config.base_url,
path.trim_start_matches('/')
)
}
fn api_key(&self) -> String {
self.config
.api_key
.as_ref()
.map(|k| k.expose_secret().to_string())
.unwrap_or_default()
}
/// Send a request to the chat completions API.
async fn send_request<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
body: &T,
) -> Result<R, LlmError> {
let url = self.api_url("chat/completions");
tracing::debug!("Sending request to NEAR AI Chat: {}", url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key()))
.header("Content-Type", "application/json")
.json(body)
.send()
.await
.map_err(|e| {
tracing::error!("NEAR AI Chat request failed: {}", e);
LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: e.to_string(),
}
})?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
tracing::debug!("NEAR AI Chat response status: {}", status);
tracing::debug!("NEAR AI Chat response body: {}", response_text);
if !status.is_success() {
if status.as_u16() == 401 {
return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(),
});
}
if status.as_u16() == 429 {
return Err(LlmError::RateLimited {
provider: "nearai_chat".to_string(),
retry_after: None,
});
}
return Err(LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("HTTP {}: {}", status, response_text),
});
}
serde_json::from_str(&response_text).map_err(|e| LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: format!("JSON parse error: {}. Raw: {}", e, response_text),
})
}
/// Fetch available models.
pub async fn list_models(&self) -> Result<Vec<String>, LlmError> {
let url = self.api_url("models");
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key()))
.send()
.await
.map_err(|e| LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("Failed to fetch models: {}", e),
})?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
reason: format!("HTTP {}: {}", status, response_text),
});
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ModelEntry>,
}
#[derive(Deserialize)]
struct ModelEntry {
id: String,
}
let resp: ModelsResponse =
serde_json::from_str(&response_text).map_err(|e| LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: format!("JSON parse error: {}", e),
})?;
Ok(resp.data.into_iter().map(|m| m.id).collect())
}
}
#[async_trait]
impl LlmProvider for NearAiChatProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let messages: Vec<ChatCompletionMessage> =
req.messages.into_iter().map(|m| m.into()).collect();
let request = ChatCompletionRequest {
model: self.config.model.clone(),
messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
tools: None,
tool_choice: None,
};
let response: ChatCompletionResponse = self.send_request(&request).await?;
let choice =
response
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: "No choices in response".to_string(),
})?;
let content = choice.message.content.unwrap_or_default();
let finish_reason = match choice.finish_reason.as_deref() {
Some("stop") => FinishReason::Stop,
Some("length") => FinishReason::Length,
Some("tool_calls") => FinishReason::ToolUse,
Some("content_filter") => FinishReason::ContentFilter,
_ => FinishReason::Unknown,
};
Ok(CompletionResponse {
content,
finish_reason,
input_tokens: response.usage.prompt_tokens,
output_tokens: response.usage.completion_tokens,
})
}
async fn complete_with_tools(
&self,
req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let messages: Vec<ChatCompletionMessage> =
req.messages.into_iter().map(|m| m.into()).collect();
let tools: Vec<ChatCompletionTool> = req
.tools
.into_iter()
.map(|t| ChatCompletionTool {
tool_type: "function".to_string(),
function: ChatCompletionFunction {
name: t.name,
description: Some(t.description),
parameters: Some(t.parameters),
},
})
.collect();
let request = ChatCompletionRequest {
model: self.config.model.clone(),
messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
tools: if tools.is_empty() { None } else { Some(tools) },
tool_choice: req.tool_choice,
};
let response: ChatCompletionResponse = self.send_request(&request).await?;
let choice =
response
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: "No choices in response".to_string(),
})?;
let content = choice.message.content;
let tool_calls: Vec<ToolCall> = choice
.message
.tool_calls
.unwrap_or_default()
.into_iter()
.map(|tc| {
let arguments = serde_json::from_str(&tc.function.arguments)
.unwrap_or(serde_json::Value::Object(Default::default()));
ToolCall {
id: tc.id,
name: tc.function.name,
arguments,
}
})
.collect();
let finish_reason = match choice.finish_reason.as_deref() {
Some("stop") => FinishReason::Stop,
Some("length") => FinishReason::Length,
Some("tool_calls") => FinishReason::ToolUse,
Some("content_filter") => FinishReason::ContentFilter,
_ => {
if !tool_calls.is_empty() {
FinishReason::ToolUse
} else {
FinishReason::Unknown
}
}
};
Ok(ToolCompletionResponse {
content,
tool_calls,
finish_reason,
input_tokens: response.usage.prompt_tokens,
output_tokens: response.usage.completion_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
(dec!(0.000003), dec!(0.000015))
}
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
NearAiChatProvider::list_models(self).await
}
}
// OpenAI-compatible Chat Completions API types
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<ChatCompletionMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<ChatCompletionTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ChatCompletionMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<ChatCompletionToolCall>>,
}
impl From<ChatMessage> for ChatCompletionMessage {
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: Some(msg.content),
tool_call_id: msg.tool_call_id,
name: msg.name,
tool_calls: None,
}
}
}
#[derive(Debug, Serialize)]
struct ChatCompletionTool {
#[serde(rename = "type")]
tool_type: String,
function: ChatCompletionFunction,
}
#[derive(Debug, Serialize)]
struct ChatCompletionFunction {
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 ChatCompletionResponse {
#[allow(dead_code)]
id: String,
choices: Vec<ChatCompletionChoice>,
usage: ChatCompletionUsage,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionChoice {
message: ChatCompletionResponseMessage,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionResponseMessage {
#[allow(dead_code)]
role: String,
content: Option<String>,
tool_calls: Option<Vec<ChatCompletionToolCall>>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ChatCompletionToolCall {
id: String,
#[serde(rename = "type")]
#[allow(dead_code)]
call_type: String,
function: ChatCompletionToolCallFunction,
}
#[derive(Debug, Serialize, Deserialize)]
struct ChatCompletionToolCallFunction {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct ChatCompletionUsage {
prompt_tokens: u32,
completion_tokens: u32,
#[allow(dead_code)]
total_tokens: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_message_conversion() {
let msg = ChatMessage::user("Hello");
let chat_msg: ChatCompletionMessage = msg.into();
assert_eq!(chat_msg.role, "user");
assert_eq!(chat_msg.content, Some("Hello".to_string()));
}
#[test]
fn test_tool_message_conversion() {
let msg = ChatMessage::tool_result("call_123", "my_tool", "result");
let chat_msg: ChatCompletionMessage = msg.into();
assert_eq!(chat_msg.role, "tool");
assert_eq!(chat_msg.tool_call_id, Some("call_123".to_string()));
assert_eq!(chat_msg.name, Some("my_tool".to_string()));
}
}