Insight Run, un modèle par type de requête
Insight Run appelle des modèles de langage pour des tâches de taille très différente, d'un conseil de quelques phrases à un bloc de plan de 16 000 tokens. Le Worker Cloudflare associe chaque route à un type de requête et choisit le modèle d'après une configuration stockée dans KV, modifiable sans publier l'app.
Insight Run est une app iOS d’analyse de course à pied. Cet article décrit comment son backend choisit le modèle de langage qui traite chaque requête, et ce qui se passe quand ce modèle ne répond pas.
Des requêtes qui ne se ressemblent pas
Plusieurs fonctions de l’app passent par un modèle de langage, avec des besoins différents. La forme du jour demande un court conseil, limité à 600 tokens et attendu au plus 12 secondes. Une séance générée est un objet JSON de 4 000 tokens au plus. Un bloc de plan d’entraînement peut atteindre 16 000 tokens de JSON. Le coach répond en flux, jusqu’à 2 000 tokens, et peut créer une séance au passage.
Un même modèle n’offre pas le meilleur compromis entre coût, délai et qualité pour toutes ces requêtes. Le choix doit aussi pouvoir changer quand un modèle plus adapté apparaît, sans publier une nouvelle version de l’app.
L’app ne détient aucune clé de fournisseur de modèles. Elle s’identifie auprès d’un Worker Cloudflare avec les en-têtes X-App-Key et X-User-ID, et c’est le Worker, qui garde la clé OpenRouter en secret, qui appelle les modèles.
Un type de requête par route
Le backend définit les types de requête dans l’énumération RequestType, dont SIMPLE, MODERATE, COMPLEX et WORKOUT_GENERATION. Chaque route d’IA choisit son modèle à partir d’un type, et la correspondance entre type et modèle est une donnée stockée dans KV. L’app ne manipule pas de nom de modèle : ses appels de génération de séance et d’analyse de l’historique passent explicitement model: nil.
En novembre 2026, la configuration de production est la suivante.
| Route | Type | Fixé par | Modèle | Repli OpenRouter |
|---|---|---|---|---|
/api/agent/chat (coach) |
COMPLEX |
serveur | GPT-5.6 Terra | Gemini 2.5 Flash |
/api/training-plan-jobs (plan) |
COMPLEX |
serveur | GPT-5.6 Terra | GPT 5.4 nano |
/api/adapt-training-plan |
COMPLEX |
serveur | GPT-5.6 Terra | GPT 5.4 nano |
/api/generate-workout |
WORKOUT_GENERATION |
route, type unique | GPT-5.6 Terra | Gemini 2.5 Flash Lite |
/api/workout/smart-suggestion |
SMART_SUGGESTION |
route, type unique | GPT-5.6 Luna | Gemini 2.5 Flash Lite |
/api/daily-readiness (forme du jour) |
SIMPLE |
serveur | GPT-5.6 Luna | Gemini 2.5 Flash Lite |
/api/analyze-history/batch |
BATCH_PROCESSING |
route, type unique | GPT-5.6 Luna | aucun |
/api/analyze-history/consolidate |
MODERATE |
route, type unique | GPT-5.6 Terra | aucun |
/api/chat (bilan hebdomadaire) |
MODERATE |
app, liste blanche | GPT-5.6 Terra | aucun |
Deux modèles couvrent aujourd’hui tous les usages : GPT-5.6 Luna pour les suggestions, la forme du jour et l’analyse de l’historique par lots, GPT-5.6 Terra pour le reste. Les modèles de repli sont fixés dans le code de chaque route.
Qui choisit le type
Sur les routes qu’appelle l’app, un type envoyé par le client n’est pas pris tel quel : une app modifiée pourrait demander COMPLEX sur une route prévue pour un type moins coûteux. La fonction selectModelFromRequest reçoit donc, pour chaque route, une liste blanche de types. Un type absent de la liste est ignoré, et la route utilise son type par défaut. Sans liste fournie, seul le type par défaut de la route est accepté.
Dans les faits, le serveur fixe le type presque partout. Le coach, la génération et l’adaptation de plans passent COMPLEX en dur, la forme du jour SIMPLE. Les routes de séance, de suggestion et d’historique lisent le type envoyé par l’app, mais n’en acceptent qu’un, le leur. /api/chat en accepte plusieurs, dont SIMPLE et MODERATE, mais pas COMPLEX, le type prévu pour le modèle le plus cher, parce qu’elle reçoit aussi le prompt système du client. Avec la configuration de novembre 2026, MODERATE et COMPLEX pointent vers le même modèle ; la liste protège la route si la configuration change. L’app l’utilise pour le bilan hebdomadaire, en MODERATE.
Quelques routes acceptent encore un champ model explicite. Il n’est pris en compte qu’en l’absence de type, si le modèle figure au catalogue et, pour un modèle marqué premium dans le catalogue, si le quota de l’utilisateur le permet. Dans les autres cas, la route revient à son type par défaut.
La configuration dans KV
La configuration tient dans deux clés de l’espace KV lié au Worker sous le nom RATE_LIMITER. admin:config:models associe chaque type à une clé du catalogue. admin:config:custom_models contient les modèles ajoutés depuis l’admin : identifiant OpenRouter, nom affiché, description, et un indicateur qui dit si le modèle est premium. GPT-5.6 Luna et GPT-5.6 Terra ne figurent pas dans le code : ils ont été ajoutés au catalogue de cette façon.
L’admin écrit ces clés par trois routes protégées par l’en-tête X-Admin-Key : PUT /api/admin/config/models pour la correspondance, PUT et DELETE /api/admin/models/:key pour le catalogue. Les modèles définis dans le code ne peuvent pas être supprimés.
La configuration est lue à chaque requête d’IA et passe par deux caches. Le premier est une variable de module, propre à l’isolat du Worker, valable 30 secondes. Quand il a expiré, le Worker lit les deux clés en parallèle avec l’option cacheTtl fixée à 60 secondes, qui garde la valeur en cache dans le centre de données Cloudflare. La correspondance part des défauts du code, et chaque type présent dans KV remplace le sien.
Si admin:config:models est absente ou que son JSON est invalide, les défauts du code s’appliquent tels quels : Grok 4.1 Fast pour SIMPLE, MODERATE et les suggestions, Gemini 2.5 Flash pour les séances, Gemini 2.5 Flash Lite pour les lots d’historique, Gemini 3 Pro Preview pour COMPLEX. Un type qui pointe vers une clé absente du catalogue, par exemple un modèle supprimé, reçoit Claude Haiku 4.5.
getModelConfigCached et selectModelForRequestType. Le résultat fusionné est remis dans le cache de l'isolat pour 30 secondes. Un autre isolat voit une modification au plus tard 90 secondes après (30 + 60), un délai déduit du code et non mesuré.Après une écriture, invalidateCache() vide le cache mémoire, mais seulement dans l’isolat qui a traité la requête d’admin. Les autres isolats gardent leur copie jusqu’à 30 secondes, puis relisent une valeur que le cache de KV peut conserver encore 60 secondes. Ce délai convient à une configuration qui change rarement, et il évite une lecture de KV à chaque requête.
Le parcours d’une question au coach
Le coach est la route qui fait intervenir le plus de pièces.
agentChat.ts et le middleware de quotas du Worker, avec les modèles de production de novembre 2026. Si GPT-5.6 Terra renvoie une erreur, OpenRouter passe à Gemini 2.5 Flash dans le même appel. Un éventuel événement function_result précède [DONE].L’app envoie la question (5 000 caractères au plus), la langue et les données d’entraînement sous forme structurée. Le Worker vérifie la clé d’app, refuse les utilisateurs et les adresses IP bloqués depuis l’admin, puis contrôle les quotas. Il demande ensuite le modèle associé à COMPLEX, ce qui lit aussi le compteur premium de l’utilisateur, et construit lui-même le prompt système à partir des données reçues.
L’appel à OpenRouter est streamé, avec deux modèles dans le champ models et un outil, generate_workout, que le modèle peut appeler pour proposer une séance. Il transmet chaque morceau de texte à l’app en Server-Sent Events, dans un événement content. Si le modèle a appelé l’outil, le Worker construit lui-même les étapes de la séance à partir des arguments (type, durée, allure visée) et les envoie dans un événement function_result, juste avant le marqueur [DONE].
Les deux côtés vérifient que la réponse est complète. Le Worker termine par un événement error une réponse vide, une réponse coupée par la limite de tokens, ou un flux qui se termine sans marqueur de fin. L’app rejette un [DONE] qui n’a été précédé d’aucun contenu, ainsi qu’un flux qui s’arrête sans [DONE] : une réponse tronquée se termine en erreur au lieu d’être prise pour une réponse complète.
Le compteur de quota est incrémenté dans waitUntil dès que la route a rendu sa réponse, donc au début du flux pour le coach, pour qu’une erreur de comptage n’interrompe pas la réponse. À la fin du flux, le Worker envoie l’événement d’analytics et, si le modèle principal est premium et que c’est lui qui a répondu, incrémente le compteur premium.
Quand le modèle ne répond pas
Il y a trois niveaux de repli.
Le premier est délégué à OpenRouter. Sur le coach, la séance, la suggestion, la forme du jour et les plans, l’appel envoie un tableau models : le modèle configuré, puis un modèle de repli. Si le premier renvoie une erreur, par exemple une limite de débit ou une indisponibilité, OpenRouter passe au second dans le même appel, et la réponse indique quel modèle a répondu (documentation). Le modèle de repli est écrit dans le code de chaque route, comme le montre le tableau. Une erreur dans la configuration de l’admin ne peut donc pas retirer le repli ; en contrepartie, en changer demande de redéployer le Worker. /api/chat et l’analyse de l’historique n’ont pas de repli.
Le deuxième niveau est décidé par le Worker. Pour la génération de séance, une erreur réseau, un timeout, un statut 408, 429 ou 5xx, ou une réponse sans choices donnent lieu à un second appel identique. La séance relance aussi la génération quand le JSON est invalide ou tronqué, en ajoutant au prompt la raison du rejet. Pour les plans et l’adaptation, toute tentative échouée, après une erreur réseau, un timeout ou un JSON invalide, est suivie d’une seconde sur GPT 5.4 nano, avec la raison du rejet quand la réponse était invalide. L’analyse de l’historique, lots et consolidation, relance le même modèle une fois, deux secondes après une erreur passagère. La génération des plans, décrite dans un article précédent, y ajoute la reprise des étapes de Cloudflare Workflows.
Le troisième niveau se passe de modèle. Si la génération du conseil de forme du jour échoue, la route renvoie un texte prédéfini, choisi selon le statut du jour, et l’app affiche quand même un conseil.
Les quotas
Toutes les routes /api/* passent par un middleware de quotas, sauf l’admin, les webhooks Strava et le suivi des jobs de plan (GET et DELETE) ; les appels sans clé d’app valide ne sont pas comptés non plus. Deux compteurs sont tenus dans KV : ratelimit:ip:<ip> et ratelimit:user:<id>. Le code prévoit par défaut 100 requêtes par heure et par IP, et 1 000 requêtes par utilisateur sur 30 jours. La clé admin:config:rate_limits remplace ces valeurs : en novembre 2026, la production garde 100 requêtes par heure et par IP, et autorise 2 000 requêtes par utilisateur, sur la même fenêtre de 30 jours. Chaque fenêtre est fixe et s’ouvre à la première requête. Au-delà de la limite, le Worker répond 429 avec les en-têtes X-RateLimit-* et Retry-After.
Le quota compte des requêtes HTTP, quel que soit le modèle appelé : la création d’un plan de 16 semaines compte pour une requête, comme une question au coach. Le compteur est lu puis réécrit sans opération atomique, que KV ne propose pas : avec sa cohérence à terme, deux requêtes simultanées peuvent n’être comptées qu’une fois. L’écart est accepté, car ce quota sert seulement à plafonner les coûts.
Un quota séparé limite les modèles marqués premium à 20 requêtes par mois calendaire, par utilisateur et par catégorie (plans, coach, reste) ; une fois épuisé, il fait passer COMPLEX sur Gemini 2.5 Flash et les autres types sur Grok 4.1 Fast. En novembre 2026, aucun type n’utilise de modèle premium, et ce quota ne se déclenche pas.
Mesurer sans lire les prompts
Chaque requête d’IA qui aboutit produit un événement PostHog $ai_generation : modèle, tokens en entrée et en sortie, latence, coût en dollars tel qu’OpenRouter le renvoie, identifiant de trace et route. Le coach y consigne aussi ses échecs ; les plans et l’historique signalent les leurs par des événements distincts. Les prompts contiennent des données de santé (âge, poids, variabilité cardiaque, sommeil). L’événement n’en garde que la longueur et une empreinte SHA-256 tronquée à 8 octets, pour le message utilisateur comme pour le prompt système. De la réponse, il ne garde que la longueur.
Sur le coach, la séance, la suggestion, la forme du jour et l’historique, $ai_model est le modèle qui a effectivement répondu, tel qu’OpenRouter l’indique : un repli se repère en comparant cette valeur au modèle configuré. Sur les plans, c’est le modèle demandé. L’événement ne porte ni le type de requête ni d’indicateur de repli.