mirror of
https://github.com/temetro/temetro.git
synced 2026-08-19 06:36:21 +00:00
b299501ab2
- 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>
134 lines
4.8 KiB
TypeScript
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] };
|
|
}
|