87 lines
3.6 KiB
TypeScript
87 lines
3.6 KiB
TypeScript
import { ENV } from "./env";
|
||
|
||
export type ResolvedAiTrace = {
|
||
provider: string;
|
||
resolvedModel: string;
|
||
transport: string;
|
||
origin: string;
|
||
};
|
||
|
||
type ReferenceDecision = {
|
||
application?: { slug?: string };
|
||
decision?: { selected?: { engine?: { slug?: string } } | null } | null;
|
||
};
|
||
|
||
function configuration() {
|
||
const url = ENV.aiReferentielUrl.trim().replace(/\/$/, "");
|
||
const token = ENV.aiReferentielToken.trim();
|
||
const applicationSlug = ENV.aiReferentielAppSlug.trim();
|
||
if (!url || !token || !applicationSlug) {
|
||
throw new Error("Référentiel IA non configuré pour cette application.");
|
||
}
|
||
const parsed = new URL(url);
|
||
const allowedHosts = new Set([
|
||
"referentiel-ia.recette.santinova-soft.org",
|
||
"referentiel-ia.santinova-soft.org",
|
||
]);
|
||
if (parsed.protocol !== "https:" || !allowedHosts.has(parsed.hostname)) {
|
||
throw new Error("URL du Référentiel IA non autorisée.");
|
||
}
|
||
return { url, token, applicationSlug };
|
||
}
|
||
|
||
/** Résout à chaque appel le moteur choisi par le Référentiel, sans cache durable. */
|
||
async function resolveSelectedEngine(
|
||
currentConfiguration: { url: string; token: string; applicationSlug: string },
|
||
fetcher: typeof fetch,
|
||
): Promise<string> {
|
||
const response = await fetcher(`${currentConfiguration.url}/api/integration/v1/recommendations`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", authorization: `Bearer ${currentConfiguration.token}` },
|
||
body: JSON.stringify({ useCase: "texte", optimizeCost: false, needsFreeTier: false, includeConnection: false }),
|
||
signal: AbortSignal.timeout(15_000),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`Référentiel IA : décision indisponible (HTTP ${response.status}).`);
|
||
}
|
||
const decision = await response.json() as ReferenceDecision;
|
||
if (decision.application?.slug !== currentConfiguration.applicationSlug) {
|
||
throw new Error("Référentiel IA : la décision ne correspond pas à l’application attendue.");
|
||
}
|
||
const engineSlug = decision.decision?.selected?.engine?.slug?.trim();
|
||
if (!engineSlug) {
|
||
throw new Error("Référentiel IA : aucun moteur n’est sélectionné.");
|
||
}
|
||
return engineSlug;
|
||
}
|
||
|
||
/**
|
||
* Le Référentiel décide seul du moteur. La Veille transmet ensuite son slug exact
|
||
* à la passerelle centrale et ne connaît jamais de clé fournisseur.
|
||
*/
|
||
export async function invokeSelectedReference<T>(payload: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<T> {
|
||
const currentConfiguration = configuration();
|
||
const engineSlug = await resolveSelectedEngine(currentConfiguration, fetcher);
|
||
const response = await fetcher(`${currentConfiguration.url}/api/integration/v1/invoke`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", authorization: `Bearer ${currentConfiguration.token}` },
|
||
body: JSON.stringify({ engineSlug, payload }),
|
||
signal: AbortSignal.timeout(120_000),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`Référentiel IA : invocation refusée (HTTP ${response.status}).`);
|
||
}
|
||
const trace: ResolvedAiTrace = {
|
||
provider: response.headers.get("x-santinova-ia-provider") || "unknown",
|
||
resolvedModel: response.headers.get("x-santinova-ia-model") || "unknown",
|
||
transport: response.headers.get("x-santinova-ia-transport") || "unknown",
|
||
origin: response.headers.get("x-santinova-ia-origin") || "unknown",
|
||
};
|
||
console.info(`[IA_TRACE] provider=${trace.provider} model=${trace.resolvedModel} transport=${trace.transport} origin=${trace.origin}`);
|
||
const body = await response.json() as unknown;
|
||
if (body && typeof body === "object" && "error" in body) {
|
||
throw new Error("Référentiel IA : le moteur a renvoyé une erreur métier.");
|
||
}
|
||
return body as T;
|
||
}
|