fix(embeddings): classify 403 'not an embeddings model' as model-incompatible + redact API key from errors (#5116) (#5117)

This commit is contained in:
Mega Mind
2026-07-22 14:48:36 +03:00
committed by GitHub
parent 9e312b65ae
commit 353e07c117
16 changed files with 131 additions and 11 deletions
@@ -666,7 +666,7 @@ const EmbeddingsPanel = ({ embedded = false }: EmbeddingsPanelProps = {}) => {
</div>
<div>
<label className="block text-[11px] font-medium text-content-secondary mb-1">
{t('settings.embeddings.apiKeyLabel').replace('{provider}', 'API')} (
{t('settings.embeddings.apiKeyLabelGeneric')} (
{t('settings.embeddings.optional')})
</label>
<SettingsTextField
+1
View File
@@ -1714,6 +1714,7 @@ const messages: TranslationMap = {
'سجّل الدخول مرة أخرى لتحديث جلسة OpenHuman، أو انتقل إلى مزوّد تضمينات محلي أو بمفتاحك الخاص.',
'settings.embeddings.signInAgain': 'تسجيل الدخول مرة أخرى',
'settings.embeddings.apiKeyLabel': 'مفتاح API لـ {provider}',
'settings.embeddings.apiKeyLabelGeneric': 'مفتاح API',
'settings.embeddings.placeholderStored': '•••••••• (مخزن)',
'settings.embeddings.placeholderKey': 'الصق مفتاح API الخاص بك…',
'settings.embeddings.keyStoredEncrypted': 'يتم تخزين مفتاح API الخاص بك مشفرًا على هذا الجهاز.',
+1
View File
@@ -1750,6 +1750,7 @@ const messages: TranslationMap = {
'OpenHuman সেশন রিফ্রেশ করতে আবার সাইন ইন করুন, অথবা স্থানীয়/নিজস্ব-কী এমবেডিং প্রোভাইডারে যান।',
'settings.embeddings.signInAgain': 'আবার সাইন ইন করুন',
'settings.embeddings.apiKeyLabel': '{provider} API কী',
'settings.embeddings.apiKeyLabelGeneric': 'API কী',
'settings.embeddings.placeholderStored': '•••••••• (সঞ্চিত)',
'settings.embeddings.placeholderKey': 'আপনার API কী পেস্ট করুন…',
'settings.embeddings.keyStoredEncrypted': 'আপনার API কী এই ডিভাইসে এনক্রিপ্ট করে সংরক্ষিত আছে।',
+1
View File
@@ -1812,6 +1812,7 @@ const messages: TranslationMap = {
'Melde dich erneut an, um deine OpenHuman-Sitzung zu aktualisieren, oder wechsle zu einem lokalen oder eigenen Einbettungsanbieter.',
'settings.embeddings.signInAgain': 'Erneut anmelden',
'settings.embeddings.apiKeyLabel': '{provider} API-Schlüssel',
'settings.embeddings.apiKeyLabelGeneric': 'API-Schlüssel',
'settings.embeddings.placeholderStored': '•••••••• (gespeichert)',
'settings.embeddings.placeholderKey': 'API-Schlüssel einfügen…',
'settings.embeddings.keyStoredEncrypted':
+1
View File
@@ -1977,6 +1977,7 @@ const en: TranslationMap = {
'Sign in again to refresh your OpenHuman session, or switch to a local or bring-your-own embeddings provider.',
'settings.embeddings.signInAgain': 'Sign in again',
'settings.embeddings.apiKeyLabel': '{provider} API key',
'settings.embeddings.apiKeyLabelGeneric': 'API key',
'settings.embeddings.placeholderStored': '•••••••• (stored)',
'settings.embeddings.placeholderKey': 'Paste your API key…',
'settings.embeddings.keyStoredEncrypted': 'Your API key is stored encrypted on this device.',
+1
View File
@@ -1790,6 +1790,7 @@ const messages: TranslationMap = {
'Vuelve a iniciar sesión para actualizar tu sesión de OpenHuman, o cambia a un proveedor de embeddings local o con tu propia clave.',
'settings.embeddings.signInAgain': 'Volver a iniciar sesión',
'settings.embeddings.apiKeyLabel': 'Clave API de {provider}',
'settings.embeddings.apiKeyLabelGeneric': 'Clave de API',
'settings.embeddings.placeholderStored': '•••••••• (almacenado)',
'settings.embeddings.placeholderKey': 'Pega tu clave API…',
'settings.embeddings.keyStoredEncrypted': 'Tu clave API se almacena cifrada en este dispositivo.',
+1
View File
@@ -1805,6 +1805,7 @@ const messages: TranslationMap = {
'Reconnectez-vous pour actualiser votre session OpenHuman, ou passez à un fournisseur local ou avec votre propre clé.',
'settings.embeddings.signInAgain': 'Se reconnecter',
'settings.embeddings.apiKeyLabel': 'Clé API {provider}',
'settings.embeddings.apiKeyLabelGeneric': 'Clé API',
'settings.embeddings.placeholderStored': '•••••••• (stocké)',
'settings.embeddings.placeholderKey': 'Collez votre clé API…',
'settings.embeddings.keyStoredEncrypted':
+1
View File
@@ -1748,6 +1748,7 @@ const messages: TranslationMap = {
'अपने OpenHuman सत्र को रीफ़्रेश करने के लिए फिर से साइन इन करें, या स्थानीय/अपनी-कुंजी वाले एम्बेडिंग प्रदाता पर स्विच करें।',
'settings.embeddings.signInAgain': 'फिर से साइन इन करें',
'settings.embeddings.apiKeyLabel': '{provider} API कुंजी',
'settings.embeddings.apiKeyLabelGeneric': 'API कुंजी',
'settings.embeddings.placeholderStored': '•••••••• (संग्रहीत)',
'settings.embeddings.placeholderKey': 'अपनी API कुंजी पेस्ट करें…',
'settings.embeddings.keyStoredEncrypted':
+1
View File
@@ -1765,6 +1765,7 @@ const messages: TranslationMap = {
'Masuk lagi untuk menyegarkan sesi OpenHuman Anda, atau beralih ke penyedia embedding lokal atau bawa-kunci-sendiri.',
'settings.embeddings.signInAgain': 'Masuk lagi',
'settings.embeddings.apiKeyLabel': 'Kunci API {provider}',
'settings.embeddings.apiKeyLabelGeneric': 'Kunci API',
'settings.embeddings.placeholderStored': '•••••••• (disimpan)',
'settings.embeddings.placeholderKey': 'Tempel kunci API Anda…',
'settings.embeddings.keyStoredEncrypted': 'Kunci API Anda disimpan terenkripsi di perangkat ini.',
+1
View File
@@ -1790,6 +1790,7 @@ const messages: TranslationMap = {
'Accedi di nuovo per aggiornare la sessione OpenHuman, oppure passa a un provider di embedding locale o con chiave personale.',
'settings.embeddings.signInAgain': 'Accedi di nuovo',
'settings.embeddings.apiKeyLabel': 'Chiave API {provider}',
'settings.embeddings.apiKeyLabelGeneric': 'Chiave API',
'settings.embeddings.placeholderStored': '•••••••• (memorizzato)',
'settings.embeddings.placeholderKey': 'Incolla la tua chiave API…',
'settings.embeddings.keyStoredEncrypted':
+1
View File
@@ -1739,6 +1739,7 @@ const messages: TranslationMap = {
'OpenHuman 세션을 새로 고치려면 다시 로그인하거나, 로컬 또는 자체 키 임베딩 공급자로 전환하세요.',
'settings.embeddings.signInAgain': '다시 로그인',
'settings.embeddings.apiKeyLabel': '{provider} API 키',
'settings.embeddings.apiKeyLabelGeneric': 'API 키',
'settings.embeddings.placeholderStored': '•••••••(저장됨)',
'settings.embeddings.placeholderKey': 'API 키를 붙여넣으세요…',
'settings.embeddings.keyStoredEncrypted': 'API 키는 이 기기에 암호화되어 저장됩니다.',
+1
View File
@@ -1781,6 +1781,7 @@ const messages: TranslationMap = {
'Zaloguj się ponownie, aby odświeżyć sesję OpenHuman, albo przełącz się na lokalnego dostawcę osadzań lub dostawcę z własnym kluczem.',
'settings.embeddings.signInAgain': 'Zaloguj się ponownie',
'settings.embeddings.apiKeyLabel': 'Klucz API {provider}',
'settings.embeddings.apiKeyLabelGeneric': 'Klucz API',
'settings.embeddings.placeholderStored': '•••••••• (zapisane)',
'settings.embeddings.placeholderKey': 'Wklej swój klucz API…',
'settings.embeddings.keyStoredEncrypted': 'Twój klucz API jest zaszyfrowany na tym urządzeniu.',
+1
View File
@@ -1788,6 +1788,7 @@ const messages: TranslationMap = {
'Faça login novamente para atualizar sua sessão OpenHuman, ou mude para um provedor de embeddings local ou com sua própria chave.',
'settings.embeddings.signInAgain': 'Entrar novamente',
'settings.embeddings.apiKeyLabel': 'Chave API {provider}',
'settings.embeddings.apiKeyLabelGeneric': 'Chave de API',
'settings.embeddings.placeholderStored': '•••••••• (armazenado)',
'settings.embeddings.placeholderKey': 'Cole sua chave API…',
'settings.embeddings.keyStoredEncrypted':
+1
View File
@@ -1771,6 +1771,7 @@ const messages: TranslationMap = {
'Войдите снова, чтобы обновить сеанс OpenHuman, или переключитесь на локального провайдера эмбеддингов либо провайдера с собственным ключом.',
'settings.embeddings.signInAgain': 'Войти снова',
'settings.embeddings.apiKeyLabel': 'API-ключ {provider}',
'settings.embeddings.apiKeyLabelGeneric': 'API-ключ',
'settings.embeddings.placeholderStored': '•••••••• (сохранено)',
'settings.embeddings.placeholderKey': 'Вставьте API-ключ…',
'settings.embeddings.keyStoredEncrypted':
+1
View File
@@ -1656,6 +1656,7 @@ const messages: TranslationMap = {
'重新登录以刷新 OpenHuman 会话,或切换到本地/自带密钥的嵌入提供商。',
'settings.embeddings.signInAgain': '重新登录',
'settings.embeddings.apiKeyLabel': '{provider} API 密钥',
'settings.embeddings.apiKeyLabelGeneric': 'API 密钥',
'settings.embeddings.placeholderStored': '••••••••(已存储)',
'settings.embeddings.placeholderKey': '粘贴您的 API 密钥…',
'settings.embeddings.keyStoredEncrypted': '您的 API 密钥已加密存储在此设备上。',
+116 -10
View File
@@ -653,7 +653,13 @@ fn classify_embed_probe(outcome: EmbedProbe) -> Option<RpcOutcome<serde_json::Va
let reject = |error: &str, message: &str, summary: &str, detail: Option<&str>| {
let mut body = serde_json::json!({ "error": error, "message": message });
if let Some(d) = detail {
body["detail"] = serde_json::Value::String(d.to_string());
// The probe detail is the raw endpoint response body. It can carry the
// API key (OpenAI's 401 echoes `Incorrect API key provided: sk-…`), and
// the frontend appends `detail` to the surfaced message — so redact any
// key/bearer material before it ever leaves the core, for both the UI
// and logs (#5116). The clean classified `message` is the primary text;
// the sanitized detail only adds a self-diagnosis hint.
body["detail"] = serde_json::Value::String(redact_secrets(d));
}
Some(RpcOutcome::new(body, vec![summary.to_string()]))
};
@@ -792,19 +798,52 @@ fn embed_error_mentions_status(lower: &str, code: u16) -> bool {
}
/// A reachable, authenticated embeddings API that **rejected the model id** — the
/// user pointed the embeddings model field at a chat/reasoning model. Gated on a
/// 400/422 plus a model-rejection phrase so a genuine 5xx or oversized-input 400
/// still falls through to the generic failure (issue #5017).
/// user pointed the embeddings model field at a chat/reasoning model.
///
/// Two tiers of phrasing:
///
/// - **Strong, status-independent phrasings** unambiguously name a model that
/// can't embed. OpenAI returns *HTTP 403* "You are not allowed to generate
/// embeddings from this model" when a chat model (e.g. `gpt-4o-mini`) is used
/// as the embeddings model — a MODEL problem, not an auth problem. Because
/// `classify_embed_probe` checks this **before** the 401/403 auth branch, that
/// 403 must be caught here or it falls through and misreports "enter a valid
/// key" (issue #5116). None of these phrases appear in a genuine auth rejection
/// (`Incorrect API key provided …`), so matching them ahead of auth is safe.
/// - **Weak phrasings** (a stray "does not exist" / odd model-name format) are
/// only unambiguous alongside a 400/422 bad-request, so a genuine 5xx or an
/// oversized-input 400 still falls through to the generic failure (issue #5017).
fn is_embedding_model_incompatible(lower: &str) -> bool {
let strong_model_rejection = lower.contains("not allowed to generate embeddings")
|| lower.contains("does not support embeddings")
|| lower.contains("not an embedding model")
|| lower.contains("is not an embedding")
|| lower.contains("not supported for embeddings")
|| (lower.contains("unsupported") && lower.contains("embedding"));
if strong_model_rejection {
return true;
}
let bad_request =
embed_error_mentions_status(lower, 400) || embed_error_mentions_status(lower, 422);
bad_request
&& (lower.contains("does not support embeddings")
|| lower.contains("not an embedding model")
|| lower.contains("is not an embedding")
|| lower.contains("does not exist")
|| lower.contains("not supported for embeddings")
|| lower.contains("unexpected model name format"))
&& (lower.contains("does not exist") || lower.contains("unexpected model name format"))
}
/// Strip API-key / bearer-token material from any text before it reaches the UI
/// or logs. Matches OpenAI-style keys (`sk-…`, including the modern `sk-proj-…`
/// form with embedded hyphens/underscores) and `Bearer <token>` headers, and
/// replaces each **whole** match — the replacements deliberately contain no `sk-`
/// substring, so not even a key *prefix* can surface (#5116).
fn redact_secrets(input: &str) -> String {
use once_cell::sync::Lazy;
use regex::Regex;
static SK_KEY_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\bsk-[A-Za-z0-9_-]+").unwrap());
static BEARER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+").unwrap());
let redacted = SK_KEY_RE.replace_all(input, "[redacted-key]");
BEARER_RE
.replace_all(&redacted, "Bearer [redacted]")
.into_owned()
}
/// The post-response length guard fired: the endpoint embedded but returned a
@@ -1224,6 +1263,73 @@ mod tests {
}
}
/// Issue #5116 — a **chat** model used as an embeddings model. OpenAI answers
/// *HTTP 403* "You are not allowed to generate embeddings from this model".
/// Before the fix this fell through to the 401/403 auth branch and told the
/// user to "enter a valid key" even though the key was fine — the model is the
/// problem. It must classify as MODEL_INCOMPATIBLE, ahead of the auth branch.
#[test]
fn classify_embed_probe_403_not_an_embeddings_model_is_model_incompatible_not_auth() {
for detail in [
r#"openai embeddings returned HTTP 403 Forbidden: {"error":{"message":"You are not allowed to generate embeddings from this model","type":"invalid_request_error","param":null,"code":null}}"#,
r#"Embedding API error (403 Forbidden): {"error":{"message":"This is not an embedding model"}}"#,
r#"openai embeddings returned HTTP 403 Forbidden: {"error":{"message":"unsupported model for embedding"}}"#,
] {
assert_eq!(
reject_code(EmbedProbe::Failed(detail.into())).as_deref(),
Some("EMBEDDINGS_MODEL_INCOMPATIBLE"),
"403 model-rejection must be model-incompatible, not auth: {detail}"
);
}
}
/// Issue #5116 (security) — a genuine bad key (401 "Incorrect API key
/// provided: sk-…") must STILL classify as auth, but the surfaced payload must
/// never carry the key: neither the message nor the redacted detail may
/// contain an `sk-` substring.
#[test]
fn classify_embed_probe_401_bad_key_is_auth_and_redacts_key() {
let detail = r#"openai embeddings returned HTTP 401 Unauthorized: {"error":{"message":"Incorrect API key provided: sk-proj-ABC123def456GHI789jkl012MNO. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}"#;
let rpc = classify_embed_probe(EmbedProbe::Failed(detail.into()))
.expect("bad key must reject the save");
assert_eq!(
rpc.value.get("error").and_then(|v| v.as_str()),
Some("EMBEDDINGS_AUTH_FAILED"),
"a genuine 401 bad key must stay classified as auth"
);
// Nothing in the surfaced payload may leak the key.
let surfaced = serde_json::to_string(&rpc.value).unwrap();
assert!(
!surfaced.contains("sk-"),
"surfaced payload must not contain any sk- key material: {surfaced}"
);
assert!(
rpc.value
.get("detail")
.and_then(|v| v.as_str())
.map(|d| d.contains("[redacted-key]"))
.unwrap_or(false),
"the detail should keep a redaction marker for support diagnosis"
);
}
/// The redaction helper strips whole OpenAI-style keys (incl. the modern
/// `sk-proj-…` form) and bearer tokens, leaving no `sk-` prefix behind.
#[test]
fn redact_secrets_removes_key_and_bearer_material() {
let redacted =
redact_secrets("key sk-proj-ABC123_def-456 and Authorization: Bearer tok-xyz.789");
assert!(
!redacted.contains("sk-"),
"no sk- prefix survives: {redacted}"
);
assert!(!redacted.contains("tok-xyz.789"), "bearer token stripped");
assert!(redacted.contains("[redacted-key]"));
assert!(redacted.contains("Bearer [redacted]"));
// Non-secret text is preserved.
assert!(redacted.contains("Authorization:"));
}
/// Issue #5017 — a transport-level failure (DNS / refused connection) is a
/// reachability problem, distinct from a server that answered. Timeouts fall
/// in the same bucket.