mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(embeddings): normalize Gemini model id on custom endpoint + classify 400 (#4184)
This commit is contained in:
@@ -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/<name>` 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/<name>`. 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/<name>` 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)
|
||||
|
||||
@@ -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/<name>` 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/<name>` 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/<name>`, 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/<name>` \
|
||||
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"))
|
||||
|
||||
@@ -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/<name>`; 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);
|
||||
|
||||
Reference in New Issue
Block a user