156 lines
4.0 KiB
Rust
156 lines
4.0 KiB
Rust
|
|
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<String>) -> Self {
|
||
|
|
Self {
|
||
|
|
role: Role::System,
|
||
|
|
content: content.into(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
pub fn user(content: impl Into<String>) -> Self {
|
||
|
|
Self {
|
||
|
|
role: Role::User,
|
||
|
|
content: content.into(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
pub fn assistant(content: impl Into<String>) -> Self {
|
||
|
|
Self {
|
||
|
|
role: Role::Assistant,
|
||
|
|
content: content.into(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize)]
|
||
|
|
struct StreamChunk {
|
||
|
|
choices: Option<Vec<StreamChoice>>,
|
||
|
|
error: Option<ApiErrorBody>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize)]
|
||
|
|
struct StreamChoice {
|
||
|
|
delta: Option<Delta>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize)]
|
||
|
|
struct Delta {
|
||
|
|
content: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize)]
|
||
|
|
struct ApiErrorBody {
|
||
|
|
message: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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<String> {
|
||
|
|
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");
|
||
|
|
}
|
||
|
|
}
|