Skip to main content

core_api/
llm.rs

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, PartialEq, Eq)]
64#[serde(rename_all = "camelCase")]
65pub struct ChatToolCallFunction {
66    pub name: String,
67    pub arguments: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
71#[serde(rename_all = "camelCase")]
72pub struct ChatToolCall {
73    pub id: String,
74    #[serde(rename = "type")]
75    pub call_type: String,
76    pub function: ChatToolCallFunction,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
80#[serde(rename_all = "camelCase")]
81pub struct ChatMessage {
82    pub role: String,
83    pub content: String,
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub images: Vec<Image>,
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub tool_calls: Vec<ChatToolCall>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub tool_call_id: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub name: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct LlmGenerationOptions {
97    #[serde(default)]
98    pub temperature: Option<f32>,
99    #[serde(default)]
100    pub max_tokens: Option<u32>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104#[serde(rename_all = "camelCase", deny_unknown_fields)]
105pub struct LlmCompleteRequest {
106    #[serde(default)]
107    pub use_case: Option<String>,
108    #[serde(default)]
109    pub mode: Option<String>,
110    pub messages: Vec<ChatMessage>,
111    #[serde(default)]
112    pub tools: Option<Value>,
113    #[serde(default)]
114    pub provider: Option<Value>,
115    #[serde(default)]
116    pub response_format: Option<Value>,
117    #[serde(default)]
118    pub options: LlmGenerationOptions,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct ProviderToolCall {
124    pub id: String,
125    pub name: String,
126    pub arguments_json: String,
127}
128
129#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
130#[serde(rename_all = "camelCase")]
131pub struct LlmTokenUsage {
132    pub input_tokens: u64,
133    pub output_tokens: u64,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub cached_input_tokens: Option<u64>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub reasoning_output_tokens: Option<u64>,
138}
139
140impl LlmTokenUsage {
141    #[must_use]
142    pub fn total_tokens(self) -> u64 {
143        self.input_tokens.saturating_add(self.output_tokens)
144    }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, Default)]
148#[serde(rename_all = "camelCase")]
149pub struct LlmRouteInfo {
150    pub provider: String,
151    pub model: String,
152    pub mode: String,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156#[serde(rename_all = "camelCase")]
157pub struct LlmCompleteResponse {
158    pub content: String,
159    #[serde(default)]
160    pub tool_calls: Vec<ProviderToolCall>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub usage: Option<LlmTokenUsage>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub route: Option<LlmRouteInfo>,
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn request_rejects_removed_top_level_generation_options() {
173        assert!(
174            serde_json::from_value::<LlmCompleteRequest>(serde_json::json!({
175                "messages": [],
176                "options": {},
177                "temperature": 0.2
178            }))
179            .is_err()
180        );
181    }
182
183    #[test]
184    fn response_includes_normalized_usage() {
185        let response = LlmCompleteResponse {
186            usage: Some(LlmTokenUsage {
187                input_tokens: 2,
188                output_tokens: 3,
189                ..LlmTokenUsage::default()
190            }),
191            ..LlmCompleteResponse::default()
192        };
193        assert_eq!(response.usage.unwrap().total_tokens(), 5);
194    }
195}