diff --git a/nuxt.config.ts b/nuxt.config.ts index 13db4a0..2a4615b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -16,6 +16,8 @@ export default defineNuxtConfig({ commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID, statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc', mistralApiKey: process.env.MISTRAL_API_KEY, + bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080', + bifrostVk: process.env.BIFROST_VK, redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379', resendApiKey: process.env.RESEND_API_KEY, emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr', diff --git a/server/api/chatbot-reseaux.post.ts b/server/api/chatbot-reseaux.post.ts index 19b1a4c..c905657 100644 --- a/server/api/chatbot-reseaux.post.ts +++ b/server/api/chatbot-reseaux.post.ts @@ -6,6 +6,7 @@ // @ts-ignore — JSON import résolu par Rollup import reseauxData from '../../public/data/reseaux-bifurcation.json' import { checkRateLimitJson } from '~/server/utils/rateLimitJson' +import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost' interface Structure { id: string @@ -61,6 +62,7 @@ export default defineEventHandler(async (event) => { const body = await readBody(event) const question: string = (body?.question ?? '').trim() if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' }) + const tier = pickBifrostTier(body?.mode) const structures: Structure[] = ((reseauxData as any).structures ?? []) const keywords = extractKeywords(question) @@ -82,18 +84,20 @@ export default defineEventHandler(async (event) => { const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0)) - const mistralApiKey = config.mistralApiKey as string - if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' }) + const bifrostUrl = config.bifrostUrl as string + const bifrostVk = config.bifrostVk as string + if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' }) let mistralRaw: string try { - const res = await $fetch<{ choices: { message: { content: string } }[] }>( - 'https://api.mistral.ai/v1/chat/completions', + const res = await $fetch( + `${bifrostUrl}/v1/chat/completions`, { method: 'POST', - headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' }, + headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'mistral-small-latest', + model: tier.model, + fallbacks: tier.fallbacks, temperature: 0.3, max_tokens: 700, response_format: { type: 'json_object' }, diff --git a/server/api/chatbot-taff.post.ts b/server/api/chatbot-taff.post.ts index b76fbb1..3a76585 100644 --- a/server/api/chatbot-taff.post.ts +++ b/server/api/chatbot-taff.post.ts @@ -7,6 +7,7 @@ // @ts-ignore — JSON import résolu par Vite/Rollup import taffData from '../../public/data/plateformes-taff.json' import { checkRateLimitJson } from '~/server/utils/rateLimitJson' +import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost' interface PlateformeMinimal { id: string @@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => { if (!question || question.length < 5) { throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' }) } + const tier = pickBifrostTier(body?.mode) // Données bundlées statiquement à la compilation (import JSON) const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({ @@ -91,20 +93,22 @@ export default defineEventHandler(async (event) => { const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0)) - const mistralApiKey = config.mistralApiKey as string - if (!mistralApiKey) { - throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' }) + const bifrostUrl = config.bifrostUrl as string + const bifrostVk = config.bifrostVk as string + if (!bifrostVk) { + throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' }) } let mistralRaw: string try { - const res = await $fetch<{ choices: { message: { content: string } }[] }>( - 'https://api.mistral.ai/v1/chat/completions', + const res = await $fetch( + `${bifrostUrl}/v1/chat/completions`, { method: 'POST', - headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' }, + headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'mistral-small-latest', + model: tier.model, + fallbacks: tier.fallbacks, temperature: 0.3, max_tokens: 700, response_format: { type: 'json_object' }, diff --git a/server/api/chatbot.post.ts b/server/api/chatbot.post.ts index b568756..f5ca706 100644 --- a/server/api/chatbot.post.ts +++ b/server/api/chatbot.post.ts @@ -19,6 +19,7 @@ import { checkRateLimitJson } from '~/server/utils/rateLimitJson' import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker' +import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost' // ── Types ────────────────────────────────────────────────────────────────────── @@ -131,18 +132,19 @@ async function logUsage(params: { nocodbUrl: string nocodbToken: string statsTableId: string + model: string tokensIn: number tokensOut: number coutEur: number }) { - const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params + const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records` try { await $fetch(logUrl, { method: 'POST', headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: 'mistral-small-latest', + model, endpoint: 'chatbot', tokens_in: tokensIn, tokens_out: tokensOut, @@ -180,6 +182,7 @@ export default defineEventHandler(async (event) => { const body = await readBody(event) const question: string = (body?.question ?? '').trim() const filters: { fonction?: string; echelle?: string } = body?.filters ?? {} + const tier = pickBifrostTier(body?.mode) if (!question || question.length < 3) { throw createError({ @@ -247,32 +250,32 @@ export default defineEventHandler(async (event) => { JSON.stringify(fichesContext, null, 0), ) - // 6. Appel Mistral Small - const mistralApiKey = config.mistralApiKey as string + // 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct) + const bifrostUrl = config.bifrostUrl as string + const bifrostVk = config.bifrostVk as string - if (!mistralApiKey) { + if (!bifrostVk) { throw createError({ statusCode: 500, - statusMessage: 'Clé API Mistral manquante.', + statusMessage: 'Clé Bifrost manquante.', }) } let mistralRaw: string let tokensIn = 0 let tokensOut = 0 + let realModel = tier.model try { - const mistralRes = await $fetch<{ - choices: { message: { content: string } }[] - usage?: { prompt_tokens: number; completion_tokens: number } - }>('https://api.mistral.ai/v1/chat/completions', { + const bifrostRes = await $fetch(`${bifrostUrl}/v1/chat/completions`, { method: 'POST', headers: { - Authorization: `Bearer ${mistralApiKey}`, + 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: 'mistral-small-latest', + model: tier.model, + fallbacks: tier.fallbacks, temperature: 0.3, max_tokens: 600, response_format: { type: 'json_object' }, @@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => { }), }) - mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}' - tokensIn = mistralRes.usage?.prompt_tokens ?? 0 - tokensOut = mistralRes.usage?.completion_tokens ?? 0 + mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}' + tokensIn = bifrostRes.usage?.prompt_tokens ?? 0 + tokensOut = bifrostRes.usage?.completion_tokens ?? 0 + realModel = + bifrostRes.extra_fields?.resolved_model_used ?? + (bifrostRes.extra_fields?.provider + ? `${bifrostRes.extra_fields.provider}/${tier.model.split('/').slice(1).join('/')}` + : tier.model) } catch (e: any) { - console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e) + console.error('[chatbot] Erreur Bifrost:', e?.message ?? e) throw createError({ statusCode: 502, statusMessage: 'Erreur appel IA — réessaie dans quelques instants.', @@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => { } // 8. Log usage (non bloquant) - const coutEur = calcCoutMistralSmall(tokensIn, tokensOut) + // Coût réel calculable seulement si le provider ayant répondu est Mistral — + // les autres tiers Bifrost (Groq, Cerebras, Gemini free tier, Cohere) sont free-tier, + // donc cout_eur=0 pour ne pas fausser le circuit breaker avec un tarif Mistral inapplicable. + const isMistral = realModel.toLowerCase().startsWith('mistral') + const coutEur = isMistral ? calcCoutMistralSmall(tokensIn, tokensOut) : 0 logUsage({ nocodbUrl: config.nocodbUrl as string, nocodbToken: config.nocodbToken as string, statsTableId, + model: realModel, tokensIn, tokensOut, coutEur, diff --git a/server/utils/bifrost.ts b/server/utils/bifrost.ts new file mode 100644 index 0000000..7a3d042 --- /dev/null +++ b/server/utils/bifrost.ts @@ -0,0 +1,41 @@ +/** + * Bifrost — gateway LLM (remplace les appels directs Mistral) + * Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions + * Auth : header x-bf-vk + * + * 2 tiers validés (Mission M3, build Bifrost) : + * RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement + * APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front) + * + * ⚠ openrouter-oai exclu (bug Bifrost confirmé — 404 HTML sur modèles avec slash) + * ⚠ gemini-oai exige le préfixe "models/" (sinon 403 silencieux) + */ + +export const BIFROST_TIER_RAPIDE = { + model: 'groq/llama-3.1-8b-instant', + fallbacks: [ + 'cerebras/gemma-4-31b', + 'gemini-oai/models/gemini-2.5-flash-lite', + 'cohere/command-r-08-2024', + ], +} + +export const BIFROST_TIER_APPROFONDI = { + model: 'groq/llama-3.3-70b-versatile', + fallbacks: [ + 'gemini-oai/models/gemini-2.5-flash', + 'mistral/mistral-large-latest', + 'cohere/command-r-plus-08-2024', + ], +} + +/** Sélectionne le tier selon le param optionnel body.mode. */ +export function pickBifrostTier(mode?: string) { + return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE +} + +export interface BifrostChatResponse { + choices: { message: { content: string } }[] + usage?: { prompt_tokens: number; completion_tokens: number } + extra_fields?: { provider?: string; resolved_model_used?: string } +}