IA Assistant version initiale

This commit is contained in:
ocroguennec committed 2026-09-05 10:54:50 +02:00
1 parent 3e8508d48b
commit 662003fdd3
29 files changed
+4253 -10

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
/**
* conversationHistory.js — Persistance des conversations de l'assistant IA
* (tables ai_conversations / ai_messages, migration dans db/index.js).
*
* Plusieurs conversations nommées par couple (user_id, investisseur_id) —
* même granularité de scope que le reste de l'application (cf.
* middleware/investisseurScope.js). Le client n'envoie que le dernier
* message utilisateur et l'id de conversation (ou aucun id pour en créer
* une nouvelle) ; c'est ce module qui reconstitue l'historique complet à
* chaque appel au modèle (voir routes/ai.js).
*/
import db from '../db/index.js';
const DEFAULT_LIMIT = 50;
function serializeConversation(row) {
if (!row) return null;
return { id: row.id, titre: row.titre, createdAt: row.created_at, updatedAt: row.updated_at };
}
/** Liste des conversations d'un utilisateur/investisseur, les plus
* récemment actives en premier. */
export function listConversations(userId, investisseurId, limit = 100) {
const rows = db.prepare(`
SELECT id, titre, created_at, updated_at
FROM ai_conversations
WHERE user_id = ? AND investisseur_id IS ?
ORDER BY updated_at DESC
LIMIT ?
`).all(userId, investisseurId, limit);
return rows.map(serializeConversation);
}
/** Une conversation précise, uniquement si elle appartient bien à ce
* couple user/investisseur (sinon null — jamais d'accès croisé). */
export function getConversation(userId, investisseurId, conversationId) {
const row = db.prepare(`
SELECT id, titre, created_at, updated_at
FROM ai_conversations WHERE id = ? AND user_id = ? AND investisseur_id IS ?
`).get(conversationId, userId, investisseurId);
return serializeConversation(row);
}
/** Crée une nouvelle conversation (titre dérivé du premier message côté
* routes/ai.js) et la retourne. */
export function createConversation(userId, investisseurId, titre) {
const { lastInsertRowid } = db.prepare(`
INSERT INTO ai_conversations (user_id, investisseur_id, titre) VALUES (?, ?, ?)
`).run(userId, investisseurId, titre || 'Nouvelle conversation');
return getConversation(userId, investisseurId, lastInsertRowid);
}
/** Supprime une conversation (et ses messages, ON DELETE CASCADE) si elle
* appartient à ce couple user/investisseur. Retourne true si une ligne a
* bien été supprimée. */
export function deleteConversation(userId, investisseurId, conversationId) {
const info = db.prepare(`
DELETE FROM ai_conversations WHERE id = ? AND user_id = ? AND investisseur_id IS ?
`).run(conversationId, userId, investisseurId);
return info.changes > 0;
}
/** Historique d'une conversation (ordre chronologique croissant), au
* format {role, content, tools:string[]} — tools est toujours [] pour
* role:'user'. Aucune vérification de propriété ici : à faire par
* l'appelant via getConversation() avant d'appeler cette fonction. */
export function getHistory(conversationId, limit = DEFAULT_LIMIT) {
const rows = db.prepare(`
SELECT role, content, outils_appeles, created_at
FROM ai_messages
WHERE conversation_id = ?
ORDER BY id DESC
LIMIT ?
`).all(conversationId, limit);
return rows.reverse().map((r) => ({
role: r.role,
content: r.content,
tools: r.outils_appeles ? JSON.parse(r.outils_appeles) : [],
createdAt: r.created_at,
}));
}
/** Enregistre un message dans une conversation et met à jour sa date
* d'activité (pour le tri par récence dans la liste). toolsUsed (tableau
* de noms d'outil ou null) n'a de sens que pour role:'assistant'.
* Best-effort : ne lève jamais (un échec de persistance ne doit pas
* casser la conversation). */
export function appendMessage(conversationId, userId, investisseurId, role, content, toolsUsed = null) {
try {
db.prepare(`
INSERT INTO ai_messages (conversation_id, user_id, investisseur_id, role, content, outils_appeles)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
conversationId,
userId,
investisseurId,
role,
content,
toolsUsed && toolsUsed.length ? JSON.stringify(toolsUsed) : null,
);
db.prepare(`UPDATE ai_conversations SET updated_at = datetime('now') WHERE id = ?`).run(conversationId);
} catch (e) {
console.error('[ai/conversationHistory] échec de persistance (ignoré) :', e.message);
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* crypto.js — Chiffrement au repos des clés API des fournisseurs IA
* (ai_providers.api_key_chiffree).
*
* Contrairement aux clés API internes de l'application (api_keys.key_hash,
* à sens unique — on ne fait que comparer un hash), une clé de fournisseur
* IA (Anthropic, OpenAI, OpenRouter...) doit être renvoyée EN CLAIR au
* fournisseur à chaque appel : elle est donc chiffrée de façon réversible
* (AES-256-GCM), jamais hashée.
*
* AI_ENC_KEY (obligatoire dès qu'un fournisseur IA est configuré) : chaîne
* hexadécimale de 64 caractères (32 octets), générée avec `openssl rand
* -hex 32` — voir .env.example. Ne jamais commiter cette clé ; la faire
* tourner invalide tous les fournisseurs déjà enregistrés (à re-saisir).
*/
import crypto from 'node:crypto';
const ALGO = 'aes-256-gcm';
const IV_LENGTH = 12; // taille recommandée pour GCM
function getKey() {
const hex = process.env.AI_ENC_KEY;
if (!hex) {
throw new Error(
"AI_ENC_KEY manquant — générez une clé avec `openssl rand -hex 32` et ajoutez-la à .env avant de configurer un fournisseur IA."
);
}
const key = Buffer.from(hex, 'hex');
if (key.length !== 32) {
throw new Error(
'AI_ENC_KEY doit être une chaîne hexadécimale de 64 caractères (32 octets) — générée avec `openssl rand -hex 32`.'
);
}
return key;
}
/**
* Chiffre une chaîne (typiquement une clé API de fournisseur IA) pour
* stockage en base. Format stocké : "<iv_hex>:<authTag_hex>:<ciphertext_hex>".
*/
export function encryptSecret(plaintext) {
const key = getKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const ciphertext = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${ciphertext.toString('hex')}`;
}
/** Déchiffre une valeur produite par encryptSecret(). Lève si AI_ENC_KEY a
* changé depuis le chiffrement (authTag invalide) ou si le format stocké
* est corrompu. */
export function decryptSecret(stored) {
const key = getKey();
const parts = String(stored).split(':');
if (parts.length !== 3) {
throw new Error('Valeur chiffrée malformée (attendu "iv:authTag:ciphertext")');
}
const [ivHex, authTagHex, ciphertextHex] = parts;
const decipher = crypto.createDecipheriv(ALGO, key, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextHex, 'hex')),
decipher.final(),
]);
return plaintext.toString('utf8');
}
+46
View File
@@ -0,0 +1,46 @@
/**
* internalApiKey.js — Clé API interne, éphémère, pour l'assistant IA embarqué.
*
* Donne au chat un accès à /api/v1 strictement identique à un client MCP
* externe (même middleware requireApiKey, même scope investisseur), sans
* dupliquer cette logique — voir mcpBridge.js pour l'usage.
*
* api_keys ne stocke normalement qu'un hash à sens unique (voir
* routes/apiKeys.js : "la valeur en clair n'est renvoyée qu'une fois"), donc
* impossible de relire une clé déjà créée pour la réutiliser. Plutôt que
* d'introduire un second mécanisme de stockage réversible, on génère une
* clé neuve à chaque requête POST /api/ai/chat (surcoût négligeable : un
* INSERT SQLite), utilisée uniquement pour la durée de cette requête, puis
* révoquée. Jamais affichée à l'utilisateur (colonne internal=1, filtrée de
* GET /api/api-keys).
*/
import db from '../db/index.js';
import { generateKey } from '../routes/apiKeys.js';
/** Crée une clé API interne à usage unique pour (userId, investisseurId).
* Retourne { id, key } — `key` est la valeur en clair à passer en
* en-tête X-API-Key, jamais stockée ailleurs que dans ce retour. */
export function createEphemeralInternalApiKey(userId, investisseurId) {
const { full, hash, prefix } = generateKey();
const info = db.prepare(`
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all, internal)
VALUES (?, ?, 'Assistant IA (interne)', ?, ?, 'read', 0, 1)
`).run(userId, investisseurId, prefix, hash);
return { id: info.lastInsertRowid, key: full };
}
/** Révoque (supprime) une clé interne créée par createEphemeralInternalApiKey.
* À appeler systématiquement en fin de requête (bloc finally), y compris en
* cas d'erreur — best-effort, ne doit jamais faire échouer l'appelant. Le
* filtre `internal = 1` est une double sécurité : cette fonction ne peut
* jamais supprimer une vraie clé créée par l'utilisateur. */
export function revokeInternalApiKey(id) {
try {
db.prepare('DELETE FROM api_keys WHERE id = ? AND internal = 1').run(id);
} catch (e) {
console.error('[ai/internalApiKey] échec de révocation (ignoré) :', e.message);
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* mcpBridge.js — Pont MCP in-process vers les mêmes outils que le serveur
* MCP externe (mcp-server/tools.js), réutilisés TELS QUELS (import direct
* depuis mcp-server/, jamais modifiés) via un client MCP standard connecté
* en mémoire (InMemoryTransport) — donc un comportement strictement
* identique à ce que voit un client MCP externe (Claude Desktop...), sans
* passer par le réseau, et sans aucun risque pour le serveur MCP externe en
* production (ce fichier ne le touche pas).
*
* Chaque appel crée son propre couple client/serveur MCP, léger, sans état
* partagé entre requêtes.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { registerDataTools } from '../../../mcp-server/tools.js';
/**
* Crée un client MCP connecté aux 9 outils de lecture seule du portefeuille,
* exécutés via `apiGet` (typiquement une clé API interne éphémère — voir
* internalApiKey.js).
*
* @param {(path: string, params?: object) => Promise<any>} apiGet
* @returns {Promise<Client>} Client MCP prêt pour listTools()/callTool().
* Appeler client.close() en fin d'usage.
*/
export async function createAiToolsClient(apiGet) {
const server = new McpServer({ name: 'crowdlending-ai-embarque', version: '0.1.0' });
registerDataTools(server, { apiGet });
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'crowdlending-ai-embarque-client', version: '0.1.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
return client;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* providerConfig.js — Résolution du fournisseur IA actif par défaut.
* Seul endroit qui définit cette règle (is_default=1 en priorité, sinon le
* plus ancien actif) — réutilisé par le middleware d'accès (aiAccess.js) et
* par la route de chat (routes/ai.js), pour ne jamais diverger.
*/
import db from '../db/index.js';
/** @returns {object|undefined} La ligne ai_providers par défaut active, ou
* undefined si aucun fournisseur actif n'est configuré. */
export function getDefaultActiveProvider() {
return db
.prepare('SELECT * FROM ai_providers WHERE actif = 1 ORDER BY is_default DESC, id ASC LIMIT 1')
.get();
}
+163
View File
@@ -0,0 +1,163 @@
/**
* providers/anthropic.js — Adaptateur fournisseur IA "anthropic" (natif,
* SDK officiel @anthropic-ai/sdk — pas de base_url, contrairement à
* l'adaptateur openai_compatible à venir).
*
* Boucle d'usage d'outils : tant que le modèle répond stop_reason
* 'tool_use', on exécute les outils demandés via le pont MCP (mcpBridge.js
* — les 9 mêmes outils en lecture seule que le serveur MCP externe) et on
* relance un tour avec le(s) tool_result ajoutés à l'historique, jusqu'à
* 'end_turn' ou MAX_TOOL_ITERATIONS (garde-fou anti-boucle).
*/
import Anthropic from '@anthropic-ai/sdk';
import { mcpToolsToAnthropic } from '../toolSchema.js';
const MAX_TOOL_ITERATIONS = 8;
const DEFAULT_MAX_TOKENS = 4096;
/**
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.model Ex. "claude-sonnet-4-5" (configuré par l'admin, ai_providers.modele)
* @param {string} [opts.systemPrompt]
* @param {Array<{role:'user'|'assistant', content:string}>} opts.messages
* Historique de conversation en texte simple (converti ici en blocs Anthropic).
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} opts.mcpClient
* Client MCP connecté (voir mcpBridge.js).
*
* @yields {
* {type:'text_delta', text:string} |
* {type:'tool_call', name:string, input:object} |
* {type:'tool_result', name:string, isError:boolean} |
* {type:'done', usage:{input_tokens:number, output_tokens:number}, toolsUsed:string[]} |
* {type:'error', message:string}
* }
*/
export async function* streamChat({ apiKey, model, systemPrompt, messages, mcpClient }) {
const anthropic = new Anthropic({ apiKey });
const { tools: mcpTools } = await mcpClient.listTools();
const tools = mcpToolsToAnthropic(mcpTools);
// Historique mutable au format API Anthropic (content = tableau de blocs).
let history = messages.map((m) => ({
role: m.role,
content: [{ type: 'text', text: m.content }],
}));
let totalInputTokens = 0;
let totalOutputTokens = 0;
const toolsUsed = [];
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let stream;
try {
stream = anthropic.messages.stream({
model,
max_tokens: DEFAULT_MAX_TOKENS,
system: systemPrompt,
messages: history,
tools,
});
} catch (e) {
yield { type: 'error', message: `Échec de l'appel au modèle : ${e.message}` };
return;
}
try {
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
yield { type: 'text_delta', text: event.delta.text };
}
}
} catch (e) {
yield { type: 'error', message: `Erreur pendant le streaming : ${e.message}` };
return;
}
let finalMessage;
try {
finalMessage = await stream.finalMessage();
} catch (e) {
yield { type: 'error', message: `Échec de récupération de la réponse : ${e.message}` };
return;
}
totalInputTokens += finalMessage.usage?.input_tokens || 0;
totalOutputTokens += finalMessage.usage?.output_tokens || 0;
history.push({ role: 'assistant', content: finalMessage.content });
if (finalMessage.stop_reason !== 'tool_use') {
yield {
type: 'done',
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens },
toolsUsed,
};
return;
}
const toolUseBlocks = finalMessage.content.filter((b) => b.type === 'tool_use');
const toolResultBlocks = [];
for (const block of toolUseBlocks) {
yield { type: 'tool_call', name: block.name, input: block.input };
toolsUsed.push(block.name);
let resultText;
let isError = false;
try {
const result = await mcpClient.callTool({ name: block.name, arguments: block.input });
isError = !!result.isError;
resultText =
result.content?.map((c) => c.text).join('\n') || JSON.stringify(result.structuredContent ?? {});
} catch (e) {
isError = true;
resultText = `Erreur : ${e.message}`;
}
yield { type: 'tool_result', name: block.name, isError };
toolResultBlocks.push({
type: 'tool_result',
tool_use_id: block.id,
content: resultText,
is_error: isError,
});
}
history.push({ role: 'user', content: toolResultBlocks });
}
yield {
type: 'error',
message: `Nombre maximum d'itérations d'outils atteint (${MAX_TOOL_ITERATIONS}) sans réponse finale.`,
};
}
/**
* testConnection() — appel minimal, sans streaming ni outils, pour
* vérifier depuis l'admin qu'une clé API + modèle fonctionnent (bouton
* « Tester la connexion » dans AdminIA.jsx, sans passer par le chat).
*
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.model
* @returns {Promise<{reply:string, durationMs:number, usage:object}>}
*/
export async function testConnection({ apiKey, model }) {
const anthropic = new Anthropic({ apiKey });
const started = Date.now();
const message = await anthropic.messages.create({
model,
max_tokens: 16,
messages: [{ role: 'user', content: 'Réponds uniquement par le mot "OK".' }],
});
const reply = (message.content || [])
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('')
.trim() || '(réponse vide)';
return { reply, durationMs: Date.now() - started, usage: message.usage };
}
@@ -0,0 +1,236 @@
/**
* providers/openaiCompatible.js — Adaptateur fournisseur IA
* "openai_compatible" : OpenAI, Mistral, Groq, un LLM local, ou OpenRouter
* (ai_providers.base_url pointé vers https://openrouter.ai/api/v1).
*
* Pas de SDK dédié : appel HTTP direct en POST {base_url}/chat/completions
* avec stream:true, et parsing manuel des trames SSE renvoyées par l'API
* (format `data: {...}\n\n`, terminé par `data: [DONE]`) — même technique
* que frontend/src/api.js:stream() côté client, pour rester cohérent avec
* le reste du projet plutôt que d'ajouter une dépendance SDK OpenAI.
*
* Boucle d'usage d'outils : même esprit que providers/anthropic.js (jusqu'à
* MAX_TOOL_ITERATIONS), adaptée au format "function calling" OpenAI : les
* tool_calls arrivent en deltas fragmentés (nom et arguments JSON accumulés
* morceau par morceau), puis sont exécutés via le pont MCP et renvoyés en
* messages role:"tool".
*
* NB : le support effectif du function calling en streaming varie selon le
* modèle/fournisseur derrière base_url (tous les modèles OpenRouter ne le
* supportent pas) — non testé en conditions réelles dans cette session,
* faute de clé API disponible (voir project_ia_embarquee.md, mémoire projet).
*/
import { mcpToolsToOpenAI } from '../toolSchema.js';
const MAX_TOOL_ITERATIONS = 8;
const DEFAULT_MAX_TOKENS = 4096;
/**
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.baseUrl Ex. "https://openrouter.ai/api/v1" (ai_providers.base_url)
* @param {string} opts.model Ex. "anthropic/claude-sonnet-4.5" (ai_providers.modele)
* @param {string} [opts.systemPrompt]
* @param {Array<{role:'user'|'assistant', content:string}>} opts.messages
* @param {import('@modelcontextprotocol/sdk/client/index.js').Client} opts.mcpClient
*
* @yields Mêmes événements que providers/anthropic.js#streamChat.
*/
export async function* streamChat({ apiKey, baseUrl, model, systemPrompt, messages, mcpClient }) {
const { tools: mcpTools } = await mcpClient.listTools();
const tools = mcpToolsToOpenAI(mcpTools);
let history = [
...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []),
...messages.map((m) => ({ role: m.role, content: m.content })),
];
let totalInputTokens = 0;
let totalOutputTokens = 0;
const toolsUsed = [];
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`;
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: history,
tools,
stream: true,
max_tokens: DEFAULT_MAX_TOKENS,
}),
});
} catch (e) {
yield { type: 'error', message: `Échec de l'appel au modèle : ${e.message}` };
return;
}
if (!res.ok || !res.body) {
let detail = '';
try { detail = await res.text(); } catch { /* noop */ }
yield { type: 'error', message: `Erreur API (${res.status}) : ${detail || res.statusText}` };
return;
}
// Accumulateurs pour reconstruire la réponse assistant à partir des
// deltas SSE (le contenu texte ET les tool_calls arrivent fragmentés).
let assistantContent = '';
const toolCallsAcc = new Map(); // index -> { id, name, argsText }
let finishReason = null;
let usage = null;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
const line = frame.split('\n').find((l) => l.startsWith('data:'));
if (!line) continue;
const data = line.slice(5).trim();
if (!data || data === '[DONE]') continue;
let parsed;
try { parsed = JSON.parse(data); } catch { continue; }
if (parsed.usage) usage = parsed.usage;
const choice = parsed.choices?.[0];
if (!choice) continue;
const delta = choice.delta || {};
if (delta.content) {
assistantContent += delta.content;
yield { type: 'text_delta', text: delta.content };
}
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? 0;
if (!toolCallsAcc.has(idx)) toolCallsAcc.set(idx, { id: tc.id || `call_${idx}`, name: '', argsText: '' });
const acc = toolCallsAcc.get(idx);
if (tc.id) acc.id = tc.id;
if (tc.function?.name) acc.name += tc.function.name;
if (tc.function?.arguments) acc.argsText += tc.function.arguments;
}
}
if (choice.finish_reason) finishReason = choice.finish_reason;
}
}
} catch (e) {
yield { type: 'error', message: `Erreur pendant le streaming : ${e.message}` };
return;
}
if (usage) {
totalInputTokens += usage.prompt_tokens || 0;
totalOutputTokens += usage.completion_tokens || 0;
}
if (finishReason !== 'tool_calls' || toolCallsAcc.size === 0) {
history.push({ role: 'assistant', content: assistantContent });
yield {
type: 'done',
usage: { input_tokens: totalInputTokens, output_tokens: totalOutputTokens },
toolsUsed,
};
return;
}
const toolCalls = [...toolCallsAcc.values()];
history.push({
role: 'assistant',
content: assistantContent || null,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: { name: tc.name, arguments: tc.argsText },
})),
});
for (const tc of toolCalls) {
let input = {};
try { input = tc.argsText ? JSON.parse(tc.argsText) : {}; }
catch { /* arguments JSON malformés renvoyés par le modèle — outil appelé avec {} */ }
yield { type: 'tool_call', name: tc.name, input };
toolsUsed.push(tc.name);
let resultText;
let isError = false;
try {
const result = await mcpClient.callTool({ name: tc.name, arguments: input });
isError = !!result.isError;
resultText =
result.content?.map((c) => c.text).join('\n') || JSON.stringify(result.structuredContent ?? {});
} catch (e) {
isError = true;
resultText = `Erreur : ${e.message}`;
}
yield { type: 'tool_result', name: tc.name, isError };
history.push({ role: 'tool', tool_call_id: tc.id, content: resultText });
}
}
yield {
type: 'error',
message: `Nombre maximum d'itérations d'outils atteint (${MAX_TOOL_ITERATIONS}) sans réponse finale.`,
};
}
/**
* testConnection() — appel minimal, sans streaming ni outils, pour
* vérifier depuis l'admin qu'une clé API + base_url + modèle fonctionnent
* (bouton « Tester la connexion » dans AdminIA.jsx, sans passer par le chat).
*
* @param {object} opts
* @param {string} opts.apiKey
* @param {string} opts.baseUrl
* @param {string} opts.model
* @returns {Promise<{reply:string, durationMs:number, usage:object}>}
*/
export async function testConnection({ apiKey, baseUrl, model }) {
const started = Date.now();
const url = `${String(baseUrl).replace(/\/+$/, '')}/chat/completions`;
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model,
messages: [{ role: 'user', content: 'Réponds uniquement par le mot "OK".' }],
max_tokens: 16,
stream: false,
}),
signal: AbortSignal.timeout(20000),
});
} catch (e) {
throw new Error(`Impossible de joindre ${baseUrl} : ${e.message}`);
}
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
if (!res.ok) {
const msg = body?.error?.message || body?.error || res.statusText || `Erreur HTTP ${res.status}`;
throw new Error(`Erreur API (${res.status}) : ${msg}`);
}
const reply = body?.choices?.[0]?.message?.content?.trim() || '(réponse vide)';
return { reply, durationMs: Date.now() - started, usage: body?.usage };
}
+39
View File
@@ -0,0 +1,39 @@
/**
* toolSchema.js — Conversion du format d'outil MCP (client.listTools(), voir
* mcpBridge.js) vers les formats `tools` attendus par les deux adaptateurs :
* API Messages Anthropic (mcpToolsToAnthropic) et function calling OpenAI
* (mcpToolsToOpenAI, utilisé par providers/openaiCompatible.js — OpenAI,
* Mistral, Groq, LLM local, OpenRouter…).
*/
/** Retire les champs de méta-schéma (ex. $schema) que le SDK MCP ajoute mais
* qu'Anthropic n'attend pas dans input_schema. */
function cleanInputSchema(schema) {
if (!schema || typeof schema !== 'object') return { type: 'object', properties: {} };
const { $schema, ...rest } = schema;
return rest;
}
/** Convertit la liste d'outils MCP (résultat de client.listTools().tools) au
* format `tools` de l'API Messages Anthropic. */
export function mcpToolsToAnthropic(mcpTools) {
return mcpTools.map((t) => ({
name: t.name,
description: t.description || '',
input_schema: cleanInputSchema(t.inputSchema),
}));
}
/** Convertit la liste d'outils MCP au format `tools` de function calling
* OpenAI (utilisé aussi par OpenRouter, Mistral, Groq, et la plupart des
* LLM locaux compatibles OpenAI). */
export function mcpToolsToOpenAI(mcpTools) {
return mcpTools.map((t) => ({
type: 'function',
function: {
name: t.name,
description: t.description || '',
parameters: cleanInputSchema(t.inputSchema),
},
}));
}