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
+4 -2
View File
@@ -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();
}
+11 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
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()));
}
}
+7 -2
View File
@@ -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;
}
}