elph_ai/api/
google_shared.rs1use 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; pub 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}