Compare commits
2 Commits
main
...
feat/bifro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d06c09a476 | ||
|
|
4291fe7529 |
@@ -16,6 +16,8 @@ export default defineNuxtConfig({
|
|||||||
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
||||||
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
||||||
mistralApiKey: process.env.MISTRAL_API_KEY,
|
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',
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
resendApiKey: process.env.RESEND_API_KEY,
|
resendApiKey: process.env.RESEND_API_KEY,
|
||||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Rollup
|
// @ts-ignore — JSON import résolu par Rollup
|
||||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface Structure {
|
interface Structure {
|
||||||
id: string
|
id: string
|
||||||
@@ -61,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
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 structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||||
const keywords = extractKeywords(question)
|
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 systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
|
const bifrostVk = config.bifrostVk as string
|
||||||
|
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
'https://api.mistral.ai/v1/chat/completions',
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||||
import taffData from '../../public/data/plateformes-taff.json'
|
import taffData from '../../public/data/plateformes-taff.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface PlateformeMinimal {
|
interface PlateformeMinimal {
|
||||||
id: string
|
id: string
|
||||||
@@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!question || question.length < 5) {
|
if (!question || question.length < 5) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||||
}
|
}
|
||||||
|
const tier = pickBifrostTier(body?.mode)
|
||||||
|
|
||||||
// Données bundlées statiquement à la compilation (import JSON)
|
// Données bundlées statiquement à la compilation (import JSON)
|
||||||
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
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 systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
if (!mistralApiKey) {
|
const bifrostVk = config.bifrostVk as string
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
if (!bifrostVk) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
'https://api.mistral.ai/v1/chat/completions',
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -131,18 +132,19 @@ async function logUsage(params: {
|
|||||||
nocodbUrl: string
|
nocodbUrl: string
|
||||||
nocodbToken: string
|
nocodbToken: string
|
||||||
statsTableId: string
|
statsTableId: string
|
||||||
|
model: string
|
||||||
tokensIn: number
|
tokensIn: number
|
||||||
tokensOut: number
|
tokensOut: number
|
||||||
coutEur: 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`
|
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||||
try {
|
try {
|
||||||
await $fetch(logUrl, {
|
await $fetch(logUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model,
|
||||||
endpoint: 'chatbot',
|
endpoint: 'chatbot',
|
||||||
tokens_in: tokensIn,
|
tokens_in: tokensIn,
|
||||||
tokens_out: tokensOut,
|
tokens_out: tokensOut,
|
||||||
@@ -180,6 +182,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
||||||
|
const tier = pickBifrostTier(body?.mode)
|
||||||
|
|
||||||
if (!question || question.length < 3) {
|
if (!question || question.length < 3) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -247,32 +250,32 @@ export default defineEventHandler(async (event) => {
|
|||||||
JSON.stringify(fichesContext, null, 0),
|
JSON.stringify(fichesContext, null, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 6. Appel Mistral Small
|
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
|
const bifrostVk = config.bifrostVk as string
|
||||||
|
|
||||||
if (!mistralApiKey) {
|
if (!bifrostVk) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Clé API Mistral manquante.',
|
statusMessage: 'Clé Bifrost manquante.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
let tokensIn = 0
|
let tokensIn = 0
|
||||||
let tokensOut = 0
|
let tokensOut = 0
|
||||||
|
let realModel = tier.model
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mistralRes = await $fetch<{
|
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
|
||||||
choices: { message: { content: string } }[]
|
|
||||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
|
||||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${mistralApiKey}`,
|
'x-bf-vk': bifrostVk,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
@@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
|
||||||
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
||||||
tokensOut = mistralRes.usage?.completion_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) {
|
} catch (e: any) {
|
||||||
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
|
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
||||||
@@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Log usage (non bloquant)
|
// 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({
|
logUsage({
|
||||||
nocodbUrl: config.nocodbUrl as string,
|
nocodbUrl: config.nocodbUrl as string,
|
||||||
nocodbToken: config.nocodbToken as string,
|
nocodbToken: config.nocodbToken as string,
|
||||||
statsTableId,
|
statsTableId,
|
||||||
|
model: realModel,
|
||||||
tokensIn,
|
tokensIn,
|
||||||
tokensOut,
|
tokensOut,
|
||||||
coutEur,
|
coutEur,
|
||||||
|
|||||||
47
server/utils/bifrost.ts
Normal file
47
server/utils/bifrost.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
* ⚠ cerebras/gemma-4-31b RETIRÉ du tier RAPIDE (M4, 15/07) : en JSON mode avec un contexte
|
||||||
|
* réel (prompt + ~20 fiches), le modèle part en boucle de répétition dégénérée ("1 1 1...")
|
||||||
|
* dans ~75% des cas où il sert de fallback, produit un JSON invalide, et Bifrost ne bascule
|
||||||
|
* PAS plus loin dans la chaîne (HTTP 200 côté provider = pas une erreur pour Bifrost). Bug
|
||||||
|
* constaté en prod sur chatbot-reseaux (1 échec/3), reproduit 3/4 sur appel direct Bifrost.
|
||||||
|
* Cerebras reste configuré dans Bifrost (dispo pour tier CODE ou reconfiguration future),
|
||||||
|
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BIFROST_TIER_RAPIDE = {
|
||||||
|
model: 'groq/llama-3.1-8b-instant',
|
||||||
|
fallbacks: [
|
||||||
|
'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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user