From 7ef6362d8359efe7be8d3a3679bd8afef5605449 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Tue, 3 Feb 2026 21:55:05 -0800 Subject: [PATCH] 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 --- src/channels/cli/app.rs | 6 +- src/channels/cli/render.rs | 19 +- src/channels/repl.rs | 134 ++++++++++-- src/config.rs | 55 ++++- src/llm/mod.rs | 29 ++- src/llm/nearai.rs | 29 ++- src/llm/nearai_chat.rs | 426 +++++++++++++++++++++++++++++++++++++ src/main.rs | 9 +- 8 files changed, 665 insertions(+), 42 deletions(-) create mode 100644 src/llm/nearai_chat.rs diff --git a/src/channels/cli/app.rs b/src/channels/cli/app.rs index 72d9caa5..a0e76113 100644 --- a/src/channels/cli/app.rs +++ b/src/channels/cli/app.rs @@ -228,8 +228,10 @@ impl AppState { /// Add an error message to the chat. pub fn add_error_message(&mut self, content: impl Into) { - 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(); } diff --git a/src/channels/cli/render.rs b/src/channels/cli/render.rs index 85a67856..ba692260 100644 --- a/src/channels/cli/render.rs +++ b/src/channels/cli/render.rs @@ -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); } - diff --git a/src/channels/repl.rs b/src/channels/repl.rs index c302f36c..c9c3f452 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -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, + /// Debug mode flag (shared with input thread). + debug_mode: Arc, } 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 { 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(()) } diff --git a/src/config.rs b/src/config.rs index bfd082a8..b2b35529 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 { + 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, } impl LlmConfig { fn from_env() -> Result { + 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, }, }) } diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 90e200c7..f2d5ffcb 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -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, ) -> Result, 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())?)) + } + } } diff --git a/src/llm/nearai.rs b/src/llm/nearai.rs index deb63e44..b204bda5 100644 --- a/src/llm/nearai.rs +++ b/src/llm/nearai.rs @@ -149,7 +149,12 @@ impl NearAiProvider { if let Some(entries) = resp.models.or(resp.data) { let models: Vec = 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::>(&response_text) { let models: Vec = 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); + } } + _ => {} } } } diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs new file mode 100644 index 00000000..35fd7593 --- /dev/null +++ b/src/llm/nearai_chat.rs @@ -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 { + 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 Deserialize<'de>>( + &self, + body: &T, + ) -> Result { + 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, 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, + } + + #[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 { + let messages: Vec = + 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 { + let messages: Vec = + req.messages.into_iter().map(|m| m.into()).collect(); + + let tools: Vec = 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 = 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, LlmError> { + NearAiChatProvider::list_models(self).await + } +} + +// OpenAI-compatible Chat Completions API types + +#[derive(Debug, Serialize)] +struct ChatCompletionRequest { + model: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ChatCompletionMessage { + role: String, + #[serde(skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, +} + +impl From 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, + #[serde(skip_serializing_if = "Option::is_none")] + parameters: Option, +} + +#[derive(Debug, Deserialize)] +struct ChatCompletionResponse { + #[allow(dead_code)] + id: String, + choices: Vec, + usage: ChatCompletionUsage, +} + +#[derive(Debug, Deserialize)] +struct ChatCompletionChoice { + message: ChatCompletionResponseMessage, + finish_reason: Option, +} + +#[derive(Debug, Deserialize)] +struct ChatCompletionResponseMessage { + #[allow(dead_code)] + role: String, + content: Option, + tool_calls: Option>, +} + +#[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())); + } +} diff --git a/src/main.rs b/src/main.rs index 075d16a3..f93084fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; } }