mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(embeddings): classify 403 'not an embeddings model' as model-incompatible + redact API key from errors (#5116) (#5117)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 الخاص بك مشفرًا على هذا الجهاز.',
|
||||
|
||||
@@ -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 কী এই ডিভাইসে এনক্রিপ্ট করে সংরক্ষিত আছে।',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 키는 이 기기에 암호화되어 저장됩니다.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user