fix(embeddings): fail fast when a keyed provider's API key is empty (TAURI-RUST-4TZ) (#4066)

This commit is contained in:
Mega Mind
2026-06-24 10:23:24 -07:00
committed by GitHub
parent 5bce4f6140
commit f30fbbdca8
4 changed files with 91 additions and 3 deletions
+4 -2
View File
@@ -50,7 +50,8 @@ pub fn create_embedding_provider(
}
"openai" => Ok(Box::new(
OpenAiEmbedding::new("https://api.openai.com", "", model, dims)
.with_send_dimensions(model_supports_dimensions(model)),
.with_send_dimensions(model_supports_dimensions(model))
.with_required_api_key(true),
)),
"cohere" => Ok(Box::new(CohereEmbedding::new("", model, dims))),
name if name.starts_with("custom:") => {
@@ -91,7 +92,8 @@ pub fn create_embedding_provider_with_credentials(
}
"openai" => Ok(Box::new(
OpenAiEmbedding::new("https://api.openai.com", api_key, model, dims)
.with_send_dimensions(model_supports_dimensions(model)),
.with_send_dimensions(model_supports_dimensions(model))
.with_required_api_key(true),
)),
"cohere" => Ok(Box::new(CohereEmbedding::new(api_key, model, dims))),
"custom" => {
+50
View File
@@ -21,6 +21,13 @@ pub struct OpenAiEmbedding {
/// LocalAI/Ollama — keep working unchanged. Set via
/// [`Self::with_send_dimensions`] for the OpenAI / custom-OpenAI paths.
send_dimensions: bool,
/// When true, this provider points at a hosted cloud endpoint that always
/// requires a bearer token (genuine OpenAI `api.openai.com`, Voyage), so an
/// empty `api_key` must fail fast instead of POSTing an unauthenticated
/// request. Off by default so the OpenAI-compatible provider keeps serving
/// keyless local/custom endpoints (LocalAI, Ollama-via-OpenAI). Set via
/// [`Self::with_required_api_key`]. See the guard in [`Self::embed`].
requires_api_key: bool,
}
impl OpenAiEmbedding {
@@ -32,6 +39,7 @@ impl OpenAiEmbedding {
model: model.to_string(),
dims,
send_dimensions: false,
requires_api_key: false,
}
}
@@ -45,6 +53,17 @@ impl OpenAiEmbedding {
self
}
/// Mark this provider as a keyed cloud endpoint that must have an API key.
/// When set, [`Self::embed`] fails fast (before any HTTP round-trip) if the
/// resolved `api_key` is empty, instead of silently omitting the
/// `Authorization` header. Use for genuine OpenAI (`api.openai.com`) and
/// Voyage; leave off for keyless local/custom OpenAI-compatible endpoints.
/// Returns `self` for builder chaining.
pub fn with_required_api_key(mut self, required: bool) -> Self {
self.requires_api_key = required;
self
}
/// Returns the configured base URL.
pub fn base_url(&self) -> &str {
&self.base_url
@@ -143,6 +162,37 @@ impl EmbeddingProvider for OpenAiEmbedding {
);
}
// Fast-fail when this is a keyed cloud provider (OpenAI / Voyage) but the
// resolved key is empty. The key collapses to "" when the stored BYO
// credential can't be read — the OS-keychain consent is `none`/declined
// (`cached_consent=none`) or the cred fails to decrypt — because
// `resolve_api_key` swallows every such failure into "". Without this
// guard the request goes out with NO `Authorization` header at all and
// OpenAI 401s "You didn't provide an API key" on every embed; the memory
// pipeline re-embeds per document and floods Sentry (TAURI-RUST-4TZ:
// 3.9k events). Bailing here skips the wasted request, and the "API key
// not set" wording is demoted by the `ApiKeyMissing` classifier in
// `core::observability` to a single low-cardinality breadcrumb. The
// remediation surfaces the keychain-consent / re-enter-key path so the
// stored key can actually be read. Scoped via `requires_api_key`: the
// OpenAI-compatible provider legitimately supports keyless local/custom
// endpoints (LocalAI, Ollama-via-OpenAI), which keep omitting the header
// rather than bailing — mirroring the Cohere guard (TAURI-RUST-52S).
if self.requires_api_key && self.api_key.trim().is_empty() {
let message = format!(
"Embedding API key not set (model={}) — re-enter your key or grant \
keychain access in Settings → Memory",
self.model,
);
crate::core::observability::report_error_or_expected(
message.as_str(),
"embeddings",
"openai_embed",
&[("model", self.model.as_str()), ("failure", "missing_key")],
);
anyhow::bail!(message);
}
let url = self.embeddings_url();
tracing::debug!(
+35
View File
@@ -295,6 +295,41 @@ async fn embed_skips_auth_header_when_key_empty() {
p.embed(&["test"]).await.unwrap();
}
/// A keyed cloud provider (`with_required_api_key(true)` — genuine OpenAI /
/// Voyage) with an empty key must bail BEFORE any HTTP request rather than
/// POSTing with no `Authorization` header and 401-ing on every embed
/// (TAURI-RUST-4TZ). The base URL points at an address nothing is listening on,
/// so asserting the error is the key-guard message — not a connection error —
/// proves no request was attempted. The "API key not set" wording is what the
/// `ApiKeyMissing` classifier keys on to demote the flood out of Sentry.
#[tokio::test]
async fn embed_required_key_empty_bails_without_request() {
for key in ["", " "] {
let p = OpenAiEmbedding::new("http://127.0.0.1:1", key, "text-embedding-3-small", 1)
.with_required_api_key(true);
let err = p.embed(&["hello"]).await.unwrap_err().to_string();
assert!(
err.contains("API key not set"),
"expected key-guard message for key {key:?}, got: {err}"
);
}
}
/// The keyless local/custom path is unaffected: without
/// `with_required_api_key`, an empty key still omits the header and sends the
/// request (LocalAI / Ollama-via-OpenAI legitimately need no bearer).
#[tokio::test]
async fn embed_empty_key_without_requirement_still_sends() {
let app = Router::new().route(
"/v1/embeddings",
post(|| async { Json(serde_json::json!({ "data": [{ "embedding": [1.0] }] })) }),
);
let url = start_mock(app).await;
let p = OpenAiEmbedding::new(&url, "", "m", 1); // no with_required_api_key
let result = p.embed(&["test"]).await.unwrap();
assert_eq!(result.len(), 1);
}
// ── embed — error paths ─────────────────────────────────
#[tokio::test]
+2 -1
View File
@@ -28,7 +28,8 @@ impl VoyageEmbedding {
let dims = if dims == 0 { VOYAGE_DEFAULT_DIMS } else { dims };
Self {
inner: OpenAiEmbedding::new(VOYAGE_API_BASE, api_key, model, dims),
inner: OpenAiEmbedding::new(VOYAGE_API_BASE, api_key, model, dims)
.with_required_api_key(true),
}
}