use adk_core::Result;
use adk_gcp::truncate_for_error;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
const FILE_NAME_ATTRIBUTE: &str = "file_name";
const JSON_MIME_TYPE: &str = "application/json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SandboxState {
#[serde(rename = "STATE_UNSPECIFIED")]
Unspecified,
#[serde(rename = "STATE_PROVISIONING")]
Provisioning,
#[serde(rename = "STATE_RUNNING")]
Running,
#[serde(rename = "STATE_DEPROVISIONING")]
Deprovisioning,
#[serde(rename = "STATE_TERMINATED")]
Terminated,
#[serde(rename = "STATE_DELETED")]
Deleted,
#[serde(other, rename = "STATE_UNKNOWN")]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MachineConfig {
#[serde(rename = "MACHINE_CONFIG_UNSPECIFIED")]
Unspecified,
#[serde(rename = "MACHINE_CONFIG_VCPU4_RAM4GIB")]
Vcpu4Ram4Gib,
#[serde(other, rename = "MACHINE_CONFIG_UNKNOWN")]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CodeLanguage {
#[serde(rename = "LANGUAGE_UNSPECIFIED")]
Unspecified,
#[serde(rename = "LANGUAGE_PYTHON")]
Python,
#[serde(rename = "LANGUAGE_JAVASCRIPT")]
Javascript,
#[serde(other, rename = "LANGUAGE_UNKNOWN")]
Unknown,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CodeExecutionEnvironment {
#[serde(skip_serializing_if = "Option::is_none")]
pub machine_config: Option<MachineConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_language: Option<CodeLanguage>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComputerUseEnvironment {}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxEnvironmentSpec {
#[serde(skip_serializing_if = "Option::is_none")]
pub code_execution_environment: Option<CodeExecutionEnvironment>,
#[serde(skip_serializing_if = "Option::is_none")]
pub computer_use_environment: Option<ComputerUseEnvironment>,
}
impl SandboxEnvironmentSpec {
pub fn code_execution(environment: CodeExecutionEnvironment) -> Self {
Self { code_execution_environment: Some(environment), computer_use_environment: None }
}
pub fn computer_use() -> Self {
Self {
code_execution_environment: None,
computer_use_environment: Some(ComputerUseEnvironment {}),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SandboxEnvironment {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub create_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<SandboxState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spec: Option<SandboxEnvironmentSpec>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_environment_template: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_info: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_sandbox_environment_snapshot: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_environment_snapshot: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expire_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CreateSandboxRequest {
pub(crate) display_name: String,
pub(crate) ttl: Option<String>,
pub(crate) spec: Option<SandboxEnvironmentSpec>,
}
impl CreateSandboxRequest {
pub fn new(display_name: impl Into<String>) -> Self {
Self { display_name: display_name.into(), ttl: None, spec: None }
}
#[must_use]
pub fn with_ttl(mut self, ttl: impl Into<String>) -> Self {
self.ttl = Some(ttl.into());
self
}
#[must_use]
pub fn with_spec(mut self, spec: SandboxEnvironmentSpec) -> Self {
self.spec = Some(spec);
self
}
pub(crate) fn into_body(self) -> SandboxEnvironment {
SandboxEnvironment {
display_name: Some(self.display_name),
ttl: self.ttl,
spec: self.spec,
..SandboxEnvironment::default()
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChunkMetadata {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub attributes: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Chunk {
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
#[serde(default)]
pub data: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<ChunkMetadata>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InputFile {
pub name: String,
pub mime_type: String,
pub data: Vec<u8>,
}
impl InputFile {
pub fn new(name: impl Into<String>, mime_type: impl Into<String>, data: Vec<u8>) -> Self {
Self { name: name.into(), mime_type: mime_type.into(), data }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutputFile {
pub name: String,
pub mime_type: Option<String>,
pub data: Vec<u8>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SandboxExecutionResult {
pub stdout: String,
pub stderr: String,
pub output_files: Vec<OutputFile>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ExecuteRequest {
pub(crate) inputs: Vec<Chunk>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct ExecuteResponse {
#[serde(default)]
pub(crate) outputs: Vec<Chunk>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ListSandboxesResponse {
#[serde(default)]
pub(crate) sandbox_environments: Vec<SandboxEnvironment>,
#[serde(default)]
pub(crate) next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ConsolePayload {
#[serde(default)]
msg_out: Option<String>,
#[serde(default)]
msg_err: Option<String>,
}
pub fn encode_code_chunk(code: &str) -> Chunk {
let payload = serde_json::json!({ "code": code });
Chunk {
mime_type: Some(JSON_MIME_TYPE.to_string()),
data: BASE64.encode(payload.to_string()),
metadata: None,
}
}
pub fn encode_file_chunk(file: &InputFile) -> Chunk {
let mut attributes = BTreeMap::new();
attributes.insert(FILE_NAME_ATTRIBUTE.to_string(), BASE64.encode(&file.name));
Chunk {
mime_type: Some(file.mime_type.clone()),
data: BASE64.encode(&file.data),
metadata: Some(ChunkMetadata { attributes }),
}
}
pub fn decode_output_chunks(outputs: &[Chunk]) -> Result<SandboxExecutionResult> {
let errors = super::errors();
let mut result = SandboxExecutionResult::default();
for chunk in outputs {
let file_name =
chunk.metadata.as_ref().and_then(|meta| meta.attributes.get(FILE_NAME_ATTRIBUTE));
if let Some(encoded_name) = file_name {
let name_bytes = BASE64.decode(encoded_name).map_err(|error| {
errors.invalid_response(format!(
"vertex sandbox output chunk carries a file_name attribute that is not valid base64: {error}",
))
})?;
let name = String::from_utf8(name_bytes).map_err(|_| {
errors.invalid_response(
"vertex sandbox output chunk carries a file_name that is not valid UTF-8",
)
})?;
let data = BASE64.decode(&chunk.data).map_err(|error| {
errors.invalid_response(format!(
"vertex sandbox output file '{}' data is not valid base64: {error}",
truncate_for_error(&name),
))
})?;
result.output_files.push(OutputFile { name, mime_type: chunk.mime_type.clone(), data });
continue;
}
if chunk.mime_type.as_deref() == Some(JSON_MIME_TYPE) {
let bytes = BASE64.decode(&chunk.data).map_err(|error| {
errors.invalid_response(format!(
"vertex sandbox console output chunk data is not valid base64: {error}",
))
})?;
let payload: ConsolePayload = serde_json::from_slice(&bytes).map_err(|error| {
errors.invalid_response(format!(
"vertex sandbox console output chunk is not valid JSON: {error}",
))
})?;
if let Some(out) = payload.msg_out {
result.stdout.push_str(&out);
}
if let Some(err) = payload.msg_err {
result.stderr.push_str(&err);
}
continue;
}
tracing::debug!(chunk.mime_type = ?chunk.mime_type, "skipping unrecognized output chunk");
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn code_chunk_encodes_the_json_code_convention() {
let chunk = encode_code_chunk("print('hi')");
assert_eq!(chunk.mime_type.as_deref(), Some(JSON_MIME_TYPE));
assert_eq!(chunk.metadata, None);
let decoded: Value = serde_json::from_slice(&BASE64.decode(&chunk.data).unwrap()).unwrap();
assert_eq!(decoded, json!({ "code": "print('hi')" }));
}
#[test]
fn file_chunk_carries_base64_name_and_bytes() {
let file = InputFile::new("data.csv", "text/csv", b"a,b\n".to_vec());
let chunk = encode_file_chunk(&file);
assert_eq!(chunk.mime_type.as_deref(), Some("text/csv"));
let attributes = chunk.metadata.unwrap().attributes;
assert_eq!(attributes.get(FILE_NAME_ATTRIBUTE).unwrap(), &BASE64.encode("data.csv"));
assert_eq!(BASE64.decode(&chunk.data).unwrap(), b"a,b\n");
}
#[test]
fn output_decoding_separates_console_and_files() {
let outputs = vec![
Chunk {
mime_type: Some(JSON_MIME_TYPE.to_string()),
data: BASE64.encode(
json!({
"msg_out": "hello\n",
"msg_err": "warning\n",
"output_files": ["ignored.txt"],
"unknown_key": 42,
})
.to_string(),
),
metadata: None,
},
Chunk {
mime_type: None,
data: BASE64.encode(b"bytes"),
metadata: Some(ChunkMetadata {
attributes: BTreeMap::from([(
FILE_NAME_ATTRIBUTE.to_string(),
BASE64.encode("out.bin"),
)]),
}),
},
];
let result = decode_output_chunks(&outputs).unwrap();
assert_eq!(
result,
SandboxExecutionResult {
stdout: "hello\n".to_string(),
stderr: "warning\n".to_string(),
output_files: vec![OutputFile {
name: "out.bin".to_string(),
mime_type: None,
data: b"bytes".to_vec(),
}],
},
);
}
#[test]
fn console_chunks_accumulate_across_outputs() {
let console = |payload: Value| Chunk {
mime_type: Some(JSON_MIME_TYPE.to_string()),
data: BASE64.encode(payload.to_string()),
metadata: None,
};
let outputs = vec![
console(json!({ "msg_out": "one" })),
console(json!({ "msg_out": "two", "msg_err": "err" })),
];
let result = decode_output_chunks(&outputs).unwrap();
assert_eq!(result.stdout, "onetwo");
assert_eq!(result.stderr, "err");
}
#[test]
fn unrecognized_chunks_are_skipped() {
let outputs = vec![Chunk {
mime_type: Some("image/png".to_string()),
data: BASE64.encode(b"png-bytes"),
metadata: None,
}];
let result = decode_output_chunks(&outputs).unwrap();
assert_eq!(result, SandboxExecutionResult::default());
}
#[test]
fn invalid_base64_in_outputs_is_rejected() {
let outputs = vec![Chunk {
mime_type: Some(JSON_MIME_TYPE.to_string()),
data: "not base64!!!".to_string(),
metadata: None,
}];
let error = decode_output_chunks(&outputs).unwrap_err();
assert_eq!(error.code, "code.vertex_sandbox.invalid_response");
}
#[test]
fn unknown_enum_values_deserialize_to_the_catch_all() {
let state: SandboxState = serde_json::from_value(json!("STATE_HIBERNATED")).unwrap();
assert_eq!(state, SandboxState::Unknown);
let running: SandboxState = serde_json::from_value(json!("STATE_RUNNING")).unwrap();
assert_eq!(running, SandboxState::Running);
}
#[test]
fn create_body_serializes_camel_case_and_skips_absent_fields() {
let body = CreateSandboxRequest::new("default_sandbox")
.with_ttl("31536000s")
.with_spec(SandboxEnvironmentSpec::code_execution(CodeExecutionEnvironment::default()))
.into_body();
assert_eq!(
serde_json::to_value(&body).unwrap(),
json!({
"displayName": "default_sandbox",
"ttl": "31536000s",
"spec": { "codeExecutionEnvironment": {} },
}),
);
}
}