Files
nav-carte/server/utils/bifrost.ts
T
Jules NenyandClaude Opus 5.5 80f69565aa feat(chatbot): mode profondeur côté serveur (rapide / approfondi) sur les 4 routes
Contrat unique `profondeur` ('rapide' | 'approfondi', défaut rapide) dans
utils/chatProfondeur.ts (libellés partagés). Les 3 routes Bifrost lisent
body.profondeur ?? body.mode, choisissent le tier (20b / 120b + replis), un
prompt système « réflexion » (Entraide, Réseaux, Taff) au même format JSON, et un
budget max_tokens adapté. Pensées lit body.profondeur (jamais body.mode, réservé à
LightRAG) : posture réflexion ajoutée au user_prompt + max_total_tokens 40000.
Modèle servi journalisé, et champ debug exposé uniquement en dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vcVGz8i4WpZm2dixKUiSD
2026-10-09 03:45:27 +02:00

121 lines
6.1 KiB
TypeScript

/**
* Bifrost — gateway LLM (remplace les appels directs Mistral)
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
* Auth : header x-bf-vk
*
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
* RAPIDE — défaut (mode « Question » du toggle front)
* APPROFONDI — body.profondeur === 'approfondi' (mode « Réflexion plus profonde », B1-M3 ;
* body.mode reste lu en compat ascendante). Contrat : utils/chatProfondeur.ts
*
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
* chatbot tombait sur le premier repli Gemini (stats_usage). Primaires passés sur
* groq/openai/gpt-oss-20b et groq/openai/gpt-oss-120b ; replis inchangés. Le worker
* /opt/aep-worker tourne sur gpt-oss-20b en prod depuis le 28/09 (WORKER_MODEL, json_object) :
* Bifrost accepte ce nom de modèle à slash côté Groq.
* ⚠ gpt-oss est un modèle à raisonnement :
* - la réponse porte un champ message.reasoning à côté de message.content. Les routes
* chatbot ne lisent que content (JSON), usage et extra_fields : le champ est ignoré ;
* - les tokens de raisonnement sont décomptés de max_tokens. Sans marge, le JSON de la
* réponse peut être tronqué ; rendu en HTTP 200, Bifrost ne bascule pas, JSON.parse
* échoue → « Je n'ai pas pu analyser ta demande ». Déjà vu le 15/07 : gpt-oss-120b (via Cerebras), 3 appels
* corrects sur 4, « tronque parfois à max_tokens 700 ». D'où REASONING_MARGIN_TOKENS, ajouté par
* chaque route à son budget de contenu.
* ⚠ 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é.
*/
import { normaliserProfondeur, type ChatProfondeur } from '~/utils/chatProfondeur'
export const BIFROST_TIER_RAPIDE = {
model: 'groq/openai/gpt-oss-20b',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash-lite',
'cohere/command-r-08-2024',
],
}
export const BIFROST_TIER_APPROFONDI = {
model: 'groq/openai/gpt-oss-120b',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash',
'mistral/mistral-large-latest',
'cohere/command-r-plus-08-2024',
],
}
/**
* Tokens ajoutés au budget de contenu de chaque route (max_tokens = contenu + marge) pour
* absorber le raisonnement de gpt-oss. Plafond seulement : les replis sans raisonnement
* n'écrivent pas plus long, les prompts bornent la réponse (200-250 mots). Rester modeste :
* la limite de tokens par minute d'une clé Groq peut compter max_tokens avec le prompt.
*/
export const REASONING_MARGIN_TOKENS = 800
/** Sélectionne le tier selon la profondeur ('approfondi' → APPROFONDI, tout le reste → RAPIDE). */
export function pickBifrostTier(profondeur?: string) {
return profondeur === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
}
/**
* Profondeur demandée par le body : `profondeur`, sinon `mode` (compat ascendante des
* routes Bifrost avant B1-M3). Valeur inconnue → 'rapide'.
*/
export function profondeurDuBody(body: any): ChatProfondeur {
return normaliserProfondeur(body?.profondeur ?? body?.mode)
}
/**
* Budget max_tokens = contenu + marge de raisonnement. Le mode approfondi autorise une
* réponse un peu plus longue (+200) et une marge plus large (+400) : le 120b raisonne plus.
* Reste sous les limites par minute d'une clé Groq (prompt ~3-4 k + 2,2 k max).
*/
export function bifrostMaxTokens(contenu: number, profondeur: ChatProfondeur): number {
return profondeur === 'approfondi'
? contenu + 200 + REASONING_MARGIN_TOKENS + 400
: contenu + REASONING_MARGIN_TOKENS
}
/** Règle guillemets, commune à tous les prompts « réflexion » (cf. REGLE_CITATIONS de Pensées). */
export const REGLE_GUILLEMETS =
'Ne mets entre guillemets que ce qui figure mot pour mot dans le contexte fourni ; sinon reformule sans guillemets.'
export interface BifrostChatResponse {
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
usage?: { prompt_tokens: number; completion_tokens: number }
extra_fields?: { provider?: string; resolved_model_used?: string }
}
/**
* Preuve du tier réellement servi : journal serveur toujours (modèle dans le log seulement),
* et champ `debug` dans la réponse UNIQUEMENT en dev (import.meta.dev, éliminé du build de
* production). Aucun nom de modèle n'est exposé publiquement.
*/
export function bifrostDebug(res: BifrostChatResponse, route: string, profondeur: ChatProfondeur) {
const modele = res.extra_fields?.resolved_model_used ?? null
const fournisseur = res.extra_fields?.provider ?? null
console.log(`[${route}] profondeur=${profondeur} fournisseur=${fournisseur} modèle=${modele}`)
return import.meta.dev ? { debug: { profondeur, fournisseur, modele } } : {}
}
/**
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
* et finit en message générique côté usager : on la signale au journal pour la voir.
*/
export function bifrostContent(res: BifrostChatResponse, route: string): string {
const choice = res.choices?.[0]
if (choice?.finish_reason === 'length') {
console.warn(`[${route}] réponse tronquée (finish_reason=length) : relever REASONING_MARGIN_TOKENS`)
}
return choice?.message?.content ?? '{}'
}