diff --git a/app.json b/app.json index 6a50ca3..b351803 100644 --- a/app.json +++ b/app.json @@ -2,6 +2,9 @@ "id": "veille-reglementaire", "name": "Veille Réglementaire", "category": "ITINOVA", + "ai": { + "activeConfigurationPath": "/api/ai/active-configuration" + }, "urls": { "recette": "https://veille.recette.santinova-soft.org", "prod": "https://veille.santinova-soft.org" diff --git a/server/_core/aiReferenceClient.test.ts b/server/_core/aiReferenceClient.test.ts new file mode 100644 index 0000000..f0f131e --- /dev/null +++ b/server/_core/aiReferenceClient.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { ENV } from "./env"; +import { invokeSelectedReference } from "./aiReferenceClient"; + +describe("client Référentiel IA", () => { + it("transmet seulement le payload métier et restitue la trace résolue", async () => { + ENV.aiReferentielUrl = "https://referentiel-ia.recette.santinova-soft.org"; + ENV.aiReferentielToken = "token-de-test"; + const fetcher = vi.fn(async () => new Response(JSON.stringify({ choices: [] }), { + status: 200, + headers: { + "x-santinova-ia-provider": "Manus", + "x-santinova-ia-model": "gemini-2.5-flash", + "x-santinova-ia-transport": "manus-forge", + "x-santinova-ia-origin": "manual", + }, + })); + + const response = await invokeSelectedReference<{ choices: unknown[] }>({ messages: [{ role: "user", content: "test" }] }, fetcher as typeof fetch); + + expect(response.choices).toEqual([]); + expect(fetcher).toHaveBeenCalledOnce(); + const [, options] = fetcher.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(options.body))).toEqual({ payload: { messages: [{ role: "user", content: "test" }] } }); + }); +}); diff --git a/server/_core/aiReferenceClient.ts b/server/_core/aiReferenceClient.ts new file mode 100644 index 0000000..d51fb1c --- /dev/null +++ b/server/_core/aiReferenceClient.ts @@ -0,0 +1,53 @@ +import { ENV } from "./env"; + +export type ResolvedAiTrace = { + provider: string; + resolvedModel: string; + transport: string; + origin: string; +}; + +function configuration() { + const url = ENV.aiReferentielUrl.trim().replace(/\/$/, ""); + const token = ENV.aiReferentielToken.trim(); + if (!url || !token) { + throw new Error("Référentiel IA non configuré pour cette application."); + } + return { url, token }; +} + +/** + * Le Référentiel décide seul du moteur et écrase tout modèle éventuellement + * présent dans le payload. La trace détaillée est persistée centralement. + */ +export async function invokeSelectedReference(payload: Record, fetcher: typeof fetch = fetch): Promise { + const { url, token } = configuration(); + const response = await fetcher(`${url}/api/integration/v1/invoke`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ 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) { + const message = typeof (body as { error?: { message?: unknown } }).error?.message === "string" + ? (body as { error: { message: string } }).error.message + : "erreur non détaillée"; + throw new Error(`Référentiel IA : ${message}`); + } + return body as T; +} diff --git a/server/_core/env.ts b/server/_core/env.ts index 2792b99..5166ba1 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -7,4 +7,6 @@ export const ENV = { isProduction: process.env.NODE_ENV === "production", forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "", forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "", + aiReferentielUrl: process.env.IA_REFERENTIEL_URL ?? "", + aiReferentielToken: process.env.IA_REFERENTIEL_TOKEN ?? "", }; diff --git a/server/_core/index.ts b/server/_core/index.ts index 850d1d1..9489817 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -15,6 +15,7 @@ import { ensureAdminExists, generateLocalToken } from "../localAuth"; import { isAzureAdConfigured, getAzureAuthUrl, handleAzureCallback } from "../azureAuth"; import { getLocalUserByAzureAdId, getLocalUserByEmail, upsertLocalUserAzure, getSetting, purgeOldArticles } from "../db"; import { runRssFetch } from "../rssEngine"; +import { getActiveAiConfiguration } from "../ai-reference"; function isPortAvailable(port: number): Promise { return new Promise(resolve => { @@ -123,6 +124,11 @@ async function startServer() { app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); + app.get("/api/ai/active-configuration", (_request, response) => { + void getActiveAiConfiguration() + .then(configuration => response.setHeader("Cache-Control", "no-store").json(configuration)) + .catch(() => response.status(503).json({ active: false, engines: [] })); + }); registerOAuthRoutes(app); app.use(uploadRoutes); diff --git a/server/_core/llm.ts b/server/_core/llm.ts index 8ea4c4a..243dee1 100644 --- a/server/_core/llm.ts +++ b/server/_core/llm.ts @@ -1,4 +1,5 @@ import { ENV } from "./env"; +import { invokeSelectedReference } from "./aiReferenceClient"; export type Role = "system" | "user" | "assistant" | "tool" | "function"; @@ -266,7 +267,6 @@ const normalizeResponseFormat = ({ }; export async function invokeLLM(params: InvokeParams): Promise { - assertApiKey(); const { messages, @@ -280,7 +280,6 @@ export async function invokeLLM(params: InvokeParams): Promise { } = params; const payload: Record = { - model: "gemini-2.5-flash", messages: messages.map(normalizeMessage), }; @@ -312,21 +311,5 @@ export async function invokeLLM(params: InvokeParams): Promise { payload.response_format = normalizedResponseFormat; } - const response = await fetch(resolveApiUrl(), { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Bearer ${ENV.forgeApiKey}`, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}` - ); - } - - return (await response.json()) as InvokeResult; + return invokeSelectedReference(payload); } diff --git a/server/ai-reference.ts b/server/ai-reference.ts new file mode 100644 index 0000000..b483b07 --- /dev/null +++ b/server/ai-reference.ts @@ -0,0 +1,167 @@ +import { ENV } from "./_core/env"; + +export type AiEngineStatus = { + state: "available" | "unconfigured" | "unreachable"; + engine?: { + name: string; + provider: string; + slug: string; + model?: string; + accessMode?: "gateway" | "recommendation_only"; + configured: boolean; + origin: "proposal" | "manual" | "local"; + rationale?: string; + }; + proposal?: { name: string; provider: string; model?: string; origin: "proposal"; rationale?: string }; + usage?: UsageSummary; + checkedAt: string; + message: string; +}; + +type ReferenceConfiguration = { + url: string; + token: string; +}; + +type RecommendationResponse = { + decision?: { + proposal?: DecisionEngine; + selected?: DecisionEngine; + }; + recommendations?: DecisionEngine[]; + usage?: UsageSummary; +}; + +type UsageMetric = { used: number; limit: number | null; percent: number | null; status: "not_configured" | "normal" | "alert" | "limit_reached" | "unpriced"; shouldBlock?: boolean }; +type UsageAlert = { metric: "budget" | "requests" | "tokens"; label: string; status: "alert" | "limit_reached"; percent: number; message: string }; +type UsageSummary = { canInvoke: boolean; blockReason?: string | null; budget: UsageMetric & { currency: string; estimated?: boolean }; requests: UsageMetric; tokens: UsageMetric; alerts: UsageAlert[] }; + +type DecisionEngine = { + origin?: "proposal" | "manual" | "local"; + rationale?: string; + engine?: { name?: string; provider?: string; slug?: string }; + connection?: { model?: string; accessMode?: "gateway" | "recommendation_only"; configured?: boolean }; +}; + +const now = () => new Date().toISOString(); + +export async function getAiEngineStatus( + configuration: ReferenceConfiguration = { + url: ENV.aiReferentielUrl, + token: ENV.aiReferentielToken, + }, + fetcher: typeof fetch = fetch, +): Promise { + if (!configuration.url || !configuration.token) { + return { + state: "unconfigured", + checkedAt: now(), + message: "Le Référentiel IA n’est pas encore configuré pour cette application.", + }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6_000); + try { + const response = await fetcher(`${configuration.url.replace(/\/$/, "")}/api/integration/v1/recommendations`, { + method: "POST", + headers: { + Authorization: `Bearer ${configuration.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + useCase: "texte", + optimizeCost: true, + needsFreeTier: true, + includeConnection: true, + }), + signal: controller.signal, + }); + + if (!response.ok) { + return { + state: "unreachable", + checkedAt: now(), + message: `Le Référentiel IA est indisponible (HTTP ${response.status}).`, + }; + } + + const payload = (await response.json()) as RecommendationResponse; + const selected = payload.decision?.selected ?? payload.recommendations?.[0]; + if (!selected?.engine?.name || !selected.engine.provider || !selected.engine.slug) { + return { + state: "unreachable", + checkedAt: now(), + message: "Le Référentiel IA n’a retourné aucun moteur autorisé.", + }; + } + + const proposal = payload.decision?.proposal; + const origin = selected.origin === "manual" || selected.origin === "local" ? selected.origin : "proposal"; + return { + state: "available", + checkedAt: now(), + message: selected.connection?.configured + ? origin === "local" + ? "Moteur IA local sélectionné par le Référentiel IA." + : origin === "manual" + ? "Moteur choisi par un administrateur pour cette application." + : "Proposition du Référentiel IA appliquée à cette application." + : "Moteur décidé par le Référentiel IA, en attente de configuration fournisseur.", + engine: { + name: selected.engine.name, + provider: selected.engine.provider, + slug: selected.engine.slug, + model: selected.connection?.model, + accessMode: selected.connection?.accessMode, + configured: Boolean(selected.connection?.configured), + origin, + rationale: selected.rationale, + }, + proposal: proposal?.engine?.name && proposal.engine.provider ? { + name: proposal.engine.name, + provider: proposal.engine.provider, + model: proposal.connection?.model, + origin: "proposal", + rationale: proposal.rationale, + } : undefined, + usage: payload.usage, + }; + } catch { + return { + state: "unreachable", + checkedAt: now(), + message: "La vérification auprès du Référentiel IA a échoué.", + }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Métadonnées strictement descriptives de la décision active. Cette fonction + * ne retourne jamais le jeton applicatif ni une URL d'intégration privée. + */ +export async function getActiveAiConfiguration( + configuration: ReferenceConfiguration = { + url: ENV.aiReferentielUrl, + token: ENV.aiReferentielToken, + }, + fetcher: typeof fetch = fetch, +) { + const status = await getAiEngineStatus(configuration, fetcher); + if (status.state !== "available" || !status.engine?.configured || !status.engine.model) { + return { active: false, engines: [] }; + } + + return { + active: true, + engines: [{ + provider: status.engine.provider, + model: status.engine.model, + role: "Moteur sélectionné par le Référentiel IA", + billing: status.engine.origin === "local" ? "free" : "paid", + source: "referential", + }], + }; +}