From ddf4b49d82de52c3f333652c35641f30cdd15f0f Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sat, 13 Jun 2026 19:27:37 +0300 Subject: [PATCH] fix: chat works with any configured provider + surfaces errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat defaulted to a Claude model, so a user who saved only a Gemini key got a silent failure (backend derived Anthropic, found no key, errored — and the UI showed nothing). - Backend: resolveModel now falls back to whichever provider actually has a key (preferring the configured one), so a Gemini key just works regardless of the picked model. Clear 400 if no provider is configured at all. - Frontend: the chat seeds its model/effort from the saved AI config, and now surfaces request failures as both a persistent alert banner and a toast — never silent. - Settings: switching provider auto-selects that provider's default model. - Refreshed the model catalog to current ids (Gemini 2.5 Pro/Flash + 2.0 Flash; dropped the retired gemini-1.5-pro); per-provider default model updated. Co-Authored-By: Claude Opus 4.8 --- backend/src/services/ai/provider.ts | 55 ++++++++++++++---- frontend/components/chat/chat-panel.tsx | 57 ++++++++++++++++++- frontend/components/settings/settings-ai.tsx | 17 +++++- frontend/lib/ai-models.ts | 33 ++++++----- frontend/lib/i18n/locales/en/translation.json | 9 ++- 5 files changed, 139 insertions(+), 32 deletions(-) diff --git a/backend/src/services/ai/provider.ts b/backend/src/services/ai/provider.ts index c6e4d8c..eda2e2a 100644 --- a/backend/src/services/ai/provider.ts +++ b/backend/src/services/ai/provider.ts @@ -39,6 +39,28 @@ const PROVIDER_LABELS: Record = { gemini: "Google Gemini", }; +const PROVIDER_ORDER: ApiProvider[] = ["anthropic", "openai", "gemini"]; + +// A safe default model id for each provider, used when the picked model doesn't +// belong to the provider we end up calling. +const DEFAULT_MODEL: Record = { + anthropic: "claude-sonnet-4-6", + openai: "gpt-4o", + gemini: "gemini-2.5-flash", +}; + +// Choose which provider to actually call: prefer the one the picked model maps +// to (if it has a key), else the user's configured provider (if keyed), else any +// provider that has a key. Returns null when no key is configured at all. +function chooseProvider( + settings: AiSettingsRow, + requested: ApiProvider | null, +): ApiProvider | null { + if (requested && getApiKey(settings, requested)) return requested; + if (getApiKey(settings, settings.provider)) return settings.provider; + return PROVIDER_ORDER.find((p) => getApiKey(settings, p)) ?? null; +} + // Resolve a concrete LanguageModel for a request. `requestedModelId` is the id // the user picked in the chat input; when it maps to a cloud provider we use // that provider's stored key, otherwise we fall back to local Ollama (also used @@ -47,11 +69,8 @@ export function resolveModel( settings: AiSettingsRow, requestedModelId: string, ): ResolvedModel { - const provider = - settings.mode === "local" ? null : providerForModel(requestedModelId); - - if (!provider) { - // Local mode via Ollama's OpenAI-compatible endpoint. No key required. + // Local mode (or the local sentinel) → Ollama's OpenAI-compatible endpoint. + if (settings.mode === "local" || requestedModelId === OLLAMA_SENTINEL) { const ollama = createOpenAICompatible({ name: "ollama", baseURL: `${settings.ollamaBaseUrl.replace(/\/$/, "")}/v1`, @@ -63,20 +82,34 @@ export function resolveModel( }; } - const apiKey = getApiKey(settings, provider); - if (!apiKey) { + // API mode. Pick a provider that actually has a key, falling back gracefully + // so a configured key (e.g. Gemini) is used even if the picked model belongs + // to a different, unconfigured provider. + const requested = providerForModel(requestedModelId); + const provider = chooseProvider(settings, requested); + if (!provider) { throw new HttpError( 400, - `No API key configured for ${PROVIDER_LABELS[provider]}. Add one in Settings → AI.`, + "No AI provider API key is configured. Add one in Settings → AI, or switch to a local model.", ); } + // Use the picked model only if it belongs to the chosen provider; otherwise + // use the user's default (or a per-provider default). + const modelName = + requested === provider + ? requestedModelId + : providerForModel(settings.defaultModel) === provider + ? settings.defaultModel + : DEFAULT_MODEL[provider]; + + const apiKey = getApiKey(settings, provider)!; const model: LanguageModel = provider === "anthropic" - ? createAnthropic({ apiKey })(requestedModelId) + ? createAnthropic({ apiKey })(modelName) : provider === "gemini" - ? createGoogleGenerativeAI({ apiKey })(requestedModelId) - : createOpenAI({ apiKey })(requestedModelId); + ? createGoogleGenerativeAI({ apiKey })(modelName) + : createOpenAI({ apiKey })(modelName); return { model, isExternal: true, providerLabel: PROVIDER_LABELS[provider] }; } diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index 230eaef..8a479be 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -2,6 +2,7 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; +import { AlertTriangle } from "lucide-react"; import { nanoid } from "nanoid"; import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -38,8 +39,10 @@ import { getModel, } from "@/lib/ai-models"; import type { TemetroUIMessage } from "@/lib/ai-chat"; +import { getAiConfig } from "@/lib/ai-settings"; import { API_BASE_URL } from "@/lib/api-client"; import { getPatient } from "@/lib/patients"; +import { notify } from "@/lib/toast"; // Trigger: `/patient 10293` or just `/10293` — a client-side fast-path that // pulls records instantly without the LLM (also works offline). @@ -65,9 +68,36 @@ export function ChatPanel() { [], ); - const { messages, setMessages, sendMessage, status, stop } = + const { messages, setMessages, sendMessage, status, stop, error } = useChat({ transport }); + // Seed the model + effort from the user's saved AI config so the chat uses the + // provider they actually configured (e.g. their Gemini default), not a stale + // hardcoded default. + useEffect(() => { + let cancelled = false; + getAiConfig() + .then((cfg) => { + if (cancelled) return; + setModel(cfg.mode === "local" ? "ollama" : cfg.defaultModel); + setEffort(cfg.defaultEffort); + }) + .catch(() => { + // Keep defaults; the chat still works and the backend falls back to any + // configured provider. + }); + return () => { + cancelled = true; + }; + }, []); + + // Pop a toast whenever a request errors, so failures are never silent. + useEffect(() => { + if (error) { + notify.error(t("chat.error.title"), error.message || t("chat.error.body")); + } + }, [error, t]); + const isCloudModel = (getModel(model)?.provider ?? "ollama") !== "ollama"; // Run the LLM agent for a message (after any consent gate). @@ -158,6 +188,21 @@ export function ChatPanel() { /> ); + const errorAlert = error ? ( +
+ +
+

{t("chat.error.title")}

+

+ {error.message || t("chat.error.body")} +

+
+
+ ) : null; + const consentDialog = ( @@ -191,7 +236,10 @@ export function ChatPanel() {

{t("chat.heading")}

- {promptInput} +
+ {errorAlert} + {promptInput} +
{consentDialog} @@ -240,7 +288,10 @@ export function ChatPanel() { -
{promptInput}
+
+ {errorAlert} + {promptInput} +
{consentDialog} ); diff --git a/frontend/components/settings/settings-ai.tsx b/frontend/components/settings/settings-ai.tsx index 420347d..6d65787 100644 --- a/frontend/components/settings/settings-ai.tsx +++ b/frontend/components/settings/settings-ai.tsx @@ -71,6 +71,19 @@ export function AIPanel() { const set = (key: K, value: AiConfig[K]) => setConfig((prev) => ({ ...prev, [key]: value })); + // Switching provider: if the current default model doesn't belong to the new + // provider, pick that provider's first model so they never end up mismatched. + const setProvider = (provider: ApiProvider) => + setConfig((prev) => { + const models = AI_MODELS.filter((m) => m.provider === provider); + const stillValid = models.some((m) => m.id === prev.defaultModel); + return { + ...prev, + provider, + defaultModel: stillValid ? prev.defaultModel : (models[0]?.id ?? prev.defaultModel), + }; + }); + // Models available for the currently selected cloud provider. const providerModels = useMemo( () => AI_MODELS.filter((m) => m.provider === config.provider), @@ -169,9 +182,7 @@ export function AIPanel() {
{t("settings.ai.provider")}