1use serde::{Deserialize, Serialize};
7
8pub const MAX_INBOUND_LINE_BYTES: usize = 1_048_576; #[derive(Debug, Clone, Serialize)]
16pub struct GeminiRole;
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "lowercase")]
20pub enum GeminiRoleName {
21 User,
22 Model,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct GeminiContent {
27 pub role: GeminiRoleName,
28 pub parts: Vec<GeminiPart>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32#[serde(untagged)]
33pub enum GeminiPart {
34 Text {
35 text: String,
36 },
37 FunctionCall {
38 #[serde(rename = "functionCall")]
39 function_call: GeminiFunctionCall,
40 #[serde(skip_serializing_if = "Option::is_none", rename = "thoughtSignature")]
41 thought_signature: Option<String>,
42 },
43 FunctionResponse {
44 #[serde(rename = "functionResponse")]
45 function_response: GeminiFunctionResponse,
46 },
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
50pub struct GeminiFunctionCall {
51 pub name: String,
52 #[serde(default)]
53 pub args: serde_json::Value,
54 #[serde(skip)]
55 pub thought_signature: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
59pub struct GeminiFunctionResponse {
60 pub name: String,
61 pub response: serde_json::Value,
62}
63
64#[derive(Debug, Clone, Serialize)]
65pub struct GeminiToolFunctionDeclaration {
66 pub name: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub description: Option<String>,
69 #[serde(
70 skip_serializing_if = "Option::is_none",
71 rename = "parametersJsonSchema"
72 )]
73 pub parameters_json_schema: Option<serde_json::Value>,
74}
75
76#[derive(Debug, Clone, Serialize)]
77pub struct GeminiTool {
78 #[serde(rename = "functionDeclarations")]
79 pub function_declarations: Vec<GeminiToolFunctionDeclaration>,
80}
81
82#[derive(Debug, Clone, Serialize)]
83pub struct GeminiSystemInstruction {
84 pub role: GeminiRoleName,
85 pub parts: Vec<GeminiPart>,
86}
87
88#[derive(Debug, Clone, Serialize)]
89pub struct GeminiGenerateRequestInner {
90 pub contents: Vec<GeminiContent>,
91 #[serde(skip_serializing_if = "Option::is_none", rename = "systemInstruction")]
92 pub system_instruction: Option<GeminiSystemInstruction>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub tools: Option<Vec<GeminiTool>>,
95 #[serde(skip_serializing_if = "Option::is_none", rename = "generationConfig")]
96 pub generation_config: Option<serde_json::Value>,
97 #[serde(skip_serializing_if = "Option::is_none", rename = "session_id")]
98 pub session_id: Option<String>,
99}
100
101#[derive(Debug, Clone, Serialize)]
104pub struct GeminiGenerateRequest {
105 pub model: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub project: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none", rename = "user_prompt_id")]
109 pub user_prompt_id: Option<String>,
110 pub request: GeminiGenerateRequestInner,
111}
112
113#[derive(Debug, Clone)]
117pub enum ChatTurn {
118 User {
119 text: String,
120 },
121 Assistant {
122 text: String,
123 },
124 ToolCall {
125 name: String,
126 args: serde_json::Value,
127 thought_signature: Option<String>,
128 },
129 ToolResult {
130 name: String,
131 result: serde_json::Value,
132 },
133}
134
135#[derive(Debug, Clone, Default)]
136pub struct ToolSpec {
137 pub name: String,
138 pub description: Option<String>,
139 pub parameters_json_schema: Option<serde_json::Value>,
140}
141
142pub fn translate_generate_content_request(
145 model: impl Into<String>,
146 project: Option<String>,
147 system_prompt: Option<String>,
148 turns: &[ChatTurn],
149 tools: &[ToolSpec],
150) -> GeminiGenerateRequest {
151 let contents: Vec<GeminiContent> = turns
152 .iter()
153 .map(|t| match t {
154 ChatTurn::User { text } => GeminiContent {
155 role: GeminiRoleName::User,
156 parts: vec![GeminiPart::Text { text: text.clone() }],
157 },
158 ChatTurn::Assistant { text } => GeminiContent {
159 role: GeminiRoleName::Model,
160 parts: vec![GeminiPart::Text { text: text.clone() }],
161 },
162 ChatTurn::ToolCall {
163 name,
164 args,
165 thought_signature,
166 } => GeminiContent {
167 role: GeminiRoleName::Model,
168 parts: vec![GeminiPart::FunctionCall {
169 function_call: GeminiFunctionCall {
170 name: name.clone(),
171 args: args.clone(),
172 thought_signature: None,
173 },
174 thought_signature: thought_signature.clone(),
175 }],
176 },
177 ChatTurn::ToolResult { name, result } => GeminiContent {
178 role: GeminiRoleName::User,
181 parts: vec![GeminiPart::FunctionResponse {
182 function_response: GeminiFunctionResponse {
183 name: name.clone(),
184 response: match result {
185 serde_json::Value::Object(_) => result.clone(),
186 value => serde_json::json!({ "output": value }),
187 },
188 },
189 }],
190 },
191 })
192 .collect();
193
194 let system_instruction = system_prompt.map(|s| GeminiSystemInstruction {
195 role: GeminiRoleName::User,
196 parts: vec![GeminiPart::Text { text: s }],
197 });
198
199 let tools_wire = if tools.is_empty() {
200 None
201 } else {
202 Some(vec![GeminiTool {
203 function_declarations: tools
204 .iter()
205 .map(|t| GeminiToolFunctionDeclaration {
206 name: t.name.clone(),
207 description: t.description.clone(),
208 parameters_json_schema: t.parameters_json_schema.clone(),
209 })
210 .collect(),
211 }])
212 };
213
214 GeminiGenerateRequest {
215 model: model.into(),
216 project,
217 user_prompt_id: None,
218 request: GeminiGenerateRequestInner {
219 contents,
220 system_instruction,
221 tools: tools_wire,
222 generation_config: None,
223 session_id: None,
224 },
225 }
226}
227
228#[derive(Debug, Clone, PartialEq)]
234pub enum GeminiStreamEvent {
235 TextDelta(String),
236 ToolCall(GeminiFunctionCall),
237 Finish {
238 reason: Option<String>,
239 },
240 Usage(GeminiUsage),
243 Ignored,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
251pub struct GeminiUsage {
252 #[serde(default, rename = "promptTokenCount")]
253 pub prompt_tokens: Option<u64>,
254 #[serde(default, rename = "candidatesTokenCount")]
255 pub candidates_tokens: Option<u64>,
256 #[serde(default, rename = "cachedContentTokenCount")]
257 pub cached_tokens: Option<u64>,
258}
259
260#[derive(Debug, Clone, Deserialize)]
264struct CaResponseEnvelope {
265 #[serde(default)]
266 response: Option<VertexResponse>,
267}
268
269#[derive(Debug, Clone, Deserialize)]
270struct VertexResponse {
271 #[serde(default)]
272 candidates: Vec<VertexCandidate>,
273 #[serde(default, rename = "usageMetadata")]
274 usage_metadata: Option<GeminiUsage>,
275}
276
277#[derive(Debug, Clone, Deserialize)]
278struct VertexCandidate {
279 #[serde(default)]
280 content: Option<VertexContent>,
281 #[serde(default, rename = "finishReason")]
282 finish_reason: Option<String>,
283}
284
285#[derive(Debug, Clone, Deserialize)]
286struct VertexContent {
287 #[serde(default)]
288 parts: Vec<VertexPart>,
289}
290
291#[derive(Debug, Clone, Deserialize)]
292struct VertexPart {
293 #[serde(default)]
294 text: Option<String>,
295 #[serde(default, rename = "functionCall")]
296 function_call: Option<GeminiFunctionCall>,
297 #[serde(default, rename = "thoughtSignature")]
298 thought_signature: Option<String>,
299}
300
301pub fn from_stream_line(line: &str) -> Result<Vec<GeminiStreamEvent>, String> {
306 if line.len() > MAX_INBOUND_LINE_BYTES {
307 return Err(format!(
308 "gemini: stream line exceeded {MAX_INBOUND_LINE_BYTES}-byte cap"
309 ));
310 }
311 let trimmed = line.trim_start();
312 let payload = if let Some(rest) = trimmed.strip_prefix("data:") {
315 rest.trim()
316 } else {
317 return Ok(Vec::new());
318 };
319 if payload.is_empty() {
320 return Ok(Vec::new());
321 }
322 if payload == "[DONE]" {
323 return Ok(vec![GeminiStreamEvent::Finish { reason: None }]);
324 }
325 let env: CaResponseEnvelope = serde_json::from_str(payload)
326 .map_err(|e| format!("gemini: malformed stream chunk: {e}"))?;
327
328 let mut out = Vec::new();
329 let Some(response) = env.response else {
330 return Ok(vec![GeminiStreamEvent::Ignored]);
331 };
332 for candidate in response.candidates {
333 if let Some(content) = candidate.content {
334 for part in content.parts {
335 if let Some(text) = part.text {
336 if !text.is_empty() {
337 out.push(GeminiStreamEvent::TextDelta(text));
338 }
339 }
340 if let Some(mut call) = part.function_call {
341 call.thought_signature = part.thought_signature;
342 out.push(GeminiStreamEvent::ToolCall(call));
343 }
344 }
345 }
346 if let Some(reason) = candidate.finish_reason {
347 out.push(GeminiStreamEvent::Finish {
348 reason: Some(reason),
349 });
350 }
351 }
352 if let Some(usage) = response.usage_metadata {
353 if usage.prompt_tokens.is_some()
356 || usage.candidates_tokens.is_some()
357 || usage.cached_tokens.is_some()
358 {
359 out.push(GeminiStreamEvent::Usage(usage));
360 }
361 }
362 if out.is_empty() {
363 out.push(GeminiStreamEvent::Ignored);
364 }
365 Ok(out)
366}
367
368#[cfg(test)]
371mod tests {
372 use super::*;
373 use serde_json::json;
374
375 #[test]
376 fn translate_produces_ca_envelope_with_contents_and_tools() {
377 let req = translate_generate_content_request(
378 "gemini-2.5-pro",
379 Some("my-proj".into()),
380 Some("be brief".into()),
381 &[
382 ChatTurn::User { text: "hi".into() },
383 ChatTurn::Assistant {
384 text: "hello".into(),
385 },
386 ChatTurn::User {
387 text: "list files".into(),
388 },
389 ],
390 &[ToolSpec {
391 name: "list_files".into(),
392 description: Some("list working directory".into()),
393 parameters_json_schema: Some(json!({"type":"object","properties":{}})),
394 }],
395 );
396 assert_eq!(req.model, "gemini-2.5-pro");
397 assert_eq!(req.project.as_deref(), Some("my-proj"));
398 let v = serde_json::to_value(&req).unwrap();
400 assert_eq!(v["request"]["contents"][0]["role"], "user");
401 assert_eq!(v["request"]["contents"][0]["parts"][0]["text"], "hi");
402 assert_eq!(v["request"]["contents"][1]["role"], "model");
403 assert_eq!(
404 v["request"]["systemInstruction"]["parts"][0]["text"],
405 "be brief"
406 );
407 assert_eq!(
408 v["request"]["tools"][0]["functionDeclarations"][0]["name"],
409 "list_files"
410 );
411 assert!(
412 v["request"]["tools"][0]["functionDeclarations"][0]["parametersJsonSchema"].is_object()
413 );
414 }
415
416 #[test]
417 fn translate_serializes_tool_calls_and_results_with_correct_roles() {
418 let req = translate_generate_content_request(
419 "gemini-2.5-flash",
420 None,
421 None,
422 &[
423 ChatTurn::User {
424 text: "search".into(),
425 },
426 ChatTurn::ToolCall {
427 name: "search".into(),
428 args: json!({"q": "rust"}),
429 thought_signature: None,
430 },
431 ChatTurn::ToolResult {
432 name: "search".into(),
433 result: json!({"hits": 3}),
434 },
435 ],
436 &[],
437 );
438 let v = serde_json::to_value(&req).unwrap();
439 assert_eq!(v["request"]["contents"][1]["role"], "model");
440 assert_eq!(
441 v["request"]["contents"][1]["parts"][0]["functionCall"]["name"],
442 "search"
443 );
444 assert_eq!(v["request"]["contents"][2]["role"], "user");
445 assert_eq!(
446 v["request"]["contents"][2]["parts"][0]["functionResponse"]["name"],
447 "search"
448 );
449 assert_eq!(
450 v["request"]["contents"][2]["parts"][0]["functionResponse"]["response"],
451 json!({"hits": 3})
452 );
453 assert!(v["request"].get("tools").is_none());
455 }
456
457 #[test]
458 fn tool_result_response_is_always_an_object_at_wire_boundary() {
459 for (result, expected) in [
460 (json!("plain output"), json!({"output":"plain output"})),
461 (json!(42), json!({"output":42})),
462 (json!(null), json!({"output":null})),
463 (json!({"hits":3}), json!({"hits":3})),
464 (
465 json!({"output":"failed","is_error":true}),
466 json!({"output":"failed","is_error":true}),
467 ),
468 ] {
469 let req = translate_generate_content_request(
470 "gemini-2.5-flash",
471 None,
472 None,
473 &[ChatTurn::ToolResult {
474 name: "tool".into(),
475 result,
476 }],
477 &[],
478 );
479 let wire = serde_json::to_value(req).unwrap();
480 assert_eq!(wire["request"]["contents"][0]["role"], "user");
481 let response =
482 &wire["request"]["contents"][0]["parts"][0]["functionResponse"]["response"];
483 assert_eq!(response, &expected);
484 assert!(response.is_object());
485 }
486 }
487
488 #[test]
489 fn sse_ignores_blank_and_comment_lines() {
490 assert!(from_stream_line("").unwrap().is_empty());
491 assert!(from_stream_line(" ").unwrap().is_empty());
492 assert!(from_stream_line(": keep-alive").unwrap().is_empty());
493 assert!(from_stream_line("event: message").unwrap().is_empty());
494 assert!(from_stream_line("id: 42").unwrap().is_empty());
495 }
496
497 #[test]
498 fn sse_decodes_text_delta_from_ca_envelope() {
499 let line =
500 r#"data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}}"#;
501 let events = from_stream_line(line).unwrap();
502 assert_eq!(events, vec![GeminiStreamEvent::TextDelta("Hello".into())]);
503 }
504
505 #[test]
506 fn sse_decodes_function_call_and_finish_reason() {
507 let line = r#"data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"search","args":{"q":"rust"}}}]},"finishReason":"STOP"}]}}"#;
508 let events = from_stream_line(line).unwrap();
509 assert_eq!(
510 events,
511 vec![
512 GeminiStreamEvent::ToolCall(GeminiFunctionCall {
513 name: "search".into(),
514 args: json!({"q":"rust"}),
515 thought_signature: None,
516 }),
517 GeminiStreamEvent::Finish {
518 reason: Some("STOP".into())
519 },
520 ]
521 );
522 }
523
524 #[test]
525 fn sse_done_sentinel_yields_finish() {
526 let events = from_stream_line("data: [DONE]").unwrap();
527 assert_eq!(events, vec![GeminiStreamEvent::Finish { reason: None }]);
528 }
529
530 #[test]
531 fn sse_malformed_json_is_a_typed_error() {
532 assert!(from_stream_line("data: {not json").is_err());
533 assert!(from_stream_line("data: garbage")
534 .unwrap_err()
535 .contains("malformed"));
536 }
537
538 #[test]
539 fn sse_oversize_line_fails_closed() {
540 let big = format!("data: {}", "x".repeat(MAX_INBOUND_LINE_BYTES));
541 assert!(from_stream_line(&big).unwrap_err().contains("cap"));
542 }
543
544 #[test]
545 fn sse_empty_response_envelope_is_ignored() {
546 let events = from_stream_line("data: {}").unwrap();
547 assert_eq!(events, vec![GeminiStreamEvent::Ignored]);
548 }
549
550 #[test]
551 fn sse_decodes_reported_usage_metadata_without_zero_filling() {
552 let line = r#"data: {"response":{"candidates":[{"content":{"parts":[{"text":"hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":5}}}"#;
553 let events = from_stream_line(line).unwrap();
554 assert_eq!(
555 events,
556 vec![
557 GeminiStreamEvent::TextDelta("hi".into()),
558 GeminiStreamEvent::Finish {
559 reason: Some("STOP".into())
560 },
561 GeminiStreamEvent::Usage(GeminiUsage {
562 prompt_tokens: Some(12),
563 candidates_tokens: Some(5),
564 cached_tokens: None,
565 }),
566 ]
567 );
568 }
569
570 #[test]
571 fn sse_empty_usage_metadata_is_not_surfaced() {
572 let line = r#"data: {"response":{"usageMetadata":{}}}"#;
575 let events = from_stream_line(line).unwrap();
576 assert_eq!(events, vec![GeminiStreamEvent::Ignored]);
577 }
578}