92 lines
5.8 KiB
TypeScript
92 lines
5.8 KiB
TypeScript
import "server-only";
|
|
|
|
import type { MastraModelConfig } from "@mastra/core/llm";
|
|
|
|
import { queryAdminRows } from "@/lib/admin/database";
|
|
import { assertAllowedModelProviderUrl } from "@/lib/epay/gateway-policy";
|
|
import { decryptModelProviderApiKey, type ModelProviderType } from "@/lib/model-provider-policy";
|
|
import type { LanguageModelCatalog, ResolvedLanguageModel } from "@/mastra/model";
|
|
|
|
const cacheTtlMs = 15_000;
|
|
const secretSettingNames = new Set(["apikey", "authorization", "cookie", "databaseurl", "connectionstring", "password", "privatekey", "clientsecret", "secret", "token"]);
|
|
|
|
type PublishedModelRow = {
|
|
model_id: string; version: number; label: string; description: string; provider_model: string;
|
|
credit_cost: number; is_default: boolean; provider_code: string; provider_type: ModelProviderType;
|
|
base_url: string | null; encrypted_api_key: string | null; input_cost: string | number; output_cost: string | number;
|
|
};
|
|
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
|
|
const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache };
|
|
|
|
function isSecretSettingKey(key: string) {
|
|
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
return secretSettingNames.has(normalized) || /(apikey|password|authorization|cookie|databaseurl|connectionstring|privatekey|clientsecret|accesstoken|refreshtoken|authtoken|bearertoken)$/.test(normalized);
|
|
}
|
|
export function modelSettingsContainSecrets(value: unknown): boolean {
|
|
if (Array.isArray(value)) return value.some(modelSettingsContainSecrets);
|
|
if (!value || typeof value !== "object") return false;
|
|
return Object.entries(value).some(([key, nested]) => isSecretSettingKey(key) || modelSettingsContainSecrets(nested));
|
|
}
|
|
export function sanitizeModelSettings(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(sanitizeModelSettings);
|
|
if (!value || typeof value !== "object") return value;
|
|
return Object.fromEntries(Object.entries(value).filter(([key]) => !isSecretSettingKey(key)).map(([key, nested]) => [key, sanitizeModelSettings(nested)]));
|
|
}
|
|
function publicModel(model: ResolvedLanguageModel) {
|
|
return { id: model.id, label: model.label, description: model.description, creditCost: model.creditCost, isDefault: model.isDefault };
|
|
}
|
|
async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel | null> {
|
|
const apiKey = decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
|
|
if (!apiKey) return null;
|
|
let model: MastraModelConfig;
|
|
if (row.provider_type === "openai") {
|
|
model = { providerId: "openai", modelId: row.provider_model.replace(/^openai\//, ""), apiKey };
|
|
} else if (row.provider_type === "anthropic") {
|
|
model = { providerId: "anthropic", modelId: row.provider_model.replace(/^anthropic\//, ""), apiKey };
|
|
} else {
|
|
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
|
|
}
|
|
return {
|
|
id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost,
|
|
isDefault: row.is_default, mode: row.provider_type === "openai-compatible" ? "compatible" : row.provider_type,
|
|
model, configVersion: row.version, inputCostMicrousdPerMillion: Number(row.input_cost), outputCostMicrousdPerMillion: Number(row.output_cost),
|
|
};
|
|
}
|
|
async function queryCatalog(where: string, values: readonly unknown[] = []) {
|
|
return queryAdminRows<PublishedModelRow>(`
|
|
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
|
|
p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key,
|
|
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost
|
|
from public.model_config_versions v join public.model_configs c on c.id=v.config_id
|
|
join public.model_providers p on p.id=v.provider_id where ${where}
|
|
`, values);
|
|
}
|
|
async function readPublishedCatalog(): Promise<LanguageModelCatalog> {
|
|
const rows = await queryCatalog("v.status='published' and v.enabled and p.enabled order by v.is_default desc,c.model_id");
|
|
const models: ResolvedLanguageModel[] = [];
|
|
for (const row of rows) { try { const model = await resolveRow(row); if (model) models.push(model); } catch { /* exclude undecryptable/unsafe providers */ } }
|
|
const defaults = models.filter((model) => model.isDefault);
|
|
return defaults.length === 1
|
|
? { models, publicModels: models.map(publicModel), defaultModelId: defaults[0]!.id, issues: [] }
|
|
: { models, publicModels: models.map(publicModel), defaultModelId: null, issues: [rows.length ? "default_model_unavailable" : "database_model_catalog_empty"] };
|
|
}
|
|
export async function loadLanguageModelCatalog(): Promise<LanguageModelCatalog> {
|
|
const cached = state.jyotishaModelCatalogCache;
|
|
if (cached && cached.expiresAt > Date.now()) return cached.catalog;
|
|
let catalog: LanguageModelCatalog;
|
|
try { catalog = await readPublishedCatalog(); }
|
|
catch { catalog = { models: [], publicModels: [], defaultModelId: null, issues: ["database_model_catalog_unavailable"] }; }
|
|
state.jyotishaModelCatalogCache = { expiresAt: Date.now() + cacheTtlMs, catalog };
|
|
return catalog;
|
|
}
|
|
async function readVersion(modelId: string, version: number) {
|
|
const rows = await queryCatalog("c.model_id=$1 and v.version=$2 and v.status in ('published','retired') and v.enabled and p.enabled", [modelId, version]);
|
|
return rows[0] ? resolveRow(rows[0]) : null;
|
|
}
|
|
export async function resolveSessionLanguageModel(modelId: string, configVersion: number | null | undefined) {
|
|
if (configVersion !== null && configVersion !== undefined) { try { return await readVersion(modelId, configVersion); } catch { return null; } }
|
|
const catalog = await loadLanguageModelCatalog();
|
|
return catalog.models.find((model) => model.id === modelId) ?? null;
|
|
}
|
|
export function invalidateLanguageModelCatalog() { delete state.jyotishaModelCatalogCache; }
|