use adk_core::{AdkError, Content, ErrorCategory, ErrorComponent, Result};
use adk_gcp::{GcpErrorCodes, GcpErrorContext, GcpHttpClient, truncate_for_error};
use google_cloud_auth::credentials::Credentials;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const EXAMPLE_STORE_API_VERSION: &str = "v1beta1";
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
const AUTH_HEADERS_TIMEOUT: Duration = Duration::from_secs(30);
const ENV_GOOGLE_CLOUD_PROJECT: &str = "GOOGLE_CLOUD_PROJECT";
const ENV_GOOGLE_CLOUD_LOCATION: &str = "GOOGLE_CLOUD_LOCATION";
const ENV_EXAMPLE_STORE_ID: &str = "EXAMPLE_STORE_ID";
#[derive(Debug, Clone)]
pub struct ExampleStoreConfig {
pub project_id: String,
pub location: String,
pub example_store: String,
pub endpoint: Option<String>,
}
impl ExampleStoreConfig {
pub fn new(
project_id: impl Into<String>,
location: impl Into<String>,
example_store: impl Into<String>,
) -> Self {
Self {
project_id: project_id.into(),
location: location.into(),
example_store: example_store.into(),
endpoint: None,
}
}
pub fn from_env() -> Result<Self> {
let read = |key: &str| {
std::env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
};
let project_id = read(ENV_GOOGLE_CLOUD_PROJECT);
let location = read(ENV_GOOGLE_CLOUD_LOCATION);
let example_store = read(ENV_EXAMPLE_STORE_ID);
match (project_id, location, example_store) {
(Some(project_id), Some(location), Some(example_store)) => {
Ok(Self::new(project_id, location, example_store))
}
(project_id, location, example_store) => {
let missing = [
(ENV_GOOGLE_CLOUD_PROJECT, project_id.is_none()),
(ENV_GOOGLE_CLOUD_LOCATION, location.is_none()),
(ENV_EXAMPLE_STORE_ID, example_store.is_none()),
]
.into_iter()
.filter_map(|(key, is_missing)| is_missing.then_some(key))
.collect::<Vec<_>>()
.join(", ");
Err(AdkError::new(
ErrorComponent::Tool,
ErrorCategory::InvalidInput,
"tool.example_store.missing_env",
format!(
"missing or blank environment variable(s): {missing}. Set them, or construct the config with ExampleStoreConfig::new",
),
)
.with_provider("vertex_ai"))
}
}
}
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
fn endpoint(&self) -> String {
let endpoint = self
.endpoint
.clone()
.unwrap_or_else(|| format!("https://{}-aiplatform.googleapis.com", self.location));
if endpoint.contains("://") { endpoint } else { format!("https://{endpoint}") }
}
fn store_path(&self) -> String {
if self.example_store.contains('/') {
self.example_store.clone()
} else {
format!(
"projects/{}/locations/{}/exampleStores/{}",
self.project_id, self.location, self.example_store,
)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Example {
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub example_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub create_time: Option<String>,
pub stored_contents_example: StoredContentsExample,
}
impl Example {
pub fn new(stored_contents_example: StoredContentsExample) -> Self {
Self { display_name: None, example_id: None, create_time: None, stored_contents_example }
}
#[must_use]
pub fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
self.display_name = Some(display_name.into());
self
}
#[must_use]
pub fn with_example_id(mut self, example_id: impl Into<String>) -> Self {
self.example_id = Some(example_id.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredContentsExample {
#[serde(skip_serializing_if = "Option::is_none")]
pub search_key: Option<String>,
pub contents_example: ContentsExample,
#[serde(skip_serializing_if = "Option::is_none")]
pub search_key_generation_method: Option<SearchKeyGenerationMethod>,
}
impl StoredContentsExample {
pub fn new(contents_example: ContentsExample) -> Self {
Self { search_key: None, contents_example, search_key_generation_method: None }
}
#[must_use]
pub fn with_search_key(mut self, search_key: impl Into<String>) -> Self {
self.search_key = Some(search_key.into());
self
}
#[must_use]
pub fn with_last_entry_search_key(mut self) -> Self {
self.search_key_generation_method = Some(SearchKeyGenerationMethod::last_entry());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentsExample {
pub contents: Vec<Content>,
pub expected_contents: Vec<ExpectedContent>,
}
impl ContentsExample {
pub fn new(contents: Vec<Content>, expected: Vec<Content>) -> Self {
Self {
contents,
expected_contents: expected
.into_iter()
.map(|content| ExpectedContent { content })
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExpectedContent {
pub content: Content,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchKeyGenerationMethod {
#[serde(skip_serializing_if = "Option::is_none")]
pub last_entry: Option<LastEntry>,
}
impl SearchKeyGenerationMethod {
pub fn last_entry() -> Self {
Self { last_entry: Some(LastEntry {}) }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LastEntry {}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpsertExamplesRequest {
pub examples: Vec<Example>,
#[serde(skip_serializing_if = "Option::is_none")]
pub overwrite: Option<bool>,
}
impl UpsertExamplesRequest {
pub fn new(examples: Vec<Example>) -> Self {
Self { examples, overwrite: None }
}
#[must_use]
pub fn with_overwrite(mut self, overwrite: bool) -> Self {
self.overwrite = Some(overwrite);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UpsertExamplesResponse {
#[serde(default)]
pub results: Vec<UpsertResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UpsertResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub example: Option<Example>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<RpcStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RpcStatus {
#[serde(default)]
pub code: i32,
#[serde(default)]
pub message: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub details: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchExamplesRequest {
#[serde(with = "int64_string")]
pub top_k: i64,
pub parameters: SearchExamplesParameters,
}
impl SearchExamplesRequest {
pub fn by_search_key(search_key: impl Into<String>, top_k: i64) -> Self {
Self {
top_k,
parameters: SearchExamplesParameters {
stored_contents_example_parameters: StoredContentsExampleParameters {
function_names: None,
search_key: Some(search_key.into()),
content_search_key: None,
},
},
}
}
pub fn by_contents(contents: Vec<Content>, top_k: i64) -> Self {
Self {
top_k,
parameters: SearchExamplesParameters {
stored_contents_example_parameters: StoredContentsExampleParameters {
function_names: None,
search_key: None,
content_search_key: Some(ContentSearchKey {
contents,
search_key_generation_method: SearchKeyGenerationMethod::last_entry(),
}),
},
},
}
}
#[must_use]
pub fn with_function_names(mut self, function_names: ExamplesArrayFilter) -> Self {
self.parameters.stored_contents_example_parameters.function_names = Some(function_names);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchExamplesParameters {
pub stored_contents_example_parameters: StoredContentsExampleParameters,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoredContentsExampleParameters {
#[serde(skip_serializing_if = "Option::is_none")]
pub function_names: Option<ExamplesArrayFilter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_search_key: Option<ContentSearchKey>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentSearchKey {
pub contents: Vec<Content>,
pub search_key_generation_method: SearchKeyGenerationMethod,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExamplesArrayFilter {
pub values: Vec<String>,
pub array_operator: ArrayOperator,
}
impl ExamplesArrayFilter {
pub fn contains_any(values: Vec<String>) -> Self {
Self { values, array_operator: ArrayOperator::ContainsAny }
}
pub fn contains_all(values: Vec<String>) -> Self {
Self { values, array_operator: ArrayOperator::ContainsAll }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ArrayOperator {
ArrayOperatorUnspecified,
ContainsAny,
ContainsAll,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SearchExamplesResponse {
#[serde(default)]
pub results: Vec<SearchExampleResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchExampleResult {
pub example: Example,
#[serde(skip_serializing_if = "Option::is_none")]
pub similarity_score: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct FetchExamplesRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub page_size: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_token: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub example_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stored_contents_example_filter: Option<StoredContentsExampleFilter>,
}
impl FetchExamplesRequest {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_page_size(mut self, page_size: i32) -> Self {
self.page_size = Some(page_size);
self
}
#[must_use]
pub fn with_page_token(mut self, page_token: impl Into<String>) -> Self {
self.page_token = Some(page_token.into());
self
}
#[must_use]
pub fn with_example_ids(mut self, example_ids: Vec<String>) -> Self {
self.example_ids = example_ids;
self
}
#[must_use]
pub fn with_filter(mut self, filter: StoredContentsExampleFilter) -> Self {
self.stored_contents_example_filter = Some(filter);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct StoredContentsExampleFilter {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub search_keys: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function_names: Option<ExamplesArrayFilter>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct FetchExamplesResponse {
#[serde(default)]
pub examples: Vec<Example>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_page_token: Option<String>,
}
mod int64_string {
use serde::de::{Deserializer, Error, Unexpected, Visitor};
use serde::ser::Serializer;
pub fn serialize<S: Serializer>(value: &i64, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&value.to_string())
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<i64, D::Error> {
struct Int64Visitor;
impl Visitor<'_> for Int64Visitor {
type Value = i64;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("an int64 as a decimal string or number")
}
fn visit_str<E: Error>(self, value: &str) -> Result<i64, E> {
value.parse().map_err(|_| Error::invalid_value(Unexpected::Str(value), &self))
}
fn visit_i64<E: Error>(self, value: i64) -> Result<i64, E> {
Ok(value)
}
fn visit_u64<E: Error>(self, value: u64) -> Result<i64, E> {
i64::try_from(value)
.map_err(|_| Error::invalid_value(Unexpected::Unsigned(value), &self))
}
}
deserializer.deserialize_any(Int64Visitor)
}
}
const GCP_ERROR_CODES: GcpErrorCodes = GcpErrorCodes {
invalid_input: "tool.example_store.invalid_input",
unauthorized: "tool.example_store.unauthorized",
forbidden: "tool.example_store.forbidden",
not_found: "tool.example_store.not_found",
rate_limited: "tool.example_store.rate_limited",
timeout: "tool.example_store.timeout",
unavailable: "tool.example_store.unavailable",
credentials_unavailable: "tool.example_store.credentials_unavailable",
invalid_response: "tool.example_store.internal",
invalid_request: "tool.example_store.invalid_request",
upstream_error: "tool.example_store.upstream_error",
operation_failed: "tool.example_store.operation_failed",
};
pub struct ExampleStoreClient {
client: GcpHttpClient,
store_path: String,
}
impl std::fmt::Debug for ExampleStoreClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExampleStoreClient")
.field("store_path", &self.store_path)
.finish_non_exhaustive()
}
}
impl ExampleStoreClient {
pub fn new_with_adc(config: ExampleStoreConfig) -> Result<Self> {
Self::build(config, None)
}
pub fn with_credentials(config: ExampleStoreConfig, credentials: Credentials) -> Result<Self> {
Self::build(config, Some(credentials))
}
fn build(config: ExampleStoreConfig, credentials: Option<Credentials>) -> Result<Self> {
let errors = GcpErrorContext::new(ErrorComponent::Tool, GCP_ERROR_CODES, "example store");
let mut builder = GcpHttpClient::builder(errors, config.endpoint())
.api_version(EXAMPLE_STORE_API_VERSION)
.connect_timeout(HTTP_CONNECT_TIMEOUT)
.request_timeout(HTTP_REQUEST_TIMEOUT)
.auth_timeout(AUTH_HEADERS_TIMEOUT);
if let Some(credentials) = credentials {
builder = builder.credentials(credentials);
}
Ok(Self { client: builder.build()?, store_path: config.store_path() })
}
pub fn store_resource_name(&self) -> &str {
&self.store_path
}
pub async fn upsert_examples(
&self,
request: UpsertExamplesRequest,
) -> Result<UpsertExamplesResponse> {
self.post_verb("upsertExamples", &request).await
}
pub async fn search_examples(
&self,
request: SearchExamplesRequest,
) -> Result<SearchExamplesResponse> {
self.post_verb("searchExamples", &request).await
}
pub async fn fetch_examples(
&self,
request: FetchExamplesRequest,
) -> Result<FetchExamplesResponse> {
self.post_verb("fetchExamples", &request).await
}
async fn post_verb<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
verb: &str,
body: &T,
) -> Result<R> {
tracing::debug!(example_store.verb = verb, "sending example store request");
let request = self
.client
.request(Method::POST, &format!("{}:{verb}", self.store_path))
.await?
.json(body);
let value = self.client.send_value(request).await?;
serde_json::from_value(value).map_err(|error| {
let error = truncate_for_error(&error.to_string());
self.client
.errors()
.invalid_response(format!("failed to parse example store response JSON: {error}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_top_k_serializes_as_a_string_and_accepts_both_forms() {
let request = SearchExamplesRequest::by_search_key("query", 5);
let value = serde_json::to_value(&request).unwrap();
assert_eq!(value["topK"], json!("5"));
let from_string: SearchExamplesRequest = serde_json::from_value(json!({
"topK": "7",
"parameters": { "storedContentsExampleParameters": { "searchKey": "q" } },
}))
.unwrap();
assert_eq!(from_string.top_k, 7);
let from_number: SearchExamplesRequest = serde_json::from_value(json!({
"topK": 7,
"parameters": { "storedContentsExampleParameters": { "searchKey": "q" } },
}))
.unwrap();
assert_eq!(from_number.top_k, 7);
}
#[test]
fn test_search_key_generation_method_serializes_last_entry_as_empty_object() {
let value = serde_json::to_value(SearchKeyGenerationMethod::last_entry()).unwrap();
assert_eq!(value, json!({ "lastEntry": {} }));
}
#[test]
fn test_config_resolves_bare_ids_and_full_resource_names() {
let bare = ExampleStoreConfig::new("p", "us-central1", "store-1");
assert_eq!(bare.store_path(), "projects/p/locations/us-central1/exampleStores/store-1");
let full = ExampleStoreConfig::new(
"p",
"us-central1",
"projects/other/locations/us-central1/exampleStores/store-2",
);
assert_eq!(full.store_path(), "projects/other/locations/us-central1/exampleStores/store-2");
}
#[tokio::test]
async fn test_endpoint_rejects_cleartext_and_decorated_origins() {
let credentials =
google_cloud_auth::credentials::api_key_credentials::Builder::new("k").build();
let config =
ExampleStoreConfig::new("p", "us-central1", "s").with_endpoint("http://example.com");
let error = ExampleStoreClient::with_credentials(config, credentials.clone()).unwrap_err();
assert!(error.message.contains("HTTPS"), "unexpected error: {}", error.message);
let config = ExampleStoreConfig::new("p", "us-central1", "s")
.with_endpoint("https://example.com/path");
let error = ExampleStoreClient::with_credentials(config, credentials).unwrap_err();
assert!(error.message.contains("origin"), "unexpected error: {}", error.message);
}
}