fix: chat works with any configured provider + surfaces errors

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 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-13 19:27:37 +03:00
parent a80086ef72
commit ddf4b49d82
5 changed files with 139 additions and 32 deletions
+44 -11
View File
@@ -39,6 +39,28 @@ const PROVIDER_LABELS: Record<ApiProvider, string> = {
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<ApiProvider, string> = {
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] };
}
+54 -3
View File
@@ -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<TemetroUIMessage>({ 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 ? (
<div
className="flex w-full items-start gap-2 rounded-2xl border border-destructive/40 bg-destructive/8 px-4 py-3 text-sm text-destructive-foreground"
role="alert"
>
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div className="space-y-0.5">
<p className="font-medium">{t("chat.error.title")}</p>
<p className="text-destructive-foreground/90">
{error.message || t("chat.error.body")}
</p>
</div>
</div>
) : null;
const consentDialog = (
<Dialog onOpenChange={setConsentOpen} open={consentOpen}>
<DialogPopup>
@@ -191,7 +236,10 @@ export function ChatPanel() {
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{t("chat.heading")}
</h1>
{promptInput}
<div className="flex w-full flex-col gap-3">
{errorAlert}
{promptInput}
</div>
</div>
{consentDialog}
</div>
@@ -240,7 +288,10 @@ export function ChatPanel() {
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 pb-4">
{errorAlert}
{promptInput}
</div>
{consentDialog}
</div>
);
+14 -3
View File
@@ -71,6 +71,19 @@ export function AIPanel() {
const set = <K extends keyof AiConfig>(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() {
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
<Select
onValueChange={(value) =>
set("provider", value as ApiProvider)
}
onValueChange={(value) => setProvider(value as ApiProvider)}
value={config.provider}
>
<SelectTrigger>
+20 -13
View File
@@ -51,7 +51,27 @@ export const AI_MODELS: AiModel[] = [
provider: "anthropic",
label: "Haiku 4.5",
descriptionKey: "chat.input.models.haiku45",
},
{
id: "gemini-2.5-pro",
provider: "gemini",
label: "Gemini 2.5 Pro",
descriptionKey: "chat.input.models.gemini25Pro",
primary: true,
supportsEffort: true,
},
{
id: "gemini-2.5-flash",
provider: "gemini",
label: "Gemini 2.5 Flash",
descriptionKey: "chat.input.models.gemini25Flash",
primary: true,
},
{
id: "gemini-2.0-flash",
provider: "gemini",
label: "Gemini 2.0 Flash",
descriptionKey: "chat.input.models.gemini20Flash",
},
{
id: "gpt-4o",
@@ -66,19 +86,6 @@ export const AI_MODELS: AiModel[] = [
label: "GPT-4o mini",
descriptionKey: "chat.input.models.gpt4oMini",
},
{
id: "gemini-2.0-flash",
provider: "gemini",
label: "Gemini 2.0 Flash",
descriptionKey: "chat.input.models.gemini20Flash",
},
{
id: "gemini-1.5-pro",
provider: "gemini",
label: "Gemini 1.5 Pro",
descriptionKey: "chat.input.models.gemini15Pro",
supportsEffort: true,
},
{
id: "ollama",
provider: "ollama",
@@ -634,10 +634,11 @@
"opus48": "For complex clinical reasoning",
"sonnet46": "Balanced for everyday tasks",
"haiku45": "Fastest for quick answers",
"gemini25Pro": "Googles most capable model",
"gemini25Flash": "Fast Google model",
"gemini20Flash": "Lightweight Google model",
"gpt4o": "OpenAI multimodal flagship",
"gpt4oMini": "Fast, low-cost OpenAI model",
"gemini20Flash": "Fast Google model",
"gemini15Pro": "Google long-context model",
"ollama": "Runs locally on your infrastructure"
}
},
@@ -673,6 +674,10 @@
"failedCount": "{{count}} failed",
"failedTitle": "Import failed",
"failedBody": "The records could not be imported. Please try again."
},
"error": {
"title": "AI request failed",
"body": "Something went wrong reaching the AI. Check your provider and API key in Settings → AI."
}
},
"patientCard": {