mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
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:
co-authored by
Claude Opus 4.5
parent
74d94d33b0
commit
7ef6362d83
+21
-8
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user