From adc13db9c792de15e493dca0c56acc840ede551e Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Fri, 31 Jul 2026 01:33:22 +0000 Subject: [PATCH 1/2] feat: multi-provider LLM, coder outage handling, 10min backoff --- aios_core/llm_balancer.py | 211 +++++++++++++++++++++---- aios_core/meta_cognitive_self_coder.py | 80 +++------- docker-compose.prod.yml | 3 + run_coder_orchestrator.py | 133 +++++++++++++--- run_telegram_bot.py | 139 ++++++++++++---- 5 files changed, 425 insertions(+), 141 deletions(-) diff --git a/aios_core/llm_balancer.py b/aios_core/llm_balancer.py index 8bc806eb8..796563dac 100644 --- a/aios_core/llm_balancer.py +++ b/aios_core/llm_balancer.py @@ -11,6 +11,7 @@ 3. Fallback-модели если основная недоступна 4. Кэширование "мёртвых" ключей на 5 минут """ +import contextlib import json import os import time @@ -18,6 +19,19 @@ import urllib.error import threading from dataclasses import dataclass, field +from pathlib import Path + +for _env_path in (Path(__file__).resolve().parents[1] / ".env",): + if _env_path.exists(): + for _line in _env_path.read_text(encoding="utf-8").splitlines(): + _line = _line.strip() + if not _line or _line.startswith("#") or "=" not in _line: + continue + _key, _, _value = _line.partition("=") + _key = _key.strip() + _value = _value.strip().strip('"').strip("'") + if _key and _key not in os.environ: + os.environ[_key] = _value @dataclass @@ -59,7 +73,7 @@ def mark_key_error(self, key: APIKey, error: str, cooldown: int = 300): key.last_error = error key.error_count += 1 key.cooldown_until = time.time() + cooldown - print(f" [Balancer] Key {key.key[:8]}... cooled down {cooldown}s: {error}") + print(f" [Balancer] {key.provider} key cooled down {cooldown}s: {error}") class LLMBalancer: @@ -91,15 +105,13 @@ class LLMBalancer: "gpt-4.1-mini", ], }, - "github": { - "base_url": "https://models.inference.ai.azure.com/chat/completions", + "groq": { + "base_url": "https://api.groq.com/openai/v1/chat/completions", "models": [ - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4o", - "gpt-4o-mini", - "DeepSeek-R1", - "Phi-4", + "llama-3.3-70b-versatile", + "llama-3.1-8b-instant", + "mixtral-8x7b-32768", + "gemma2-9b-it", ], }, "deepseek": { @@ -119,6 +131,14 @@ class LLMBalancer: "glm-5", ], }, + "cerebras": { + "base_url": "https://api.cerebras.ai/v1/chat/completions", + "models": [ + "llama-3.3-70b", + "llama-3.1-8b", + "gemma-2-9b", + ], + }, } # Fallback chain: if primary model fails, try these @@ -160,6 +180,90 @@ class LLMBalancer: "gemini-2.0-flash", "meta-llama/llama-4-maverick", ], + "deepseek-chat": [ + "deepseek-reasoner", + "meta-llama/llama-4-maverick", + "gpt-4o-mini", + "glm-4.5-flash", + ], + "deepseek/deepseek-chat-v3-0324": [ + "deepseek-chat", + "meta-llama/llama-4-maverick", + "mistralai/mistral-small-3.2-24b-instruct", + "gpt-4o-mini", + ], + "gpt-4o": [ + "gpt-4o-mini", + "gemini-2.0-flash", + "meta-llama/llama-4-maverick", + "glm-4.5-flash", + ], + "gpt-4.1-mini": [ + "gpt-4o-mini", + "gemini-2.0-flash", + "meta-llama/llama-4-maverick", + ], + "deepseek-reasoner": [ + "deepseek-chat", + "meta-llama/llama-4-maverick", + "gpt-4o-mini", + ], + "deepseek-coder": [ + "deepseek-chat", + "meta-llama/llama-4-maverick", + "gpt-4o-mini", + ], + "glm-4.5": [ + "glm-4.5-flash", + "glm-4.7-flash", + "meta-llama/llama-4-maverick", + ], + "glm-4.7-flash": [ + "glm-4.5-flash", + "gemini-2.0-flash", + "meta-llama/llama-4-maverick", + ], + "glm-5": [ + "glm-4.7-flash", + "glm-4.5-flash", + "gemini-2.0-flash", + "meta-llama/llama-4-maverick", + ], + "llama-3.3-70b-versatile": [ + "llama-3.1-8b-instant", + "meta-llama/llama-4-maverick", + "gemini-2.0-flash", + ], + "llama-3.1-8b-instant": [ + "llama-3.3-70b-versatile", + "meta-llama/llama-4-maverick", + "gemini-2.0-flash", + ], + "mixtral-8x7b-32768": [ + "llama-3.1-8b-instant", + "mistralai/mistral-small-3.2-24b-instruct", + "meta-llama/llama-4-maverick", + ], + "gemma2-9b-it": [ + "llama-3.1-8b-instant", + "gemini-2.0-flash", + "meta-llama/llama-4-maverick", + ], + "gemini-2.5-pro": [ + "gemini-2.5-flash", + "gemini-2.0-flash", + "meta-llama/llama-4-maverick", + "gpt-4o-mini", + ], + "gpt-3.5-turbo": [ + "meta-llama/llama-4-maverick", + "mistralai/mistral-small-3.2-24b-instruct", + "deepseek/deepseek-chat-v3-0324", + "llama-3.1-8b-instant", + "glm-4.5-flash", + "gemini-2.0-flash", + "gpt-4o-mini", + ], } def __init__(self): @@ -170,17 +274,32 @@ def __init__(self): self._provider_stats: dict[str, int] = {} def _load_from_env(self): - """Load providers and keys from environment variables.""" + """Load providers and keys from env plus the external runtime registry.""" + # Import runtime keys without putting secrets in source code or images. + # The registry is mounted at /app/data in Docker and lives in data/ on host. + for key_file in (Path("/app/data/.llm_keys.json"), Path(__file__).resolve().parents[1] / "data/.llm_keys.json"): + try: + runtime = json.loads(key_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + env_prefix = {"openrouter": "OPENROUTER_API_KEY", "gemini": "GEMINI_API_KEY", "openai": "OPENAI_API_KEY", "deepseek": "DEEPSEEK_API_KEY", "zai": "ZAI_API_KEY", "cerebras": "CEREBRAS_API_KEY"} + for provider, keys in runtime.items(): + prefix = env_prefix.get(provider) + if not prefix or not isinstance(keys, list): + continue + for index, key in enumerate(keys, 1): + if key and not os.environ.get(f"{prefix}_{index}"): + os.environ[f"{prefix}_{index}"] = str(key) # OpenRouter keys or_keys = [] # Primary key pk = os.environ.get("OPENROUTER_API_KEY", "") if pk: or_keys.append(APIKey(key=pk, provider="openrouter")) - # Additional keys from env + # Additional keys from env with value dedup for i in range(1, 10): k = os.environ.get(f"OPENROUTER_API_KEY_{i}", "") - if k: + if k and not any(ek.key == k for ek in or_keys): or_keys.append(APIKey(key=k, provider="openrouter")) if or_keys: @@ -227,22 +346,21 @@ def _load_from_env(self): models=self.PROVIDERS["openai"]["models"], ) - # GitHub Models keys (free!) - gh_keys = [] + groq_keys = [] for i in range(1, 10): - k = os.environ.get(f"GITHUB_API_KEY_{i}", "") + k = os.environ.get(f"GROQ_API_KEY_{i}", "") if k: - gh_keys.append(APIKey(key=k, provider="github")) - gk = os.environ.get("GITHUB_API_KEY", "") - if gk and not any(k.key == gk for k in gh_keys): - gh_keys.append(APIKey(key=gk, provider="github")) - - if gh_keys: - self.providers["github"] = Provider( - name="github", - base_url=self.PROVIDERS["github"]["base_url"], - keys=gh_keys, - models=self.PROVIDERS["github"]["models"], + groq_keys.append(APIKey(key=k, provider="groq")) + gqk = os.environ.get("GROQ_API_KEY", "") + if gqk and not any(k.key == gqk for k in groq_keys): + groq_keys.append(APIKey(key=gqk, provider="groq")) + + if groq_keys: + self.providers["groq"] = Provider( + name="groq", + base_url=self.PROVIDERS["groq"]["base_url"], + keys=groq_keys, + models=self.PROVIDERS["groq"]["models"], ) # DeepSeek keys @@ -282,6 +400,24 @@ def _load_from_env(self): models=self.PROVIDERS["zai"]["models"], ) + # Cerebras keys + cerebras_keys = [] + for i in range(1, 10): + k = os.environ.get(f"CEREBRAS_API_KEY_{i}", "") + if k: + cerebras_keys.append(APIKey(key=k, provider="cerebras")) + ck = os.environ.get("CEREBRAS_API_KEY", "") + if ck and not any(k.key == ck for k in cerebras_keys): + cerebras_keys.append(APIKey(key=ck, provider="cerebras")) + + if cerebras_keys: + self.providers["cerebras"] = Provider( + name="cerebras", + base_url=self.PROVIDERS["cerebras"]["base_url"], + keys=cerebras_keys, + models=self.PROVIDERS["cerebras"]["models"], + ) + def add_key(self, provider: str, key: str): """Dynamically add an API key.""" if provider not in self.providers: @@ -383,7 +519,7 @@ def chat(self, messages: list[dict], model: str = "", system: str = "", # Success! self._provider_stats[prov_name] = self._provider_stats.get(prov_name, 0) + 1 - print(f" [Balancer] OK: {prov_name}/{try_model} key={best_key.key[:8]}...") + print(f" [Balancer] OK: {prov_name}/{try_model}") if "choices" in data and data["choices"]: return data["choices"][0]["message"]["content"] @@ -398,7 +534,7 @@ def chat(self, messages: list[dict], model: str = "", system: str = "", continue except urllib.error.HTTPError as e: - last_error = f"{prov_name}/{try_model}: HTTP {e.code} key={best_key.key[:8]}" + last_error = f"{prov_name}/{try_model}: HTTP {e.code}" print(f" [Balancer] {last_error}") if e.code in (402, 429): @@ -406,13 +542,24 @@ def chat(self, messages: list[dict], model: str = "", system: str = "", continue # try next key elif e.code == 404: break # model not on this provider, try next model - elif e.code >= 500: - best_provider.mark_key_error(best_key, f"HTTP {e.code}", cooldown=60) - continue elif e.code == 401: best_provider.mark_key_error(best_key, "Auth failed", cooldown=600) continue + elif e.code == 403: + body = "" + with contextlib.suppress(Exception): + body = e.read().decode(errors="replace") + label = "HTTP 403" + (f" / {body.split(':',1)[0][:80]}" if body else "") + cooldown = 900 if "1010" in body else 600 + best_provider.mark_key_error(best_key, label, cooldown=cooldown) + continue + elif e.code >= 500: + best_provider.mark_key_error(best_key, f"HTTP {e.code}", cooldown=60) + continue else: + last_error = f"{prov_name}/{try_model}: HTTP {e.code}" + print(f" [Balancer] {last_error}") + best_provider.mark_key_error(best_key, f"HTTP {e.code}", cooldown=300) continue except Exception as e: @@ -422,7 +569,7 @@ def chat(self, messages: list[dict], model: str = "", system: str = "", continue self._total_errors += 1 - return f"LLM Error: all providers failed. Last: {last_error}" + return "⚠️ Все LLM-провайдеры временно недоступны. Проверьте квоты и API-ключи." def status(self) -> dict: """Return balancer status.""" diff --git a/aios_core/meta_cognitive_self_coder.py b/aios_core/meta_cognitive_self_coder.py index fcd25855d..7d0b98ebe 100644 --- a/aios_core/meta_cognitive_self_coder.py +++ b/aios_core/meta_cognitive_self_coder.py @@ -72,68 +72,26 @@ def __init__(self, config: CoderConfig): self.config = config def chat(self, messages: list[dict], system: str = "") -> str: - """Send chat completion with multi-provider fallback.""" - all_messages = [] + """Send chat completion with multi-provider fallback via LLMBalancer.""" + from aios_core.llm_balancer import LLMBalancer + + balancer = LLMBalancer() + prompt_messages = [] if system: - all_messages.append({"role": "system", "content": system}) - all_messages.extend(messages) - - # Build endpoint list. Runtime keys are stored outside the image in - # /app/data/.llm_keys.json (or /data on the host). - endpoints = [] - key_files = [Path("/app/data/.llm_keys.json"), Path(self.config.repo_path) / "data/.llm_keys.json"] - seen_keys = set() - for key_file in key_files: - try: - key_data = json.loads(key_file.read_text(encoding="utf-8")) - except (OSError, ValueError): - continue - for key in key_data.get("openrouter", []): - if key and key not in seen_keys: - endpoints.append({ - "url": "https://openrouter.ai/api/v1/chat/completions", - "key": key, - "model": "mistralai/mistral-small-3.2-24b-instruct", - "name": "OpenRouter", - }) - seen_keys.add(key) - - gh_key = os.environ.get("GITHUB_API_KEY", "") - if gh_key and gh_key not in seen_keys: - endpoints.append({ - "url": "https://models.inference.ai.azure.com/chat/completions", - "key": gh_key, - "model": "gpt-4.1-mini", - "name": "GitHub", - }) - seen_keys.add(gh_key) - if self.config.llm_api_key and self.config.llm_api_key not in seen_keys: - endpoints.append({ - "url": self.config.llm_base_url.rstrip("/") + "/chat/completions", - "key": self.config.llm_api_key, - "model": self.config.llm_model, - "name": "Config", - }) - - for ep in endpoints: - try: - payload = json.dumps({ - "model": ep["model"], - "messages": all_messages, - "max_tokens": self.config.max_tokens, - "temperature": self.config.temperature, - }).encode() - req = urllib.request.Request(ep["url"], data=payload, headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {ep['key']}", - }) - with urllib.request.urlopen(req, timeout=120) as resp: - data = json.loads(resp.read()) - if "choices" in data and data["choices"]: - return data["choices"][0]["message"]["content"] - except Exception as e: - log.warning(f"{ep['name']} failed: {e}") - continue + prompt_messages.append({"role": "system", "content": system}) + prompt_messages.extend(messages) + + try: + response = balancer.chat( + messages=prompt_messages, + model=self.config.llm_model, + max_tokens=self.config.max_tokens, + temperature=self.config.temperature, + ) + if response and not response.startswith("⚠️"): + return response + except Exception as e: + log.warning(f"LLMBalancer failed: {e}") raise ValueError("Все LLM endpoints недоступны. Проверьте ключи и квоту провайдера.") diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 9457f44e1..50fc3c9b0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -20,6 +20,7 @@ services: AIOS_SHARDS_DB: /app/data/shards.sqlite AIOS_OLX_DB: /app/data/olx.sqlite OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + GITHUB_API_KEY: ${GITHUB_API_KEY:-} LLM_API_KEY: ${LLM_API_KEY:-${OPENROUTER_API_KEY:-}} LLM_BASE_URL: ${LLM_BASE_URL:-https://openrouter.ai/api/v1} LLM_MODEL: ${LLM_MODEL:-anthropic/claude-3.5-sonnet} @@ -113,6 +114,7 @@ services: AIOS_DEVICES_DB: /app/data/devices.sqlite AIOS_WEBHOOK_URL: ${AIOS_WEBHOOK_URL:-} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + GITHUB_API_KEY: ${GITHUB_API_KEY:-} TELEGRAM_BOT_TOKEN: "8374235817:AAHiLVFkgEC6YHqt8z7q3pPn2HQUlEhxF1E" TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} OLX_CLIENT_ID: ${OLX_CLIENT_ID:-} @@ -142,6 +144,7 @@ services: TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} AIOS_TELEGRAM_TOKEN: "8374235817:AAHiLVFkgEC6YHqt8z7q3pPn2HQUlEhxF1E" OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + GITHUB_API_KEY: ${GITHUB_API_KEY:-} LLM_API_KEY: ${LLM_API_KEY:-${OPENROUTER_API_KEY:-}} OLX_CLIENT_ID: ${OLX_CLIENT_ID:-} AIOS_DB_PATH: /app/data/aios.sqlite diff --git a/run_coder_orchestrator.py b/run_coder_orchestrator.py index 24b9a1673..bea1b7426 100644 --- a/run_coder_orchestrator.py +++ b/run_coder_orchestrator.py @@ -235,6 +235,8 @@ def get_project_context() -> dict: _consecutive_errors = 0 MAX_ERRORS = 5 _previous_issues = [] +_last_llm_error_cycle = 0 +_LLM_ERROR_COOLDOWN_CYCLES = 60 # ~10 min at 10s interval def phase_analyze(llm: LLMClient, ctx: dict, backlog: dict) -> dict: """Phase 1: Deep intelligent analysis of project state.""" @@ -300,6 +302,14 @@ def phase_analyze(llm: LLMClient, ctx: dict, backlog: dict) -> dict: return result except (json.JSONDecodeError, ValueError): pass + except Exception as e: + return { + "health_score": 0, + "summary": f"LLM Error: {e}", + "issues": [], + "opportunities": [], + "priority_task": "Продолжить развитие", + } return { "health_score": 5, @@ -636,9 +646,32 @@ def build_report(cycle_num: int, ctx: dict, analysis: dict, plan: dict, return "\n".join(lines) +def _is_llm_failed_cycle(analysis: dict) -> bool: + asum = str(analysis.get("summary", "")) + return ( + "LLM Error" in asum + or "Все LLM-провайдеры" in asum + or "LLM endpoints недоступны" in asum + or asum.startswith("error") + or analysis.get("health_score") == 0 + ) + + +def _is_llm_error_str(text: str) -> bool: + text = text.lower() + return ( + "Все LLM" in text + or "LLM endpoints" in text + or "token expired" in text + or "payment required" in text + or "insufficient" in text + or "auth failed" in text + ) + + def run_cycle(): """Execute full orchestrator cycle.""" - global _cycle_count + global _cycle_count, _consecutive_errors, _last_llm_error_cycle _cycle_count += 1 now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M") @@ -651,50 +684,112 @@ def run_cycle(): tg_send("❌ Coder Orchestrator\n\nLLM API ключ не настроен") return - # Phase 1: Analyze - print(" [1/5] ANALYZE — анализ проекта...") ctx = get_project_context() backlog = load_backlog() backlog["cycle_count"] = backlog.get("cycle_count", 0) + 1 - analysis = phase_analyze(llm, ctx, backlog) - # Track consecutive LLM errors - global _consecutive_errors - _asum = str(analysis.get("summary", "")) - if "LLM Error" in _asum or _asum.startswith("error"): + + # Phase 1: Analyze + print(" [1/5] ANALYZE — анализ проекта...") + try: + analysis = phase_analyze(llm, ctx, backlog) + except Exception as e: + print(f" [WARN] Phase 1 failed: {e}") + analysis = {"health_score": 0, "summary": f"error:{e}", "issues": [], "opportunities": [], "priority_task": "", "new_tasks": []} + + if _is_llm_failed_cycle(analysis): _consecutive_errors += 1 - print(f" [WARN] Errors: {_consecutive_errors}/{MAX_ERRORS}") + _last_llm_error_cycle = _cycle_count + print(f" [WARN] LLM unavailable. Errors: {_consecutive_errors}/{MAX_ERRORS}") if _consecutive_errors >= MAX_ERRORS: - _stop = chr(9940) + " AUTO-STOP: " + str(MAX_ERRORS) + " errors in a row!" - _stop += chr(10) + "LLM not responding." - _stop += chr(10) + "systemctl start aios-auto-coder" + _stop = ( + chr(9940) + + " AUTO-STOP: " + + str(MAX_ERRORS) + + " errors in a row!\n" + + "LLM not responding.\n" + + "systemctl start aios-auto-coder" + ) tg_send(_stop) import subprocess as _sp3 _sp3.run(["systemctl", "stop", "aios-auto-coder"], timeout=10) sys.exit(1) - else: - _consecutive_errors = 0 + _wait = 600 + print(f" [BACKOFF] Waiting {_wait}s before next cycle") + if _consecutive_errors == 1 or _consecutive_errors % 10 == 0: + tg_send( + chr(9888) + " Coder Orchestrator\n\n" + "LLM временно недоступен. Цикл пропущен.\n" + "Проверьте ключи: /llm_status" + ) + time.sleep(_wait) + return + _consecutive_errors = 0 + print(f" Health: {analysis.get('health_score', '?')}/10") print(f" Issues: {len(analysis.get('issues', []))}") # Phase 2: Plan print(" [2/5] PLAN — составление плана...") - plan = phase_plan(llm, analysis, ctx, backlog) + try: + plan = phase_plan(llm, analysis, ctx, backlog) + except Exception as e: + print(f" [WARN] Phase 2 failed: {e}") + plan = {"action": "monitor", "description": "LLM unavailable", "file": "", "code_needed": False, "instruction": ""} print(f" Action: {plan.get('action', '?')}") print(f" Code needed: {plan.get('code_needed', False)}") # Phase 3: Code print(" [3/5] CODE — генерация/рефакторинг...") - code_result = phase_code(plan) + try: + code_result = phase_code(plan) + except Exception as e: + print(f" [WARN] Phase 3 failed: {e}") + code_result = {"status": "error", "error": str(e), "file": plan.get("file", "")} print(f" Status: {code_result.get('status', '?')}") + if code_result.get("status") == "error" and _is_llm_error_str(code_result.get("error", "")): + _consecutive_errors += 1 + _last_llm_error_cycle = _cycle_count + if _consecutive_errors >= MAX_ERRORS: + _stop = ( + chr(9940) + + " AUTO-STOP: " + + str(MAX_ERRORS) + + " errors in a row!\n" + + "LLM not responding.\n" + + "systemctl start aios-auto-coder" + ) + tg_send(_stop) + import subprocess as _sp3 + _sp3.run(["systemctl", "stop", "aios-auto-coder"], timeout=10) + sys.exit(1) + _wait = 600 + print(f" [BACKOFF] LLM error in code phase. Waiting {_wait}s before next cycle") + if _consecutive_errors == 1 or _consecutive_errors % 10 == 0: + tg_send( + chr(9888) + " Coder Orchestrator\n\n" + "LLM временно недоступен. Цикл пропущен.\n" + "Проверьте ключи: /llm_status" + ) + time.sleep(_wait) + return + _consecutive_errors = 0 # Phase 4: Validate print(" [4/5] VALIDATE — проверка...") - validation = phase_validate(code_result) + try: + validation = phase_validate(code_result) + except Exception as e: + print(f" [WARN] Phase 4 failed: {e}") + validation = {"status": "failed", "reason": str(e)} print(f" Status: {validation.get('status', '?')}") # Phase 5: Commit print(" [5/5] COMMIT — деплой...") - commit_result = phase_commit(code_result, plan, validation) + try: + commit_result = phase_commit(code_result, plan, validation) + except Exception as e: + print(f" [WARN] Phase 5 failed: {e}") + commit_result = {"status": "skipped", "reason": str(e)} print(f" Status: {commit_result.get('status', '?')}") # Build and send report @@ -734,7 +829,7 @@ def run_cycle(): import argparse parser = argparse.ArgumentParser(description="AIOS Coder Orchestrator") parser.add_argument("--once", action="store_true", help="Run once and exit") - parser.add_argument("--interval", type=int, default=10, help="Cycle interval (default: 10s)") + parser.add_argument("--interval", type=int, default=600, help="Cycle interval (default: 600s = 10min)") args = parser.parse_args() print(f"🧠 AIOS Coder Orchestrator v1.0") diff --git a/run_telegram_bot.py b/run_telegram_bot.py index da79b88ac..7fc5d61be 100644 --- a/run_telegram_bot.py +++ b/run_telegram_bot.py @@ -35,6 +35,18 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) +_env_path = Path(__file__).resolve().parent / ".env" +if _env_path.exists(): + for _line in _env_path.read_text(encoding="utf-8").splitlines(): + _line = _line.strip() + if not _line or _line.startswith("#") or "=" not in _line: + continue + _key, _, _value = _line.partition("=") + _key = _key.strip() + _value = _value.strip().strip('\"').strip("'") + if _key and _key not in os.environ: + os.environ[_key] = _value + # --------------------------------------------------------------------------- # Telegram API helpers (zero-dependency) # --------------------------------------------------------------------------- @@ -535,6 +547,17 @@ def parse_command(text: str) -> tuple[str, str]: def _handle_button(api: TelegramAPI, chat_id: int, data: str) -> None: """Handle button press by action name.""" + try: + _handle_button_inner(api, chat_id, data) + except Exception as e: + print(f" [BTN CRASH] {data}: {e}") + import traceback; traceback.print_exc() + try: + api.send_message(chat_id, "Error: " + str(e)[:200]) + except: + pass + +def _handle_button_inner(api: TelegramAPI, chat_id: int, data: str) -> None: reply = None keyboard = None @@ -757,10 +780,13 @@ def _handle_button(api: TelegramAPI, chat_id: int, data: str) -> None: else: api.send_message(chat_id, reply) except Exception as e: + print(f" [BTN SEND ERR] {data}: {e}") try: - api.send_message(chat_id, reply) - except: - pass + api.send_message(chat_id, str(reply)[:3900], parse_mode="") + except Exception as e2: + print(f" [BTN SEND ERR2] {e2}") + else: + print(f" [BTN] no reply generated for: {data}") def _handle_callback(api: TelegramAPI, upd: dict) -> None: @@ -869,6 +895,37 @@ def _handle_callback(api: TelegramAPI, upd: dict) -> None: print(f" → callback {data} (chat {chat_id})") +def _llm_status() -> str: + """Return LLM provider status without consuming credits.""" + import importlib.util as _iu, sys as _sys + try: + spec = _iu.spec_from_file_location("lb_s", "/app/aios_core/llm_balancer.py") + mod = _iu.module_from_spec(spec) + _sys.modules["lb_s"] = mod + spec.loader.exec_module(mod) + b = mod.LLMBalancer() + s = b.status() + lines = [chr(128268) + " LLM Providers", ""] + lines.append("Requests: " + str(s.get("total_requests", 0))) + lines.append("Errors: " + str(s.get("total_errors", 0))) + lines.append("") + for pn, pd in s.get("providers", {}).items(): + a = pd.get("keys_available", 0) + t = pd.get("keys_total", 0) + em = chr(9989) if a > 0 else chr(10060) + lines.append(em + " " + pn.upper() + ": " + str(a) + "/" + str(t) + " keys") + for kk, vv in pd.items(): + if kk.startswith("key_"): + avail = vv.get("available", False) + errs = vv.get("errors", 0) + last = vv.get("last_error", "") + status_em = chr(9989) if avail else chr(10060) + lines.append(" " + status_em + " " + kk + " errors=" + str(errs) + ("" if not last else " last=" + last[:40])) + return "\n".join(lines) + except Exception as e: + return chr(10060) + " " + str(e) + + def _llm_chat(chat_id: int, user_text: str) -> str: """LLM chat with root system access. Uses tool-calling pattern.""" import json as _json, urllib.request as _urllib, os as _os @@ -904,10 +961,17 @@ def _llm_chat(chat_id: int, user_text: str) -> str: messages = [{"role": "system", "content": system}] + _chat_history[chat_id] - # LLM endpoints - + # LLM endpoints: use the shared multi-provider balancer first. + # It loads runtime keys from /app/data/.llm_keys.json and performs + # round-robin/fallback across providers and keys. + _balancer = None + try: + from aios_core.llm_balancer import LLMBalancer as _LLMBalancer + _balancer = _LLMBalancer() + except Exception as _e: + print(f" [LLM] balancer init failed: {_e}") - # Load keys from file (no secrets in code) + # Legacy direct endpoints remain as a last-resort compatibility fallback. endpoints = [] try: with open("/app/data/.llm_keys.json") as _kf: @@ -926,28 +990,43 @@ def _llm_chat(chat_id: int, user_text: str) -> str: # Tool loop: up to 3 command iterations for iteration in range(4): response = None - for url, key, model in endpoints: + if _balancer is not None: try: - payload = _json.dumps({ - "model": model, - "messages": messages, - "max_tokens": 2000, - "temperature": 0.3, - }).encode() - req = _urllib.Request(url, data=payload, headers={ - "Content-Type": "application/json", - "Authorization": "Bearer " + key, - }) - with _urllib.urlopen(req, timeout=90) as resp: - data = _json.loads(resp.read()) - if "choices" in data and data["choices"]: - response = data["choices"][0]["message"]["content"] - break - except Exception: - continue - - if not response: - return "LLM temporarily unavailable." + response = _balancer.chat( + messages[1:], + model=_os.environ.get("LLM_MODEL", "meta-llama/llama-4-maverick"), + system=system, + max_tokens=2000, + temperature=0.3, + ) + print(f" [LLM] balancer response ({len(response or '')} chars)") + except Exception as _e: + print(f" [LLM] balancer failed: {_e}") + if response: + pass + else: + for url, key, model in endpoints: + try: + payload = _json.dumps({ + "model": model, + "messages": messages, + "max_tokens": 2000, + "temperature": 0.3, + }).encode() + req = _urllib.Request(url, data=payload, headers={ + "Content-Type": "application/json", + "Authorization": "Bearer " + key, + }) + with _urllib.urlopen(req, timeout=90) as resp: + data = _json.loads(resp.read()) + if "choices" in data and data["choices"]: + response = data["choices"][0]["message"]["content"] + break + except Exception: + continue + + if not response: + return "LLM temporarily unavailable." # Check if LLM wants to run a command cmd_match = _re.search(r"(.*?)", response, _re.DOTALL) @@ -1143,6 +1222,8 @@ def run_bot(token: str) -> None: elif cmd == "/coder": reply = "🧠 Агент-кодер MetaCognitiveCoder\n\nУправление автономным кодером:" keyboard = CODER_MENU_KEYBOARD + elif cmd == "/llm_status": + reply = _llm_status() elif cmd == "/code": reply = cmd_code_generate(args) elif cmd == "/review": @@ -1174,9 +1255,9 @@ def run_bot(token: str) -> None: # --------------------------------------------------------------------------- if __name__ == "__main__": - TOKEN = os.environ.get("AIOS_TELEGRAM_TOKEN") + TOKEN = os.environ.get("AIOS_TELEGRAM_TOKEN") or os.environ.get("TELEGRAM_BOT_TOKEN") if not TOKEN: - print("❌ Установите AIOS_TELEGRAM_TOKEN") + print("❌ Установите AIOS_TELEGRAM_TOKEN или TELEGRAM_BOT_TOKEN") sys.exit(1) run_bot(TOKEN) From 91fe2b9cb8248df8af796a7a1837f05f559b875f Mon Sep 17 00:00:00 2001 From: JoTalbot Date: Fri, 31 Jul 2026 01:38:37 +0000 Subject: [PATCH 2/2] docs: sync .env.example with current providers and secret slots --- .env.example | 49 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 74ac56dbe..81ad6088f 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,52 @@ LLM_API_KEY=sk-your-key-here LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-3.5-turbo -# Для облегчённого Fly.io API задавайте реальный ключ только через `fly secrets set`. -OPENROUTER_API_KEY= -# === Telegram Bot (для одобрения черновиков) === +# === Telegram Bot === TELEGRAM_BOT_TOKEN=your-bot-token-from-BotFather TELEGRAM_CHAT_ID=your-chat-id +AIOS_TELEGRAM_TOKEN=your-aios-telegram-token + +# === GitHub === +GITHUB_API_KEY=ghp_your-github-token + +# === OpenRouter === +OPENROUTER_API_KEY= +OPENROUTER_API_KEY_2= +OPENROUTER_API_KEY_3= +OPENROUTER_API_KEY_4= + +# === Gemini === +GEMINI_API_KEY= +GEMINI_API_KEY_1= +GEMINI_API_KEY_2= +GEMINI_API_KEY_3= + +# === OpenAI === +OPENAI_API_KEY= +OPENAI_API_KEY_1= +OPENAI_API_KEY_2= +OPENAI_API_KEY_3= + +# === DeepSeek === +DEEPSEEK_API_KEY= +DEEPSEEK_API_KEY_1= +DEEPSEEK_API_KEY_2= +DEEPSEEK_API_KEY_3= + +# === Z.ai === +ZAI_API_KEY= +ZAI_API_KEY_1= +ZAI_API_KEY_2= +ZAI_API_KEY_3= +ZAI_API_KEY_4= + +# === Groq === +GROQ_API_KEY= +GROQ_API_KEY_2= + +# === Cerebras === +CEREBRAS_API_KEY= # === OLX === OLX_CLIENT_ID= @@ -36,3 +76,6 @@ VIBER_AUTH_TOKEN= WHATSAPP_ACCESS_TOKEN= WHATSAPP_PHONE_NUMBER_ID= WHATSAPP_VERIFY_TOKEN= + +# === Grafana === +GRAFANA_PASSWORD=