use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use google_cloud_aiplatform_v1 as vertexai;
use rig_core::OneOrMany;
use rig_core::completion::{CompletionError, CompletionResponse, Usage};
use rig_core::message::{
AssistantContent, ImageDetail, ImageMediaType, MediaType, MimeType, Reasoning, Text, ToolCall,
ToolFunction,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub struct VertexGenerateContentOutput(pub vertexai::model::GenerateContentResponse);
impl TryFrom<VertexGenerateContentOutput> for CompletionResponse<VertexGenerateContentOutput> {
type Error = CompletionError;
fn try_from(value: VertexGenerateContentOutput) -> Result<Self, Self::Error> {
let response = &value.0;
let candidate = response.candidates.first().ok_or_else(|| {
CompletionError::ProviderError("No candidates in response".to_string())
})?;
let content = candidate
.content
.as_ref()
.ok_or_else(|| CompletionError::ProviderError("No content in candidate".to_string()))?;
let mut assistant_contents = Vec::new();
for part in content.parts.iter() {
let signature = (!part.thought_signature.is_empty())
.then(|| BASE64.encode(&part.thought_signature));
if let Some(function_call) = part.function_call() {
let args_json = function_call
.args
.as_ref()
.map(|s| serde_json::Value::Object(s.clone()))
.unwrap_or_else(|| serde_json::json!({}));
assistant_contents.push(AssistantContent::ToolCall(
ToolCall::new(
function_call.name.clone(),
ToolFunction::new(function_call.name.clone(), args_json),
)
.with_signature(signature),
));
} else if let Some(text) = part.text() {
if part.thought {
assistant_contents.push(AssistantContent::Reasoning(
Reasoning::new_with_signature(text, signature),
));
} else {
assistant_contents.push(AssistantContent::Text(Text::new(text.clone())));
}
} else if let Some(inline_data) = part.inline_data() {
if signature.is_some() {
return Err(CompletionError::ResponseError(
"Vertex inline images with thought_signature cannot be replayed through assistant history"
.to_string(),
));
}
if part.thought {
continue;
}
let media_type = MediaType::from_mime_type(&inline_data.mime_type);
match media_type {
Some(MediaType::Image(
media_type @ (ImageMediaType::JPEG
| ImageMediaType::PNG
| ImageMediaType::WEBP
| ImageMediaType::HEIC
| ImageMediaType::HEIF),
)) => {
assistant_contents.push(AssistantContent::image_base64(
BASE64.encode(&inline_data.data),
Some(media_type),
Some(ImageDetail::default()),
));
}
Some(MediaType::Image(media_type)) => {
return Err(CompletionError::ResponseError(format!(
"Unsupported Vertex inline image media type {media_type:?}; it cannot be replayed through assistant history"
)));
}
_ => {
return Err(CompletionError::ResponseError(format!(
"Unsupported Vertex inline media type {:?}",
inline_data.mime_type
)));
}
}
} else if signature.is_some() {
tracing::warn!(
"Vertex response part carries a thought_signature but is neither a function \
call nor text; signature dropped (no rig-core carrier)."
);
}
}
if assistant_contents.is_empty() {
return Err(CompletionError::ProviderError(
"No text or tool call content found in response".to_string(),
));
}
let choice = OneOrMany::many(assistant_contents).map_err(|e| {
CompletionError::ProviderError(format!("Failed to create OneOrMany: {e}"))
})?;
let usage = response
.usage_metadata
.as_ref()
.map(|usage| Usage {
input_tokens: usage.prompt_token_count as u64,
output_tokens: usage.candidates_token_count as u64,
total_tokens: usage.total_token_count as u64,
cached_input_tokens: 0, cache_creation_input_tokens: 0,
tool_use_prompt_tokens: 0,
reasoning_tokens: 0,
})
.unwrap_or_default();
Ok(CompletionResponse {
choice,
usage,
raw_response: value,
message_id: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use google_cloud_aiplatform_v1 as vertexai;
use rig_core::OneOrMany;
use rig_core::message::{
AssistantContent, DocumentSourceKind, ImageDetail, ImageMediaType, Text, ToolCall,
};
fn create_text_response(text: &str) -> VertexGenerateContentOutput {
let part = vertexai::model::Part::new().set_text(text.to_string());
let content = vertexai::model::Content::new()
.set_role("model")
.set_parts([part]);
let candidate = vertexai::model::Candidate::new()
.set_content(content)
.set_finish_reason(vertexai::model::candidate::FinishReason::Stop);
let response = vertexai::model::GenerateContentResponse::new().set_candidates([candidate]);
VertexGenerateContentOutput(response)
}
fn create_parts_response(
parts: impl IntoIterator<Item = vertexai::model::Part>,
) -> VertexGenerateContentOutput {
let content = vertexai::model::Content::new()
.set_role("model")
.set_parts(parts);
let candidate = vertexai::model::Candidate::new().set_content(content);
let response = vertexai::model::GenerateContentResponse::new().set_candidates([candidate]);
VertexGenerateContentOutput(response)
}
fn inline_data_part(mime_type: &str, data: Vec<u8>) -> vertexai::model::Part {
vertexai::model::Part::new().set_inline_data(
vertexai::model::Blob::new()
.set_mime_type(mime_type)
.set_data(data),
)
}
fn create_tool_call_response(
function_name: &str,
args: serde_json::Value,
) -> VertexGenerateContentOutput {
let struct_args = match args {
serde_json::Value::Object(map) => map,
_ => panic!("Expected JSON object for Struct conversion"),
};
let function_call = vertexai::model::FunctionCall::new()
.set_name(function_name.to_string())
.set_args(struct_args);
let part = vertexai::model::Part::new().set_function_call(function_call);
let content = vertexai::model::Content::new()
.set_role("model")
.set_parts([part]);
let candidate = vertexai::model::Candidate::new()
.set_content(content)
.set_finish_reason(vertexai::model::candidate::FinishReason::Stop);
let response = vertexai::model::GenerateContentResponse::new().set_candidates([candidate]);
VertexGenerateContentOutput(response)
}
fn create_signed_tool_call_response(
function_name: &str,
signature: &[u8],
) -> VertexGenerateContentOutput {
let function_call = vertexai::model::FunctionCall::new()
.set_name(function_name.to_string())
.set_args(serde_json::Map::new());
let part = vertexai::model::Part::new()
.set_function_call(function_call)
.set_thought_signature(signature.to_vec());
let content = vertexai::model::Content::new()
.set_role("model")
.set_parts([part]);
let candidate = vertexai::model::Candidate::new().set_content(content);
let response = vertexai::model::GenerateContentResponse::new().set_candidates([candidate]);
VertexGenerateContentOutput(response)
}
#[test]
fn test_tool_call_response_captures_thought_signature() {
let raw = b"\x00\x01\x02thinking-sig\xff";
let response: CompletionResponse<VertexGenerateContentOutput> =
create_signed_tool_call_response("add", raw)
.try_into()
.unwrap();
match response.choice.first() {
AssistantContent::ToolCall(tc) => assert_eq!(tc.signature, Some(BASE64.encode(raw))),
_ => panic!("Expected ToolCall"),
}
}
#[test]
fn test_tool_call_response_without_signature_is_none() {
let response: CompletionResponse<VertexGenerateContentOutput> =
create_tool_call_response("add", serde_json::json!({"x": 1}))
.try_into()
.unwrap();
match response.choice.first() {
AssistantContent::ToolCall(tc) => assert_eq!(tc.signature, None),
_ => panic!("Expected ToolCall"),
}
}
#[test]
fn test_thought_text_response_captures_thought_signature() {
let raw = b"\x00\x01\x02thinking-text-sig\xff";
let part = vertexai::model::Part::new()
.set_text("thinking text".to_string())
.set_thought(true)
.set_thought_signature(raw.to_vec());
let content = vertexai::model::Content::new()
.set_role("model")
.set_parts([part]);
let candidate = vertexai::model::Candidate::new().set_content(content);
let response = vertexai::model::GenerateContentResponse::new().set_candidates([candidate]);
let response: CompletionResponse<VertexGenerateContentOutput> =
VertexGenerateContentOutput(response).try_into().unwrap();
match response.choice.first() {
AssistantContent::Reasoning(reasoning) => {
assert_eq!(reasoning.display_text(), "thinking text");
assert_eq!(
reasoning.first_signature(),
Some(BASE64.encode(raw).as_str())
);
}
_ => panic!("Expected Reasoning"),
}
}
#[test]
fn test_text_response_conversion() {
let vertex_output = create_text_response("Hello, world!");
let completion_response: Result<CompletionResponse<VertexGenerateContentOutput>, _> =
vertex_output.try_into();
assert!(completion_response.is_ok());
let response = completion_response.unwrap();
assert_eq!(
response.choice,
OneOrMany::one(AssistantContent::Text(Text::new(
"Hello, world!".to_string()
)))
);
}
#[test]
fn test_tool_call_response_conversion() {
let args = serde_json::json!({
"x": 5,
"y": 3
});
let vertex_output = create_tool_call_response("add", args.clone());
let completion_response: Result<CompletionResponse<VertexGenerateContentOutput>, _> =
vertex_output.try_into();
assert!(completion_response.is_ok());
let response = completion_response.unwrap();
match response.choice.first() {
AssistantContent::ToolCall(ToolCall { id, function, .. }) => {
assert_eq!(id, "add");
assert_eq!(function.name, "add");
assert_eq!(function.arguments, args);
}
_ => panic!("Expected ToolCall"),
}
}
#[test]
fn inline_image_response_converts_raw_bytes_to_base64_with_mime_type() {
let raw = vec![0, 1, 2, 255];
let response: CompletionResponse<VertexGenerateContentOutput> =
create_parts_response([inline_data_part("image/png", raw.clone())])
.try_into()
.expect("image response should convert");
match response.choice.first() {
AssistantContent::Image(image) => {
assert_eq!(image.data, DocumentSourceKind::Base64(BASE64.encode(raw)));
assert_eq!(image.media_type, Some(ImageMediaType::PNG));
assert_eq!(image.detail, Some(ImageDetail::default()));
}
_ => panic!("Expected Image"),
}
}
#[test]
fn mixed_text_and_image_response_preserves_part_order() {
let raw = vec![1, 2, 3];
let response: CompletionResponse<VertexGenerateContentOutput> = create_parts_response([
vertexai::model::Part::new().set_text("before"),
inline_data_part("image/jpeg", raw.clone()),
vertexai::model::Part::new().set_text("after"),
])
.try_into()
.expect("mixed response should convert");
let contents: Vec<_> = response.choice.iter().collect();
assert!(matches!(contents[0], AssistantContent::Text(text) if text.text == "before"));
match contents[1] {
AssistantContent::Image(image) => {
assert_eq!(image.data, DocumentSourceKind::Base64(BASE64.encode(raw)));
assert_eq!(image.media_type, Some(ImageMediaType::JPEG));
}
_ => panic!("Expected Image"),
}
assert!(matches!(contents[2], AssistantContent::Text(text) if text.text == "after"));
}
#[test]
fn mixed_text_and_thought_image_response_keeps_only_visible_text_in_order() {
let response: CompletionResponse<VertexGenerateContentOutput> = create_parts_response([
vertexai::model::Part::new().set_text("before"),
inline_data_part("image/png", vec![1, 2, 3]).set_thought(true),
vertexai::model::Part::new().set_text("after"),
])
.try_into()
.expect("thought image should be skipped");
let contents: Vec<_> = response.choice.iter().collect();
assert_eq!(contents.len(), 2);
assert!(matches!(contents[0], AssistantContent::Text(text) if text.text == "before"));
assert!(matches!(contents[1], AssistantContent::Text(text) if text.text == "after"));
}
#[test]
fn thought_image_only_response_fails_without_visible_assistant_content() {
let result =
CompletionResponse::<VertexGenerateContentOutput>::try_from(create_parts_response([
inline_data_part("image/png", vec![1, 2, 3]).set_thought(true),
]));
let error = match result {
Err(error) => error,
Ok(_) => panic!("thought-image-only response must fail"),
};
assert!(matches!(error, CompletionError::ProviderError(_)));
assert!(
error
.to_string()
.contains("No text or tool call content found in response")
);
}
#[test]
fn inline_audio_and_non_image_media_are_rejected() {
for mime_type in ["audio/wav", "application/pdf", "application/octet-stream"] {
let result = CompletionResponse::<VertexGenerateContentOutput>::try_from(
create_parts_response([inline_data_part(mime_type, vec![0])]),
);
let error = match result {
Err(error) => error,
Ok(_) => panic!("unsupported inline media must fail"),
};
assert!(matches!(error, CompletionError::ResponseError(_)));
assert!(error.to_string().contains(mime_type));
}
}
#[test]
fn inline_gif_and_svg_images_are_rejected() {
for mime_type in ["image/gif", "image/svg+xml"] {
let result = CompletionResponse::<VertexGenerateContentOutput>::try_from(
create_parts_response([inline_data_part(mime_type, vec![0])]),
);
let error = match result {
Err(error) => error,
Ok(_) => panic!("non-replayable inline image must fail"),
};
assert!(matches!(error, CompletionError::ResponseError(_)));
assert!(
error
.to_string()
.contains("Unsupported Vertex inline image media type")
);
}
}
#[test]
fn signed_inline_image_is_rejected() {
let part = inline_data_part("image/png", vec![0]).set_thought_signature(vec![1, 2, 3]);
let result =
CompletionResponse::<VertexGenerateContentOutput>::try_from(create_parts_response([
part,
]));
let error = match result {
Err(error) => error,
Ok(_) => panic!("signed inline image must fail"),
};
assert!(matches!(error, CompletionError::ResponseError(_)));
assert!(error.to_string().contains("thought_signature"));
}
#[test]
fn test_usage_metadata_conversion() {
let mut response = create_text_response("test").0;
let usage_metadata = vertexai::model::generate_content_response::UsageMetadata::new()
.set_prompt_token_count(10)
.set_candidates_token_count(20)
.set_total_token_count(30);
response = response.set_usage_metadata(usage_metadata);
let vertex_output = VertexGenerateContentOutput(response);
let completion_response: Result<CompletionResponse<VertexGenerateContentOutput>, _> =
vertex_output.try_into();
assert!(completion_response.is_ok());
let response = completion_response.unwrap();
assert_eq!(response.usage.input_tokens, 10);
assert_eq!(response.usage.output_tokens, 20);
assert_eq!(response.usage.total_tokens, 30);
}
#[test]
fn test_empty_response_error() {
let response = vertexai::model::GenerateContentResponse::new();
let vertex_output = VertexGenerateContentOutput(response);
let completion_response: Result<CompletionResponse<VertexGenerateContentOutput>, _> =
vertex_output.try_into();
assert!(completion_response.is_err());
}
}