gemini_adk_rs/code_executors/
vertex_ai.rs1#![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
36const 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
90const SUPPORTED_IMAGE_TYPES: &[&str] = &["png", "jpg", "jpeg"];
93
94const SUPPORTED_DATA_FILE_TYPES: &[&str] = &["csv"];
97
98#[derive(Debug, Clone)]
100pub struct VertexAiCodeExecutorConfig {
101 pub project: String,
103 pub location: String,
105 pub timeout_secs: u64,
107 pub resource_name: Option<String>,
111}
112
113impl VertexAiCodeExecutorConfig {
114 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 pub fn resource_name(mut self, name: impl Into<String>) -> Self {
128 self.resource_name = Some(name.into());
129 self
130 }
131
132 pub fn timeout_secs(mut self, secs: u64) -> Self {
134 self.timeout_secs = secs;
135 self
136 }
137
138 fn api_base(&self) -> String {
140 format!(
141 "https://{location}-aiplatform.googleapis.com/v1beta1",
142 location = self.location,
143 )
144 }
145
146 fn extensions_parent(&self) -> String {
148 format!(
149 "{}/projects/{}/locations/{}",
150 self.api_base(),
151 self.project,
152 self.location,
153 )
154 }
155}
156
157enum TokenProvider {
160 None,
162 Static(String),
164 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#[derive(Debug)]
206pub struct VertexAiCodeExecutor {
207 config: VertexAiCodeExecutorConfig,
208 token_provider: TokenProvider,
209 #[cfg(feature = "vertex-ai-code-executor")]
210 client: reqwest::Client,
211 extension_name: parking_lot::Mutex<Option<String>>,
213}
214
215impl VertexAiCodeExecutor {
216 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 pub fn with_token(mut self, token: impl Into<String>) -> Self {
234 self.token_provider = TokenProvider::Static(token.into());
235 self
236 }
237
238 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 pub fn project(&self) -> &str {
246 &self.config.project
247 }
248
249 pub fn location(&self) -> &str {
251 &self.config.location
252 }
253
254 fn code_with_imports(code: &str) -> String {
256 format!("\n{IMPORTED_LIBRARIES}\n\n{code}\n")
257 }
258
259 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#[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#[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 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 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 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 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#[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 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
449fn 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}