Files

1 line
55 KiB
JSON

{"repo": "axellum/vromvrom-engine", "files": {"core/llm_gateway.py": {"b04fbcbdb50c8b18aa761352f65d02751c733f4b": {"t": 1785674606.744756, "n": 53165, "h": "d7aa588b61447c60", "c": "\"\"\"\ncore/llm_gateway.py — Passerelle unifiée d'accès aux ~18 providers LLM.\n\nInstancie et orchestre tous les providers (DeepSeek, Gemini natif/compat,\nClaude natif/CLI, Mistral, Cohere, Cerebras, OpenRouter, xAI, MiniMax,\nDeepInfra, GitHub Models, LM Studio/Ollama locaux...) derrière une interface\nunique. Sélectionne le provider par tier via ProviderScorer (core/provider_scorer.py :\ncoût + quota + solde + latence live), applique le Circuit Breaker par modèle\n(core/llm/circuit_breaker.py) et bascule en cascade sur échec.\n\nVoir aussi : contexte_ia/03_Software/CARTOGRAPHIE_MOTEUR.md §3.2 pour le détail\ndes relations avec les autres modules de ce groupe (elo_scorer, budget_guard,\nkey_pool, semantic_cache...).\n\"\"\"\nimport copy\nimport json\nimport logging\nimport os\nimport threading\nfrom typing import Any\n\nlogger = logging.getLogger(__name__)\n\n# Import du pool HTTP partagé (connexions TLS persistantes)\ntry:\n from core.openai_compat_provider import SharedHTTPPool\n _USE_HTTP_POOL = True\nexcept ImportError:\n _USE_HTTP_POOL = False\n SharedHTTPPool = None\n\n# Imports des modules découpés [v12.1.0]\nfrom core.llm.circuit_breaker import CircuitBreaker\nfrom core.llm.providers.base import LLMProvider\nfrom core.llm.providers.deepseek import (\n ClaudeInstructionsWrapper,\n FallbackProvider,\n LMStudioProvider,\n OllamaDeckProvider,\n _make_claude,\n)\nfrom core.llm.providers.gemini import GeminiCLIProvider, GeminiProvider\n\n# Import du provider natif Gemini (caching + grounding)\ntry:\n from core.gemini_native import GeminiNativeProvider\nexcept ImportError:\n GeminiNativeProvider = None\n\n\n# [#T118] Poids de la latence live (CircuitBreaker.avg_latency_ms) dans le score\n# de routage — un tie-breaker en complément du cascade_priority statique, pas un\n# remplacement : plafonné pour ne jamais dominer un écart de cascade_priority\n# entre providers réellement différents.\nLIVE_LATENCY_WEIGHT = 0.3\nLIVE_LATENCY_MAX_PENALTY = 3.0\n\n\ndef get_live_latency_penalty(model_name: str) -> float:\n \"\"\"\n [#T118] Pénalité de score dérivée de la latence live moyenne (EMA) du modèle,\n mesurée par son CircuitBreaker (`core/llm/circuit_breaker.py`). Plafonnée à\n `LIVE_LATENCY_MAX_PENALTY` pour rester un tie-breaker, jamais un remplacement\n du `cascade_priority` statique.\n\n Retourne 0.0 tant qu'aucun appel réussi n'a encore mesuré de latence.\n \"\"\"\n try:\n avg_latency_ms = CircuitBreaker.get_or_create(model_name).avg_latency_ms\n except Exception:\n return 0.0\n if not avg_latency_ms:\n return 0.0\n return min((avg_latency_ms / 1000.0) * LIVE_LATENCY_WEIGHT, LIVE_LATENCY_MAX_PENALTY)\n\n\nclass LLMGateway:\n\n \"\"\"\n Passerelle unifiée permettant d'appeler le LLM approprié de manière transparente.\n \"\"\"\n def __init__(self, deepseek_key: str = None, gemini_key: str = None):\n # Clé hardcodée supprimée — warning explicite si absente du .env\n ds_key = deepseek_key or os.environ.get(\"DEEPSEEK_API_KEY\")\n if not ds_key:\n logger.warning(\n \"[LLMGateway] DEEPSEEK_API_KEY absente du .env — \"\n \"les providers DeepSeek seront désactivés. \"\n \"Ajoutez DEEPSEEK_API_KEY=sk-... dans moteur_agents/.env\"\n )\n gem_free_key = gemini_key or os.environ.get(\"GEMINI_API_KEY\")\n # Clé payante de secours (s'il y en a une, sinon on fallback sur la clé gratuite si billing activé)\n gem_paid_key = os.environ.get(\"GEMINI_PAYANT_API_KEY\") or gem_free_key\n\n mistral_key = os.environ.get(\"MISTRAL_API_KEY\")\n if not mistral_key:\n logger.warning(\n \"[LLMGateway] MISTRAL_API_KEY absente du .env — \"\n \"les providers Mistral seront désactivés. \"\n \"Ajoutez MISTRAL_API_KEY=... dans moteur_agents/.env\"\n )\n\n cohere_key = os.environ.get(\"COHERE_API_KEY\")\n if not cohere_key:\n logger.info(\n \"[LLMGateway] COHERE_API_KEY absente du .env — \"\n \"les providers Cohere seront désactivés. \"\n \"Ajoutez COHERE_API_KEY=... dans moteur_agents/.env\"\n )\n\n cerebras_free_key = os.environ.get(\"CEREBRAS_API_KEY\")\n cerebras_paid_key = os.environ.get(\"CEREBRAS_PAYANT_API_KEY\")\n if not cerebras_free_key and not cerebras_paid_key:\n logger.info(\n \"[LLMGateway] CEREBRAS_API_KEY / CEREBRAS_PAYANT_API_KEY absente du .env — \"\n \"les providers Cerebras seront désactivés.\"\n )\n\n openrouter_key = os.environ.get(\"OPENROUTER_API_KEY\")\n if not openrouter_key:\n logger.info(\n \"[LLMGateway] OPENROUTER_API_KEY absente du .env — \"\n \"les providers OpenRouter seront désactivés. \"\n \"Ajoutez OPENROUTER_API_KEY=... dans moteur_agents/.env\"\n )\n\n xai_key = os.environ.get(\"XAI_API_KEY\")\n if not xai_key:\n logger.info(\n \"[LLMGateway] XAI_API_KEY absente du .env — \"\n \"les providers xAI seront désactivés. \"\n \"Ajoutez XAI_API_KEY=... dans moteur_agents/.env\"\n )\n\n minimax_key = os.environ.get(\"MINIMAX_API_KEY\")\n if not minimax_key:\n logger.info(\n \"[LLMGateway] MINIMAX_API_KEY absente du .env — \"\n \"les providers MiniMax seront désactivés. \"\n \"Ajoutez MINIMAX_API_KEY=... dans moteur_agents/.env\"\n )\n\n deepinfra_key = os.environ.get(\"DEEPINFRA_API_KEY\")\n if not deepinfra_key:\n logger.info(\n \"[LLMGateway] DEEPINFRA_API_KEY absente du .env — \"\n \"les providers DeepInfra seront désactivés. \"\n \"Ajoutez DEEPINFRA_API_KEY=... dans moteur_agents/.env\"\n )\n\n github_key = os.environ.get(\"GITHUB_TOKEN\")\n if not github_key:\n logger.info(\n \"[LLMGateway] GITHUB_TOKEN absente du .env — \"\n \"les providers GitHub Models seront désactivés. \"\n \"Ajoutez GITHUB_TOKEN=github_pat_... dans moteur_agents/.env\"\n )\n\n zhipu_key = os.environ.get(\"ZHIPU_API_KEY\")\n if not zhipu_key:\n logger.info(\n \"[LLMGateway] ZHIPU_API_KEY absente du .env — \"\n \"les providers Zhipu AI seront désactivés. \"\n \"Ajoutez ZHIPU_API_KEY=... dans moteur_agents/.env\"\n )\n\n # Coding Plan Alibaba (Lite/Pro) — clé sk-sp-* uniquement.\n # Alias BAILIAN_CODING_PLAN_API_KEY accepté (outils Qwen Code / OpenClaw).\n dashscope_key = (\n os.environ.get(\"DASHSCOPE_API_KEY\")\n or os.environ.get(\"BAILIAN_CODING_PLAN_API_KEY\")\n )\n if not dashscope_key:\n logger.info(\n \"[LLMGateway] DASHSCOPE_API_KEY absente du .env — \"\n \"le Coding Plan Alibaba (DashScope) sera désactivé. \"\n \"Ajoutez DASHSCOPE_API_KEY=sk-sp-... dans moteur_agents/.env\"\n )\n\n anthropic_key = os.environ.get(\"ANTHROPIC_API_KEY\")\n if not anthropic_key:\n logger.info(\n \"[LLMGateway] ANTHROPIC_API_KEY absente du .env — \"\n \"l'accès direct à l'API Anthropic sera désactivé (seul le CLI \"\n \"Claude Pro reste disponible via claude_cli). \"\n \"Ajoutez ANTHROPIC_API_KEY=sk-ant-... dans moteur_agents/.env\"\n )\n\n\n\n # Instanciation des 9 providers OpenAI-compatibles via la factory\n # Remplace ~1500 lignes de classes dupliquées par des appels config-driven\n from core.openai_compat_provider import OPENAI_COMPAT_PROVIDERS, OpenAICompatibleProvider\n\n def _make_compat(provider_id: str, model: str, api_key: str, timeout: tuple = None) -> OpenAICompatibleProvider:\n \"\"\"Crée un provider OpenAI-compatible à partir du registre centralisé.\"\"\"\n config = OPENAI_COMPAT_PROVIDERS.get(provider_id, {})\n base_url = config.get(\"base_url\", \"\")\n # Surcharge d'endpoint Coding Plan (CN vs Intl) via env.\n if provider_id == \"dashscope\":\n override = os.environ.get(\"DASHSCOPE_BASE_URL\", \"\").rstrip(\"/\")\n if override:\n base_url = (\n override\n if override.endswith(\"/chat/completions\")\n else f\"{override}/chat/completions\"\n )\n return OpenAICompatibleProvider(\n provider_name=provider_id.capitalize(),\n base_url=base_url,\n api_key=api_key,\n model=model,\n extra_headers=config.get(\"extra_headers\"),\n timeout=timeout,\n )\n\n # --- DeepSeek ---\n _ds = {}\n if ds_key:\n _ds = {\n \"deepseek\": _make_compat(\"deepseek\", \"deepseek-chat\", ds_key),\n \"deepseek-chat\": _make_compat(\"deepseek\", \"deepseek-chat\", ds_key),\n \"deepseek-reasoner\": _make_compat(\"deepseek\", \"deepseek-reasoner\", ds_key),\n \"deepseek-r1\": _make_compat(\"deepseek\", \"deepseek-reasoner\", ds_key),\n \"deepseek-v4-flash\": _make_compat(\"deepseek\", \"deepseek-chat\", ds_key),\n \"deepseek-v4-pro\": _make_compat(\"deepseek\", \"deepseek-chat\", ds_key),\n }\n\n # --- Mistral ---\n _mistral = {}\n if mistral_key:\n _mistral = {\n \"mistral\": _make_compat(\"mistral\", \"mistral-large-latest\", mistral_key),\n \"mistral-large-latest\": _make_compat(\"mistral\", \"mistral-large-latest\", mistral_key),\n \"codestral-latest\": _make_compat(\"mistral\", \"codestral-latest\", mistral_key),\n \"open-mistral-nemo\": _make_compat(\"mistral\", \"open-mistral-nemo\", mistral_key),\n }\n\n # --- Cohere ---\n _cohere = {}\n if cohere_key:\n _cohere = {\n \"cohere\": _make_compat(\"cohere\", \"command-r-plus-08-2024\", cohere_key),\n \"command-r-plus-08-2024\": _make_compat(\"cohere\", \"command-r-plus-08-2024\", cohere_key),\n \"command-r-08-2024\": _make_compat(\"cohere\", \"command-r-08-2024\", cohere_key),\n \"command-r-plus-latest\": _make_compat(\"cohere\", \"command-r-plus-08-2024\", cohere_key),\n \"command-r-latest\": _make_compat(\"cohere\", \"command-r-08-2024\", cohere_key),\n }\n\n # --- Cerebras ---\n # BudgetGuard « cerebras-free » → gpt-oss-120b : DOIT consommer la clé free\n # (sinon la rotation gratuite facture la clé payante). Les modèles « paid »\n # (gemma / zai) préfèrent CEREBRAS_PAYANT_API_KEY, free en secours.\n _cerebras = {}\n key_free_path = cerebras_free_key or cerebras_paid_key\n key_paid_path = cerebras_paid_key or cerebras_free_key\n if key_free_path:\n _cerebras.update({\n \"cerebras\": _make_compat(\"cerebras\", \"gpt-oss-120b\", key_free_path),\n \"gpt-oss-120b\": _make_compat(\"cerebras\", \"gpt-oss-120b\", key_free_path),\n })\n if key_paid_path:\n _cerebras.update({\n \"gemma-4-31b-cerebras\": _make_compat(\"cerebras\", \"gemma-4-31b\", key_paid_path),\n \"gemma-4-31b\": _make_compat(\"cerebras\", \"gemma-4-31b\", key_paid_path),\n \"zai-glm-4.7\": _make_compat(\"cerebras\", \"zai-glm-4.7\", key_paid_path),\n })\n\n # --- OpenRouter ---\n _openrouter = {}\n if openrouter_key:\n _openrouter = {\n \"openrouter\": _make_compat(\"openrouter\", \"meta-llama/llama-3.3-70b-instruct:free\", openrouter_key),\n \"meta-llama/llama-3.3-70b-instruct:free\": _make_compat(\"openrouter\", \"meta-llama/llama-3.3-70b-instruct:free\", openrouter_key),\n \"meta-llama/llama-3.2-3b-instruct:free\": _make_compat(\"openrouter\", \"meta-llama/llama-3.2-3b-instruct:free\", openrouter_key),\n }\n\n # --- xAI (Grok) ---\n _xai = {}\n if xai_key:\n _xai = {\n \"grok\": _make_compat(\"xai\", \"grok-4.3\", xai_key),\n \"grok-4.3\": _make_compat(\"xai\", \"grok-4.3\", xai_key),\n \"grok-4.20-non-reasoning\": _make_compat(\"xai\", \"grok-4.20-0309-non-reasoning\", xai_key),\n \"grok-4.20-reasoning\": _make_compat(\"xai\", \"grok-4.20-0309-reasoning\", xai_key),\n \"grok-4.20-multi-agent\": _make_compat(\"xai\", \"grok-4.20-multi-agent-0309\", xai_key),\n \"grok-build\": _make_compat(\"xai\", \"grok-build-0.1\", xai_key),\n }\n\n # --- MiniMax ---\n # Gamme complète validée par test live (2026-06-16)\n # Endpoint officiel : https://api.minimax.io/v1/chat/completions\n # Modèles disponibles sur ce compte (5/7 OK) :\n # OK : M3, M2.7, M2.7-highspeed, M2.5, M2.5-highspeed, M2.1, M2.1-highspeed, M2\n # KO : M1, Text-01 (érreur 500 \"plan not supported\" — non inclus dans le plan actuel)\n # Note : Les modèles MiniMax retournent des blocs <think>...</think> qu'il faut filtrer.\n _minimax = {}\n if minimax_key:\n def _make_minimax(model: str) -> OpenAICompatibleProvider:\n \"\"\"[D3] Factory MiniMax via la sous-classe MiniMaxProvider (plus de monkey-patch).\"\"\"\n from core.openai_compat_provider import MiniMaxProvider\n mm_config = OPENAI_COMPAT_PROVIDERS.get(\"minimax\", {})\n return MiniMaxProvider(\n provider_name=\"Minimax\",\n base_url=mm_config.get(\"base_url\", \"\"),\n api_key=minimax_key,\n model=model,\n extra_headers=mm_config.get(\"extra_headers\"),\n )\n\n _minimax = {\n # Alias principal vers le flagship\n \"minimax\": _make_minimax(\"MiniMax-M3\"),\n # --- Gamme M3 (juin 2026, MoE multimodal, 1M tokens, flagship) ---\n \"MiniMax-M3\": _make_minimax(\"MiniMax-M3\"),\n \"minimax-m3\": _make_minimax(\"MiniMax-M3\"),\n # --- Gamme M2.7 (mars 2026, raisonnement haute performance) ---\n \"MiniMax-M2.7\": _make_minimax(\"MiniMax-M2.7\"),\n \"minimax-m2.7\": _make_minimax(\"MiniMax-M2.7\"),\n \"MiniMax-M2.7-highspeed\": _make_minimax(\"MiniMax-M2.7-highspeed\"),\n \"minimax-m2.7-highspeed\": _make_minimax(\"MiniMax-M2.7-highspeed\"),\n # --- Gamme M2.5 (fév 2026, équilibre vitesse/intelligence) ---\n \"MiniMax-M2.5\": _make_minimax(\"MiniMax-M2.5\"),\n \"minimax-m2.5\": _make_minimax(\"MiniMax-M2.5\"),\n \"MiniMax-M2.5-highspeed\": _make_minimax(\"MiniMax-M2.5-highspeed\"),\n \"minimax-m2.5-highspeed\": _make_minimax(\"MiniMax-M2.5-highspeed\"),\n # --- Gamme M2.1 (déc 2025, modèle léger applicatif) ---\n \"MiniMax-M2.1\": _make_minimax(\"MiniMax-M2.1\"),\n \"minimax-m2.1\": _make_minimax(\"MiniMax-M2.1\"),\n \"MiniMax-M2.1-highspeed\": _make_minimax(\"MiniMax-M2.1-highspeed\"),\n \"minimax-m2.1-highspeed\": _make_minimax(\"MiniMax-M2.1-highspeed\"),\n # --- Gamme M2 (oct 2025, modèle agentique fondateur) ---\n \"MiniMax-M2\": _make_minimax(\"MiniMax-M2\"),\n \"minimax-m2\": _make_minimax(\"MiniMax-M2\"),\n # NOTE : MiniMax-M1 et MiniMax-Text-01 non supportés par le plan actuel\n }\n\n # --- DeepInfra ---\n _deepinfra = {}\n if deepinfra_key:\n _deepinfra = {\n \"deepinfra/llama-3.3-70b-instruct\": _make_compat(\"deepinfra\", \"meta-llama/Llama-3.3-70B-Instruct\", deepinfra_key),\n \"deepinfra/qwen-2.5-72b-instruct\": _make_compat(\"deepinfra\", \"Qwen/Qwen2.5-72B-Instruct\", deepinfra_key),\n \"deepinfra/deepseek-r1\": _make_compat(\"deepinfra\", \"deepseek-ai/DeepSeek-R1\", deepinfra_key),\n }\n\n # --- GitHub Models ---\n _github = {}\n if github_key:\n _github = {\n \"github\": _make_compat(\"github\", \"gpt-4o-mini\", github_key),\n \"github/gpt-4o\": _make_compat(\"github\", \"gpt-4o\", github_key),\n \"github/gpt-4o-mini\": _make_compat(\"github\", \"gpt-4o-mini\", github_key),\n \"github/meta-llama-3.3-70b-instruct\": _make_compat(\"github\", \"meta-llama-3.3-70b-instruct\", github_key),\n }\n\n # --- Zhipu AI (Z.ai) ---\n _zhipu = {}\n if zhipu_key:\n _zhipu = {\n \"zhipu\": _make_compat(\"zhipu\", \"glm-5-turbo\", zhipu_key),\n \"z-ai\": _make_compat(\"zhipu\", \"glm-5.2\", zhipu_key),\n \"glm-5.2\": _make_compat(\"zhipu\", \"glm-5.2\", zhipu_key),\n \"glm-5.1\": _make_compat(\"zhipu\", \"glm-5.1\", zhipu_key),\n \"glm-5\": _make_compat(\"zhipu\", \"glm-5\", zhipu_key),\n \"glm-5-turbo\": _make_compat(\"zhipu\", \"glm-5-turbo\", zhipu_key),\n \"glm-4.7\": _make_compat(\"zhipu\", \"glm-4.7\", zhipu_key),\n \"glm-4.6\": _make_compat(\"zhipu\", \"glm-4.6\", zhipu_key),\n \"glm-4.5\": _make_compat(\"zhipu\", \"glm-4.5\", zhipu_key),\n \"glm-4.5-air\": _make_compat(\"zhipu\", \"glm-4.5-air\", zhipu_key),\n }\n\n # --- Alibaba DashScope Coding Plan (Lite) ---\n # Allowlist exacte renvoyée par GET /v1/models (2026-07-26).\n # IDs préfixés `dashscope/...` pour éviter les collisions avec local/zhipu/minimax\n # (qwen3-coder-next, glm-5, glm-4.7, MiniMax-M2.5).\n # ⚠️ CGU Alibaba : forfait prévu pour outils de coding interactifs ; usage\n # backend/API automatisé hors périmètre officiel (risque de révocation).\n _dashscope = {}\n if dashscope_key:\n _ds_models = [\n \"qwen3.7-plus\",\n \"qwen3.6-plus\",\n \"qwen3.5-plus\",\n \"qwen3-max-2026-01-23\",\n \"qwen3-coder-next\",\n \"qwen3-coder-plus\",\n \"kimi-k2.5\",\n \"glm-5\",\n \"glm-4.7\",\n \"MiniMax-M2.5\",\n ]\n _dashscope = {\n f\"dashscope/{mid}\": _make_compat(\"dashscope\", mid, dashscope_key)\n for mid in _ds_models\n }\n # Alias principal + raccourcis uniques (pas de collision catalogue)\n _dashscope[\"dashscope\"] = _make_compat(\"dashscope\", \"qwen3-coder-next\", dashscope_key)\n _dashscope[\"qwen3.7-plus\"] = _dashscope[\"dashscope/qwen3.7-plus\"]\n _dashscope[\"qwen3.6-plus\"] = _dashscope[\"dashscope/qwen3.6-plus\"]\n _dashscope[\"qwen3.5-plus\"] = _dashscope[\"dashscope/qwen3.5-plus\"]\n _dashscope[\"qwen3-max-2026-01-23\"] = _dashscope[\"dashscope/qwen3-max-2026-01-23\"]\n _dashscope[\"qwen3-coder-plus\"] = _dashscope[\"dashscope/qwen3-coder-plus\"]\n _dashscope[\"kimi-k2.5\"] = _dashscope[\"dashscope/kimi-k2.5\"]\n\n # --- Anthropic API directe (ANTHROPIC_API_KEY, indépendant du CLI Claude Pro) ---\n _anthropic_native = {}\n if anthropic_key:\n from core.anthropic_native_provider import AnthropicNativeProvider\n\n def _make_anthropic(model: str) -> AnthropicNativeProvider:\n return AnthropicNativeProvider(api_key=anthropic_key, model=model)\n\n _anthropic_native = {\n # Modèles non disponibles via le CLI Claude Pro (claude_cli)\n \"claude-sonnet-5\": _make_anthropic(\"claude-sonnet-5\"),\n \"claude-fable-5\": _make_anthropic(\"claude-fable-5\"),\n # Accès direct alternatif aux modèles déjà exposés via claude_cli — suffixe\n # \"-direct\" car la clé du dict est un espace de nommage global (self.providers)\n # et \"claude-opus-4-8\"/\"claude-haiku-4-5\" pointent déjà vers ClaudeCLIProvider.\n \"claude-opus-4-8-direct\": _make_anthropic(\"claude-opus-4-8\"),\n \"claude-haiku-4-5-direct\": _make_anthropic(\"claude-haiku-4-5\"),\n }\n\n # --- Ollama Local (PC de développement) ---\n # Modèle local PRINCIPAL = fine-tune projet (QLoRA sur le code/HA/Tab5, base\n # Qwen2.5-Coder-7B). Converti via llama.cpp puis quantifié q4_K_M (le converter\n # interne d'Ollama corrompt ce modèle — cf. Modelfile.domotique-gguf).\n # Les modèles de base restent accessibles par leur nom.\n _ollama_local = {\n \"ollama_local\": _make_compat(\"ollama_local\", \"domotique-qwen7b:q4\", \"ollama\"),\n \"domotique-qwen7b\": _make_compat(\"ollama_local\", \"domotique-qwen7b:q4\", \"ollama\"),\n \"domotique-qwen7b:q4\": _make_compat(\"ollama_local\", \"domotique-qwen7b:q4\", \"ollama\"),\n \"qwen2.5-coder:7b\": _make_compat(\"ollama_local\", \"qwen2.5-coder:7b\", \"ollama\"),\n \"deepseek-r1:8b\": _make_compat(\"ollama_local\", \"deepseek-r1:8b\", \"ollama\"),\n # Variante joignable en LAN (${OLLAMA_HOST:-192.168.1.x}) depuis le Deck — cf. commentaire\n # dans OPENAI_COMPAT_PROVIDERS[\"ollama_pc\"]. Utilisée par le fast path vocal\n # (FAST_PATH_PROVIDERS) pour du local-first même quand le moteur tourne sur le Deck.\n # Timeout dédié (connect 2s, read 15s) — PAS la famille \"lmstudio\" (120s de read) :\n # mesuré en direct le 16/07, un modèle Ollama \"froid\" (pas encore chargé en VRAM)\n # met 30s+ à répondre au premier appel. Le budget vocal Discussion est de 20s au\n # total (source_router.py, ModeType.CHAT) : un read_timeout de 15s laisse encore\n # de la marge pour basculer sur le cloud dans le budget, plutôt que de faire\n # attendre l'utilisateur ~30-90s en silence sur un cold start.\n \"ollama_pc\": _make_compat(\"ollama_pc\", \"domotique-qwen7b:q4\", \"ollama\", timeout=(2.0, 15.0)),\n }\n\n self.providers: dict[str, LLMProvider] = {\n **_ds,\n **_mistral,\n **_cohere,\n **_cerebras,\n **_openrouter,\n **_xai,\n **_minimax,\n **_deepinfra,\n **_github,\n **_zhipu,\n **_dashscope,\n **_anthropic_native,\n **_ollama_local,\n\n \"local\": LMStudioProvider(),\n # === STEAM DECK EDGE AI (Ollama RDNA2) ===\n # Endpoint réseau local : http://${ENGINE_HOST:-192.168.1.x}:11434\n # Disponibilité vérifiée dynamiquement via ping_available()\n # Tiers recommandés : parsing_logs, yaml_format, resume_court\n \"deck_ollama\": OllamaDeckProvider(), # phi3:mini par défaut\n \"deck_ollama_phi3\": OllamaDeckProvider(model_name=\"phi3:mini\"), # Parsing rapide\n \"deck_ollama_gemma\": OllamaDeckProvider(model_name=\"gemma2:2b\"), # Reformatage YAML\n \"deck_ollama_llama\": OllamaDeckProvider(model_name=\"llama3.2:3b\"), # Résumés courts\n # === CLAUDE CODE CLI — Modèles validés le 2026-05-24 ===\n # Accès via abonnement Claude Pro/Max — inclus dans le forfait\n # Le ClaudeCLIProvider passe --model {default_model} au CLI\n \"claude\": _make_claude(), # défaut = sonnet-4-6\n \"claude-opus-4-8\": _make_claude(\"claude-opus-4-8\"), # ✅ Le plus puissant (depuis 2026-05-28)\n \"claude-opus-4-7\": _make_claude(\"claude-opus-4-7\"), # ✅ Génération stable précédente\n \"claude-opus-4-0\": _make_claude(\"claude-opus-4-8\"), # ✅ Alias vers opus-4-8\n \"claude-opus-4-5\": _make_claude(\"claude-opus-4-5\"), # ✅ Génération stable\n \"claude-sonnet-4-6\": _make_claude(\"claude-sonnet-4-6\"), # ✅ Défaut CLI\n \"claude-sonnet-4-5\": _make_claude(\"claude-sonnet-4-5\"), # ✅\n \"claude-haiku-4-5\": _make_claude(\"claude-haiku-4-5\"), # ✅ Rapide\n # Anciens alias legacy\n \"claude-sonnet-4.6-thinking-cli\": _make_claude(\"claude-sonnet-4-6\"),\n \"claude-opus-4.6-thinking-cli\": _make_claude(\"claude-opus-4-8\"),\n\n\n \"gemini-cli\": GeminiCLIProvider(),\n \"antigravity\": GeminiCLIProvider(), # alias\n \"gemini-3.5-flash-high-cli\": GeminiCLIProvider(mode=\"chat\", model_name=\"gemini-3.5-flash-high-cli\"),\n \"gemini-3.5-flash-medium-cli\": GeminiCLIProvider(mode=\"chat\", model_name=\"gemini-3.5-flash-medium-cli\"),\n }\n\n # Choix du provider Gemini : Natif (caching + grounding) ou OpenAI-compatible (fallback)\n _GeminiClass = GeminiNativeProvider if GeminiNativeProvider else GeminiProvider\n _provider_type = \"Natif (caching+grounding)\" if GeminiNativeProvider else \"OpenAI-compatible (legacy)\"\n logger.info(f\"[LLMGateway] Provider Gemini sélectionné : {_provider_type}\")\n\n # 1. Enregistrement des versions gratuites (clé gratuite AI Studio)\n if gem_free_key:\n if GeminiNativeProvider:\n # Providers natifs avec caching implicite (cache explicite = payant uniquement)\n # Note : le cache EXPLICITE (cachedContents API) n'est PAS disponible en Free Tier\n # (erreur 429: TotalCachedContentStorageTokensPerModelFreeTier limit=0)\n # Le cache IMPLICITE (systemInstruction) reste actif et GRATUIT.\n self.providers[\"gemini-3.5-flash-free\"] = GeminiNativeProvider(\n api_key=gem_free_key, model=\"gemini-3.5-flash\",\n search_grounding_available=False,\n enable_explicit_cache=False, # Cache explicite = payant uniquement\n use_key_pool=True # [Phase 1] Rotation multi-clés Free Tier\n )\n self.providers[\"gemini-3.1-flash-lite-free\"] = GeminiNativeProvider(\n api_key=gem_free_key, model=\"gemini-3.1-flash-lite\",\n enable_explicit_cache=False,\n use_key_pool=True\n )\n self.providers[\"gemini-2.5-flash-free\"] = GeminiNativeProvider(\n api_key=gem_free_key, model=\"gemini-2.5-flash\",\n enable_explicit_cache=False,\n use_key_pool=True\n )\n else:\n # Fallback OpenAI-compatible si gemini_native.py n'est pas disponible\n self.providers[\"gemini-3.5-flash-free\"] = GeminiProvider(api_key=gem_free_key, model=\"gemini-3.5-flash\")\n self.providers[\"gemini-3.1-flash-lite-free\"] = GeminiProvider(api_key=gem_free_key, model=\"gemini-3.1-flash-lite\")\n self.providers[\"gemini-2.5-flash-free\"] = GeminiProvider(api_key=gem_free_key, model=\"gemini-2.5-flash\")\n\n # Compatibilité et fallbacks historiques vers gratuit par défaut\n self.providers[\"gemini\"] = self.providers[\"gemini-3.5-flash-free\"]\n self.providers[\"gemini-flash\"] = self.providers[\"gemini-3.5-flash-free\"]\n self.providers[\"gemini-3.5-flash\"] = self.providers[\"gemini-3.5-flash-free\"]\n self.providers[\"gemini-pro\"] = self.providers[\"gemini-3.5-flash-free\"] # Fallback sur 3.5 Flash\n self.providers[\"gemini-2.5-pro\"] = self.providers[\"gemini-3.5-flash-free\"]\n self.providers[\"gemini-3.1-pro\"] = self.providers[\"gemini-3.5-flash-free\"]\n self.providers[\"gemini-3.1-flash\"] = self.providers[\"gemini-3.5-flash-free\"]\n self.providers[\"gemini-3.1-flash-lite\"] = self.providers[\"gemini-3.1-flash-lite-free\"]\n self.providers[\"gemini-2.5-flash\"] = self.providers[\"gemini-2.5-flash-free\"]\n logger.info(f\"✅ Provider Gemini Gratuit ({_provider_type}) activé avec succès.\")\n else:\n logger.warning(\"Clé GEMINI_API_KEY (gratuite) non fournie.\")\n\n # 2. Enregistrement des versions payantes (clé payante GCP)\n # Enregistrées uniquement si GEMINI_PAYANT_API_KEY est explicitement définie dans le .env\n gem_paid_key = os.environ.get(\"GEMINI_PAYANT_API_KEY\")\n if gem_paid_key:\n if GeminiNativeProvider:\n # Providers natifs payants avec Search Grounding débloqué\n self.providers[\"gemini-3.5-flash-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-3.5-flash\",\n search_grounding_available=True # 🔍 Grounding débloqué !\n )\n self.providers[\"gemini-2.5-pro-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-2.5-pro\",\n search_grounding_available=True\n )\n self.providers[\"gemini-2.5-flash-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-2.5-flash\",\n search_grounding_available=True\n )\n self.providers[\"gemini-2.0-flash-tts-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-2.0-flash-tts\",\n enable_explicit_cache=False # TTS : pas de cache\n )\n self.providers[\"gemini-3.1-flash-lite-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-3.1-flash-lite\",\n enable_explicit_cache=False\n )\n # [P1 FIX] Corrigé : gemini-1.5-pro (retiré/404) → gemini-3-pro-preview\n self.providers[\"gemini-3-pro-short-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-3-pro-preview\",\n search_grounding_available=True\n )\n # [P2] Ajout des providers Pro manquants (testés OK le 26/05/2026)\n self.providers[\"gemini-3.1-pro-preview-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-3.1-pro-preview\",\n search_grounding_available=True # 🔍 Pro avec Grounding + context 2M\n )\n self.providers[\"gemini-3.1-pro-customtools-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-3.1-pro-preview-customtools\",\n search_grounding_available=True # 🛠️ Pro optimisé Tool Use\n )\n self.providers[\"gemini-3-pro-preview-paid\"] = GeminiNativeProvider(\n api_key=gem_paid_key, model=\"gemini-3-pro-preview\",\n search_grounding_available=True\n )\n else:\n # Fallback OpenAI-compatible\n self.providers[\"gemini-3.5-flash-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-3.5-flash\")\n self.providers[\"gemini-2.5-pro-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-2.5-pro\")\n self.providers[\"gemini-2.5-flash-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-2.5-flash\")\n self.providers[\"gemini-2.0-flash-tts-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-2.0-flash-tts\")\n self.providers[\"gemini-3.1-flash-lite-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-3.1-flash-lite\")\n # [P1 FIX] Corrigé : gemini-1.5-pro → gemini-3-pro-preview\n self.providers[\"gemini-3-pro-short-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-3-pro-preview\")\n # [P2] Ajout Pro manquants (fallback OpenAI-compatible)\n self.providers[\"gemini-3.1-pro-preview-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-3.1-pro-preview\")\n self.providers[\"gemini-3.1-pro-customtools-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-3.1-pro-preview-customtools\")\n self.providers[\"gemini-3-pro-preview-paid\"] = GeminiProvider(api_key=gem_paid_key, model=\"gemini-3-pro-preview\")\n logger.info(f\"✅ Provider Gemini Payant ({_provider_type}) activé avec succès.\")\n else:\n logger.info(\"Clé GEMINI_PAYANT_API_KEY absente du .env — les providers GCP payants sont désactivés.\")\n\n def get_access_map(self) -> dict[str, dict[str, Any]]:\n \"\"\"\n Retourne la carte d'accès des modèles disponibles (clés configurées).\n \"\"\"\n access_map = {}\n for name, provider in self.providers.items():\n # Déterminer le provider parent\n provider_type = \"unknown\"\n if \"deepseek\" in name:\n provider_type = \"deepseek\"\n elif \"gemini\" in name:\n provider_type = \"gemini\"\n elif \"claude\" in name:\n provider_type = \"claude\"\n elif \"grok\" in name or \"xai\" in name:\n provider_type = \"xai\"\n elif \"mistral\" in name:\n provider_type = \"mistral\"\n elif \"cohere\" in name:\n provider_type = \"cohere\"\n elif \"cerebras\" in name or \"gpt-oss\" in name:\n provider_type = \"cerebras\"\n elif \"dashscope\" in name or name in (\n \"qwen3.7-plus\", \"qwen3.6-plus\", \"qwen3.5-plus\",\n \"qwen3-max-2026-01-23\", \"qwen3-coder-plus\", \"kimi-k2.5\",\n ):\n provider_type = \"dashscope\"\n elif \"openrouter\" in name:\n provider_type = \"openrouter\"\n elif \"minimax\" in name:\n provider_type = \"minimax\"\n elif \"deepinfra\" in name:\n provider_type = \"deepinfra\"\n elif \"github\" in name:\n provider_type = \"github\"\n elif \"local\" in name or \"deck_ollama\" in name:\n provider_type = \"local\"\n\n # Récupérer l'objet provider interne si ClaudeInstructionsWrapper\n actual_provider = provider\n if hasattr(provider, \"provider\"):\n actual_provider = provider.provider\n\n model_name = (\n getattr(actual_provider, \"model\", None)\n or getattr(actual_provider, \"model_name\", None)\n or name\n )\n\n access_map[name] = {\n \"available\": True,\n \"provider\": provider_type,\n \"model_name\": model_name\n }\n return access_map\n\n def _get_raw_provider(self, name: str) -> LLMProvider:\n \"\"\"\n Résolution interne SANS Circuit Breaker (ClaudeInstructionsWrapper seul).\n\n Réservée à get_provider_for_tier(), qui applique déjà son propre wrapping\n FallbackProvider/CircuitBreaker sur la liste complète du tier — passer par\n get_provider() ici imbriquerait deux cascades (double CB, double retry).\n \"\"\"\n provider = self.providers.get(name.lower())\n if not provider:\n raise ValueError(f\"Provider LLM inconnu : {name}\")\n # Envelopper le provider pour injecter automatiquement CLAUDE.md\n # sauf si c'est un FallbackProvider (qui va déléguer aux providers enveloppés)\n if not isinstance(provider, FallbackProvider) and not isinstance(provider, ClaudeInstructionsWrapper):\n return ClaudeInstructionsWrapper(provider)\n return provider\n\n def get_provider(self, name: str) -> LLMProvider:\n \"\"\"\n [#T212] Accès direct par ID littéral (ex: planner_model=\"gemini-3.5-flash-free\").\n\n Retourne désormais une cascade d'un seul élément (FallbackProvider) : l'appel\n passe ainsi par le MÊME wrapping Circuit Breaker / retry 429 / cache sémantique\n que le chemin get_provider_for_tier(), au lieu de contourner le CB comme avant.\n Le nom de CB utilisé est la clé du registre (name.lower()), soit le même espace\n de nommage que les modèles résolus par tier.\n\n Lève toujours ValueError si le nom est inconnu (contrat inchangé pour les\n appelants qui s'en servent comme test de disponibilité).\n \"\"\"\n raw = self._get_raw_provider(name)\n if isinstance(raw, FallbackProvider):\n return raw\n return FallbackProvider([(name.lower(), raw)])\n\n def stream(self, provider_name: str, system_prompt: str, user_prompt: str, **kwargs):\n \"\"\"\n Streaming token-par-token via le provider spécifié.\n \n Yields:\n dict: {\"token\": str, \"done\": bool, \"usage\": dict|None}\n \"\"\"\n provider = self.get_provider(provider_name)\n yield from provider.generate_stream(system_prompt, user_prompt, **kwargs)\n\n def _resolve_tier_models(self, tier: str, config: dict) -> tuple[str, list]:\n \"\"\"\n [T133][T183-migration] Résout un nom de tier (avec alias) en (actual_tier, allowed_models).\n\n Source de vérité : models_registry.db (colonne routing_tier, filtrée sur\n status='active') via core.models_db.get_models_for_tier(). config.json[\"tiers\"]\n et le default_map ne servent plus que de filet de sécurité si la DB ne renvoie\n rien (ex: DB absente, routing_tier pas encore renseigné pour ce tier).\n \"\"\"\n tier = tier.lower()\n tier_mapping = {\n \"flash\": \"leger\",\n \"standard\": \"moyen\",\n \"reasoner\": \"fort\",\n \"pro\": \"fort\",\n \"local\": \"leger\",\n \"antigravity\": \"fort\"\n }\n actual_tier = tier_mapping.get(tier, tier)\n if actual_tier not in [\"leger\", \"moyen\", \"fort\", \"automatique\"]:\n actual_tier = \"moyen\"\n\n try:\n from core.models_db import get_models_for_tier\n db_models = get_models_for_tier(actual_tier)\n allowed_models = [m[\"id\"] for m in db_models]\n except Exception as e:\n logger.warning(f\"[LLMGateway] get_models_for_tier({actual_tier}) indisponible ({e}), repli sur config.json.\")\n allowed_models = []\n\n if not allowed_models:\n tiers_config = config.get(\"tiers\", {})\n allowed_models = tiers_config.get(actual_tier, [])\n\n if not allowed_models:\n default_map = {\n \"leger\": [\"local\", \"gemini-3.5-flash\"],\n \"moyen\": [\"deepseek-chat\", \"gemini-3.5-flash\"],\n \"fort\": [\"deepseek-reasoner\", \"claude\", \"gemini-2.5-pro\"],\n \"automatique\": [\"local\", \"gemini-3.5-flash\", \"deepseek-chat\", \"gemini-cli\", \"deepseek-reasoner\", \"claude\", \"gemini-2.5-pro\"]\n }\n allowed_models = default_map.get(actual_tier, [\"gemini-3.5-flash\"])\n\n # [routing_policy] Exclusions volontaires du routage automatique, quelle\n # que soit la source (config.json OU models_registry.db). Cas d'usage :\n # claude-fable-5 est délibérément hors tiers (décision Axel 07/2026,\n # anti auto-escalade vers le modèle le plus cher) — ce filtre garantit\n # qu'un ajout ultérieur en DB (routing_tier) ne le réintroduira pas.\n # L'accès EXPLICITE via get_provider(\"claude-fable-5\") reste possible.\n excluded = set(config.get(\"routing_policy\", {}).get(\"excluded_models\", []))\n if excluded:\n filtered = [m for m in allowed_models if m not in excluded]\n if len(filtered) != len(allowed_models):\n logger.info(\n f\"[LLMGateway] routing_policy : modèle(s) exclu(s) du tier '{actual_tier}' : \"\n f\"{sorted(set(allowed_models) - set(filtered))}\"\n )\n allowed_models = filtered\n\n return actual_tier, allowed_models\n\n def get_provider_for_tier(self, tier: str, config: dict, elo_order: list = None) -> tuple[str, FallbackProvider]:\n \"\"\"\n Résout un Tier en un FallbackProvider contenant les modèles configurés pour ce Tier, triés dynamiquement.\n\n Si elo_order est fourni (liste de dicts {\"model\": str, \"elo\": float}),\n les modèles sont triés par Elo décroissant en priorité, puis par coût/quota en\n second critère. Cela permet au routeur de privilégier le modèle le plus fiable\n pour le domaine d'intention détecté.\n\n [T133] Le scoring coût/quota/latence est délégué à ProviderScorer\n (core/provider_scorer.py, ex God Object de 150+ lignes) — cette méthode ne\n fait plus que résoudre le tier, instancier les providers et trier.\n \"\"\"\n actual_tier, allowed_models = self._resolve_tier_models(tier, config)\n\n providers_list = []\n for model in allowed_models:\n try:\n # [#T212] Résolution brute : le FallbackProvider construit ci-dessous\n # applique déjà le Circuit Breaker sur chaque modèle de la liste.\n provider = self._get_raw_provider(model)\n providers_list.append((model, provider))\n except ValueError:\n logger.warning(f\"Modèle {model} non disponible ou clé API manquante.\")\n continue\n\n from core.provider_scorer import ProviderScorer\n scorer = ProviderScorer([m for m, _ in providers_list])\n providers_list = scorer.sort_providers(providers_list, elo_order)\n\n logger.info(f\"[LLMGateway] Tier '{tier}' (actual: '{actual_tier}') -> allowed_models: {allowed_models} -> trié (prioritaire d'abord): {[p[0] for p in providers_list]}\")\n\n if not providers_list:\n for fallback_model in [\"gemini-3.5-flash\", \"deepseek-chat\", \"local\"]:\n try:\n providers_list.append((fallback_model, self._get_raw_provider(fallback_model)))\n break\n except ValueError:\n continue\n\n return f\"tier-{actual_tier}\", FallbackProvider(providers_list)\n\n def get_providers_summary(self) -> list:\n \"\"\"\n Retourne le résumé de tous les providers LLM configurés.\n Méthode requise par la route /api/providers pour l'IHM Models Registry.\n \"\"\"\n try:\n from core.models_db import get_all_providers\n return get_all_providers()\n except Exception as e:\n logger.error(f\"[LLMGateway] Erreur dans get_providers_summary: {e}\")\n # Fallback statique si la BDD n'est pas accessible\n return [\n {\n \"id\": name,\n \"name\": name.capitalize(),\n \"cascade_priority\": 5.0\n }\n for name in self.providers\n ]\n\n def get_circuit_breakers_status(self) -> dict:\n \"\"\"\n Retourne l'état de tous les Circuit Breakers du registre global.\n Méthode d'instance requise par le serveur MCP Tab5-Engine.\n \"\"\"\n try:\n cb_status = {}\n if CircuitBreaker is not None:\n with CircuitBreaker._registry_lock:\n for name, cb in CircuitBreaker._registry.items():\n cb_status[name] = cb.to_dict()\n\n providers_info = {}\n for name in self.providers:\n providers_info[name] = {\n \"available\": True,\n \"circuit_breaker\": cb_status.get(name, {\"state\": \"CLOSED\", \"failure_count\": 0}),\n \"type\": type(self.providers[name]).__name__\n }\n\n return {\n \"providers\": providers_info,\n \"circuit_breakers\": cb_status,\n \"total_providers\": len(providers_info),\n \"total_circuit_breakers\": len(cb_status)\n }\n except Exception as e:\n logger.error(f\"[LLMGateway] Erreur dans get_circuit_breakers_status: {e}\")\n return {\"error\": str(e), \"providers\": {}, \"circuit_breakers\": {}}\n\n def get_deck_provider(self, model_name: str = None) -> \"OllamaDeckProvider | None\":\n \"\"\"\n Retourne un OllamaDeckProvider si le Steam Deck est joignable via réseau local.\n Teste automatiquement les deux IPs (Ethernet .43 puis Wi-Fi .139).\n\n Args:\n model_name: Modèle Ollama à utiliser (phi3:mini, gemma2:2b, llama3.2:3b).\n Si None, utilise le modèle par défaut configuré.\n\n Returns:\n OllamaDeckProvider prêt à l'emploi, ou None si le Deck est hors ligne.\n \"\"\"\n # Sélectionner le bon provider selon le modèle demandé\n provider_map = {\n \"phi3:mini\": \"deck_ollama_phi3\",\n \"gemma2:2b\": \"deck_ollama_gemma\",\n \"llama3.2:3b\": \"deck_ollama_llama\",\n }\n provider_key = provider_map.get(model_name, \"deck_ollama\")\n provider = self.providers.get(provider_key)\n\n if provider is None:\n logger.warning(f\"[LLMGateway] Provider Deck '{provider_key}' introuvable.\")\n return None\n\n # Test de disponibilité réseau (timeout 2s, non bloquant)\n if provider.ping_available():\n logger.info(f\"[LLMGateway] Steam Deck Ollama disponible → {provider.host} (modèle: {provider.model_name})\")\n return provider\n else:\n logger.debug(\"[LLMGateway] Steam Deck hors ligne ou Ollama non démarré.\")\n return None\n\n def get_all_cache_status(self) -> dict:\n\n \"\"\"\n Retourne l'état des caches Gemini explicites de tous les providers natifs.\n Utilisé par la route API /api/google-cache-status.\n \"\"\"\n cache_statuses = {}\n for name, provider in self.providers.items():\n if hasattr(provider, 'get_cache_status'):\n status = provider.get_cache_status()\n if status.get(\"active\"):\n cache_statuses[name] = status\n # [Cache sémantique] Stats du cache de réponses LLM (hit-rate, entrées).\n try:\n from core.semantic_cache import get_semantic_cache\n semantic_cache_stats = get_semantic_cache().stats()\n except Exception:\n semantic_cache_stats = None\n return {\n \"active_caches\": len(cache_statuses),\n \"caches\": cache_statuses,\n \"native_provider_available\": GeminiNativeProvider is not None,\n \"semantic_cache\": semantic_cache_stats,\n }\n\ndef migrate_old_gemini_id(model_id: str) -> str:\n \"\"\"Migre les anciens IDs de modèles Gemini vers leurs équivalents opérationnels par défaut.\"\"\"\n mapping = {\n \"gemini-3.5-flash\": \"gemini-3.5-flash-free\",\n \"gemini-3.1-pro\": \"gemini-3.5-flash-free\",\n \"gemini-3.1-flash\": \"gemini-3.5-flash-free\",\n \"gemini-3.1-flash-lite\": \"gemini-3.1-flash-lite-free\",\n \"gemini-2.5-pro\": \"gemini-3.5-flash-free\",\n \"gemini-2.5-flash\": \"gemini-2.5-flash-free\",\n \"gemini-2.0-flash-tts\": \"gemini-3.5-flash-free\"\n }\n return mapping.get(model_id.lower(), model_id)\n\n# [PHASE 1 - D1] Cache de configuration invalidé par mtime.\n# Évite la relecture disque + parsing JSON + logique de migration à CHAQUE appel\n# (config.json était relu sur le hot-path à chaque requête). Thread-safe.\n_CONFIG_CACHE: dict = {\"mtime\": None, \"data\": None}\n_CONFIG_CACHE_LOCK = threading.Lock()\n\n\ndef load_config(force_reload: bool = False) -> dict:\n \"\"\"\n Charge la configuration et garantit la rétrocompatibilité pour les tiers.\n\n [PHASE 1 - D1] Résultat mis en cache et invalidé sur le mtime du fichier.\n L'écriture de migration est protégée par un FileLock pour éviter la corruption\n en cas d'accès concurrent. Retourne toujours une COPIE profonde pour que les\n appelants puissent muter leur dict sans impacter le cache partagé.\n \"\"\"\n config_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), \"config.json\")\n\n # Hit de cache : si le fichier n'a pas changé depuis le dernier parsing, on évite l'I/O.\n try:\n current_mtime = os.path.getmtime(config_file) if os.path.exists(config_file) else None\n except OSError:\n current_mtime = None\n if not force_reload:\n with _CONFIG_CACHE_LOCK:\n if _CONFIG_CACHE[\"data\"] is not None and _CONFIG_CACHE[\"mtime\"] == current_mtime:\n return copy.deepcopy(_CONFIG_CACHE[\"data\"])\n\n default_config = {\n \"planner_model\": \"deepseek-reasoner\",\n \"executor_model\": \"gemini-3.5-flash-free\",\n \"antigravity_model\": \"fort\",\n \"tiers\": {\n \"leger\": [\"local\", \"gemini-3.5-flash-free\", \"deepseek-chat\"],\n \"moyen\": [\"deepseek-chat\", \"gemini-3.5-flash-free\", \"local\"],\n \"fort\": [\"deepseek-reasoner\", \"claude\"],\n \"automatique\": [\"local\", \"gemini-3.5-flash-free\", \"deepseek-chat\", \"deepseek-reasoner\", \"claude\"]\n },\n \"persistent_agents\": {\n \"daemon_model\": \"leger\",\n \"daemon_interval_minutes\": 10,\n \"daemon_enabled\": True,\n \"dreamer_model\": \"leger\",\n \"dreamer_schedule\": \"02:00\",\n \"dreamer_enabled\": True,\n \"dreamer_idle_trigger_hours\": 3,\n \"routines_model\": \"fort\",\n # [#T202] Clone Git dédié aux tâches DreamCoder (branches task/*).\n # Vide = repli sur le dossier du moteur (poste de dev). Sur le Deck\n # (prod overlay sans dépôt légitime), pointer le clone dédié, ex:\n # /home/deck/dev_station/moteur_agents_dreamcoder\n \"dreamcoder_repo_path\": \"\",\n }\n }\n if os.path.exists(config_file):\n try:\n with open(config_file, encoding='utf-8') as f:\n data = json.load(f)\n\n needs_migration = False\n\n # Migration des anciens IDs pour les rôles d'agents\n if \"planner_model\" in data:\n new_val = migrate_old_gemini_id(data[\"planner_model\"])\n if new_val != data[\"planner_model\"]:\n data[\"planner_model\"] = new_val\n needs_migration = True\n if \"executor_model\" in data:\n new_val = migrate_old_gemini_id(data[\"executor_model\"])\n if new_val != data[\"executor_model\"]:\n data[\"executor_model\"] = new_val\n needs_migration = True\n if \"antigravity_model\" in data:\n new_val = migrate_old_gemini_id(data[\"antigravity_model\"])\n if new_val != data[\"antigravity_model\"]:\n data[\"antigravity_model\"] = new_val\n needs_migration = True\n\n # Migration des anciens IDs dans les tiers\n if \"tiers\" not in data:\n data[\"tiers\"] = default_config[\"tiers\"]\n needs_migration = True\n else:\n for tier_key in [\"leger\", \"moyen\", \"fort\", \"automatique\"]:\n if tier_key not in data[\"tiers\"]:\n data[\"tiers\"][tier_key] = default_config[\"tiers\"][tier_key]\n needs_migration = True\n else:\n new_list = [migrate_old_gemini_id(m) for m in data[\"tiers\"][tier_key]]\n if new_list != data[\"tiers\"][tier_key]:\n data[\"tiers\"][tier_key] = new_list\n needs_migration = True\n\n # Injection des defaults pour persistent_agents (daemon, dreamer)\n if \"persistent_agents\" not in data:\n data[\"persistent_agents\"] = default_config[\"persistent_agents\"]\n needs_migration = True\n else:\n # Compléter les clés manquantes avec les defaults\n for pa_key, pa_default in default_config[\"persistent_agents\"].items():\n if pa_key not in data[\"persistent_agents\"]:\n data[\"persistent_agents\"][pa_key] = pa_default\n needs_migration = True\n\n if needs_migration:\n try:\n # [PHASE 1 - D1] Écriture protégée par FileLock (fichier partagé).\n from filelock import FileLock\n with FileLock(config_file + \".lock\", timeout=10):\n with open(config_file, 'w', encoding='utf-8') as wf:\n json.dump(data, wf, indent=2)\n logger.info(\"config.json migré avec succès avec les nouveaux IDs de modèles Gemini Free.\")\n except Exception as we:\n logger.error(f\"Erreur d'écriture de la migration de config.json: {we}\")\n\n # Mise en cache du résultat parsé+migré (clé = mtime courant du fichier).\n try:\n new_mtime = os.path.getmtime(config_file)\n except OSError:\n new_mtime = current_mtime\n with _CONFIG_CACHE_LOCK:\n _CONFIG_CACHE[\"mtime\"] = new_mtime\n _CONFIG_CACHE[\"data\"] = data\n return copy.deepcopy(data)\n except Exception as e:\n logger.error(f\"Erreur lors du chargement de config.json: {e}\")\n return copy.deepcopy(default_config)\n"}}}}