From c4418e4625a206e2e630d795cb9ac666ecbc6c8b Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:54:30 +0530 Subject: [PATCH] fix(embeddings): normalize Gemini model id on custom endpoint + classify 400 (#4184) --- src/core/observability.rs | 36 +++++++++- src/openhuman/embeddings/openai.rs | 83 +++++++++++++++++++++--- src/openhuman/embeddings/openai_tests.rs | 53 +++++++++++++++ 3 files changed, 163 insertions(+), 9 deletions(-) diff --git a/src/core/observability.rs b/src/core/observability.rs index f3b44bf98..caabdd624 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -814,7 +814,20 @@ pub(crate) fn is_embedding_endpoint_absent(lower: &str) -> bool { fn is_embedding_model_rejected(lower: &str) -> bool { lower.contains("embedding api error") && lower.contains("(400") - && (lower.contains("does not exist") || lower.contains("does not support embeddings")) + && (lower.contains("does not exist") + || lower.contains("does not support embeddings") + // Gemini's OpenAI-compat shim (generativelanguage.googleapis.com) + // maps `/v1/embeddings` → `BatchEmbedContents` and rejects a bare + // model id with `BatchEmbedContentsRequest.model: unexpected model + // name format` / `INVALID_ARGUMENT` on every re-embed (TAURI-RUST-4SA, + // 4,494 events / 1 user). The Custom-endpoint path now normalizes the + // id to `models/` at the source; this demotes any that slip + // through (already-stored bad state, older releases, other compat + // hosts) so the per-embed flood stays out of Sentry. Distinct cause + // from the #4070/9SK `"does not exist"` family. + || lower.contains("unexpected model name format") + || (lower.contains("invalid_argument") + && lower.contains("batchembedcontentsrequest.model"))) } /// Detect the memory-store chunk DB's circuit-breaker-open message that @@ -3173,6 +3186,27 @@ mod tests { } } + #[test] + fn classifies_gemini_model_format_400_as_config_rejection() { + // TAURI-RUST-4SA (~4,494 events / 1 user) — a bare model id sent to + // Gemini's OpenAI-compat shim, which maps `/v1/embeddings` → + // `BatchEmbedContents` and demands `models/`. Verbatim Gemini wire + // body plus the enriched form after the emit site appends the Gemini + // remediation, and the alternate `INVALID_ARGUMENT` + field-path shape. + // All must demote so the per-embed flood stays out of Sentry. + for raw in [ + r#"Embedding API error (400 Bad Request): {"error":{"code":400,"message":"BatchEmbedContentsRequest.model: unexpected model name format","status":"INVALID_ARGUMENT"}}"#, + "Embedding API error (400 Bad Request): {\"error\":{\"code\":400,\"message\":\"BatchEmbedContentsRequest.model: unexpected model name format\",\"status\":\"INVALID_ARGUMENT\"}} — Gemini needs the embeddings model id in `models/` form (e.g. `models/text-embedding-004`); fix it in Settings → Memory", + r#"Embedding API error (400 Bad Request): {"error":{"code":400,"message":"Invalid value at 'model'","status":"INVALID_ARGUMENT","details":[{"field":"BatchEmbedContentsRequest.model"}]}}"#, + ] { + assert_eq!( + expected_error_kind(raw), + Some(ExpectedErrorKind::ProviderConfigRejection), + "should classify Gemini model-format 400 as config rejection: {raw}" + ); + } + } + #[test] fn does_not_classify_unrelated_embedding_400s() { // Polarity: a 400 that is NOT a model-rejection (e.g. oversized input) diff --git a/src/openhuman/embeddings/openai.rs b/src/openhuman/embeddings/openai.rs index 60d91a89f..64e32319d 100644 --- a/src/openhuman/embeddings/openai.rs +++ b/src/openhuman/embeddings/openai.rs @@ -30,13 +30,69 @@ pub struct OpenAiEmbedding { requires_api_key: bool, } +/// True when `base_url` is Google Gemini's OpenAI-compatibility host. +/// +/// Gemini exposes an OpenAI-compatible shim at +/// `https://generativelanguage.googleapis.com/v1beta/openai/`. We match on the +/// host (any path / scheme) so it triggers whether the user pasted the +/// `/v1beta/openai` form, a bare host, or a future path variant. +fn is_gemini_base_url(base_url: &str) -> bool { + reqwest::Url::parse(base_url) + .ok() + .and_then(|u| u.host_str().map(str::to_ascii_lowercase)) + .is_some_and(|h| { + h == "generativelanguage.googleapis.com" + || h.ends_with(".generativelanguage.googleapis.com") + }) +} + +/// Normalize a model id for the configured base URL. +/// +/// Gemini's OpenAI-compat shim maps `POST /v1/embeddings` onto its native +/// `BatchEmbedContents` RPC, which requires the model in `models/` form +/// (e.g. `models/text-embedding-004`). A user who points the Custom +/// (OpenAI-compatible) embeddings provider at Gemini's compat base URL and +/// pastes a bare id (`text-embedding-004`) gets a `400 … BatchEmbedContents\ +/// Request.model: unexpected model name format` on every memory re-embed +/// (TAURI-RUST-4SA, 4,494 events / 1 user — non-fatal, so the pipeline retries +/// per document and floods Sentry). Prefix `models/` so the request is +/// well-formed before it leaves the process. Host-scoped (genuine OpenAI / +/// LocalAI / Ollama ids are untouched) and idempotent (an already-prefixed +/// `models/…` or a `tunedModels/…` id is left alone). +fn normalize_model_for_base_url(base_url: &str, model: &str) -> String { + if is_gemini_base_url(base_url) + && !model.is_empty() + && !model.starts_with("models/") + && !model.starts_with("tunedModels/") + { + format!("models/{model}") + } else { + model.to_string() + } +} + +/// True when a `400` body is Gemini's model-id format rejection (the OpenAI-side +/// surface of TAURI-RUST-4SA). Mirrors the wire phrases the +/// `is_embedding_model_rejected` classifier in `core::observability` keys on so +/// the remediation hint and the Sentry demotion never drift. +fn is_gemini_model_format_rejection(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + lower.contains("unexpected model name format") + || (lower.contains("invalid_argument") && lower.contains("batchembedcontentsrequest.model")) +} + impl OpenAiEmbedding { /// Creates a new OpenAI-style provider. pub fn new(base_url: &str, api_key: &str, model: &str, dims: usize) -> Self { + let base_url = base_url.trim_end_matches('/').to_string(); + // Repair a bare Gemini model id (`text-embedding-004`) into the + // `models/` form Gemini's OpenAI-compat shim requires, so the + // Custom-endpoint path doesn't 400 on every embed (TAURI-RUST-4SA). + let model = normalize_model_for_base_url(&base_url, model); Self { - base_url: base_url.trim_end_matches('/').to_string(), + base_url, api_key: api_key.to_string(), - model: model.to_string(), + model, dims, send_dimensions: false, requires_api_key: false, @@ -294,16 +350,27 @@ impl EmbeddingProvider for OpenAiEmbedding { embeddings-capable provider in Settings → Memory", ); } + // A 400 with Gemini's `… BatchEmbedContentsRequest.model: + // unexpected model name format` / `INVALID_ARGUMENT` body means + // the user pointed the Custom provider at Gemini's OpenAI-compat + // URL with a bare model id. The constructor now normalizes the id + // to `models/`, so this only fires for already-stored bad + // state or other compat hosts; append an actionable hint while + // PRESERVING the `(400` + body so the + // `observability::is_embedding_model_rejected` classifier still + // demotes the per-embed flood. TAURI-RUST-4SA. + else if status.as_u16() == 400 && is_gemini_model_format_rejection(&text) { + message.push_str( + " — Gemini needs the embeddings model id in `models/` \ + form (e.g. `models/text-embedding-004`); fix it in \ + Settings → Memory", + ); + } // A 400 "… does not exist" / "does not support embeddings" body // means the endpoint IS an embeddings API but the configured // model id is not an embeddings model — the user pasted a chat // model (e.g. an OpenRouter `…:free` id) into the embeddings - // model field. Append an actionable remediation while PRESERVING - // the `(400` + body text that `observability:: - // is_embedding_model_rejected` keys on, so the event is demoted - // from a per-embed Sentry flood to a breadcrumb. Scoped to the - // model-rejection body shape so a genuine 400 (oversized input, - // real server fault) still reaches Sentry. TAURI-RUST-9SK. + // model field. Same demotion contract as above. TAURI-RUST-9SK. else if status.as_u16() == 400 && (text.contains("does not exist") || text.contains("does not support embeddings")) diff --git a/src/openhuman/embeddings/openai_tests.rs b/src/openhuman/embeddings/openai_tests.rs index ba906ce2f..a1f393e61 100644 --- a/src/openhuman/embeddings/openai_tests.rs +++ b/src/openhuman/embeddings/openai_tests.rs @@ -40,6 +40,59 @@ fn accessors() { assert_eq!(p.signature(), "provider=openai;model=m;dims=1"); } +// ── Gemini model-id normalization (TAURI-RUST-4SA) ────── + +#[test] +fn gemini_base_url_prefixes_bare_model() { + // Gemini's OpenAI-compat shim requires `models/`; a bare id 400s with + // `BatchEmbedContentsRequest.model: unexpected model name format`. + let p = OpenAiEmbedding::new( + "https://generativelanguage.googleapis.com/v1beta/openai", + "key", + "text-embedding-004", + 768, + ); + assert_eq!(p.model(), "models/text-embedding-004"); + assert_eq!(p.model_id(), "models/text-embedding-004"); +} + +#[test] +fn gemini_normalization_is_idempotent() { + // An already-prefixed `models/…` or a `tunedModels/…` id is left untouched. + let p = OpenAiEmbedding::new( + "https://generativelanguage.googleapis.com/v1beta/openai/", + "key", + "models/text-embedding-004", + 768, + ); + assert_eq!(p.model(), "models/text-embedding-004"); + + let tuned = OpenAiEmbedding::new( + "https://generativelanguage.googleapis.com", + "key", + "tunedModels/my-embed", + 768, + ); + assert_eq!(tuned.model(), "tunedModels/my-embed"); +} + +#[test] +fn non_gemini_base_url_leaves_model_unchanged() { + // Genuine OpenAI / LocalAI / Ollama ids must not grow a `models/` prefix. + for base in [ + "https://api.openai.com", + "http://localhost:11434", + "https://api.example.com/v1", + ] { + let p = OpenAiEmbedding::new(base, "key", "text-embedding-004", 768); + assert_eq!( + p.model(), + "text-embedding-004", + "non-Gemini base must not prefix the model: {base}" + ); + } +} + #[test] fn url_standard_openai() { let p = OpenAiEmbedding::new("https://api.openai.com", "key", "model", 1536);