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