diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index cfc44e5f2a65..79f036c968c5 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1478,7 +1478,10 @@ def _try_openrouter(explicit_api_key: str = None) -> Tuple[Optional[OpenAI], Opt if pool_present: or_key = explicit_api_key or _pool_runtime_api_key(entry) if not or_key: - _mark_provider_unhealthy("openrouter", ttl=60) + # Missing credentials are a static availability/config condition, not + # a transient provider-health issue. Do NOT poison the unhealthy + # cache here — doing so makes later calls skip OpenRouter even after + # a key appears in env/pool during the same process. return None, None base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL logger.debug("Auxiliary client: OpenRouter via pool") @@ -1487,7 +1490,7 @@ def _try_openrouter(explicit_api_key: str = None) -> Tuple[Optional[OpenAI], Opt or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY") if not or_key: - _mark_provider_unhealthy("openrouter", ttl=60) + # No key configured yet: fall through quietly so other providers may win. return None, None logger.debug("Auxiliary client: OpenRouter") return OpenAI(api_key=or_key, base_url=OPENROUTER_BASE_URL, @@ -1527,11 +1530,8 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: nous = _read_nous_auth() runtime = _resolve_nous_runtime_api(force_refresh=False) if runtime is None and not nous: - logger.warning( - "Auxiliary Nous client unavailable: no Nous authentication found " - "(run: hermes auth)." - ) - _mark_provider_unhealthy("nous", ttl=60) + # No Nous auth/runtime configured: this is simple unavailability, not a + # health failure. Let the chain fall through without poisoning cache. return None, None if runtime is None and nous: # Runtime credential mint failed but stored Nous auth is still present. @@ -4123,6 +4123,7 @@ def _build_call_kwargs( "model": model, "messages": messages, "timeout": timeout, + "stream": False, } fixed_temperature = _fixed_temperature_for_model(model, base_url) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 809d6cd8a030..ee4d22bb7274 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -3115,6 +3115,47 @@ def _run_sync(): response_headers = ( {"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {} ) + accept_header = request.headers.get("Accept", "").lower() + if body.get("stream") is True and "text/event-stream" in accept_header: + response = web.StreamResponse( + status=200, + headers={ + **response_headers, + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + await response.prepare(request) + try: + await response.write(( + "data: " + + json.dumps({ + "event": "response.created", + "run_id": run_id, + "session_id": session_id, + "timestamp": created_at, + }) + + "\n\n" + ).encode()) + while True: + try: + event = await asyncio.wait_for(q.get(), timeout=30.0) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n") + continue + if event is None: + await response.write(b": stream closed\n\n") + break + payload = f"data: {json.dumps(event)}\n\n" + await response.write(payload.encode()) + except Exception as exc: + logger.debug("[api_server] inline SSE stream error for run %s: %s", run_id, exc) + finally: + self._run_streams.pop(run_id, None) + self._run_streams_created.pop(run_id, None) + return response + return web.json_response( {"run_id": run_id, "status": "started"}, status=202, diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 50813c25dc6a..5be49cd46643 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -14,6 +14,7 @@ import tempfile import html as _html import re +from types import SimpleNamespace from typing import Dict, List, Optional, Any logger = logging.getLogger(__name__) @@ -29,6 +30,7 @@ CommandHandler, CallbackQueryHandler, MessageHandler as TelegramMessageHandler, + TypeHandler, ContextTypes, filters, ) @@ -47,6 +49,7 @@ CommandHandler = Any CallbackQueryHandler = Any TelegramMessageHandler = Any + TypeHandler = Any HTTPXRequest = Any filters = None ParseMode = None @@ -101,6 +104,9 @@ class _MockContextTypes: ".gif": "image/gif", } +_TELEGRAM_GUEST_UPDATE_TYPE = "guest_message" +_TELEGRAM_GUEST_CHAT_PREFIX = "guest:" + def check_telegram_requirements() -> bool: """Check if Telegram dependencies are available. @@ -112,7 +118,7 @@ def check_telegram_requirements() -> bool: """ global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton global InlineKeyboardMarkup, LinkPreviewOptions, Application - global CommandHandler, CallbackQueryHandler, TelegramMessageHandler + global CommandHandler, CallbackQueryHandler, TelegramMessageHandler, TypeHandler global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest if TELEGRAM_AVAILABLE: return True @@ -131,7 +137,7 @@ def check_telegram_requirements() -> bool: from telegram.ext import ( Application as _App, CommandHandler as _CH, CallbackQueryHandler as _CQH, - MessageHandler as _MH, + MessageHandler as _MH, TypeHandler as _TH, ContextTypes as _CT, filters as _filters, ) from telegram.constants import ParseMode as _PM, ChatType as _CtT @@ -148,6 +154,7 @@ def check_telegram_requirements() -> bool: CommandHandler = _CH CallbackQueryHandler = _CQH TelegramMessageHandler = _MH + TypeHandler = _TH ContextTypes = _CT filters = _filters ParseMode = _PM @@ -434,6 +441,11 @@ def __init__(self, config: PlatformConfig): # Slash-confirm button state: confirm_id → session_key (for /reload-mcp # and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm). self._slash_confirm_state: Dict[str, str] = {} + # Guest Bot API bridge: answerGuestQuery returns SentGuestMessage with + # an inline_message_id. Cache it per guest query so later progress + # updates/final sends edit the same guest bubble instead of trying to + # answer the one-shot query again. + self._guest_inline_message_ids: Dict[str, str] = {} # Clarify button state: clarify_id → session_key (for the clarify tool's # multiple-choice prompts; see GatewayRunner clarify_callback wiring). self._clarify_state: Dict[str, str] = {} @@ -510,6 +522,54 @@ def _is_callback_user_authorized( allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()} return "*" in allowed_ids or normalized_user_id in allowed_ids + @classmethod + def _allowed_update_types(cls) -> list[Any]: + """Return PTB update types plus Bot API fields not yet exposed by PTB. + + Bot API 10.0 added ``guest_message`` for Guest Bots. Until + python-telegram-bot exposes it in ``Update.ALL_TYPES``, requesting only + ``Update.ALL_TYPES`` silently excludes guest summons from long-polling + and webhook delivery. + + Temporary local patch note: this production checkout intentionally + appends ``guest_message`` here until PTB ships first-class Guest Bot + update support. + """ + updates = list(getattr(Update, "ALL_TYPES", []) or []) + if _TELEGRAM_GUEST_UPDATE_TYPE not in {str(item) for item in updates}: + updates.append(_TELEGRAM_GUEST_UPDATE_TYPE) + return updates + + @classmethod + def _guest_query_id_from_chat_id(cls, chat_id: str) -> Optional[str]: + text = str(chat_id or "") + if text.startswith(_TELEGRAM_GUEST_CHAT_PREFIX): + return text[len(_TELEGRAM_GUEST_CHAT_PREFIX):] or None + return None + + def _guest_inline_message_ids_cache(self) -> Dict[str, str]: + cache = getattr(self, "_guest_inline_message_ids", None) + if not isinstance(cache, dict): + cache = {} + self._guest_inline_message_ids = cache + return cache + + @staticmethod + def _guest_inline_message_id_from_response(response: Any) -> Optional[str]: + if isinstance(response, dict): + inline_id = response.get("inline_message_id") + if inline_id: + return str(inline_id) + # Be permissive while PTB has no typed SentGuestMessage object and + # local tests/mocks may still use old message_id-shaped payloads. + message_id = response.get("message_id") or response.get("id") + return str(message_id) if message_id is not None else None + inline_id = getattr(response, "inline_message_id", None) + if inline_id: + return str(inline_id) + message_id = getattr(response, "message_id", None) or getattr(response, "id", None) + return str(message_id) if message_id is not None else None + @classmethod def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]: if not metadata: @@ -798,7 +858,7 @@ async def _handle_polling_network_error(self, error: Exception) -> None: try: await self._app.updater.start_polling( - allowed_updates=Update.ALL_TYPES, + allowed_updates=self._allowed_update_types(), drop_pending_updates=False, error_callback=self._polling_error_callback_ref, ) @@ -901,7 +961,7 @@ async def _handle_polling_conflict(self, error: Exception) -> None: await self._drain_polling_connections() try: await self._app.updater.start_polling( - allowed_updates=Update.ALL_TYPES, + allowed_updates=self._allowed_update_types(), drop_pending_updates=False, error_callback=self._polling_error_callback_ref, ) @@ -1290,6 +1350,10 @@ def _env_float(name: str, default: float) -> float: filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL, self._handle_media_message )) + # Bot API 10.0 guest messages are not modeled by PTB 22.x yet, so + # catch the raw Update and dispatch only when api_kwargs contains + # the new guest_message payload. + self._app.add_handler(TypeHandler(Update, self._handle_guest_update), group=1) # Handle inline keyboard button callbacks (update prompts) self._app.add_handler(CallbackQueryHandler(self._handle_callback_query)) @@ -1354,7 +1418,7 @@ def _env_float(name: str, default: float) -> float: url_path=webhook_path, webhook_url=webhook_url, secret_token=webhook_secret, - allowed_updates=Update.ALL_TYPES, + allowed_updates=self._allowed_update_types(), drop_pending_updates=True, ) self._webhook_mode = True @@ -1387,7 +1451,7 @@ def _polling_error_callback(error: Exception) -> None: self._polling_error_callback_ref = _polling_error_callback await self._app.updater.start_polling( - allowed_updates=Update.ALL_TYPES, + allowed_updates=self._allowed_update_types(), drop_pending_updates=True, error_callback=_polling_error_callback, ) @@ -1495,6 +1559,84 @@ def _should_thread_reply(self, reply_to: Optional[str], chunk_index: int) -> boo else: # "first" (default) return chunk_index == 0 + async def _answer_guest_query(self, guest_query_id: str, content: str) -> SendResult: + """Reply to a Bot API 10.0 guest query using raw Bot API plumbing. + + PTB 22.x has no ``Bot.answer_guest_query`` helper yet. The private + ``_post`` method is the same escape hatch PTB methods use internally, + and can be removed once PTB exposes first-class Guest Bot support. + + Temporary local patch note: keep this raw ``answerGuestQuery`` bridge + only until python-telegram-bot grows an official helper/API surface for + Guest Bots. + """ + try: + chunks = self.truncate_message(content.strip(), self.MAX_MESSAGE_LENGTH, len_fn=utf16_len) + message_text = chunks[0] if chunks else content.strip() + result = { + "type": "article", + "id": f"hermes-{abs(hash((guest_query_id, message_text))) & 0xffffffff:x}", + "title": "Hermes", + "input_message_content": { + "message_text": message_text, + "disable_web_page_preview": self._disable_link_previews, + }, + } + response = await self._bot._post( + "answerGuestQuery", + data={"guest_query_id": guest_query_id, "result": json.dumps(result)}, + ) + inline_message_id = self._guest_inline_message_id_from_response(response) + if inline_message_id: + self._guest_inline_message_ids_cache()[guest_query_id] = inline_message_id + return SendResult(success=True, message_id=inline_message_id, raw_response=response) + except Exception as exc: + logger.error("[%s] Failed to answer Telegram guest query: %s", self.name, exc, exc_info=True) + return SendResult(success=False, error=str(exc)) + + async def _edit_guest_inline_message( + self, + inline_message_id: str, + content: str, + *, + finalize: bool = False, + ) -> SendResult: + """Edit the single inline message created by ``answerGuestQuery``.""" + if not self._bot: + return SendResult(success=False, error="Not connected") + try: + text = content.strip() + chunks = self.truncate_message(text, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len) + message_text = chunks[0] if chunks else text + data: Dict[str, Any] = { + "inline_message_id": inline_message_id, + "text": self.format_message(message_text) if finalize else message_text, + } + if finalize: + data["parse_mode"] = getattr(ParseMode, "MARKDOWN_V2", "MarkdownV2") + if self._disable_link_previews: + data["disable_web_page_preview"] = True + try: + response = await self._bot._post("editMessageText", data=data) + except Exception as fmt_exc: + if not finalize: + raise + if "not modified" in str(fmt_exc).lower(): + return SendResult(success=True, message_id=inline_message_id) + # MarkdownV2 formatting is best-effort; fall back to raw text + # so a formatting edge case does not strand the guest bubble on + # the last tool-progress line. + data.pop("parse_mode", None) + data["text"] = message_text + response = await self._bot._post("editMessageText", data=data) + return SendResult(success=True, message_id=inline_message_id, raw_response=response) + except Exception as exc: + err = str(exc) + if "not modified" in err.lower(): + return SendResult(success=True, message_id=inline_message_id) + logger.error("[%s] Failed to edit Telegram guest inline message %s: %s", self.name, inline_message_id, exc, exc_info=True) + return SendResult(success=False, error=err) + async def send( self, chat_id: str, @@ -1509,6 +1651,17 @@ async def send( # Skip whitespace-only text to prevent Telegram 400 empty-text errors. if not content or not content.strip(): return SendResult(success=True, message_id=None) + + guest_query_id = self._guest_query_id_from_chat_id(chat_id) + if guest_query_id: + inline_message_id = self._guest_inline_message_ids_cache().get(guest_query_id) + if inline_message_id: + return await self._edit_guest_inline_message( + inline_message_id, + content, + finalize=True, + ) + return await self._answer_guest_query(guest_query_id, content) try: # Format and split message if needed @@ -1708,6 +1861,17 @@ async def edit_message( if not self._bot: return SendResult(success=False, error="Not connected") + guest_query_id = self._guest_query_id_from_chat_id(chat_id) + if guest_query_id: + inline_message_id = message_id or self._guest_inline_message_ids_cache().get(guest_query_id) + if not inline_message_id: + return SendResult(success=False, error="Guest inline message id not available") + return await self._edit_guest_inline_message( + inline_message_id, + content, + finalize=finalize, + ) + # Pre-flight: if content already exceeds the limit, split-and-deliver # without round-tripping a doomed edit. if utf16_len(content) > self.MAX_MESSAGE_LENGTH: @@ -3985,6 +4149,111 @@ def _should_process_message(self, message: Message, *, is_command: bool = False) return True return self._message_matches_mention_patterns(message) + @staticmethod + def _guest_caller_name(caller_user: Optional[Dict[str, Any]]) -> Optional[str]: + if not isinstance(caller_user, dict): + return None + parts = [ + str(caller_user.get("first_name") or "").strip(), + str(caller_user.get("last_name") or "").strip(), + ] + full_name = " ".join(part for part in parts if part) + return full_name or caller_user.get("username") + + @staticmethod + def _raw_guest_message_to_object(raw: Dict[str, Any]) -> Any: + """Build the minimal Message-like object Hermes needs from raw Bot API JSON.""" + def _full_name(data: Dict[str, Any]) -> Optional[str]: + parts = [str(data.get("first_name") or "").strip(), str(data.get("last_name") or "").strip()] + full = " ".join(part for part in parts if part) + return full or data.get("username") + + chat_data = raw.get("chat") or {} + from_data = raw.get("from") or {} + chat = SimpleNamespace( + id=chat_data.get("id"), + type=chat_data.get("type", "private"), + title=chat_data.get("title"), + full_name=_full_name(chat_data), + is_forum=bool(chat_data.get("is_forum", False)), + ) + user = None + if from_data: + user = SimpleNamespace( + id=from_data.get("id"), + username=from_data.get("username"), + full_name=_full_name(from_data), + ) + return SimpleNamespace( + message_id=raw.get("message_id"), + date=raw.get("date"), + chat=chat, + from_user=user, + text=raw.get("text"), + caption=raw.get("caption"), + entities=raw.get("entities") or [], + caption_entities=raw.get("caption_entities") or [], + message_thread_id=raw.get("message_thread_id"), + is_topic_message=bool(raw.get("is_topic_message", False)), + reply_to_message=None, + quote=None, + api_kwargs={k: v for k, v in raw.items() if k not in {"message_id", "date", "chat", "from", "text", "caption"}}, + ) + + async def _handle_guest_update(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle Bot API 10.0 Guest Bot updates while PTB lacks first-class support.""" + raw_guest = getattr(update, _TELEGRAM_GUEST_UPDATE_TYPE, None) + if raw_guest is None: + raw_guest = (getattr(update, "api_kwargs", None) or {}).get(_TELEGRAM_GUEST_UPDATE_TYPE) + if raw_guest is None: + return + if not self._telegram_guest_mode(): + logger.info("[%s] Ignoring Telegram guest_message because guest_mode is disabled", self.name) + return + + try: + if hasattr(raw_guest, "chat"): + guest_message = raw_guest + elif isinstance(raw_guest, dict): + guest_message = self._raw_guest_message_to_object(raw_guest) + else: + guest_message = Message.de_json(raw_guest, None) + except Exception as exc: + logger.warning("[%s] Failed to parse Telegram guest_message: %s", self.name, exc, exc_info=True) + return + + api_kwargs = getattr(guest_message, "api_kwargs", None) or {} + guest_query_id = getattr(guest_message, "guest_query_id", None) or api_kwargs.get("guest_query_id") + if not guest_query_id: + logger.warning("[%s] Dropping guest_message without guest_query_id", self.name) + return + + text = getattr(guest_message, "text", None) or getattr(guest_message, "caption", None) or "" + msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT + event = self._build_message_event(guest_message, msg_type, update_id=getattr(update, "update_id", None)) + event.text = self._clean_bot_trigger_text(event.text) + + caller_user = api_kwargs.get("guest_bot_caller_user") + if isinstance(caller_user, dict): + caller_id = caller_user.get("id") + if caller_id is not None: + event.source.user_id = str(caller_id) + caller_name = self._guest_caller_name(caller_user) + if caller_name: + event.source.user_name = str(caller_name) + + # Route the eventual agent response through answerGuestQuery instead + # of sendMessage. The real target chat is opaque to bots in guest mode. + event.source.chat_id = f"{_TELEGRAM_GUEST_CHAT_PREFIX}{guest_query_id}" + event.source.chat_type = "dm" + logger.info( + "[%s] Received Telegram guest_message from user=%s query=%s", + self.name, + event.source.user_id, + str(guest_query_id)[:12], + ) + await self.handle_message(event) + async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming text messages. diff --git a/gateway/run.py b/gateway/run.py index 458603c3115b..b583bb7d09f6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -64,6 +64,27 @@ _PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0 _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT = 5.0 _TELEGRAM_COMMAND_MENTION_RE = re.compile(r"(? bool: + platform_value = getattr(getattr(source, "platform", None), "value", getattr(source, "platform", None)) + return platform_value == "telegram" and str(getattr(source, "chat_id", "") or "").startswith( + _TELEGRAM_GUEST_CHAT_PREFIX + ) + + +def _effective_tool_progress_mode(source: Any, configured_mode: Any) -> str: + """Guest Bot turns need one editable progress bubble to keep the query alive.""" + if _is_telegram_guest_source(source): + return "new" + return str(configured_mode or "all") + + +def _guest_initial_progress_message(source: Any) -> Optional[str]: + if _is_telegram_guest_source(source): + return "Думаю…" + return None def _telegramize_command_mentions(text: str, platform: Any) -> str: @@ -671,6 +692,21 @@ def _reload_runtime_env_preserving_config_authority() -> None: _AGENT_PENDING_SENTINEL = object() +def _load_credential_pool_for_provider(provider: Optional[str]): + """Best-effort credential pool load for session-scoped runtime overrides.""" + provider = (provider or "").strip().lower() + if not provider: + return None + try: + from agent.credential_pool import load_pool + + pool = load_pool(provider) + return pool if pool and pool.has_credentials() else None + except Exception as exc: + logger.debug("Could not load credential pool for provider %s: %s", provider, exc) + return None + + def _resolve_runtime_agent_kwargs() -> dict: """Resolve provider credentials for gateway-created AIAgent instances. @@ -1842,6 +1878,7 @@ def _resolve_session_agent_runtime( "api_key": override.get("api_key"), "base_url": override.get("base_url"), "api_mode": override.get("api_mode"), + "credential_pool": _load_credential_pool_for_provider(override.get("provider")), } if override_runtime.get("api_key"): logger.debug( @@ -13455,8 +13492,9 @@ async def _enrich_message_with_transcription( if result["success"]: transcript = result["transcript"] enriched_parts.append( - f'[The user sent a voice message~ ' - f'Here\'s what they said: "{transcript}"]' + "[The user sent a voice message. It was auto-transcribed, " + "so punctuation, wording, names, or small details may be " + f"inaccurate. Transcript: \"{transcript}\"]" ) else: error = result.get("error", "unknown error") @@ -14628,6 +14666,8 @@ def _run_still_current() -> bool: if _env_tp and not _tool_progress_configured else (_resolved_tp or _env_tp or "all") ) + progress_mode = _effective_tool_progress_mode(source, progress_mode) + is_guest_turn = _is_telegram_guest_source(source) # Disable tool progress for webhooks - they don't support message editing, # so each progress line would be sent as a separate message. from gateway.config import Platform @@ -14637,6 +14677,7 @@ def _run_still_current() -> bool: # in chat platforms while opting into concise mid-turn updates. interim_assistant_messages_enabled = ( source.platform != Platform.WEBHOOK + and not is_guest_turn and is_truthy_value( display_config.get("interim_assistant_messages"), default=True, @@ -14648,6 +14689,10 @@ def _run_still_current() -> bool: last_tool = [None] # Mutable container for tracking in closure last_progress_msg = [None] # Track last message for dedup repeat_count = [0] # How many times the same message repeated + if progress_queue is not None: + _initial_guest_progress = _guest_initial_progress_message(source) + if _initial_guest_progress: + progress_queue.put(_initial_guest_progress) # Auto-cleanup of temporary progress bubbles (Telegram + any adapter # that implements ``delete_message``). When enabled via @@ -15165,6 +15210,11 @@ def run_sync(): if _plat_streaming is None else bool(_plat_streaming) ) + if is_guest_turn: + # Guest mode has a one-shot query. Keep UX to a single + # tool-progress bubble that is later overwritten by the final + # answer; token/interim streaming would fight that transport. + _streaming_enabled = False _want_stream_deltas = _streaming_enabled _want_interim_messages = interim_assistant_messages_enabled _want_interim_consumer = _want_interim_messages diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c1f68e1c88c9..fdb5dd24a18e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1085,7 +1085,7 @@ def _ensure_hermes_home_managed(home: Path): "stt": { "enabled": True, - "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) + "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "deepgram" | "xai" "local": { "model": "base", # tiny, base, small, medium, large-v3 "language": "", # auto-detect by default; set to "en", "es", "fr", etc. to force @@ -1096,6 +1096,14 @@ def _ensure_hermes_home_managed(home: Path): "mistral": { "model": "voxtral-mini-latest", # voxtral-mini-latest, voxtral-mini-2602 }, + "deepgram": { + "model": "nova-3", # Best general-purpose/WER choice for Russian per Deepgram docs + "language": "ru", # Force Russian instead of default English + "smart_format": True, # Enables punctuation + best-available formatting + "paragraphs": True, # Improves readability for longer voice notes + "utterances": True, # Better semantic segmentation in the API response + "numerals": True, # Convert spoken numbers into numeric form when supported + }, }, "voice": { @@ -2223,6 +2231,13 @@ def _ensure_hermes_home_managed(home: Path): "password": True, "category": "tool", }, + "DEEPGRAM_API_KEY": { + "description": "Deepgram API key for speech-to-text transcription (STT)", + "prompt": "Deepgram API key", + "url": "https://console.deepgram.com/", + "password": True, + "category": "tool", + }, "GITHUB_TOKEN": { "description": "GitHub token for Skills Hub (higher API rate limits, skill publish)", "prompt": "GitHub Token", @@ -5030,7 +5045,7 @@ def set_config_value(key: str, value: str): # Check if it's an API key (goes to .env) api_keys = [ 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', - 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', + 'DEEPGRAM_API_KEY', 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index fec1f33d0925..040510f64cf7 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1442,7 +1442,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool: except Exception: _cp_model_ids = curated.get(_cp.slug, []) else: - _cp_model_ids = curated.get(_cp.slug, []) + _cp_model_ids = provider_model_ids(_cp.slug) or curated.get(_cp.slug, []) _cp_total = len(_cp_model_ids) _cp_top = _cp_model_ids[:max_models] diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 9243b3f6f849..7115e8d29b44 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -11,7 +11,10 @@ and additional env vars that models.dev doesn't track. Small dict, maintained here. -3. **User config** (``providers:`` section in config.yaml) — user-defined +3. **Provider plugins** (``ProviderProfile`` registry) — bundled and user + provider plugins discovered from ``plugins/model-providers``. + +4. **User config** (``providers:`` section in config.yaml) — user-defined endpoints and overrides. Merged on top of everything else. Other modules import from this file. No parallel registries. @@ -402,7 +405,55 @@ def normalize_provider(name: str) -> str: corresponds to a known provider. """ key = name.strip().lower() - return ALIASES.get(key, key) + canonical = ALIASES.get(key) + if canonical: + return canonical + profile = _get_plugin_provider_profile(key) + if profile is not None: + return profile.name + return key + + +def _get_plugin_provider_profile(name: str): + """Return a provider plugin profile by name or alias, if one exists.""" + key = (name or "").strip().lower() + if not key: + return None + try: + from providers import get_provider_profile + except Exception: + return None + try: + return get_provider_profile(key) + except Exception: + return None + + +def _transport_from_profile_api_mode(api_mode: str) -> str: + """Map ProviderProfile.api_mode to ProviderDef.transport.""" + mode = (api_mode or "chat_completions").strip().lower() + if mode == "anthropic_messages": + return "anthropic_messages" + if mode == "codex_responses": + return "codex_responses" + if mode == "bedrock_converse": + return "bedrock_converse" + return "openai_chat" + + +def _provider_def_from_plugin_profile(profile) -> ProviderDef: + """Convert a ProviderProfile into the ProviderDef shape used by switchers.""" + return ProviderDef( + id=profile.name, + name=profile.display_name or profile.name, + transport=_transport_from_profile_api_mode(profile.api_mode), + api_key_env_vars=tuple(profile.env_vars or ()), + base_url=profile.base_url or "", + is_aggregator=False, + auth_type=profile.auth_type or "api_key", + doc=profile.description or "", + source="plugin", + ) def get_provider(name: str) -> Optional[ProviderDef]: @@ -472,6 +523,10 @@ def get_provider(name: str) -> Optional[ProviderDef]: source="hermes", ) + plugin_profile = _get_plugin_provider_profile(canonical) + if plugin_profile is not None: + return _provider_def_from_plugin_profile(plugin_profile) + return None diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 96c02feb732c..e98c5fbb9730 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -519,6 +519,14 @@ def do_install(identifier: str, category: str = "", force: bool = False, if len(id_parts) >= 3: category = id_parts[1] + # Official optional skills are searched/listed by their frontmatter name. + # Keep install-time naming aligned even when the source directory uses a + # different slug (e.g. here-now/ -> name: here.now). + if bundle.source == "official" and meta is not None: + meta_name = getattr(meta, "name", "") + if isinstance(meta_name, str) and meta_name.strip(): + bundle.name = meta_name.strip() + # Check if already installed lock = HubLockFile() existing = lock.get_installed(bundle.name) @@ -772,7 +780,8 @@ def do_list(source_filter: str = "all", ``skills.disabled`` list because ``-p`` swaps ``HERMES_HOME`` at process start. No explicit profile flag needed here. """ - from tools.skills_hub import HubLockFile, ensure_hub_dirs + from agent.skill_utils import parse_frontmatter + from tools.skills_hub import HubLockFile, SKILLS_DIR, ensure_hub_dirs from tools.skills_sync import _read_manifest from tools.skills_tool import _find_all_skills from agent.skill_utils import get_disabled_skill_names @@ -780,7 +789,24 @@ def do_list(source_filter: str = "all", c = console or _console ensure_hub_dirs() lock = HubLockFile() - hub_installed = {e["name"]: e for e in lock.list_installed()} + hub_installed = {} + for entry in lock.list_installed(): + entry_name = entry.get("name") + if entry_name: + hub_installed[entry_name] = entry + + install_path = entry.get("install_path") + if not install_path: + continue + skill_md = SKILLS_DIR / install_path / "SKILL.md" + try: + frontmatter, _body = parse_frontmatter(skill_md.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError): + continue + frontmatter_name = frontmatter.get("name") + if isinstance(frontmatter_name, str) and frontmatter_name.strip(): + hub_installed.setdefault(frontmatter_name.strip(), entry) + builtin_names = set(_read_manifest()) # Pull ALL skills (including disabled ones) so we can annotate status. diff --git a/run_agent.py b/run_agent.py index 2931c4fa3493..567f414005f3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2860,6 +2860,12 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod new_norm = (new_provider or "").strip().lower() fallback_chain = list(getattr(self, "_fallback_chain", []) or []) if old_norm and new_norm and old_norm != new_norm: + stripped = self._strip_provider_reasoning_state(getattr(self, "messages", None)) + if stripped: + logging.info( + "Provider switch scrubbed %d provider-specific reasoning fields from in-memory history", + stripped, + ) fallback_chain = [ entry for entry in fallback_chain if (entry.get("provider") or "").strip().lower() not in {old_norm, new_norm} @@ -10528,6 +10534,36 @@ def _copy_reasoning_content_for_api(self, source_msg: dict, api_msg: dict) -> No # context compaction). Don't pass null to the API. api_msg.pop("reasoning_content", None) + @staticmethod + def _strip_provider_reasoning_state(messages: Optional[list]) -> int: + """Remove provider-specific reasoning continuity blobs from assistant turns. + + Some providers persist opaque state that must only be replayed back to the + SAME backend on the next turn: + + - ``reasoning_details`` (Anthropic/OpenRouter signatures, etc.) + - ``codex_reasoning_items`` (Responses API ``encrypted_content`` blobs) + - ``codex_message_items`` (Responses API exact assistant replay items) + + When a session switches providers mid-conversation, replaying those blobs + into a different backend can trigger 400s (for example xAI rejecting + encrypted_content produced by another Responses-compatible provider). + + Returns the number of fields removed across all assistant messages. + """ + if not isinstance(messages, list): + return 0 + + stripped = 0 + for msg in messages: + if not isinstance(msg, dict) or msg.get("role") != "assistant": + continue + for key in ("reasoning_details", "codex_reasoning_items", "codex_message_items"): + if key in msg: + msg.pop(key, None) + stripped += 1 + return stripped + @staticmethod def _sanitize_tool_calls_for_strict_api(api_msg: dict) -> dict: """Strip Codex Responses API fields from tool_calls for strict providers. @@ -12891,6 +12927,7 @@ def run_conversation( nous_auth_retry_attempted=False copilot_auth_retry_attempted=False thinking_sig_retry_attempted = False + xai_encrypted_retry_attempted = False image_shrink_retry_attempted = False oauth_1m_beta_retry_attempted = False llama_cpp_grammar_retry_attempted = False @@ -14068,6 +14105,42 @@ def _stop_spinner(): print(f"{self.log_prefix} • Legacy cleanup: hermes config set ANTHROPIC_TOKEN \"\"") print(f"{self.log_prefix} • Clear stale keys: hermes config set ANTHROPIC_API_KEY \"\"") + # ── xAI encrypted-content recovery ────────────────────── + # Grok Responses requires ``reasoning.encrypted_content`` + # blobs to come back to the SAME provider that issued them. + # Mid-session provider switches can leave stale Responses + # history from OpenAI/Codex/CommandCode in memory; replaying + # that into xAI yields HTTP 400 "Could not decrypt the + # provided encrypted_content". Recovery: strip all + # provider-specific reasoning continuity fields from the + # replay history and retry once. + _err_text_lower = str(api_error).lower() + _xai_encrypted_error = ( + "could not decrypt the provided encrypted_content" in _err_text_lower + or "invalid_encrypted_content" in _err_text_lower + ) + if ( + self.api_mode == "codex_responses" + and (self.provider == "xai" or self._base_url_hostname == "api.x.ai") + and status_code == 400 + and _xai_encrypted_error + and not xai_encrypted_retry_attempted + ): + xai_encrypted_retry_attempted = True + stripped_history = self._strip_provider_reasoning_state(messages) + stripped_api = self._strip_provider_reasoning_state(api_messages) + self._vprint( + f"{self.log_prefix}⚠️ xAI rejected encrypted reasoning from earlier provider history — stripped provider-specific reasoning state and retrying...", + force=True, + ) + logger.warning( + "%sxAI encrypted_content recovery: stripped %d fields from history and %d fields from api_messages", + self.log_prefix, + stripped_history, + stripped_api, + ) + continue + # ── Thinking block signature recovery ───────────────── # Anthropic signs thinking blocks against the full turn # content. Any upstream mutation (context compression, diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 96f5802f8399..09eaf15ecf1c 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -33,13 +33,18 @@ @pytest.fixture(autouse=True) def _clean_env(monkeypatch): - """Strip provider env vars so each test starts clean.""" + """Strip provider env vars and reset provider-health globals for each test.""" + from agent.auxiliary_client import _reset_aux_unhealthy_cache + + _reset_aux_unhealthy_cache() for key in ( "OPENROUTER_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL", "LLM_MODEL", "NOUS_INFERENCE_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", ): monkeypatch.delenv(key, raising=False) + yield + _reset_aux_unhealthy_cache() @pytest.fixture @@ -2481,6 +2486,12 @@ def test_none_tools_unchanged(self): ) assert "tools" not in kwargs + def test_auxiliary_calls_are_non_streaming(self): + kwargs = _build_call_kwargs( + provider="custom", model="deepseek-v4-pro", messages=[] + ) + assert kwargs["stream"] is False + @pytest.fixture(autouse=True) def _clean_env(monkeypatch): @@ -2701,6 +2712,30 @@ def test_alias_normalization(self): _mark_provider_unhealthy("codex") assert _is_provider_unhealthy("openai-codex") is True + def test_try_openrouter_without_key_does_not_mark_unhealthy(self, monkeypatch): + """Missing credentials are not a payment failure and must not poison the cache.""" + from agent.auxiliary_client import _try_openrouter, _is_provider_unhealthy + + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + client, model = _try_openrouter() + + assert client is None + assert model is None + assert _is_provider_unhealthy("openrouter") is False + + def test_try_nous_without_auth_does_not_mark_unhealthy(self): + """Absent Nous auth should fall through quietly, not mark the provider unhealthy.""" + from agent.auxiliary_client import _try_nous, _is_provider_unhealthy + + with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client._resolve_nous_runtime_api", return_value=None), \ + patch("agent.nous_rate_guard.nous_rate_limit_remaining", return_value=None): + client, model = _try_nous() + + assert client is None + assert model is None + assert _is_provider_unhealthy("nous") is False + def test_resolve_auto_skips_unhealthy_step2(self): """_resolve_auto Step-2 chain skips unhealthy providers.""" from agent.auxiliary_client import ( diff --git a/tests/gateway/test_api_server_runs.py b/tests/gateway/test_api_server_runs.py index bdb00d74a7ba..06e6446362d9 100644 --- a/tests/gateway/test_api_server_runs.py +++ b/tests/gateway/test_api_server_runs.py @@ -191,6 +191,30 @@ async def test_start_with_valid_auth(self, auth_adapter): ) assert resp.status == 202 + @pytest.mark.asyncio + async def test_start_stream_true_with_sse_accept_returns_event_stream(self, adapter): + app = _create_runs_app(adapter) + async with TestClient(TestServer(app)) as cli: + with patch.object(adapter, "_create_agent") as mock_create: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "Hello from runs"} + mock_agent.session_prompt_tokens = 3 + mock_agent.session_completion_tokens = 4 + mock_agent.session_total_tokens = 7 + mock_create.return_value = mock_agent + + resp = await cli.post( + "/v1/runs", + json={"input": "hello", "stream": True}, + headers={"Accept": "text/event-stream"}, + ) + + assert resp.status == 200 + assert resp.headers["Content-Type"].startswith("text/event-stream") + body = await resp.text() + assert "run.completed" in body + assert "Hello from runs" in body + # --------------------------------------------------------------------------- # GET /v1/runs/{run_id} — poll run status diff --git a/tests/gateway/test_session_model_override_routing.py b/tests/gateway/test_session_model_override_routing.py index 3530744e2236..c276ece1e41d 100644 --- a/tests/gateway/test_session_model_override_routing.py +++ b/tests/gateway/test_session_model_override_routing.py @@ -82,10 +82,20 @@ def _explode_runtime_resolution(): ) +class _FakePool: + pass + + def test_run_agent_prefers_session_override_over_global_runtime(monkeypatch): monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", _explode_runtime_resolution) + fake_pool = _FakePool() + monkeypatch.setattr( + gateway_run, + "_load_credential_pool_for_provider", + lambda provider: fake_pool if provider == "openai-codex" else None, + ) fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = _CapturingAgent @@ -123,6 +133,7 @@ def test_run_agent_prefers_session_override_over_global_runtime(monkeypatch): assert _CapturingAgent.last_init["api_mode"] == "codex_responses" assert _CapturingAgent.last_init["base_url"] == "https://chatgpt.com/backend-api/codex" assert _CapturingAgent.last_init["api_key"] == "***" + assert _CapturingAgent.last_init["credential_pool"] is fake_pool assert _CapturingAgent.last_init["reasoning_config"] == {"enabled": True, "effort": "high"} @@ -130,6 +141,12 @@ def test_run_agent_prefers_session_override_over_global_runtime(monkeypatch): async def test_background_task_prefers_session_override_over_global_runtime(monkeypatch): monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", _explode_runtime_resolution) + fake_pool = _FakePool() + monkeypatch.setattr( + gateway_run, + "_load_credential_pool_for_provider", + lambda provider: fake_pool if provider == "openai-codex" else None, + ) fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = _CapturingAgent @@ -162,6 +179,7 @@ async def test_background_task_prefers_session_override_over_global_runtime(monk assert _CapturingAgent.last_init["api_mode"] == "codex_responses" assert _CapturingAgent.last_init["base_url"] == "https://chatgpt.com/backend-api/codex" assert _CapturingAgent.last_init["api_key"] == "***" + assert _CapturingAgent.last_init["credential_pool"] is fake_pool assert _CapturingAgent.last_init["reasoning_config"] == {"enabled": True, "effort": "high"} def test_gateway_auth_fallback_uses_fallback_model_from_config(tmp_path, monkeypatch): @@ -217,4 +235,3 @@ def fake_resolve_runtime_provider(*, requested=None, explicit_base_url=None, exp assert model == "minimax/minimax-m2.7" assert runtime_kwargs["provider"] == "openrouter" assert runtime_kwargs["api_key"] == "sk-openrouter" - diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 282320ad10f6..595762666cc3 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -1,4 +1,5 @@ import json +import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock @@ -43,6 +44,7 @@ def _make_adapter( adapter._pending_text_batches = {} adapter._pending_text_batch_tasks = {} adapter._text_batch_delay_seconds = 0.01 + adapter._disable_link_previews = False adapter._mention_patterns = adapter._compile_mention_patterns() return adapter @@ -389,3 +391,95 @@ def test_config_bridges_telegram_ignored_threads(monkeypatch, tmp_path): assert config is not None assert __import__("os").environ["TELEGRAM_IGNORED_THREADS"] == "31,42" + + +def test_allowed_update_types_include_raw_guest_message(): + adapter = _make_adapter(guest_mode=True) + + assert "guest_message" in adapter._allowed_update_types() + + +def test_raw_guest_update_routes_via_guest_query_id_and_caller_user(): + adapter = _make_adapter(guest_mode=True) + adapter.handle_message = AsyncMock() + raw_guest = { + "message_id": 123, + "date": 0, + "chat": {"id": 555, "type": "private", "first_name": "Target"}, + "from": {"id": 111, "is_bot": False, "first_name": "Sender"}, + "text": "@hermes_bot ping", + "guest_query_id": "guest-query-1", + "guest_bot_caller_user": {"id": 273403055, "is_bot": False, "first_name": "Maxim"}, + } + update = SimpleNamespace(update_id=77, api_kwargs={"guest_message": raw_guest}) + + asyncio.run(adapter._handle_guest_update(update, SimpleNamespace())) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "ping" + assert event.source.chat_id == "guest:guest-query-1" + assert event.source.user_id == "273403055" + assert event.source.user_name == "Maxim" + + +def test_send_guest_chat_uses_answer_guest_query(): + adapter = _make_adapter(guest_mode=True) + adapter._bot = SimpleNamespace(_post=AsyncMock(return_value={"inline_message_id": "inline-42"})) + + result = asyncio.run(adapter.send("guest:guest-query-1", "hello from Hermes")) + + assert result.success is True + assert result.message_id == "inline-42" + adapter._bot._post.assert_awaited_once() + endpoint = adapter._bot._post.await_args.args[0] + data = adapter._bot._post.await_args.kwargs["data"] + assert endpoint == "answerGuestQuery" + assert data["guest_query_id"] == "guest-query-1" + payload = json.loads(data["result"]) + assert payload["type"] == "article" + assert payload["input_message_content"]["message_text"] == "hello from Hermes" + + +def test_guest_chat_second_send_edits_existing_inline_message(): + adapter = _make_adapter(guest_mode=True) + adapter._bot = SimpleNamespace(_post=AsyncMock(return_value={"inline_message_id": "inline-42"})) + + first = asyncio.run(adapter.send("guest:guest-query-1", "Думаю…")) + second = asyncio.run(adapter.send("guest:guest-query-1", "готовый ответ")) + + assert first.success is True + assert second.success is True + assert adapter._bot._post.await_count == 2 + first_call, second_call = adapter._bot._post.await_args_list + assert first_call.args[0] == "answerGuestQuery" + assert second_call.args[0] == "editMessageText" + assert second_call.kwargs["data"]["inline_message_id"] == "inline-42" + assert second_call.kwargs["data"]["text"] == "готовый ответ" + + +def test_guest_edit_message_uses_inline_message_id(): + adapter = _make_adapter(guest_mode=True) + adapter._bot = SimpleNamespace(_post=AsyncMock(return_value=True)) + + result = asyncio.run(adapter.edit_message("guest:guest-query-1", "inline-42", "🔎 session_search...")) + + assert result.success is True + adapter._bot._post.assert_awaited_once() + endpoint = adapter._bot._post.await_args.args[0] + data = adapter._bot._post.await_args.kwargs["data"] + assert endpoint == "editMessageText" + assert data["inline_message_id"] == "inline-42" + assert data["text"] == "🔎 session_search..." + + +def test_telegram_guest_sources_force_new_tool_progress_mode(): + from gateway.run import _effective_tool_progress_mode + + source = SimpleNamespace(platform=Platform.TELEGRAM, chat_id="guest:guest-query-1") + + assert _effective_tool_progress_mode(source, "all") == "new" + assert _effective_tool_progress_mode(source, "off") == "new" + + normal_source = SimpleNamespace(platform=Platform.TELEGRAM, chat_id="273403055") + assert _effective_tool_progress_mode(normal_source, "all") == "all" diff --git a/tests/hermes_cli/test_skills_hub.py b/tests/hermes_cli/test_skills_hub.py index fa611e1a587d..1709140a5846 100644 --- a/tests/hermes_cli/test_skills_hub.py +++ b/tests/hermes_cli/test_skills_hub.py @@ -524,3 +524,74 @@ def test_existing_categories_returns_empty_when_skills_dir_missing(monkeypatch, from hermes_cli.skills_hub import _existing_categories assert _existing_categories() == [] + + +def test_official_install_prefers_frontmatter_name(monkeypatch, tmp_path, hub_env): + class _OfficialSource: + def inspect(self, identifier): + return type("Meta", (), { + "extra": {}, + "identifier": identifier, + "name": "here.now", + "path": "productivity/here-now", + })() + + def fetch(self, identifier): + return type("Bundle", (), { + "name": "here-now", + "files": {"SKILL.md": "---\nname: here.now\ndescription: ok\n---\n# body\n"}, + "source": "official", + "identifier": identifier, + "trust_level": "builtin", + "metadata": {}, + })() + + installs = _install_mocks(monkeypatch, tmp_path, _OfficialSource) + + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + do_install( + "official/productivity/here-now", + console=console, + skip_confirm=True, + ) + + assert installs == [{"name": "here.now", "category": "productivity"}] + + +def test_do_list_matches_hub_entry_by_installed_frontmatter_name(monkeypatch, tmp_path, hub_env): + import tools.skills_hub as hub + import tools.skills_sync as skills_sync + import tools.skills_tool as skills_tool + + install_dir = hub.SKILLS_DIR / "productivity" / "here-now" + install_dir.mkdir(parents=True) + (install_dir / "SKILL.md").write_text( + "---\nname: here.now\ndescription: ok\n---\n# body\n", + encoding="utf-8", + ) + + monkeypatch.setattr( + hub, + "HubLockFile", + lambda: _DummyLockFile([ + { + "name": "here-now", + "source": "official", + "trust_level": "builtin", + "install_path": "productivity/here-now", + } + ]), + ) + monkeypatch.setattr( + skills_tool, + "_find_all_skills", + lambda **_kwargs: [{"name": "here.now", "category": "productivity", "description": "publish"}], + ) + monkeypatch.setattr(skills_sync, "_read_manifest", lambda: {}) + + output = _capture() + + assert "here.now" in output + assert "official" in output + assert "1 hub-installed, 0 builtin, 0 local" in output diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 5652281eb424..a19ad276bc45 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -1965,3 +1965,84 @@ def test_preflight_codex_input_deduplicates_reasoning_ids(monkeypatch): # IDs must be stripped — with store=False the API 404s on id lookups. for it in reasoning_items: assert "id" not in it + + +def test_switch_model_scrubs_provider_reasoning_state_on_provider_change(monkeypatch): + agent = _build_agent(monkeypatch) + agent.context_compressor = None + agent.messages = [ + { + "role": "assistant", + "content": "Old provider turn", + "reasoning_details": [{"type": "reasoning.encrypted_content", "encrypted_content": "opaque"}], + "codex_reasoning_items": [{"type": "reasoning", "encrypted_content": "enc_a"}], + "codex_message_items": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}], + }, + {"role": "user", "content": "keep context"}, + ] + agent._fallback_chain = [ + {"provider": "openai-codex", "model": "gpt-5-codex"}, + {"provider": "xai", "model": "grok-4.3"}, + {"provider": "copilot", "model": "gpt-5.4"}, + ] + monkeypatch.setattr(agent, "_create_openai_client", lambda kwargs, reason, shared: object()) + monkeypatch.setattr(agent, "_anthropic_prompt_cache_policy", lambda **kwargs: (False, False)) + monkeypatch.setattr(agent, "_ensure_lmstudio_runtime_loaded", lambda: None) + + agent.switch_model( + "grok-4.3", + "xai", + api_key="xai-token", + base_url="https://api.x.ai/v1", + api_mode="codex_responses", + ) + + assistant_msg = agent.messages[0] + assert agent.provider == "xai" + assert "reasoning_details" not in assistant_msg + assert "codex_reasoning_items" not in assistant_msg + assert "codex_message_items" not in assistant_msg + assert agent._fallback_chain == [{"provider": "copilot", "model": "gpt-5.4"}] + + +def test_run_conversation_xai_encrypted_content_error_retries_after_scrub(monkeypatch): + agent = _build_agent(monkeypatch) + agent.provider = "xai" + agent.api_mode = "codex_responses" + agent.base_url = "https://api.x.ai/v1" + agent._base_url_hostname = "api.x.ai" + calls = {"api": 0} + history = [ + { + "role": "assistant", + "content": "Old provider turn", + "reasoning_details": [{"type": "reasoning.encrypted_content", "encrypted_content": "opaque"}], + "codex_reasoning_items": [{"type": "reasoning", "encrypted_content": "enc_a"}], + "codex_message_items": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}], + } + ] + + class _BadRequestError(RuntimeError): + def __init__(self): + super().__init__( + "Error code: 400 - Could not decrypt the provided encrypted_content. " + "Ensure the value is the unmodified encrypted_content from a previous response." + ) + self.status_code = 400 + + def _fake_api_call(api_kwargs): + calls["api"] += 1 + if calls["api"] == 1: + raise _BadRequestError() + return _codex_message_response("Recovered after xAI scrub") + + monkeypatch.setattr(agent, "_interruptible_api_call", _fake_api_call) + + result = agent.run_conversation("Say OK", conversation_history=history) + + assert calls["api"] == 2 + assert result["completed"] is True + assert result["final_response"] == "Recovered after xAI scrub" + assert "reasoning_details" not in result["messages"][0] + assert "codex_reasoning_items" not in result["messages"][0] + assert "codex_message_items" not in result["messages"][0] diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index 7f83565b5d8d..d0ee5ae13225 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -48,6 +48,7 @@ def clean_env(monkeypatch): monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("GROQ_API_KEY", raising=False) + monkeypatch.delenv("DEEPGRAM_API_KEY", raising=False) monkeypatch.delenv("MISTRAL_API_KEY", raising=False) monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False) monkeypatch.delenv("HERMES_LOCAL_STT_LANGUAGE", raising=False) @@ -1405,3 +1406,56 @@ def test_no_env_var_uses_list_mode(self, monkeypatch): monkeypatch.delenv(LOCAL_STT_COMMAND_ENV, raising=False) use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) assert use_shell is False + + +# ============================================================================ +# _get_provider — Deepgram +# ============================================================================ + +class TestGetProviderDeepgram: + """Deepgram-specific provider selection tests.""" + + def test_deepgram_when_key_set(self, monkeypatch): + monkeypatch.setenv("DEEPGRAM_API_KEY", "dg-test") + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "deepgram"}) == "deepgram" + + def test_deepgram_explicit_no_key_returns_none(self, monkeypatch): + """Explicit deepgram with no key returns none — no cross-provider fallback.""" + monkeypatch.delenv("DEEPGRAM_API_KEY", raising=False) + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "deepgram"}) == "none" + + +# ============================================================================ +# transcribe_audio — Deepgram dispatch +# ============================================================================ + +class TestTranscribeAudioDeepgramDispatch: + def test_dispatches_to_deepgram(self, sample_ogg): + config = {"provider": "deepgram", "deepgram": {"model": "nova-3"}} + with patch("tools.transcription_tools._load_stt_config", return_value=config), \ + patch("tools.transcription_tools._get_provider", return_value="deepgram"), \ + patch( + "tools.transcription_tools._transcribe_deepgram", + return_value={"success": True, "transcript": "hi", "provider": "deepgram"}, + ) as mock_deepgram: + from tools.transcription_tools import transcribe_audio + result = transcribe_audio(sample_ogg) + + assert result["success"] is True + assert result["provider"] == "deepgram" + mock_deepgram.assert_called_once_with(sample_ogg, "nova-3") + + def test_config_model_used_for_deepgram(self, sample_ogg): + config = {"provider": "deepgram", "deepgram": {"model": "nova-3-medical"}} + with patch("tools.transcription_tools._load_stt_config", return_value=config), \ + patch("tools.transcription_tools._get_provider", return_value="deepgram"), \ + patch( + "tools.transcription_tools._transcribe_deepgram", + return_value={"success": True, "transcript": "hi", "provider": "deepgram"}, + ) as mock_deepgram: + from tools.transcription_tools import transcribe_audio + transcribe_audio(sample_ogg, model=None) + + assert mock_deepgram.call_args[0][1] == "nova-3-medical" diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 35cec56e08e8..4b47531decaa 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -2433,8 +2433,20 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]: if not files: return None - # Determine category from directory structure + # Prefer the declared frontmatter name when present so official skills + # install under the same identifier surfaced by search/list/inspect. name = skill_dir.name + skill_doc = files.get("SKILL.md") + if isinstance(skill_doc, bytes): + try: + skill_doc = skill_doc.decode("utf-8") + except UnicodeDecodeError: + skill_doc = "" + if isinstance(skill_doc, str): + fm = self._parse_frontmatter(skill_doc) + frontmatter_name = fm.get("name") + if isinstance(frontmatter_name, str) and frontmatter_name.strip(): + name = frontmatter_name.strip() return SkillBundle( name=name, diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index d741530d3582..84998dd94989 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -2,13 +2,16 @@ """ Transcription Tools Module -Provides speech-to-text transcription with six providers: +Provides speech-to-text transcription with several providers: - **local** (default, free) — faster-whisper running locally, no API key needed. Auto-downloads the model (~150 MB for ``base``) on first use. - **groq** (free tier) — Groq Whisper API, requires ``GROQ_API_KEY``. - **openai** (paid) — OpenAI Whisper API, requires ``VOICE_TOOLS_OPENAI_KEY``. - **mistral** — Mistral Voxtral Transcribe API, requires ``MISTRAL_API_KEY``. + - **deepgram** — Deepgram Speech-to-Text API, requires ``DEEPGRAM_API_KEY``. + Recommended default for Russian/general ASR: ``nova-3`` with + ``smart_format=true`` for punctuation and readability improvements. - **xai** — xAI Grok STT API, requires ``XAI_API_KEY``. High accuracy, Inverse Text Normalization, diarization, 21 languages. @@ -84,6 +87,7 @@ def _safe_find_spec(module_name: str) -> bool: DEFAULT_STT_MODEL = os.getenv("STT_OPENAI_MODEL", "whisper-1") DEFAULT_GROQ_STT_MODEL = os.getenv("STT_GROQ_MODEL", "whisper-large-v3-turbo") DEFAULT_MISTRAL_STT_MODEL = os.getenv("STT_MISTRAL_MODEL", "voxtral-mini-latest") +DEFAULT_DEEPGRAM_STT_MODEL = os.getenv("STT_DEEPGRAM_MODEL", "nova-3") LOCAL_STT_COMMAND_ENV = "HERMES_LOCAL_STT_COMMAND" LOCAL_STT_LANGUAGE_ENV = "HERMES_LOCAL_STT_LANGUAGE" COMMON_LOCAL_BIN_DIRS = ("/opt/homebrew/bin", "/usr/local/bin") @@ -91,6 +95,7 @@ def _safe_find_spec(module_name: str) -> bool: GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1") OPENAI_BASE_URL = os.getenv("STT_OPENAI_BASE_URL", "https://api.openai.com/v1") XAI_STT_BASE_URL = os.getenv("XAI_STT_BASE_URL", "https://api.x.ai/v1") +DEEPGRAM_STT_BASE_URL = os.getenv("DEEPGRAM_STT_BASE_URL", "https://api.deepgram.com/v1") SUPPORTED_FORMATS = {".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".aac", ".flac"} LOCAL_NATIVE_AUDIO_FORMATS = {".wav", ".aiff", ".aif"} @@ -265,6 +270,14 @@ def _get_provider(stt_config: dict) -> str: ) return "none" + if provider == "deepgram": + if get_env_value("DEEPGRAM_API_KEY"): + return "deepgram" + logger.warning( + "STT provider 'deepgram' configured but DEEPGRAM_API_KEY not set" + ) + return "none" + if provider == "xai": from tools.xai_http import resolve_xai_http_credentials @@ -699,6 +712,130 @@ def _transcribe_mistral(file_path: str, model_name: str) -> Dict[str, Any]: return {"success": False, "transcript": "", "error": f"Mistral transcription failed: {type(e).__name__}"} +# --------------------------------------------------------------------------- +# Provider: Deepgram (Nova-3 / Flux) +# --------------------------------------------------------------------------- + + +def _deepgram_bool(value: Any, default: bool) -> bool: + """Normalize config booleans that may arrive as strings from YAML/env.""" + if value is None: + return default + return is_truthy_value(value, default=default) + + +def _extract_deepgram_transcript(result: Dict[str, Any]) -> str: + """Return the best human-readable transcript from a Deepgram response.""" + try: + alternatives = ( + result.get("results", {}) + .get("channels", [{}])[0] + .get("alternatives", []) + ) + if not alternatives: + return "" + alt0 = alternatives[0] or {} + paragraphs = alt0.get("paragraphs") or {} + paragraph_text = paragraphs.get("transcript") + if isinstance(paragraph_text, str) and paragraph_text.strip(): + return paragraph_text.strip() + transcript_text = alt0.get("transcript") + if isinstance(transcript_text, str): + return transcript_text.strip() + except Exception: + return "" + return "" + + +def _transcribe_deepgram(file_path: str, model_name: str) -> Dict[str, Any]: + """Transcribe using Deepgram's Speech-to-Text API.""" + api_key = get_env_value("DEEPGRAM_API_KEY") + if not api_key: + return {"success": False, "transcript": "", "error": "DEEPGRAM_API_KEY not set"} + + stt_config = _load_stt_config() + deepgram_cfg = stt_config.get("deepgram", {}) + base_url = str( + deepgram_cfg.get("base_url") + or get_env_value("DEEPGRAM_STT_BASE_URL") + or DEEPGRAM_STT_BASE_URL + ).strip().rstrip("/") + language = str(deepgram_cfg.get("language", "ru") or "ru").strip() + + params: Dict[str, Any] = { + "model": model_name, + "smart_format": str(_deepgram_bool(deepgram_cfg.get("smart_format"), True)).lower(), + "paragraphs": str(_deepgram_bool(deepgram_cfg.get("paragraphs"), True)).lower(), + "utterances": str(_deepgram_bool(deepgram_cfg.get("utterances"), True)).lower(), + "numerals": str(_deepgram_bool(deepgram_cfg.get("numerals"), True)).lower(), + } + if language: + params["language"] = language + + if "dictation" in deepgram_cfg: + params["dictation"] = str(_deepgram_bool(deepgram_cfg.get("dictation"), False)).lower() + if "diarize" in deepgram_cfg: + params["diarize"] = str(_deepgram_bool(deepgram_cfg.get("diarize"), False)).lower() + if "detect_language" in deepgram_cfg: + params["detect_language"] = str(_deepgram_bool(deepgram_cfg.get("detect_language"), False)).lower() + if "punctuate" in deepgram_cfg: + params["punctuate"] = str(_deepgram_bool(deepgram_cfg.get("punctuate"), False)).lower() + + try: + import mimetypes + import requests + + content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + with open(file_path, "rb") as audio_file: + response = requests.post( + f"{base_url}/listen", + headers={ + "Authorization": f"Token {api_key}", + "Content-Type": content_type, + }, + params=params, + data=audio_file, + timeout=120, + ) + + if response.status_code != 200: + detail = response.text[:500] + try: + err_body = response.json() + detail = err_body.get("err_msg") or err_body.get("message") or detail + except Exception: + pass + return { + "success": False, + "transcript": "", + "error": f"Deepgram STT API error (HTTP {response.status_code}): {detail}", + } + + result = response.json() + transcript_text = _extract_deepgram_transcript(result) + if not transcript_text: + return { + "success": False, + "transcript": "", + "error": "Deepgram STT returned empty transcript", + } + + logger.info( + "Transcribed %s via Deepgram (%s, lang=%s, %d chars)", + Path(file_path).name, + model_name, + language or result.get("results", {}).get("language"), + len(transcript_text), + ) + return {"success": True, "transcript": transcript_text, "provider": "deepgram"} + + except PermissionError: + return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"} + except Exception as e: + logger.error("Deepgram transcription failed: %s", e, exc_info=True) + return {"success": False, "transcript": "", "error": f"Deepgram transcription failed: {e}"} + + # --------------------------------------------------------------------------- # Provider: xAI (Grok STT API) # --------------------------------------------------------------------------- @@ -819,6 +956,9 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A 1. User config (``stt.provider`` in config.yaml) 2. Auto-detect: local faster-whisper (free) > Groq (free tier) > OpenAI (paid) + Deepgram is supported as an explicit opt-in provider via + ``stt.provider: deepgram``. + Args: file_path: Absolute path to the audio file to transcribe. model: Override the model. If None, uses config or provider default. @@ -874,6 +1014,11 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A model_name = model or mistral_cfg.get("model", DEFAULT_MISTRAL_STT_MODEL) return _transcribe_mistral(file_path, model_name) + if provider == "deepgram": + deepgram_cfg = stt_config.get("deepgram", {}) + model_name = model or deepgram_cfg.get("model", DEFAULT_DEEPGRAM_STT_MODEL) + return _transcribe_deepgram(file_path, model_name) + if provider == "xai": # xAI Grok STT doesn't use a model parameter — pass through for logging model_name = model or "grok-stt" @@ -886,8 +1031,9 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A "error": ( "No STT provider available. Install faster-whisper for free local " f"transcription, configure {LOCAL_STT_COMMAND_ENV} or install a local whisper CLI, " - "set GROQ_API_KEY for free Groq Whisper, set MISTRAL_API_KEY for Mistral " - "Voxtral Transcribe, configure xAI OAuth or set XAI_API_KEY for xAI Grok STT, or set VOICE_TOOLS_OPENAI_KEY " + "set GROQ_API_KEY for free Groq Whisper, set DEEPGRAM_API_KEY for Deepgram, " + "set MISTRAL_API_KEY for Mistral Voxtral Transcribe, configure xAI OAuth " + "or set XAI_API_KEY for xAI Grok STT, or set VOICE_TOOLS_OPENAI_KEY " "or OPENAI_API_KEY for the OpenAI Whisper API." ), }