use std::borrow::Cow;
use std::sync::atomic::AtomicU64;
use crate::error::{LiterLlmError, Result};
use crate::provider::Provider;
use crate::types::ChatCompletionChunk;
mod gemini_request;
mod gemini_response;
mod gemini_stream;
pub(crate) use gemini_request::transform_gemini_request;
pub(crate) use gemini_response::transform_gemini_response;
pub(crate) use gemini_stream::parse_gemini_stream_event;
const DEFAULT_LOCATION: &str = "us-central1";
static TOOL_CALL_COUNTER: AtomicU64 = AtomicU64::new(0);
fn reasoning_effort_to_thinking_budget(effort: &str) -> i64 {
match effort {
"minimal" => 0,
"low" => 1024,
"medium" => 4096,
"high" => 16384,
"max" => 24576,
_ => 4096,
}
}
pub struct VertexAiProvider {
base_url: String,
}
impl VertexAiProvider {
#[must_use]
pub fn new(project: impl Into<String>, location: impl Into<String>) -> Self {
let project = project.into();
let location = location.into();
let base_url =
format!("https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}");
Self { base_url }
}
#[must_use]
pub fn with_base_url(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_owned(),
}
}
#[must_use]
pub fn from_env() -> Self {
let project = std::env::var("VERTEXAI_PROJECT").unwrap_or_default();
let location = std::env::var("VERTEXAI_LOCATION").unwrap_or_else(|_| DEFAULT_LOCATION.to_owned());
if project.is_empty() {
return Self {
base_url: String::new(),
};
}
Self::new(project, location)
}
}
impl Provider for VertexAiProvider {
fn name(&self) -> &str {
"vertex_ai"
}
fn base_url(&self) -> &str {
&self.base_url
}
fn validate(&self) -> Result<()> {
if self.base_url.is_empty() {
return Err(LiterLlmError::BadRequest {
message: "Vertex AI requires a project ID. \
Set VERTEXAI_PROJECT (and optionally VERTEXAI_LOCATION) \
in the environment, or provide an explicit base_url in \
ClientConfig."
.into(),
status: 400,
});
}
Ok(())
}
fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
}
fn matches_model(&self, model: &str) -> bool {
model.starts_with("vertex_ai/")
}
fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
model.strip_prefix("vertex_ai/").unwrap_or(model)
}
fn build_url(&self, endpoint_path: &str, model: &str) -> String {
let base = self.base_url();
if base.is_empty() {
return String::new();
}
let base = base.trim_end_matches('/');
if endpoint_path.contains("chat/completions") {
format!("{base}/publishers/google/models/{model}:generateContent")
} else if endpoint_path.contains("embeddings") {
format!("{base}/publishers/google/models/{model}:predict")
} else {
format!("{base}{endpoint_path}")
}
}
fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
if body.get("input").is_some() && body.get("messages").is_none() {
return transform_vertex_embed_request(body);
}
transform_gemini_request(body)
}
fn transform_response(&self, body: &mut serde_json::Value) -> Result<()> {
transform_gemini_response(body)
}
fn build_stream_url(&self, endpoint_path: &str, model: &str) -> String {
let url = self.build_url(endpoint_path, model);
if url.is_empty() {
return url;
}
let url = url.replace(":generateContent", ":streamGenerateContent");
format!("{url}?alt=sse")
}
fn parse_stream_event(&self, event_data: &str) -> Result<Option<ChatCompletionChunk>> {
parse_gemini_stream_event(event_data)
}
}
fn transform_gemini_embed_request(body: &mut serde_json::Value) -> Result<()> {
use crate::error::LiterLlmError;
use serde_json::json;
let input = body.get("input").cloned().unwrap_or_default();
let texts = match &input {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) if arr.iter().all(serde_json::Value::is_string) => arr
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_owned)
.collect(),
_ => {
return Err(LiterLlmError::BadRequest {
message: "Google AI embedding adapters support text input only; use a multimodal-compatible custom provider for image embeddings".into(),
status: 400,
});
}
};
let model = body
.get("model")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let dimensions = body.get("dimensions").and_then(serde_json::Value::as_u64);
let requests: Vec<_> = texts
.into_iter()
.map(|text| {
let mut request = json!({
"model": format!("models/{model}"),
"content": {"parts": [{"text": text}]}
});
if let Some(dimensions) = dimensions {
request["outputDimensionality"] = json!(dimensions);
}
request
})
.collect();
*body = json!({"requests": requests});
Ok(())
}
fn transform_vertex_embed_request(body: &mut serde_json::Value) -> Result<()> {
use crate::error::LiterLlmError;
use serde_json::json;
let input = body.get("input").cloned().unwrap_or_default();
let texts = match &input {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) if arr.iter().all(serde_json::Value::is_string) => arr
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::to_owned)
.collect(),
_ => {
return Err(LiterLlmError::BadRequest {
message: "Vertex AI embedding adapters support text input only; use a multimodal-compatible custom provider for image embeddings".into(),
status: 400,
});
}
};
let instances: Vec<_> = texts.into_iter().map(|text| json!({"content": text})).collect();
*body = json!({"instances": instances});
Ok(())
}
pub(crate) fn convert_user_content_to_gemini(content: Option<&serde_json::Value>) -> Vec<serde_json::Value> {
use serde_json::json;
match content {
Some(serde_json::Value::String(s)) => vec![json!({"text": s})],
Some(serde_json::Value::Array(parts)) => parts
.iter()
.filter_map(|part| {
let part_type = part.get("type").and_then(|t| t.as_str())?;
match part_type {
"text" => {
let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
Some(json!({"text": text}))
}
"image_url" => {
let url = part.pointer("/image_url/url").and_then(|u| u.as_str())?;
if url.starts_with("data:")
&& let Some((header, data)) = url.split_once(',')
{
let mime_type = header.trim_start_matches("data:").trim_end_matches(";base64");
return Some(json!({
"inlineData": {
"mimeType": mime_type,
"data": data
}
}));
}
Some(json!({
"fileData": {
"mimeType": "image/jpeg",
"fileUri": url
}
}))
}
"document" => {
let doc = part.get("document")?;
let data = doc.get("data").and_then(|d| d.as_str())?;
let media_type = doc
.get("media_type")
.and_then(|m| m.as_str())
.unwrap_or("application/pdf");
Some(json!({
"inlineData": {
"mimeType": media_type,
"data": data
}
}))
}
_ => {
let text = part.get("text").and_then(|t| t.as_str()).unwrap_or("");
if text.is_empty() {
None
} else {
Some(json!({"text": text}))
}
}
}
})
.collect(),
_ => vec![json!({"text": ""})],
}
}
fn translate_tool_choice(tool_choice: Option<&serde_json::Value>) -> Option<serde_json::Value> {
use serde_json::json;
let tc = tool_choice?;
if let Some(s) = tc.as_str() {
let mode = match s {
"none" => "NONE",
"auto" => "AUTO",
"required" => "ANY",
_ => return None,
};
return Some(json!({
"functionCallingConfig": {
"mode": mode
}
}));
}
if let Some(name) = tc.pointer("/function/name").and_then(|n| n.as_str()) {
return Some(json!({
"functionCallingConfig": {
"mode": "ANY",
"allowedFunctionNames": [name]
}
}));
}
None
}
#[cfg(test)]
mod response_tests;
#[cfg(test)]
mod tests;