gemini_adk_rs/code_executors/
vertex_ai.rs

1//! Vertex AI code executor — runs code via the Vertex AI Code Interpreter Extension.
2//!
3//! Mirrors ADK-Python's `vertex_ai_code_executor`. The Python SDK wraps the
4//! Vertex AI **Extension** service (`vertexai.preview.extensions.Extension`):
5//! it loads an existing code-interpreter extension (or imports one from the
6//! prebuilt hub) and then calls its `execute` operation. This Rust port talks
7//! directly to the underlying Vertex AI Extension Execution REST API:
8//!
9//! * Import from hub:
10//!   `POST https://{loc}-aiplatform.googleapis.com/v1beta1/projects/{p}/locations/{loc}/extensions:import`
11//! * Execute:
12//!   `POST https://{loc}-aiplatform.googleapis.com/v1beta1/{resource_name}:execute`
13//!   with body `{"operationId": "execute", "operationParams": {...}}`.
14//!
15//! The execute response carries a `content` payload holding `execution_result`
16//! (stdout), `execution_error` (stderr) and `output_files` (a list of
17//! `{name, contents}`), exactly as ADK consumes them.
18//!
19//! The HTTP integration is gated behind the `vertex-ai-code-executor` feature
20//! (which pulls in `reqwest`). Without it, the executor and its types still
21//! compile as a drop-in, but `execute_code` returns an error explaining the
22//! feature is required — so the request/response helpers are only exercised
23//! when the feature is enabled.
24#![cfg_attr(
25    not(feature = "vertex-ai-code-executor"),
26    allow(dead_code, unused_imports)
27)]
28
29use std::sync::Arc;
30
31use async_trait::async_trait;
32
33use super::base::{CodeExecutor, CodeExecutorError};
34use super::types::{CodeExecutionInput, CodeExecutionResult, CodeFile};
35
36/// Library preamble injected ahead of user code, mirroring ADK's
37/// `_IMPORTED_LIBRARIES`. Makes the common data-science stack and the
38/// `explore_df` helper available to executed snippets.
39const IMPORTED_LIBRARIES: &str = r#"
40import io
41import math
42import re
43
44import matplotlib.pyplot as plt
45import numpy as np
46import pandas as pd
47import scipy
48
49def crop(s: str, max_chars: int = 64) -> str:
50  """Crops a string to max_chars characters."""
51  return s[: max_chars - 3] + '...' if len(s) > max_chars else s
52
53
54def explore_df(df: pd.DataFrame) -> None:
55  """Prints some information about a pandas DataFrame."""
56
57  with pd.option_context(
58      'display.max_columns', None, 'display.expand_frame_repr', False
59  ):
60    # Print the column names to never encounter KeyError when selecting one.
61    df_dtypes = df.dtypes
62
63    # Obtain information about data types and missing values.
64    df_nulls = (len(df) - df.isnull().sum()).apply(
65        lambda x: f'{x} / {df.shape[0]} non-null'
66    )
67
68    # Explore unique total values in columns using `.unique()`.
69    df_unique_count = df.apply(lambda x: len(x.unique()))
70
71    # Explore unique values in columns using `.unique()`.
72    df_unique = df.apply(lambda x: crop(str(list(x.unique()))))
73
74    df_info = pd.concat(
75        (
76            df_dtypes.rename('Dtype'),
77            df_nulls.rename('Non-Null Count'),
78            df_unique_count.rename('Unique Values Count'),
79            df_unique.rename('Unique Values'),
80        ),
81        axis=1,
82    )
83    df_info.index.name = 'Columns'
84    print(f"""Total rows: {df.shape[0]}
85Total columns: {df.shape[1]}
86
87{df_info}""")
88"#;
89
90/// Image extensions that map to `image/{ext}` MIME types (mirrors ADK's
91/// `_SUPPORTED_IMAGE_TYPES`).
92const SUPPORTED_IMAGE_TYPES: &[&str] = &["png", "jpg", "jpeg"];
93
94/// Data-file extensions that map to `text/{ext}` MIME types (mirrors ADK's
95/// `_SUPPORTED_DATA_FILE_TYPES`).
96const SUPPORTED_DATA_FILE_TYPES: &[&str] = &["csv"];
97
98/// Configuration for Vertex AI code execution.
99#[derive(Debug, Clone)]
100pub struct VertexAiCodeExecutorConfig {
101    /// Google Cloud project ID.
102    pub project: String,
103    /// Google Cloud location (e.g., "us-central1").
104    pub location: String,
105    /// Execution timeout in seconds.
106    pub timeout_secs: u64,
107    /// If set, the full resource name of an existing code interpreter
108    /// extension to reuse instead of importing a new one. Format:
109    /// `projects/123/locations/us-central1/extensions/456`.
110    pub resource_name: Option<String>,
111}
112
113impl VertexAiCodeExecutorConfig {
114    /// Create a new config with the default timeout (no preexisting extension).
115    pub fn new(project: impl Into<String>, location: impl Into<String>) -> Self {
116        Self {
117            project: project.into(),
118            location: location.into(),
119            timeout_secs: 60,
120            resource_name: None,
121        }
122    }
123
124    /// Reuse an existing code interpreter extension resource name.
125    ///
126    /// Format: `projects/123/locations/us-central1/extensions/456`.
127    pub fn resource_name(mut self, name: impl Into<String>) -> Self {
128        self.resource_name = Some(name.into());
129        self
130    }
131
132    /// Override the execution timeout in seconds.
133    pub fn timeout_secs(mut self, secs: u64) -> Self {
134        self.timeout_secs = secs;
135        self
136    }
137
138    /// Base Vertex AI API URL for this project/location.
139    fn api_base(&self) -> String {
140        format!(
141            "https://{location}-aiplatform.googleapis.com/v1beta1",
142            location = self.location,
143        )
144    }
145
146    /// Parent resource for the `extensions:import` call.
147    fn extensions_parent(&self) -> String {
148        format!(
149            "{}/projects/{}/locations/{}",
150            self.api_base(),
151            self.project,
152            self.location,
153        )
154    }
155}
156
157/// How to supply a bearer token for Vertex AI requests. Mirrors the token
158/// pattern used by [`crate::session::VertexAiSessionService`].
159enum TokenProvider {
160    /// No token configured — requests fail with a clear message.
161    None,
162    /// A static, pre-fetched bearer token string.
163    Static(String),
164    /// A dynamic refresher: called before every request.
165    Refresher(Arc<dyn Fn() -> String + Send + Sync>),
166}
167
168impl std::fmt::Debug for TokenProvider {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        match self {
171            TokenProvider::None => f.write_str("TokenProvider::None"),
172            TokenProvider::Static(_) => f.write_str("TokenProvider::Static(..)"),
173            TokenProvider::Refresher(_) => f.write_str("TokenProvider::Refresher(..)"),
174        }
175    }
176}
177
178impl TokenProvider {
179    fn get(&self) -> Result<String, CodeExecutorError> {
180        match self {
181            TokenProvider::None => Err(CodeExecutorError::Other(
182                "missing auth token: call .with_token() or .with_token_refresher()".into(),
183            )),
184            TokenProvider::Static(t) => Ok(t.clone()),
185            TokenProvider::Refresher(f) => Ok(f()),
186        }
187    }
188}
189
190/// Code executor that runs code via the Vertex AI Code Interpreter Extension.
191///
192/// Uses the Vertex AI Extension Execution REST API to run Python code in a
193/// Google-managed sandboxed environment, mirroring ADK-Python's
194/// `VertexAiCodeExecutor`.
195///
196/// # Quick start
197///
198/// ```rust,no_run
199/// # use gemini_adk_rs::code_executors::{VertexAiCodeExecutor, VertexAiCodeExecutorConfig};
200/// let executor = VertexAiCodeExecutor::new(
201///     VertexAiCodeExecutorConfig::new("my-project", "us-central1"),
202/// )
203/// .with_token("ya29.my-access-token");
204/// ```
205#[derive(Debug)]
206pub struct VertexAiCodeExecutor {
207    config: VertexAiCodeExecutorConfig,
208    token_provider: TokenProvider,
209    #[cfg(feature = "vertex-ai-code-executor")]
210    client: reqwest::Client,
211    /// Lazily-resolved extension resource name (cached after first import).
212    extension_name: parking_lot::Mutex<Option<String>>,
213}
214
215impl VertexAiCodeExecutor {
216    /// Create a new Vertex AI code executor.
217    ///
218    /// No auth token is configured yet — call [`with_token`](Self::with_token)
219    /// or [`with_token_refresher`](Self::with_token_refresher) before issuing
220    /// requests.
221    pub fn new(config: VertexAiCodeExecutorConfig) -> Self {
222        let extension_name = parking_lot::Mutex::new(config.resource_name.clone());
223        Self {
224            config,
225            token_provider: TokenProvider::None,
226            #[cfg(feature = "vertex-ai-code-executor")]
227            client: reqwest::Client::new(),
228            extension_name,
229        }
230    }
231
232    /// Set a static bearer token for all requests.
233    pub fn with_token(mut self, token: impl Into<String>) -> Self {
234        self.token_provider = TokenProvider::Static(token.into());
235        self
236    }
237
238    /// Set a dynamic token refresher closure, invoked before every request.
239    pub fn with_token_refresher(mut self, f: impl Fn() -> String + Send + Sync + 'static) -> Self {
240        self.token_provider = TokenProvider::Refresher(Arc::new(f));
241        self
242    }
243
244    /// Returns the configured project ID.
245    pub fn project(&self) -> &str {
246        &self.config.project
247    }
248
249    /// Returns the configured location.
250    pub fn location(&self) -> &str {
251        &self.config.location
252    }
253
254    /// Build the code string with the standard ADK library preamble.
255    fn code_with_imports(code: &str) -> String {
256        format!("\n{IMPORTED_LIBRARIES}\n\n{code}\n")
257    }
258
259    /// Map a code-interpreter output file into a [`CodeFile`], guessing a MIME
260    /// type from the extension exactly as ADK does.
261    fn map_output_file(name: String, contents: String) -> CodeFile {
262        let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
263        let mime_type = if SUPPORTED_IMAGE_TYPES.contains(&ext.as_str()) {
264            format!("image/{ext}")
265        } else if SUPPORTED_DATA_FILE_TYPES.contains(&ext.as_str()) {
266            format!("text/{ext}")
267        } else {
268            guess_mime_type(&name)
269        };
270        CodeFile {
271            name,
272            content: contents,
273            mime_type,
274        }
275    }
276}
277
278/// Code-interpreter output file as returned by the extension.
279#[cfg(feature = "vertex-ai-code-executor")]
280#[derive(Debug, serde::Deserialize)]
281struct OutputFile {
282    #[serde(default)]
283    name: String,
284    #[serde(default)]
285    contents: String,
286}
287
288/// The decoded `content` payload of an `:execute` response.
289#[cfg(feature = "vertex-ai-code-executor")]
290#[derive(Debug, Default, serde::Deserialize)]
291struct ExecuteContent {
292    #[serde(default)]
293    execution_result: String,
294    #[serde(default)]
295    execution_error: String,
296    #[serde(default)]
297    output_files: Vec<OutputFile>,
298}
299
300#[cfg(feature = "vertex-ai-code-executor")]
301impl VertexAiCodeExecutor {
302    /// Resolve (loading or importing) the code interpreter extension resource
303    /// name, caching it for subsequent calls.
304    async fn ensure_extension(&self, token: &str) -> Result<String, CodeExecutorError> {
305        if let Some(name) = self.extension_name.lock().clone() {
306            return Ok(name);
307        }
308
309        // Import the prebuilt "code_interpreter" extension from the hub.
310        // Mirrors ADK's `Extension.from_hub('code_interpreter')`.
311        let url = format!("{}/extensions:import", self.config.extensions_parent());
312        let body = serde_json::json!({
313            "displayName": "Code Interpreter",
314            "description": "This extension generates and executes code in the specified language",
315            "manifest": {
316                "name": "code_interpreter_tool",
317                "description": "Google Code Interpreter Extension",
318                "apiSpec": {
319                    "openApiGcsUri": "gs://vertex-extension-public/code_interpreter.yaml"
320                },
321                "authConfig": {
322                    "authType": "GOOGLE_SERVICE_ACCOUNT_AUTH",
323                    "googleServiceAccountConfig": {}
324                }
325            }
326        });
327
328        let resp = self
329            .client
330            .post(&url)
331            .header("Authorization", format!("Bearer {token}"))
332            .header("Content-Type", "application/json")
333            .json(&body)
334            .send()
335            .await
336            .map_err(|e| CodeExecutorError::Other(format!("extension import failed: {e}")))?;
337
338        let status = resp.status().as_u16();
339        let text = resp
340            .text()
341            .await
342            .map_err(|e| CodeExecutorError::Other(format!("reading import response: {e}")))?;
343        if !(200..300).contains(&status) {
344            return Err(CodeExecutorError::Other(format!(
345                "Vertex AI extension import failed [{status}]: {text}"
346            )));
347        }
348
349        let json: serde_json::Value = serde_json::from_str(&text)
350            .map_err(|e| CodeExecutorError::Other(format!("parsing import response: {e}")))?;
351        // The import op may return the extension directly (`name`) or wrap it in
352        // an LRO with the resource under `response.name`.
353        let name = json
354            .get("name")
355            .and_then(|v| v.as_str())
356            .filter(|n| n.contains("/extensions/"))
357            .or_else(|| json.pointer("/response/name").and_then(|v| v.as_str()))
358            .map(String::from)
359            .ok_or_else(|| {
360                CodeExecutorError::Other(format!(
361                    "extension import returned no resource name: {text}"
362                ))
363            })?;
364
365        *self.extension_name.lock() = Some(name.clone());
366        Ok(name)
367    }
368
369    /// Call the extension's `execute` operation and decode its content payload.
370    async fn execute_code_interpreter(
371        &self,
372        code: &str,
373        input_files: &[CodeFile],
374        session_id: Option<&str>,
375    ) -> Result<ExecuteContent, CodeExecutorError> {
376        let token = self.token_provider.get()?;
377        let extension = self.ensure_extension(&token).await?;
378
379        let mut operation_params = serde_json::json!({ "code": code });
380        if !input_files.is_empty() {
381            operation_params["files"] = serde_json::Value::Array(
382                input_files
383                    .iter()
384                    .map(|f| serde_json::json!({ "name": f.name, "contents": f.content }))
385                    .collect(),
386            );
387        }
388        if let Some(sid) = session_id {
389            operation_params["session_id"] = serde_json::Value::String(sid.to_string());
390        }
391
392        let url = format!("{}/{extension}:execute", self.config.api_base());
393        let body = serde_json::json!({
394            "operationId": "execute",
395            "operationParams": operation_params,
396        });
397
398        let resp = self
399            .client
400            .post(&url)
401            .header("Authorization", format!("Bearer {token}"))
402            .header("Content-Type", "application/json")
403            .json(&body)
404            .send()
405            .await
406            .map_err(|e| {
407                CodeExecutorError::ExecutionFailed(format!("execute request failed: {e}"))
408            })?;
409
410        let status = resp.status().as_u16();
411        let text = resp.text().await.map_err(|e| {
412            CodeExecutorError::ExecutionFailed(format!("reading execute response: {e}"))
413        })?;
414        if !(200..300).contains(&status) {
415            return Err(CodeExecutorError::ExecutionFailed(format!(
416                "Vertex AI code execution failed [{status}]: {text}"
417            )));
418        }
419
420        parse_execute_content(&text)
421    }
422}
423
424/// Parse the body of an `:execute` response into an [`ExecuteContent`].
425///
426/// The extension wraps its payload in a `content` field, which may be either a
427/// JSON object or a JSON-encoded string. Both shapes are handled, as is a
428/// top-level payload with no `content` wrapper.
429#[cfg(feature = "vertex-ai-code-executor")]
430fn parse_execute_content(text: &str) -> Result<ExecuteContent, CodeExecutorError> {
431    let json: serde_json::Value = serde_json::from_str(text).map_err(|e| {
432        CodeExecutorError::ExecutionFailed(format!("parsing execute response: {e}"))
433    })?;
434
435    let content = json.get("content").unwrap_or(&json);
436    let value = match content {
437        // `content` delivered as a JSON-encoded string.
438        serde_json::Value::String(s) => serde_json::from_str(s).map_err(|e| {
439            CodeExecutorError::ExecutionFailed(format!("parsing execute content string: {e}"))
440        })?,
441        other => other.clone(),
442    };
443
444    serde_json::from_value(value).map_err(|e| {
445        CodeExecutorError::ExecutionFailed(format!("decoding execute content fields: {e}"))
446    })
447}
448
449/// Best-effort MIME type guess from a file extension. Covers the common types
450/// produced by the code interpreter; falls back to `text/plain`.
451fn guess_mime_type(name: &str) -> String {
452    let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
453    match ext.as_str() {
454        "png" => "image/png",
455        "jpg" | "jpeg" => "image/jpeg",
456        "gif" => "image/gif",
457        "svg" => "image/svg+xml",
458        "csv" => "text/csv",
459        "json" => "application/json",
460        "html" | "htm" => "text/html",
461        "txt" | "log" => "text/plain",
462        "pdf" => "application/pdf",
463        "xml" => "application/xml",
464        _ => "text/plain",
465    }
466    .to_string()
467}
468
469#[async_trait]
470impl CodeExecutor for VertexAiCodeExecutor {
471    async fn execute_code(
472        &self,
473        input: CodeExecutionInput,
474    ) -> Result<CodeExecutionResult, CodeExecutorError> {
475        #[cfg(not(feature = "vertex-ai-code-executor"))]
476        {
477            let _ = &input;
478            return Err(CodeExecutorError::Other(
479                "VertexAiCodeExecutor requires the `vertex-ai-code-executor` feature".into(),
480            ));
481        }
482
483        #[cfg(feature = "vertex-ai-code-executor")]
484        {
485            let code = Self::code_with_imports(&input.code);
486            let content = self
487                .execute_code_interpreter(&code, &input.input_files, input.execution_id.as_deref())
488                .await?;
489
490            let output_files = content
491                .output_files
492                .into_iter()
493                .map(|f| Self::map_output_file(f.name, f.contents))
494                .collect();
495
496            Ok(CodeExecutionResult {
497                stdout: content.execution_result,
498                stderr: content.execution_error,
499                output_files,
500            })
501        }
502    }
503
504    fn stateful(&self) -> bool {
505        true
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    fn test_config() -> VertexAiCodeExecutorConfig {
514        VertexAiCodeExecutorConfig::new("test-project", "us-central1")
515    }
516
517    #[test]
518    fn executor_metadata() {
519        let exec = VertexAiCodeExecutor::new(test_config());
520        assert_eq!(exec.project(), "test-project");
521        assert_eq!(exec.location(), "us-central1");
522        assert!(exec.stateful());
523    }
524
525    #[test]
526    fn config_builder() {
527        let cfg = VertexAiCodeExecutorConfig::new("p", "us-central1")
528            .resource_name("projects/p/locations/us-central1/extensions/42")
529            .timeout_secs(120);
530        assert_eq!(cfg.timeout_secs, 120);
531        assert_eq!(
532            cfg.resource_name.as_deref(),
533            Some("projects/p/locations/us-central1/extensions/42")
534        );
535    }
536
537    #[test]
538    fn api_url_construction() {
539        let cfg = test_config();
540        assert_eq!(
541            cfg.api_base(),
542            "https://us-central1-aiplatform.googleapis.com/v1beta1"
543        );
544        assert!(
545            cfg.extensions_parent()
546                .ends_with("/projects/test-project/locations/us-central1")
547        );
548    }
549
550    #[test]
551    fn preexisting_resource_name_is_cached() {
552        let cfg = test_config().resource_name("projects/p/locations/l/extensions/9");
553        let exec = VertexAiCodeExecutor::new(cfg);
554        assert_eq!(
555            exec.extension_name.lock().as_deref(),
556            Some("projects/p/locations/l/extensions/9")
557        );
558    }
559
560    #[test]
561    fn code_with_imports_includes_preamble() {
562        let code = VertexAiCodeExecutor::code_with_imports("print(1)");
563        assert!(code.contains("import pandas as pd"));
564        assert!(code.contains("def explore_df"));
565        assert!(code.contains("print(1)"));
566    }
567
568    #[test]
569    fn map_output_file_image_mime() {
570        let f = VertexAiCodeExecutor::map_output_file("chart.png".into(), "AAAA".into());
571        assert_eq!(f.mime_type, "image/png");
572        let f = VertexAiCodeExecutor::map_output_file("photo.JPG".into(), "AAAA".into());
573        assert_eq!(f.mime_type, "image/jpg");
574    }
575
576    #[test]
577    fn map_output_file_data_mime() {
578        let f = VertexAiCodeExecutor::map_output_file("out.csv".into(), "a,b".into());
579        assert_eq!(f.mime_type, "text/csv");
580    }
581
582    #[test]
583    fn map_output_file_fallback_mime() {
584        let f = VertexAiCodeExecutor::map_output_file("notes.txt".into(), "hi".into());
585        assert_eq!(f.mime_type, "text/plain");
586        let f = VertexAiCodeExecutor::map_output_file("data.json".into(), "{}".into());
587        assert_eq!(f.mime_type, "application/json");
588    }
589
590    #[test]
591    fn with_token_sets_provider() {
592        let exec = VertexAiCodeExecutor::new(test_config()).with_token("tok123");
593        assert_eq!(exec.token_provider.get().unwrap(), "tok123");
594    }
595
596    #[test]
597    fn missing_token_errors() {
598        let exec = VertexAiCodeExecutor::new(test_config());
599        assert!(exec.token_provider.get().is_err());
600    }
601
602    #[cfg(feature = "vertex-ai-code-executor")]
603    #[test]
604    fn parse_execute_content_object() {
605        let body = serde_json::json!({
606            "content": {
607                "execution_result": "42\n",
608                "execution_error": "",
609                "output_files": [{"name": "chart.png", "contents": "AAAA"}],
610            }
611        })
612        .to_string();
613        let content = parse_execute_content(&body).unwrap();
614        assert_eq!(content.execution_result, "42\n");
615        assert_eq!(content.output_files.len(), 1);
616        assert_eq!(content.output_files[0].name, "chart.png");
617    }
618
619    #[cfg(feature = "vertex-ai-code-executor")]
620    #[test]
621    fn parse_execute_content_stringified() {
622        let inner = serde_json::json!({
623            "execution_result": "ok",
624            "execution_error": "boom",
625            "output_files": [],
626        })
627        .to_string();
628        let body = serde_json::json!({ "content": inner }).to_string();
629        let content = parse_execute_content(&body).unwrap();
630        assert_eq!(content.execution_result, "ok");
631        assert_eq!(content.execution_error, "boom");
632    }
633
634    #[cfg(feature = "vertex-ai-code-executor")]
635    #[test]
636    fn parse_execute_content_no_wrapper() {
637        let body = serde_json::json!({
638            "execution_result": "hi",
639            "execution_error": "",
640            "output_files": [],
641        })
642        .to_string();
643        let content = parse_execute_content(&body).unwrap();
644        assert_eq!(content.execution_result, "hi");
645    }
646
647    #[cfg(not(feature = "vertex-ai-code-executor"))]
648    #[tokio::test]
649    async fn execute_without_feature_errors() {
650        let exec = VertexAiCodeExecutor::new(test_config()).with_token("t");
651        let input = CodeExecutionInput {
652            code: "print(42)".into(),
653            input_files: vec![],
654            execution_id: None,
655        };
656        assert!(exec.execute_code(input).await.is_err());
657    }
658}