From d5669906cb251ecade02dfc99d90a5d4119b276c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 29 Jul 2026 02:24:22 +0530 Subject: [PATCH 1/4] fix(inference): explain background-role provider fallback and verify BYOK keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #5146 §2.1 and §2.4. §2.1 — spurious cross-provider key errors A user on a local Ollama chat model who attaches an image gets "failed to read API key for slug 'anthropic'", naming a provider they never configured. The cause is `provider_for_role`: the background roles (vision, embeddings, memory, heartbeat, learning, subconscious, agentic, burst) fall through to the primary cloud provider when their own route is unset, and the resulting auth failure surfaces as a bare slug error. That routing is deliberate and stays. Background workloads run tier-specific models (vision-v1, summarization-v1, ...) that local runtimes do not serve, and a local-chat + managed-subscription user genuinely wants them on the cloud. Hard-failing here would break that configuration; users who want no cloud egress at all already have PrivacyMode::LocalOnly, enforced at the inference chokepoint. What was missing is the explanation. New `fallback_diagnostics` module holds the wording as pure, unit-testable functions, wired at two sites: - `provider_for_role` now logs why a background role went to the cloud, naming the local chat model and the config knob that overrides it (`burst` correctly points at `agentic_provider`, which exists, rather than a `burst_provider` that does not). - `resolve_cloud_slug` wraps the key-lookup failure with that context, so the error explains that a background role fell back, why, and what to change — instead of naming an unconfigured provider with no reason. A vision pre-flight logs the actionable "switch to llava:7b" message at the point the session builder decides a model cannot take images, where the turn engine would otherwise strip the image silently. §2.4 — connected but unusable Storing a key only proves it was written to disk, and the existing save probe calls /models, which proves the key and endpoint are valid but not that inference works. Adds `verifyCloudProviderConnection`, which runs a real minimal completion through the existing `openhuman.inference_test_provider_model` RPC and never throws, plus `describeProviderVerificationFailure`, which maps the common upstream shapes (401, model_not_found, quota/429, bare 404, timeout) onto concrete next steps and passes anything unrecognised through verbatim. The manual provider Test action now reports the classified message instead of a raw upstream string. The automatic post-save probe is deliberately not wired yet: the RPC takes a fully-qualified `slug:model` provider string, and guessing one from the provider entry risks a false negative that would roll back a working provider. Tracked in the PR body as follow-up. §2.3 (DeepSeek) is not in this change — see the PR body. Tests: Rust unit tests pin the diagnostics wording, the knob mapping, and the vision pre-flight, plus `provider_for_role` regressions asserting the background-role routing is unchanged and that the drift between the diagnostics role list and the real fallback set is caught. Vitest covers every branch of the failure classifier and the verification result shape. --- .../components/settings/panels/AIPanel.tsx | 7 +- .../api/__tests__/aiSettingsApi.test.ts | 117 ++++++ app/src/services/api/aiSettingsApi.ts | 98 ++++++ .../agent/harness/session/builder/factory.rs | 13 + src/openhuman/inference/provider/factory.rs | 43 ++- .../inference/provider/factory_tests.rs | 91 +++++ .../provider/fallback_diagnostics.rs | 332 ++++++++++++++++++ src/openhuman/inference/provider/mod.rs | 2 + 8 files changed, 687 insertions(+), 16 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 074fafe654..b592e828b9 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,11 @@ 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); + setTestError(describeProviderVerificationFailure(currentProviderString, raw)); } finally { if (testRequestIdRef.current === requestId) { setTestBusy(false); diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index e44a9a3609..11ce51edf2 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -12,6 +12,7 @@ import { type AISettings, clearCloudProviderKey, completeOpenAiCodexOAuth, + describeProviderVerificationFailure, flushCloudProviders, importOpenAiCodexCliAuth, listProviderModels, @@ -31,6 +32,7 @@ import { startOpenAiCodexOAuth, testProviderModel, upsertModelRegistryVision, + verifyCloudProviderConnection, } from '../aiSettingsApi'; // ─── Mock declarations (must be hoisted before imports) ─────────────────────── @@ -1078,3 +1080,118 @@ describe('model registry vision helpers', () => { expect(modelRegistryVision(flipped, 'openai', 'text-only')).toBe(true); }); }); + +// ─── #5146 §2.4: connected-but-unusable providers ───────────────────────────── + +describe('describeProviderVerificationFailure', () => { + it('maps an auth rejection onto a key-checking remedy', () => { + const msg = describeProviderVerificationFailure('openai', 'HTTP 401 invalid_api_key'); + 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." + ); + 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)).toContain( + 'quota or billing reasons' + ); + } + }); + + it('maps a bare 404 onto the /v1 base-url remedy', () => { + const msg = describeProviderVerificationFailure('custom', 'HTTP 404'); + expect(msg).toContain('/v1'); + expect(msg).toContain('base URL'); + }); + + it('maps a timeout onto an endpoint/network remedy', () => { + expect(describeProviderVerificationFailure('custom', 'request timed out')).toContain( + 'did not respond in time' + ); + }); + + it('passes an unrecognised error through verbatim rather than inventing a diagnosis', () => { + const msg = describeProviderVerificationFailure('custom', 'kaboom: something novel'); + expect(msg).toContain('kaboom: something novel'); + }); + + it('does not produce a dangling colon when the error string is empty', () => { + expect(describeProviderVerificationFailure('custom', ' ')).toContain('unknown error'); + }); + + it('classifies case-insensitively', () => { + expect(describeProviderVerificationFailure('openai', 'INVALID API KEY')).toContain( + 'rejected it' + ); + }); +}); + +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'); + }); +}); diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index 9c5681c6f8..ed6b011261 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -462,6 +462,104 @@ 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; +} + +/** + * 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. + */ +export function describeProviderVerificationFailure(slug: string, raw: string): string { + const detail = raw.trim(); + const haystack = detail.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 `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.`; + } + if ( + haystack.includes('model_not_found') || + haystack.includes('does not exist') || + haystack.includes('is not available') || + haystack.includes('unknown model') || + haystack.includes('invalid model') + ) { + return `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).`; + } + if ( + haystack.includes('quota') || + haystack.includes('insufficient') || + haystack.includes('billing') || + haystack.includes('429') || + haystack.includes('rate limit') + ) { + return `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.`; + } + if (haystack.includes('404') || haystack.includes('not found')) { + return `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).`; + } + if (haystack.includes('timeout') || haystack.includes('timed out')) { + return `The key was saved, but '${slug}' did not respond in time. Check the endpoint URL and your network, then test again.`; + } + return `The key was saved, but a test call to '${slug}' failed: ${detail || 'unknown error'}`; +} + +/** + * 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( + slug: string, + workload: WorkloadId = 'chat' +): Promise { + try { + const result = await testProviderModel(workload, slug, 'ping'); + const reply = (result?.reply ?? '').trim(); + if (!reply) { + return { + ok: false, + message: `The key was saved, but '${slug}' returned an empty response to a test prompt. Check the model id configured for this provider.`, + detail: '', + }; + } + return { ok: true, message: '', detail: '' }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { + ok: false, + message: describeProviderVerificationFailure(slug, detail), + 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 96e5553339..51e2e0002a 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 5f3842899c..4af1673408 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -362,29 +362,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 +2203,20 @@ 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. + let key = lookup_key_for_slug(slug, config).map_err(|e| { + let local_chat = config.chat_provider.as_deref().filter(|chat| { + crate::openhuman::inference::local::profile::is_local_provider_string(chat) + }); + let guidance = super::fallback_diagnostics::missing_provider_credentials_message( + role, slug, local_chat, + ); + anyhow::anyhow!("{guidance} (underlying error: {e})") + })?; 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 c4565b4f8f..b065d2ef2e 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -1470,3 +1470,94 @@ 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", + "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 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", + "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 0000000000..1ef514f06e --- /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 533411129a..05152325e6 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; From c5789e151691f94388a4a6f22f0360ca76590a82 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 29 Jul 2026 03:37:15 +0530 Subject: [PATCH 2/4] fix(inference): address review on BYOK routing diagnostics (#5146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend Checks was red on Prettier; that is fixed, and the review items on top of it: i18n (codex P1, CodeRabbit major) — `describeProviderVerificationFailure` returned six hard-coded English sentences that `AIPanel` renders verbatim, so non-English builds showed English and the i18n tooling could not see the copy. Classification is now split from wording: `classifyProviderVerificationFailure` returns a reason code, and the message resolves `settings.ai.providerTest.*` through the translator, following the existing `presentProviderSetupError` idiom. Eight keys added to en.ts and all 13 locale files with real translations, placeholders preserved, no em dashes. Wrong identifier (CodeRabbit major) — AIPanel passed `currentProviderString` (the composite `provider:model[@temp]`) where a bare slug was expected, so the banner read "'openai:gpt-4o' rejected it". It now passes `registrySlug`. Model-qualified verification (codex P2) — the core only builds a configured cloud provider from `:`, so `verifyCloudProviderConnection` would have failed a valid key when handed a bare slug. It now forwards the composite verbatim and derives the bare slug for the message. Diagnostics (CodeRabbit major/minor) — `verifyCloudProviderConnection` logs entry/empty-reply/ok/failure, and the credential-lookup failure logs role, slug, auth style and whether the route was an implicit fallback. Never the underlying error or the key. Empty BYOK key (codex P2) — a readable auth profile with no key for the slug returns `Ok("")`, which built a client with an empty bearer and surfaced as a raw 401 later: exactly the error this diagnostic replaces. An empty key is now treated as missing for the credentialed auth styles (`OpenhumanJwt` and `None` are legitimately blank). Fallback attribution (CodeRabbit major) — an explicitly configured cloud route that fails key lookup was described as a local-chat fallback. The role→route mapping is factored out of `provider_for_role` into `configured_route_for_role`, and `role_uses_implicit_cloud_fallback` gates the explanation so only a genuine implicit fallback names the local chat model. Tests: `summarization` added to both fallback matrices (CodeRabbit), plus coverage for the empty-key path, the implicit-fallback predicate, the i18n key resolution, and model-qualified verification. --- .../components/settings/panels/AIPanel.tsx | 4 +- app/src/lib/i18n/ar.ts | 15 ++ app/src/lib/i18n/bn.ts | 15 ++ app/src/lib/i18n/de.ts | 15 ++ app/src/lib/i18n/en.ts | 15 ++ app/src/lib/i18n/es.ts | 15 ++ app/src/lib/i18n/fr.ts | 15 ++ app/src/lib/i18n/hi.ts | 15 ++ app/src/lib/i18n/id.ts | 15 ++ app/src/lib/i18n/it.ts | 15 ++ app/src/lib/i18n/ko.ts | 15 ++ app/src/lib/i18n/pl.ts | 15 ++ app/src/lib/i18n/pt.ts | 15 ++ app/src/lib/i18n/ru.ts | 15 ++ app/src/lib/i18n/zh-CN.ts | 14 ++ .../api/__tests__/aiSettingsApi.test.ts | 113 +++++++++++-- app/src/services/api/aiSettingsApi.ts | 158 +++++++++++++++--- src/openhuman/inference/provider/factory.rs | 108 +++++++++--- .../inference/provider/factory_tests.rs | 57 +++++++ 19 files changed, 585 insertions(+), 64 deletions(-) diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index b592e828b9..4fdd970c59 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -2157,7 +2157,9 @@ const CustomRoutingDialog = ({ // 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); - setTestError(describeProviderVerificationFailure(currentProviderString, 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/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 7e49f8021b..037c22a8a4 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4600,6 +4600,21 @@ 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}': {detail}", + 'settings.ai.providerTest.unknownError': 'خطأ غير معروف', '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 d070aef6e9..fd6b6dee54 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4714,6 +4714,21 @@ 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}' এ পরীক্ষামূলক কল ব্যর্থ হয়েছে: {detail}", + 'settings.ai.providerTest.unknownError': 'অজানা ত্রুটি', '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 a18f98ba11..179c502121 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4847,6 +4847,21 @@ 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: {detail}", + 'settings.ai.providerTest.unknownError': 'unbekannter Fehler', '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 448a30fd1d..4196139199 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5302,6 +5302,21 @@ 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: {detail}", + 'settings.ai.providerTest.unknownError': 'unknown error', '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 5318855fa8..29590b78ab 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4794,6 +4794,21 @@ 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ó: {detail}", + 'settings.ai.providerTest.unknownError': 'error desconocido', '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 f1b3936014..f0e77e6750 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4824,6 +4824,21 @@ 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é : {detail}", + 'settings.ai.providerTest.unknownError': 'erreur inconnue', '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 d61df65f23..f1532cabc5 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4714,6 +4714,21 @@ 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}' पर परीक्षण कॉल विफल रही: {detail}", + 'settings.ai.providerTest.unknownError': 'अज्ञात त्रुटि', '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 8942170e48..9fdcc51384 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4731,6 +4731,21 @@ 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: {detail}", + 'settings.ai.providerTest.unknownError': 'kesalahan tidak diketahui', '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 647ba4720a..e0af4f92f3 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4787,6 +4787,21 @@ 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: {detail}", + 'settings.ai.providerTest.unknownError': 'errore sconosciuto', '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 8f98a32375..ac153e67e2 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4660,6 +4660,21 @@ 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}'에 대한 테스트 호출이 실패했습니다: {detail}", + 'settings.ai.providerTest.unknownError': '알 수 없는 오류', '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 679f2f734f..2253c87180 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4785,6 +4785,21 @@ 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ę: {detail}", + 'settings.ai.providerTest.unknownError': 'nieznany błąd', '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 ee442f7b6a..34127b1a2c 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4781,6 +4781,21 @@ 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: {detail}", + 'settings.ai.providerTest.unknownError': 'erro desconhecido', '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 6bb4c55935..68edef3982 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4758,6 +4758,21 @@ 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}' завершился ошибкой: {detail}", + 'settings.ai.providerTest.unknownError': 'неизвестная ошибка', '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 8aaea9009b..8123b022ad 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4455,6 +4455,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}' 的测试调用失败:{detail}", + 'settings.ai.providerTest.unknownError': '未知错误', '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 11ce51edf2..70fb21cd10 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -10,6 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type AISettings, + classifyProviderVerificationFailure, clearCloudProviderKey, completeOpenAiCodexOAuth, describeProviderVerificationFailure, @@ -1083,9 +1084,18 @@ describe('model registry vision helpers', () => { // ─── #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'); + 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. @@ -1095,46 +1105,100 @@ describe('describeProviderVerificationFailure', () => { 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." + '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)).toContain( + 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'); + 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')).toContain( + expect(describeProviderVerificationFailure('custom', 'request timed out', tFallback)).toContain( 'did not respond in time' ); }); it('passes an unrecognised error through verbatim rather than inventing a diagnosis', () => { - const msg = describeProviderVerificationFailure('custom', 'kaboom: something novel'); + const msg = describeProviderVerificationFailure('custom', 'kaboom: something novel', tFallback); expect(msg).toContain('kaboom: something novel'); }); it('does not produce a dangling colon when the error string is empty', () => { - expect(describeProviderVerificationFailure('custom', ' ')).toContain('unknown error'); + expect(describeProviderVerificationFailure('custom', ' ', tFallback)).toContain( + 'unknown error' + ); }); it('classifies case-insensitively', () => { - expect(describeProviderVerificationFailure('openai', 'INVALID API KEY')).toContain( + 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'); + expect(classifyProviderVerificationFailure('request timed out')).toBe('timeout'); + expect(classifyProviderVerificationFailure('kaboom')).toBe('unknown'); + expect(classifyProviderVerificationFailure('')).toBe('unknown'); + }); }); describe('verifyCloudProviderConnection', () => { @@ -1160,9 +1224,7 @@ describe('verifyCloudProviderConnection', () => { await verifyCloudProviderConnection('openai'); expect(mockCallCoreRpc).toHaveBeenCalledWith( - expect.objectContaining({ - params: { workload: 'chat', provider: 'openai', prompt: 'ping' }, - }) + expect.objectContaining({ params: { workload: 'chat', provider: 'openai', prompt: 'ping' } }) ); }); @@ -1194,4 +1256,31 @@ describe('verifyCloudProviderConnection', () => { 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 ed6b011261..93073d107d 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -477,18 +477,36 @@ export interface CloudProviderVerification { 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; + /** - * Turn a raw provider/RPC failure into something a user can act on (#5146 §2.4). + * Which failure a raw provider/RPC error represents (#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. + * 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 function describeProviderVerificationFailure(slug: string, raw: string): string { - const detail = raw.trim(); - const haystack = detail.toLowerCase(); +export type ProviderVerificationReason = + | 'auth' + | 'model' + | 'quota' + | 'endpoint' + | 'timeout' + | 'empty' + | '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') || @@ -498,7 +516,7 @@ export function describeProviderVerificationFailure(slug: string, raw: string): haystack.includes('unauthorized') || haystack.includes('authentication') ) { - return `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.`; + return 'auth'; } if ( haystack.includes('model_not_found') || @@ -507,7 +525,7 @@ export function describeProviderVerificationFailure(slug: string, raw: string): haystack.includes('unknown model') || haystack.includes('invalid model') ) { - return `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).`; + return 'model'; } if ( haystack.includes('quota') || @@ -516,15 +534,89 @@ export function describeProviderVerificationFailure(slug: string, raw: string): haystack.includes('429') || haystack.includes('rate limit') ) { - return `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.`; + return 'quota'; } if (haystack.includes('404') || haystack.includes('not found')) { - return `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).`; + return 'endpoint'; } if (haystack.includes('timeout') || haystack.includes('timed out')) { - return `The key was saved, but '${slug}' did not respond in time. Check the endpoint URL and your network, then test again.`; + 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: + return fillTemplate( + t( + 'settings.ai.providerTest.unknown', + "The key was saved, but a test call to '{slug}' failed: {detail}" + ), + { slug, detail: detail || t('settings.ai.providerTest.unknownError', 'unknown error') } + ); } - return `The key was saved, but a test call to '${slug}' failed: ${detail || 'unknown error'}`; } /** @@ -536,27 +628,45 @@ export function describeProviderVerificationFailure(slug: string, raw: string): * always wants to show it rather than lose the save. */ export async function verifyCloudProviderConnection( - slug: string, - workload: WorkloadId = 'chat' + 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, slug, 'ping'); + 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: `The key was saved, but '${slug}' returned an empty response to a test prompt. Check the model id configured for this provider.`, + 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); - return { - ok: false, - message: describeProviderVerificationFailure(slug, detail), - detail, - }; + // 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 }; } } diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 4af1673408..625ef253af 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. -/// -/// 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. +/// The provider route a role has **explicitly** configured, before any +/// fallback. /// -/// 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. @@ -2208,15 +2234,43 @@ fn resolve_cloud_slug<'a>( // 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. - let key = lookup_key_for_slug(slug, config).map_err(|e| { - let local_chat = config.chat_provider.as_deref().filter(|chat| { + // 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) - }); - let guidance = super::fallback_diagnostics::missing_provider_credentials_message( - role, slug, local_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 ); - anyhow::anyhow!("{guidance} (underlying error: {e})") - })?; + 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. Styles that carry no stored key + // (`OpenhumanJwt` injects a session JWT downstream, `None` sends no auth + // header at all) are legitimately blank and must not trip this. + if 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 b065d2ef2e..0469d46e3a 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -1489,6 +1489,7 @@ fn local_chat_still_routes_background_roles_to_the_managed_backend() { "vision", "embeddings", "memory", + "summarization", "heartbeat", "learning", "subconscious", @@ -1529,6 +1530,61 @@ fn explicit_background_route_overrides_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}" + ); +} + +#[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`, @@ -1543,6 +1599,7 @@ fn cloud_fallback_roles_match_the_roles_provider_for_role_actually_falls_back() "vision", "embeddings", "memory", + "summarization", "heartbeat", "learning", "subconscious", From 654c4d1c7b313cb72ede39b9f8fe64a1bf95568c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 29 Jul 2026 03:56:12 +0530 Subject: [PATCH 3/4] fix(ai-settings): keep raw provider errors out of the test banner (#5146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass on the verification messages. Raw error leakage (CodeRabbit, 4 threads) — the fallback branch interpolated the upstream provider/RPC string straight into user-visible copy, which can echo request material (headers, key fragments) into a screenshot-able banner and contradicted this PR's own rule that diagnostics must not carry the underlying error. The message is now generic; the raw text stays on `CloudProviderVerification.detail` for the details surface, and both callers log it (`console.error`) so it is still reachable for diagnosis. `model not found` crossfire (greptile P1) — the endpoint branch matches a bare `not found`, so providers phrasing a missing model as "model not found" or "The model `x` was not found" were told to check their base URL and `/v1` suffix instead of their model id. Classified as a model problem when the text mentions both, which covers the contiguous and the interpolated phrasings. Dead `empty` variant (greptile P2) — removed from `ProviderVerificationReason`: the empty-reply path is handled inside `verifyCloudProviderConnection` and never reaches the classifier, so the variant could only mislead. Locales updated for the new generic wording; `providerTest.unknownError` is gone with the `{detail}` interpolation it existed to fill. --- .../components/settings/panels/AIPanel.tsx | 4 +++ app/src/lib/i18n/ar.ts | 3 +- app/src/lib/i18n/bn.ts | 3 +- app/src/lib/i18n/de.ts | 3 +- app/src/lib/i18n/en.ts | 3 +- app/src/lib/i18n/es.ts | 3 +- app/src/lib/i18n/fr.ts | 3 +- app/src/lib/i18n/hi.ts | 3 +- app/src/lib/i18n/id.ts | 3 +- app/src/lib/i18n/it.ts | 3 +- app/src/lib/i18n/ko.ts | 3 +- app/src/lib/i18n/pl.ts | 3 +- app/src/lib/i18n/pt.ts | 3 +- app/src/lib/i18n/ru.ts | 3 +- app/src/lib/i18n/zh-CN.ts | 4 +-- .../api/__tests__/aiSettingsApi.test.ts | 29 ++++++++++++++----- app/src/services/api/aiSettingsApi.ts | 15 ++++++++-- 17 files changed, 53 insertions(+), 38 deletions(-) diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 4fdd970c59..d0d51c90b1 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -2157,6 +2157,10 @@ const CustomRoutingDialog = ({ // 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)); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 037c22a8a4..a204c74b0a 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4613,8 +4613,7 @@ const messages: TranslationMap = { 'settings.ai.providerTest.emptyReply': "تم حفظ المفتاح، لكن '{slug}' أعاد رداً فارغاً على رسالة اختبار. تحقق من معرّف النموذج المهيأ لهذا المزوّد.", 'settings.ai.providerTest.unknown': - "تم حفظ المفتاح، لكن فشل استدعاء اختباري إلى '{slug}': {detail}", - 'settings.ai.providerTest.unknownError': 'خطأ غير معروف', + "تم حفظ المفتاح، لكن فشل استدعاء اختباري إلى '{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 fd6b6dee54..499329c9ec 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4727,8 +4727,7 @@ const messages: TranslationMap = { 'settings.ai.providerTest.emptyReply': "কী সংরক্ষণ করা হয়েছে, কিন্তু '{slug}' পরীক্ষামূলক প্রম্পটে খালি উত্তর ফেরত দিয়েছে। এই প্রদানকারীর জন্য কনফিগার করা মডেল আইডি দেখুন।", 'settings.ai.providerTest.unknown': - "কী সংরক্ষণ করা হয়েছে, কিন্তু '{slug}' এ পরীক্ষামূলক কল ব্যর্থ হয়েছে: {detail}", - 'settings.ai.providerTest.unknownError': 'অজানা ত্রুটি', + "কী সংরক্ষণ করা হয়েছে, কিন্তু '{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 179c502121..ba05c58e96 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4860,8 +4860,7 @@ const messages: TranslationMap = { '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: {detail}", - 'settings.ai.providerTest.unknownError': 'unbekannter Fehler', + "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 4196139199..d17c5d0926 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5315,8 +5315,7 @@ const en: TranslationMap = { '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: {detail}", - 'settings.ai.providerTest.unknownError': 'unknown error', + "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 29590b78ab..7f1b7d4774 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4807,8 +4807,7 @@ const messages: TranslationMap = { '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ó: {detail}", - 'settings.ai.providerTest.unknownError': 'error desconocido', + "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 f0e77e6750..1699f2e243 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4837,8 +4837,7 @@ const messages: TranslationMap = { '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é : {detail}", - 'settings.ai.providerTest.unknownError': 'erreur inconnue', + "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 f1532cabc5..0c0978610c 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4727,8 +4727,7 @@ const messages: TranslationMap = { 'settings.ai.providerTest.emptyReply': "कुंजी सहेज दी गई, लेकिन '{slug}' ने परीक्षण संकेत पर खाली उत्तर लौटाया। इस प्रदाता के लिए कॉन्फ़िगर किया गया मॉडल आईडी जाँचें।", 'settings.ai.providerTest.unknown': - "कुंजी सहेज दी गई, लेकिन '{slug}' पर परीक्षण कॉल विफल रही: {detail}", - 'settings.ai.providerTest.unknownError': 'अज्ञात त्रुटि', + "कुंजी सहेज दी गई, लेकिन '{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 9fdcc51384..c33af485b7 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4744,8 +4744,7 @@ const messages: TranslationMap = { '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: {detail}", - 'settings.ai.providerTest.unknownError': 'kesalahan tidak diketahui', + "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 e0af4f92f3..5faddabd79 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4800,8 +4800,7 @@ const messages: TranslationMap = { '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: {detail}", - 'settings.ai.providerTest.unknownError': 'errore sconosciuto', + "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 ac153e67e2..ae6f053914 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4673,8 +4673,7 @@ const messages: TranslationMap = { 'settings.ai.providerTest.emptyReply': "키는 저장되었지만 '{slug}'가 테스트 프롬프트에 빈 응답을 반환했습니다. 이 제공업체에 설정된 모델 ID를 확인하세요.", 'settings.ai.providerTest.unknown': - "키는 저장되었지만 '{slug}'에 대한 테스트 호출이 실패했습니다: {detail}", - 'settings.ai.providerTest.unknownError': '알 수 없는 오류', + "키는 저장되었지만 '{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 2253c87180..cd3216903c 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4798,8 +4798,7 @@ const messages: TranslationMap = { '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ę: {detail}", - 'settings.ai.providerTest.unknownError': 'nieznany błąd', + "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 34127b1a2c..0e084af05b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4794,8 +4794,7 @@ const messages: TranslationMap = { '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: {detail}", - 'settings.ai.providerTest.unknownError': 'erro desconhecido', + "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 68edef3982..0c2a9b5bd9 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4771,8 +4771,7 @@ const messages: TranslationMap = { 'settings.ai.providerTest.emptyReply': "Ключ сохранён, но '{slug}' вернул пустой ответ на тестовый запрос. Проверьте идентификатор модели, настроенный для этого провайдера.", 'settings.ai.providerTest.unknown': - "Ключ сохранён, но тестовый вызов к '{slug}' завершился ошибкой: {detail}", - 'settings.ai.providerTest.unknownError': 'неизвестная ошибка', + "Ключ сохранён, но тестовый вызов к '{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 8123b022ad..60737e4657 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4467,8 +4467,8 @@ const messages: TranslationMap = { "密钥已保存,但 '{slug}' 未在规定时间内响应。请检查接口地址和网络后重新测试。", 'settings.ai.providerTest.emptyReply': "密钥已保存,但 '{slug}' 对测试提示返回了空响应。请检查为该服务商配置的模型 ID。", - 'settings.ai.providerTest.unknown': "密钥已保存,但对 '{slug}' 的测试调用失败:{detail}", - 'settings.ai.providerTest.unknownError': '未知错误', + '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 70fb21cd10..a8b89ace03 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -1136,15 +1136,25 @@ describe('describeProviderVerificationFailure', () => { ); }); - it('passes an unrecognised error through verbatim rather than inventing a diagnosis', () => { - const msg = describeProviderVerificationFailure('custom', 'kaboom: something novel', tFallback); - expect(msg).toContain('kaboom: something novel'); + 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('does not produce a dangling colon when the error string is empty', () => { - expect(describeProviderVerificationFailure('custom', ' ', tFallback)).toContain( - 'unknown error' - ); + 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', () => { @@ -1195,6 +1205,11 @@ describe('classifyProviderVerificationFailure', () => { 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'); diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index 93073d107d..9d0faf4ac3 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -501,7 +501,6 @@ export type ProviderVerificationReason = | 'quota' | 'endpoint' | 'timeout' - | 'empty' | 'unknown'; /** Map a raw upstream error string onto a [`ProviderVerificationReason`]. */ @@ -520,6 +519,11 @@ export function classifyProviderVerificationFailure(raw: string): ProviderVerifi } 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') || @@ -609,12 +613,17 @@ export function describeProviderVerificationFailure( { 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: {detail}" + "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, detail: detail || t('settings.ai.providerTest.unknownError', 'unknown error') } + { slug } ); } } From 4b0f26630ccb263f8f460e4d9e671ba255def09f Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 29 Jul 2026 04:17:29 +0530 Subject: [PATCH 4/4] fix(inference): scope the empty-key guard to the implicit fallback (#5146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the first pass being too broad: treating a blank key as missing for every Bearer/Anthropic slug failed 8 pre-existing factory tests that build cloud models without a stored key, plus the BYOK subconscious routing test. Callers legitimately construct such models to probe or describe a provider before a key is saved, so failing at construction time was a behaviour change nobody asked for. The guard now fires only on the implicit cloud-fallback path — the case the diagnostic exists for, and the one codex described: a local-chat user whose background role landed on a BYOK slug they never configured. Explicitly routed providers keep their existing behaviour. The test pins both halves. Also fixes the AIPanel test this PR had broken: the panel now imports `describeProviderVerificationFailure`, which the suite's `vi.mock` factory did not stub, so the dialog threw and rendered no alert at all. The mock now mirrors the mapped copy, and the assertion checks the actionable message rather than the raw upstream string the UI no longer shows. (Frontend Checks had been dying at Prettier before reaching vitest, which is why this surfaced only now.) --- .../panels/__tests__/AIPanel.test.tsx | 13 +++++++++++- src/openhuman/inference/provider/factory.rs | 20 +++++++++++++++---- .../inference/provider/factory_tests.rs | 10 ++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index 2daa48468d..d2d6143b39 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/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 625ef253af..6685d11b6f 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -2264,10 +2264,22 @@ fn resolve_cloud_slug<'a>( // 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. Styles that carry no stored key - // (`OpenhumanJwt` injects a session JWT downstream, `None` sends no auth - // header at all) are legitimately blank and must not trip this. - if key.trim().is_empty() && matches!(entry.auth_style, AuthStyle::Bearer | AuthStyle::Anthropic) + // 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()); } diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 0469d46e3a..b54f1f20c3 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -1556,6 +1556,16 @@ fn a_readable_profile_with_no_stored_key_is_treated_as_missing_credentials() { 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]