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:
@@ -2,6 +2,7 @@ import express, { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { classifyInternalChat } from "../ai/internal-chat-graph";
|
||||
import { runInternalChatWorkflow } from "../ai/internal-chat-workflow";
|
||||
import { resolveAiProvider } from "../ai/llm-factory";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -20,8 +21,11 @@ router.get("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
aiEnabled: settings.aiEnabled ?? true,
|
||||
openAiKey: settings.openAiKey ?? "",
|
||||
openAiEnabled: settings.openAiEnabled ?? false,
|
||||
openAiModel: settings.openAiModel ?? "gpt-5.2",
|
||||
claudeAiKey: settings.claudeAiKey ?? "",
|
||||
claudeAiEnabled: settings.claudeAiEnabled ?? false,
|
||||
claudeAiModel: settings.claudeAiModel ?? "claude-haiku-4-5-20251001",
|
||||
googleAiModel: settings.googleAiModel ?? "gemini-2.5-flash",
|
||||
dentalMgmtKey: settings.dentalMgmtKey ?? "",
|
||||
dentalMgmtEnabled: settings.dentalMgmtEnabled ?? false,
|
||||
openPhoneReply: settings.openPhoneReply ?? false,
|
||||
@@ -109,6 +113,27 @@ router.put("/provider-enabled", async (req: Request, res: Response): Promise<any
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/ai/provider-model
|
||||
router.put("/provider-model", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { provider, model } = req.body;
|
||||
if (!["claudeAi", "openAi", "googleAi"].includes(provider)) {
|
||||
return res.status(400).json({ message: "Invalid provider" });
|
||||
}
|
||||
if (!model?.trim()) {
|
||||
return res.status(400).json({ message: "model is required" });
|
||||
}
|
||||
|
||||
await storage.setProviderModel(userId, provider, model.trim());
|
||||
return res.status(200).json({ provider, model: model.trim() });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to save provider model", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/ai/advanced-settings
|
||||
router.get("/advanced-settings", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
@@ -236,9 +261,10 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
||||
if (!message?.trim()) return res.status(400).json({ message: "message is required" });
|
||||
|
||||
const aiSettings = await storage.getAiSettings(userId);
|
||||
if (!aiSettings?.apiKey) {
|
||||
const activeAi = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAi) {
|
||||
return res.status(200).json({
|
||||
reply: "AI is not configured. Please add a Google AI API key in Settings.",
|
||||
reply: "AI is not configured. Please add an API key in AI Settings.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,9 +275,11 @@ router.post("/internal-chat", async (req: Request, res: Response): Promise<any>
|
||||
|
||||
const classification = await classifyInternalChat(
|
||||
message.trim(),
|
||||
aiSettings.apiKey,
|
||||
activeAi.key,
|
||||
extraSystemPrompt || undefined,
|
||||
Array.isArray(history) ? history : []
|
||||
Array.isArray(history) ? history : [],
|
||||
activeAi.provider,
|
||||
activeAi.model
|
||||
);
|
||||
|
||||
const response = await runInternalChatWorkflow(classification, userId, storage, customAliases);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getOfficeHoursDisplay,
|
||||
timeLabel,
|
||||
} from "../ai/reschedule-graph";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
import { getLlm, resolveAiProvider } from "../ai/llm-factory";
|
||||
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
||||
import {
|
||||
getHandoff, getAfterHoursHandoff,
|
||||
@@ -109,7 +109,9 @@ function normalizeDob(raw: string): string {
|
||||
|
||||
async function parseMassHealthInfo(
|
||||
message: string,
|
||||
apiKey: string
|
||||
apiKey: string,
|
||||
provider: import("../ai/llm-factory").AiProvider = "google",
|
||||
model?: string
|
||||
): Promise<{ memberId: string | null; dob: string | null }> {
|
||||
// Regex: member IDs are typically 8-12 digits; DOB as MM/DD/YYYY or similar
|
||||
const idMatch = message.match(/\b(\d{8,12})\b/);
|
||||
@@ -123,7 +125,7 @@ async function parseMassHealthInfo(
|
||||
|
||||
// Fall back to LLM structured extraction
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const llm = getLlm(provider, apiKey, model);
|
||||
const res = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
@@ -332,7 +334,8 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
|
||||
// Fetch required context for this office
|
||||
const aiSettings = await storage.getAiSettings(userId);
|
||||
if (!aiSettings?.apiKey) {
|
||||
const activeAiU = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAiU) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
@@ -445,7 +448,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
|
||||
// ── Unknown: asked_appointment_time → parse date, check office hours ──
|
||||
if (stage === "asked_appointment_time") {
|
||||
const parsedDate = await parseDateOnlyFromMessage(Body, aiSettings.apiKey);
|
||||
const parsedDate = await parseDateOnlyFromMessage(Body, activeAiU.key, activeAiU.provider, activeAiU.model);
|
||||
if (!parsedDate) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.",
|
||||
@@ -497,7 +500,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
"asked_appointment_time",
|
||||
);
|
||||
}
|
||||
const startTime = await parseTime(Body, aiSettings.apiKey);
|
||||
const startTime = await parseTime(Body, activeAiU.key, activeAiU.provider, activeAiU.model);
|
||||
if (!startTime) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: `I didn't catch the time. What time do you prefer on ${pendingApptDate.dateLabel}? For example: '10am' or '2pm'.`,
|
||||
@@ -563,7 +566,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
];
|
||||
if (unknownNewPatientStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
||||
Body, stage, language, aiSettings.apiKey, chatTemplates.generalFallback
|
||||
Body, stage, language, activeAiU.key, chatTemplates.generalFallback, activeAiU.provider, activeAiU.model
|
||||
);
|
||||
return replyUnknown(aiReply, nextStage);
|
||||
}
|
||||
@@ -585,7 +588,8 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
}
|
||||
|
||||
const aiSettings = await storage.getAiSettings(patient.userId);
|
||||
if (!aiSettings?.apiKey) {
|
||||
const activeAi = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAi) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
@@ -626,8 +630,8 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
// Use Google AI (LangGraph) to read the patient's reply and classify yes/no
|
||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||
const { reply: intentReply, intent } = await runReminderGraph(
|
||||
Body, aiSettings.apiKey, language, apptDatetime,
|
||||
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
|
||||
Body, activeAi.key, language, apptDatetime,
|
||||
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
||||
);
|
||||
|
||||
if (intentReply) {
|
||||
@@ -652,7 +656,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body);
|
||||
if (hasDateInMessage) {
|
||||
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
|
||||
Body, "asked_reschedule_datetime", language, patient.id, aiSettings.apiKey, patient.userId
|
||||
Body, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
||||
);
|
||||
return reply(rescheduleReply, rescheduleNextStage);
|
||||
}
|
||||
@@ -670,8 +674,8 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
if (stage === "greeted") {
|
||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||
const { reply: aiReply, intent } = await runReminderGraph(
|
||||
Body, aiSettings.apiKey, language, apptDatetime,
|
||||
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
|
||||
Body, activeAi.key, language, apptDatetime,
|
||||
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
||||
);
|
||||
if (aiReply) {
|
||||
let nextStage: ConversationStage;
|
||||
@@ -686,7 +690,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body);
|
||||
if (hasDateInMessage) {
|
||||
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
|
||||
Body, "asked_reschedule_datetime", language, patient.id, aiSettings.apiKey, patient.userId
|
||||
Body, "asked_reschedule_datetime", language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
||||
);
|
||||
return reply(rescheduleReply, rescheduleNextStage);
|
||||
}
|
||||
@@ -705,14 +709,14 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
];
|
||||
if (rescheduleStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runRescheduleStep(
|
||||
Body, stage, language, patient.id, aiSettings.apiKey, patient.userId
|
||||
Body, stage, language, patient.id, activeAi.key, patient.userId, activeAi.provider, activeAi.model
|
||||
);
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
|
||||
// ── Stage: awaiting MassHealth member ID + DOB ────────────────────────
|
||||
if (stage === "awaiting_masshealth_info") {
|
||||
const { memberId, dob } = await parseMassHealthInfo(Body, aiSettings.apiKey);
|
||||
const { memberId, dob } = await parseMassHealthInfo(Body, activeAi.key, activeAi.provider, activeAi.model);
|
||||
|
||||
if (!memberId || !dob) {
|
||||
// Couldn't parse — ask again with a clearer format hint
|
||||
@@ -748,7 +752,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
res.send(twimlReply(checkingMsg));
|
||||
|
||||
// Fire-and-forget: run check and send result SMS when complete
|
||||
runMassHealthCheckAndNotify(patient, memberId, dob, aiSettings.apiKey).catch(() => {});
|
||||
runMassHealthCheckAndNotify(patient, memberId, dob, activeAi.key).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -797,7 +801,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
|
||||
// Fire-and-forget Selenium check; existing patient gets simpler result
|
||||
runMassHealthCheckAndNotify(
|
||||
patient, patientRecord.insuranceId, dobStr, aiSettings.apiKey, true
|
||||
patient, patientRecord.insuranceId, dobStr, activeAi.key, true
|
||||
).catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -852,7 +856,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
|
||||
// ── Stage: asked_appointment_time → parse date, check office hours ───
|
||||
if (stage === "asked_appointment_time") {
|
||||
const parsedDate = await parseDateOnlyFromMessage(Body, aiSettings.apiKey);
|
||||
const parsedDate = await parseDateOnlyFromMessage(Body, activeAi.key, activeAi.provider, activeAi.model);
|
||||
if (!parsedDate) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.",
|
||||
@@ -899,7 +903,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
if (!pending) {
|
||||
return reply("I lost track of the date. What day would you prefer?", "asked_appointment_time");
|
||||
}
|
||||
const startTime = await parseTime(Body, aiSettings.apiKey);
|
||||
const startTime = await parseTime(Body, activeAi.key, activeAi.provider, activeAi.model);
|
||||
if (!startTime) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: `I didn't catch the time. What time do you prefer on ${pending.dayLabel}? For example: '10am' or '2pm'.`,
|
||||
@@ -1002,7 +1006,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
];
|
||||
if (newPatientStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
||||
Body, stage, language, aiSettings.apiKey, chatTemplates.generalFallback
|
||||
Body, stage, language, activeAi.key, chatTemplates.generalFallback, activeAi.provider, activeAi.model
|
||||
);
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
@@ -1038,10 +1042,9 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
: `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`,
|
||||
};
|
||||
const fallback = CLOSING[language] ?? CLOSING["English"]!;
|
||||
if (aiSettings?.apiKey && apptDatetime) {
|
||||
if (apptDatetime) {
|
||||
try {
|
||||
const { ChatGoogleGenerativeAI } = await import("@langchain/google-genai");
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey: aiSettings.apiKey });
|
||||
const llm = getLlm(activeAi.provider, activeAi.key, activeAi.model);
|
||||
const res = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
|
||||
Reference in New Issue
Block a user