openai.rs
Language Rust8.2 KB228 lines
//! The OpenAI-compatible chat wire format, as DeepInfra, Ollama, LM Studio and//! llama.cpp speak it. Parsing is deliberately tolerant: unknown fields are//! ignored and every field is optional, because each server differs a little.use serde::{Deserialize, Serialize};use serde_json::Value;use super::error::{error_message, ProviderError};use crate::types::Usage;#[derive(Debug, Clone, Serialize, PartialEq)]pub struct ChatRequest {pub model: String,pub messages: Vec<OutMessage>,pub stream: bool,#[serde(skip_serializing_if = "Option::is_none")]pub stream_options: Option<StreamOptions>,#[serde(skip_serializing_if = "Option::is_none")]pub temperature: Option<f32>,#[serde(skip_serializing_if = "Option::is_none")]pub max_tokens: Option<u32>,}#[derive(Debug, Clone, Serialize, PartialEq)]pub struct StreamOptions {pub include_usage: bool,}#[derive(Debug, Clone, Serialize, PartialEq)]pub struct OutMessage {pub role: String,pub content: OutContent,}#[derive(Debug, Clone, Serialize, PartialEq)]#[serde(untagged)]pub enum OutContent {Text(String),Parts(Vec<Part>),}#[derive(Debug, Clone, Serialize, PartialEq)]#[serde(tag = "type", rename_all = "snake_case")]pub enum Part {Text { text: String },ImageUrl { image_url: ImageUrl },}#[derive(Debug, Clone, Serialize, PartialEq)]pub struct ImageUrl {pub url: String,}impl OutMessage {pub fn text(role: &str, text: impl Into<String>) -> Self {Self { role: role.into(), content: OutContent::Text(text.into()) }}/// Text plus images (data: URLs). Plain text when there are no images, since/// some local servers reject the parts form entirely.pub fn with_images(role: &str, text: impl Into<String>, image_urls: Vec<String>) -> Self {let text = text.into();if image_urls.is_empty() {return Self::text(role, text);}let mut parts = vec![Part::Text { text }];parts.extend(image_urls.into_iter().map(|url| Part::ImageUrl { image_url: ImageUrl { url } }));Self { role: role.into(), content: OutContent::Parts(parts) }}}/// What one `data:` payload meant.#[derive(Debug, Clone, PartialEq, Default)]pub struct Chunk {pub content: Option<String>,pub reasoning: Option<String>,pub finish_reason: Option<String>,pub usage: Option<Usage>,/// DeepInfra may report its own cost with the usage; kept for comparison only.pub provider_cost_usd: Option<f64>,pub done: bool,}#[derive(Deserialize)]struct RawChunk {#[serde(default)]choices: Vec<RawChoice>,#[serde(default)]usage: Option<RawUsage>,}#[derive(Deserialize)]struct RawChoice {#[serde(default)]delta: Option<RawDelta>,#[serde(default)]finish_reason: Option<String>,}#[derive(Deserialize, Default)]struct RawDelta {#[serde(default)]content: Option<String>,#[serde(default)]reasoning_content: Option<String>,/// Ollama and some proxies use `reasoning`.#[serde(default)]reasoning: Option<String>,}#[derive(Deserialize)]struct RawUsage {#[serde(default)]prompt_tokens: Option<i64>,#[serde(default)]completion_tokens: Option<i64>,#[serde(default)]prompt_tokens_details: Option<Value>,#[serde(default)]estimated_cost: Option<f64>,}pub fn parse_chunk(data: &str) -> Result<Chunk, ProviderError> {let data = data.trim();if data == "[DONE]" {return Ok(Chunk { done: true, ..Default::default() });}let v: Value = serde_json::from_str(data).map_err(|e| ProviderError::Stream(format!("unreadable chunk: {e}")))?;if v.get("error").is_some() {return Err(ProviderError::Stream(error_message(data).unwrap_or_else(|| "unknown error".into())));}let raw: RawChunk =serde_json::from_value(v).map_err(|e| ProviderError::Stream(format!("unexpected chunk: {e}")))?;let mut out = Chunk::default();if let Some(choice) = raw.choices.into_iter().next() {let d = choice.delta.unwrap_or_default();out.content = d.content.filter(|s| !s.is_empty());out.reasoning = d.reasoning_content.or(d.reasoning).filter(|s| !s.is_empty());out.finish_reason = choice.finish_reason;}if let Some(u) = raw.usage {if u.prompt_tokens.is_some() || u.completion_tokens.is_some() {let cached = u.prompt_tokens_details.as_ref().and_then(|d| d.get("cached_tokens")).and_then(Value::as_i64).unwrap_or(0);out.usage = Some(Usage {prompt_tokens: u.prompt_tokens.unwrap_or(0),completion_tokens: u.completion_tokens.unwrap_or(0),cached_tokens: cached,});}out.provider_cost_usd = u.estimated_cost;}Ok(out)}#[cfg(test)]mod tests {use super::*;#[test]fn request_shape() {let req = ChatRequest {model: "m".into(),messages: vec![OutMessage::text("system", "be brief"),OutMessage::with_images("user", "what is this", vec!["data:image/png;base64,AAA".into()]),OutMessage::with_images("user", "plain", vec![]),],stream: true,stream_options: Some(StreamOptions { include_usage: true }),temperature: None,max_tokens: Some(512),};let v = serde_json::to_value(&req).unwrap();assert_eq!(v["stream_options"]["include_usage"], true);assert_eq!(v["messages"][0]["content"], "be brief");assert_eq!(v["messages"][1]["content"][0], serde_json::json!({"type": "text", "text": "what is this"}));assert_eq!(v["messages"][1]["content"][1]["image_url"]["url"], "data:image/png;base64,AAA");assert_eq!(v["messages"][2]["content"], "plain");assert!(v.get("temperature").is_none());}#[test]fn deepinfra_shape_with_final_usage_chunk() {let c = parse_chunk(r#"{"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"},"finish_reason":null}]}"#).unwrap();assert_eq!(c.content.as_deref(), Some("Hel"));let last = parse_chunk(r#"{"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":30,"total_tokens":42,"prompt_tokens_details":{"cached_tokens":8},"estimated_cost":0.0000123}}"#).unwrap();assert_eq!(last.usage, Some(Usage { prompt_tokens: 12, completion_tokens: 30, cached_tokens: 8 }));assert_eq!(last.provider_cost_usd, Some(0.0000123));assert!(parse_chunk("[DONE]").unwrap().done);}#[test]fn usage_on_last_content_chunk_and_finish_reason() {let c = parse_chunk(r#"{"choices":[{"delta":{"content":"!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}"#).unwrap();assert_eq!(c.content.as_deref(), Some("!"));assert_eq!(c.finish_reason.as_deref(), Some("stop"));assert_eq!(c.usage.unwrap().completion_tokens, 2);}#[test]fn ollama_and_llamacpp_shapes() {// Ollama: reasoning under `reasoning`, no usage unless asked.let c = parse_chunk(r#"{"id":"chatcmpl-1","model":"qwen3","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"thinking"},"finish_reason":null}]}"#).unwrap();assert_eq!(c.reasoning.as_deref(), Some("thinking"));assert_eq!(c.content, None);// llama.cpp: usage with nulls elsewhere, timings block ignored.let c = parse_chunk(r#"{"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"usage":{"completion_tokens":9,"prompt_tokens":4,"total_tokens":13},"timings":{"predicted_ms":10}}"#).unwrap();assert_eq!(c.usage.unwrap().prompt_tokens, 4);}#[test]fn deepinfra_reasoning_content() {let c = parse_chunk(r#"{"choices":[{"delta":{"reasoning_content":"step 1"}}]}"#).unwrap();assert_eq!(c.reasoning.as_deref(), Some("step 1"));}#[test]fn errors_in_stream() {let e = parse_chunk(r#"{"error":{"message":"model overloaded"}}"#).unwrap_err();assert_eq!(e, ProviderError::Stream("model overloaded".into()));assert_eq!(parse_chunk("not json").unwrap_err().kind(), "stream");}}