Skip to main content

llm_api/
service.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")]
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}