mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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": "Google’s 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": {
|
||||
|
||||
Reference in New Issue
Block a user