use crate::config::Config; use anyhow::{Context, Result, anyhow}; use futures_util::StreamExt; use serde::{Deserialize, Serialize}; use serde_json::json; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Role { System, User, Assistant, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatMessage { pub role: Role, pub content: String, } impl ChatMessage { pub fn system(content: impl Into) -> Self { Self { role: Role::System, content: content.into(), } } pub fn user(content: impl Into) -> Self { Self { role: Role::User, content: content.into(), } } pub fn assistant(content: impl Into) -> Self { Self { role: Role::Assistant, content: content.into(), } } } #[derive(Debug, Deserialize)] struct StreamChunk { choices: Option>, error: Option, } #[derive(Debug, Deserialize)] struct StreamChoice { delta: Option, } #[derive(Debug, Deserialize)] struct Delta { content: Option, } #[derive(Debug, Deserialize)] struct ApiErrorBody { message: Option, } /// Stream a chat completion; invoke `on_delta` for each text piece. /// Returns the full assistant text. pub async fn stream_chat( config: &Config, messages: &[ChatMessage], mut on_delta: impl FnMut(&str), ) -> Result { let client = reqwest::Client::new(); let url = format!("{}/chat/completions", config.base_url); let body = json!({ "model": config.model, "messages": messages, "stream": true, }); let response = client .post(&url) .bearer_auth(&config.api_key) .header("content-type", "application/json") .json(&body) .send() .await .context("request to chat completions failed")?; if !response.status().is_success() { let status = response.status(); let text = response.text().await.unwrap_or_default(); return Err(anyhow!("chat completions HTTP {status}: {text}")); } let mut full = String::new(); let mut stream = response.bytes_stream(); let mut buffer = String::new(); while let Some(item) = stream.next().await { let chunk = item.context("reading SSE stream")?; buffer.push_str(&String::from_utf8_lossy(&chunk)); while let Some(pos) = buffer.find('\n') { let mut line = buffer[..pos].to_string(); buffer.drain(..=pos); if line.ends_with('\r') { line.pop(); } let line = line.trim(); if line.is_empty() { continue; } if !line.starts_with("data:") { continue; } let data = line[5..].trim(); if data == "[DONE]" { return Ok(full); } let parsed: StreamChunk = match serde_json::from_str(data) { Ok(v) => v, Err(_) => continue, }; if let Some(err) = parsed.error { return Err(anyhow!( "provider error: {}", err.message.unwrap_or_else(|| data.to_string()) )); } if let Some(choices) = parsed.choices { for choice in choices { if let Some(delta) = choice.delta.and_then(|d| d.content) { if !delta.is_empty() { on_delta(&delta); full.push_str(&delta); } } } } } } Ok(full) } #[cfg(test)] mod tests { use super::*; #[test] fn messages_serialize_roles() { let msg = ChatMessage::user("hi"); let v = serde_json::to_value(&msg).unwrap(); assert_eq!(v["role"], "user"); assert_eq!(v["content"], "hi"); } }