mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
fix(inference): explain background-role provider fallback and verify BYOK keys (#5146 §2.1, §2.4) (#5254)
Co-authored-by: M3gA-Mind <elvin@mahadao.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
type ProviderRef as ApiProviderRef,
|
||||
clearCloudProviderKey,
|
||||
type CloudProviderView,
|
||||
describeProviderVerificationFailure,
|
||||
flushCloudProviders,
|
||||
importOpenAiCodexCliAuth,
|
||||
listProviderModels,
|
||||
@@ -2152,7 +2153,17 @@ const CustomRoutingDialog = ({
|
||||
setTestReply(result.reply);
|
||||
} catch (err) {
|
||||
if (testRequestIdRef.current !== requestId) return;
|
||||
setTestError(err instanceof Error ? err.message : String(err));
|
||||
// #5146 §2.4: a raw upstream string ("401", "model_not_found", a bare
|
||||
// 404) tells the user nothing about what to change. Map the common
|
||||
// shapes onto a concrete next step; unrecognised errors pass through.
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
// The banner copy is deliberately generic (a provider error can echo
|
||||
// request material), so keep the raw text on the console where it is
|
||||
// still reachable for diagnosis.
|
||||
console.error(`[ai-settings][test] provider test failed workload=${workload.id}`, raw);
|
||||
// The bare slug, not `currentProviderString` — that is the composite
|
||||
// `provider:model[@temp]` and would read as "'openai:gpt-4o' rejected it".
|
||||
setTestError(describeProviderVerificationFailure(registrySlug ?? '', raw, t));
|
||||
} finally {
|
||||
if (testRequestIdRef.current === requestId) {
|
||||
setTestBusy(false);
|
||||
|
||||
@@ -53,6 +53,12 @@ vi.mock('../../../../services/api/aiSettingsApi', () => ({
|
||||
loadLocalProviderSnapshot: vi.fn(),
|
||||
loadProviderAuthErrors: vi.fn().mockResolvedValue([]),
|
||||
testProviderModel: vi.fn(),
|
||||
// #5146 §2.4: AIPanel no longer renders the raw provider string — it maps
|
||||
// the failure onto actionable copy first. Mirror that shape here so the
|
||||
// banner asserted below is what a user actually sees; the mapping itself is
|
||||
// covered in aiSettingsApi.test.ts.
|
||||
describeProviderVerificationFailure: (slug: string, _raw: string) =>
|
||||
`The key was saved, but '${slug}' rejected it. Check that you pasted the whole key.`,
|
||||
modelRegistryVision: vi.fn(() => false),
|
||||
upsertModelRegistryVision: vi.fn((registry: unknown[]) => registry),
|
||||
setCloudProviderKey: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -1440,7 +1446,12 @@ describe('AIPanel', () => {
|
||||
const dialog = await screen.findByRole('dialog', { name: /Custom routing/i });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Test$/i }));
|
||||
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('401 invalid api key');
|
||||
// The banner carries the actionable message keyed to the provider slug,
|
||||
// not the raw upstream string (which can echo request material).
|
||||
const alert = await within(dialog).findByRole('alert');
|
||||
expect(alert).toHaveTextContent('rejected it');
|
||||
expect(alert).toHaveTextContent('openai');
|
||||
expect(alert).not.toHaveTextContent('401 invalid api key');
|
||||
});
|
||||
|
||||
it('renders background loop diagnostics with newest spend row and budget math', async () => {
|
||||
|
||||
@@ -4632,6 +4632,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'فعّل هذا إذا كان النموذج يقبل الصور. يتيح لمربع الدردشة إرفاق الصور عند اختيار هذا النموذج.',
|
||||
'settings.ai.testFailed': 'فشل الاختبار',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"تم حفظ المفتاح، لكن '{slug}' رفضه. تأكد من لصق المفتاح كاملاً ومن أنه ما زال نشطاً في لوحة تحكم المزوّد.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"تم حفظ المفتاح وقبوله، لكن '{slug}' لا يتعرف على النموذج المحدد. اختر معرّف نموذج يقدمه هذا المزوّد فعلياً (يُضبط نموذجه الافتراضي في إدخال المزوّد).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"تم حفظ المفتاح وقبوله، لكن '{slug}' رفض الطلب لأسباب تتعلق بالحصة أو الفوترة. تحقق من رصيد حسابك وحدود الاستخدام لدى المزوّد.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"تم حفظ المفتاح، لكن نقطة النهاية المهيأة لـ '{slug}' أعادت 404. تحقق من عنوان URL الأساسي: عادةً ما يحتاج المزوّد المتوافق مع OpenAI إلى اللاحقة '/v1' (مثل https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"تم حفظ المفتاح، لكن '{slug}' لم يستجب في الوقت المحدد. تحقق من عنوان نقطة النهاية ومن شبكتك ثم أعد الاختبار.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"تم حفظ المفتاح، لكن '{slug}' أعاد رداً فارغاً على رسالة اختبار. تحقق من معرّف النموذج المهيأ لهذا المزوّد.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"تم حفظ المفتاح، لكن فشل استدعاء اختباري إلى '{slug}'. تحقق من صفحة حالة المزوّد ومن عنوان نقطة النهاية ثم أعد الاختبار.",
|
||||
'settings.ai.testingModel': 'نموذج الاختبار...',
|
||||
'settings.ai.modelResponse': 'استجابة النموذج',
|
||||
'settings.ai.providerWithValue': 'الموفر: {value}',
|
||||
|
||||
@@ -4746,6 +4746,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'এই মডেল ছবি গ্রহণ করলে সক্ষম করুন। এই মডেল নির্বাচিত থাকলে চ্যাটে ছবি সংযুক্ত করতে দেয়।',
|
||||
'settings.ai.testFailed': 'পরীক্ষা ব্যর্থ হয়েছে',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"কী সংরক্ষণ করা হয়েছে, কিন্তু '{slug}' সেটি প্রত্যাখ্যান করেছে। দেখুন আপনি পুরো কী পেস্ট করেছেন কি না এবং সেটি প্রদানকারীর ড্যাশবোর্ডে এখনও সক্রিয় কি না।",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"কী সংরক্ষণ ও গৃহীত হয়েছে, কিন্তু '{slug}' নির্বাচিত মডেলটি চিনতে পারছে না। এই প্রদানকারী সত্যিই দেয় এমন একটি মডেল আইডি বেছে নিন (এর ডিফল্ট মডেল প্রদানকারীর এন্ট্রিতে সেট করা হয়)।",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"কী সংরক্ষণ ও গৃহীত হয়েছে, কিন্তু '{slug}' কোটা বা বিলিংয়ের কারণে অনুরোধটি প্রত্যাখ্যান করেছে। প্রদানকারীর কাছে আপনার অ্যাকাউন্ট ব্যালেন্স ও ব্যবহারের সীমা দেখুন।",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"কী সংরক্ষণ করা হয়েছে, কিন্তু '{slug}' এর জন্য কনফিগার করা এন্ডপয়েন্ট 404 ফেরত দিয়েছে। বেস URL দেখুন: OpenAI-সঙ্গতিপূর্ণ প্রদানকারীর সাধারণত '/v1' প্রত্যয় প্রয়োজন (যেমন https://api.openai.com/v1)।",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"কী সংরক্ষণ করা হয়েছে, কিন্তু '{slug}' সময়মতো সাড়া দেয়নি। এন্ডপয়েন্ট URL ও আপনার নেটওয়ার্ক দেখে আবার পরীক্ষা করুন।",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"কী সংরক্ষণ করা হয়েছে, কিন্তু '{slug}' পরীক্ষামূলক প্রম্পটে খালি উত্তর ফেরত দিয়েছে। এই প্রদানকারীর জন্য কনফিগার করা মডেল আইডি দেখুন।",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"কী সংরক্ষণ করা হয়েছে, কিন্তু '{slug}' এ পরীক্ষামূলক কল ব্যর্থ হয়েছে। প্রদানকারীর স্ট্যাটাস পৃষ্ঠা ও এন্ডপয়েন্ট URL দেখে আবার পরীক্ষা করুন।",
|
||||
'settings.ai.testingModel': 'পরীক্ষার মডেল...',
|
||||
'settings.ai.modelResponse': 'মডেল প্রতিক্রিয়া',
|
||||
'settings.ai.providerWithValue': 'প্রদানকারী: {value}',
|
||||
|
||||
@@ -4880,6 +4880,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Aktivieren, wenn dieses Modell Bilder akzeptiert. Ermöglicht das Anhängen von Bildern im Chat, wenn dieses Modell ausgewählt ist.',
|
||||
'settings.ai.testFailed': 'Test fehlgeschlagen.',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"Der Schlüssel wurde gespeichert, aber '{slug}' hat ihn abgelehnt. Prüfen Sie, ob Sie den vollständigen Schlüssel eingefügt haben und ob er im Dashboard des Anbieters noch aktiv ist.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"Der Schlüssel wurde gespeichert und akzeptiert, aber '{slug}' kennt das ausgewählte Modell nicht. Wählen Sie eine Modell-ID, die dieser Anbieter tatsächlich bereitstellt (das Standardmodell wird im Anbietereintrag festgelegt).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"Der Schlüssel wurde gespeichert und akzeptiert, aber '{slug}' hat die Anfrage aus Kontingent- oder Abrechnungsgründen abgelehnt. Prüfen Sie Ihr Guthaben und Ihre Limits beim Anbieter.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"Der Schlüssel wurde gespeichert, aber der konfigurierte Endpunkt für '{slug}' lieferte 404. Prüfen Sie die Basis-URL: ein OpenAI-kompatibler Anbieter benötigt meist den Zusatz '/v1' (z. B. https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"Der Schlüssel wurde gespeichert, aber '{slug}' hat nicht rechtzeitig geantwortet. Prüfen Sie die Endpunkt-URL und Ihr Netzwerk und testen Sie erneut.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"Der Schlüssel wurde gespeichert, aber '{slug}' lieferte auf eine Testanfrage eine leere Antwort. Prüfen Sie die für diesen Anbieter konfigurierte Modell-ID.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"Der Schlüssel wurde gespeichert, aber ein Testaufruf an '{slug}' ist fehlgeschlagen. Prüfen Sie die Statusseite des Anbieters und die Endpunkt-URL und testen Sie erneut.",
|
||||
'settings.ai.testingModel': 'Modell wird getestet...',
|
||||
'settings.ai.modelResponse': 'Modellantwort',
|
||||
'settings.ai.providerWithValue': 'Anbieter: {value}',
|
||||
|
||||
@@ -5335,6 +5335,20 @@ const en: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Enable if this model accepts images. Lets the chat composer attach images when this model is selected.',
|
||||
'settings.ai.testFailed': 'Test failed',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"The key was saved, but '{slug}' rejected it. Check that you pasted the whole key and that it is still active in the provider's dashboard.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"The key was saved and accepted, but '{slug}' does not recognise the selected model. Pick a model id this provider actually serves (its default model is set on the provider entry).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"The key was saved and accepted, but '{slug}' refused the request for quota or billing reasons. Check your account balance and rate limits with the provider.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"The key was saved, but the configured endpoint for '{slug}' returned 404. Check the base URL: an OpenAI-compatible provider usually needs the '/v1' suffix (e.g. https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"The key was saved, but '{slug}' did not respond in time. Check the endpoint URL and your network, then test again.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"The key was saved, but '{slug}' returned an empty response to a test prompt. Check the model id configured for this provider.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"The key was saved, but a test call to '{slug}' failed. Check the provider's status page and the endpoint URL, then test again.",
|
||||
'settings.ai.testingModel': 'Testing model...',
|
||||
'settings.ai.modelResponse': 'Model response',
|
||||
'settings.ai.providerWithValue': 'Provider: {value}',
|
||||
|
||||
@@ -4827,6 +4827,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Actívalo si este modelo acepta imágenes. Permite adjuntar imágenes en el chat cuando este modelo está seleccionado.',
|
||||
'settings.ai.testFailed': 'Prueba fallida',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"La clave se guardó, pero '{slug}' la rechazó. Comprueba que pegaste la clave completa y que sigue activa en el panel del proveedor.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"La clave se guardó y se aceptó, pero '{slug}' no reconoce el modelo seleccionado. Elige un identificador de modelo que este proveedor sirva realmente (su modelo predeterminado se define en la entrada del proveedor).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"La clave se guardó y se aceptó, pero '{slug}' rechazó la solicitud por motivos de cuota o facturación. Revisa el saldo de tu cuenta y los límites de uso con el proveedor.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"La clave se guardó, pero el endpoint configurado para '{slug}' devolvió 404. Revisa la URL base: un proveedor compatible con OpenAI suele necesitar el sufijo '/v1' (por ejemplo, https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"La clave se guardó, pero '{slug}' no respondió a tiempo. Revisa la URL del endpoint y tu red, y vuelve a probar.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"La clave se guardó, pero '{slug}' devolvió una respuesta vacía a un mensaje de prueba. Revisa el identificador de modelo configurado para este proveedor.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"La clave se guardó, pero una llamada de prueba a '{slug}' falló. Revisa la página de estado del proveedor y la URL del endpoint, y vuelve a probar.",
|
||||
'settings.ai.testingModel': 'Modelo de prueba...',
|
||||
'settings.ai.modelResponse': 'Respuesta modelo',
|
||||
'settings.ai.providerWithValue': 'Proveedor: {value}',
|
||||
|
||||
@@ -4857,6 +4857,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Activez si ce modèle accepte les images. Permet de joindre des images dans le chat lorsque ce modèle est sélectionné.',
|
||||
'settings.ai.testFailed': 'Test échoué',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"La clé a été enregistrée, mais '{slug}' l'a refusée. Vérifiez que vous avez collé la clé entière et qu'elle est toujours active dans le tableau de bord du fournisseur.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"La clé a été enregistrée et acceptée, mais '{slug}' ne reconnaît pas le modèle sélectionné. Choisissez un identifiant de modèle réellement proposé par ce fournisseur (son modèle par défaut est défini sur la fiche du fournisseur).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"La clé a été enregistrée et acceptée, mais '{slug}' a refusé la requête pour des raisons de quota ou de facturation. Vérifiez le solde de votre compte et vos limites auprès du fournisseur.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"La clé a été enregistrée, mais le point de terminaison configuré pour '{slug}' a renvoyé une erreur 404. Vérifiez l'URL de base : un fournisseur compatible OpenAI nécessite généralement le suffixe '/v1' (par exemple https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"La clé a été enregistrée, mais '{slug}' n'a pas répondu à temps. Vérifiez l'URL du point de terminaison et votre réseau, puis réessayez.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"La clé a été enregistrée, mais '{slug}' a renvoyé une réponse vide à une invite de test. Vérifiez l'identifiant de modèle configuré pour ce fournisseur.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"La clé a été enregistrée, mais un appel de test vers '{slug}' a échoué. Vérifiez la page d'état du fournisseur et l'URL du point de terminaison, puis réessayez.",
|
||||
'settings.ai.testingModel': 'Modèle de test...',
|
||||
'settings.ai.modelResponse': 'Réponse du modèle',
|
||||
'settings.ai.providerWithValue': 'Fournisseur : {value}',
|
||||
|
||||
@@ -4746,6 +4746,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'यदि यह मॉडल छवियाँ स्वीकार करता है तो सक्षम करें। यह मॉडल चुने जाने पर चैट में छवियाँ संलग्न करने देता है।',
|
||||
'settings.ai.testFailed': 'परीक्षण विफल रहा',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"कुंजी सहेज दी गई, लेकिन '{slug}' ने इसे अस्वीकार कर दिया। जाँचें कि आपने पूरी कुंजी चिपकाई है और वह प्रदाता के डैशबोर्ड में अब भी सक्रिय है।",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"कुंजी सहेजी और स्वीकार की गई, लेकिन '{slug}' चयनित मॉडल को नहीं पहचानता। ऐसा मॉडल आईडी चुनें जो यह प्रदाता वास्तव में देता है (इसका डिफ़ॉल्ट मॉडल प्रदाता प्रविष्टि में सेट होता है)।",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"कुंजी सहेजी और स्वीकार की गई, लेकिन '{slug}' ने कोटा या बिलिंग कारणों से अनुरोध ठुकरा दिया। प्रदाता के पास अपना खाता शेष और उपयोग सीमाएँ देखें।",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"कुंजी सहेज दी गई, लेकिन '{slug}' के लिए कॉन्फ़िगर किए गए एंडपॉइंट ने 404 लौटाया। बेस URL जाँचें: OpenAI-संगत प्रदाता को आम तौर पर '/v1' प्रत्यय चाहिए (जैसे https://api.openai.com/v1)।",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"कुंजी सहेज दी गई, लेकिन '{slug}' ने समय पर उत्तर नहीं दिया। एंडपॉइंट URL और अपना नेटवर्क जाँचें, फिर दोबारा परीक्षण करें।",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"कुंजी सहेज दी गई, लेकिन '{slug}' ने परीक्षण संकेत पर खाली उत्तर लौटाया। इस प्रदाता के लिए कॉन्फ़िगर किया गया मॉडल आईडी जाँचें।",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"कुंजी सहेज दी गई, लेकिन '{slug}' पर परीक्षण कॉल विफल रही। प्रदाता का स्थिति पृष्ठ और एंडपॉइंट URL जाँचें, फिर दोबारा परीक्षण करें।",
|
||||
'settings.ai.testingModel': 'परीक्षण मॉडल...',
|
||||
'settings.ai.modelResponse': 'मॉडल प्रतिक्रिया',
|
||||
'settings.ai.providerWithValue': 'प्रदाता: {value}',
|
||||
|
||||
@@ -4764,6 +4764,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Aktifkan jika model ini menerima gambar. Memungkinkan penyusun obrolan melampirkan gambar saat model ini dipilih.',
|
||||
'settings.ai.testFailed': 'Pengujian gagal',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"Kunci sudah disimpan, tetapi '{slug}' menolaknya. Pastikan Anda menempelkan kunci secara utuh dan kunci itu masih aktif di dasbor penyedia.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"Kunci sudah disimpan dan diterima, tetapi '{slug}' tidak mengenali model yang dipilih. Pilih id model yang benar-benar disediakan penyedia ini (model bawaannya diatur pada entri penyedia).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"Kunci sudah disimpan dan diterima, tetapi '{slug}' menolak permintaan karena alasan kuota atau penagihan. Periksa saldo akun dan batas penggunaan Anda pada penyedia.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"Kunci sudah disimpan, tetapi endpoint yang dikonfigurasi untuk '{slug}' mengembalikan 404. Periksa URL dasar: penyedia yang kompatibel dengan OpenAI biasanya memerlukan akhiran '/v1' (misalnya https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"Kunci sudah disimpan, tetapi '{slug}' tidak merespons tepat waktu. Periksa URL endpoint dan jaringan Anda, lalu uji lagi.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"Kunci sudah disimpan, tetapi '{slug}' mengembalikan respons kosong untuk prompt uji. Periksa id model yang dikonfigurasi untuk penyedia ini.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"Kunci sudah disimpan, tetapi panggilan uji ke '{slug}' gagal. Periksa halaman status penyedia dan URL endpoint, lalu uji lagi.",
|
||||
'settings.ai.testingModel': 'Pengujian model...',
|
||||
'settings.ai.modelResponse': 'Respons model',
|
||||
'settings.ai.providerWithValue': 'Penyedia: {value}',
|
||||
|
||||
@@ -4820,6 +4820,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Attiva se questo modello accetta immagini. Consente di allegare immagini nella chat quando è selezionato questo modello.',
|
||||
'settings.ai.testFailed': 'Test non riuscito',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"La chiave è stata salvata, ma '{slug}' l'ha rifiutata. Verifica di aver incollato la chiave completa e che sia ancora attiva nella dashboard del provider.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"La chiave è stata salvata e accettata, ma '{slug}' non riconosce il modello selezionato. Scegli un id di modello che questo provider offre davvero (il modello predefinito si imposta nella voce del provider).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"La chiave è stata salvata e accettata, ma '{slug}' ha rifiutato la richiesta per motivi di quota o fatturazione. Controlla il saldo del tuo account e i limiti di utilizzo presso il provider.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"La chiave è stata salvata, ma l'endpoint configurato per '{slug}' ha restituito 404. Controlla l'URL di base: un provider compatibile con OpenAI di solito richiede il suffisso '/v1' (ad esempio https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"La chiave è stata salvata, ma '{slug}' non ha risposto in tempo. Controlla l'URL dell'endpoint e la tua rete, poi riprova.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"La chiave è stata salvata, ma '{slug}' ha restituito una risposta vuota a un prompt di prova. Controlla l'id del modello configurato per questo provider.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"La chiave è stata salvata, ma una chiamata di prova a '{slug}' non è riuscita. Controlla la pagina di stato del provider e l'URL dell'endpoint, poi riprova.",
|
||||
'settings.ai.testingModel': 'Test del modello...',
|
||||
'settings.ai.modelResponse': 'Risposta del modello',
|
||||
'settings.ai.providerWithValue': 'Fornitore: {value}',
|
||||
|
||||
@@ -4692,6 +4692,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'이 모델이 이미지를 지원하면 활성화하세요. 이 모델을 선택하면 채팅 작성기에서 이미지를 첨부할 수 있습니다.',
|
||||
'settings.ai.testFailed': '테스트 실패',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"키는 저장되었지만 '{slug}'에서 거부했습니다. 키 전체를 붙여넣었는지, 제공업체 대시보드에서 아직 활성 상태인지 확인하세요.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"키는 저장되어 승인되었지만 '{slug}'에서 선택한 모델을 인식하지 못합니다. 이 제공업체가 실제로 제공하는 모델 ID를 선택하세요(기본 모델은 제공업체 항목에서 설정합니다).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"키는 저장되어 승인되었지만 '{slug}'에서 할당량 또는 결제 문제로 요청을 거부했습니다. 제공업체에서 계정 잔액과 사용 한도를 확인하세요.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"키는 저장되었지만 '{slug}'에 설정된 엔드포인트가 404를 반환했습니다. 기본 URL을 확인하세요. OpenAI 호환 제공업체는 보통 '/v1' 접미사가 필요합니다(예: https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"키는 저장되었지만 '{slug}'가 제때 응답하지 않았습니다. 엔드포인트 URL과 네트워크를 확인한 뒤 다시 테스트하세요.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"키는 저장되었지만 '{slug}'가 테스트 프롬프트에 빈 응답을 반환했습니다. 이 제공업체에 설정된 모델 ID를 확인하세요.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"키는 저장되었지만 '{slug}'에 대한 테스트 호출이 실패했습니다. 제공업체의 상태 페이지와 엔드포인트 URL을 확인한 뒤 다시 테스트하세요.",
|
||||
'settings.ai.testingModel': '모델 테스트 중...',
|
||||
'settings.ai.modelResponse': '모델 응답',
|
||||
'settings.ai.providerWithValue': '공급자: {value}',
|
||||
|
||||
@@ -4817,6 +4817,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Włącz, jeśli ten model akceptuje obrazy. Pozwala dołączać obrazy w czacie, gdy ten model jest wybrany.',
|
||||
'settings.ai.testFailed': 'Test nie powiódł się',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"Klucz został zapisany, ale '{slug}' go odrzucił. Sprawdź, czy wkleiłeś cały klucz i czy nadal jest aktywny w panelu dostawcy.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"Klucz został zapisany i zaakceptowany, ale '{slug}' nie rozpoznaje wybranego modelu. Wybierz identyfikator modelu, który ten dostawca faktycznie udostępnia (model domyślny ustawia się we wpisie dostawcy).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"Klucz został zapisany i zaakceptowany, ale '{slug}' odrzucił żądanie z powodu limitu lub rozliczeń. Sprawdź saldo konta i limity u dostawcy.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"Klucz został zapisany, ale skonfigurowany endpoint dla '{slug}' zwrócił 404. Sprawdź adres bazowy: dostawca zgodny z OpenAI zwykle wymaga przyrostka '/v1' (na przykład https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"Klucz został zapisany, ale '{slug}' nie odpowiedział na czas. Sprawdź adres endpointu i sieć, a następnie przetestuj ponownie.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"Klucz został zapisany, ale '{slug}' zwrócił pustą odpowiedź na zapytanie testowe. Sprawdź identyfikator modelu skonfigurowany dla tego dostawcy.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"Klucz został zapisany, ale wywołanie testowe do '{slug}' nie powiodło się. Sprawdź stronę statusu dostawcy i adres endpointu, a następnie przetestuj ponownie.",
|
||||
'settings.ai.testingModel': 'Testowanie modelu...',
|
||||
'settings.ai.modelResponse': 'Odpowiedź modelu',
|
||||
'settings.ai.providerWithValue': 'Dostawca: {value}',
|
||||
|
||||
@@ -4813,6 +4813,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Ative se este modelo aceitar imagens. Permite anexar imagens no chat quando este modelo está selecionado.',
|
||||
'settings.ai.testFailed': 'Teste falhou',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"A chave foi salva, mas '{slug}' a recusou. Verifique se você colou a chave inteira e se ela continua ativa no painel do provedor.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"A chave foi salva e aceita, mas '{slug}' não reconhece o modelo selecionado. Escolha um id de modelo que este provedor realmente ofereça (o modelo padrão é definido no registro do provedor).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"A chave foi salva e aceita, mas '{slug}' recusou a solicitação por motivos de cota ou faturamento. Verifique o saldo da sua conta e os limites de uso com o provedor.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"A chave foi salva, mas o endpoint configurado para '{slug}' retornou 404. Verifique a URL base: um provedor compatível com OpenAI normalmente precisa do sufixo '/v1' (por exemplo, https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"A chave foi salva, mas '{slug}' não respondeu a tempo. Verifique a URL do endpoint e sua rede e teste novamente.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"A chave foi salva, mas '{slug}' retornou uma resposta vazia a um prompt de teste. Verifique o id do modelo configurado para este provedor.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"A chave foi salva, mas uma chamada de teste para '{slug}' falhou. Verifique a página de status do provedor e a URL do endpoint e teste novamente.",
|
||||
'settings.ai.testingModel': 'Modelo de teste...',
|
||||
'settings.ai.modelResponse': 'Resposta do modelo',
|
||||
'settings.ai.providerWithValue': 'Provedor: {value}',
|
||||
|
||||
@@ -4790,6 +4790,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVisionDesc':
|
||||
'Включите, если модель принимает изображения. Позволяет прикреплять изображения в чате, когда выбрана эта модель.',
|
||||
'settings.ai.testFailed': 'Тест не пройден.',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"Ключ сохранён, но '{slug}' его отклонил. Проверьте, что вы вставили ключ целиком и что он всё ещё активен в панели провайдера.",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"Ключ сохранён и принят, но '{slug}' не распознаёт выбранную модель. Укажите идентификатор модели, которую этот провайдер действительно обслуживает (модель по умолчанию задаётся в записи провайдера).",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"Ключ сохранён и принят, но '{slug}' отклонил запрос из-за квоты или оплаты. Проверьте баланс счёта и лимиты у провайдера.",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"Ключ сохранён, но настроенный endpoint для '{slug}' вернул 404. Проверьте базовый URL: провайдеру, совместимому с OpenAI, обычно нужен суффикс '/v1' (например, https://api.openai.com/v1).",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"Ключ сохранён, но '{slug}' не ответил вовремя. Проверьте URL endpoint и сеть, затем повторите проверку.",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"Ключ сохранён, но '{slug}' вернул пустой ответ на тестовый запрос. Проверьте идентификатор модели, настроенный для этого провайдера.",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"Ключ сохранён, но тестовый вызов к '{slug}' не удался. Проверьте страницу состояния провайдера и URL endpoint, затем повторите проверку.",
|
||||
'settings.ai.testingModel': 'Модель тестирования...',
|
||||
'settings.ai.modelResponse': 'Ответ модели',
|
||||
'settings.ai.providerWithValue': 'Поставщик: {value}',
|
||||
|
||||
@@ -4486,6 +4486,20 @@ const messages: TranslationMap = {
|
||||
'settings.ai.modelVision': '支持视觉(图像输入)',
|
||||
'settings.ai.modelVisionDesc': '如果此模型接受图像,请启用。选择此模型后可在聊天框中附加图像。',
|
||||
'settings.ai.testFailed': '测试失败',
|
||||
'settings.ai.providerTest.authRejected':
|
||||
"密钥已保存,但 '{slug}' 拒绝了它。请确认你粘贴了完整的密钥,并且它在服务商后台仍然有效。",
|
||||
'settings.ai.providerTest.modelNotRecognized':
|
||||
"密钥已保存并通过验证,但 '{slug}' 无法识别所选模型。请选择该服务商确实提供的模型 ID(默认模型在服务商条目中设置)。",
|
||||
'settings.ai.providerTest.quotaOrBilling':
|
||||
"密钥已保存并通过验证,但 '{slug}' 因配额或账单原因拒绝了请求。请在服务商处查看账户余额和速率限制。",
|
||||
'settings.ai.providerTest.endpointNotFound':
|
||||
"密钥已保存,但为 '{slug}' 配置的接口地址返回了 404。请检查基础 URL:兼容 OpenAI 的服务商通常需要 '/v1' 后缀(例如 https://api.openai.com/v1)。",
|
||||
'settings.ai.providerTest.timeout':
|
||||
"密钥已保存,但 '{slug}' 未在规定时间内响应。请检查接口地址和网络后重新测试。",
|
||||
'settings.ai.providerTest.emptyReply':
|
||||
"密钥已保存,但 '{slug}' 对测试提示返回了空响应。请检查为该服务商配置的模型 ID。",
|
||||
'settings.ai.providerTest.unknown':
|
||||
"密钥已保存,但对 '{slug}' 的测试调用失败。请查看该服务商的状态页面和接口地址后重新测试。",
|
||||
'settings.ai.testingModel': '测试模型...',
|
||||
'settings.ai.modelResponse': '模型响应',
|
||||
'settings.ai.providerWithValue': '提供者:{value}',
|
||||
|
||||
@@ -10,8 +10,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type AISettings,
|
||||
classifyProviderVerificationFailure,
|
||||
clearCloudProviderKey,
|
||||
completeOpenAiCodexOAuth,
|
||||
describeProviderVerificationFailure,
|
||||
flushCloudProviders,
|
||||
importOpenAiCodexCliAuth,
|
||||
listProviderModels,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
startOpenAiCodexOAuth,
|
||||
testProviderModel,
|
||||
upsertModelRegistryVision,
|
||||
verifyCloudProviderConnection,
|
||||
} from '../aiSettingsApi';
|
||||
|
||||
// ─── Mock declarations (must be hoisted before imports) ───────────────────────
|
||||
@@ -1078,3 +1081,221 @@ describe('model registry vision helpers', () => {
|
||||
expect(modelRegistryVision(flipped, 'openai', 'text-only')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #5146 §2.4: connected-but-unusable providers ─────────────────────────────
|
||||
|
||||
// The messages are rendered through `useT()` in the panel; these tests drive the
|
||||
// same code path with a translator that returns each key's English fallback, so
|
||||
// the assertions read as the user-visible copy while the i18n wiring is exercised.
|
||||
const tFallback = (_key: string, fallback?: string) => fallback ?? '';
|
||||
|
||||
describe('describeProviderVerificationFailure', () => {
|
||||
it('maps an auth rejection onto a key-checking remedy', () => {
|
||||
const msg = describeProviderVerificationFailure(
|
||||
'openai',
|
||||
'HTTP 401 invalid_api_key',
|
||||
tFallback
|
||||
);
|
||||
expect(msg).toContain('openai');
|
||||
expect(msg).toContain('rejected it');
|
||||
// Must not leave the user thinking the save itself failed.
|
||||
expect(msg).toContain('saved');
|
||||
});
|
||||
|
||||
it('maps an unknown model onto a model-id remedy, not an auth remedy', () => {
|
||||
const msg = describeProviderVerificationFailure(
|
||||
'openai',
|
||||
'The model `gpt-4p` does not exist or you do not have access to it.',
|
||||
tFallback
|
||||
);
|
||||
expect(msg).toContain('does not recognise the selected model');
|
||||
expect(msg).not.toContain('rejected it');
|
||||
});
|
||||
|
||||
it('maps quota and rate-limit failures onto a billing remedy', () => {
|
||||
for (const raw of [
|
||||
'429 Too Many Requests',
|
||||
'insufficient_quota',
|
||||
'billing hard limit reached',
|
||||
]) {
|
||||
expect(describeProviderVerificationFailure('deepseek', raw, tFallback)).toContain(
|
||||
'quota or billing reasons'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps a bare 404 onto the /v1 base-url remedy', () => {
|
||||
const msg = describeProviderVerificationFailure('custom', 'HTTP 404', tFallback);
|
||||
expect(msg).toContain('/v1');
|
||||
expect(msg).toContain('base URL');
|
||||
});
|
||||
|
||||
it('maps a timeout onto an endpoint/network remedy', () => {
|
||||
expect(describeProviderVerificationFailure('custom', 'request timed out', tFallback)).toContain(
|
||||
'did not respond in time'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an unrecognised provider error out of the user-visible copy', () => {
|
||||
// A raw upstream string can echo request material (headers, key
|
||||
// fragments) and this lands in a screenshot-able banner, so the fallback
|
||||
// branch must stay generic. The raw text is still returned on
|
||||
// `CloudProviderVerification.detail` and logged by the caller.
|
||||
const msg = describeProviderVerificationFailure(
|
||||
'custom',
|
||||
'kaboom: sk-secret-tail leaked in provider text',
|
||||
tFallback
|
||||
);
|
||||
expect(msg).not.toContain('kaboom');
|
||||
expect(msg).not.toContain('sk-secret-tail');
|
||||
expect(msg).toContain('custom');
|
||||
});
|
||||
|
||||
it('reads cleanly when the error string is empty', () => {
|
||||
const msg = describeProviderVerificationFailure('custom', ' ', tFallback);
|
||||
expect(msg).toContain('custom');
|
||||
expect(msg).not.toMatch(/:\s*$/);
|
||||
});
|
||||
|
||||
it('classifies case-insensitively', () => {
|
||||
expect(describeProviderVerificationFailure('openai', 'INVALID API KEY', tFallback)).toContain(
|
||||
'rejected it'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders through i18n rather than hard-coded copy', () => {
|
||||
// The panel is non-English for most users; every branch must resolve a key
|
||||
// so the locale files (not this module) own the wording.
|
||||
const seen: string[] = [];
|
||||
const t = (key: string, fallback?: string) => {
|
||||
seen.push(key);
|
||||
return fallback ?? '';
|
||||
};
|
||||
|
||||
describeProviderVerificationFailure('openai', 'HTTP 401', t);
|
||||
describeProviderVerificationFailure('openai', 'model_not_found', t);
|
||||
describeProviderVerificationFailure('openai', 'insufficient_quota', t);
|
||||
describeProviderVerificationFailure('openai', 'HTTP 404', t);
|
||||
describeProviderVerificationFailure('openai', 'timed out', t);
|
||||
describeProviderVerificationFailure('openai', 'something novel', t);
|
||||
|
||||
expect(seen).toEqual([
|
||||
'settings.ai.providerTest.authRejected',
|
||||
'settings.ai.providerTest.modelNotRecognized',
|
||||
'settings.ai.providerTest.quotaOrBilling',
|
||||
'settings.ai.providerTest.endpointNotFound',
|
||||
'settings.ai.providerTest.timeout',
|
||||
'settings.ai.providerTest.unknown',
|
||||
]);
|
||||
});
|
||||
|
||||
it('interpolates the slug into the translated template', () => {
|
||||
const msg = describeProviderVerificationFailure(
|
||||
'deepseek',
|
||||
'HTTP 401',
|
||||
() => 'translated: {slug} refused'
|
||||
);
|
||||
expect(msg).toBe('translated: deepseek refused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyProviderVerificationFailure', () => {
|
||||
it('maps each recognised shape onto its reason, defaulting to unknown', () => {
|
||||
expect(classifyProviderVerificationFailure('HTTP 401 invalid_api_key')).toBe('auth');
|
||||
expect(classifyProviderVerificationFailure('unknown model')).toBe('model');
|
||||
expect(classifyProviderVerificationFailure('429 rate limit')).toBe('quota');
|
||||
expect(classifyProviderVerificationFailure('HTTP 404')).toBe('endpoint');
|
||||
// The endpoint branch matches a bare 'not found'; these natural phrasings
|
||||
// must still reach the model branch rather than sending the user to check
|
||||
// their base URL (greptile #5254).
|
||||
expect(classifyProviderVerificationFailure('model not found')).toBe('model');
|
||||
expect(classifyProviderVerificationFailure('The model `x` was not found')).toBe('model');
|
||||
expect(classifyProviderVerificationFailure('request timed out')).toBe('timeout');
|
||||
expect(classifyProviderVerificationFailure('kaboom')).toBe('unknown');
|
||||
expect(classifyProviderVerificationFailure('')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyCloudProviderConnection', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('reports ok when the provider actually answers a test prompt', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ result: { reply: 'pong' } });
|
||||
|
||||
const result = await verifyCloudProviderConnection('openai');
|
||||
|
||||
expect(result).toEqual({ ok: true, message: '', detail: '' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'openhuman.inference_test_provider_model' })
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to the chat workload and sends a minimal prompt', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ result: { reply: 'pong' } });
|
||||
|
||||
await verifyCloudProviderConnection('openai');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ params: { workload: 'chat', provider: 'openai', prompt: 'ping' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an empty reply as unusable — this is the connected-but-unusable case', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ result: { reply: ' ' } });
|
||||
|
||||
const result = await verifyCloudProviderConnection('openai');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain('empty response');
|
||||
});
|
||||
|
||||
it('returns the classified failure instead of throwing, so the caller never loses the save', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue(new Error('HTTP 401 invalid_api_key'));
|
||||
|
||||
const result = await verifyCloudProviderConnection('openai');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.message).toContain('rejected it');
|
||||
// Raw provider text stays available for the details expander.
|
||||
expect(result.detail).toContain('401');
|
||||
});
|
||||
|
||||
it('handles a non-Error rejection without stringifying to [object Object]', async () => {
|
||||
mockCallCoreRpc.mockRejectedValue('plain string failure');
|
||||
|
||||
const result = await verifyCloudProviderConnection('openai');
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.detail).toBe('plain string failure');
|
||||
});
|
||||
|
||||
it('forwards a model-qualified provider verbatim but names only the slug', async () => {
|
||||
// The core only builds a configured cloud provider from `<slug>:<model>`, so
|
||||
// the composite must reach the RPC untouched; the user-facing message must
|
||||
// still say "openai", not "openai:gpt-4o".
|
||||
mockCallCoreRpc.mockRejectedValue(new Error('HTTP 401 invalid_api_key'));
|
||||
|
||||
const result = await verifyCloudProviderConnection('openai:gpt-4o');
|
||||
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { workload: 'chat', provider: 'openai:gpt-4o', prompt: 'ping' },
|
||||
})
|
||||
);
|
||||
expect(result.message).toContain("'openai'");
|
||||
expect(result.message).not.toContain('gpt-4o');
|
||||
});
|
||||
|
||||
it('renders its own messages through the supplied translator', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ result: { reply: '' } });
|
||||
|
||||
const result = await verifyCloudProviderConnection('openai:gpt-4o', 'chat', (key: string) =>
|
||||
key === 'settings.ai.providerTest.emptyReply' ? 'translated empty {slug}' : 'wrong key'
|
||||
);
|
||||
|
||||
expect(result.message).toBe('translated empty openai');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -462,6 +462,223 @@ export async function setCloudProviderKey(slug: string, apiKey: string): Promise
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcome of a post-save connection check (#5146 §2.4).
|
||||
*
|
||||
* `ok: false` means the credential was stored but the provider could not
|
||||
* actually serve an inference call — the "connected but unusable" state where
|
||||
* the UI previously showed a healthy provider that failed on first real use.
|
||||
*/
|
||||
export interface CloudProviderVerification {
|
||||
ok: boolean;
|
||||
/** Actionable, user-facing explanation. Empty when `ok`. */
|
||||
message: string;
|
||||
/** Raw provider error, kept for the details/expander. Empty when `ok`. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** Minimal `{placeholder}` interpolation, matching `ProviderSetupErrorNotice`. */
|
||||
function fillTemplate(template: string, replacements: Record<string, string>): string {
|
||||
return Object.entries(replacements).reduce(
|
||||
(result, [key, value]) => result.replaceAll(`{${key}}`, value),
|
||||
template
|
||||
);
|
||||
}
|
||||
|
||||
/** Translator shape accepted here — the `(key, fallback)` form `useT` returns. */
|
||||
export type TranslateFn = (key: string, fallback?: string) => string;
|
||||
|
||||
/**
|
||||
* Which failure a raw provider/RPC error represents (#5146 §2.4).
|
||||
*
|
||||
* Classification is kept separate from wording so the decision is unit-testable
|
||||
* without a translator and the copy can live in the locale files where the i18n
|
||||
* tooling can see it.
|
||||
*/
|
||||
export type ProviderVerificationReason =
|
||||
| 'auth'
|
||||
| 'model'
|
||||
| 'quota'
|
||||
| 'endpoint'
|
||||
| 'timeout'
|
||||
| 'unknown';
|
||||
|
||||
/** Map a raw upstream error string onto a [`ProviderVerificationReason`]. */
|
||||
export function classifyProviderVerificationFailure(raw: string): ProviderVerificationReason {
|
||||
const haystack = raw.trim().toLowerCase();
|
||||
|
||||
if (
|
||||
haystack.includes('401') ||
|
||||
haystack.includes('invalid api key') ||
|
||||
haystack.includes('invalid_api_key') ||
|
||||
haystack.includes('incorrect api key') ||
|
||||
haystack.includes('unauthorized') ||
|
||||
haystack.includes('authentication')
|
||||
) {
|
||||
return 'auth';
|
||||
}
|
||||
if (
|
||||
haystack.includes('model_not_found') ||
|
||||
// Must be tested here: the endpoint branch below matches a bare
|
||||
// 'not found', which would otherwise claim every provider that phrases a
|
||||
// missing model as "model not found" or "The model `x` was not found" and
|
||||
// send the user off to check their base URL instead of their model id.
|
||||
(haystack.includes('not found') && haystack.includes('model')) ||
|
||||
haystack.includes('does not exist') ||
|
||||
haystack.includes('is not available') ||
|
||||
haystack.includes('unknown model') ||
|
||||
haystack.includes('invalid model')
|
||||
) {
|
||||
return 'model';
|
||||
}
|
||||
if (
|
||||
haystack.includes('quota') ||
|
||||
haystack.includes('insufficient') ||
|
||||
haystack.includes('billing') ||
|
||||
haystack.includes('429') ||
|
||||
haystack.includes('rate limit')
|
||||
) {
|
||||
return 'quota';
|
||||
}
|
||||
if (haystack.includes('404') || haystack.includes('not found')) {
|
||||
return 'endpoint';
|
||||
}
|
||||
if (haystack.includes('timeout') || haystack.includes('timed out')) {
|
||||
return 'timeout';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw provider/RPC failure into something a user can act on (#5146 §2.4).
|
||||
*
|
||||
* Storing a key only proves we wrote it to disk. The failures users actually
|
||||
* hit (wrong model id, exhausted quota, a base URL that doesn't speak the
|
||||
* endpoint we call) all surface as opaque upstream strings, so map the common
|
||||
* shapes onto a concrete next step and pass anything unrecognised through
|
||||
* verbatim rather than inventing a diagnosis.
|
||||
*
|
||||
* The rendered string is user-visible UI text, so it goes through `t` like the
|
||||
* sibling `presentProviderSetupError`: the caller passes the translator from
|
||||
* `useT()` and every branch resolves against the locale files. `slug` must be
|
||||
* the bare provider slug (`openai`), not a composite `provider:model` string.
|
||||
*/
|
||||
export function describeProviderVerificationFailure(
|
||||
slug: string,
|
||||
raw: string,
|
||||
t: TranslateFn
|
||||
): string {
|
||||
const detail = raw.trim();
|
||||
const reason = classifyProviderVerificationFailure(detail);
|
||||
|
||||
switch (reason) {
|
||||
case 'auth':
|
||||
return fillTemplate(
|
||||
t(
|
||||
'settings.ai.providerTest.authRejected',
|
||||
"The key was saved, but '{slug}' rejected it. Check that you pasted the whole key and that it is still active in the provider's dashboard."
|
||||
),
|
||||
{ slug }
|
||||
);
|
||||
case 'model':
|
||||
return fillTemplate(
|
||||
t(
|
||||
'settings.ai.providerTest.modelNotRecognized',
|
||||
"The key was saved and accepted, but '{slug}' does not recognise the selected model. Pick a model id this provider actually serves (its default model is set on the provider entry)."
|
||||
),
|
||||
{ slug }
|
||||
);
|
||||
case 'quota':
|
||||
return fillTemplate(
|
||||
t(
|
||||
'settings.ai.providerTest.quotaOrBilling',
|
||||
"The key was saved and accepted, but '{slug}' refused the request for quota or billing reasons. Check your account balance and rate limits with the provider."
|
||||
),
|
||||
{ slug }
|
||||
);
|
||||
case 'endpoint':
|
||||
return fillTemplate(
|
||||
t(
|
||||
'settings.ai.providerTest.endpointNotFound',
|
||||
"The key was saved, but the configured endpoint for '{slug}' returned 404. Check the base URL: an OpenAI-compatible provider usually needs the '/v1' suffix (e.g. https://api.openai.com/v1)."
|
||||
),
|
||||
{ slug }
|
||||
);
|
||||
case 'timeout':
|
||||
return fillTemplate(
|
||||
t(
|
||||
'settings.ai.providerTest.timeout',
|
||||
"The key was saved, but '{slug}' did not respond in time. Check the endpoint URL and your network, then test again."
|
||||
),
|
||||
{ slug }
|
||||
);
|
||||
default:
|
||||
// Deliberately generic. The upstream string can echo request material
|
||||
// (headers, key fragments) and this lands in a screenshot-able banner, so
|
||||
// it must not be interpolated into user-visible copy. The raw text stays
|
||||
// on `CloudProviderVerification.detail` for the details surface and is
|
||||
// logged by the caller for local diagnosis.
|
||||
return fillTemplate(
|
||||
t(
|
||||
'settings.ai.providerTest.unknown',
|
||||
"The key was saved, but a test call to '{slug}' failed. Check the provider's status page and the endpoint URL, then test again."
|
||||
),
|
||||
{ slug }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove a freshly configured provider can actually run inference (#5146 §2.4).
|
||||
*
|
||||
* Runs one real, minimal completion through the existing
|
||||
* `openhuman.inference_test_provider_model` RPC. Never throws: a provider that
|
||||
* cannot serve a request is a *result*, not an exception, because the caller
|
||||
* always wants to show it rather than lose the save.
|
||||
*/
|
||||
export async function verifyCloudProviderConnection(
|
||||
provider: string,
|
||||
workload: WorkloadId = 'chat',
|
||||
t: TranslateFn = (_key, fallback) => fallback ?? ''
|
||||
): Promise<CloudProviderVerification> {
|
||||
// The core only builds a configured cloud provider from the `<slug>:<model>`
|
||||
// form, so a bare slug would be rejected as an unrecognised provider and a
|
||||
// perfectly good key would be reported as broken. Callers pass the same
|
||||
// composite string the Test button uses; the bare slug is what the *messages*
|
||||
// name, so derive it rather than echoing `provider:model[:temp]` at the user.
|
||||
const providerString = provider.trim();
|
||||
const slug = providerString.split(':')[0]?.trim() || providerString;
|
||||
|
||||
console.debug(`[ai-settings][verify] start workload=${workload} slug=${slug}`);
|
||||
try {
|
||||
const result = await testProviderModel(workload, providerString, 'ping');
|
||||
const reply = (result?.reply ?? '').trim();
|
||||
if (!reply) {
|
||||
console.debug(`[ai-settings][verify] empty-reply workload=${workload} slug=${slug}`);
|
||||
return {
|
||||
ok: false,
|
||||
message: fillTemplate(
|
||||
t(
|
||||
'settings.ai.providerTest.emptyReply',
|
||||
"The key was saved, but '{slug}' returned an empty response to a test prompt. Check the model id configured for this provider."
|
||||
),
|
||||
{ slug }
|
||||
),
|
||||
detail: '',
|
||||
};
|
||||
}
|
||||
console.debug(`[ai-settings][verify] ok workload=${workload} slug=${slug}`);
|
||||
return { ok: true, message: '', detail: '' };
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
// The raw detail can carry provider text; log only the classification.
|
||||
console.error(
|
||||
`[ai-settings][verify] failed workload=${workload} slug=${slug} reason=${classifyProviderVerificationFailure(detail)}`
|
||||
);
|
||||
return { ok: false, message: describeProviderVerificationFailure(slug, detail, t), detail };
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear a stored API key. */
|
||||
export async function clearCloudProviderKey(slug: string): Promise<void> {
|
||||
if (slug === 'openhuman') {
|
||||
|
||||
@@ -490,6 +490,19 @@ impl Agent {
|
||||
let model_vision =
|
||||
crate::openhuman::inference::model_context::model_supports_vision(&model_name, config);
|
||||
|
||||
// #5146 §2.1/§2.3: when the active model can't take images the turn
|
||||
// engine silently strips them, and the user gets a confident answer
|
||||
// about an image the model never saw. Log the actionable reason (which
|
||||
// model, and what to switch to) at the moment the decision is made.
|
||||
if let Err(reason) =
|
||||
crate::openhuman::inference::provider::fallback_diagnostics::vision_preflight(
|
||||
&model_name,
|
||||
config,
|
||||
)
|
||||
{
|
||||
log::info!("[vision-preflight] {reason}");
|
||||
}
|
||||
|
||||
// Dispatcher selection is deferred until after the tool list is
|
||||
// finalised (orchestrator tools are appended below). We capture
|
||||
// the choice string now so the provider borrow doesn't conflict
|
||||
|
||||
@@ -300,26 +300,14 @@ pub(crate) fn oh_tier_supports_vision(model: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the configured provider string for a named workload role.
|
||||
/// The provider route a role has **explicitly** configured, before any
|
||||
/// fallback.
|
||||
///
|
||||
/// Empty / `"cloud"` resolves through BYOK fallback first for the three
|
||||
/// chat-tier roles (`chat`, `reasoning`, `coding`), then `primary_cloud`.
|
||||
/// When a BYOK cloud provider is detected on any workload, unset chat-tier
|
||||
/// routes inherit it rather than silently falling back to the managed backend.
|
||||
///
|
||||
/// Only `chat`, `reasoning`, and `coding` participate in BYOK inheritance.
|
||||
/// Background workloads (`memory`, `embeddings`, `heartbeat`, `learning`,
|
||||
/// `subconscious`) and the `agentic`/`burst` workloads always fall through to
|
||||
/// `primary_cloud` when their explicit provider route is unset — they use
|
||||
/// tier-specific models that BYOK providers don't understand, and their
|
||||
/// providers are configured independently.
|
||||
///
|
||||
/// For backwards compatibility, a legacy external `inference_url` takes
|
||||
/// precedence when `primary_cloud` still points at OpenHuman because
|
||||
/// migration 1→2 preserved the URL as a custom provider entry but older
|
||||
/// configs did not explicitly set per-workload routes.
|
||||
pub fn provider_for_role(role: &str, config: &Config) -> String {
|
||||
let opt = match role {
|
||||
/// Split out of [`provider_for_role`] so the fallback machinery can ask the
|
||||
/// same question the router asks — "did the user route this role anywhere?" —
|
||||
/// without re-deriving the role→config-field mapping and drifting from it.
|
||||
fn configured_route_for_role<'a>(role: &str, config: &'a Config) -> Option<&'a str> {
|
||||
match role {
|
||||
"chat" => config.chat_provider.as_deref(),
|
||||
"reasoning" => config.reasoning_provider.as_deref(),
|
||||
"agentic" => config.agentic_provider.as_deref(),
|
||||
@@ -343,7 +331,45 @@ pub fn provider_for_role(role: &str, config: &Config) -> String {
|
||||
"learning" => config.learning_provider.as_deref(),
|
||||
"subconscious" => config.subconscious_provider.as_deref(),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `role` reached a cloud slug by *implicit fallback* rather than by an
|
||||
/// explicit route.
|
||||
///
|
||||
/// True only when the role is one of the cloud-fallback background roles **and**
|
||||
/// its own route is unset (or the literal `"cloud"`). An explicitly configured
|
||||
/// cloud route — say `vision_provider = "anthropic:claude-…"` — is not a
|
||||
/// fallback, so a credential failure there must not be explained as "your local
|
||||
/// chat model cannot do this".
|
||||
pub(crate) fn role_uses_implicit_cloud_fallback(role: &str, config: &Config) -> bool {
|
||||
if !super::fallback_diagnostics::role_falls_back_to_cloud(role) {
|
||||
return false;
|
||||
}
|
||||
let route = configured_route_for_role(role, config).unwrap_or("").trim();
|
||||
route.is_empty() || route == "cloud"
|
||||
}
|
||||
|
||||
/// Return the configured provider string for a named workload role.
|
||||
///
|
||||
/// Empty / `"cloud"` resolves through BYOK fallback first for the three
|
||||
/// chat-tier roles (`chat`, `reasoning`, `coding`), then `primary_cloud`.
|
||||
/// When a BYOK cloud provider is detected on any workload, unset chat-tier
|
||||
/// routes inherit it rather than silently falling back to the managed backend.
|
||||
///
|
||||
/// Only `chat`, `reasoning`, and `coding` participate in BYOK inheritance.
|
||||
/// Background workloads (`memory`, `embeddings`, `heartbeat`, `learning`,
|
||||
/// `subconscious`) and the `agentic`/`burst` workloads always fall through to
|
||||
/// `primary_cloud` when their explicit provider route is unset — they use
|
||||
/// tier-specific models that BYOK providers don't understand, and their
|
||||
/// providers are configured independently.
|
||||
///
|
||||
/// For backwards compatibility, a legacy external `inference_url` takes
|
||||
/// precedence when `primary_cloud` still points at OpenHuman because
|
||||
/// migration 1→2 preserved the URL as a custom provider entry but older
|
||||
/// configs did not explicitly set per-workload routes.
|
||||
pub fn provider_for_role(role: &str, config: &Config) -> String {
|
||||
let opt = configured_route_for_role(role, config);
|
||||
let s = opt.unwrap_or("").trim();
|
||||
if s.is_empty() || s == "cloud" {
|
||||
// BYOK inheritance is scoped to the three chat-tier roles only.
|
||||
@@ -362,29 +388,29 @@ pub fn provider_for_role(role: &str, config: &Config) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic: when the user has a local provider configured for chat
|
||||
// but this background workload is falling through to cloud, emit a
|
||||
// warning so it's visible in logs (no silent fallback).
|
||||
if !matches!(role, "chat" | "reasoning" | "coding") {
|
||||
let resolved = resolve_primary_cloud_provider_string(config);
|
||||
|
||||
// #5146 §2.1: the fallback itself is correct and stays — background
|
||||
// workloads run tier-specific models that local runtimes don't serve,
|
||||
// and a local-chat + managed-subscription user genuinely wants them on
|
||||
// the cloud. What was missing is the *explanation*: when this route
|
||||
// later fails for want of a key, the user saw a bare slug-level auth
|
||||
// error naming a provider they never configured. Emit the same
|
||||
// user-facing sentence the error path uses, so the routing decision is
|
||||
// visible in logs and support transcripts before anything goes wrong.
|
||||
if super::fallback_diagnostics::role_falls_back_to_cloud(role) {
|
||||
if let Some(chat) = config.chat_provider.as_deref() {
|
||||
if crate::openhuman::inference::local::profile::is_local_provider_string(chat) {
|
||||
let override_hint = if role == "burst" {
|
||||
"set agentic_provider explicitly to override".to_string()
|
||||
} else {
|
||||
format!("set {role}_provider explicitly to override")
|
||||
};
|
||||
log::info!(
|
||||
"[providers][local-fallback] role={} using managed backend (chat is \
|
||||
local '{}' but background workloads require cloud — {})",
|
||||
"[providers][local-fallback] role={} {}",
|
||||
role,
|
||||
chat,
|
||||
override_hint
|
||||
super::fallback_diagnostics::cloud_fallback_notice(role, chat, &resolved)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve_primary_cloud_provider_string(config)
|
||||
resolved
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
@@ -2203,7 +2229,60 @@ fn resolve_cloud_slug<'a>(
|
||||
redact_endpoint(&entry.endpoint)
|
||||
);
|
||||
|
||||
let key = lookup_key_for_slug(slug, config)?;
|
||||
// #5146 §2.1: a raw "failed to read API key for slug 'anthropic'" is
|
||||
// baffling when the user never configured Anthropic — they set a local
|
||||
// Ollama model and this is a background role that fell back to the cloud.
|
||||
// Attach the role, and the local chat model that caused the fallback, so
|
||||
// the message explains itself and names a concrete remedy.
|
||||
// Only an *implicit* fallback is explained as one. A role with its own
|
||||
// explicit cloud route can fail key lookup here too, and telling that user
|
||||
// their local chat model caused it would be a lie.
|
||||
let implicit_fallback = role_uses_implicit_cloud_fallback(role, config);
|
||||
let local_chat = if implicit_fallback {
|
||||
config.chat_provider.as_deref().filter(|chat| {
|
||||
crate::openhuman::inference::local::profile::is_local_provider_string(chat)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let missing_credentials = || {
|
||||
// Safe fields only: role, slug, and the routing shape. Never the
|
||||
// underlying error (it can echo a key) and never the key itself.
|
||||
log::warn!(
|
||||
"[providers][chat-factory] credential lookup failed role={} slug={} auth_style={} implicit_cloud_fallback={}",
|
||||
role,
|
||||
slug,
|
||||
entry.auth_style.as_str(),
|
||||
implicit_fallback
|
||||
);
|
||||
super::fallback_diagnostics::missing_provider_credentials_message(role, slug, local_chat)
|
||||
};
|
||||
|
||||
let key = lookup_key_for_slug(slug, config)
|
||||
.map_err(|e| anyhow::anyhow!("{} (underlying error: {e})", missing_credentials()))?;
|
||||
|
||||
// A readable auth profile with no key for this slug returns `Ok("")`, which
|
||||
// would otherwise build a client with an empty bearer and surface as a raw
|
||||
// 401 from the provider several layers later — exactly the baffling error
|
||||
// this diagnostic exists to replace.
|
||||
//
|
||||
// Scoped to the *implicit fallback* path deliberately. That is the case the
|
||||
// diagnostic is for: a local-chat user whose background role landed on a
|
||||
// BYOK slug they never configured. An explicitly routed provider keeps its
|
||||
// existing behaviour and is allowed to build without a stored key — callers
|
||||
// construct such models to probe or describe a provider before a key is
|
||||
// saved, and failing that at construction time would be a behaviour change
|
||||
// well beyond this diagnostic.
|
||||
//
|
||||
// Styles that carry no stored key (`OpenhumanJwt` injects a session JWT
|
||||
// downstream, `None` sends no auth header at all) are legitimately blank and
|
||||
// never trip this.
|
||||
if implicit_fallback
|
||||
&& key.trim().is_empty()
|
||||
&& matches!(entry.auth_style, AuthStyle::Bearer | AuthStyle::Anthropic)
|
||||
{
|
||||
anyhow::bail!("{}", missing_credentials());
|
||||
}
|
||||
let codex = resolve_openai_codex_routing(config, slug, &entry.endpoint, &key)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
|
||||
@@ -1470,3 +1470,161 @@ async fn create_chat_model_local_runtime_does_not_emit_egress_realpath() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── #5146 §2.1: local chat + background workloads ────────────────────────────
|
||||
//
|
||||
// The fix for #5146 §2.1 is an *explanation* change, not a routing change. These
|
||||
// pin the routing so a future "never fall back" refactor cannot silently break
|
||||
// local-chat + managed-subscription users without a failing test.
|
||||
|
||||
#[test]
|
||||
fn local_chat_still_routes_background_roles_to_the_managed_backend() {
|
||||
let mut config = Config::default();
|
||||
config.chat_provider = Some("ollama:gemma3:1b".to_string());
|
||||
|
||||
// Every background role keeps falling through to the managed backend: they
|
||||
// run tier-specific models a local runtime does not serve, and the user's
|
||||
// subscription is what pays for them.
|
||||
for role in [
|
||||
"vision",
|
||||
"embeddings",
|
||||
"memory",
|
||||
"summarization",
|
||||
"heartbeat",
|
||||
"learning",
|
||||
"subconscious",
|
||||
"agentic",
|
||||
"burst",
|
||||
] {
|
||||
assert_eq!(
|
||||
provider_for_role(role, &config),
|
||||
"openhuman",
|
||||
"role '{role}' must keep falling back to the managed backend when chat is local"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_chat_role_is_returned_verbatim_and_never_falls_back() {
|
||||
let mut config = Config::default();
|
||||
config.chat_provider = Some("ollama:gemma3:1b".to_string());
|
||||
|
||||
assert_eq!(
|
||||
provider_for_role("chat", &config),
|
||||
"ollama:gemma3:1b",
|
||||
"an explicitly configured local chat route must be honoured verbatim"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_background_route_overrides_the_cloud_fallback() {
|
||||
let mut config = Config::default();
|
||||
config.chat_provider = Some("ollama:gemma3:1b".to_string());
|
||||
config.vision_provider = Some("ollama:llava:7b".to_string());
|
||||
|
||||
// The remedy the new diagnostics point users at must actually work.
|
||||
assert_eq!(
|
||||
provider_for_role("vision", &config),
|
||||
"ollama:llava:7b",
|
||||
"setting vision_provider must take precedence over the cloud fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_readable_profile_with_no_stored_key_is_treated_as_missing_credentials() {
|
||||
// The common BYOK-with-no-key shape: the auth profile reads fine, it just
|
||||
// has nothing for this slug, so the lookup succeeds with an empty string.
|
||||
// Without an emptiness check the client would be built with a blank bearer
|
||||
// and the user would get a raw 401 from the provider instead of guidance.
|
||||
let _guard = crate::openhuman::inference::inference_test_guard();
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let mut config = config_with_providers_in_tempdir(&tmp, vec![openai_entry("p_oai", "openai")]);
|
||||
config.chat_provider = Some("ollama:gemma3:1b".to_string());
|
||||
|
||||
let err = create_test_chat_model_from_string("vision", "openai:gpt-4o", &config)
|
||||
.err()
|
||||
.expect("a slug with no stored key must not build a client");
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(
|
||||
message.contains("No usable credentials for 'openai'"),
|
||||
"expected the actionable guidance, got: {message}"
|
||||
);
|
||||
// It is a genuine implicit fallback here (vision has no route of its own),
|
||||
// so the local chat model that caused it is named.
|
||||
assert!(
|
||||
message.contains("ollama:gemma3:1b"),
|
||||
"expected the local chat model to be named, got: {message}"
|
||||
);
|
||||
|
||||
// Scope: an explicitly routed provider is NOT failed at construction time
|
||||
// for a missing key. Callers build such models to probe or describe a
|
||||
// provider before a key is saved, so only the implicit-fallback path (the
|
||||
// one this diagnostic exists for) turns a blank key into an error.
|
||||
config.vision_provider = Some("openai:gpt-4o".to_string());
|
||||
assert!(
|
||||
create_test_chat_model_from_string("vision", "openai:gpt-4o", &config).is_ok(),
|
||||
"an explicitly routed provider must still build without a stored key"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_cloud_fallback_is_claimed_only_when_the_role_has_no_route_of_its_own() {
|
||||
let mut config = Config::default();
|
||||
config.chat_provider = Some("ollama:gemma3:1b".to_string());
|
||||
|
||||
// Unset background route: the role genuinely landed on the cloud because
|
||||
// the local chat model cannot serve it, so the explanation applies.
|
||||
assert!(role_uses_implicit_cloud_fallback("vision", &config));
|
||||
// The literal "cloud" is the same "route me wherever the cloud is" intent.
|
||||
config.embeddings_provider = Some("cloud".to_string());
|
||||
assert!(role_uses_implicit_cloud_fallback("embeddings", &config));
|
||||
// Whitespace is not a configured route.
|
||||
config.memory_provider = Some(" ".to_string());
|
||||
assert!(role_uses_implicit_cloud_fallback("memory", &config));
|
||||
|
||||
// Explicitly routed to a cloud slug: a credential failure here is about
|
||||
// that route, not about the local chat model, so it must not be described
|
||||
// as a fallback.
|
||||
config.vision_provider = Some("anthropic:claude-3-5-sonnet-latest".to_string());
|
||||
assert!(!role_uses_implicit_cloud_fallback("vision", &config));
|
||||
|
||||
// Chat-tier roles are never described as cloud fallbacks, routed or not.
|
||||
for role in ["chat", "reasoning", "coding"] {
|
||||
assert!(!role_uses_implicit_cloud_fallback(role, &config));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_fallback_roles_match_the_roles_provider_for_role_actually_falls_back() {
|
||||
// `factory_tests` is a child module of `factory`, so `super` is `factory`,
|
||||
// not `provider` — reach the sibling module by its crate path.
|
||||
use crate::openhuman::inference::provider::fallback_diagnostics::role_falls_back_to_cloud;
|
||||
let mut config = Config::default();
|
||||
config.chat_provider = Some("ollama:gemma3:1b".to_string());
|
||||
|
||||
// The diagnostics module carries its own role list; if the two drift, users
|
||||
// get either a missing explanation or one that names the wrong knob.
|
||||
for role in [
|
||||
"vision",
|
||||
"embeddings",
|
||||
"memory",
|
||||
"summarization",
|
||||
"heartbeat",
|
||||
"learning",
|
||||
"subconscious",
|
||||
"agentic",
|
||||
"burst",
|
||||
] {
|
||||
assert!(
|
||||
role_falls_back_to_cloud(role),
|
||||
"'{role}' falls back in provider_for_role but is missing from CLOUD_FALLBACK_ROLES"
|
||||
);
|
||||
}
|
||||
for role in ["chat", "reasoning", "coding"] {
|
||||
assert!(
|
||||
!role_falls_back_to_cloud(role),
|
||||
"'{role}' is a chat-tier role and must not be described as a cloud fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Actionable diagnostics for background-workload provider fallback (#5146 §2.1).
|
||||
//!
|
||||
//! ## The user-visible bug
|
||||
//!
|
||||
//! A user configures a local Ollama model for chat, attaches an image, and gets
|
||||
//! `failed to read API key for slug 'anthropic'` — naming a provider they never
|
||||
//! configured. The same shape appears for embeddings, memory and the other
|
||||
//! background workloads.
|
||||
//!
|
||||
//! ## Why it happens, and why the routing itself is correct
|
||||
//!
|
||||
//! [`super::factory::provider_for_role`] deliberately routes the background
|
||||
//! roles (`vision`, `embeddings`, `memory`, `heartbeat`, `learning`,
|
||||
//! `subconscious`, `agentic`, `burst`) to the primary cloud provider when their
|
||||
//! own route is unset: they run tier-specific models (`vision-v1`,
|
||||
//! `summarization-v1`, …) that local runtimes and BYOK slugs do not serve. A
|
||||
//! user on a local chat model with a managed subscription genuinely wants those
|
||||
//! workloads on the cloud, so the fallback must stay.
|
||||
//!
|
||||
//! What was wrong is that the fallback was **unexplained**. When it landed on a
|
||||
//! provider with no usable credentials, the failure surfaced as a raw slug-level
|
||||
//! auth error with no hint that (a) this was a background role, (b) it fell back
|
||||
//! because the local model cannot serve that capability, or (c) what to do about
|
||||
//! it. That is the whole of the fix here: the routing decision is unchanged, the
|
||||
//! *explanation* is not.
|
||||
//!
|
||||
//! Everything in this module is a pure string/predicate function so the wording
|
||||
//! and the routing rules can be unit-tested without a live provider.
|
||||
//!
|
||||
//! Users who want no cloud egress at all already have
|
||||
//! [`crate::openhuman::config::schema::PrivacyMode::LocalOnly`], enforced at the
|
||||
//! inference chokepoint by `factory::enforce_local_only_inference`. This module
|
||||
//! deliberately does not duplicate that gate.
|
||||
|
||||
/// Workload roles that inherit the primary cloud provider when their own route
|
||||
/// is unset — the set [`super::factory::provider_for_role`] does **not** include
|
||||
/// in chat-tier BYOK inheritance.
|
||||
///
|
||||
/// `chat`, `reasoning` and `coding` are absent on purpose: they inherit a
|
||||
/// configured BYOK slug instead, so they never produce the confusion this
|
||||
/// module exists to explain.
|
||||
const CLOUD_FALLBACK_ROLES: &[&str] = &[
|
||||
"vision",
|
||||
"embeddings",
|
||||
"memory",
|
||||
"summarization",
|
||||
"heartbeat",
|
||||
"learning",
|
||||
"subconscious",
|
||||
"agentic",
|
||||
"burst",
|
||||
];
|
||||
|
||||
/// Whether `role` falls through to the primary cloud provider when its own
|
||||
/// route is unset.
|
||||
pub(crate) fn role_falls_back_to_cloud(role: &str) -> bool {
|
||||
CLOUD_FALLBACK_ROLES.contains(&role.trim())
|
||||
}
|
||||
|
||||
/// The capability a background role provides, phrased for a user-facing
|
||||
/// sentence ("… does not support **vision**").
|
||||
///
|
||||
/// `None` for roles whose name is not a capability the user would recognise as
|
||||
/// a model feature (`heartbeat`, `burst`, …); callers fall back to the role
|
||||
/// name itself.
|
||||
pub(crate) fn role_capability_label(role: &str) -> Option<&'static str> {
|
||||
match role.trim() {
|
||||
"vision" => Some("vision (image input)"),
|
||||
"embeddings" => Some("embeddings"),
|
||||
"memory" | "summarization" => Some("summarization"),
|
||||
"agentic" | "burst" => Some("agentic tool use"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a role is described in a sentence — the capability when it maps to one,
|
||||
/// otherwise the bare role name.
|
||||
fn role_phrase(role: &str) -> String {
|
||||
match role_capability_label(role) {
|
||||
Some(capability) => capability.to_string(),
|
||||
None => role.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Explanation logged when a background role runs on the cloud because the
|
||||
/// user's chat model is local and cannot serve that capability.
|
||||
///
|
||||
/// This is the informational, everything-is-working case: the user has a cloud
|
||||
/// route available and the workload will succeed. It exists so the routing
|
||||
/// decision is visible in logs and support transcripts rather than silent.
|
||||
pub(crate) fn cloud_fallback_notice(
|
||||
role: &str,
|
||||
local_chat_provider: &str,
|
||||
resolved_provider: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"{} is running via your managed/cloud provider ('{}') because your local chat model \
|
||||
('{}') does not serve this workload. Set {}_provider explicitly in Connections to \
|
||||
override.",
|
||||
capitalize_first(&role_phrase(role)),
|
||||
resolved_provider.trim(),
|
||||
local_chat_provider.trim(),
|
||||
override_knob_for_role(role),
|
||||
)
|
||||
}
|
||||
|
||||
/// The config knob a user sets to override a role's route.
|
||||
///
|
||||
/// `burst` has no knob of its own — it rides the agentic route — so it points
|
||||
/// the user at `agentic_provider` rather than a setting that does not exist.
|
||||
pub(crate) fn override_knob_for_role(role: &str) -> &str {
|
||||
match role.trim() {
|
||||
"burst" => "agentic",
|
||||
"summarization" => "memory",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Actionable error for the failing case: a background role fell back to a
|
||||
/// cloud slug whose credentials cannot be read.
|
||||
///
|
||||
/// Replaces the bare `failed to read API key for slug 'anthropic'` that named a
|
||||
/// provider the user never chose. Mentions the local chat model when there is
|
||||
/// one, because that is the missing half of the story.
|
||||
pub(crate) fn missing_provider_credentials_message(
|
||||
role: &str,
|
||||
slug: &str,
|
||||
local_chat_provider: Option<&str>,
|
||||
) -> String {
|
||||
let role = role.trim();
|
||||
let slug = slug.trim();
|
||||
match local_chat_provider {
|
||||
Some(local) if !local.trim().is_empty() => format!(
|
||||
"No usable credentials for '{slug}', which OpenHuman selected for the {} workload. \
|
||||
Your chat model is local ('{}') and does not serve this workload, so it fell back \
|
||||
to your cloud provider — but '{slug}' has no API key configured. Add a key for \
|
||||
'{slug}' in Connections → LLM, set {}_provider to a provider that is configured, \
|
||||
or enable the managed OpenHuman backend.",
|
||||
role_phrase(role),
|
||||
local.trim(),
|
||||
override_knob_for_role(role),
|
||||
),
|
||||
_ => format!(
|
||||
"No usable credentials for '{slug}', which OpenHuman selected for the {} workload. \
|
||||
Add a key for '{slug}' in Connections → LLM, set {}_provider to a provider that is \
|
||||
configured, or enable the managed OpenHuman backend.",
|
||||
role_phrase(role),
|
||||
override_knob_for_role(role),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Actionable message for the vision pre-flight: the active model cannot accept
|
||||
/// images, so the caller should say so instead of stripping the image and
|
||||
/// producing a confusing blind answer.
|
||||
pub(crate) fn local_vision_unsupported_message(model: &str) -> String {
|
||||
format!(
|
||||
"The selected local model ('{}') does not support vision (image input). Switch to a \
|
||||
vision-capable model such as llava:7b (`ollama pull llava:7b`), or set vision_provider \
|
||||
in Connections → LLM to a provider that supports images.",
|
||||
model.trim()
|
||||
)
|
||||
}
|
||||
|
||||
/// Pure pre-flight for image input: `Ok(())` when `model` accepts images,
|
||||
/// `Err(actionable message)` when it does not.
|
||||
///
|
||||
/// Split from the call sites so the wording is unit-testable and identical
|
||||
/// everywhere an image is about to be dropped.
|
||||
pub(crate) fn vision_preflight(
|
||||
model: &str,
|
||||
config: &crate::openhuman::config::Config,
|
||||
) -> Result<(), String> {
|
||||
if crate::openhuman::inference::model_context::model_supports_vision(model, config) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(local_vision_unsupported_message(model))
|
||||
}
|
||||
|
||||
fn capitalize_first(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chat_tier_roles_do_not_fall_back_to_cloud() {
|
||||
// These inherit a BYOK slug instead; including them would attach the
|
||||
// "your local model can't serve this" explanation to the very roles the
|
||||
// user did configure locally.
|
||||
for role in ["chat", "reasoning", "coding"] {
|
||||
assert!(
|
||||
!role_falls_back_to_cloud(role),
|
||||
"{role} must not be treated as a cloud-fallback role"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_roles_fall_back_to_cloud() {
|
||||
for role in [
|
||||
"vision",
|
||||
"embeddings",
|
||||
"memory",
|
||||
"summarization",
|
||||
"heartbeat",
|
||||
"learning",
|
||||
"subconscious",
|
||||
"agentic",
|
||||
"burst",
|
||||
] {
|
||||
assert!(
|
||||
role_falls_back_to_cloud(role),
|
||||
"{role} must be treated as a cloud-fallback role"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_role_does_not_fall_back() {
|
||||
assert!(!role_falls_back_to_cloud("not-a-role"));
|
||||
assert!(!role_falls_back_to_cloud(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_falls_back_to_cloud_ignores_surrounding_whitespace() {
|
||||
assert!(role_falls_back_to_cloud(" vision "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_points_at_the_agentic_knob_that_actually_exists() {
|
||||
// There is no `burst_provider`; telling the user to set one would be a
|
||||
// dead end.
|
||||
assert_eq!(override_knob_for_role("burst"), "agentic");
|
||||
assert_eq!(override_knob_for_role("summarization"), "memory");
|
||||
assert_eq!(override_knob_for_role("vision"), "vision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_notice_names_capability_local_model_and_override() {
|
||||
let msg = cloud_fallback_notice("vision", "ollama:gemma3:1b", "anthropic:claude-sonnet-4");
|
||||
assert!(msg.contains("Vision (image input)"), "got: {msg}");
|
||||
assert!(msg.contains("ollama:gemma3:1b"), "got: {msg}");
|
||||
assert!(msg.contains("anthropic:claude-sonnet-4"), "got: {msg}");
|
||||
assert!(msg.contains("vision_provider"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_notice_for_role_without_capability_label_uses_role_name() {
|
||||
let msg = cloud_fallback_notice("heartbeat", "ollama:gemma3:1b", "openhuman");
|
||||
assert!(msg.contains("Heartbeat"), "got: {msg}");
|
||||
assert!(msg.contains("heartbeat_provider"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_credentials_message_explains_the_local_fallback() {
|
||||
let msg =
|
||||
missing_provider_credentials_message("vision", "anthropic", Some("ollama:gemma3:1b"));
|
||||
// The whole point of #5146 §2.1: the user must learn *why* a provider
|
||||
// they never configured is being asked for a key.
|
||||
assert!(msg.contains("anthropic"), "got: {msg}");
|
||||
assert!(msg.contains("ollama:gemma3:1b"), "got: {msg}");
|
||||
assert!(msg.contains("vision (image input)"), "got: {msg}");
|
||||
assert!(msg.contains("Connections"), "got: {msg}");
|
||||
assert!(msg.contains("vision_provider"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_credentials_message_without_local_chat_omits_the_local_clause() {
|
||||
let msg = missing_provider_credentials_message("embeddings", "openai", None);
|
||||
assert!(msg.contains("openai"), "got: {msg}");
|
||||
assert!(msg.contains("embeddings"), "got: {msg}");
|
||||
assert!(
|
||||
!msg.contains("Your chat model is local"),
|
||||
"must not claim a local chat model when there is none: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_credentials_message_treats_blank_local_provider_as_absent() {
|
||||
let msg = missing_provider_credentials_message("embeddings", "openai", Some(" "));
|
||||
assert!(
|
||||
!msg.contains("Your chat model is local"),
|
||||
"blank provider string is not a local model: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vision_unsupported_message_names_model_and_a_concrete_remedy() {
|
||||
let msg = local_vision_unsupported_message("gemma3:1b");
|
||||
assert!(msg.contains("gemma3:1b"), "got: {msg}");
|
||||
assert!(msg.contains("llava:7b"), "got: {msg}");
|
||||
assert!(msg.contains("vision_provider"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vision_preflight_allows_a_vision_capable_model() {
|
||||
use crate::openhuman::config::schema::ModelRegistryEntry;
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.model_registry = vec![ModelRegistryEntry {
|
||||
id: "llava:7b".into(),
|
||||
provider: "ollama".into(),
|
||||
cost_per_1m_output: 0.0,
|
||||
vision: true,
|
||||
..Default::default()
|
||||
}];
|
||||
assert!(vision_preflight("llava:7b", &config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vision_preflight_rejects_a_text_only_model_with_an_actionable_message() {
|
||||
let config = crate::openhuman::config::Config::default();
|
||||
let err = vision_preflight("gemma3:1b", &config)
|
||||
.expect_err("a text-only model must fail the vision pre-flight");
|
||||
assert!(err.contains("gemma3:1b"), "got: {err}");
|
||||
assert!(err.contains("llava:7b"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vision_preflight_allows_the_managed_vision_tier() {
|
||||
// `vision-v1` is multimodal per `oh_tier_supports_vision`; the
|
||||
// pre-flight must not fire for the managed vision sub-agent.
|
||||
let config = crate::openhuman::config::Config::default();
|
||||
assert!(vision_preflight("vision-v1", &config).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub mod crate_openai;
|
||||
pub mod error_classify;
|
||||
pub mod error_code;
|
||||
pub mod factory;
|
||||
/// Actionable diagnostics for background-workload provider fallback (#5146 §2.1).
|
||||
pub(crate) mod fallback_diagnostics;
|
||||
mod openai_codex;
|
||||
/// Crate-native managed OpenHuman backend as a host `ChatModel` (issue #4727).
|
||||
pub mod openhuman_backend_model;
|
||||
|
||||
Reference in New Issue
Block a user