{"_id":"@astratra/ai","_rev":"9-ec9f7424fbdf4692dca4335d5676ef33","name":"@astratra/ai","dist-tags":{"latest":"1.5.0"},"versions":{"0.1.0":{"name":"@astratra/ai","version":"0.1.0","keywords":["astratra","ai","agents"],"license":"MIT","_id":"@astratra/ai@0.1.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"0194cd7555655cb391ee70417f78f9e4cdf2cacc","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-0.1.0.tgz","fileCount":8,"integrity":"sha512-j+1D3/XzgiGvNmBAwiFgwautMw+4yprjZPRjk5Q9iil/6Vtov22S/MP28BJFiv/FfT2pl2MS5kmlBevF2teNHg==","signatures":[{"sig":"MEUCID6Eq4SdjoxB+I888AcPjITq830z4u/tU6Hrua064oBwAiEA2kOI4PV1CYGM8ldtYPI0CbBSe5LzIiOHO4o9QCdGOCg=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":23109},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","gitHead":"1e576a2e512d525705894c42f7269882b6a09a7f","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.5.1","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"_nodeVersion":"22.18.0","dependencies":{"@astratra/core":"^0.1.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/ai_0.1.0_1786200646630_0.6975751243270307","host":"s3://npm-registry-packages-npm-production"}},"1.0.0":{"name":"@astratra/ai","version":"1.0.0","keywords":["astratra","ai","agents"],"license":"MIT","_id":"@astratra/ai@1.0.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"380fb976de86919cea7f9fb1d07e024653a09582","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-1.0.0.tgz","fileCount":8,"integrity":"sha512-1NSQFN/mVOTiIskoZX0CiPwelqkzZ6e6fmETvI+a9ar1GtaE3rHZDCS9pNEBkT739fH3bZekptFuIzKJ0/o4Ww==","signatures":[{"sig":"MEYCIQDD5fJlU7SOv4lvemCxxnetfGsvA6tdm/dT1a1J+h0RaQIhAKcVmidzj19MKqSbofOoq7bopiQMR+Pg4ZNtGY3gM0zh","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":23010},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","gitHead":"e4ba5847cd3342fd9398c867592b762f852a8ef8","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"_nodeVersion":"24.16.0","dependencies":{"@astratra/core":"^1.0.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/ai_1.0.0_1786210215178_0.9739150172417876","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@astratra/ai","version":"1.0.1","keywords":["astratra","ai","agents"],"license":"MIT","_id":"@astratra/ai@1.0.1","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"5596ef9b00a6a469aec8c52866bbddbdf3844159","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-1.0.1.tgz","fileCount":8,"integrity":"sha512-+jG4+IyxkKreCYb9sJYf18L7qAGqSAprywp4BEjh3ky2AuYDjTuPsgfg6ykgoZfqxNbz1scPaO/nq4NLwc//Vg==","signatures":[{"sig":"MEYCIQDbkXNQpOD7cC1HiNcrgQ6aooncyw0mBP8H/1uIiugJswIhAJpHMnGPBPEQJt9ACxzZNWaK6/fcW37InWhPUsRO3ECk","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":24731},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","gitHead":"c50d54fa744c599f7565555bf9ad9fd61676ad81","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"_nodeVersion":"24.16.0","dependencies":{"@astratra/core":"^1.0.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/ai_1.0.1_1786214300109_0.5549599521528148","host":"s3://npm-registry-packages-npm-production"}},"1.0.2":{"name":"@astratra/ai","version":"1.0.2","keywords":["astratra","ai","agents"],"license":"MIT","_id":"@astratra/ai@1.0.2","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"19e343b789772c99567bcd1124e1f76e956039d9","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-1.0.2.tgz","fileCount":8,"integrity":"sha512-SUtf+LLjE+WDfThnlXlVWaxn3QbCG9EGDJ/OS1YjgKtV98FOHJijeYNd4+IhOuXM65SHMNM13IV6zJprmXR30w==","signatures":[{"sig":"MEUCICde2qnTNgGwSH3eAFMian+rxKMHjETvUx4f/pxiFQ8aAiEA3bDuFugOQkADo59BCIPUOW2ygIKmTTB5WJWV6LPRxsk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":24770},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"c0f1cf78836764168bbbeabdebb1574f2ec68543","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"_nodeVersion":"24.16.0","dependencies":{"@astratra/core":"^1.0.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/ai_1.0.2_1786297842984_0.43878038441092215","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@astratra/ai","version":"1.1.0","keywords":["astratra","ai","agents"],"license":"MIT","_id":"@astratra/ai@1.1.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"834fa6c0b6edb89243dde5cee65a3a6479d69291","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-1.1.0.tgz","fileCount":8,"integrity":"sha512-ZluvMltRwtP7KGYOSuB9KkyV3Dl005zhQiMnWln2KmOclgjk+4Ih+2Lie+sq8NQCtNyC8Iq+otve/MC357hIrA==","signatures":[{"sig":"MEUCIFlFukCH9aTXK5TUfK/csV/P85oVQW8dWUBjb5edIio4AiEAooP9FVOdJguCS6+bZT+tmnbO+OErDtJqa2BruDCFMDw=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":27647},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"530705b0609c79552379ded37365aaac5b712707","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"_nodeVersion":"24.16.0","dependencies":{"@astratra/core":"^1.0.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/ai_1.1.0_1786739926190_0.46471591554170666","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"@astratra/ai","version":"1.2.0","keywords":["astratra","ai","agents"],"license":"MIT","_id":"@astratra/ai@1.2.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"7624eebdfad75dcbcb000cfc02e391069257dbef","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-1.2.0.tgz","fileCount":10,"integrity":"sha512-fr2xuScWRBjdrS60pC7r0dLUu5XxXGeRZUHAoVLALJTGee1hxG1Wg/1DGJQyKrc+bmxEs5Yig4quANAy4ZZdFA==","signatures":[{"sig":"MEQCIDjzWXQiYqEyGI7AQOh2hXahvW19mYUbGSMLCq0cxK/6AiBTVrhFklT+n9eU9b+x70Y1H4YOPeZ8i9tusY2u/jgw7w==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":46010},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a3f7c737294561ba8651e14d507a18b41e199999","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"_nodeVersion":"24.16.0","dependencies":{"@astratra/core":"^1.0.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/ai_1.2.0_1787666090042_0.2026288712978941","host":"s3://npm-registry-packages-npm-production"}},"1.3.0":{"name":"@astratra/ai","version":"1.3.0","keywords":["astratra","ai","agents"],"license":"MIT","_id":"@astratra/ai@1.3.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"1a9b1635a994ad178bf009803cccece2fb2bef46","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-1.3.0.tgz","fileCount":12,"integrity":"sha512-Ylz4N4KI0VtPKdXtW43xZLKp8VoqtRJUP3tflgzK8AtYg2tL/oPL4UY4BYCfVcTzusz2XwA0etlldix//5NQzQ==","signatures":[{"sig":"MEYCIQDQtfCGnAGh/XYLk42wtcFn9pHqCwEcyJtZBzcYkr2qXQIhAK7iNUGqhPcJFiKjGff4+dWVchPl0MYKQkroFsKgFvYW","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCICjW6/JQOZtT7TktIwTULCxsMDEbCXZpOREGCEF1rdQlAiEA6lxip6MwjbGbONGbQH3AJ7WH05734gnVSJXvakFi3x4=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":69726},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"a6eda5bf009d1d47caff00955940c6d2303bee2c","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"_nodeVersion":"24.16.0","dependencies":{"@astratra/core":"^1.0.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/ai_1.3.0_1789991405097_0.7856526958547556","host":"s3://npm-registry-packages-npm-production"}},"1.4.0":{"name":"@astratra/ai","version":"1.4.0","keywords":["astratra","ai","agents"],"license":"MIT","_id":"@astratra/ai@1.4.0","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"dist":{"shasum":"21cc9428a438e30c4e8a8caa3e3cc6ed9a3e5262","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-1.4.0.tgz","fileCount":20,"integrity":"sha512-kdWoqq9E9LpmK/7w8oFVekogJVM7WVbQeNccaTy9YG7uojbVJjeg8Zq7cCDShHd5HLDyQlr7sCFCvTfXWg7Raw==","signatures":[{"sig":"MEYCIQCOz2+iikd9qxJNrTzsB7AjAZWNWlQycYoMz5XJJNC87AIhAPB4G0sOF+O+qTB87PzqCgxazf5x6bqJRmZ77Mkr/ar+","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEYCIQCux0aSWLPVhb92gDQbeKEBjFN9tl7y6umVM4CPk/W0FAIhALRx6XCKJkhUzPbRLA8GLa2pB1VJtJTw9k2d3ppqg4o0","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":145395},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"fa5ec86eac3e65bcf1d4599f8af962dc57751cbd","scripts":{"test":"jest"},"_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"_npmVersion":"11.13.0","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"_nodeVersion":"24.16.0","dependencies":{"@astratra/core":"^1.0.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0","@astratra/resilience":"^0.2.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/ai_1.4.0_1790628071741_0.10125531872946647","host":"s3://npm-registry-packages-npm-production"}},"1.5.0":{"_id":"@astratra/ai@1.5.0","dist":{"shasum":"22ed3f63555dbc6180d111c829014fcda4c88e6e","tarball":"https://registry.npmjs.org/@astratra/ai/-/ai-1.5.0.tgz","fileCount":25,"integrity":"sha512-xyZjkwp3i0L1h+USW0vEZ8oLytPtpgTv+TcQm73QA4dituLXC5mJ3xzo90MfQlVHGEgPQGyihIF+5GsRFKQDiw==","signatures":[{"sig":"MEYCIQDI/REsQkRCBfC+zKQ+6vuexc4l1lue/xEBCs9gvYHxZAIhALtqygvP0i9ZPgM6K3e8OPooAiG5lir60o5rQvsFWtuB","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCAbWaHZXo973pe0GvabAGzADY5ikODcT92/BZaJGyxawIgZ6iGpp1xliuNYawDIJN8nYRiqZmFNf3nkwLltgDLjks="}],"unpackedSize":199052},"jest":{"testMatch":["**/__tests__/**/*.test.js"]},"main":"src/index.js","name":"@astratra/ai","type":"commonjs","types":"src/index.d.ts","engines":{"node":">=20"},"gitHead":"fa5ec86eac3e65bcf1d4599f8af962dc57751cbd","license":"MIT","scripts":{"test":"jest"},"version":"1.5.0","_npmUser":{"name":"emch99","email":"emchkongo@gmail.com"},"keywords":["astratra","ai","agents"],"_npmVersion":"11.13.0","description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","directories":{},"maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"_nodeVersion":"24.16.0","dependencies":{"@astratra/core":"^1.0.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"jest":"30.4.2","redis":"^4.7.0","@astratra/resilience":"^0.2.0"},"peerDependencies":{"redis":">=4"},"peerDependenciesMeta":{"redis":{"optional":true}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/ai_1.5.0_1790638850952_0.5212460151144802"}}},"time":{"created":"2026-08-08T14:50:46.483Z","modified":"2026-09-28T23:40:51.211Z","0.1.0":"2026-08-08T14:50:46.799Z","1.0.0":"2026-08-08T17:30:15.312Z","1.0.1":"2026-08-08T18:38:20.252Z","1.0.2":"2026-08-09T17:50:43.143Z","1.1.0":"2026-08-14T20:38:46.335Z","1.2.0":"2026-08-25T13:54:50.204Z","1.3.0":"2026-09-21T11:50:05.170Z","1.4.0":"2026-09-28T20:41:11.835Z","1.5.0":"2026-09-28T23:40:51.045Z"},"license":"MIT","keywords":["astratra","ai","agents"],"description":"Generic AI provider routing, tool registry, and agent loop utilities for Astratra.","maintainers":[{"name":"emch99","email":"emchkongo@gmail.com"}],"readme":"# @astratra/ai\n\nRouting IA multi-provider générique, registre d'outils et une boucle\nd'agent minimale à tool-calling. Dépend de `@astratra/core`.\n\nCe package ne fournit volontairement aucun catalogue de modèles, aucun SDK\nprovider, aucun outil métier — tout ça vient du projet consommateur. Ce\nqu'il fournit, c'est le mécanisme durement acquis : suivi de quota, ordre de\nfallback, cooldown/dégradation, deux adaptateurs HTTP sans dépendance (le\nformat OpenAI et Gemini), et deux boucles d'agent (protocole écrit, ou\nappels d'outils natifs).\n\n## Routeur de providers\n\n```js\nconst { createProviderRouter } = require('@astratra/ai');\n\nconst router = createProviderRouter({\n  redisUrl: process.env.REDIS_URL,  // optionnel — quotas atomiques partagés entre instances\n  intentRouting: {\n    summarize: { preferred: ['fast-model'] }\n  },\n  providers: [\n    {\n      id: 'mon-provider-llm',\n      models: [{ id: 'fast-model', rpm: 30, rpd: 1000, tpd: 200000, complexity: ['simple', 'medium'] }],\n      call: async (prompt, ctx, model) => monClient.complete(model.id, prompt)\n    }\n  ]\n});\n\nconst reponse = await router.ask('Résume ceci.', { complexity: 'simple', estimatedTokens: 200 });\nrouter.getStats();  // usage RPM/RPD/TPD par \"providerId:modelId\", état cooldown/dégradé\nrouter.stop();      // arrête le timer de reset minuit et ferme le lien Redis, s'il existe\n```\n\nLes providers sont essayés dans l'ordre du tableau que vous fournissez —\nl'ordre de fallback est votre décision, pas figé dans le package. Les\nquotas RPM/RPD/TPD, le cooldown après 429 avec jitter et la dégradation\naprès échecs répétés sont suivis par couple `providerId:modelId`. Avec\n`redisUrl`, la réservation des quotas est atomique entre instances avant\nl'appel du provider. Sans Redis, ou si Redis devient indisponible, le routeur\ncontinue avec des compteurs RAM locaux : ce repli ne peut pas garantir un\nquota distribué. Les compteurs journaliers se réinitialisent automatiquement\nà minuit.\n\n### L'ordre d'une demande, et qui a répondu\n\nUn même projet a souvent plusieurs ordres pour les mêmes modèles : le plus\nrapide d'abord pour ce que la personne attend, le plus capable d'abord pour\nle reste, ceux qui voient seulement pour une photo. `route` dit en plus qui a\nrépondu (pour les journaux).\n\n```js\nconst router = createProviderRouter({\n  providers,\n  cooldownMs: 120_000,\n  cooldownJitterMs: 0,\n  // Ce qui met au repos : un 429 par défaut ; ici aussi un 503 et un délai dépassé.\n  cooldownOn: (error) => [429, 503].includes(error.statusCode) || error.name === 'TimeoutError',\n  // Tous au repos ? On les essaie quand même : un repos est une supposition.\n  whenAllCooling: 'try'\n});\n\nconst { value, key, partial } = await router.route(requete, {\n  candidates: [{ provider: 'groq', model: 'qwen/qwen3.8-27b', extra: { reasoning_format: 'hidden' } }, { provider: 'gemini', model: 'gemini-3.6-flash' }],\n  select: (model, provider) => provider.id === 'gemini' || model.vision === true, // une photo : ceux qui voient\n  accepts: (reponse) => Boolean(reponse.text),                                   // refusée : le suivant\n  partial: (reponse) => reponse.cut === true                                     // coupée : en dernier recours\n}, { signal, purpose: 'chat' });\n```\n\n- Un fournisseur dont `available(ctx)` est faux (pas de clé) est sauté sans\n  bruit ; aucun disponible : `code: 'AI_NO_PROVIDER'`. Aucun qui convienne à\n  `select` : `'AI_NO_MATCH'`. Tous essayés : `'AI_UNAVAILABLE'` (AppError 503).\n- Une réponse refusée par `accepts` passe au modèle suivant sans compter comme\n  une panne ; une réponse `partial` n'est rendue que si personne ne finit.\n- Une **voie** (`provider.lane(ctx)`) : une clé à part pour un usage, avec son\n  propre quota. Elle se repose seule (`\"gemini:modèle@news\"`) : une clé saturée\n  par un travail de fond n'arrête jamais les conversations.\n- `ctx.signal` interrompu : la demande s'arrête net avec la raison de\n  l'appelant, aucun modèle suivant n'est demandé et aucun ne se repose.\n- `now` injecte l'horloge des repos ; `reset()` les oublie. Le minuteur de\n  minuit ne garde jamais le processus en vie.\n\n### Les adaptateurs : format OpenAI et Gemini\n\n```js\nconst { createGeminiProvider, createOpenAICompatibleProvider } = require('@astratra/ai');\n\ncreateGeminiProvider({\n  getKey: (ctx) => ctx.env[`GEMINI_API_KEY_${ctx.purpose}`] || ctx.env.GEMINI_API_KEY,\n  lane: (ctx) => (ctx.env[`GEMINI_API_KEY_${ctx.purpose}`] ? ctx.purpose : null),\n  fetch, detailed: true, toRequest: (requete) => requete\n});\ncreateOpenAICompatibleProvider({\n  id: 'cloudflare',\n  url: (ctx) => ctx.env.CF_ACCOUNT && `https://api.cloudflare.com/client/v4/accounts/${ctx.env.CF_ACCOUNT}/ai/v1/chat/completions`,\n  getKey: (ctx) => ctx.env.CF_TOKEN, fetch, detailed: true, toRequest: (requete) => requete\n});\n```\n\nLes deux prennent la même requête (`{ system, messages, tools, maxTokens }`,\nphotos comprises) et rendent la même réponse : `detailed: true` rend\n`{ text, toolCalls, cut }` au routeur, qui juge. La clé et l'adresse sont\nrelues à chaque appel (`ctx`) ; `ctx.fetch` et `ctx.timeoutMs` valent pour un\nappel ; `extra` d'un modèle s'ajoute à celui du fournisseur. Gemma reçoit la\nconsigne en tête du premier message ; les parties de réflexion et les balises\n`<think>` (même fermée sans ouverture) ne sont jamais montrées ; un appel\nd'outil **écrit dans le texte** (façons de Qwen et de Hermes) devient un vrai\nappel — et n'est jamais une réponse à montrer. Des arguments illisibles gardent\nl'appel, marqué `invalid` avec `invalidReason` (`not_json`, `not_object`), et\nrepartent en `{}` au tour suivant.\n\n## Outils natifs : la boucle d'un agent qui appelle ses outils\n\n```js\nconst { createToolCaller, runToolLoop, toolSpecs, validateNativeTools } = require('@astratra/ai');\n\nconst outils = validateNativeTools(catalogue, { requireSummary: true }); // au démarrage\nconst appeler = createToolCaller({\n  tools: outils, context: { userId }, signal, timeoutMs: 20_000,\n  emit: flux.send,                   // 'step' { id, tool, params } puis 'step_done' { id, ok }\n  keep: sources.keep,                // createSourceLedger()\n  record: async ({ tool, args, found }) => garderAction(tool, args, found), // à confirmer, à annuler\n  messages: { waiting: 'Rien n’est écrit : la personne doit confirmer sur son téléphone.' }\n});\nconst { text } = await runToolLoop({\n  system, messages, tools: toolSpecs(outils), turn, callTool: appeler,\n  maxTurns: 6, maxMs: 60_000, finalInstruction: 'Answer now with what you have read: no more tools.', signal\n});\n```\n\nUn outil : `{ name, description, parameters, kind, summary?, run }`, avec\n`perform` pour un outil `confirm` (rien n'est écrit avant qu'un humain\nconfirme ; le modèle lit que ça attend) et `undo` pour un outil `write`. Un\noutil qui lève, qui tarde, qu'on invente, ou des arguments illisibles\ndeviennent une erreur que le modèle **lit** et corrige au tour suivant ; ce\nqu'un outil rend est borné (`resultMax`, 6 000). Les outils demandés ensemble\ntournent en même temps. À court de tours ou de temps, un dernier tour sans\noutils répond avec ce qui a été lu ; un tour sans texte ni outil lève\n`code: 'AI_NO_ANSWER'`, pour que l'appelant réponde autrement.\n`summary(args)` donne les valeurs de la ligne d'étape (`stepParams`, coupées à\n120 caractères) : l'interface écrit la phrase, le serveur n'en écrit aucune.\n\n## Le flux vers l'app (server-sent events)\n\n```js\nconst flux = openEventStream(res);   // Node ou Express\nflux.send('step', { id: 's1', tool: 'read_bible' });\nflux.close();\nflux.signal;                          // interrompu quand la personne part — pas quand le serveur ferme\n```\n\nChaque bloc s'écrit `event: <type>\\ndata: <json>\\n\\n`, sans mise en tampon par\nun proxy (`X-Accel-Buffering: no`) ; écrire après la fin ne fait rien.\n\n## Recherche web (Serper)\n\n```js\nconst resultats = await searchSerper({ query, sites: ['jw.org'], hl: 'fr' }, {\n  key: process.env.SERPER_API_KEY, fetch,\n  accept: (resultat, url) => !estHostile(resultat)   // écarté avant que le modèle ne lise\n});\n```\n\nDes résultats https avec un titre, 8 au plus. Sans clé, rien n'est demandé\n(`code: 'WEB_SEARCH_NO_KEY'`) ; une panne porte `'WEB_SEARCH_FAILED'`, jamais la clé.\n\n## Registre d'outils\n\n```js\nconst { createToolRegistry } = require('@astratra/ai');\n\nconst registry = createToolRegistry();\nregistry.register({\n  name: 'get_patient_record',\n  description: \"Récupère le dossier d'un patient par son id\",\n  type: 'read',\n  roles: ['doctor', 'admin'],\n  params: { patientId: 'string' },\n  handler: async ({ patientId }, ctx) => patientStore.findById(patientId)\n});\n```\n\nVide par défaut — aucun outil pré-enregistré. `registry.formatToolsForPrompt(role)`\nformate en texte les outils visibles pour un rôle donné, à injecter dans un\nprompt système.\n\n## Boucle d'agent\n\n```js\nconst { runAgentLoop } = require('@astratra/ai');\n\nconst reponse = await runAgentLoop({\n  prompt: 'Quel est le solde du patient X ?',\n  ctx: { tenantId: 'clinic-1' },\n  registry,\n  router,\n  userRole: 'doctor',\n  maxSteps: 5,\n  onChunk: (chunk) => res.write(chunk),           // streaming token par token, optionnel\n  confirmTool: async (toolCall) => askUser(toolCall) // confirmation avant exécution, optionnel\n});\n```\n\nParse `<tool_call name=\"...\">{...json...}</tool_call>` dans la réponse du\nmodèle, exécute l'outil correspondant enregistré (refuse si le rôle n'y a\npas accès), réinjecte le résultat sous forme de `<tool_result>`, et boucle\njusqu'à une réponse finale ou `maxSteps` atteint.\n\n`onChunk(chunk)` est appelé pour chaque morceau reçu si `router.ask()`\nretourne un flux (async iterable) — un vrai passthrough token par token vers\nton UI. La boucle continue d'accumuler le texte complet en interne (elle en\na besoin pour détecter un `<tool_call>`), donc le fournir ne change rien au\ncomportement, juste un point d'observation en plus.\n\n`confirmTool(toolCall, ctx)` est attendu avant l'exécution d'un appel d'outil\ndétecté. Retourne `false` (ou une promesse résolue en `false`) pour refuser\n— la boucle ne plante pas, elle informe le modèle (`{\"denied\": true, ...}`\ncomme résultat d'outil) et continue, il peut réagir (expliquer, proposer\nautre chose, s'arrêter). Omis, chaque outil autorisé s'exécute automatiquement,\ncomme avant.\n\n**Périmètre V0 — toujours volontairement exclu :** gestion d'images/vision.\nFonctionnalité non triviale dont une boucle d'agent de production a besoin,\nmais dont le portage fidèle reste jugé trop ambitieux pour ce package. À\nconstruire dans votre propre boucle, ou à couvrir dans un futur spec.\n\n## Un disjoncteur par fournisseur, jamais un pour tous\n\nUn seul disjoncteur partagé par toutes les dépendances extérieures a l'air\npropre et c'est un piège : un reclasseur lent l'ouvrait, et le détecteur de\nnoms derrière le masquage s'éteignait avec lui pendant une minute. Ce qui\ntombe doit être seul à s'arrêter.\n\nLe paquet n'a pas de disjoncteur à lui : celui de `@astratra/resilience` (une\nseule sonde en demi-ouverture) s'injecte.\n\n```js\nconst { createCircuitBreaker } = require('@astratra/resilience');\nconst { createProviderRouter, isProviderOutage } = require('@astratra/ai');\n\nconst router = createProviderRouter({\n  providers,\n  breakers: (id) => createCircuitBreaker({ name: id, failureThreshold: 3, recoveryMs: 60_000, isFailure: isProviderOutage })\n});\nrouter.getStats()['groq:llama'].circuit; // 'closed' | 'open' | 'half-open'\n```\n\n`isProviderOutage` : délais, erreurs réseau, 408 et 5xx sont des pannes ; un\n429 ne l'est pas (le refroidissement du routeur s'en charge), ni un 400/401/404\n(c'est la requête ou la clé, pas le fournisseur). Un appel refusé par le\ndisjoncteur ne consomme aucun quota et ne compte pas comme échec du modèle.\n`createBreakerPool` sert pour tout le reste (routes d'un service local de\nmodèles…) et **refuse une fabrique qui rendrait le même disjoncteur pour deux\nclés** — c'est exactement le disjoncteur partagé.\n\n## Masquer ce qui sort, démasquer ce qui revient\n\nLa question était masquée ; la recherche web ne l'était pas. Le modèle écrivait\nla requête à partir de la question masquée, la boucle démasquait les paramètres\npour les outils qui ont besoin des vrais noms — et le nom de l'enfant partait\nen clair chez le moteur de recherche. La règle porte donc sur la **direction** :\nce qui quitte la machine est masqué au moment où il la quitte.\n\n```js\nconst { createReversibleMasker } = require('@astratra/ai');\n\nconst masker = createReversibleMasker({\n  names: registreDeLEcole,                                // sensible à la casse\n  patterns: [{ type: 'EMAIL', pattern: /[\\w.+-]+@[\\w-]+\\.[\\w.]+/g }],\n  detect: (texte) => serviceLocal.entites(texte),         // NER local, jamais distant\n});\n\n// Routeur : un fournisseur externe reçoit le texte masqué, la réponse revient démasquée\n// (y compris en flux). Un fournisseur `external: false` (sur la machine) reçoit le clair.\nawait router.ask(question, { complexity: 'simple' }, { masker });\n\n// Boucle d'agent : un outil `external: true` reçoit des paramètres MASQUÉS.\nregistry.register({ name: 'web_search', external: true, /* … */ });\nawait runAgentLoop({ prompt, registry, router, userRole, masker });\n```\n\nUn masqueur par conversation ; il **apprend** : un nom trouvé une fois est\nmasqué partout ensuite, avec le même jeton. Le registre est comparé en\nrespectant la casse et sur des mots entiers (« Grace » masqué, « grâce à »\nintact), un nom de moins de trois lettres n'est jamais masqué, un nom absent du\ntexte ne crée aucun jeton, les détections sous 0,6 sont ignorées et le\ndétecteur ne reçoit que 1 500 caractères coupés sur un blanc. Au démasquage, un\nmodèle qui a perdu le `#` du jeton récupère quand même le nom, et\n`#PERSON_0001` ne mange jamais le début de `#PERSON_00012`.\n\n## Des passages dans une autre langue\n\nUne question en anglais peut trouver sa meilleure réponse dans un texte qui\nn'existe qu'en français. Chaque passage dit sa langue (`[fr]`) et une consigne\n— la tienne, par langue — demande de traduire ce qui sert **en gardant la\nréférence d'origine**.\n\n```js\nconst lignes = buildPassagesContext({\n  passages,            // null = la recherche a échoué : on ne dit rien de la bibliothèque\n  lang: 'en',\n  texts: { header, footer, empty, foreign: 'A passage marked {languages} … keep its original reference.' }\n});\n```\n\n`fr-FR` et `fr` sont la même langue (sous-étiquette principale). Un passage\nétranger sans consigne est refusé plutôt que laissé à deviner au modèle.\n\n## Les sources : lues, utilisées, contredites\n\n- `createSourceLedger()` collecte les sources rendues par les outils ; un outil\n  qui en rend plusieurs ne rattache pas toute sa sortie à chacune.\n- `usedSources(réponse, sources, preuves, options)` ne garde que celles sur\n  lesquelles la réponse s'appuie (son lien, une référence nommée des deux\n  côtés, assez de mots distinctifs communs). Rien n'est jamais ajouté.\n- `findContradiction(réponse, extraits, { compare })` confronte chaque phrase\n  factuelle à l'extrait le plus proche via un modèle d'inférence local ; seule\n  une contradiction franche compte, un doute, une panne ou un délai dépassé ne\n  disent rien (`null`).\n- `rerankResults(question, trouvés, { score })` range les résultats par\n  pertinence et les sources suivent ; sans score, l'ordre du moteur reste.\n\n## Le texte de la réponse\n\n`tidyMarkdown` ramène la réponse au Markdown qu'un téléphone dessine (avec\n`remove`, des expressions que l'app ne montre jamais), `plainText` l'enlève\npour une voix, `wholeSentences` ramène un texte coupé à sa dernière phrase\nentière, et `verifyQuotations(texte, { findReferences, resolve })` remplace une\ncitation infidèle suivie de sa référence par le vrai texte — ce qu'est une\nréférence et où vit son texte t'appartient.\n\n## La langue de la réponse, la cadence, les points compatibles OpenAI\n\n- `createLanguageDetector({ words, identify })` : les petits mots d'abord (un\n  « merci » ne se devine pas autrement), puis un identifiant injecté, cru\n  seulement au-dessus d'un seuil — bien plus haut pour deux mots.\n- `createAskLimit({ max, windowMs, code })` : fenêtre glissante par personne ;\n  le refus porte un code et l'attente, jamais une phrase ; les comptes qui\n  n'écrivent plus sont oubliés (toutes les 500 demandes, et dès qu'une fenêtre\n  est passée depuis le dernier nettoyage).\n- `createOpenAICompatibleProvider({ id, url, getKey, models, fetch })` : un\n  fournisseur pour le routeur. La clé est relue à chaque appel, une erreur porte\n  le statut HTTP (429 → refroidissement) et jamais la clé, des arguments d'outil\n  mal formés sont signalés (`invalid: true`) au lieu de faire tomber le tour, les\n  balises de réflexion ne sont jamais montrées.\n\n## La boucle d'agent : outils en panne, budget de temps, dernier tour\n\n```js\nawait runAgentLoop({\n  prompt, registry, router, userRole,\n  reportToolErrors: true,   // un outil qui lève devient { error: 'tool_failed' } pour le modèle\n  toolTimeoutMs: 20_000,    // … ou { error: 'tool_timeout' }\n  maxMs: 60_000,            // budget total\n  finalInstruction: 'Réponds maintenant avec ce que tu as lu, sans outil.'\n});\n```\n\nLe modèle lit un code, jamais le message d'erreur. À court de tours ou de\ntemps, un dernier tour sans outils répond avec ce qui a été lu, plutôt qu'une\nerreur.\n\n## Tests\n\n```bash\nnpm test --workspace @astratra/ai\n```\n\n## Le sas : un agent propose, un humain dispose\n\nUn agent autorisé à écrire est dangereux d'une façon qu'un agent qui répond\nn'est pas. Le mode de panne n'est pas la malveillance, c'est l'assurance : le\nmodèle appelle `send_email` avec un destinataire plausible et un corps\nplausible, et une vraie famille reçoit un vrai message que personne n'a\napprouvé.\n\n```js\nconst { createPendingActions } = require('@astratra/ai');\n\nconst sas = createPendingActions({\n  store,\n  // Seul ce qui figure ici peut JAMAIS s'exécuter.\n  tools: {\n    send_email: async (payload) => mailer.send(payload),\n    send_fee_reminders: async (payload) => finance.remind(payload),\n  },\n  onPending: (action) => notifier.tell(action),   // « quelque chose attend »\n});\n\n// L'agent propose — rien ne part.\nawait sas.propose({ action: 'send_email', payload, proposedBy: 'agent', dedupeKey });\n\n// Un humain tranche.\nawait sas.approve(id, { approvedBy: userId, amend: { to: 'bonne-adresse@x.cd' } });\nawait sas.reject(id,  { rejectedBy: userId, note: 'mauvais destinataire' });\n```\n\n`createMemoryActionStore()` fournit un store en mémoire pour les tests et le\ndéveloppement — non persistant, donc à remplacer par une vraie table dès qu'il\ny a plusieurs instances : la revendication atomique n'a de sens que partagée.\n\nCe que le cycle garantit :\n\n- **une exécution, jamais deux** — la transition vers `executing` est une\n  revendication atomique : deux approbations simultanées produisent un envoi ;\n- **`dedupeKey`** empêche un modèle insistant d'empiler cinq propositions\n  identiques ;\n- **`amend`** : l'humain corrige le brouillon du modèle — destinataire, liste —\n  et la correction est consignée ;\n- un outil qui **retourne** une erreur est marqué `failed`, jamais `executed` :\n  « Envoyé » à l'écran pour un message jamais parti est le mensonge que ce sas\n  existe pour empêcher ;\n- un canal de notification mort ne fait pas échouer l'agent — l'action reste\n  visible dans sa liste.\n\n## Le repli déterministe : répondre quand tout est tombé\n\nLa norme, quand le dernier fournisseur de la chaîne échoue, est un message\nd'erreur. L'alternative est une réponse calculée SANS modèle, depuis les données\nqu'on a déjà. Ce n'est pas aussi bien — et ce n'est jamais un écran vide.\n\n```js\nconst { createDeterministicFallback } = require('@astratra/ai');\n\nconst repli = createDeterministicFallback({\n  responders: {\n    average: async ({ grades }) => ({ text: `La moyenne est de ${mean(grades)}/20.` }),\n  },\n  classify: (input) => input.question.includes('moyenne') ? 'average' : null,\n});\n\nconst { degraded, answer, providerError } = await repli.withFallback(\n  (input) => router.ask(input),\n  input,\n);\n```\n\nLa règle d'honnêteté est la partie qui compte : une réponse de repli **dit**\nqu'elle en est une (`degraded: true`). Servir une réponse dégradée comme si de\nrien n'était apprend aux utilisateurs à se méfier des bonnes.\n\nEt l'erreur du fournisseur est **transportée**, pas avalée : la gober en\nsilence cacherait la panne à ta propre supervision. Une question sans réponse\ndéterministe est déclinée, pas inventée.\n\n## Nettoyer une réponse avant qu'un humain la lise\n\nUne règle écrite dans l'invite n'est qu'un vœu : le modèle la suit quand ça\nl'arrange. Trois fuites arrivaient quand même à l'écran — du JSON brut, les\nétiquettes d'une structure que le modèle s'est inventée (`**introduction** :`),\net des brouillons de raisonnement ou des relances robotiques en fin de réponse.\n`createResponseCleaner` les retire de façon déterministe.\n\n```js\nconst { createResponseCleaner } = require('@astratra/ai');\n\nconst nettoyeur = createResponseCleaner({\n  shared: {                        // appliqué quelle que soit la langue\n    payloadKeys: ['response', 'message', 'answer'],\n    titleKeys: ['title', 'name'],\n    lineLabels: ['title', 'introduction', 'features', /key[ _]features/],\n    reasoningStarters: ['Wait', 'Actually'],\n    finalMarkers: [/Final\\s*answer/],\n  },\n  languages: {\n    fr: {\n      payloadKeys: ['réponse'],\n      closingPhrases: [/Besoin d'autre chose[^.?!]*[.?!]?/],\n    },\n  },\n  fallbackLanguage: 'fr',\n});\n\nnettoyeur.clean(texteDuModele, { language: 'fr' });\n```\n\n**Aucune clé JSON à l'écran, jamais.** Un ancien convertisseur écrivait\n« title : … », « features : … » : les clés restaient visibles. Ici un titre\ndevient une ligne en gras, un texte un paragraphe, une liste des puces, un\nélément nommé « **Nom** — description ». Quand le modèle enveloppe sa réponse\n(`{\"role\": \"...\", \"response\": \"...\"}`), le champ utile EST la réponse.\n\nLes étiquettes de ligne ne sont retirées **qu'en début de ligne** : le même mot\nau milieu d'une phrase n'est jamais touché. Pour un brouillon, seul ce qui suit\nla **dernière** ligne de raisonnement est gardé. Les relances empilées en fin de\nréponse sont retirées en plusieurs passes, bornées (`maxClosingPasses`, 3 par\ndéfaut).\n\nLe paquet ne contient **aucun mot** : chaque entrée est un mot littéral\n(échappé) ou une `RegExp`, fournie par l'appelant, par langue. Sans vocabulaire,\nseul le nettoyage structurel tourne (blocs `<think>`, JSON, espaces).\n\n## Dire au modèle où part sa réponse\n\n```js\nconst { createFormatInstructions } = require('@astratra/ai');\n\nconst forme = createFormatInstructions({\n  languages: {\n    fr: {\n      heading: '## Où part ta réponse',\n      intro: \"Ta réponse s'affiche sur {surface}.\",\n      surfaceNames: { phone: 'un téléphone', tablet: 'une tablette', desktop: 'un navigateur' },\n      table: 'Un tableau tient au maximum {columns} colonnes courtes.',\n      narrow: \"Sur un téléphone, préfère la liste au moindre doute.\",\n      wide: 'Le tableau reste réservé aux données réellement tabulaires.',\n      paragraphs: 'Écris en paragraphes : une idée chacun, des phrases complètes, jamais de trait (---).',\n    },\n  },\n});\n\nforme.build(req.headers['x-surface'], 'fr');\n```\n\nSurfaces par défaut : téléphone 3 colonnes, tablette 4, bureau 6. Une surface\nabsente ou inconnue vaut **téléphone** : se tromper vers le petit coûte une liste\nlà où un tableau tenait, se tromper vers le grand coûte un tableau illisible.\nLa règle `paragraphs` est **obligatoire** : un pack de langue qui ne l'a pas est\nrefusé à la création, pas découvert sur un écran.\n","readmeFilename":"README.md"}