Skip to main content

agent_engine/runtime/google_gemini/
translate.rs

1//! Translation between the runtime's generic chat/tool shape and the Gemini
2//! Code Assist `streamGenerateContent` wire format (Vertex-style contents +
3//! function calls). Also decodes the SSE line format used by
4//! `cloudcode-pa.googleapis.com` when called with `?alt=sse`.
5
6use serde::{Deserialize, Serialize};
7
8/// Maximum bytes we will buffer for a single SSE line before failing closed.
9/// Google's Code Assist emits reasonably small JSON chunks; refusing anything
10/// bigger prevents a hostile upstream from ballooning memory.
11pub const MAX_INBOUND_LINE_BYTES: usize = 1_048_576; // 1 MiB per line
12
13// ── Outbound request ─────────────────────────────────────────────────────────
14
15#[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/// Envelope matching the Code Assist `streamGenerateContent` request shape:
102/// `{ model, project?, user_prompt_id?, request: VertexGenerateContentRequest }`.
103#[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/// Minimal, generic input shape callers assemble before calling the translator.
114/// Kept intentionally small: text / tool-call / tool-response only. Media
115/// payloads are unsupported in this experimental slice.
116#[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
142/// Translate a normalized conversation + tool set into a
143/// `GeminiGenerateRequest` ready for `/v1internal:streamGenerateContent`.
144pub 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                // Gemini Content supports only `user` and `model`; a
179                // functionResponse is submitted as a user turn.
180                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// ── Inbound SSE decoding ─────────────────────────────────────────────────────
229
230/// A decoded, runtime-facing event from the Gemini stream. Text fragments and
231/// tool calls are surfaced separately so the runtime can route them without
232/// re-parsing the vendor shape.
233#[derive(Debug, Clone, PartialEq)]
234pub enum GeminiStreamEvent {
235    TextDelta(String),
236    ToolCall(GeminiFunctionCall),
237    Finish {
238        reason: Option<String>,
239    },
240    /// Provider-reported token accounting (`usageMetadata`). Counts the
241    /// upstream did not report stay `None` — never fabricated zeros.
242    Usage(GeminiUsage),
243    /// The upstream chunk was well-formed JSON but did not carry text/tool/
244    /// finish info. Callers usually drop these.
245    Ignored,
246}
247
248/// Provider-reported usage decoded from a stream chunk's `usageMetadata`.
249/// Every field is optional: absent upstream counts stay absent.
250#[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/// A single `data: ...` payload as decoded from the Code Assist SSE stream.
261/// The full wire shape looks like `{"response": {...vertex...}}` optionally
262/// alongside `traceId` and credit accounting we do not surface.
263#[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
301/// Decode one inbound SSE `data:` line into runtime events. Empty/comment
302/// lines yield an empty vec; `data: [DONE]` yields `Finish { reason: None }`.
303/// Malformed JSON is surfaced as an `Err` so the caller can decide whether to
304/// terminate the stream (spec: fail closed on structurally invalid frames).
305pub 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    // SSE: only `data:` lines carry payload. Ignore comments (`:`), event ids,
313    // and blank lines.
314    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        // Only surface usage the provider actually reported — an
354        // all-`None` struct would invite zero-filling downstream.
355        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// ── Tests ────────────────────────────────────────────────────────────────────
369
370#[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        // Vertex envelope shape.
399        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        // tools omitted → key absent, not null.
454        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        // usageMetadata with no reported counts must not produce a Usage
573        // event — nothing observed means nothing recorded.
574        let line = r#"data: {"response":{"usageMetadata":{}}}"#;
575        let events = from_stream_line(line).unwrap();
576        assert_eq!(events, vec![GeminiStreamEvent::Ignored]);
577    }
578}