From e6da6e51ef05f46efe9b5d877befa5f5a0e2f113 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Mon, 23 Feb 2026 22:11:23 +0100 Subject: [PATCH 1/6] feat: support ext. api keys in header --- src/external_api_keys.rs | 31 +++++++++++++ src/lib.rs | 1 + src/qdrant_client/config.rs | 87 +++++++++++++++++++++++++++++++++++++ src/qdrant_client/mod.rs | 11 +++++ src/qdrant_client/points.rs | 1 + 5 files changed, 131 insertions(+) create mode 100644 src/external_api_keys.rs diff --git a/src/external_api_keys.rs b/src/external_api_keys.rs new file mode 100644 index 0000000..87faa2a --- /dev/null +++ b/src/external_api_keys.rs @@ -0,0 +1,31 @@ +use std::collections::HashMap; + +use tonic::metadata::{MetadataKey, MetadataValue}; +use tonic::service::Interceptor; +use tonic::{Request, Status}; + +pub struct ExternalApiKeysInterceptor { + external_api_keys: Option>, +} + +impl ExternalApiKeysInterceptor { + pub fn new(external_api_keys: Option>) -> Self { + Self { external_api_keys } + } +} + +impl Interceptor for ExternalApiKeysInterceptor { + fn call(&mut self, mut request: Request<()>) -> anyhow::Result, Status> { + if let Some(ext_api_keys) = &self.external_api_keys { + for (k, v) in ext_api_keys { + let key = MetadataKey::from_bytes(k.as_bytes()) + .map_err(|_| Status::invalid_argument(format!("Invalid metadata key: {k}")))?; + let value = MetadataValue::try_from(v.as_str()).map_err(|_| { + Status::invalid_argument(format!("Invalid metadata value for {k}: {v}")) + })?; + request.metadata_mut().insert(key, value); + } + } + Ok(request) + } +} diff --git a/src/lib.rs b/src/lib.rs index b0cc2de..faa9834 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -138,6 +138,7 @@ mod builder_types; mod builders; mod channel_pool; mod expressions; +mod external_api_keys; mod filters; mod grpc_conversions; mod grpc_macros; diff --git a/src/qdrant_client/config.rs b/src/qdrant_client/config.rs index f68467d..7d48a2a 100644 --- a/src/qdrant_client/config.rs +++ b/src/qdrant_client/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::time::Duration; use crate::{Qdrant, QdrantError}; @@ -33,6 +34,9 @@ pub struct QdrantConfig { /// Optional API key or token to use for authorization pub api_key: Option, + /// Optional API keys for external embedding providers (OpenAI, JINA, Cohere, OpenRouter) + pub external_api_keys: Option>, + /// Optional compression schema to use for API requests pub compression: Option, @@ -113,6 +117,22 @@ impl QdrantConfig { self } + /// Set an optional map of external API keys for embedding providers (OpenAI, JINA, Cohere, OpenRouter) + /// + /// # Examples + /// ```rust,no_run + ///# use std::collections::HashMap; + ///# let config: HashMap<&str, String> = HashMap::new(); + ///# use qdrant_client::Qdrant; + /// let client = Qdrant::from_url("http://localhost:6334") + /// .external_api_keys(config.get("external_api_keys")) + /// .build(); + /// ``` + pub fn external_api_keys(mut self, external_api_keys: impl AsOptionExternalApiKeys) -> Self { + self.external_api_keys = external_api_keys.external_api_keys(); + self + } + /// Keep the connection alive while idle pub fn keep_alive_while_idle(mut self) -> Self { self.keep_alive_while_idle = true; @@ -225,6 +245,7 @@ impl Default for QdrantConfig { connect_timeout: Duration::from_secs(5), keep_alive_while_idle: true, api_key: None, + external_api_keys: None, compression: None, check_compatibility: true, pool_size: 3, @@ -328,3 +349,69 @@ impl AsOptionApiKey for Result { self.ok() } } + +/// Set an optional API key from various types +/// +/// For example: +/// +/// ```rust +///# use std::time::Duration; +///# use qdrant_client::Qdrant; +///# let mut config = Qdrant::from_url("http://localhost:6334"); +/// config +/// .external_api_keys(("openai-api-key", "")) +/// .external_api_keys((String::from("openai-api-key"), String::from(""))) +/// .external_api_keys((String::from("openai-api-key").unwrap(), std::env::var("OPENAI_API_KEY").unwrap())); +/// ``` +/// +/// /// ```rust +///# use std::time::Duration; +///# use qdrant_client::Qdrant; +///# let mut config = Qdrant::from_url("http://localhost:6334"); +///# let ext_api_keys = HashMap::from([("openai-api-key", ""), ("cohere-api-key", "")]) +/// config +/// .external_api_keys(ext_api_keys); +/// ``` +pub trait AsOptionExternalApiKeys { + fn external_api_keys(self) -> Option>; +} + +impl AsOptionExternalApiKeys for (K, V) +where + K: Into, + V: Into, +{ + fn external_api_keys(self) -> Option> { + let (k, v) = self; + Some(HashMap::from([(k.into(), v.into())])) + } +} + +impl AsOptionExternalApiKeys for Option<(K, V)> +where + K: Into, + V: Into, +{ + fn external_api_keys(self) -> Option> { + let (k, v) = self?; + Some(HashMap::from([(k.into(), v.into())])) + } +} + +impl AsOptionExternalApiKeys for Option> { + fn external_api_keys(self) -> Option> { + self + } +} + +impl AsOptionExternalApiKeys for HashMap { + fn external_api_keys(self) -> Option> { + Some(self) + } +} + +impl AsOptionExternalApiKeys for Result, E> { + fn external_api_keys(self) -> Option> { + self.ok() + } +} diff --git a/src/qdrant_client/mod.rs b/src/qdrant_client/mod.rs index ae131be..f471edc 100644 --- a/src/qdrant_client/mod.rs +++ b/src/qdrant_client/mod.rs @@ -25,6 +25,8 @@ use crate::channel_pool::ChannelPool; use crate::qdrant::{qdrant_client, HealthCheckReply, HealthCheckRequest}; use crate::qdrant_client::config::QdrantConfig; use crate::qdrant_client::version_check::is_compatible; +use crate::auth::TokenInterceptor; +use crate::external_api_keys::ExternalApiKeysInterceptor; use crate::QdrantError; /// [`Qdrant`] client result @@ -187,6 +189,15 @@ impl Qdrant { InterceptedService::new(channel, interceptor) } + /// Wraps a service with external API keys interceptor + fn with_external_api_keys( + &self, + service: S, + ) -> InterceptedService { + let interceptor = ExternalApiKeysInterceptor::new(self.config.external_api_keys.clone()); + InterceptedService::new(service, interceptor) + } + // Access to raw root qdrant API async fn with_root_qdrant_client>>( &self, diff --git a/src/qdrant_client/points.rs b/src/qdrant_client/points.rs index 914fcf0..a11583e 100644 --- a/src/qdrant_client/points.rs +++ b/src/qdrant_client/points.rs @@ -29,6 +29,7 @@ impl Qdrant { .with_channel( |channel| { let service = self.with_api_key(channel); + let service = self.with_external_api_keys(service); let mut client = PointsClient::new(service).max_decoding_message_size(usize::MAX); if let Some(compression) = self.config.compression { From c08bf538d9be75296adeb5ccd9e1be91e0672782 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Tue, 24 Feb 2026 10:20:32 +0100 Subject: [PATCH 2/6] feat: add tests --- src/external_api_keys.rs | 75 ++++++ tests/snippet_tests/mod.rs | 3 +- tests/snippet_tests/test_external_api_keys.rs | 247 ++++++++++++++++++ 3 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 tests/snippet_tests/test_external_api_keys.rs diff --git a/src/external_api_keys.rs b/src/external_api_keys.rs index 87faa2a..30252de 100644 --- a/src/external_api_keys.rs +++ b/src/external_api_keys.rs @@ -29,3 +29,78 @@ impl Interceptor for ExternalApiKeysInterceptor { Ok(request) } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tonic::service::Interceptor; + use tonic::Request; + + use super::ExternalApiKeysInterceptor; + + #[test] + fn inserts_external_api_keys_into_metadata_headers() { + let api_keys = HashMap::from([ + ("openai-api-key".to_string(), "openai-secret".to_string()), + ("cohere-api-key".to_string(), "cohere-secret".to_string()), + ]); + + let mut interceptor = ExternalApiKeysInterceptor::new(Some(api_keys)); + let request = interceptor + .call(Request::new(())) + .expect("interceptor must accept valid external API keys"); + + let openai = request + .metadata() + .get("openai-api-key") + .expect("missing openai-api-key header") + .to_str() + .expect("openai-api-key header must be valid ASCII"); + let cohere = request + .metadata() + .get("cohere-api-key") + .expect("missing cohere-api-key header") + .to_str() + .expect("cohere-api-key header must be valid ASCII"); + + assert_eq!(openai, "openai-secret"); + assert_eq!(cohere, "cohere-secret"); + } + + #[test] + fn keeps_request_unchanged_when_external_keys_are_missing() { + let mut interceptor = ExternalApiKeysInterceptor::new(None); + let request = interceptor + .call(Request::new(())) + .expect("interceptor must accept empty external API key config"); + + assert!(request.metadata().is_empty()); + } + + #[test] + fn returns_invalid_argument_for_invalid_metadata_key() { + let api_keys = HashMap::from([("openai api key".to_string(), "secret".to_string())]); + + let mut interceptor = ExternalApiKeysInterceptor::new(Some(api_keys)); + let error = interceptor + .call(Request::new(())) + .expect_err("interceptor must reject invalid metadata keys"); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("Invalid metadata key")); + } + + #[test] + fn returns_invalid_argument_for_invalid_metadata_value() { + let api_keys = HashMap::from([("openai-api-key".to_string(), "bad\nkey".to_string())]); + + let mut interceptor = ExternalApiKeysInterceptor::new(Some(api_keys)); + let error = interceptor + .call(Request::new(())) + .expect_err("interceptor must reject invalid metadata values"); + + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("Invalid metadata value")); + } +} diff --git a/tests/snippet_tests/mod.rs b/tests/snippet_tests/mod.rs index decfd24..b33ed5e 100644 --- a/tests/snippet_tests/mod.rs +++ b/tests/snippet_tests/mod.rs @@ -20,6 +20,7 @@ mod test_delete_snapshot; mod test_delete_vectors; mod test_discover_batch_points; mod test_discover_points; +mod test_external_api_keys; mod test_facets; mod test_get_collection; mod test_get_collection_aliases; @@ -56,4 +57,4 @@ mod test_upsert_image; mod test_upsert_points; mod test_upsert_points_fallback_shard_key; mod test_upsert_points_insert_only; -mod test_upsert_points_with_condition; \ No newline at end of file +mod test_upsert_points_with_condition; diff --git a/tests/snippet_tests/test_external_api_keys.rs b/tests/snippet_tests/test_external_api_keys.rs new file mode 100644 index 0000000..9a8157b --- /dev/null +++ b/tests/snippet_tests/test_external_api_keys.rs @@ -0,0 +1,247 @@ +use std::collections::HashMap; + +use qdrant_client::qdrant::{ + CreateCollectionBuilder, Distance, Document, PointStruct, Query, QueryPointsBuilder, + UpsertPointsBuilder, VectorParamsBuilder, +}; +use qdrant_client::{Payload, Qdrant}; +use serde_json::json; + +const PROXY_URL: &str = "http://localhost:6334"; +const UPSERT_COLLECTION_NAME: &str = "test_external_api_keys_upsert"; +const QUERY_COLLECTION_NAME: &str = "test_external_api_keys_query"; +const DUAL_OPENAI_COLLECTION_NAME: &str = "test_external_api_keys_dual_openai"; +const DUAL_COHERE_COLLECTION_NAME: &str = "test_external_api_keys_dual_cohere"; +const OPENAI_MODEL: &str = "openai/text-embedding-3-small"; +const OPENAI_VECTOR_SIZE: u64 = 1536; +const COHERE_MODEL: &str = "cohere/embed-english-v3.0"; +const COHERE_VECTOR_SIZE: u64 = 1024; + +fn create_client_with_external_keys(external_api_keys: HashMap) -> Qdrant { + Qdrant::from_url(PROXY_URL) + .skip_compatibility_check() + .api_key("1234") + .external_api_keys(external_api_keys) + .timeout(30u64) + .build() + .expect("Failed to build client") +} + +async fn setup_collection(client: &Qdrant, collection_name: &str, vector_size: u64) { + let _ = client.delete_collection(collection_name).await; + + client + .create_collection( + CreateCollectionBuilder::new(collection_name) + .vectors_config(VectorParamsBuilder::new(vector_size, Distance::Cosine)), + ) + .await + .expect("Failed to create collection"); +} + +fn cohere_document(text: impl Into, input_type: &'static str) -> Document { + Document { + text: text.into(), + model: COHERE_MODEL.to_string(), + options: HashMap::from([("input_type".to_string(), input_type.into())]), + } +} + +#[tokio::test] +async fn test_upsert_with_external_api_keys() { + let Some(openai_api_key) = std::env::var("OPENAI_API_KEY").ok() else { + eprintln!("Skipping test_upsert_with_external_api_keys: OPENAI_API_KEY is not set"); + return; + }; + let collection_name = UPSERT_COLLECTION_NAME; + let client = create_client_with_external_keys(HashMap::from([( + "openai-api-key".to_string(), + openai_api_key, + )])); + setup_collection(&client, collection_name, OPENAI_VECTOR_SIZE).await; + + let doc = Document::new("Qdrant is a vector search engine", OPENAI_MODEL); + + let result = client + .upsert_points( + UpsertPointsBuilder::new( + collection_name, + vec![PointStruct::new( + 1, + doc, + Payload::try_from(json!({"source": "test"})).unwrap(), + )], + ) + .wait(true), + ) + .await; + + assert!( + result.is_ok(), + "Upsert with external API keys failed: {result:?}" + ); + + let _ = client.delete_collection(collection_name).await; +} + +#[tokio::test] +async fn test_query_with_external_api_keys() { + let Some(openai_api_key) = std::env::var("OPENAI_API_KEY").ok() else { + eprintln!("Skipping test_query_with_external_api_keys: OPENAI_API_KEY is not set"); + return; + }; + let collection_name = QUERY_COLLECTION_NAME; + let client = create_client_with_external_keys(HashMap::from([( + "openai-api-key".to_string(), + openai_api_key, + )])); + setup_collection(&client, collection_name, OPENAI_VECTOR_SIZE).await; + + // Upsert a point first + let doc = Document::new("Qdrant is a vector search engine", OPENAI_MODEL); + client + .upsert_points( + UpsertPointsBuilder::new( + collection_name, + vec![PointStruct::new( + 1, + doc, + Payload::try_from(json!({"source": "test"})).unwrap(), + )], + ) + .wait(true), + ) + .await + .expect("Upsert failed"); + + // Query with a document (server-side inference) + let query_doc = Document::new("vector database", OPENAI_MODEL); + + let result = client + .query( + QueryPointsBuilder::new(collection_name) + .query(Query::new_nearest(query_doc)) + .limit(1) + .with_payload(true), + ) + .await; + + assert!( + result.is_ok(), + "Query with external API keys failed: {result:?}" + ); + + let response = result.unwrap(); + assert_eq!(response.result.len(), 1); + assert!(response.result[0].payload.contains_key("source")); + + let _ = client.delete_collection(collection_name).await; +} + +#[tokio::test] +async fn test_query_with_two_external_api_providers() { + let Some(openai_api_key) = std::env::var("OPENAI_API_KEY").ok() else { + eprintln!("Skipping test_query_with_two_external_api_providers: OPENAI_API_KEY is not set"); + return; + }; + let Some(cohere_api_key) = std::env::var("COHERE_API_KEY").ok() else { + eprintln!("Skipping test_query_with_two_external_api_providers: COHERE_API_KEY is not set"); + return; + }; + + let client = create_client_with_external_keys(HashMap::from([ + ("openai-api-key".to_string(), openai_api_key), + ("cohere-api-key".to_string(), cohere_api_key), + ])); + + setup_collection(&client, DUAL_OPENAI_COLLECTION_NAME, OPENAI_VECTOR_SIZE).await; + setup_collection(&client, DUAL_COHERE_COLLECTION_NAME, COHERE_VECTOR_SIZE).await; + + let openai_doc = Document::new("OpenAI provider document", OPENAI_MODEL); + let cohere_doc = cohere_document("Cohere provider document", "search_document"); + + let openai_upsert = client + .upsert_points( + UpsertPointsBuilder::new( + DUAL_OPENAI_COLLECTION_NAME, + vec![PointStruct::new( + 1, + openai_doc, + Payload::try_from(json!({"provider": "openai"})).unwrap(), + )], + ) + .wait(true), + ) + .await; + assert!( + openai_upsert.is_ok(), + "OpenAI upsert with external API keys failed: {openai_upsert:?}" + ); + + let cohere_upsert = client + .upsert_points( + UpsertPointsBuilder::new( + DUAL_COHERE_COLLECTION_NAME, + vec![PointStruct::new( + 1, + cohere_doc, + Payload::try_from(json!({"provider": "cohere"})).unwrap(), + )], + ) + .wait(true), + ) + .await; + assert!( + cohere_upsert.is_ok(), + "Cohere upsert with external API keys failed: {cohere_upsert:?}" + ); + + let openai_query = client + .query( + QueryPointsBuilder::new(DUAL_OPENAI_COLLECTION_NAME) + .query(Query::new_nearest(Document::new( + "OpenAI provider query", + OPENAI_MODEL, + ))) + .limit(1) + .with_payload(true), + ) + .await; + assert!( + openai_query.is_ok(), + "OpenAI query with external API keys failed: {openai_query:?}" + ); + + let cohere_query = client + .query( + QueryPointsBuilder::new(DUAL_COHERE_COLLECTION_NAME) + .query(Query::new_nearest(cohere_document( + "Cohere provider query", + "search_query", + ))) + .limit(1) + .with_payload(true), + ) + .await; + assert!( + cohere_query.is_ok(), + "Cohere query with external API keys failed: {cohere_query:?}" + ); + + let openai_response = openai_query.unwrap(); + assert_eq!(openai_response.result.len(), 1); + assert_eq!( + openai_response.result[0].payload["provider"], + "openai".into() + ); + + let cohere_response = cohere_query.unwrap(); + assert_eq!(cohere_response.result.len(), 1); + assert_eq!( + cohere_response.result[0].payload["provider"], + "cohere".into() + ); + + let _ = client.delete_collection(DUAL_OPENAI_COLLECTION_NAME).await; + let _ = client.delete_collection(DUAL_COHERE_COLLECTION_NAME).await; +} From b98a91bc68c5221bc8fd400e4186853258fbeafd Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Tue, 24 Feb 2026 10:37:17 +0100 Subject: [PATCH 3/6] fix: linter issues --- src/qdrant_client/mod.rs | 3 +-- tests/snippet_tests/mod.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/qdrant_client/mod.rs b/src/qdrant_client/mod.rs index f471edc..3b9f875 100644 --- a/src/qdrant_client/mod.rs +++ b/src/qdrant_client/mod.rs @@ -22,11 +22,10 @@ use tonic::Status; use crate::auth::MetadataInterceptor; use crate::channel_pool::ChannelPool; +use crate::external_api_keys::ExternalApiKeysInterceptor; use crate::qdrant::{qdrant_client, HealthCheckReply, HealthCheckRequest}; use crate::qdrant_client::config::QdrantConfig; use crate::qdrant_client::version_check::is_compatible; -use crate::auth::TokenInterceptor; -use crate::external_api_keys::ExternalApiKeysInterceptor; use crate::QdrantError; /// [`Qdrant`] client result diff --git a/tests/snippet_tests/mod.rs b/tests/snippet_tests/mod.rs index b33ed5e..decfd24 100644 --- a/tests/snippet_tests/mod.rs +++ b/tests/snippet_tests/mod.rs @@ -20,7 +20,6 @@ mod test_delete_snapshot; mod test_delete_vectors; mod test_discover_batch_points; mod test_discover_points; -mod test_external_api_keys; mod test_facets; mod test_get_collection; mod test_get_collection_aliases; @@ -57,4 +56,4 @@ mod test_upsert_image; mod test_upsert_points; mod test_upsert_points_fallback_shard_key; mod test_upsert_points_insert_only; -mod test_upsert_points_with_condition; +mod test_upsert_points_with_condition; \ No newline at end of file From 6f31ab83ccaa7206932286c93a039e774f4fe0f0 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Tue, 24 Feb 2026 11:34:35 +0100 Subject: [PATCH 4/6] fix: rust docs --- src/external_api_keys.rs | 28 ++++++++++++++++++++++++++++ src/qdrant_client/config.rs | 22 ++++++++++++++-------- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/external_api_keys.rs b/src/external_api_keys.rs index 30252de..d6cb0fc 100644 --- a/src/external_api_keys.rs +++ b/src/external_api_keys.rs @@ -18,6 +18,10 @@ impl Interceptor for ExternalApiKeysInterceptor { fn call(&mut self, mut request: Request<()>) -> anyhow::Result, Status> { if let Some(ext_api_keys) = &self.external_api_keys { for (k, v) in ext_api_keys { + // Treat empty values as "missing key" (e.g. absent env var), so requests can proceed. + if v.trim().is_empty() { + continue; + } let key = MetadataKey::from_bytes(k.as_bytes()) .map_err(|_| Status::invalid_argument(format!("Invalid metadata key: {k}")))?; let value = MetadataValue::try_from(v.as_str()).map_err(|_| { @@ -103,4 +107,28 @@ mod tests { assert_eq!(error.code(), tonic::Code::InvalidArgument); assert!(error.message().contains("Invalid metadata value")); } + + #[test] + fn skips_empty_external_api_key_values() { + let api_keys = HashMap::from([ + ("openai-api-key".to_string(), "".to_string()), + ("cohere-api-key".to_string(), "cohere-secret".to_string()), + ]); + + let mut interceptor = ExternalApiKeysInterceptor::new(Some(api_keys)); + let request = interceptor + .call(Request::new(())) + .expect("interceptor must ignore empty external API key values"); + + assert!(request.metadata().get("openai-api-key").is_none()); + assert_eq!( + request + .metadata() + .get("cohere-api-key") + .expect("cohere-api-key header must exist") + .to_str() + .expect("cohere-api-key header must be valid ASCII"), + "cohere-secret" + ); + } } diff --git a/src/qdrant_client/config.rs b/src/qdrant_client/config.rs index 7d48a2a..9ccc753 100644 --- a/src/qdrant_client/config.rs +++ b/src/qdrant_client/config.rs @@ -122,10 +122,14 @@ impl QdrantConfig { /// # Examples /// ```rust,no_run ///# use std::collections::HashMap; - ///# let config: HashMap<&str, String> = HashMap::new(); + ///# let mut config: HashMap<&str, HashMap> = HashMap::new(); + ///# config.insert( + ///# "external_api_keys", + ///# HashMap::from([("openai-api-key".to_string(), "".to_string())]), + ///# ); ///# use qdrant_client::Qdrant; /// let client = Qdrant::from_url("http://localhost:6334") - /// .external_api_keys(config.get("external_api_keys")) + /// .external_api_keys(config.get("external_api_keys").cloned()) /// .build(); /// ``` pub fn external_api_keys(mut self, external_api_keys: impl AsOptionExternalApiKeys) -> Self { @@ -350,25 +354,27 @@ impl AsOptionApiKey for Result { } } -/// Set an optional API key from various types +/// Set optional external API keys from various types /// /// For example: /// /// ```rust -///# use std::time::Duration; ///# use qdrant_client::Qdrant; ///# let mut config = Qdrant::from_url("http://localhost:6334"); /// config /// .external_api_keys(("openai-api-key", "")) /// .external_api_keys((String::from("openai-api-key"), String::from(""))) -/// .external_api_keys((String::from("openai-api-key").unwrap(), std::env::var("OPENAI_API_KEY").unwrap())); +/// .external_api_keys((String::from("openai-api-key"), "".to_string())); /// ``` /// -/// /// ```rust -///# use std::time::Duration; +/// ```rust +///# use std::collections::HashMap; ///# use qdrant_client::Qdrant; ///# let mut config = Qdrant::from_url("http://localhost:6334"); -///# let ext_api_keys = HashMap::from([("openai-api-key", ""), ("cohere-api-key", "")]) +///# let ext_api_keys = HashMap::from([ +///# ("openai-api-key".to_string(), "".to_string()), +///# ("cohere-api-key".to_string(), "".to_string()), +///# ]); /// config /// .external_api_keys(ext_api_keys); /// ``` From 5a223a0a70a5e0bee66ccddc5fa06d70c63779d5 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Fri, 27 Feb 2026 09:13:11 +0100 Subject: [PATCH 5/6] fix: tests for custom header --- src/external_api_keys.rs | 134 ------------------ src/lib.rs | 1 - src/qdrant_client/config.rs | 92 ------------ src/qdrant_client/mod.rs | 10 -- src/qdrant_client/points.rs | 1 - tests/snippet_tests/mod.rs | 3 +- tests/snippet_tests/test_external_api_keys.rs | 11 +- 7 files changed, 8 insertions(+), 244 deletions(-) delete mode 100644 src/external_api_keys.rs diff --git a/src/external_api_keys.rs b/src/external_api_keys.rs deleted file mode 100644 index d6cb0fc..0000000 --- a/src/external_api_keys.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::collections::HashMap; - -use tonic::metadata::{MetadataKey, MetadataValue}; -use tonic::service::Interceptor; -use tonic::{Request, Status}; - -pub struct ExternalApiKeysInterceptor { - external_api_keys: Option>, -} - -impl ExternalApiKeysInterceptor { - pub fn new(external_api_keys: Option>) -> Self { - Self { external_api_keys } - } -} - -impl Interceptor for ExternalApiKeysInterceptor { - fn call(&mut self, mut request: Request<()>) -> anyhow::Result, Status> { - if let Some(ext_api_keys) = &self.external_api_keys { - for (k, v) in ext_api_keys { - // Treat empty values as "missing key" (e.g. absent env var), so requests can proceed. - if v.trim().is_empty() { - continue; - } - let key = MetadataKey::from_bytes(k.as_bytes()) - .map_err(|_| Status::invalid_argument(format!("Invalid metadata key: {k}")))?; - let value = MetadataValue::try_from(v.as_str()).map_err(|_| { - Status::invalid_argument(format!("Invalid metadata value for {k}: {v}")) - })?; - request.metadata_mut().insert(key, value); - } - } - Ok(request) - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use tonic::service::Interceptor; - use tonic::Request; - - use super::ExternalApiKeysInterceptor; - - #[test] - fn inserts_external_api_keys_into_metadata_headers() { - let api_keys = HashMap::from([ - ("openai-api-key".to_string(), "openai-secret".to_string()), - ("cohere-api-key".to_string(), "cohere-secret".to_string()), - ]); - - let mut interceptor = ExternalApiKeysInterceptor::new(Some(api_keys)); - let request = interceptor - .call(Request::new(())) - .expect("interceptor must accept valid external API keys"); - - let openai = request - .metadata() - .get("openai-api-key") - .expect("missing openai-api-key header") - .to_str() - .expect("openai-api-key header must be valid ASCII"); - let cohere = request - .metadata() - .get("cohere-api-key") - .expect("missing cohere-api-key header") - .to_str() - .expect("cohere-api-key header must be valid ASCII"); - - assert_eq!(openai, "openai-secret"); - assert_eq!(cohere, "cohere-secret"); - } - - #[test] - fn keeps_request_unchanged_when_external_keys_are_missing() { - let mut interceptor = ExternalApiKeysInterceptor::new(None); - let request = interceptor - .call(Request::new(())) - .expect("interceptor must accept empty external API key config"); - - assert!(request.metadata().is_empty()); - } - - #[test] - fn returns_invalid_argument_for_invalid_metadata_key() { - let api_keys = HashMap::from([("openai api key".to_string(), "secret".to_string())]); - - let mut interceptor = ExternalApiKeysInterceptor::new(Some(api_keys)); - let error = interceptor - .call(Request::new(())) - .expect_err("interceptor must reject invalid metadata keys"); - - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(error.message().contains("Invalid metadata key")); - } - - #[test] - fn returns_invalid_argument_for_invalid_metadata_value() { - let api_keys = HashMap::from([("openai-api-key".to_string(), "bad\nkey".to_string())]); - - let mut interceptor = ExternalApiKeysInterceptor::new(Some(api_keys)); - let error = interceptor - .call(Request::new(())) - .expect_err("interceptor must reject invalid metadata values"); - - assert_eq!(error.code(), tonic::Code::InvalidArgument); - assert!(error.message().contains("Invalid metadata value")); - } - - #[test] - fn skips_empty_external_api_key_values() { - let api_keys = HashMap::from([ - ("openai-api-key".to_string(), "".to_string()), - ("cohere-api-key".to_string(), "cohere-secret".to_string()), - ]); - - let mut interceptor = ExternalApiKeysInterceptor::new(Some(api_keys)); - let request = interceptor - .call(Request::new(())) - .expect("interceptor must ignore empty external API key values"); - - assert!(request.metadata().get("openai-api-key").is_none()); - assert_eq!( - request - .metadata() - .get("cohere-api-key") - .expect("cohere-api-key header must exist") - .to_str() - .expect("cohere-api-key header must be valid ASCII"), - "cohere-secret" - ); - } -} diff --git a/src/lib.rs b/src/lib.rs index faa9834..b0cc2de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -138,7 +138,6 @@ mod builder_types; mod builders; mod channel_pool; mod expressions; -mod external_api_keys; mod filters; mod grpc_conversions; mod grpc_macros; diff --git a/src/qdrant_client/config.rs b/src/qdrant_client/config.rs index 9ccc753..1fa5fd3 100644 --- a/src/qdrant_client/config.rs +++ b/src/qdrant_client/config.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::time::Duration; use crate::{Qdrant, QdrantError}; @@ -34,9 +33,6 @@ pub struct QdrantConfig { /// Optional API key or token to use for authorization pub api_key: Option, - /// Optional API keys for external embedding providers (OpenAI, JINA, Cohere, OpenRouter) - pub external_api_keys: Option>, - /// Optional compression schema to use for API requests pub compression: Option, @@ -117,26 +113,6 @@ impl QdrantConfig { self } - /// Set an optional map of external API keys for embedding providers (OpenAI, JINA, Cohere, OpenRouter) - /// - /// # Examples - /// ```rust,no_run - ///# use std::collections::HashMap; - ///# let mut config: HashMap<&str, HashMap> = HashMap::new(); - ///# config.insert( - ///# "external_api_keys", - ///# HashMap::from([("openai-api-key".to_string(), "".to_string())]), - ///# ); - ///# use qdrant_client::Qdrant; - /// let client = Qdrant::from_url("http://localhost:6334") - /// .external_api_keys(config.get("external_api_keys").cloned()) - /// .build(); - /// ``` - pub fn external_api_keys(mut self, external_api_keys: impl AsOptionExternalApiKeys) -> Self { - self.external_api_keys = external_api_keys.external_api_keys(); - self - } - /// Keep the connection alive while idle pub fn keep_alive_while_idle(mut self) -> Self { self.keep_alive_while_idle = true; @@ -249,7 +225,6 @@ impl Default for QdrantConfig { connect_timeout: Duration::from_secs(5), keep_alive_while_idle: true, api_key: None, - external_api_keys: None, compression: None, check_compatibility: true, pool_size: 3, @@ -354,70 +329,3 @@ impl AsOptionApiKey for Result { } } -/// Set optional external API keys from various types -/// -/// For example: -/// -/// ```rust -///# use qdrant_client::Qdrant; -///# let mut config = Qdrant::from_url("http://localhost:6334"); -/// config -/// .external_api_keys(("openai-api-key", "")) -/// .external_api_keys((String::from("openai-api-key"), String::from(""))) -/// .external_api_keys((String::from("openai-api-key"), "".to_string())); -/// ``` -/// -/// ```rust -///# use std::collections::HashMap; -///# use qdrant_client::Qdrant; -///# let mut config = Qdrant::from_url("http://localhost:6334"); -///# let ext_api_keys = HashMap::from([ -///# ("openai-api-key".to_string(), "".to_string()), -///# ("cohere-api-key".to_string(), "".to_string()), -///# ]); -/// config -/// .external_api_keys(ext_api_keys); -/// ``` -pub trait AsOptionExternalApiKeys { - fn external_api_keys(self) -> Option>; -} - -impl AsOptionExternalApiKeys for (K, V) -where - K: Into, - V: Into, -{ - fn external_api_keys(self) -> Option> { - let (k, v) = self; - Some(HashMap::from([(k.into(), v.into())])) - } -} - -impl AsOptionExternalApiKeys for Option<(K, V)> -where - K: Into, - V: Into, -{ - fn external_api_keys(self) -> Option> { - let (k, v) = self?; - Some(HashMap::from([(k.into(), v.into())])) - } -} - -impl AsOptionExternalApiKeys for Option> { - fn external_api_keys(self) -> Option> { - self - } -} - -impl AsOptionExternalApiKeys for HashMap { - fn external_api_keys(self) -> Option> { - Some(self) - } -} - -impl AsOptionExternalApiKeys for Result, E> { - fn external_api_keys(self) -> Option> { - self.ok() - } -} diff --git a/src/qdrant_client/mod.rs b/src/qdrant_client/mod.rs index 3b9f875..ae131be 100644 --- a/src/qdrant_client/mod.rs +++ b/src/qdrant_client/mod.rs @@ -22,7 +22,6 @@ use tonic::Status; use crate::auth::MetadataInterceptor; use crate::channel_pool::ChannelPool; -use crate::external_api_keys::ExternalApiKeysInterceptor; use crate::qdrant::{qdrant_client, HealthCheckReply, HealthCheckRequest}; use crate::qdrant_client::config::QdrantConfig; use crate::qdrant_client::version_check::is_compatible; @@ -188,15 +187,6 @@ impl Qdrant { InterceptedService::new(channel, interceptor) } - /// Wraps a service with external API keys interceptor - fn with_external_api_keys( - &self, - service: S, - ) -> InterceptedService { - let interceptor = ExternalApiKeysInterceptor::new(self.config.external_api_keys.clone()); - InterceptedService::new(service, interceptor) - } - // Access to raw root qdrant API async fn with_root_qdrant_client>>( &self, diff --git a/src/qdrant_client/points.rs b/src/qdrant_client/points.rs index a11583e..914fcf0 100644 --- a/src/qdrant_client/points.rs +++ b/src/qdrant_client/points.rs @@ -29,7 +29,6 @@ impl Qdrant { .with_channel( |channel| { let service = self.with_api_key(channel); - let service = self.with_external_api_keys(service); let mut client = PointsClient::new(service).max_decoding_message_size(usize::MAX); if let Some(compression) = self.config.compression { diff --git a/tests/snippet_tests/mod.rs b/tests/snippet_tests/mod.rs index decfd24..b33ed5e 100644 --- a/tests/snippet_tests/mod.rs +++ b/tests/snippet_tests/mod.rs @@ -20,6 +20,7 @@ mod test_delete_snapshot; mod test_delete_vectors; mod test_discover_batch_points; mod test_discover_points; +mod test_external_api_keys; mod test_facets; mod test_get_collection; mod test_get_collection_aliases; @@ -56,4 +57,4 @@ mod test_upsert_image; mod test_upsert_points; mod test_upsert_points_fallback_shard_key; mod test_upsert_points_insert_only; -mod test_upsert_points_with_condition; \ No newline at end of file +mod test_upsert_points_with_condition; diff --git a/tests/snippet_tests/test_external_api_keys.rs b/tests/snippet_tests/test_external_api_keys.rs index 9a8157b..ebb4473 100644 --- a/tests/snippet_tests/test_external_api_keys.rs +++ b/tests/snippet_tests/test_external_api_keys.rs @@ -18,13 +18,14 @@ const COHERE_MODEL: &str = "cohere/embed-english-v3.0"; const COHERE_VECTOR_SIZE: u64 = 1024; fn create_client_with_external_keys(external_api_keys: HashMap) -> Qdrant { - Qdrant::from_url(PROXY_URL) + let mut builder = Qdrant::from_url(PROXY_URL) .skip_compatibility_check() .api_key("1234") - .external_api_keys(external_api_keys) - .timeout(30u64) - .build() - .expect("Failed to build client") + .timeout(30u64); + for (key, value) in external_api_keys { + builder = builder.header(key, value); + } + builder.build().expect("Failed to build client") } async fn setup_collection(client: &Qdrant, collection_name: &str, vector_size: u64) { From 6f0685ab752521ffa7b761602007daa8ff40aceb Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Fri, 27 Feb 2026 09:14:57 +0100 Subject: [PATCH 6/6] fix: linter issues --- src/qdrant_client/config.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/qdrant_client/config.rs b/src/qdrant_client/config.rs index 1fa5fd3..f68467d 100644 --- a/src/qdrant_client/config.rs +++ b/src/qdrant_client/config.rs @@ -328,4 +328,3 @@ impl AsOptionApiKey for Result { self.ok() } } -