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

@@ -1,4 +1,4 @@
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { getLlm, type AiProvider } from "./llm-factory";
// ─── Intent types ─────────────────────────────────────────────────────────────
@@ -92,7 +92,9 @@ export async function classifyInternalChat(
message: string,
apiKey: string,
extraSystemPrompt?: string,
history: { role: "user" | "assistant"; text: string }[] = []
history: { role: "user" | "assistant"; text: string }[] = [],
provider: AiProvider = "google",
model?: string
): Promise<ChatClassification> {
const fallback: ChatClassification = {
intent: "general",
@@ -107,7 +109,7 @@ export async function classifyInternalChat(
: BASE_SYSTEM_PROMPT;
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-flash-latest", apiKey });
const llm = getLlm(provider, apiKey, model);
// Gemini requires conversation to start with a user turn — drop any leading assistant messages
const trimmedHistory = history.slice(

View File

@@ -0,0 +1,51 @@
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { ChatAnthropic } from "@langchain/anthropic";
import { ChatOpenAI } from "@langchain/openai";
export type AiProvider = "google" | "claude" | "openai";
export function getLlm(provider: AiProvider, apiKey: string, model?: string) {
if (provider === "claude") {
return new ChatAnthropic({ model: model || "claude-haiku-4-5-20251001", apiKey });
}
if (provider === "openai") {
return new ChatOpenAI({ model: model || "gpt-4o-mini", apiKey });
}
return new ChatGoogleGenerativeAI({ model: model || "gemini-1.5-flash", apiKey });
}
export interface AiSettingsLike {
apiKey?: string | null;
claudeAiKey?: string | null;
claudeAiEnabled?: boolean | null;
claudeAiModel?: string | null;
openAiKey?: string | null;
openAiEnabled?: boolean | null;
openAiModel?: string | null;
googleAiModel?: string | null;
}
export function resolveAiProvider(settings: AiSettingsLike): { provider: AiProvider; key: string; model: string } | null {
if (settings.claudeAiEnabled && settings.claudeAiKey?.trim()) {
return {
provider: "claude",
key: settings.claudeAiKey.trim(),
model: settings.claudeAiModel?.trim() || "claude-haiku-4-5-20251001",
};
}
if (settings.openAiEnabled && settings.openAiKey?.trim()) {
return {
provider: "openai",
key: settings.openAiKey.trim(),
model: settings.openAiModel?.trim() || "gpt-5.2",
};
}
if (settings.apiKey?.trim()) {
return {
provider: "google",
key: settings.apiKey.trim(),
model: settings.googleAiModel?.trim() || "gemini-2.5-flash",
};
}
return null;
}

View File

@@ -1,5 +1,5 @@
import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { getLlm, type AiProvider } from "./llm-factory";
import type { ConversationStage } from "./aiHandoffStore";
// ── Graph state ───────────────────────────────────────────────────────────────
@@ -80,10 +80,12 @@ async function llmReply(
system: string,
userMsg: string,
fallback: string,
apiKey: string
apiKey: string,
provider: AiProvider = "google",
model?: string
): Promise<string> {
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const llm = getLlm(provider, apiKey, model);
const res = await llm.invoke([
{ role: "system", content: system },
{ role: "user", content: userMsg },
@@ -192,6 +194,8 @@ function routeNode(state: GraphStateType): string {
async function askNewOrExistingNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "Are you a new patient or an existing patient?",
@@ -208,7 +212,7 @@ async function askNewOrExistingNode(state: GraphStateType, config: any) {
? await llmReply(
`You are a friendly dental office AI assistant. Ask the patient in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`,
`Patient wants an appointment. Ask if new or existing.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -218,6 +222,8 @@ async function askNewOrExistingNode(state: GraphStateType, config: any) {
async function askNewPatientInsuranceNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "Do you have any dental insurance?",
@@ -234,7 +240,7 @@ async function askNewPatientInsuranceNode(state: GraphStateType, config: any) {
? await llmReply(
`You are a friendly dental office AI assistant. Ask the new patient in ${lang} if they have dental insurance. One sentence, no formatting.`,
`New patient confirmed. Ask about insurance.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -244,6 +250,8 @@ async function askNewPatientInsuranceNode(state: GraphStateType, config: any) {
async function askInsuranceTypeNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "What kind of insurance do you have?",
@@ -260,7 +268,7 @@ async function askInsuranceTypeNode(state: GraphStateType, config: any) {
? await llmReply(
`You are a friendly dental office AI assistant. The patient confirmed they have insurance. Ask them in ${lang} what kind of insurance they have. One sentence, no formatting.`,
`Patient has insurance. Ask what kind.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -270,6 +278,8 @@ async function askInsuranceTypeNode(state: GraphStateType, config: any) {
async function askMassHealthCheckConsentNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "Do you want to check your MassHealth insurance now?",
@@ -286,7 +296,7 @@ async function askMassHealthCheckConsentNode(state: GraphStateType, config: any)
? await llmReply(
`You are a friendly dental office AI assistant. The patient has MassHealth insurance. Ask them in ${lang} if they would like to check their MassHealth coverage right now. One sentence, no formatting.`,
`Patient has MassHealth. Ask if they want to check it now.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -296,6 +306,8 @@ async function askMassHealthCheckConsentNode(state: GraphStateType, config: any)
async function askMassHealthInfoNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "Please send me your MassHealth Member ID and date of birth so I can check your coverage.",
@@ -312,7 +324,7 @@ async function askMassHealthInfoNode(state: GraphStateType, config: any) {
? await llmReply(
`You are a friendly dental office AI assistant. The patient wants to check their MassHealth coverage. Ask them in ${lang} to provide their MassHealth Member ID and date of birth. 1-2 sentences, no formatting.`,
`Patient agreed to MassHealth check. Ask for member ID and DOB.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -322,6 +334,8 @@ async function askMassHealthInfoNode(state: GraphStateType, config: any) {
async function askExistingInsuranceNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "Got it — you are an existing patient! Do you still have the same insurance on file?",
@@ -338,7 +352,7 @@ async function askExistingInsuranceNode(state: GraphStateType, config: any) {
? await llmReply(
`You are a friendly dental office AI assistant. The patient confirmed they are an existing patient. In ${lang}, acknowledge that they are an existing patient and then ask if they still have the same dental insurance on file. 1-2 sentences, no formatting.`,
`Existing patient confirmed. Acknowledge and ask about insurance.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -348,6 +362,8 @@ async function askExistingInsuranceNode(state: GraphStateType, config: any) {
async function askAppointmentTimeNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "When would you like to come in for your appointment?",
@@ -364,7 +380,7 @@ async function askAppointmentTimeNode(state: GraphStateType, config: any) {
? await llmReply(
`You are a friendly dental office AI assistant. Ask the patient in ${lang} what date and time they would prefer for their appointment. One sentence, no formatting.`,
`Ask when to schedule.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -374,6 +390,8 @@ async function askAppointmentTimeNode(state: GraphStateType, config: any) {
async function acknowledgeAppointmentTimeNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "Thank you! Our office staff will confirm your appointment details shortly.",
@@ -390,7 +408,7 @@ async function acknowledgeAppointmentTimeNode(state: GraphStateType, config: any
? await llmReply(
`You are a friendly dental office AI assistant. The patient stated their preferred appointment time. Acknowledge in ${lang} and tell them the office staff will confirm shortly. 1-2 sentences, no formatting.`,
`Patient said: "${state.message}". Acknowledge.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -400,6 +418,8 @@ async function acknowledgeAppointmentTimeNode(state: GraphStateType, config: any
async function handleAppointmentPreferenceNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "When would you like to come in? Are you looking for a routine check-up and teeth cleaning, or do you have a tooth problem or pain?",
@@ -416,7 +436,7 @@ async function handleAppointmentPreferenceNode(state: GraphStateType, config: an
? await llmReply(
`You are a friendly dental office AI assistant. The patient's MassHealth is active. In ${lang}, ask when they would like to come in and whether they want a routine check-up and teeth cleaning, or if they have a dental problem or pain. 1-2 sentences, no formatting.`,
`MassHealth is active. Ask appointment preference.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -426,6 +446,8 @@ async function handleAppointmentPreferenceNode(state: GraphStateType, config: an
async function handleSelfPayNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const text = state.message.toLowerCase();
const acceptsSelfPay = /yes|sure|ok|okay|yep|yeah|sí|si|claro|sim|confirmado|好的|نعم|wi|oke/i.test(text);
@@ -479,6 +501,8 @@ async function handleSelfPayNode(state: GraphStateType, config: any) {
async function askContactInfoNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "Please leave your name and phone number. Our receptionist will contact you as soon as possible.",
@@ -495,7 +519,7 @@ async function askContactInfoNode(state: GraphStateType, config: any) {
? await llmReply(
`You are a friendly dental office AI assistant. The patient's MassHealth is inactive and they have no other insurance. In ${lang}, politely ask them to leave their name and phone number so the receptionist can contact them. 1-2 sentences, no formatting.`,
`MassHealth inactive, no other insurance. Ask for name and phone.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -505,6 +529,8 @@ async function askContactInfoNode(state: GraphStateType, config: any) {
async function acknowledgeContactInfoNode(state: GraphStateType, config: any) {
const lang = state.language || "English";
const apiKey: string | undefined = config?.configurable?.apiKey;
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const fallbacks: Record<string, string> = {
English: "Thank you! Our receptionist will reach out to you shortly.",
@@ -521,7 +547,7 @@ async function acknowledgeContactInfoNode(state: GraphStateType, config: any) {
? await llmReply(
`You are a friendly dental office AI assistant. The patient has left their contact information. Thank them in ${lang} and let them know the receptionist will reach out soon. 1-2 sentences, no formatting.`,
`Patient provided contact info. Acknowledge.`,
fallback, apiKey
fallback, apiKey, provider, model
)
: fallback;
@@ -588,11 +614,13 @@ export async function runNewPatientStep(
stage: ConversationStage,
language: string,
apiKey: string,
generalFallback = ""
generalFallback = "",
provider: AiProvider = "google",
model?: string
): Promise<{ reply: string; nextStage: ConversationStage }> {
const result = await graph.invoke(
{ message, stage, intent: "", reply: "", language, nextStage: "", generalFallback },
{ configurable: { apiKey } }
{ configurable: { apiKey, provider, model } }
);
return {
reply: result.reply || transferMsg(language),

View File

@@ -1,5 +1,5 @@
import { StateGraph, END, START, Annotation } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { getLlm, type AiProvider } from "./llm-factory";
const GraphState = Annotation.Root({
message: Annotation<string>(),
@@ -95,7 +95,9 @@ async function confirmNode(state: GraphStateType, config: any) {
if (!apiKey) return { reply: fallback };
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const apptClause = appt ? ` Their appointment is on ${appt}.` : "";
const response = await llm.invoke([
{
@@ -134,7 +136,9 @@ async function rescheduleNode(state: GraphStateType, config: any) {
if (!apiKey) return { reply: fallback };
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
@@ -160,7 +164,9 @@ async function otherNode(state: GraphStateType, config: any) {
const fallback = NEW_APPT_FALLBACKS[lang] ?? NEW_APPT_FALLBACKS["English"]!;
if (!apiKey) return { reply: fallback, intent: "wants_appointment" };
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
@@ -177,7 +183,9 @@ async function otherNode(state: GraphStateType, config: any) {
const fallback = state.generalFallback || (GENERAL_FALLBACKS[lang] ?? GENERAL_FALLBACKS["English"]!);
if (!apiKey) return { reply: fallback };
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const provider: AiProvider = config?.configurable?.provider ?? "google";
const model: string | undefined = config?.configurable?.model;
const llm = getLlm(provider, apiKey, model);
const response = await llm.invoke([
{
role: "system",
@@ -215,11 +223,13 @@ export async function runReminderGraph(
language = "English",
appointmentDatetime = "",
rescheduleGreeting = "",
generalFallback = ""
generalFallback = "",
provider: AiProvider = "google",
model?: string
): Promise<{ reply: string | null; intent: string | null }> {
const result = await graph.invoke(
{ message: patientMessage, intent: "", reply: "", language, appointmentDatetime, rescheduleGreeting, generalFallback },
{ configurable: { apiKey } }
{ configurable: { apiKey, provider, model } }
);
return {
reply: result.reply || null,

View File

@@ -1,4 +1,4 @@
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { getLlm, type AiProvider } from "./llm-factory";
import { prisma as db } from "@repo/db/client";
import { storage } from "../storage";
import {
@@ -83,6 +83,8 @@ function messageHasTime(msg: string): boolean {
export async function parseDateOnlyFromMessage(
message: string,
apiKey: string,
provider: AiProvider = "google",
model?: string
): Promise<{ date: Date; dateLabel: string } | null> {
const now = new Date();
const todayStr = now.toLocaleDateString("en-CA");
@@ -106,9 +108,9 @@ export async function parseDateOnlyFromMessage(
}
}
// Gemini fallback for "next Monday", "Tuesday", etc.
// LLM fallback for "next Monday", "Tuesday", etc.
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const llm = getLlm(provider, apiKey, model);
const res = await llm.invoke([
{
role: "system",
@@ -145,6 +147,8 @@ Return ONLY raw JSON: {"date":"YYYY-MM-DD"}
async function parseDatetimeFromMessage(
message: string,
apiKey: string,
provider: AiProvider = "google",
model?: string
): Promise<{ date: Date; startTime: string; displayLabel: string } | null> {
const now = new Date();
const todayStr = now.toLocaleDateString("en-CA"); // YYYY-MM-DD local
@@ -188,9 +192,9 @@ async function parseDatetimeFromMessage(
}
}
// ── Step 2: fall back to Google AI for natural language ───────────────────
// ── Step 2: fall back to AI for natural language ───────────────────────────
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const llm = getLlm(provider, apiKey, model);
const res = await llm.invoke([
{
role: "system",
@@ -387,7 +391,7 @@ async function isSlotAvailable(
// ── Time parsing (legacy) ─────────────────────────────────────────────────────
export async function parseTime(message: string, apiKey: string): Promise<string | null> {
export async function parseTime(message: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string | null> {
const t = message.toLowerCase();
if (/\bmorning\b|mañana|manhã|上午|صباح|maten/i.test(t)) return "09:00";
if (/\bafternoon\b|tarde|après-midi|下午|مساء|aprèmidi/i.test(t)) return "13:00";
@@ -402,7 +406,7 @@ export async function parseTime(message: string, apiKey: string): Promise<string
}
if (clock) return clock[0]!;
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const llm = getLlm(provider, apiKey, model);
const res = await llm.invoke([
{ role: "system", content: 'Extract the time from the message. Return ONLY a 24-hour time in "HH:MM" format. If no time is mentioned, return "null".' },
{ role: "user", content: message },
@@ -465,9 +469,9 @@ function prefersWednesday(t: string) { return /wednesday|miércoles|quarta|周
// ── LLM helper ────────────────────────────────────────────────────────────────
async function llmReply(system: string, user: string, fallback: string, apiKey: string): Promise<string> {
async function llmReply(system: string, user: string, fallback: string, apiKey: string, provider: AiProvider = "google", model?: string): Promise<string> {
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const llm = getLlm(provider, apiKey, model);
const res = await llm.invoke([
{ role: "system", content: system },
{ role: "user", content: user },
@@ -499,6 +503,8 @@ export async function runRescheduleStep(
patientId: number,
apiKey: string,
userId: number = 0,
provider: AiProvider = "google",
model?: string
): Promise<{ reply: string; nextStage: ConversationStage }> {
const lang = language || "English";
@@ -513,7 +519,7 @@ export async function runRescheduleStep(
if (!hasTime) {
// Try to parse date only
const parsedDate = await parseDateOnlyFromMessage(message, apiKey);
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
if (!parsedDate) {
const fallbacks: Record<string, string> = {
English: "I didn't catch that. What day would you prefer? For example: 'Monday', 'next Tuesday', or '5/18'.",
@@ -559,13 +565,13 @@ export async function runRescheduleStep(
`You are a friendly dental office assistant. The patient wants to reschedule to ${dateLabel} but hasn't given a time. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`,
`Patient wants ${dateLabel} but gave no time.`,
fallbacks[lang] ?? fallbacks["English"]!,
apiKey,
apiKey, provider, model
);
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
}
// Both date and time present — parse the full datetime
const parsed = await parseDatetimeFromMessage(message, apiKey);
const parsed = await parseDatetimeFromMessage(message, apiKey, provider, model);
if (!parsed) {
const fallbacks: Record<string, string> = {
English: "I didn't catch that. What day and time would you prefer? For example: 'Monday at 10am' or '5/18 at 2pm'.",
@@ -613,7 +619,7 @@ export async function runRescheduleStep(
`You are a friendly dental office AI assistant. The patient mentioned a date/time preference that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm: "Do you mean ${displayLabel}?" 1 sentence, natural and friendly. No formatting.`,
`Patient said: "${message}"`,
fallbacks[lang] ?? fallbacks["English"]!,
apiKey,
apiKey, provider, model
);
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
}
@@ -636,7 +642,7 @@ export async function runRescheduleStep(
}
// Parse the time from the patient's reply
const startTime = await parseTime(message, apiKey);
const startTime = await parseTime(message, apiKey, provider, model);
if (!startTime) {
const fallbacks: Record<string, string> = {
English: `I didn't catch the time. What time would you prefer on ${pending.dayLabel}? For example: '10am', '2pm', or '1:30pm'.`,
@@ -698,7 +704,7 @@ export async function runRescheduleStep(
`You are a friendly dental office AI assistant. The patient wants ${fullLabel}. Ask them in ${lang} to confirm: "Do you mean ${fullLabel}?" 1 sentence, natural and friendly. No formatting.`,
`Patient said: "${message}"`,
fallbacks[lang] ?? fallbacks["English"]!,
apiKey,
apiKey, provider, model
);
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
}
@@ -812,7 +818,7 @@ export async function runRescheduleStep(
`You are a friendly dental office AI assistant. The patient's appointment has been successfully moved to ${displayLabel}. Write a warm confirmation message in ${lang}: tell them the appointment is moved to ${displayLabel}, and that our dental receptionist will confirm it with them tomorrow. 2 sentences max, no formatting.`,
`Appointment rescheduled to ${displayLabel}.`,
fallback,
apiKey,
apiKey, provider, model
);
return { reply, nextStage: "done" };
}
@@ -832,7 +838,7 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply(
`You are a friendly dental office assistant. The patient does not want to reschedule. Write a warm, brief closing message in ${lang}. 1 sentence, no formatting.`,
`Patient said: "${message}"`, fallback, apiKey,
`Patient said: "${message}"`, fallback, apiKey, provider, model
);
return { reply, nextStage: "done" };
}
@@ -844,7 +850,7 @@ export async function runRescheduleStep(
if (hasTime) {
// Try to parse full datetime from the message
const parsed = await parseDatetimeFromMessage(message, apiKey);
const parsed = await parseDatetimeFromMessage(message, apiKey, provider, model);
if (parsed) {
const { date, startTime, displayLabel } = parsed;
const dayCheck = await isOfficeDayOpen(date, userId);
@@ -873,14 +879,14 @@ export async function runRescheduleStep(
};
const confirmReply = await llmReply(
`You are a friendly dental office AI assistant. The patient mentioned a date/time that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm. 1 sentence, natural and friendly. No formatting.`,
`Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey,
`Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, provider
);
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
}
}
// Try to parse a date-only from the message
const parsedDate = await parseDateOnlyFromMessage(message, apiKey);
const parsedDate = await parseDateOnlyFromMessage(message, apiKey, provider, model);
if (parsedDate) {
const { date, dateLabel } = parsedDate;
const dayCheck = await isOfficeDayOpen(date, userId);
@@ -909,7 +915,7 @@ export async function runRescheduleStep(
};
const askTimeReply = await llmReply(
`You are a friendly dental office assistant. The patient wants ${dateLabel}. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`,
`Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey,
`Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey, provider
);
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
}
@@ -927,7 +933,7 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply(
`You are a friendly dental office assistant. The patient wants to reschedule. Ask them in ${lang} what day and time they prefer. Give 1-2 examples like "Monday at 10am" or "next Tuesday afternoon". 1-2 sentences, no formatting.`,
`Patient wants to reschedule.`, fallback, apiKey,
`Patient wants to reschedule.`, fallback, apiKey, provider, model
);
return { reply, nextStage: "asked_reschedule_datetime" };
}
@@ -951,7 +957,7 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply(
`You are a friendly dental office assistant. Ask the patient in ${lang} if they can come in tomorrow, ${tomorrow}. 1 sentence, no formatting.`,
`Patient wants to reschedule ASAP.`, fallback, apiKey,
`Patient wants to reschedule ASAP.`, fallback, apiKey, provider, model
);
return { reply, nextStage: "asked_reschedule_asap" };
}
@@ -970,7 +976,7 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply(
`You are a friendly dental office assistant. Offer next week's Monday (${mon}), Tuesday (${tue}), or Wednesday (${wed}) in ${lang}. 1-2 sentences, no formatting.`,
`Patient prefers next week.`, fallback, apiKey,
`Patient prefers next week.`, fallback, apiKey, provider, model
);
return { reply, nextStage: "asked_reschedule_next_week" };
}
@@ -995,7 +1001,7 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply(
`You are a friendly dental office assistant. The patient confirmed ${label}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
`Patient confirmed tomorrow.`, fallback, apiKey,
`Patient confirmed tomorrow.`, fallback, apiKey, provider, model
);
return { reply, nextStage: "asked_reschedule_time" };
}
@@ -1013,7 +1019,7 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply(
`You are a friendly dental office assistant. The patient cannot come tomorrow. Offer next week: ${mon}, ${tue}, or ${wed} in ${lang}. 1-2 sentences, no formatting.`,
`Patient can't come tomorrow.`, fallback, apiKey,
`Patient can't come tomorrow.`, fallback, apiKey, provider, model
);
return { reply, nextStage: "asked_reschedule_next_week" };
}
@@ -1043,7 +1049,7 @@ export async function runRescheduleStep(
const fallback = fallbacks[lang] ?? fallbacks["English"]!;
const reply = await llmReply(
`You are a friendly dental office assistant. The patient chose ${day}. Ask in ${lang} whether they prefer morning (9am-12pm) or afternoon (1pm-5pm). 1 sentence, no formatting.`,
`Patient chose ${day}.`, fallback, apiKey,
`Patient chose ${day}.`, fallback, apiKey, provider, model
);
return { reply, nextStage: "asked_reschedule_time" };
}
@@ -1066,7 +1072,7 @@ export async function runRescheduleStep(
const pending = getPendingReschedule(userId, patientId);
if (!pending) return { reply: tx, nextStage: "done" };
const startTime = await parseTime(message, apiKey);
const startTime = await parseTime(message, apiKey, provider, model);
if (!startTime) {
const fallbacks: Record<string, string> = {
English: "I didn't catch the time. Would you prefer morning (9am12pm) or afternoon (1pm5pm)?",
@@ -1101,7 +1107,7 @@ export async function runRescheduleStep(
const fallback = `Your appointment has been moved to ${apptLabel}. Our dental receptionist will confirm it with you tomorrow.`;
const reply = await llmReply(
`You are a friendly dental office assistant. The patient's appointment has been rescheduled to ${apptLabel}. Confirm in ${lang}: say the appointment is moved to ${apptLabel} and that our dental receptionist will confirm it with them tomorrow. 2 sentences, no formatting.`,
`Appointment moved to ${apptLabel}.`, fallback, apiKey,
`Appointment moved to ${apptLabel}.`, fallback, apiKey, provider, model
);
return { reply, nextStage: "done" };
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -30,6 +30,15 @@ export const aiSettingsStorage = {
});
},
async setProviderModel(userId: number, provider: "claudeAi" | "openAi" | "googleAi", model: string): Promise<void> {
const field = provider === "claudeAi" ? "claudeAiModel" : provider === "openAi" ? "openAiModel" : "googleAiModel";
await db.aiSettings.upsert({
where: { userId },
update: { [field]: model },
create: { userId, apiKey: "", [field]: model },
});
},
async setProviderEnabled(userId: number, provider: "openAi" | "claudeAi" | "dentalMgmt", enabled: boolean): Promise<void> {
const field = provider === "openAi" ? "openAiEnabled" : provider === "claudeAi" ? "claudeAiEnabled" : "dentalMgmtEnabled";
await db.aiSettings.upsert({