Skip to main content

elph_ai/api/
google_shared.rs

1use serde_json::{Value, json};
2
3use crate::types::{AssistantContentBlock, ContentBlock, Context, Message, Model, StopReason, UserContent};
4use crate::utils::sanitize_unicode::sanitize_surrogates;
5
6use super::transform_messages::transform_messages;
7
8pub type GoogleThinkingLevel = &'static str; // "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"
9
10pub fn is_thinking_part(part: &Value) -> bool {
11    part.get("thought").and_then(|v| v.as_bool()) == Some(true)
12}
13
14pub fn retain_thought_signature(existing: Option<&str>, incoming: Option<&str>) -> Option<String> {
15    if let Some(incoming) = incoming
16        && !incoming.is_empty()
17    {
18        return Some(incoming.to_string());
19    }
20    existing.map(|s| s.to_string())
21}
22
23pub fn requires_tool_call_id(model_id: &str) -> bool {
24    model_id.starts_with("claude-") || model_id.starts_with("gpt-oss-")
25}
26
27fn get_gemini_major_version(model_id: &str) -> Option<u32> {
28    let lower = model_id.to_lowercase();
29    let re = regex::Regex::new(r"^gemini(?:-live)?-(\d+)").ok()?;
30    let caps = re.captures(&lower)?;
31    caps.get(1)?.as_str().parse().ok()
32}
33
34fn supports_multimodal_function_response(model_id: &str) -> bool {
35    if let Some(major) = get_gemini_major_version(model_id) {
36        return major >= 3;
37    }
38    true
39}
40
41pub fn convert_messages(model: &Model, context: &Context) -> Vec<Value> {
42    let mut contents = Vec::new();
43    let normalize = |id: &str| -> String {
44        if !requires_tool_call_id(&model.id) {
45            return id.to_string();
46        }
47        let sanitized: String = id
48            .chars()
49            .map(|c| {
50                if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
51                    c
52                } else {
53                    '_'
54                }
55            })
56            .collect();
57        sanitized.chars().take(64).collect()
58    };
59
60    let transformed = transform_messages(context.messages.clone(), model, |id, _m, _src| normalize(id));
61
62    for msg in transformed {
63        match msg {
64            Message::User { content, .. } => {
65                let parts = match content {
66                    UserContent::Text(text) => vec![json!({ "text": sanitize_surrogates(&text) })],
67                    UserContent::Blocks(blocks) => blocks
68                        .into_iter()
69                        .map(|b| match b {
70                            ContentBlock::Text { text } => json!({ "text": sanitize_surrogates(&text) }),
71                            ContentBlock::Image { data, mime_type } => json!({
72                                "inlineData": { "mimeType": mime_type, "data": data }
73                            }),
74                        })
75                        .collect(),
76                };
77                if parts.is_empty() {
78                    continue;
79                }
80                contents.push(json!({ "role": "user", "parts": parts }));
81            }
82            Message::Assistant(assistant) => {
83                let is_same = assistant.provider == model.provider && assistant.model == model.id;
84                let mut parts = Vec::new();
85                for block in &assistant.content {
86                    match block {
87                        AssistantContentBlock::Text(t) => {
88                            if t.text.trim().is_empty() {
89                                continue;
90                            }
91                            let mut part = json!({ "text": sanitize_surrogates(&t.text) });
92                            if let Some(sig) = resolve_thought_signature(is_same, t.text_signature.as_deref()) {
93                                part["thoughtSignature"] = json!(sig);
94                            }
95                            parts.push(part);
96                        }
97                        AssistantContentBlock::Thinking(t) => {
98                            if t.thinking.trim().is_empty() {
99                                continue;
100                            }
101                            if is_same {
102                                let mut part = json!({
103                                    "thought": true,
104                                    "text": sanitize_surrogates(&t.thinking)
105                                });
106                                if let Some(sig) = resolve_thought_signature(is_same, t.thinking_signature.as_deref()) {
107                                    part["thoughtSignature"] = json!(sig);
108                                }
109                                parts.push(part);
110                            } else {
111                                parts.push(json!({ "text": sanitize_surrogates(&t.thinking) }));
112                            }
113                        }
114                        AssistantContentBlock::ToolCall(tc) => {
115                            let mut fc = json!({
116                                "name": tc.name,
117                                "args": tc.arguments
118                            });
119                            if requires_tool_call_id(&model.id) {
120                                fc["id"] = json!(tc.id);
121                            }
122                            let mut part = json!({ "functionCall": fc });
123                            if let Some(sig) = resolve_thought_signature(is_same, tc.thought_signature.as_deref()) {
124                                part["thoughtSignature"] = json!(sig);
125                            }
126                            parts.push(part);
127                        }
128                    }
129                }
130                if parts.is_empty() {
131                    continue;
132                }
133                contents.push(json!({ "role": "model", "parts": parts }));
134            }
135            Message::ToolResult {
136                tool_name,
137                tool_call_id,
138                content,
139                is_error,
140                ..
141            } => {
142                let text_result: String = content
143                    .iter()
144                    .filter_map(|b| match b {
145                        ContentBlock::Text { text } => Some(text.as_str()),
146                        _ => None,
147                    })
148                    .collect::<Vec<_>>()
149                    .join("\n");
150                let has_images = content.iter().any(|b| matches!(b, ContentBlock::Image { .. }));
151                let has_text = !text_result.is_empty();
152                let response_value = if has_text {
153                    sanitize_surrogates(&text_result)
154                } else if has_images {
155                    "(see attached image)".to_string()
156                } else {
157                    String::new()
158                };
159
160                let image_parts: Vec<Value> = content
161                    .iter()
162                    .filter_map(|b| match b {
163                        ContentBlock::Image { data, mime_type } if model.input.iter().any(|i| i == "image") => {
164                            Some(json!({ "inlineData": { "mimeType": mime_type, "data": data } }))
165                        }
166                        _ => None,
167                    })
168                    .collect();
169
170                let multimodal = supports_multimodal_function_response(&model.id);
171                let mut fr = json!({
172                    "name": tool_name,
173                    "response": if is_error {
174                        json!({ "error": response_value })
175                    } else {
176                        json!({ "output": response_value })
177                    }
178                });
179                if has_images && multimodal {
180                    fr["parts"] = json!(image_parts);
181                }
182                if requires_tool_call_id(&model.id) {
183                    fr["id"] = json!(tool_call_id);
184                }
185                let part = json!({ "functionResponse": fr });
186
187                if let Some(last) = contents.last_mut() {
188                    if last.get("role") == Some(&json!("user"))
189                        && last
190                            .get("parts")
191                            .and_then(|p| p.as_array())
192                            .map(|a| a.iter().any(|p| p.get("functionResponse").is_some()))
193                            == Some(true)
194                    {
195                        last["parts"].as_array_mut().unwrap().push(part);
196                    } else {
197                        contents.push(json!({ "role": "user", "parts": [part] }));
198                    }
199                } else {
200                    contents.push(json!({ "role": "user", "parts": [part] }));
201                }
202
203                if has_images && !multimodal {
204                    contents.push(json!({
205                        "role": "user",
206                        "parts": [{ "text": "Tool result image:" }, image_parts]
207                    }));
208                }
209            }
210        }
211    }
212    contents
213}
214
215fn resolve_thought_signature(is_same: bool, signature: Option<&str>) -> Option<String> {
216    if !is_same {
217        return None;
218    }
219    let sig = signature?;
220    if sig.len() % 4 != 0 {
221        return None;
222    }
223    if sig
224        .chars()
225        .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
226    {
227        Some(sig.to_string())
228    } else {
229        None
230    }
231}
232
233const JSON_SCHEMA_META: &[&str] = &[
234    "$schema",
235    "$id",
236    "$anchor",
237    "$dynamicAnchor",
238    "$vocabulary",
239    "$comment",
240    "$defs",
241    "definitions",
242];
243
244fn sanitize_for_openapi(schema: &Value) -> Value {
245    match schema {
246        Value::Object(map) => {
247            let mut result = serde_json::Map::new();
248            for (k, v) in map {
249                if JSON_SCHEMA_META.contains(&k.as_str()) {
250                    continue;
251                }
252                result.insert(k.clone(), sanitize_for_openapi(v));
253            }
254            Value::Object(result)
255        }
256        Value::Array(arr) => Value::Array(arr.iter().map(sanitize_for_openapi).collect()),
257        other => other.clone(),
258    }
259}
260
261pub fn convert_tools(tools: &[crate::types::Tool], use_parameters: bool) -> Option<Vec<Value>> {
262    if tools.is_empty() {
263        return None;
264    }
265    let decls: Vec<Value> = tools
266        .iter()
267        .map(|tool| {
268            let mut decl = json!({
269                "name": tool.name,
270                "description": tool.description,
271            });
272            if use_parameters {
273                decl["parameters"] = sanitize_for_openapi(&tool.parameters);
274            } else {
275                decl["parametersJsonSchema"] = tool.parameters.clone();
276            }
277            decl
278        })
279        .collect();
280    Some(vec![json!({ "functionDeclarations": decls })])
281}
282
283pub fn map_tool_choice(choice: &str) -> &'static str {
284    match choice {
285        "auto" => "AUTO",
286        "none" => "NONE",
287        "any" => "ANY",
288        _ => "AUTO",
289    }
290}
291
292pub fn map_stop_reason_string(reason: &str) -> StopReason {
293    match reason {
294        "STOP" => StopReason::Stop,
295        "MAX_TOKENS" => StopReason::Length,
296        _ => StopReason::Error,
297    }
298}
299
300pub fn map_stop_reason_finish(finish: &str) -> StopReason {
301    match finish {
302        "STOP" | "FINISH_REASON_UNSPECIFIED" => StopReason::Stop,
303        "MAX_TOKENS" => StopReason::Length,
304        _ => StopReason::Error,
305    }
306}