mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53: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
@@ -228,8 +228,10 @@ impl AppState {
|
||||
|
||||
/// Add an error message to the chat.
|
||||
pub fn add_error_message(&mut self, content: impl Into<String>) {
|
||||
self.messages
|
||||
.push(ChatMessage::system(format!("Error: {}", content.into())).with_status(MessageStatus::Error));
|
||||
self.messages.push(
|
||||
ChatMessage::system(format!("Error: {}", content.into()))
|
||||
.with_status(MessageStatus::Error),
|
||||
);
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,9 @@ fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) {
|
||||
if models.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
"Loading models...",
|
||||
Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
} else {
|
||||
for (i, model) in models.iter().enumerate() {
|
||||
@@ -189,12 +191,14 @@ fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) {
|
||||
}
|
||||
|
||||
let content = Paragraph::new(Line::from(spans))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(Span::styled("Select Model", Style::default().fg(Color::Cyan))),
|
||||
)
|
||||
.scroll((0, calculate_model_scroll(overlay, area.width.saturating_sub(2))));
|
||||
.block(Block::default().borders(Borders::ALL).title(Span::styled(
|
||||
"Select Model",
|
||||
Style::default().fg(Color::Cyan),
|
||||
)))
|
||||
.scroll((
|
||||
0,
|
||||
calculate_model_scroll(overlay, area.width.saturating_sub(2)),
|
||||
));
|
||||
|
||||
frame.render_widget(content, area);
|
||||
}
|
||||
@@ -335,4 +339,3 @@ fn render_approval_overlay(frame: &mut Frame, app: &AppState) {
|
||||
|
||||
frame.render_widget(content, overlay_area);
|
||||
}
|
||||
|
||||
|
||||
+119
-15
@@ -1,8 +1,22 @@
|
||||
//! Simple REPL channel for testing without TUI.
|
||||
//! Interactive REPL channel for debugging and testing.
|
||||
//!
|
||||
//! Provides a basic stdin/stdout interface for testing the agent.
|
||||
//! Provides a command-line interface for interacting with the agent.
|
||||
//!
|
||||
//! ## Commands
|
||||
//!
|
||||
//! - `/help` - Show available commands
|
||||
//! - `/quit` or `/exit` - Exit the REPL
|
||||
//! - `/debug` - Toggle debug mode (verbose tool output)
|
||||
//! - `/undo` - Undo the last turn
|
||||
//! - `/redo` - Redo an undone turn
|
||||
//! - `/clear` - Clear the conversation
|
||||
//! - `/compact` - Compact the context
|
||||
//! - `/new` - Start a new thread
|
||||
//! - `yes`/`no`/`always` - Respond to tool approval prompts
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -11,10 +25,12 @@ use tokio_stream::wrappers::ReceiverStream;
|
||||
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
/// Simple REPL channel using stdin/stdout.
|
||||
/// REPL channel for interactive agent debugging.
|
||||
pub struct ReplChannel {
|
||||
/// Optional single message to send (for -m flag).
|
||||
single_message: Option<String>,
|
||||
/// Debug mode flag (shared with input thread).
|
||||
debug_mode: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ReplChannel {
|
||||
@@ -22,6 +38,7 @@ impl ReplChannel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
single_message: None,
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +46,13 @@ impl ReplChannel {
|
||||
pub fn with_message(message: String) -> Self {
|
||||
Self {
|
||||
single_message: Some(message),
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_debug(&self) -> bool {
|
||||
self.debug_mode.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ReplChannel {
|
||||
@@ -39,6 +61,35 @@ impl Default for ReplChannel {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
r#"
|
||||
NEAR Agent REPL - Interactive debugging mode
|
||||
|
||||
Commands:
|
||||
/help Show this help message
|
||||
/quit, /exit Exit the REPL
|
||||
/debug Toggle debug mode (verbose output)
|
||||
/undo Undo the last turn
|
||||
/redo Redo an undone turn
|
||||
/clear Clear the conversation
|
||||
/compact Compact the context window
|
||||
/new Start a new conversation thread
|
||||
/interrupt Stop the current operation
|
||||
|
||||
Approval responses (when prompted):
|
||||
yes, y Approve the tool execution
|
||||
no, n Deny the tool execution
|
||||
always Approve and auto-approve this tool for the session
|
||||
|
||||
Tips:
|
||||
- Tool calls requiring approval will pause and wait for your response
|
||||
- Use /debug to see detailed tool inputs and outputs
|
||||
- Press Ctrl+C to interrupt a long-running operation
|
||||
"#
|
||||
);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for ReplChannel {
|
||||
fn name(&self) -> &str {
|
||||
@@ -48,6 +99,7 @@ impl Channel for ReplChannel {
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let single_message = self.single_message.clone();
|
||||
let debug_mode = Arc::clone(&self.debug_mode);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// If single message mode, send it and exit
|
||||
@@ -64,9 +116,17 @@ impl Channel for ReplChannel {
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
|
||||
println!("NEAR Agent REPL - Type /help for commands, /quit to exit");
|
||||
println!();
|
||||
|
||||
loop {
|
||||
// Print prompt
|
||||
print!("> ");
|
||||
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
||||
"[debug] > "
|
||||
} else {
|
||||
"> "
|
||||
};
|
||||
print!("{}", prompt);
|
||||
let _ = stdout.flush();
|
||||
|
||||
// Read line
|
||||
@@ -78,8 +138,25 @@ impl Channel for ReplChannel {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line == "/quit" || line == "/exit" {
|
||||
break;
|
||||
|
||||
// Handle local REPL commands
|
||||
match line.to_lowercase().as_str() {
|
||||
"/quit" | "/exit" => break,
|
||||
"/help" | "/?" => {
|
||||
print_help();
|
||||
continue;
|
||||
}
|
||||
"/debug" => {
|
||||
let current = debug_mode.load(Ordering::Relaxed);
|
||||
debug_mode.store(!current, Ordering::Relaxed);
|
||||
if !current {
|
||||
println!("Debug mode ON - showing verbose tool output");
|
||||
} else {
|
||||
println!("Debug mode OFF");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let msg = IncomingMessage::new("repl", "user", line);
|
||||
@@ -100,26 +177,51 @@ impl Channel for ReplChannel {
|
||||
_msg: &IncomingMessage,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
println!("\n{}\n", response.content);
|
||||
println!();
|
||||
println!("{}", response.content);
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_status(&self, status: StatusUpdate) -> Result<(), ChannelError> {
|
||||
let debug = self.is_debug();
|
||||
|
||||
match status {
|
||||
StatusUpdate::Thinking(msg) => eprintln!("[thinking] {}", msg),
|
||||
StatusUpdate::ToolStarted { name } => eprintln!("[tool] Starting: {}", name),
|
||||
StatusUpdate::ToolCompleted { name, success } => {
|
||||
if success {
|
||||
eprintln!("[tool] Completed: {}", name);
|
||||
StatusUpdate::Thinking(msg) => {
|
||||
if debug {
|
||||
eprintln!("\x1b[90m[thinking] {}\x1b[0m", msg);
|
||||
} else {
|
||||
eprintln!("[tool] Failed: {}", name);
|
||||
eprint!(".");
|
||||
let _ = io::stderr().flush();
|
||||
}
|
||||
}
|
||||
StatusUpdate::ToolStarted { name } => {
|
||||
if debug {
|
||||
eprintln!("\x1b[33m[tool:start] {}\x1b[0m", name);
|
||||
} else {
|
||||
eprintln!("\x1b[33m⚡ {}\x1b[0m", name);
|
||||
}
|
||||
}
|
||||
StatusUpdate::ToolCompleted { name, success } => {
|
||||
if debug {
|
||||
if success {
|
||||
eprintln!("\x1b[32m[tool:done] {} ✓\x1b[0m", name);
|
||||
} else {
|
||||
eprintln!("\x1b[31m[tool:fail] {} ✗\x1b[0m", name);
|
||||
}
|
||||
} else if !success {
|
||||
eprintln!("\x1b[31m✗ {} failed\x1b[0m", name);
|
||||
}
|
||||
}
|
||||
StatusUpdate::StreamChunk(chunk) => {
|
||||
print!("{}", chunk);
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
StatusUpdate::Status(msg) => eprintln!("[status] {}", msg),
|
||||
StatusUpdate::Status(msg) => {
|
||||
if debug || msg.contains("approval") || msg.contains("Approval") {
|
||||
eprintln!("\x1b[90m[status] {}\x1b[0m", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -129,7 +231,9 @@ impl Channel for ReplChannel {
|
||||
_user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
println!("\n[broadcast] {}\n", response.content);
|
||||
println!();
|
||||
println!("\x1b[36m[notification]\x1b[0m {}", response.content);
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+53
-2
@@ -77,28 +77,77 @@ pub struct LlmConfig {
|
||||
pub nearai: NearAiConfig,
|
||||
}
|
||||
|
||||
/// API mode for NEAR AI.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum NearAiApiMode {
|
||||
/// Use the Responses API (chat-api proxy) - session-based auth
|
||||
#[default]
|
||||
Responses,
|
||||
/// Use the Chat Completions API (cloud-api) - API key auth
|
||||
ChatCompletions,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for NearAiApiMode {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"responses" | "response" => Ok(Self::Responses),
|
||||
"chat_completions" | "chatcompletions" | "chat" | "completions" => {
|
||||
Ok(Self::ChatCompletions)
|
||||
}
|
||||
_ => Err(format!(
|
||||
"invalid API mode '{}', expected 'responses' or 'chat_completions'",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NEAR AI chat-api configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NearAiConfig {
|
||||
/// 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)
|
||||
/// Base URL for the NEAR AI API (default: https://api.near.ai)
|
||||
pub base_url: String,
|
||||
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
|
||||
pub auth_base_url: String,
|
||||
/// Path to session file (default: ~/.near-agent/session.json)
|
||||
pub session_path: PathBuf,
|
||||
/// API mode: "responses" (chat-api) or "chat_completions" (cloud-api)
|
||||
pub api_mode: NearAiApiMode,
|
||||
/// API key for cloud-api (required for chat_completions mode)
|
||||
pub api_key: Option<SecretString>,
|
||||
}
|
||||
|
||||
impl LlmConfig {
|
||||
fn from_env() -> Result<Self, ConfigError> {
|
||||
let api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
|
||||
|
||||
// Determine API mode: explicit setting, or infer from API key presence
|
||||
let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? {
|
||||
mode_str.parse().map_err(|e| ConfigError::InvalidValue {
|
||||
key: "NEARAI_API_MODE".to_string(),
|
||||
message: e,
|
||||
})?
|
||||
} else if api_key.is_some() {
|
||||
// If API key is provided, default to chat_completions mode
|
||||
NearAiApiMode::ChatCompletions
|
||||
} else {
|
||||
NearAiApiMode::Responses
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
nearai: NearAiConfig {
|
||||
// Load model from saved settings first, then env, then default
|
||||
model: crate::settings::Settings::load()
|
||||
.selected_model
|
||||
.or_else(|| optional_env("NEARAI_MODEL").ok().flatten())
|
||||
.unwrap_or_else(|| "zai-org/GLM-4.7".to_string()),
|
||||
.unwrap_or_else(|| {
|
||||
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
|
||||
.to_string()
|
||||
}),
|
||||
base_url: optional_env("NEARAI_BASE_URL")?
|
||||
.unwrap_or_else(|| "https://api.near.ai".to_string()),
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
@@ -106,6 +155,8 @@ impl LlmConfig {
|
||||
session_path: optional_env("NEARAI_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_session_path),
|
||||
api_mode,
|
||||
api_key,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
+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()));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -142,12 +142,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
Ok(_) => {
|
||||
let _ = event_tx
|
||||
.send(AppEvent::ErrorMessage("No models available from API".into()))
|
||||
.send(AppEvent::ErrorMessage(
|
||||
"No models available from API".into(),
|
||||
))
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = event_tx
|
||||
.send(AppEvent::ErrorMessage(format!("Failed to fetch models: {}", e)))
|
||||
.send(AppEvent::ErrorMessage(format!(
|
||||
"Failed to fetch models: {}",
|
||||
e
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user