All files / services/shared llmChat.js

89.13% Statements 82/92
41.66% Branches 5/12
75% Functions 3/4
89.13% Lines 82/92

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 931x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x       1x 1x 3x 3x 3x 6x 6x 3x 3x 3x 3x 3x 3x 3x 3x         3x 3x 3x 3x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x     3x 3x   3x 1x 1x  
/**
 * Appel chat/completions avec raisonnement/budget configurables et replis.
 *
 * Objectifs (bug « 0 assertion ») :
 *  - laisser le modèle libre par défaut (aucun `max_tokens`) ;
 *  - laisser l'utilisateur choisir le raisonnement (qualité vs coût) ;
 *  - ne JAMAIS perdre la réponse quand le modèle coupe avant d'écrire
 *    (`finish_reason: "length"`, `content: null`) : on rejoue avec des replis.
 *
 * Chaîne de replis (du plus fidèle au plus contraint) :
 *   1. configuration utilisateur (raisonnement on/off, `max_tokens` si fourni) ;
 *   2. **sans `max_tokens`** (si fourni) — le modèle est libre ;
 *   3. **sans raisonnement** (`reasoning: { enabled: false }`) — garantit la sortie.
 *
 * Le paramètre `reasoning` d'OpenRouter est inopérant pour *borner* la réflexion
 * (seul `enabled: false` agit) → on ne l'utilise jamais comme plafond.
 */
 
function getAxios() {
  return require("axios");
}
 
/** Construit la liste des variantes de payload à essayer (dédupliquées). */
function buildVariants({ reasoning = true, maxTokens } = {}) {
  const variants = [];
  const push = (v) => {
    const key = JSON.stringify(v);
    if (!variants.some((x) => JSON.stringify(x) === key)) variants.push(v);
  };
 
  const primary = {};
  if (maxTokens != null) primary.max_tokens = maxTokens;
  if (reasoning === false) primary.reasoning = { enabled: false };
  push(primary);
 
  if (maxTokens != null) {
    const noMax = {};
    if (reasoning === false) noMax.reasoning = { enabled: false };
    push(noMax); // repli sans max_tokens
  }
  if (reasoning !== false) {
    push({ reasoning: { enabled: false } }); // repli sans raisonnement
  }
  return variants;
}
 
/**
 * @param {Object}   opts
 * @param {string}   opts.url         endpoint chat/completions
 * @param {string}   opts.apiKey
 * @param {Object}   opts.payload     { model, messages, temperature, provider, ... }
 * @param {boolean}  [opts.reasoning] défaut true
 * @param {number}   [opts.maxTokens] optionnel ; absent = pas de plafond
 * @param {Object}   [opts.axiosInstance]
 * @param {number}   [opts.timeout]
 * @param {Object}   [opts.logger]
 */
async function chatCompletion({
  url,
  apiKey,
  payload,
  reasoning = true,
  maxTokens,
  axiosInstance,
  timeout = 120000,
  logger = console,
}) {
  const axios = axiosInstance || getAxios();
  const variants = buildVariants({ reasoning, maxTokens });
 
  let response;
  for (let i = 0; i < variants.length; i += 1) {
    response = await axios.post(
      url,
      { ...payload, ...variants[i] },
      {
        headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
        timeout,
      },
    );
    const choice = response?.data?.choices?.[0];
    if (choice?.message?.content || choice?.finish_reason !== "length") {
      return response;
    }
    logger.warn?.(
      `[llmChat] finish_reason=length (tentative ${i + 1}/${variants.length}) → repli ${JSON.stringify(variants[i])}`,
    );
  }
  return response;
}
 
module.exports = { chatCompletion, buildVariants };