From 73d3439301e58515a4849996d1d627e863bbce8f Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:29:27 +0530 Subject: [PATCH] =?UTF-8?q?fix(inference):=20explain=20background-role=20p?= =?UTF-8?q?rovider=20fallback=20and=20verify=20BYOK=20keys=20(#5146=20?= =?UTF-8?q?=C2=A72.1,=20=C2=A72.4)=20(#5254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: M3gA-Mind --- .../components/settings/panels/AIPanel.tsx | 13 +- .../panels/__tests__/AIPanel.test.tsx | 13 +- app/src/lib/i18n/ar.ts | 14 + app/src/lib/i18n/bn.ts | 14 + app/src/lib/i18n/de.ts | 14 + app/src/lib/i18n/en.ts | 14 + app/src/lib/i18n/es.ts | 14 + app/src/lib/i18n/fr.ts | 14 + app/src/lib/i18n/hi.ts | 14 + app/src/lib/i18n/id.ts | 14 + app/src/lib/i18n/it.ts | 14 + app/src/lib/i18n/ko.ts | 14 + app/src/lib/i18n/pl.ts | 14 + app/src/lib/i18n/pt.ts | 14 + app/src/lib/i18n/ru.ts | 14 + app/src/lib/i18n/zh-CN.ts | 14 + .../api/__tests__/aiSettingsApi.test.ts | 221 ++++++++++++ app/src/services/api/aiSettingsApi.ts | 217 ++++++++++++ .../agent/harness/session/builder/factory.rs | 13 + src/openhuman/inference/provider/factory.rs | 149 ++++++-- .../inference/provider/factory_tests.rs | 158 +++++++++ .../provider/fallback_diagnostics.rs | 332 ++++++++++++++++++ src/openhuman/inference/provider/mod.rs | 2 + 23 files changed, 1277 insertions(+), 37 deletions(-) create mode 100644 src/openhuman/inference/provider/fallback_diagnostics.rs diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 074fafe65..d0d51c90b 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -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); diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index 2daa48468..d2d6143b3 100644 --- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx @@ -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 () => { diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index e2806cacb..cb2e44eb9 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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}', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 4918567ff..e7e8c4757 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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}', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 3a3a0c900..f0e59185d 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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}', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 88638139e..3ddfb72a4 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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}', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index d06f9f4c7..d7586faa2 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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}', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 5e485f507..f95c2d1eb 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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}', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index a5ff61712..57b9c7797 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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}', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index b56586988..c4c6d9eab 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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}', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index fb444c5be..253f48682 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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}', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 02cb83784..090de3b07 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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}', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 1461f8e6b..34150f4ab 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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}', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index cd4a3e57e..7e65e8f2b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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}', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 3d3e9ca2e..88346750d 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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}', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 70d1b6e7e..729be85fd 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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}', diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index e44a9a360..a8b89ace0 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -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 `:`, 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'); + }); +}); diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index 9c5681c6f..9d0faf4ac 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -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 { + 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 { + // The core only builds a configured cloud provider from the `:` + // 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 { if (slug === 'openhuman') { diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 96e555333..51e2e0002 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -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 diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 5f3842899..6685d11b6 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -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)?; diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index c4565b4f8..b54f1f20c 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -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" + ); + } +} diff --git a/src/openhuman/inference/provider/fallback_diagnostics.rs b/src/openhuman/inference/provider/fallback_diagnostics.rs new file mode 100644 index 000000000..1ef514f06 --- /dev/null +++ b/src/openhuman/inference/provider/fallback_diagnostics.rs @@ -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::() + 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()); + } +} diff --git a/src/openhuman/inference/provider/mod.rs b/src/openhuman/inference/provider/mod.rs index 533411129..05152325e 100644 --- a/src/openhuman/inference/provider/mod.rs +++ b/src/openhuman/inference/provider/mod.rs @@ -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;