Ce tutoriel explique pas à pas comment utiliser Jev de TypeSafe via OpenRouter pour classifier automatiquement l’intention derrière une requête Google à partir de sa page de résultats. Il s’adresse aux développeurs TypeScript à l’aise avec Node.js et la ligne de commande. En quelques centaines de millisecondes, vous obtenez une décision typée et probabilisée parmi quatre intentions : INFORMATIONNELLE, COMMERCIALE, TRANSACTIONNELLE et NAVIGATIONNELLE.
Ce qu’il faut préparer avant de coder
Préparez ces éléments avant d’écrire la première ligne de logique. Les étapes suivantes partent d’un projet existant et d’un accès déjà configuré à OpenRouter.

Prérequis techniques
Vous travaillez en TypeScript avec Node.js 18 ou supérieur. Cette version fournit nativement fetch et exécute les modules ES. Vous avez besoin d’un projet Node.js initialisé avec npm ou pnpm, ainsi que d’un fichier .env pour les secrets. Créez votre clé API OpenRouter depuis le tableau de bord, puis exportez-la dans la variable d’environnement OPENROUTER_API_KEY.
Aucune clé propre à TypeSafe n’est requise : l’authentification passe uniquement par OpenRouter. Vérifiez que la clé est chargée avec echo $OPENROUTER_API_KEY ou en la lisant dans votre script. Ne la versionnez jamais dans Git et ajoutez .env à .gitignore.
Dépendances à installer
Installez les dépendances minimales pour cette CLI. Le SDK officiel donne accès au namespace alpha.decisions.
| Élément | Rôle | Commande |
|---|---|---|
| @openrouter/sdk | Client officiel, publié en ESM uniquement | npm install @openrouter/sdk |
| dotenv | Chargement de OPENROUTER_API_KEY depuis .env | npm install dotenv |
| tsx | Exécution directe de TypeScript sans compilation manuelle | npm install -D tsx typescript @types/node |
| Node.js 18+ | Environnement d’exécution | node -v |
Le SDK étant ESM only, déclarez le projet en module avant tout, sinon les imports échoueront :
npm pkg set type=module
Ajoutez ensuite un fichier tsconfig.json. La résolution nodenext est indispensable : l’ancienne valeur node ne sait pas lire la carte d’exports d’un paquet ESM.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"strict": true,
"outDir": "dist"
}
}
Votre fichier .env doit contenir au minimum :
OPENROUTER_API_KEY=sk-or-v1-votre_cle_ici
Coût, latence et limites
Jev n’est pas un modèle génératif. C’est le premier modèle System One de TypeSafe : il ne produit pas de texte libre, mais une décision typée accompagnée de probabilités calibrées. Son échantillonneur parallèle énumère les sorties possibles à l’avance, ce qui garantit la conformité au schéma que vous avez défini. Attention à la nuance : le modèle ne peut pas sortir du schéma, mais rien ne garantit que le label choisi soit le bon. Le modèle a été entraîné avec la méthode RLCD (Reinforcement Learning for Calibrated Decisions), qui optimise la calibration des probabilités plutôt que la préférence humaine.
Sur OpenRouter, Jev existe sous deux identifiants : l’alias dynamique ~typesafe/jev-latest, qui pointe vers la dernière version, et la version figée typesafe/jev-1.13 (publiée le 18 septembre 2026), recommandée en production. Le tilde de l’alias n’est pas décoratif : la forme typesafe/jev-latest sans tilde renvoie une erreur 400 « Model does not exist ». La fenêtre de contexte accepte 32 000 tokens en entrée. Seule l’entrée est facturée, à 0,042 $ par million de tokens, la sortie étant à 0 $/M. TypeSafe annonce une latence de bout en bout de 70 à 500 ms ; sa démo maison mesure 0,114 s contre 8,566 s pour GPT-5.6 Terra sur la même tâche, soit le facteur 193x mis en avant au lancement. Ce sont ses propres chiffres, pas un test indépendant.
L’API Decisions est en alpha
C’est l’information la plus importante de ce tutoriel. Le endpoint vit à /api/alpha/decisions, hors du préfixe /api/v1 utilisé par le client de chat. Le schéma peut donc évoluer sans préavis, et plusieurs retours terrain signalent des appels qui restent bloqués en lecture. Prévoyez dès le départ un timeout explicite et une temporisation exponentielle, traités plus loin. Deux en-têtes optionnels facilitent le suivi dans le tableau de bord : HTTP-Referer pour l’URL de votre application et X-OpenRouter-Title pour son nom.
Étape 1 : Définir le modèle de décision pour l’intention de recherche
Vous allez définir ce que Jev doit décider, sans lui transmettre encore les données de la SERP.
Jev impose une séparation stricte entre l’état et les questions. Le champ state contiendra plus tard les faits bruts, soit la requête et les résultats de la SERP. Le champ questions contient les règles d’évaluation. Ne mélangez jamais les deux : ne glissez aucune consigne dans l’état, elle a sa place dans instructions.
Le modèle propose trois primitives. noul répond par vrai ou faux avec une probabilité, et exige un critère pour chacune des deux branches. score évalue une échelle ordonnée d’au moins deux critères. choice sélectionne exactement une option dans une liste fermée, jusqu’à 255 options. Pour classifier une intention, utilisez la primitive choice.
Créez un fichier src/decisionModel.ts. Notez la forme exacte du schéma : instructions au pluriel, et un objet criteria dont chaque clé est un label, chaque valeur sa description. Il n’y a pas de niveau options intermédiaire.
// src/decisionModel.ts
export const intentQuestions = {
intention: {
// Primitive Jev : choice
type: "choice" as const,
instructions:
"Classifie l'intention de recherche principale de la requête. Décide uniquement à partir de la SERP fournie.",
// Une clé par label, une description par clé
criteria: {
INFORMATIONNELLE:
"L'utilisateur cherche à comprendre, apprendre ou obtenir une réponse factuelle. La SERP contient des guides, définitions, articles de blog, Wikipédia, pages 'comment faire'.",
COMMERCIALE:
"L'utilisateur compare des offres avant d'acheter. La SERP contient des comparatifs, 'meilleur', 'avis', classements, tableaux de prix.",
TRANSACTIONNELLE:
"L'utilisateur veut acheter ou effectuer une action immédiate. La SERP contient des pages produit, des prix, 'ajouter au panier', 'livraison', 'pas cher', des boutiques en ligne.",
NAVIGATIONNELLE:
"L'utilisateur veut accéder à un site ou une marque précise. La SERP est dominée par la marque, son site officiel, une page de connexion."
}
}
};
Chaque critère décrit concrètement ce que Jev doit reconnaître. Restez descriptif et sans ambiguïté. Indiquez des signaux observables dans la SERP plutôt que des définitions abstraites : la calibration dépend directement de cette précision.
Étape 2 : Préparer l’état avec la requête et les données de SERP
Vous allez transformer une requête Google et ses résultats en objet state exploitable par Jev.

Pour chaque requête, récupérez les titres et extraits organiques de la première page. Ce tutoriel utilise un exemple figé afin de rester en CLI, sans scraper. En usage réel, branchez l’API de votre fournisseur SERP, comme Semscraper, SerpAPI ou ValueSERP, au même format.
Créez un fichier src/serpExamples.ts :
// src/serpExamples.ts
export type SerpResult = { title: string; snippet: string; url: string };
export const serpFixtures: Record<string, { query: string; results: SerpResult[] }> = {
info: {
query: "comment fonctionne un panneau solaire photovoltaïque",
results: [
{ title: "Panneau solaire photovoltaïque : principe et fonctionnement", snippet: "Le photovoltaïque convertit la lumière en électricité via des cellules en silicium. Schémas et rendement expliqués.", url: "https://exemple.fr/guide-photovoltaique" },
{ title: "Wikipédia - Énergie solaire photovoltaïque", snippet: "L'effet photovoltaïque a été découvert en 1839 par Becquerel. Une installation comprend onduleur et compteur.", url: "https://fr.wikipedia.org/wiki/Photovoltaïque" },
{ title: "ADEME - Comprendre le solaire", snippet: "Guide complet pour comprendre l'autoconsommation et le dimensionnement.", url: "https://ademe.fr/comprendre-solaire" }
]
},
transactionnel: {
query: "acheter aspirateur robot xiaomi s12 pas cher",
results: [
{ title: "Xiaomi Robot Vacuum S12 - Acheter au meilleur prix", snippet: "En stock. Livraison 24h. 199,00 €. Ajouter au panier. Garantie 2 ans.", url: "https://boutique.fr/xiaomi-s12" },
{ title: "Aspirateur robot S12 - Boulanger", snippet: "Prix : 189 €. Retrait magasin 1h. Paiement 4x sans frais.", url: "https://boulanger.fr/s12" },
{ title: "Amazon.fr - Xiaomi S12", snippet: "Achetez maintenant. Vendeur officiel. Retour gratuit 30 jours.", url: "https://amazon.fr/dp/s12" }
]
},
commerciale: {
query: "meilleur aspirateur robot 2026 comparatif",
results: [
{ title: "Comparatif 2026 : les 10 meilleurs aspirateurs robots", snippet: "Nous avons testé 12 modèles. Notes, autonomie, navigation lidar. Tableau comparatif.", url: "https://comparatif.fr/meilleurs-robots-2026" },
{ title: "Avis aspirateur robot : lequel choisir ?", snippet: "Roborock vs Xiaomi vs iRobot. Avantages et inconvénients selon votre budget.", url: "https://guide-achat.fr/avis-robots" },
{ title: "Quel aspirateur robot pour poils d'animaux ?", snippet: "Classement par performance et prix. Notre sélection 2026.", url: "https://maison.fr/poils-animaux" }
]
},
navigationnelle: {
query: "connexion ameli.fr mon compte",
results: [
{ title: "ameli.fr - Mon compte assuré", snippet: "Site officiel de l'Assurance Maladie. Accédez à vos remboursements et attestation.", url: "https://assure.ameli.fr" },
{ title: "ameli - Connexion", snippet: "Se connecter à mon compte ameli. Service officiel ameli.fr", url: "https://www.ameli.fr/assure" },
{ title: "Assurance Maladie - ameli.fr", snippet: "ameli.fr : toutes les démarches en ligne.", url: "https://www.ameli.fr" }
]
}
};
Le champ state accepte une chaîne, un objet ou un tableau. Préférez un objet clair à un long texte concaténé : le JSON aide à contrôler la fenêtre de 32 000 tokens. Comptez environ 4 caractères par token pour estimer la taille. Avec 10 résultats, des titres et des snippets de 150 caractères, vous restez largement sous les 2 000 tokens.
Exemple d’état attendu par l’API :
{
"requete": "meilleur aspirateur robot 2026 comparatif",
"serp": [
{ "title": "Comparatif 2026 : les 10 meilleurs...", "snippet": "Nous avons testé 12 modèles...", "url": "https://..." }
]
}
Astuce pour la qualité de l’état
Ajoutez toujours la requête brute aux résultats. Une même SERP peut recouvrir plusieurs intentions si la requête manque. Retirez les données inutiles, comme les publicités et les balises HTML. Conservez seulement le titre, le snippet et le domaine. Et gardez les consignes hors de l’état : elles appartiennent au champ instructions de la question.
Étape 3 : Envoyer la requête de décision à OpenRouter
Cette étape appelle le endpoint dédié et récupère la décision typée.
N’utilisez jamais le endpoint de chat classique. Jev ne fonctionne ni avec /api/v1/chat/completions ni avec openrouter.chat.completions.create. Deux points de vigilance avec le SDK : l’appel passe par alpha.decisions.create, et il faut instancier un client dédié avec serverURL pointé sur la racine du domaine, car l’URL de base par défaut ne couvre pas le chemin /api/alpha.
Créez le fichier principal src/index.ts :
// src/index.ts
import "dotenv/config";
import { OpenRouter } from "@openrouter/sdk";
import { intentQuestions } from "./decisionModel.js";
import { serpFixtures } from "./serpExamples.js";
// 1. Client dédié : la Decisions API vit hors du préfixe /api/v1
const decisions = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY!,
serverURL: "https://openrouter.ai"
});
// Alias dynamique en dev, version figée en prod
const MODEL = "~typesafe/jev-latest"; // ou "typesafe/jev-1.13"
type ChoiceAnswer = {
choice: string;
confidence: number;
probabilities: Record<string, number>;
};
async function classifyIntent(key: keyof typeof serpFixtures) {
const fixture = serpFixtures[key];
// 2. L'état ne contient que des faits
const state = {
requete: fixture.query,
serp: fixture.results
};
// 3. Les paramètres sont enveloppés dans decisionsRequest
const response = await decisions.alpha.decisions.create({
decisionsRequest: {
model: MODEL,
state,
questions: intentQuestions
}
});
// 4. Une entrée par question, sous la clé que vous avez nommée
const answer = response.answers.intention as ChoiceAnswer;
return { fixture, answer };
}
function printResult(fixture: { query: string }, answer: ChoiceAnswer) {
console.log("\n========================================");
console.log(`Requête : "${fixture.query}"`);
console.log("----------------------------------------");
console.log(`Décision : ${answer.choice}`);
console.log(`Confiance : ${(answer.confidence * 100).toFixed(1)} %`);
console.log("\nDistribution complète :");
for (const [label, p] of Object.entries(answer.probabilities)) {
console.log(` - ${label} : ${(p * 100).toFixed(1)} %`);
}
console.log("========================================\n");
}
async function main() {
const arg = process.argv[2] as keyof typeof serpFixtures | undefined;
const keys = arg ? [arg] : (Object.keys(serpFixtures) as (keyof typeof serpFixtures)[]);
for (const key of keys) {
if (!serpFixtures[key]) {
console.error(`Fixture inconnue : ${key}. Choix : ${Object.keys(serpFixtures).join(", ")}`);
continue;
}
try {
const { fixture, answer } = await classifyIntent(key);
printResult(fixture, answer);
} catch (err: any) {
const status = err.statusCode ?? err.status;
console.error(`Erreur pour ${key} :`, err.message ?? err);
if (status === 400) console.error("Payload refusé : vérifiez 'instructions', 'criteria' et le nom du modèle (tilde compris).");
if (status === 401) console.error("OPENROUTER_API_KEY manquante ou invalide.");
if (status === 429) console.error("Limite de débit atteinte : réessayez avec backoff, en respectant Retry-After.");
if (status && status >= 500) console.error("Erreur serveur ou provider indisponible : réessayez.");
}
}
}
main();
L’appel est parallèle par nature : ajoutez plusieurs questions à intentQuestions et elles seront évaluées dans une seule passe d’inférence, isolément et contre le même état, sans tokens de sortie supplémentaires.
Variante HTTP brute sans SDK
Avec fetch direct, notez l’URL exacte et le timeout explicite :
const res = await fetch("https://openrouter.ai/api/alpha/decisions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://votredomaine.fr",
"X-OpenRouter-Title": "Jev SERP Classifier"
},
body: JSON.stringify({
model: "~typesafe/jev-latest",
state: { requete: "...", serp: [] },
questions: intentQuestions
}),
// Le endpoint est en alpha : ne partez jamais sans timeout
signal: AbortSignal.timeout(20_000)
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { answers } = await res.json();
console.log(answers.intention.choice, answers.intention.confidence);
Étape 4 : Interpréter la décision structurée et exploiter les probabilités
Vous allez transformer la réponse JSON en logique applicative déterministe.
Les réponses arrivent sous la clé answers, indexées par le nom que vous avez donné à chaque question. Pour une primitive choice, trois champs : choice pour le label retenu, confidence pour la concentration de la distribution, et probabilities pour la distribution complète. Voici la réponse pour la requête meilleur aspirateur robot 2026 comparatif :
{
"answers": {
"intention": {
"choice": "COMMERCIALE",
"confidence": 0.87,
"probabilities": {
"INFORMATIONNELLE": 0.08,
"COMMERCIALE": 0.87,
"TRANSACTIONNELLE": 0.04,
"NAVIGATIONNELLE": 0.01
}
}
}
}
confidence n’est pas une sortie indépendante du modèle : c’est une statistique dérivée de probabilities, élevée quand la distribution est concentrée et basse quand elle est plate. Rien ne vous empêche donc de calculer votre propre mesure, par exemple l’écart entre le premier et le deuxième label.
Utilisez ces probabilités calibrées pour piloter votre code sans expressions régulières fragiles. Fixez un seuil par action, proportionné au coût d’une erreur, plutôt qu’un seuil unique pour tout le système.
// Exemple d'aiguillage déterministe
const { choice, confidence } = answers.intention;
if (choice === "TRANSACTIONNELLE" && confidence > 0.75) {
// Orienter vers fiche produit et module paiement
await afficherOffres(fixture.query);
} else if (choice === "COMMERCIALE" && confidence > 0.65) {
// Orienter vers comparatif et guide d'achat
await afficherComparatif(fixture.query);
} else if (choice === "INFORMATIONNELLE") {
// Orienter vers contenu éditorial
await afficherGuide(fixture.query);
} else if (choice === "NAVIGATIONNELLE") {
// Orienter vers le site officiel
await redirigerVersOfficiel(fixture.query);
}
if (confidence < 0.55) {
console.warn("Confiance faible, journaliser pour audit manuel.");
}
À la différence des LLM génératifs, Jev n’exige ni validateur JSON ni parseur de texte. La sortie est typée et peut alimenter directement un switch, un routeur de modèles ou un système de filtrage. Dans une architecture Jev-Verified Cascade, vous générez d’abord une réponse avec un modèle économique, puis Jev vérifie si les sources la soutiennent avant une éventuelle escalade vers un modèle de pointe.
Étape 5 : Exécuter en CLI, gérer les erreurs et passer en production
Cette dernière étape lance le classifieur depuis le terminal, puis le durcit pour un usage répété.

Exécutez le projet avec tsx, sans étape de compilation :
# Test d'une seule intention
npx tsx src/index.ts info
# Test d'une autre intention
npx tsx src/index.ts transactionnel
# Test de toutes les fixtures
npx tsx src/index.ts
Sortie attendue pour info :
========================================
Requête : "comment fonctionne un panneau solaire photovoltaïque"
----------------------------------------
Décision : INFORMATIONNELLE
Confiance : 92.3 %
Distribution complète :
- INFORMATIONNELLE : 92.3 %
- COMMERCIALE : 3.1 %
- TRANSACTIONNELLE : 1.2 %
- NAVIGATIONNELLE : 3.4 %
========================================
Pour tester votre propre SERP sans toucher au code, chargez un fichier serp.json à la racine, avec la structure { « query »: « … », « results »: […] }, et lisez-le avec fs.readFileSync. Conservez la même forme d’objet pour le state.
Gérer les erreurs courantes
Une erreur 400 signale un payload ou un modèle refusé : critère vide, instruction au singulier au lieu de instructions, niveau options superflu, question score avec moins de deux critères, ou alias sans tilde. Une 401 indique une clé invalide ou non chargée : vérifiez dotenv et le nom exact OPENROUTER_API_KEY. Une 429 correspond à une limite de débit, les 500, 502 et 503 à une erreur serveur ou à un provider indisponible. Dans ces trois derniers cas, réessayez avec une temporisation exponentielle et un jitter aléatoire, en honorant l’en-tête Retry-After quand il est présent.
async function withBackoff<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (e: any) {
const status = e.statusCode ?? e.status;
const retryable =
status === 429 || (status && status >= 500) || e.name === "TimeoutError";
if (!retryable || i === retries - 1) throw e;
const delay = Math.pow(2, i) * 500 + Math.random() * 200;
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error("unreachable");
}
Surveillez aussi la taille de l’état : au-delà de 32 000 tokens, la requête est rejetée. Limitez les snippets à 200 caractères et gardez au plus 8 à 10 résultats. Jev ne peut pas inventer de label hors schéma, mais une instruction floue produit une distribution plate, avec des probabilités proches de 25 % chacune. Reformulez alors les critères avec des signaux plus concrets.
Trois règles pour la production
Figez le modèle avec typesafe/jev-1.13 plutôt que l’alias, pour éviter un changement de comportement lors d’une mise à jour. Journalisez systématiquement probabilities, et pas seulement le label gagnant, afin d’auditer la calibration et d’ajuster vos seuils. Enfin, versionnez votre objet questions comme du code applicatif : c’est lui qui détermine vos décisions.
Pour industrialiser, ajoutez plusieurs dimensions de jugement au même appel plutôt que de multiplier les requêtes. Une question noul pour détecter la présence d’une marque, une question score pour la difficulté SEO, sans latence notable supplémentaire. Rappel : un noul exige un critère pour true et pour false.
// Extension multi-questions évaluée en une seule passe
export const extendedQuestions = {
intention: intentQuestions.intention,
is_brand_query: {
type: "noul" as const,
instructions: "La requête contient-elle une marque explicite ?",
criteria: {
true: "Un nom de marque ou d'entreprise est présent dans la requête ou domine les domaines de la SERP.",
false: "Aucune marque identifiable, ni dans la requête ni dans les domaines de la SERP."
}
}
};
Ce projet minimal vous laisse une brique réutilisable : un modèle de décision typé, un état structuré à partir d’une SERP, un appel à la Decisions API d’OpenRouter et des probabilités exploitables pour orienter l’utilisateur vers le bon type de contenu. Le endpoint restant en alpha, gardez un œil sur la documentation avant chaque mise en production.















Laisser un commentaire
Vous devez vous connecter pour publier un commentaire.