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:
Mega Mind
2026-07-29 05:59:27 +03:00
committed by GitHub
co-authored by M3gA-Mind
parent 4811ea4b0c
commit 73d3439301
23 changed files with 1277 additions and 37 deletions
+12 -1
View File
@@ -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 () => {
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+14
View File
@@ -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');
});
});
+217
View File
@@ -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
+114 -35
View File
@@ -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());
}
}
+2
View File
@@ -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;