feat: multi-provider AI support with per-provider model selection

- Add llm-factory.ts: unified LLM provider abstraction (Google/Claude/OpenAI)
- Install @langchain/anthropic and @langchain/openai packages
- resolveAiProvider picks active provider from DB settings (Claude > OpenAI > Google)
- All AI graphs (reminder, new-patient, reschedule, internal-chat) now accept provider+model params
- Add claudeAiModel, openAiModel, googleAiModel columns to ai_settings table
- New PUT /api/ai/provider-model route to save selected model per provider
- UI model dropdowns for Claude (Haiku/Sonnet/Opus), OpenAI (GPT-5.x series), Google (Gemini 2.5/3.x)
- Google AI section also gets model selector alongside existing API key field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-06-06 09:34:11 -04:00
parent d5bc96ff39
commit 4899ab8368
57 changed files with 681 additions and 138 deletions

View File

@@ -13,13 +13,17 @@ type AiSettings = {
aiEnabled: boolean;
openAiKey: string;
openAiEnabled: boolean;
openAiModel: string;
claudeAiKey: string;
claudeAiEnabled: boolean;
claudeAiModel: string;
googleAiModel: string;
dentalMgmtKey: string;
dentalMgmtEnabled: boolean;
};
type Provider = "openAi" | "claudeAi" | "dentalMgmt";
type ModelProvider = "claudeAi" | "openAi" | "googleAi";
function ApiKeySection({
title,
@@ -31,6 +35,9 @@ function ApiKeySection({
onToggle,
isSaving,
isToggling,
modelOptions,
selectedModel,
onModelChange,
}: {
title: string;
description: string;
@@ -41,6 +48,9 @@ function ApiKeySection({
onToggle: (enabled: boolean) => void;
isSaving: boolean;
isToggling: boolean;
modelOptions?: { value: string; label: string }[];
selectedModel?: string;
onModelChange?: (model: string) => void;
}) {
const [localKey, setLocalKey] = useState(apiKey);
const [showKey, setShowKey] = useState(false);
@@ -68,6 +78,21 @@ function ApiKeySection({
<p className="text-sm text-gray-500">{description}</p>
{modelOptions && onModelChange && (
<div>
<label className="block text-sm font-medium mb-1">Model</label>
<select
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
className="p-2 border rounded w-full text-sm bg-white"
>
{modelOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium">API Key</label>
<div className="relative mt-1">
@@ -168,6 +193,21 @@ export function AiSettingsCard() {
onError: (err: any) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
// Provider model change
const providerModelMutation = useMutation({
mutationFn: async ({ provider, model }: { provider: ModelProvider; model: string }) => {
const res = await apiRequest("PUT", "/api/ai/provider-model", { provider, model });
if (!res.ok) throw new Error((await res.json().catch(() => null))?.message || "Failed");
return { provider, model };
},
onSuccess: ({ provider, model }) => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/settings"] });
const names: Record<string, string> = { claudeAi: "Claude AI", openAi: "OpenAI" };
toast({ title: "Model updated", description: `${names[provider]} model set to ${model}.` });
},
onError: (err: any) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
// Provider toggle
const providerToggleMutation = useMutation({
mutationFn: async ({ provider, enabled }: { provider: Provider; enabled: boolean }) => {
@@ -183,6 +223,28 @@ export function AiSettingsCard() {
onError: (err: any) => toast({ title: "Error", description: err?.message, variant: "destructive" }),
});
const CLAUDE_MODELS = [
{ value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 — Fast & affordable" },
{ value: "claude-sonnet-4-6", label: "Claude Sonnet 4.6 — Balanced" },
{ value: "claude-opus-4-8", label: "Claude Opus 4.8 — Most capable" },
];
const OPENAI_MODELS = [
{ value: "gpt-5.2", label: "GPT-5.2 — Standard (recommended)" },
{ value: "gpt-5.2-pro", label: "GPT-5.2 Pro — Professional grade" },
{ value: "gpt-5.4", label: "GPT-5.4 — Previous gen, high quality" },
{ value: "gpt-5.4-pro", label: "GPT-5.4 Pro — Previous gen pro" },
{ value: "gpt-5.5", label: "GPT-5.5 — Flagship, complex tasks" },
{ value: "gpt-5.5-pro", label: "GPT-5.5 Pro — Flagship professional" },
];
const GOOGLE_MODELS = [
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash — Fast & balanced (recommended)" },
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro — High capability, 1M context" },
{ value: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite — Optimized for speed/cost" },
{ value: "gemini-3.5-flash", label: "Gemini 3.5 Flash — Latest frontier" },
];
if (isLoading) {
return <Card><CardContent className="py-6"><p className="text-sm text-gray-400">Loading...</p></CardContent></Card>;
}
@@ -214,6 +276,18 @@ export function AiSettingsCard() {
<p className="text-sm text-gray-500">
Enter your Google AI Studio API key to enable AI-powered SMS replies.
</p>
<div>
<label className="block text-sm font-medium mb-1">Model</label>
<select
value={settings?.googleAiModel ?? "gemini-2.5-flash"}
onChange={(e) => providerModelMutation.mutate({ provider: "googleAi", model: e.target.value })}
className="p-2 border rounded w-full text-sm bg-white"
>
{GOOGLE_MODELS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium">Google AI Studio API Key</label>
<div className="relative mt-1">
@@ -263,6 +337,9 @@ export function AiSettingsCard() {
onToggle={(v) => providerToggleMutation.mutate({ provider: "openAi", enabled: v })}
isSaving={providerKeyMutation.isPending && (providerKeyMutation.variables as any)?.provider === "openAi"}
isToggling={providerToggleMutation.isPending && (providerToggleMutation.variables as any)?.provider === "openAi"}
modelOptions={OPENAI_MODELS}
selectedModel={settings?.openAiModel ?? "gpt-4o-mini"}
onModelChange={(model) => providerModelMutation.mutate({ provider: "openAi", model })}
/>
<Separator />
@@ -278,6 +355,9 @@ export function AiSettingsCard() {
onToggle={(v) => providerToggleMutation.mutate({ provider: "claudeAi", enabled: v })}
isSaving={providerKeyMutation.isPending && (providerKeyMutation.variables as any)?.provider === "claudeAi"}
isToggling={providerToggleMutation.isPending && (providerToggleMutation.variables as any)?.provider === "claudeAi"}
modelOptions={CLAUDE_MODELS}
selectedModel={settings?.claudeAiModel ?? "claude-haiku-4-5-20251001"}
onModelChange={(model) => providerModelMutation.mutate({ provider: "claudeAi", model })}
/>
<Separator />