Files
temetro/backend/src/services/ai/provider.ts
T
Khalid Abdi b299501ab2 frontend+backend: patients/settings UI, AI Auto/Off modes, wallet doc push
- patients: move status filter to its own "Filter" row above the table
- patient sheet: name + ⋯ menu on one left row; Edit moved into the ⋯ menu
- settings: redesign sections with the COSS CardFrame surface (SettingsFrame)
- AI: add Automatic (auto-pick provider) and Off modes; default to Automatic
  so a fresh install shows the setup banner until a provider is configured
- AI: fix the setup banner never showing (defaulted Ollama URL counted as
  configured); add an "AI off" notice; add a fallback render for unknown chat
  data parts so cards never silently vanish
- backend: include patient attachment metadata in the wallet record-update
  bundle so pushed documents reach the wallet
- i18n: add mode/off/card keys across all five locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:36 +03:00

134 lines
4.8 KiB
TypeScript

import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LanguageModel } from "ai";
import { HttpError } from "../../lib/http-error.js";
import type { ApiProvider } from "../../types/ai.js";
import { getApiKey } from "./config.js";
import type { userAiSettings } from "../../db/schema/ai.js";
type AiSettingsRow = typeof userAiSettings.$inferSelect;
export type ResolvedModel = {
model: LanguageModel;
// True for external cloud providers — Veil de-identification applies. False
// for local Ollama (data never leaves the clinic).
isExternal: boolean;
providerLabel: string;
};
// The "ollama" sentinel id from the frontend catalog means "use my local
// model" regardless of the model field.
const OLLAMA_SENTINEL = "ollama";
// Derive the cloud provider from a catalog model id, so the picker drives which
// provider/key is used. Returns null for the local sentinel.
function providerForModel(modelId: string): ApiProvider | null {
if (modelId === OLLAMA_SENTINEL) return null;
if (modelId.startsWith("claude")) return "anthropic";
if (modelId.startsWith("gemini")) return "gemini";
if (modelId.startsWith("gpt") || /^o\d/.test(modelId)) return "openai";
return null;
}
const PROVIDER_LABELS: Record<ApiProvider, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
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
// when mode === "local" or the picked model is the local sentinel).
export function resolveModel(
settings: AiSettingsRow,
requestedModelId: string,
): ResolvedModel {
// Off → the assistant is disabled. Guard here in case a request slips past the
// route-level short-circuit.
if (settings.mode === "off") {
throw new HttpError(
400,
"The AI assistant is turned off. Turn it on in Settings → AI.",
);
}
const requested = providerForModel(requestedModelId);
// In api/auto mode, find a cloud provider that actually has a key. Auto with
// no key (and api mode's remaining fall-through) drops to local Ollama below.
const provider =
settings.mode === "api" || settings.mode === "auto"
? chooseProvider(settings, requested)
: null;
// Local when: explicit local mode, the local sentinel was picked, or auto with
// no configured cloud key. → Ollama's OpenAI-compatible endpoint.
if (
settings.mode === "local" ||
requestedModelId === OLLAMA_SENTINEL ||
(settings.mode === "auto" && !provider)
) {
const ollama = createOpenAICompatible({
name: "ollama",
baseURL: `${settings.ollamaBaseUrl.replace(/\/$/, "")}/v1`,
});
return {
model: ollama(settings.ollamaModel),
isExternal: false,
providerLabel: "Local (Ollama)",
};
}
// API mode with a picked cloud model but no matching/any key.
if (!provider) {
throw new HttpError(
400,
"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 })(modelName)
: provider === "gemini"
? createGoogleGenerativeAI({ apiKey })(modelName)
: createOpenAI({ apiKey })(modelName);
return { model, isExternal: true, providerLabel: PROVIDER_LABELS[provider] };
}