mattrguerra/cortexmainPrivate on GitHub, published here in full

openai.rs

Language Rust8.2 KB228 lines

crates/cortex-core/src/provider/openai.rs

  1. //! The OpenAI-compatible chat wire format, as DeepInfra, Ollama, LM Studio and
  2. //! llama.cpp speak it. Parsing is deliberately tolerant: unknown fields are
  3. //! ignored and every field is optional, because each server differs a little.
  4. use serde::{Deserialize, Serialize};
  5. use serde_json::Value;
  6. use super::error::{error_message, ProviderError};
  7. use crate::types::Usage;
  8. #[derive(Debug, Clone, Serialize, PartialEq)]
  9. pub struct ChatRequest {
  10. pub model: String,
  11. pub messages: Vec<OutMessage>,
  12. pub stream: bool,
  13. #[serde(skip_serializing_if = "Option::is_none")]
  14. pub stream_options: Option<StreamOptions>,
  15. #[serde(skip_serializing_if = "Option::is_none")]
  16. pub temperature: Option<f32>,
  17. #[serde(skip_serializing_if = "Option::is_none")]
  18. pub max_tokens: Option<u32>,
  19. }
  20. #[derive(Debug, Clone, Serialize, PartialEq)]
  21. pub struct StreamOptions {
  22. pub include_usage: bool,
  23. }
  24. #[derive(Debug, Clone, Serialize, PartialEq)]
  25. pub struct OutMessage {
  26. pub role: String,
  27. pub content: OutContent,
  28. }
  29. #[derive(Debug, Clone, Serialize, PartialEq)]
  30. #[serde(untagged)]
  31. pub enum OutContent {
  32. Text(String),
  33. Parts(Vec<Part>),
  34. }
  35. #[derive(Debug, Clone, Serialize, PartialEq)]
  36. #[serde(tag = "type", rename_all = "snake_case")]
  37. pub enum Part {
  38. Text { text: String },
  39. ImageUrl { image_url: ImageUrl },
  40. }
  41. #[derive(Debug, Clone, Serialize, PartialEq)]
  42. pub struct ImageUrl {
  43. pub url: String,
  44. }
  45. impl OutMessage {
  46. pub fn text(role: &str, text: impl Into<String>) -> Self {
  47. Self { role: role.into(), content: OutContent::Text(text.into()) }
  48. }
  49. /// Text plus images (data: URLs). Plain text when there are no images, since
  50. /// some local servers reject the parts form entirely.
  51. pub fn with_images(role: &str, text: impl Into<String>, image_urls: Vec<String>) -> Self {
  52. let text = text.into();
  53. if image_urls.is_empty() {
  54. return Self::text(role, text);
  55. }
  56. let mut parts = vec![Part::Text { text }];
  57. parts.extend(image_urls.into_iter().map(|url| Part::ImageUrl { image_url: ImageUrl { url } }));
  58. Self { role: role.into(), content: OutContent::Parts(parts) }
  59. }
  60. }
  61. /// What one `data:` payload meant.
  62. #[derive(Debug, Clone, PartialEq, Default)]
  63. pub struct Chunk {
  64. pub content: Option<String>,
  65. pub reasoning: Option<String>,
  66. pub finish_reason: Option<String>,
  67. pub usage: Option<Usage>,
  68. /// DeepInfra may report its own cost with the usage; kept for comparison only.
  69. pub provider_cost_usd: Option<f64>,
  70. pub done: bool,
  71. }
  72. #[derive(Deserialize)]
  73. struct RawChunk {
  74. #[serde(default)]
  75. choices: Vec<RawChoice>,
  76. #[serde(default)]
  77. usage: Option<RawUsage>,
  78. }
  79. #[derive(Deserialize)]
  80. struct RawChoice {
  81. #[serde(default)]
  82. delta: Option<RawDelta>,
  83. #[serde(default)]
  84. finish_reason: Option<String>,
  85. }
  86. #[derive(Deserialize, Default)]
  87. struct RawDelta {
  88. #[serde(default)]
  89. content: Option<String>,
  90. #[serde(default)]
  91. reasoning_content: Option<String>,
  92. /// Ollama and some proxies use `reasoning`.
  93. #[serde(default)]
  94. reasoning: Option<String>,
  95. }
  96. #[derive(Deserialize)]
  97. struct RawUsage {
  98. #[serde(default)]
  99. prompt_tokens: Option<i64>,
  100. #[serde(default)]
  101. completion_tokens: Option<i64>,
  102. #[serde(default)]
  103. prompt_tokens_details: Option<Value>,
  104. #[serde(default)]
  105. estimated_cost: Option<f64>,
  106. }
  107. pub fn parse_chunk(data: &str) -> Result<Chunk, ProviderError> {
  108. let data = data.trim();
  109. if data == "[DONE]" {
  110. return Ok(Chunk { done: true, ..Default::default() });
  111. }
  112. let v: Value = serde_json::from_str(data).map_err(|e| ProviderError::Stream(format!("unreadable chunk: {e}")))?;
  113. if v.get("error").is_some() {
  114. return Err(ProviderError::Stream(error_message(data).unwrap_or_else(|| "unknown error".into())));
  115. }
  116. let raw: RawChunk =
  117. serde_json::from_value(v).map_err(|e| ProviderError::Stream(format!("unexpected chunk: {e}")))?;
  118. let mut out = Chunk::default();
  119. if let Some(choice) = raw.choices.into_iter().next() {
  120. let d = choice.delta.unwrap_or_default();
  121. out.content = d.content.filter(|s| !s.is_empty());
  122. out.reasoning = d.reasoning_content.or(d.reasoning).filter(|s| !s.is_empty());
  123. out.finish_reason = choice.finish_reason;
  124. }
  125. if let Some(u) = raw.usage {
  126. if u.prompt_tokens.is_some() || u.completion_tokens.is_some() {
  127. let cached = u
  128. .prompt_tokens_details
  129. .as_ref()
  130. .and_then(|d| d.get("cached_tokens"))
  131. .and_then(Value::as_i64)
  132. .unwrap_or(0);
  133. out.usage = Some(Usage {
  134. prompt_tokens: u.prompt_tokens.unwrap_or(0),
  135. completion_tokens: u.completion_tokens.unwrap_or(0),
  136. cached_tokens: cached,
  137. });
  138. }
  139. out.provider_cost_usd = u.estimated_cost;
  140. }
  141. Ok(out)
  142. }
  143. #[cfg(test)]
  144. mod tests {
  145. use super::*;
  146. #[test]
  147. fn request_shape() {
  148. let req = ChatRequest {
  149. model: "m".into(),
  150. messages: vec![
  151. OutMessage::text("system", "be brief"),
  152. OutMessage::with_images("user", "what is this", vec!["data:image/png;base64,AAA".into()]),
  153. OutMessage::with_images("user", "plain", vec![]),
  154. ],
  155. stream: true,
  156. stream_options: Some(StreamOptions { include_usage: true }),
  157. temperature: None,
  158. max_tokens: Some(512),
  159. };
  160. let v = serde_json::to_value(&req).unwrap();
  161. assert_eq!(v["stream_options"]["include_usage"], true);
  162. assert_eq!(v["messages"][0]["content"], "be brief");
  163. assert_eq!(v["messages"][1]["content"][0], serde_json::json!({"type": "text", "text": "what is this"}));
  164. assert_eq!(v["messages"][1]["content"][1]["image_url"]["url"], "data:image/png;base64,AAA");
  165. assert_eq!(v["messages"][2]["content"], "plain");
  166. assert!(v.get("temperature").is_none());
  167. }
  168. #[test]
  169. fn deepinfra_shape_with_final_usage_chunk() {
  170. let c = parse_chunk(r#"{"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"},"finish_reason":null}]}"#).unwrap();
  171. assert_eq!(c.content.as_deref(), Some("Hel"));
  172. 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();
  173. assert_eq!(last.usage, Some(Usage { prompt_tokens: 12, completion_tokens: 30, cached_tokens: 8 }));
  174. assert_eq!(last.provider_cost_usd, Some(0.0000123));
  175. assert!(parse_chunk("[DONE]").unwrap().done);
  176. }
  177. #[test]
  178. fn usage_on_last_content_chunk_and_finish_reason() {
  179. let c = parse_chunk(r#"{"choices":[{"delta":{"content":"!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}"#).unwrap();
  180. assert_eq!(c.content.as_deref(), Some("!"));
  181. assert_eq!(c.finish_reason.as_deref(), Some("stop"));
  182. assert_eq!(c.usage.unwrap().completion_tokens, 2);
  183. }
  184. #[test]
  185. fn ollama_and_llamacpp_shapes() {
  186. // Ollama: reasoning under `reasoning`, no usage unless asked.
  187. let c = parse_chunk(r#"{"id":"chatcmpl-1","model":"qwen3","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"thinking"},"finish_reason":null}]}"#).unwrap();
  188. assert_eq!(c.reasoning.as_deref(), Some("thinking"));
  189. assert_eq!(c.content, None);
  190. // llama.cpp: usage with nulls elsewhere, timings block ignored.
  191. 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();
  192. assert_eq!(c.usage.unwrap().prompt_tokens, 4);
  193. }
  194. #[test]
  195. fn deepinfra_reasoning_content() {
  196. let c = parse_chunk(r#"{"choices":[{"delta":{"reasoning_content":"step 1"}}]}"#).unwrap();
  197. assert_eq!(c.reasoning.as_deref(), Some("step 1"));
  198. }
  199. #[test]
  200. fn errors_in_stream() {
  201. let e = parse_chunk(r#"{"error":{"message":"model overloaded"}}"#).unwrap_err();
  202. assert_eq!(e, ProviderError::Stream("model overloaded".into()));
  203. assert_eq!(parse_chunk("not json").unwrap_err().kind(), "stream");
  204. }
  205. }