LazyBoy2/crates/grokboy-core/src/model.rs

156 lines
4.0 KiB
Rust
Raw Normal View History

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");
}
}