fix: tracer le moteur IA réellement invoqué
All checks were successful
Validation applicative / TypeScript, tests et build (push) Successful in 1m52s

This commit is contained in:
Manus Deployment
2026-09-09 08:27:43 +00:00
parent f7cfa73206
commit 7754affe96
7 changed files with 2659 additions and 16 deletions

View File

@@ -0,0 +1,3 @@
ALTER TABLE `llmLogs` ADD `resolvedProvider` varchar(30);--> statement-breakpoint
ALTER TABLE `llmLogs` ADD `resolvedModel` varchar(80);--> statement-breakpoint
ALTER TABLE `llmLogs` ADD `transport` varchar(30);

File diff suppressed because it is too large Load Diff

View File

@@ -281,6 +281,13 @@
"when": 1788350633569,
"tag": "0039_good_carmella_unuscione",
"breakpoints": true
},
{
"idx": 40,
"version": "5",
"when": 1788942350737,
"tag": "0040_sudden_flatman",
"breakpoints": true
}
]
}

View File

@@ -248,7 +248,14 @@ export const llmLogs = mysqlTable("llmLogs", {
sourceFileId: int("sourceFileId"), // Optional: link to source file if applicable
invoiceId: int("invoiceId"), // Optional: link to invoice if applicable
operation: varchar("operation", { length: 50 }).notNull(), // "detection" or "extraction"
model: varchar("model", { length: 50 }).notNull(), // LLM model used
/** Modèle demandé ou historique, conservé pour compatibilité. */
model: varchar("model", { length: 50 }).notNull(),
/** Fournisseur réellement résolu par l’adaptateur au moment de l’appel. */
resolvedProvider: varchar("resolvedProvider", { length: 30 }),
/** Modèle réellement inclus dans le payload envoyé au fournisseur. */
resolvedModel: varchar("resolvedModel", { length: 80 }),
/** Catégorie de transport, sans URL ni secret. */
transport: varchar("transport", { length: 30 }),
promptSent: text("promptSent").notNull(), // Full prompt sent to LLM
rawResponse: text("rawResponse").notNull(), // Raw response from LLM (before cleaning)
cleanedResponse: text("cleanedResponse"), // Response after markdown cleaning

View File

@@ -97,6 +97,29 @@ export type InvokeResult = {
};
};
export type LlmProvider = "mistral" | "manus" | "gemini";
export type LlmTransport = "mistral-api" | "manus-forge" | "gemini-api";
export type UserLlmSettings = {
aiProvider?: string | null;
mistralApiKey?: string | null;
manusForgeApiKey?: string | null;
manusForgeApiUrl?: string | null;
geminiApiKey?: string | null;
};
/** Métadonnées non sensibles décrivant l’appel LLM réellement résolu. */
export type ResolvedLlmTrace = {
provider: LlmProvider;
model: string;
transport: LlmTransport;
};
export type ResolvedLlmInvocation = {
response: InvokeResult;
trace: ResolvedLlmTrace;
};
export type JsonSchema = {
name: string;
schema: Record<string, unknown>;
@@ -283,7 +306,7 @@ const normalizeResponseFormat = ({
/**
* Résout l'URL API en tenant compte des paramètres utilisateur (DB) en priorité sur les variables d'environnement.
*/
function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }): string {
function resolveApiUrlWithSettings(settings?: UserLlmSettings): string {
const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") {
return "https://api.mistral.ai/v1/chat/completions";
@@ -297,7 +320,7 @@ function resolveApiUrlWithSettings(settings?: { aiProvider?: string | null; mist
return `${forgeUrl.replace(/\/$/, "")}/v1/chat/completions`;
}
function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; geminiApiKey?: string | null }): string {
function getApiKeyWithSettings(settings?: UserLlmSettings): string {
const provider = settings?.aiProvider || "mistral";
if (provider === "mistral") {
return settings?.mistralApiKey || ENV.mistralApiKey || "";
@@ -308,11 +331,20 @@ function getApiKeyWithSettings(settings?: { aiProvider?: string | null; mistralA
return settings?.manusForgeApiKey || ENV.forgeApiKey || "";
}
function getModelForProvider(provider: string): string {
if (provider === "mistral") return "mistral-large-latest";
if (provider === "gemini") return "gemini-2.0-flash";
// Manus Forge → Gemini 2.5 Flash via proxy
return "gemini-2.5-flash";
/**
* Résout le fournisseur, le modèle et la passerelle effectivement utilisés.
* Cette trace ne contient aucun secret ni URL personnalisée.
*/
export function resolveLlmTrace(settings?: UserLlmSettings): ResolvedLlmTrace {
const provider = settings?.aiProvider || "mistral";
if (provider === "manus") {
return { provider: "manus", model: "gemini-2.5-flash", transport: "manus-forge" };
}
if (provider === "gemini") {
return { provider: "gemini", model: "gemini-2.0-flash", transport: "gemini-api" };
}
return { provider: "mistral", model: "mistral-large-latest", transport: "mistral-api" };
}
/**
@@ -321,14 +353,15 @@ function getModelForProvider(provider: string): string {
*/
export async function invokeLLMWithUserSettings(
params: InvokeParams,
userSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null; geminiApiKey?: string | null }
): Promise<InvokeResult> {
userSettings?: UserLlmSettings
): Promise<ResolvedLlmInvocation> {
const apiKey = getApiKeyWithSettings(userSettings);
if (!apiKey || apiKey.trim().length === 0) {
throw new Error("Aucune clé API configurée pour le moteur IA sélectionné");
}
const provider = userSettings?.aiProvider || "mistral";
const trace = resolveLlmTrace(userSettings);
const provider = trace.provider;
const {
messages,
@@ -341,7 +374,7 @@ export async function invokeLLMWithUserSettings(
response_format,
} = params;
const model = getModelForProvider(provider);
const model = trace.model;
const payload: Record<string, unknown> = {
model,
@@ -378,7 +411,10 @@ export async function invokeLLMWithUserSettings(
throw new Error(`LLM invoke failed: ${response.status} ${response.statusText} – ${errorText}`);
}
return (await response.json()) as InvokeResult;
return {
response: (await response.json()) as InvokeResult,
trace,
};
}
export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {

View File

@@ -1,4 +1,4 @@
import { invokeLLMWithUserSettings } from "./_core/llm";
import { invokeLLMWithUserSettings, type UserLlmSettings } from "./_core/llm";
import { PDFDocument } from "pdf-lib";
import { createLlmLog } from "./db";
import PDFParser from "pdf2json";
@@ -144,7 +144,7 @@ export async function extractInvoicesWithMistral(
subscription?: string | null;
recipient?: string | null;
},
aiSettings?: { aiProvider?: string | null; mistralApiKey?: string | null; manusForgeApiKey?: string | null; manusForgeApiUrl?: string | null }
aiSettings?: UserLlmSettings
): Promise<MultiInvoiceResult> {
// Load user's field configuration
const { getLlmFieldsConfigByUser } = await import("./db");
@@ -241,7 +241,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
// Call Mistral LLM with extracted text
const fullPrompt = `${prompt}\n\nTexte extrait du PDF:\n${pdfText}`;
const response = await invokeLLMWithUserSettings(
const invocation = await invokeLLMWithUserSettings(
{
messages: [
{
@@ -252,6 +252,7 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
},
aiSettings
);
const { response, trace } = invocation;
const rawResponse = typeof response.choices[0]?.message?.content === "string"
? response.choices[0].message.content
@@ -276,6 +277,9 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
sourceFileId,
operation: "extraction",
model,
resolvedProvider: trace.provider,
resolvedModel: trace.model,
transport: trace.transport,
promptSent: prompt,
rawResponse: rawResponse.substring(0, 10000),
cleanedResponse: cleanedResponse.substring(0, 10000),
@@ -293,6 +297,9 @@ Si une information n'est pas trouvée, utilise null. Ne retourne AUCUN texte en
sourceFileId,
operation: "extraction",
model,
resolvedProvider: trace.provider,
resolvedModel: trace.model,
transport: trace.transport,
promptSent: prompt.substring(0, 10000),
rawResponse: rawResponse.substring(0, 10000),
cleanedResponse: cleanedResponse.substring(0, 10000),

66
server/llm-trace.test.ts Normal file
View File

@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { invokeLLMWithUserSettings, resolveLlmTrace } from "./_core/llm";
describe("resolveLlmTrace", () => {
it("trace Manus Forge avec le modèle réellement résolu", () => {
expect(resolveLlmTrace({ aiProvider: "manus" })).toEqual({
provider: "manus",
model: "gemini-2.5-flash",
transport: "manus-forge",
});
});
it("trace Mistral et Gemini sans déduire le modèle depuis un champ historique", () => {
expect(resolveLlmTrace({ aiProvider: "mistral" })).toEqual({
provider: "mistral",
model: "mistral-large-latest",
transport: "mistral-api",
});
expect(resolveLlmTrace({ aiProvider: "gemini" })).toEqual({
provider: "gemini",
model: "gemini-2.0-flash",
transport: "gemini-api",
});
});
it("reste sur Mistral lorsque le fournisseur est absent", () => {
expect(resolveLlmTrace()).toEqual({
provider: "mistral",
model: "mistral-large-latest",
transport: "mistral-api",
});
});
});
describe("invokeLLMWithUserSettings", () => {
afterEach(() => vi.unstubAllGlobals());
it("retourne la trace Manus correspondant au payload réellement envoyé", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "test", created: 0, model: "gemini-2.5-flash", choices: [] }),
});
vi.stubGlobal("fetch", fetchMock);
const invocation = await invokeLLMWithUserSettings(
{ messages: [{ role: "user", content: "test non sensible" }] },
{
aiProvider: "manus",
manusForgeApiKey: "test-only-not-a-secret",
manusForgeApiUrl: "https://forge.example.test",
},
);
expect(invocation.trace).toEqual({
provider: "manus",
model: "gemini-2.5-flash",
transport: "manus-forge",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://forge.example.test/v1/chat/completions",
expect.objectContaining({ method: "POST" }),
);
const request = fetchMock.mock.calls[0][1] as RequestInit;
expect(JSON.parse(String(request.body))).toMatchObject({ model: "gemini-2.5-flash" });
});
});