1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4pub const COMPLETE_TARGET: &str = "/llm/complete";
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
7#[serde(rename_all = "camelCase")]
8pub struct Image {
9 #[serde(skip_serializing_if = "Option::is_none")]
10 pub url: Option<String>,
11 #[serde(skip_serializing_if = "Option::is_none")]
12 pub base64: Option<String>,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub mime_type: Option<String>,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub created_at: Option<i64>,
17}
18
19impl Image {
20 #[must_use]
21 pub fn from_base64(base64: impl Into<String>, mime_type: impl Into<String>) -> Self {
22 Self {
23 base64: Some(base64.into()),
24 mime_type: Some(mime_type.into()),
25 ..Self::default()
26 }
27 }
28
29 #[must_use]
30 pub fn from_url(url: impl Into<String>) -> Self {
31 Self {
32 url: Some(url.into()),
33 ..Self::default()
34 }
35 }
36
37 #[must_use]
38 pub fn resolved_mime_type(&self) -> Option<String> {
39 let mime_type = self
40 .mime_type
41 .as_ref()
42 .map(|value| value.trim())
43 .filter(|value| !value.is_empty())?;
44 let normalized = mime_type.to_ascii_lowercase();
45 Some(match normalized.as_str() {
46 "image/jpg" => "image/jpeg".to_string(),
47 _ => normalized,
48 })
49 }
50
51 #[must_use]
52 pub fn is_empty(&self) -> bool {
53 self.url
54 .as_ref()
55 .is_none_or(|value| value.trim().is_empty())
56 && self
57 .base64
58 .as_ref()
59 .is_none_or(|value| value.trim().is_empty())
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64#[serde(rename_all = "camelCase", deny_unknown_fields)]
65pub struct LlmGenerationOptions {
66 #[serde(default)]
67 pub temperature: Option<f32>,
68 #[serde(default)]
69 pub max_tokens: Option<u32>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73#[serde(rename_all = "camelCase", deny_unknown_fields)]
74pub struct LlmCompleteRequest {
75 #[serde(default)]
76 pub constraints: crate::ModelConstraints,
77 #[serde(default)]
78 pub use_case: Option<String>,
79 #[serde(default)]
80 pub mode: Option<String>,
81 pub messages: Vec<crate::Message>,
82 #[serde(default)]
83 pub tools: Option<Vec<crate::ToolDefinition>>,
84 #[serde(default)]
85 pub provider: Option<Value>,
86 #[serde(default)]
87 pub response_format: Option<Value>,
88 #[serde(default)]
89 pub options: LlmGenerationOptions,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, Default)]
93#[serde(rename_all = "camelCase")]
94pub struct LlmRouteInfo {
95 pub provider: String,
96 pub model: String,
97 pub mode: String,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101#[serde(rename_all = "camelCase")]
102pub struct LlmCompleteResponse {
103 pub message: crate::Message,
104 pub finish_reason: crate::FinishReason,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub usage: Option<crate::TokenUsage>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub route: Option<LlmRouteInfo>,
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn request_rejects_removed_top_level_generation_options() {
117 assert!(
118 serde_json::from_value::<LlmCompleteRequest>(serde_json::json!({
119 "messages": [],
120 "options": {},
121 "temperature": 0.2
122 }))
123 .is_err()
124 );
125 }
126
127 #[test]
128 fn response_includes_normalized_usage() {
129 let response = LlmCompleteResponse {
130 usage: Some(crate::TokenUsage {
131 input_tokens: 2,
132 output_tokens: 3,
133 ..crate::TokenUsage::default()
134 }),
135 ..LlmCompleteResponse::default()
136 };
137 assert_eq!(response.usage.unwrap().total_tokens(), 5);
138 }
139}