diff --git a/.env.example b/.env.example index ac6a187f3..452f23eb5 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,14 @@ # Copy this file to .env and fill in your API keys # ============================================================================= -# LLM PROVIDER (OpenRouter) +# LLM PROVIDER # ============================================================================= -# OpenRouter provides access to many models through one API -# All LLM calls go through OpenRouter - no direct provider keys needed +# Provider selection override: auto | openrouter | nous | openai-codex +# If unset, Hermes auto-detects from auth/config. +# HERMES_INFERENCE_PROVIDER=auto + +# OpenRouter key (required when using OpenRouter directly, and still used by +# some tools even when your primary chat provider is Nous/Codex/custom). # Get your key at: https://openrouter.ai/keys OPENROUTER_API_KEY= @@ -13,6 +17,11 @@ OPENROUTER_API_KEY= # Examples: anthropic/claude-opus-4.6, openai/gpt-4o, google/gemini-2.0-flash, zhipuai/glm-4-plus LLM_MODEL=anthropic/claude-opus-4.6 +# OpenAI Codex provider uses Codex CLI auth state: +# hermes login --provider openai-codex +# (reads CODEX_HOME/auth.json, default: ~/.codex/auth.json) +# CODEX_HOME=~/.codex + # ============================================================================= # TOOL API KEYS # ============================================================================= diff --git a/README.md b/README.md index a97e63771..9ddbb3dff 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ **The fully open-source AI agent that grows with you.** Install it on a machine, give it your messaging accounts, and it becomes a persistent personal agent — learning your projects, building its own skills, running tasks on a schedule, and reaching you wherever you are. An autonomous agent that lives on your server, remembers what it learns, and gets more capable the longer it runs. -Use any model you want — log in with a [Nous Portal](https://portal.nousresearch.com) subscription for zero-config access, connect an [OpenRouter](https://openrouter.ai) key for 200+ models, or point it at your own VLLM/SGLang endpoint. Switch with `hermes model` — no code changes, no lock-in. +Use any model you want — log in with [Nous Portal](https://portal.nousresearch.com), log in with OpenAI Codex via `hermes login --provider openai-codex`, connect an [OpenRouter](https://openrouter.ai) key for 200+ models, or point it at your own VLLM/SGLang endpoint. Switch with `hermes model` — no code changes, no lock-in. Built by [Nous Research](https://nousresearch.com). Under the hood, the same architecture powers [batch data generation](#batch-processing) and [RL training environments](#-atropos-rl-environments) for training the next generation of tool-calling models. @@ -121,11 +121,14 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | Provider | Setup | |----------|-------| | **Nous Portal** | `hermes login` (OAuth, subscription-based) | +| **OpenAI Codex** | `hermes login --provider openai-codex` (uses `CODEX_HOME/auth.json`) | | **OpenRouter** | `OPENROUTER_API_KEY` in `~/.hermes/.env` | | **Custom Endpoint** | `OPENAI_BASE_URL` + `OPENAI_API_KEY` in `~/.hermes/.env` | **Note:** Even when using Nous Portal or a custom endpoint, some tools (vision, web summarization, MoA) use OpenRouter independently. An `OPENROUTER_API_KEY` enables these tools. +**Codex note:** The `openai-codex` provider uses Codex CLI auth (`CODEX_HOME/auth.json`, default `~/.codex/auth.json`) and Hermes routes that provider through the Responses API transport. + --- ## Configuration @@ -136,7 +139,7 @@ All your settings are stored in `~/.hermes/` for easy access: ~/.hermes/ ├── config.yaml # Settings (model, terminal, TTS, compression, etc.) ├── .env # API keys and secrets -├── auth.json # OAuth provider credentials (Nous Portal, etc.) +├── auth.json # OAuth provider credentials (Nous Portal, OpenAI Codex) ├── SOUL.md # Optional: global persona (agent embodies this personality) ├── memories/ # Persistent memory (MEMORY.md, USER.md) ├── skills/ # Agent-created skills (managed via skill_manage tool) @@ -335,6 +338,7 @@ hermes chat -q "Hello" # Single query mode # Provider & model management hermes model # Switch provider and model interactively hermes login # Authenticate with Nous Portal (OAuth) +hermes login --provider openai-codex hermes logout # Clear stored OAuth credentials # Configuration @@ -1406,7 +1410,7 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t **Provider Auth (OAuth):** | Variable | Description | |----------|-------------| -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous` (default: `auto`) | +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex` (default: `auto`) | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | @@ -1481,7 +1485,7 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t |------|-------------| | `~/.hermes/config.yaml` | Your settings | | `~/.hermes/.env` | API keys and secrets | -| `~/.hermes/auth.json` | OAuth provider credentials (managed by `hermes login`) | +| `~/.hermes/auth.json` | OAuth provider credentials (managed by `hermes login`, including Codex metadata) | | `~/.hermes/cron/` | Scheduled jobs data | | `~/.hermes/sessions/` | Gateway session data | | `~/.hermes/hermes-agent/` | Installation directory | @@ -1509,11 +1513,11 @@ hermes config # View current settings Common issues: - **"API key not set"**: Run `hermes setup` or `hermes config set OPENROUTER_API_KEY your_key` - **"hermes: command not found"**: Reload your shell (`source ~/.bashrc`) or check PATH -- **"Run `hermes login` to re-authenticate"**: Your Nous Portal session expired. Run `hermes login` to refresh. +- **"Run `hermes login` to re-authenticate"**: Your OAuth session expired. Use `hermes login` for Nous or `hermes login --provider openai-codex` for Codex. - **"No active paid subscription"**: Your Nous Portal account needs an active subscription for inference. - **Gateway won't start**: Check `hermes gateway status` and logs - **Missing config after update**: Run `hermes config check` to see what's new, then `hermes config migrate` to add missing options -- **Provider auto-detection wrong**: Force a provider with `hermes chat --provider openrouter` or set `HERMES_INFERENCE_PROVIDER` in `.env` +- **Provider auto-detection wrong**: Force a provider with `hermes chat --provider openrouter` (or `nous` / `openai-codex`) or set `HERMES_INFERENCE_PROVIDER` in `.env` --- diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 0b49368dc..d42d9db26 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -13,6 +13,7 @@ model: # "auto" - Use Nous Portal if logged in, otherwise OpenRouter/env vars (default) # "openrouter" - Always use OpenRouter API key from OPENROUTER_API_KEY # "nous" - Always use Nous Portal (requires: hermes login) + # "openai-codex" - Always use Codex CLI auth (requires: hermes login --provider openai-codex) # Can also be overridden with --provider flag or HERMES_INFERENCE_PROVIDER env var. provider: "auto" diff --git a/cli.py b/cli.py index a09d50162..32a751d15 100755 --- a/cli.py +++ b/cli.py @@ -751,7 +751,7 @@ class HermesCLI: Args: model: Model to use (default: from env or claude-sonnet) toolsets: List of toolsets to enable (default: all) - provider: Inference provider ("auto", "openrouter", "nous") + provider: Inference provider ("auto", "openrouter", "nous", "openai-codex") api_key: API key (default: from environment) base_url: API base URL (default: OpenRouter) max_turns: Maximum tool-calling iterations (default: 60) @@ -766,28 +766,26 @@ class HermesCLI: # Configuration - priority: CLI args > env vars > config file # Model can come from: CLI arg, LLM_MODEL env, OPENAI_MODEL env (custom endpoint), or config self.model = model or os.getenv("LLM_MODEL") or os.getenv("OPENAI_MODEL") or CLI_CONFIG["model"]["default"] - - # Base URL: custom endpoint (OPENAI_BASE_URL) takes precedence over OpenRouter - self.base_url = base_url or os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", CLI_CONFIG["model"]["base_url"]) - - # API key: custom endpoint (OPENAI_API_KEY) takes precedence over OpenRouter - self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") - # Provider resolution: determines whether to use OAuth credentials or env var keys - from hermes_cli.auth import resolve_provider + self._explicit_api_key = api_key + self._explicit_base_url = base_url + + # Provider selection is resolved lazily at use-time via _ensure_runtime_credentials(). self.requested_provider = ( provider or os.getenv("HERMES_INFERENCE_PROVIDER") or CLI_CONFIG["model"].get("provider") or "auto" ) - self.provider = resolve_provider( - self.requested_provider, - explicit_api_key=api_key, - explicit_base_url=base_url, + self._provider_source: Optional[str] = None + self.provider = self.requested_provider + self.api_mode = "chat_completions" + self.base_url = ( + base_url + or os.getenv("OPENAI_BASE_URL") + or os.getenv("OPENROUTER_BASE_URL", CLI_CONFIG["model"]["base_url"]) ) - self._nous_key_expires_at: Optional[str] = None - self._nous_key_source: Optional[str] = None + self.api_key = api_key or os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY") # Max turns priority: CLI arg > env var > config file (agent.max_turns or root max_turns) > default if max_turns != 60: # CLI arg was explicitly set self.max_turns = max_turns @@ -844,45 +842,51 @@ class HermesCLI: def _ensure_runtime_credentials(self) -> bool: """ - Ensure OAuth provider credentials are fresh before agent use. - For Nous Portal: checks agent key TTL, refreshes/re-mints as needed. - If the key changed, tears down the agent so it rebuilds with new creds. + Ensure runtime credentials are resolved before agent use. + Re-resolves provider credentials so key rotation and token refresh + are picked up without restarting the CLI. Returns True if credentials are ready, False on auth failure. """ - if self.provider != "nous": - return True - - from hermes_cli.auth import format_auth_error, resolve_nous_runtime_credentials + from hermes_cli.runtime_provider import ( + resolve_runtime_provider, + format_runtime_provider_error, + ) try: - credentials = resolve_nous_runtime_credentials( - min_key_ttl_seconds=max( - 60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800")) - ), - timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), + runtime = resolve_runtime_provider( + requested=self.requested_provider, + explicit_api_key=self._explicit_api_key, + explicit_base_url=self._explicit_base_url, ) except Exception as exc: - message = format_auth_error(exc) + message = format_runtime_provider_error(exc) self.console.print(f"[bold red]{message}[/]") return False - api_key = credentials.get("api_key") - base_url = credentials.get("base_url") + api_key = runtime.get("api_key") + base_url = runtime.get("base_url") + resolved_provider = runtime.get("provider", "openrouter") + resolved_api_mode = runtime.get("api_mode", self.api_mode) if not isinstance(api_key, str) or not api_key: - self.console.print("[bold red]Nous credential resolver returned an empty API key.[/]") + self.console.print("[bold red]Provider resolver returned an empty API key.[/]") return False if not isinstance(base_url, str) or not base_url: - self.console.print("[bold red]Nous credential resolver returned an empty base URL.[/]") + self.console.print("[bold red]Provider resolver returned an empty base URL.[/]") return False credentials_changed = api_key != self.api_key or base_url != self.base_url + routing_changed = ( + resolved_provider != self.provider + or resolved_api_mode != self.api_mode + ) + self.provider = resolved_provider + self.api_mode = resolved_api_mode + self._provider_source = runtime.get("source") self.api_key = api_key self.base_url = base_url - self._nous_key_expires_at = credentials.get("expires_at") - self._nous_key_source = credentials.get("source") # AIAgent/OpenAI client holds auth at init time, so rebuild if key rotated - if credentials_changed and self.agent is not None: + if (credentials_changed or routing_changed) and self.agent is not None: self.agent = None return True @@ -897,7 +901,7 @@ class HermesCLI: if self.agent is not None: return True - if self.provider == "nous" and not self._ensure_runtime_credentials(): + if not self._ensure_runtime_credentials(): return False # Initialize SQLite session store for CLI sessions @@ -913,6 +917,8 @@ class HermesCLI: model=self.model, api_key=self.api_key, base_url=self.base_url, + provider=self.provider, + api_mode=self.api_mode, max_iterations=self.max_turns, enabled_toolsets=self.enabled_toolsets, verbose_logging=self.verbose, @@ -1004,8 +1010,8 @@ class HermesCLI: toolsets_info = f" [dim #B8860B]·[/] [#CD7F32]toolsets: {', '.join(self.enabled_toolsets)}[/]" provider_info = f" [dim #B8860B]·[/] [dim]provider: {self.provider}[/]" - if self.provider == "nous" and self._nous_key_source: - provider_info += f" [dim #B8860B]·[/] [dim]key: {self._nous_key_source}[/]" + if self._provider_source: + provider_info += f" [dim #B8860B]·[/] [dim]auth: {self._provider_source}[/]" self.console.print( f" {api_indicator} [#FFBF00]{model_short}[/] " @@ -1786,8 +1792,8 @@ class HermesCLI: Returns: The agent's response, or None on error """ - # Refresh OAuth credentials if needed (handles key rotation transparently) - if self.provider == "nous" and not self._ensure_runtime_credentials(): + # Refresh provider credentials if needed (handles key rotation transparently) + if not self._ensure_runtime_credentials(): return None # Initialize agent if needed diff --git a/cron/scheduler.py b/cron/scheduler.py index 62987cca6..4d45fde1e 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -170,8 +170,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: load_dotenv(os.path.expanduser("~/.hermes/.env"), override=True, encoding="latin-1") model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") - api_key = os.getenv("OPENROUTER_API_KEY", "") - base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") try: import yaml @@ -184,14 +182,27 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: model = _model_cfg elif isinstance(_model_cfg, dict): model = _model_cfg.get("default", model) - base_url = _model_cfg.get("base_url", base_url) except Exception: pass + from hermes_cli.runtime_provider import ( + resolve_runtime_provider, + format_runtime_provider_error, + ) + try: + runtime = resolve_runtime_provider( + requested=os.getenv("HERMES_INFERENCE_PROVIDER"), + ) + except Exception as exc: + message = format_runtime_provider_error(exc) + raise RuntimeError(message) from exc + agent = AIAgent( model=model, - api_key=api_key, - base_url=base_url, + api_key=runtime.get("api_key"), + base_url=runtime.get("base_url"), + provider=runtime.get("provider"), + api_mode=runtime.get("api_mode"), quiet_mode=True, session_id=f"cron_{job_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" ) diff --git a/gateway/run.py b/gateway/run.py index 214a026ab..387f88339 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -83,6 +83,28 @@ from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageTyp logger = logging.getLogger(__name__) +def _resolve_runtime_agent_kwargs() -> dict: + """Resolve provider credentials for gateway-created AIAgent instances.""" + from hermes_cli.runtime_provider import ( + resolve_runtime_provider, + format_runtime_provider_error, + ) + + try: + runtime = resolve_runtime_provider( + requested=os.getenv("HERMES_INFERENCE_PROVIDER"), + ) + except Exception as exc: + raise RuntimeError(format_runtime_provider_error(exc)) from exc + + return { + "api_key": runtime.get("api_key"), + "base_url": runtime.get("base_url"), + "provider": runtime.get("provider"), + "api_mode": runtime.get("api_mode"), + } + + class GatewayRunner: """ Main gateway controller. @@ -768,6 +790,7 @@ class GatewayRunner: def _do_flush(): tmp_agent = AIAgent( model=os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6"), + **_resolve_runtime_agent_kwargs(), max_iterations=5, quiet_mode=True, enabled_toolsets=["memory"], @@ -1378,7 +1401,7 @@ class GatewayRunner: combined_ephemeral = context_prompt or "" if self._ephemeral_system_prompt: combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() - + # Re-read .env and config for fresh credentials (gateway is long-lived, # keys may change without restart). try: @@ -1388,8 +1411,6 @@ class GatewayRunner: except Exception: pass - api_key = os.getenv("OPENROUTER_API_KEY", "") - base_url = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6") try: @@ -1403,14 +1424,22 @@ class GatewayRunner: model = _model_cfg elif isinstance(_model_cfg, dict): model = _model_cfg.get("default", model) - base_url = _model_cfg.get("base_url", base_url) except Exception: pass + try: + runtime_kwargs = _resolve_runtime_agent_kwargs() + except Exception as exc: + return { + "final_response": f"⚠️ Provider authentication failed: {exc}", + "messages": [], + "api_calls": 0, + "tools": [], + } + agent = AIAgent( model=model, - api_key=api_key, - base_url=base_url, + **runtime_kwargs, max_iterations=max_iterations, quiet_mode=True, enabled_toolsets=enabled_toolsets, diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0941c6d91..328b84f14 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -18,7 +18,9 @@ from __future__ import annotations import json import logging import os +import shutil import stat +import subprocess import time import webbrowser from contextlib import contextmanager @@ -55,6 +57,7 @@ DEFAULT_NOUS_SCOPE = "inference:mint_agent_key" DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s +DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" # ============================================================================= @@ -84,7 +87,12 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { client_id=DEFAULT_NOUS_CLIENT_ID, scope=DEFAULT_NOUS_SCOPE, ), - # Future: "openai_codex", "anthropic", etc. + "openai-codex": ProviderConfig( + id="openai-codex", + name="OpenAI Codex", + auth_type="oauth_external", + inference_base_url=DEFAULT_CODEX_BASE_URL, + ), } @@ -298,12 +306,15 @@ def resolve_provider( """ normalized = (requested or "auto").strip().lower() + if normalized in {"openrouter", "custom"}: + return "openrouter" if normalized in PROVIDER_REGISTRY: return normalized - if normalized == "openrouter": - return "openrouter" if normalized != "auto": - return "openrouter" + raise AuthError( + f"Unknown provider '{normalized}'.", + code="invalid_provider", + ) # Explicit one-off CLI creds always mean openrouter/custom if explicit_api_key or explicit_base_url: @@ -314,8 +325,8 @@ def resolve_provider( auth_store = _load_auth_store() active = auth_store.get("active_provider") if active and active in PROVIDER_REGISTRY: - state = _load_provider_state(auth_store, active) - if state and (state.get("access_token") or state.get("refresh_token")): + status = get_auth_status(active) + if status.get("logged_in"): return active except Exception as e: logger.debug("Could not detect active auth provider: %s", e) @@ -378,6 +389,108 @@ def _is_remote_session() -> bool: return bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")) +# ============================================================================= +# OpenAI Codex auth file helpers +# ============================================================================= + +def resolve_codex_home_path() -> Path: + """Resolve CODEX_HOME, defaulting to ~/.codex.""" + codex_home = os.getenv("CODEX_HOME", "").strip() + if not codex_home: + codex_home = str(Path.home() / ".codex") + return Path(codex_home).expanduser() + + +def _codex_auth_file_path() -> Path: + return resolve_codex_home_path() / "auth.json" + + +def read_codex_auth_file() -> Dict[str, Any]: + """Read and validate Codex auth.json shape.""" + codex_home = resolve_codex_home_path() + if not codex_home.exists(): + raise AuthError( + f"Codex home directory not found at {codex_home}.", + provider="openai-codex", + code="codex_home_missing", + relogin_required=True, + ) + + auth_path = codex_home / "auth.json" + if not auth_path.exists(): + raise AuthError( + f"Codex auth file not found at {auth_path}.", + provider="openai-codex", + code="codex_auth_missing", + relogin_required=True, + ) + + try: + payload = json.loads(auth_path.read_text()) + except Exception as exc: + raise AuthError( + f"Failed to parse Codex auth file at {auth_path}.", + provider="openai-codex", + code="codex_auth_invalid_json", + relogin_required=True, + ) from exc + + tokens = payload.get("tokens") + if not isinstance(tokens, dict): + raise AuthError( + "Codex auth file is missing a valid 'tokens' object.", + provider="openai-codex", + code="codex_auth_invalid_shape", + relogin_required=True, + ) + + access_token = tokens.get("access_token") + refresh_token = tokens.get("refresh_token") + if not isinstance(access_token, str) or not access_token.strip(): + raise AuthError( + "Codex auth file is missing tokens.access_token.", + provider="openai-codex", + code="codex_auth_missing_access_token", + relogin_required=True, + ) + if not isinstance(refresh_token, str) or not refresh_token.strip(): + raise AuthError( + "Codex auth file is missing tokens.refresh_token.", + provider="openai-codex", + code="codex_auth_missing_refresh_token", + relogin_required=True, + ) + + return { + "payload": payload, + "tokens": tokens, + "auth_path": auth_path, + "codex_home": codex_home, + } + + +def resolve_codex_runtime_credentials() -> Dict[str, Any]: + """Resolve runtime credentials from Codex CLI auth state.""" + data = read_codex_auth_file() + payload = data["payload"] + tokens = data["tokens"] + base_url = ( + os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") + or DEFAULT_CODEX_BASE_URL + ) + + return { + "provider": "openai-codex", + "base_url": base_url, + "api_key": tokens["access_token"], + "source": "codex-auth-json", + "last_refresh": payload.get("last_refresh"), + "auth_mode": payload.get("auth_mode"), + "auth_file": str(data["auth_path"]), + "codex_home": str(data["codex_home"]), + } + + # ============================================================================= # TLS verification helper # ============================================================================= @@ -806,11 +919,37 @@ def get_nous_auth_status() -> Dict[str, Any]: } +def get_codex_auth_status() -> Dict[str, Any]: + """Status snapshot for Codex auth.""" + state = get_provider_auth_state("openai-codex") or {} + auth_file = state.get("auth_file") or str(_codex_auth_file_path()) + codex_home = state.get("codex_home") or str(resolve_codex_home_path()) + try: + creds = resolve_codex_runtime_credentials() + return { + "logged_in": True, + "auth_file": creds.get("auth_file"), + "codex_home": creds.get("codex_home"), + "last_refresh": creds.get("last_refresh"), + "auth_mode": creds.get("auth_mode"), + "source": creds.get("source"), + } + except AuthError as exc: + return { + "logged_in": False, + "auth_file": auth_file, + "codex_home": codex_home, + "error": str(exc), + } + + def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]: """Generic auth status dispatcher.""" target = provider_id or get_active_provider() if target == "nous": return get_nous_auth_status() + if target == "openai-codex": + return get_codex_auth_status() return {"logged_in": False} @@ -982,11 +1121,64 @@ def login_command(args) -> None: if provider_id == "nous": _login_nous(args, pconfig) + elif provider_id == "openai-codex": + _login_openai_codex(args, pconfig) else: print(f"Login for provider '{provider_id}' is not yet implemented.") raise SystemExit(1) +def _login_openai_codex(args, pconfig: ProviderConfig) -> None: + """OpenAI Codex login flow using Codex CLI auth state.""" + codex_path = shutil.which("codex") + if not codex_path: + print("Codex CLI was not found in PATH.") + print("Install Codex CLI, then retry `hermes login --provider openai-codex`.") + raise SystemExit(1) + + print(f"Starting Hermes login via {pconfig.name}...") + print(f"Using Codex CLI: {codex_path}") + print(f"Codex home: {resolve_codex_home_path()}") + + creds: Dict[str, Any] + try: + creds = resolve_codex_runtime_credentials() + except AuthError: + print("No usable Codex auth found. Running `codex login`...") + try: + subprocess.run(["codex", "login"], check=True) + except subprocess.CalledProcessError as exc: + print(f"Codex login failed with exit code {exc.returncode}.") + raise SystemExit(1) + except KeyboardInterrupt: + print("\nLogin cancelled.") + raise SystemExit(130) + try: + creds = resolve_codex_runtime_credentials() + except AuthError as exc: + print(format_auth_error(exc)) + raise SystemExit(1) + + auth_state = { + "auth_file": creds.get("auth_file"), + "codex_home": creds.get("codex_home"), + "last_refresh": creds.get("last_refresh"), + "auth_mode": creds.get("auth_mode"), + "source": creds.get("source"), + } + + with _auth_store_lock(): + auth_store = _load_auth_store() + _save_provider_state(auth_store, "openai-codex", auth_state) + saved_to = _save_auth_store(auth_store) + + config_path = _update_config_for_provider("openai-codex", creds["base_url"]) + print() + print("Login successful!") + print(f" Auth state: {saved_to}") + print(f" Config updated: {config_path} (model.provider=openai-codex)") + + def _login_nous(args, pconfig: ProviderConfig) -> None: """Nous Portal device authorization flow.""" portal_base_url = ( diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index f9156354a..77ff65d1e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -171,6 +171,36 @@ def run_doctor(args): else: check_warn("config.yaml not found", "(using defaults)") + # ========================================================================= + # Check: Auth providers + # ========================================================================= + print() + print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) + + try: + from hermes_cli.auth import get_nous_auth_status, get_codex_auth_status + + nous_status = get_nous_auth_status() + if nous_status.get("logged_in"): + check_ok("Nous Portal auth", "(logged in)") + else: + check_warn("Nous Portal auth", "(not logged in)") + + codex_status = get_codex_auth_status() + if codex_status.get("logged_in"): + check_ok("OpenAI Codex auth", "(logged in)") + else: + check_warn("OpenAI Codex auth", "(not logged in)") + if codex_status.get("error"): + check_info(codex_status["error"]) + except Exception as e: + check_warn("Auth provider status", f"(could not check: {e})") + + if shutil.which("codex"): + check_ok("codex CLI") + else: + check_warn("codex CLI not found", "(required for openai-codex login)") + # ========================================================================= # Check: Directory structure # ========================================================================= diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 544932020..3d1c76c00 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -53,6 +53,7 @@ logger = logging.getLogger(__name__) def _has_any_provider_configured() -> bool: """Check if at least one inference provider is usable.""" from hermes_cli.config import get_env_path, get_hermes_home + from hermes_cli.auth import get_auth_status # Check env vars (may be set by .env or shell) if os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") or os.getenv("ANTHROPIC_API_KEY"): @@ -81,8 +82,8 @@ def _has_any_provider_configured() -> bool: auth = json.loads(auth_file.read_text()) active = auth.get("active_provider") if active: - state = auth.get("providers", {}).get(active, {}) - if state.get("access_token") or state.get("refresh_token"): + status = get_auth_status(active) + if status.get("logged_in"): return True except Exception: pass @@ -145,7 +146,7 @@ def cmd_model(args): resolve_provider, get_provider_auth_state, PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, _update_config_for_provider, resolve_nous_runtime_credentials, fetch_nous_models, AuthError, format_auth_error, - _login_nous, ProviderConfig, + _login_nous, ) from hermes_cli.config import load_config, save_config, get_env_value, save_env_value @@ -168,7 +169,12 @@ def cmd_model(args): or config_provider or "auto" ) - active = resolve_provider(effective_provider) + try: + active = resolve_provider(effective_provider) + except AuthError as exc: + warning = format_auth_error(exc) + print(f"Warning: {warning} Falling back to auto provider detection.") + active = resolve_provider("auto") # Detect custom endpoint if active == "openrouter" and get_env_value("OPENAI_BASE_URL"): @@ -177,6 +183,7 @@ def cmd_model(args): provider_labels = { "openrouter": "OpenRouter", "nous": "Nous Portal", + "openai-codex": "OpenAI Codex", "custom": "Custom endpoint", } active_label = provider_labels.get(active, active) @@ -190,11 +197,12 @@ def cmd_model(args): providers = [ ("openrouter", "OpenRouter (100+ models, pay-per-use)"), ("nous", "Nous Portal (Nous Research subscription)"), + ("openai-codex", "OpenAI Codex (ChatGPT/Codex CLI login)"), ("custom", "Custom endpoint (self-hosted / VLLM / etc.)"), ] # Reorder so the active provider is at the top - active_key = active if active in ("openrouter", "nous") else "custom" + active_key = active if active in ("openrouter", "nous", "openai-codex") else "custom" ordered = [] for key, label in providers: if key == active_key: @@ -215,6 +223,8 @@ def cmd_model(args): _model_flow_openrouter(config, current_model) elif selected_provider == "nous": _model_flow_nous(config, current_model) + elif selected_provider == "openai-codex": + _model_flow_openai_codex(config, current_model) elif selected_provider == "custom": _model_flow_custom(config) @@ -368,6 +378,52 @@ def _model_flow_nous(config, current_model=""): print("No change.") +def _model_flow_openai_codex(config, current_model=""): + """OpenAI Codex provider: ensure logged in, then pick model.""" + from hermes_cli.auth import ( + get_codex_auth_status, _prompt_model_selection, _save_model_choice, + _update_config_for_provider, _login_openai_codex, + PROVIDER_REGISTRY, DEFAULT_CODEX_BASE_URL, + ) + from hermes_cli.config import get_env_value, save_env_value + import argparse + + status = get_codex_auth_status() + if not status.get("logged_in"): + print("Not logged into OpenAI Codex. Starting login...") + print() + try: + mock_args = argparse.Namespace() + _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"]) + except SystemExit: + print("Login cancelled or failed.") + return + except Exception as exc: + print(f"Login failed: {exc}") + return + + # Codex models are not discoverable through /models with this auth path, + # so provide curated IDs with custom fallback. + codex_models = [ + "gpt-5-codex", + "gpt-5.3-codex", + "gpt-5.2-codex", + "gpt-5.1-codex", + ] + + selected = _prompt_model_selection(codex_models, current_model=current_model) + if selected: + _save_model_choice(selected) + _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) + # Clear custom endpoint env vars that would otherwise override Codex. + if get_env_value("OPENAI_BASE_URL"): + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + print(f"Default model set to: {selected} (via OpenAI Codex)") + else: + print("No change.") + + def _model_flow_custom(config): """Custom endpoint: collect URL, API key, and model name.""" from hermes_cli.auth import _save_model_choice, deactivate_provider @@ -678,7 +734,7 @@ For more help on a command: ) chat_parser.add_argument( "--provider", - choices=["auto", "openrouter", "nous"], + choices=["auto", "openrouter", "nous", "openai-codex"], default=None, help="Inference provider (default: auto)" ) @@ -765,9 +821,9 @@ For more help on a command: ) login_parser.add_argument( "--provider", - choices=["nous"], + choices=["nous", "openai-codex"], default=None, - help="Provider to authenticate with (default: interactive selection)" + help="Provider to authenticate with (default: nous)" ) login_parser.add_argument( "--portal-url", @@ -819,7 +875,7 @@ For more help on a command: ) logout_parser.add_argument( "--provider", - choices=["nous"], + choices=["nous", "openai-codex"], default=None, help="Provider to log out from (default: active provider)" ) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py new file mode 100644 index 000000000..1f070ac22 --- /dev/null +++ b/hermes_cli/runtime_provider.py @@ -0,0 +1,149 @@ +"""Shared runtime provider resolution for CLI, gateway, cron, and helpers.""" + +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +from hermes_cli.auth import ( + AuthError, + format_auth_error, + resolve_provider, + resolve_nous_runtime_credentials, + resolve_codex_runtime_credentials, +) +from hermes_cli.config import load_config +from hermes_constants import OPENROUTER_BASE_URL + + +def _get_model_config() -> Dict[str, Any]: + config = load_config() + model_cfg = config.get("model") + if isinstance(model_cfg, dict): + return dict(model_cfg) + if isinstance(model_cfg, str) and model_cfg.strip(): + return {"default": model_cfg.strip()} + return {} + + +def resolve_requested_provider(requested: Optional[str] = None) -> str: + """Resolve provider request from explicit arg, env, then config.""" + if requested and requested.strip(): + return requested.strip().lower() + + env_provider = os.getenv("HERMES_INFERENCE_PROVIDER", "").strip().lower() + if env_provider: + return env_provider + + model_cfg = _get_model_config() + cfg_provider = model_cfg.get("provider") + if isinstance(cfg_provider, str) and cfg_provider.strip(): + return cfg_provider.strip().lower() + + return "auto" + + +def _resolve_openrouter_runtime( + *, + requested_provider: str, + explicit_api_key: Optional[str] = None, + explicit_base_url: Optional[str] = None, +) -> Dict[str, Any]: + model_cfg = _get_model_config() + cfg_base_url = model_cfg.get("base_url") if isinstance(model_cfg.get("base_url"), str) else "" + cfg_provider = model_cfg.get("provider") if isinstance(model_cfg.get("provider"), str) else "" + requested_norm = (requested_provider or "").strip().lower() + cfg_provider = cfg_provider.strip().lower() + + env_openai_base_url = os.getenv("OPENAI_BASE_URL", "").strip() + env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip() + + use_config_base_url = False + if requested_norm == "auto": + if cfg_base_url.strip() and not explicit_base_url and not env_openai_base_url: + if not cfg_provider or cfg_provider == "auto": + use_config_base_url = True + + base_url = ( + (explicit_base_url or "").strip() + or env_openai_base_url + or (cfg_base_url.strip() if use_config_base_url else "") + or env_openrouter_base_url + or OPENROUTER_BASE_URL + ).rstrip("/") + + api_key = ( + explicit_api_key + or os.getenv("OPENAI_API_KEY") + or os.getenv("OPENROUTER_API_KEY") + or "" + ) + + source = "explicit" if (explicit_api_key or explicit_base_url) else "env/config" + + return { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": base_url, + "api_key": api_key, + "source": source, + } + + +def resolve_runtime_provider( + *, + requested: Optional[str] = None, + explicit_api_key: Optional[str] = None, + explicit_base_url: Optional[str] = None, +) -> Dict[str, Any]: + """Resolve runtime provider credentials for agent execution.""" + requested_provider = resolve_requested_provider(requested) + + provider = resolve_provider( + requested_provider, + explicit_api_key=explicit_api_key, + explicit_base_url=explicit_base_url, + ) + + if provider == "nous": + creds = resolve_nous_runtime_credentials( + min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), + timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), + ) + return { + "provider": "nous", + "api_mode": "chat_completions", + "base_url": creds.get("base_url", "").rstrip("/"), + "api_key": creds.get("api_key", ""), + "source": creds.get("source", "portal"), + "expires_at": creds.get("expires_at"), + "requested_provider": requested_provider, + } + + if provider == "openai-codex": + creds = resolve_codex_runtime_credentials() + return { + "provider": "openai-codex", + "api_mode": "codex_responses", + "base_url": creds.get("base_url", "").rstrip("/"), + "api_key": creds.get("api_key", ""), + "source": creds.get("source", "codex-auth-json"), + "auth_file": creds.get("auth_file"), + "codex_home": creds.get("codex_home"), + "last_refresh": creds.get("last_refresh"), + "requested_provider": requested_provider, + } + + runtime = _resolve_openrouter_runtime( + requested_provider=requested_provider, + explicit_api_key=explicit_api_key, + explicit_base_url=explicit_base_url, + ) + runtime["requested_provider"] = requested_provider + return runtime + + +def format_runtime_provider_error(error: Exception) -> str: + if isinstance(error, AuthError): + return format_auth_error(error) + return str(error) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 95c59213d..08fd28ddd 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -613,6 +613,7 @@ def run_setup_wizard(args): get_active_provider, get_provider_auth_state, PROVIDER_REGISTRY, format_auth_error, AuthError, fetch_nous_models, resolve_nous_runtime_credentials, _update_config_for_provider, + _login_openai_codex, get_codex_auth_status, DEFAULT_CODEX_BASE_URL, ) existing_custom = get_env_value("OPENAI_BASE_URL") existing_or = get_env_value("OPENROUTER_API_KEY") @@ -633,6 +634,7 @@ def run_setup_wizard(args): provider_choices = [ "Login with Nous Portal (Nous Research subscription)", + "Login with OpenAI Codex (ChatGPT/Codex CLI auth)", "OpenRouter API key (100+ models, pay-per-use)", "Custom OpenAI-compatible endpoint (self-hosted / VLLM / etc.)", ] @@ -640,7 +642,7 @@ def run_setup_wizard(args): provider_choices.append(keep_label) # Default to "Keep current" if a provider exists, otherwise OpenRouter (most common) - default_provider = len(provider_choices) - 1 if has_any_provider else 1 + default_provider = len(provider_choices) - 1 if has_any_provider else 2 if not has_any_provider: print_warning("An inference provider is required for Hermes to work.") @@ -649,7 +651,7 @@ def run_setup_wizard(args): provider_idx = prompt_choice("Select your inference provider:", provider_choices, default_provider) # Track which provider was selected for model step - selected_provider = None # "nous", "openrouter", "custom", or None (keep) + selected_provider = None # "nous", "openai-codex", "openrouter", "custom", or None (keep) nous_models = [] # populated if Nous login succeeds if provider_idx == 0: # Nous Portal @@ -692,7 +694,33 @@ def run_setup_wizard(args): print_info("You can try again later with: hermes login") selected_provider = None - elif provider_idx == 1: # OpenRouter + elif provider_idx == 1: # OpenAI Codex + selected_provider = "openai-codex" + print() + print_header("OpenAI Codex Login") + print_info("This uses your Codex CLI auth state from CODEX_HOME/auth.json.") + print_info("If you're not logged in, Hermes will run `codex login`.") + print() + + try: + import argparse + mock_args = argparse.Namespace() + _login_openai_codex(mock_args, PROVIDER_REGISTRY["openai-codex"]) + # Clear custom endpoint vars that would override provider routing. + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) + except SystemExit: + print_warning("OpenAI Codex login was cancelled or failed.") + print_info("You can try again later with: hermes login --provider openai-codex") + selected_provider = None + except Exception as e: + print_error(f"Login failed: {e}") + print_info("You can try again later with: hermes login --provider openai-codex") + selected_provider = None + + elif provider_idx == 2: # OpenRouter selected_provider = "openrouter" print() print_header("OpenRouter API Key") @@ -719,7 +747,7 @@ def run_setup_wizard(args): save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_API_KEY", "") - elif provider_idx == 2: # Custom endpoint + elif provider_idx == 3: # Custom endpoint selected_provider = "custom" print() print_header("Custom OpenAI-Compatible Endpoint") @@ -746,14 +774,14 @@ def run_setup_wizard(args): config['model'] = model_name save_env_value("LLM_MODEL", model_name) print_success("Custom endpoint configured") - # else: provider_idx == 3 (Keep current) — only shown when a provider already exists + # else: provider_idx == 4 (Keep current) — only shown when a provider already exists # ========================================================================= # Step 1b: OpenRouter API Key for tools (if not already set) # ========================================================================= # Tools (vision, web, MoA) use OpenRouter independently of the main provider. # Prompt for OpenRouter key if not set and a non-OpenRouter provider was chosen. - if selected_provider in ("nous", "custom") and not get_env_value("OPENROUTER_API_KEY"): + if selected_provider in ("nous", "openai-codex", "custom") and not get_env_value("OPENROUTER_API_KEY"): print() print_header("OpenRouter API Key (for tools)") print_info("Tools like vision analysis, web search, and MoA use OpenRouter") @@ -799,6 +827,29 @@ def run_setup_wizard(args): config['model'] = custom save_env_value("LLM_MODEL", custom) # else: keep current + elif selected_provider == "openai-codex": + codex_models = [ + "gpt-5-codex", + "gpt-5.3-codex", + "gpt-5.2-codex", + "gpt-5.1-codex", + ] + model_choices = [f"{m}" for m in codex_models] + model_choices.append("Custom model") + model_choices.append(f"Keep current ({current_model})") + + keep_idx = len(model_choices) - 1 + model_idx = prompt_choice("Select default model:", model_choices, keep_idx) + + if model_idx < len(codex_models): + config['model'] = codex_models[model_idx] + save_env_value("LLM_MODEL", codex_models[model_idx]) + elif model_idx == len(codex_models): + custom = prompt("Enter model name") + if custom: + config['model'] = custom + save_env_value("LLM_MODEL", custom) + _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) else: # Static list for OpenRouter / fallback (from canonical list) from hermes_cli.models import model_ids, menu_labels diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 33ebd4983..4d542ece6 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -100,10 +100,12 @@ def show_status(args): print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) try: - from hermes_cli.auth import get_nous_auth_status + from hermes_cli.auth import get_nous_auth_status, get_codex_auth_status nous_status = get_nous_auth_status() + codex_status = get_codex_auth_status() except Exception: nous_status = {} + codex_status = {} nous_logged_in = bool(nous_status.get("logged_in")) print( @@ -120,6 +122,20 @@ def show_status(args): print(f" Key exp: {key_exp}") print(f" Refresh: {refresh_label}") + codex_logged_in = bool(codex_status.get("logged_in")) + print( + f" {'OpenAI Codex':<12} {check_mark(codex_logged_in)} " + f"{'logged in' if codex_logged_in else 'not logged in (run: hermes login --provider openai-codex)'}" + ) + codex_auth_file = codex_status.get("auth_file") + if codex_auth_file: + print(f" Auth file: {codex_auth_file}") + codex_last_refresh = _format_iso_timestamp(codex_status.get("last_refresh")) + if codex_status.get("last_refresh"): + print(f" Refreshed: {codex_last_refresh}") + if codex_status.get("error") and not codex_logged_in: + print(f" Error: {codex_status.get('error')}") + # ========================================================================= # Terminal Configuration # ========================================================================= diff --git a/run_agent.py b/run_agent.py index beb9d07a1..d1d3d27e5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -30,6 +30,7 @@ import re import sys import time import threading +from types import SimpleNamespace import uuid from typing import List, Dict, Any, Optional from openai import OpenAI @@ -95,6 +96,8 @@ class AIAgent: self, base_url: str = None, api_key: str = None, + provider: str = None, + api_mode: str = None, model: str = "anthropic/claude-opus-4.6", # OpenRouter format max_iterations: int = 60, # Default tool-calling iterations tool_delay: float = 1.0, @@ -127,6 +130,8 @@ class AIAgent: Args: base_url (str): Base URL for the model API (optional) api_key (str): API key for authentication (optional, uses env var if not provided) + provider (str): Provider identifier (optional; used for telemetry/routing hints) + api_mode (str): API mode override: "chat_completions" or "codex_responses" model (str): Model name to use (default: "anthropic/claude-opus-4.6") max_iterations (int): Maximum number of tool calling iterations (default: 60) tool_delay (float): Delay between tool calls in seconds (default: 1.0) @@ -172,6 +177,17 @@ class AIAgent: # Store effective base URL for feature detection (prompt caching, reasoning, etc.) # When no base_url is provided, the client defaults to OpenRouter, so reflect that here. self.base_url = base_url or OPENROUTER_BASE_URL + provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None + self.provider = provider_name or "openrouter" + if api_mode in {"chat_completions", "codex_responses"}: + self.api_mode = api_mode + elif self.provider == "openai-codex": + self.api_mode = "codex_responses" + elif (provider_name is None) and "chatgpt.com/backend-api/codex" in self.base_url.lower(): + self.api_mode = "codex_responses" + self.provider = "openai-codex" + else: + self.api_mode = "chat_completions" self.tool_progress_callback = tool_progress_callback self.clarify_callback = clarify_callback self._last_reported_tool = None # Track for "new tool" mode @@ -1122,6 +1138,220 @@ class AIAgent: if self._memory_store: self._memory_store.load_from_disk() + def _responses_tools(self, tools: Optional[List[Dict[str, Any]]] = None) -> Optional[List[Dict[str, Any]]]: + """Convert chat-completions tool schemas to Responses function-tool schemas.""" + source_tools = tools if tools is not None else self.tools + if not source_tools: + return None + + converted: List[Dict[str, Any]] = [] + for item in source_tools: + fn = item.get("function", {}) if isinstance(item, dict) else {} + name = fn.get("name") + if not isinstance(name, str) or not name.strip(): + continue + converted.append({ + "type": "function", + "name": name, + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {"type": "object", "properties": {}}), + }) + return converted or None + + def _chat_messages_to_responses_input(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert internal chat-style messages to Responses input items.""" + items: List[Dict[str, Any]] = [] + + for msg in messages: + if not isinstance(msg, dict): + continue + role = msg.get("role") + if role == "system": + continue + + if role in {"user", "assistant"}: + content = msg.get("content", "") + content_text = str(content) if content is not None else "" + + if role == "assistant": + if content_text.strip(): + items.append({"role": "assistant", "content": content_text}) + + tool_calls = msg.get("tool_calls") + if isinstance(tool_calls, list): + for tc in tool_calls: + if not isinstance(tc, dict): + continue + fn = tc.get("function", {}) + fn_name = fn.get("name") + if not isinstance(fn_name, str) or not fn_name.strip(): + continue + + call_id = tc.get("id") or tc.get("call_id") + if not isinstance(call_id, str) or not call_id.strip(): + call_id = f"call_{uuid.uuid4().hex[:12]}" + + arguments = fn.get("arguments", "{}") + if isinstance(arguments, dict): + arguments = json.dumps(arguments, ensure_ascii=False) + elif not isinstance(arguments, str): + arguments = str(arguments) + arguments = arguments.strip() or "{}" + + items.append({ + "type": "function_call", + "id": call_id, + "call_id": call_id, + "name": fn_name, + "arguments": arguments, + }) + continue + + items.append({"role": role, "content": content_text}) + continue + + if role == "tool": + call_id = msg.get("tool_call_id") + if not isinstance(call_id, str) or not call_id.strip(): + continue + items.append({ + "type": "function_call_output", + "call_id": call_id, + "output": str(msg.get("content", "") or ""), + }) + + return items + + def _extract_responses_message_text(self, item: Any) -> str: + """Extract assistant text from a Responses message output item.""" + content = getattr(item, "content", None) + if not isinstance(content, list): + return "" + + chunks: List[str] = [] + for part in content: + ptype = getattr(part, "type", None) + if ptype not in {"output_text", "text"}: + continue + text = getattr(part, "text", None) + if isinstance(text, str) and text: + chunks.append(text) + return "".join(chunks).strip() + + def _extract_responses_reasoning_text(self, item: Any) -> str: + """Extract a compact reasoning text from a Responses reasoning item.""" + summary = getattr(item, "summary", None) + if isinstance(summary, list): + chunks: List[str] = [] + for part in summary: + text = getattr(part, "text", None) + if isinstance(text, str) and text: + chunks.append(text) + if chunks: + return "\n".join(chunks).strip() + text = getattr(item, "text", None) + if isinstance(text, str) and text: + return text.strip() + return "" + + def _normalize_codex_response(self, response: Any) -> tuple[Any, str]: + """Normalize a Responses API object to an assistant_message-like object.""" + output = getattr(response, "output", None) + if not isinstance(output, list) or not output: + raise RuntimeError("Responses API returned no output items") + + response_status = getattr(response, "status", None) + if isinstance(response_status, str): + response_status = response_status.strip().lower() + else: + response_status = None + + if response_status in {"failed", "cancelled"}: + error_obj = getattr(response, "error", None) + if isinstance(error_obj, dict): + error_msg = error_obj.get("message") or str(error_obj) + else: + error_msg = str(error_obj) if error_obj else f"Responses API returned status '{response_status}'" + raise RuntimeError(error_msg) + + content_parts: List[str] = [] + reasoning_parts: List[str] = [] + tool_calls: List[Any] = [] + has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"} + + for item in output: + item_type = getattr(item, "type", None) + item_status = getattr(item, "status", None) + if isinstance(item_status, str): + item_status = item_status.strip().lower() + else: + item_status = None + + if item_status in {"queued", "in_progress", "incomplete"}: + has_incomplete_items = True + + if item_type == "message": + message_text = self._extract_responses_message_text(item) + if message_text: + content_parts.append(message_text) + elif item_type == "reasoning": + reasoning_text = self._extract_responses_reasoning_text(item) + if reasoning_text: + reasoning_parts.append(reasoning_text) + elif item_type == "function_call": + if item_status in {"queued", "in_progress", "incomplete"}: + continue + fn_name = getattr(item, "name", "") or "" + arguments = getattr(item, "arguments", "{}") + if not isinstance(arguments, str): + arguments = str(arguments) + call_id = getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{uuid.uuid4().hex[:12]}" + tool_calls.append(SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace(name=fn_name, arguments=arguments), + )) + elif item_type == "custom_tool_call": + fn_name = getattr(item, "name", "") or "" + arguments = getattr(item, "input", "{}") + if not isinstance(arguments, str): + arguments = str(arguments) + call_id = getattr(item, "call_id", None) or getattr(item, "id", None) or f"call_{uuid.uuid4().hex[:12]}" + tool_calls.append(SimpleNamespace( + id=call_id, + type="function", + function=SimpleNamespace(name=fn_name, arguments=arguments), + )) + + final_text = "\n".join([p for p in content_parts if p]).strip() + if not final_text and hasattr(response, "output_text"): + out_text = getattr(response, "output_text", "") + if isinstance(out_text, str): + final_text = out_text.strip() + + assistant_message = SimpleNamespace( + content=final_text, + tool_calls=tool_calls, + reasoning="\n\n".join(reasoning_parts).strip() if reasoning_parts else None, + reasoning_content=None, + reasoning_details=None, + ) + + if tool_calls: + finish_reason = "tool_calls" + elif has_incomplete_items: + finish_reason = "incomplete" + else: + finish_reason = "stop" + return assistant_message, finish_reason + + def _run_codex_stream(self, api_kwargs: dict): + """Execute one streaming Responses API request and return the final response.""" + with self.client.responses.stream(**api_kwargs) as stream: + for _ in stream: + pass + return stream.get_final_response() + def _interruptible_api_call(self, api_kwargs: dict): """ Run the API call in a background thread so the main conversation loop @@ -1135,7 +1365,10 @@ class AIAgent: def _call(): try: - result["response"] = self.client.chat.completions.create(**api_kwargs) + if self.api_mode == "codex_responses": + result["response"] = self._run_codex_stream(api_kwargs) + else: + result["response"] = self.client.chat.completions.create(**api_kwargs) except Exception as e: result["error"] = e @@ -1160,7 +1393,24 @@ class AIAgent: return result["response"] def _build_api_kwargs(self, api_messages: list) -> dict: - """Build the keyword arguments dict for the chat completions API call.""" + """Build the keyword arguments dict for the active API mode.""" + if self.api_mode == "codex_responses": + instructions = "" + payload_messages = api_messages + if api_messages and api_messages[0].get("role") == "system": + instructions = str(api_messages[0].get("content") or "").strip() + payload_messages = api_messages[1:] + if not instructions: + instructions = DEFAULT_AGENT_IDENTITY + + return { + "model": self.model, + "instructions": instructions, + "input": self._chat_messages_to_responses_input(payload_messages), + "tools": self._responses_tools(), + "store": False, + } + provider_preferences = {} if self.providers_allowed: provider_preferences["only"] = self.providers_allowed @@ -1308,36 +1558,43 @@ class AIAgent: messages.pop() # remove flush msg return - api_kwargs = { - "model": self.model, - "messages": api_messages, - "tools": [memory_tool_def], - "temperature": 0.3, - "max_tokens": 1024, - } + if self.api_mode == "codex_responses": + codex_kwargs = self._build_api_kwargs(api_messages) + codex_kwargs["tools"] = self._responses_tools([memory_tool_def]) + response = self._run_codex_stream(codex_kwargs) + assistant_message, _ = self._normalize_codex_response(response) + else: + api_kwargs = { + "model": self.model, + "messages": api_messages, + "tools": [memory_tool_def], + "temperature": 0.3, + "max_tokens": 1024, + } + response = self.client.chat.completions.create(**api_kwargs, timeout=30.0) + if not response.choices: + assistant_message = None + else: + assistant_message = response.choices[0].message - response = self.client.chat.completions.create(**api_kwargs, timeout=30.0) - - if response.choices: - assistant_message = response.choices[0].message - if assistant_message.tool_calls: - # Execute only memory tool calls - for tc in assistant_message.tool_calls: - if tc.function.name == "memory": - try: - args = json.loads(tc.function.arguments) - from tools.memory_tool import memory_tool as _memory_tool - result = _memory_tool( - action=args.get("action"), - target=args.get("target", "memory"), - content=args.get("content"), - old_text=args.get("old_text"), - store=self._memory_store, - ) - if not self.quiet_mode: - print(f" 🧠 Memory flush: saved to {args.get('target', 'memory')}") - except Exception as e: - logger.debug("Memory flush tool call failed: %s", e) + if assistant_message and assistant_message.tool_calls: + # Execute only memory tool calls + for tc in assistant_message.tool_calls: + if tc.function.name == "memory": + try: + args = json.loads(tc.function.arguments) + from tools.memory_tool import memory_tool as _memory_tool + _memory_tool( + action=args.get("action"), + target=args.get("target", "memory"), + content=args.get("content"), + old_text=args.get("old_text"), + store=self._memory_store, + ) + if not self.quiet_mode: + print(f" 🧠 Memory flush: saved to {args.get('target', 'memory')}") + except Exception as e: + logger.debug("Memory flush tool call failed: %s", e) except Exception as e: logger.debug("Memory flush API call failed: %s", e) finally: @@ -1628,24 +1885,37 @@ class AIAgent: if _is_nous: summary_extra_body["tags"] = ["product=hermes-agent"] - summary_kwargs = { - "model": self.model, - "messages": api_messages, - } - if self.max_tokens is not None: - summary_kwargs["max_tokens"] = self.max_tokens - if summary_extra_body: - summary_kwargs["extra_body"] = summary_extra_body - - summary_response = self.client.chat.completions.create(**summary_kwargs) - - if summary_response.choices and summary_response.choices[0].message.content: - final_response = summary_response.choices[0].message.content + if self.api_mode == "codex_responses": + summary_kwargs = self._build_api_kwargs(api_messages) + summary_kwargs["tools"] = None + summary_response = self._run_codex_stream(summary_kwargs) + assistant_message, _ = self._normalize_codex_response(summary_response) + final_response = assistant_message.content or "" if "" in final_response: final_response = re.sub(r'.*?\s*', '', final_response, flags=re.DOTALL).strip() - messages.append({"role": "assistant", "content": final_response}) + if final_response: + messages.append({"role": "assistant", "content": final_response}) + else: + final_response = "I reached the iteration limit and couldn't generate a summary." else: - final_response = "I reached the iteration limit and couldn't generate a summary." + summary_kwargs = { + "model": self.model, + "messages": api_messages, + } + if self.max_tokens is not None: + summary_kwargs["max_tokens"] = self.max_tokens + if summary_extra_body: + summary_kwargs["extra_body"] = summary_extra_body + + summary_response = self.client.chat.completions.create(**summary_kwargs) + + if summary_response.choices and summary_response.choices[0].message.content: + final_response = summary_response.choices[0].message.content + if "" in final_response: + final_response = re.sub(r'.*?\s*', '', final_response, flags=re.DOTALL).strip() + messages.append({"role": "assistant", "content": final_response}) + else: + final_response = "I reached the iteration limit and couldn't generate a summary." except Exception as e: logging.warning(f"Failed to get summary response: {e}") @@ -1848,6 +2118,8 @@ class AIAgent: retry_count = 0 max_retries = 6 # Increased to allow longer backoff periods + finish_reason = "stop" + while retry_count <= max_retries: try: api_kwargs = self._build_api_kwargs(api_messages) @@ -1873,8 +2145,33 @@ class AIAgent: resp_model = getattr(response, 'model', 'N/A') if response else 'N/A' logging.debug(f"API Response received - Model: {resp_model}, Usage: {response.usage if hasattr(response, 'usage') else 'N/A'}") - # Validate response has valid choices before proceeding - if response is None or not hasattr(response, 'choices') or response.choices is None or len(response.choices) == 0: + # Validate response shape before proceeding + response_invalid = False + error_details = [] + if self.api_mode == "codex_responses": + output_items = getattr(response, "output", None) if response is not None else None + if response is None: + response_invalid = True + error_details.append("response is None") + elif not isinstance(output_items, list): + response_invalid = True + error_details.append("response.output is not a list") + elif len(output_items) == 0: + response_invalid = True + error_details.append("response.output is empty") + else: + if response is None or not hasattr(response, 'choices') or response.choices is None or len(response.choices) == 0: + response_invalid = True + if response is None: + error_details.append("response is None") + elif not hasattr(response, 'choices'): + error_details.append("response has no 'choices' attribute") + elif response.choices is None: + error_details.append("response.choices is None") + else: + error_details.append("response.choices is empty") + + if response_invalid: # Stop spinner before printing error messages if thinking_spinner: thinking_spinner.stop(f"(´;ω;`) oops, retrying...") @@ -1882,15 +2179,6 @@ class AIAgent: # This is often rate limiting or provider returning malformed response retry_count += 1 - error_details = [] - if response is None: - error_details.append("response is None") - elif not hasattr(response, 'choices'): - error_details.append("response has no 'choices' attribute") - elif response.choices is None: - error_details.append("response.choices is None") - else: - error_details.append("response.choices is empty") # Check for error field in response (some providers include this) error_msg = "Unknown" @@ -1927,7 +2215,7 @@ class AIAgent: "messages": messages, "completed": False, "api_calls": api_call_count, - "error": f"Invalid API response (choices is None/empty). Likely rate limited by provider.", + "error": "Invalid API response shape. Likely rate limited or malformed provider response.", "failed": True # Mark as failure for filtering } @@ -1953,7 +2241,20 @@ class AIAgent: continue # Retry the API call # Check finish_reason before proceeding - finish_reason = response.choices[0].finish_reason + if self.api_mode == "codex_responses": + status = getattr(response, "status", None) + incomplete_details = getattr(response, "incomplete_details", None) + incomplete_reason = None + if isinstance(incomplete_details, dict): + incomplete_reason = incomplete_details.get("reason") + else: + incomplete_reason = getattr(incomplete_details, "reason", None) + if status == "incomplete" and incomplete_reason in {"max_output_tokens", "length"}: + finish_reason = "length" + else: + finish_reason = "stop" + else: + finish_reason = response.choices[0].finish_reason # Handle "length" finish_reason - response was truncated if finish_reason == "length": @@ -1990,10 +2291,21 @@ class AIAgent: # Track actual token usage from response for context management if hasattr(response, 'usage') and response.usage: + if self.api_mode == "codex_responses": + prompt_tokens = getattr(response.usage, 'input_tokens', 0) or 0 + completion_tokens = getattr(response.usage, 'output_tokens', 0) or 0 + total_tokens = ( + getattr(response.usage, 'total_tokens', None) + or (prompt_tokens + completion_tokens) + ) + else: + prompt_tokens = getattr(response.usage, 'prompt_tokens', 0) or 0 + completion_tokens = getattr(response.usage, 'completion_tokens', 0) or 0 + total_tokens = getattr(response.usage, 'total_tokens', 0) or 0 usage_dict = { - "prompt_tokens": getattr(response.usage, 'prompt_tokens', 0), - "completion_tokens": getattr(response.usage, 'completion_tokens', 0), - "total_tokens": getattr(response.usage, 'total_tokens', 0), + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, } self.context_compressor.update_from_response(usage_dict) @@ -2145,7 +2457,10 @@ class AIAgent: break try: - assistant_message = response.choices[0].message + if self.api_mode == "codex_responses": + assistant_message, finish_reason = self._normalize_codex_response(response) + else: + assistant_message = response.choices[0].message # Handle assistant response if assistant_message.content and not self.quiet_mode: @@ -2185,6 +2500,48 @@ class AIAgent: # Reset incomplete scratchpad counter on clean response if hasattr(self, '_incomplete_scratchpad_retries'): self._incomplete_scratchpad_retries = 0 + + if self.api_mode == "codex_responses" and finish_reason == "incomplete": + if not hasattr(self, "_codex_incomplete_retries"): + self._codex_incomplete_retries = 0 + self._codex_incomplete_retries += 1 + + interim_msg = self._build_assistant_message(assistant_message, finish_reason) + interim_has_content = bool(interim_msg.get("content", "").strip()) + interim_has_reasoning = bool(interim_msg.get("reasoning", "").strip()) if isinstance(interim_msg.get("reasoning"), str) else False + + if interim_has_content or interim_has_reasoning: + last_msg = messages[-1] if messages else None + duplicate_interim = ( + isinstance(last_msg, dict) + and last_msg.get("role") == "assistant" + and last_msg.get("finish_reason") == "incomplete" + and (last_msg.get("content") or "") == (interim_msg.get("content") or "") + and (last_msg.get("reasoning") or "") == (interim_msg.get("reasoning") or "") + ) + if not duplicate_interim: + messages.append(interim_msg) + self._log_msg_to_db(interim_msg) + + if self._codex_incomplete_retries < 3: + if not self.quiet_mode: + print(f"{self.log_prefix}↻ Codex response incomplete; continuing turn ({self._codex_incomplete_retries}/3)") + self._session_messages = messages + self._save_session_log(messages) + continue + + self._codex_incomplete_retries = 0 + self._persist_session(messages, conversation_history) + return { + "final_response": None, + "messages": messages, + "api_calls": api_call_count, + "completed": False, + "partial": True, + "error": "Codex response remained incomplete after 3 continuation attempts", + } + elif hasattr(self, "_codex_incomplete_retries"): + self._codex_incomplete_retries = 0 # Check for tool calls if assistant_message.tool_calls: diff --git a/tests/test_auth_codex_provider.py b/tests/test_auth_codex_provider.py new file mode 100644 index 000000000..eaca52aac --- /dev/null +++ b/tests/test_auth_codex_provider.py @@ -0,0 +1,114 @@ +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +import yaml + +from hermes_cli.auth import ( + AuthError, + DEFAULT_CODEX_BASE_URL, + PROVIDER_REGISTRY, + _login_openai_codex, + login_command, + get_codex_auth_status, + get_provider_auth_state, + read_codex_auth_file, + resolve_codex_runtime_credentials, + resolve_provider, +) + + +def _write_codex_auth(codex_home: Path, *, access_token: str = "access", refresh_token: str = "refresh") -> Path: + codex_home.mkdir(parents=True, exist_ok=True) + auth_file = codex_home / "auth.json" + auth_file.write_text( + json.dumps( + { + "auth_mode": "oauth", + "last_refresh": "2026-02-26T00:00:00Z", + "tokens": { + "access_token": access_token, + "refresh_token": refresh_token, + }, + } + ) + ) + return auth_file + + +def test_read_codex_auth_file_success(tmp_path, monkeypatch): + codex_home = tmp_path / "codex-home" + auth_file = _write_codex_auth(codex_home) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + payload = read_codex_auth_file() + + assert payload["auth_path"] == auth_file + assert payload["tokens"]["access_token"] == "access" + assert payload["tokens"]["refresh_token"] == "refresh" + + +def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkeypatch): + codex_home = tmp_path / "codex-home" + _write_codex_auth(codex_home, access_token="") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + with pytest.raises(AuthError) as exc: + resolve_codex_runtime_credentials() + + assert exc.value.code == "codex_auth_missing_access_token" + assert exc.value.relogin_required is True + + +def test_resolve_provider_explicit_codex_does_not_fallback(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + assert resolve_provider("openai-codex") == "openai-codex" + + +def test_get_codex_auth_status_not_logged_in(tmp_path, monkeypatch): + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex-home")) + status = get_codex_auth_status() + assert status["logged_in"] is False + assert "error" in status + + +def test_login_openai_codex_persists_provider_state(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes-home" + codex_home = tmp_path / "codex-home" + _write_codex_auth(codex_home) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setattr("hermes_cli.auth.shutil.which", lambda _: "/usr/local/bin/codex") + monkeypatch.setattr("hermes_cli.auth.subprocess.run", lambda *a, **k: None) + + _login_openai_codex(SimpleNamespace(), PROVIDER_REGISTRY["openai-codex"]) + + state = get_provider_auth_state("openai-codex") + assert state is not None + assert state["source"] == "codex-auth-json" + assert state["auth_file"].endswith("auth.json") + + config_path = hermes_home / "config.yaml" + config = yaml.safe_load(config_path.read_text()) + assert config["model"]["provider"] == "openai-codex" + assert config["model"]["base_url"] == DEFAULT_CODEX_BASE_URL + + +def test_login_command_defaults_to_nous(monkeypatch): + calls = {"nous": 0, "codex": 0} + + def _fake_nous(args, pconfig): + calls["nous"] += 1 + + def _fake_codex(args, pconfig): + calls["codex"] += 1 + + monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_nous) + monkeypatch.setattr("hermes_cli.auth._login_openai_codex", _fake_codex) + + login_command(SimpleNamespace()) + + assert calls["nous"] == 1 + assert calls["codex"] == 0 diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py new file mode 100644 index 000000000..3c8fe14a5 --- /dev/null +++ b/tests/test_cli_provider_resolution.py @@ -0,0 +1,187 @@ +import importlib +import sys +import types +from contextlib import nullcontext +from types import SimpleNamespace + +from hermes_cli.auth import AuthError +from hermes_cli import main as hermes_main + + +def _install_prompt_toolkit_stubs(): + class _Dummy: + def __init__(self, *args, **kwargs): + pass + + class _Condition: + def __init__(self, func): + self.func = func + + def __bool__(self): + return bool(self.func()) + + class _ANSI(str): + pass + + root = types.ModuleType("prompt_toolkit") + history = types.ModuleType("prompt_toolkit.history") + styles = types.ModuleType("prompt_toolkit.styles") + patch_stdout = types.ModuleType("prompt_toolkit.patch_stdout") + application = types.ModuleType("prompt_toolkit.application") + layout = types.ModuleType("prompt_toolkit.layout") + processors = types.ModuleType("prompt_toolkit.layout.processors") + filters = types.ModuleType("prompt_toolkit.filters") + dimension = types.ModuleType("prompt_toolkit.layout.dimension") + menus = types.ModuleType("prompt_toolkit.layout.menus") + widgets = types.ModuleType("prompt_toolkit.widgets") + key_binding = types.ModuleType("prompt_toolkit.key_binding") + completion = types.ModuleType("prompt_toolkit.completion") + formatted_text = types.ModuleType("prompt_toolkit.formatted_text") + + history.FileHistory = _Dummy + styles.Style = _Dummy + patch_stdout.patch_stdout = lambda *args, **kwargs: nullcontext() + application.Application = _Dummy + layout.Layout = _Dummy + layout.HSplit = _Dummy + layout.Window = _Dummy + layout.FormattedTextControl = _Dummy + layout.ConditionalContainer = _Dummy + processors.Processor = _Dummy + processors.Transformation = _Dummy + processors.PasswordProcessor = _Dummy + processors.ConditionalProcessor = _Dummy + filters.Condition = _Condition + dimension.Dimension = _Dummy + menus.CompletionsMenu = _Dummy + widgets.TextArea = _Dummy + key_binding.KeyBindings = _Dummy + completion.Completer = _Dummy + completion.Completion = _Dummy + formatted_text.ANSI = _ANSI + root.print_formatted_text = lambda *args, **kwargs: None + + sys.modules.setdefault("prompt_toolkit", root) + sys.modules.setdefault("prompt_toolkit.history", history) + sys.modules.setdefault("prompt_toolkit.styles", styles) + sys.modules.setdefault("prompt_toolkit.patch_stdout", patch_stdout) + sys.modules.setdefault("prompt_toolkit.application", application) + sys.modules.setdefault("prompt_toolkit.layout", layout) + sys.modules.setdefault("prompt_toolkit.layout.processors", processors) + sys.modules.setdefault("prompt_toolkit.filters", filters) + sys.modules.setdefault("prompt_toolkit.layout.dimension", dimension) + sys.modules.setdefault("prompt_toolkit.layout.menus", menus) + sys.modules.setdefault("prompt_toolkit.widgets", widgets) + sys.modules.setdefault("prompt_toolkit.key_binding", key_binding) + sys.modules.setdefault("prompt_toolkit.completion", completion) + sys.modules.setdefault("prompt_toolkit.formatted_text", formatted_text) + + +def _import_cli(): + try: + importlib.import_module("prompt_toolkit") + except ModuleNotFoundError: + _install_prompt_toolkit_stubs() + return importlib.import_module("cli") + + +def test_hermes_cli_init_does_not_eagerly_resolve_runtime_provider(monkeypatch): + cli = _import_cli() + calls = {"count": 0} + + def _unexpected_runtime_resolve(**kwargs): + calls["count"] += 1 + raise AssertionError("resolve_runtime_provider should not be called in HermesCLI.__init__") + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _unexpected_runtime_resolve) + monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) + + shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1) + + assert shell is not None + assert calls["count"] == 0 + + +def test_runtime_resolution_failure_is_not_sticky(monkeypatch): + cli = _import_cli() + calls = {"count": 0} + + def _runtime_resolve(**kwargs): + calls["count"] += 1 + if calls["count"] == 1: + raise RuntimeError("temporary auth failure") + return { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "test-key", + "source": "env/config", + } + + class _DummyAgent: + def __init__(self, *args, **kwargs): + self.kwargs = kwargs + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) + monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) + monkeypatch.setattr(cli, "AIAgent", _DummyAgent) + + shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1) + + assert shell._init_agent() is False + assert shell._init_agent() is True + assert calls["count"] == 2 + assert shell.agent is not None + + +def test_runtime_resolution_rebuilds_agent_on_routing_change(monkeypatch): + cli = _import_cli() + + def _runtime_resolve(**kwargs): + return { + "provider": "openai-codex", + "api_mode": "codex_responses", + "base_url": "https://same-endpoint.example/v1", + "api_key": "same-key", + "source": "env/config", + } + + monkeypatch.setattr("hermes_cli.runtime_provider.resolve_runtime_provider", _runtime_resolve) + monkeypatch.setattr("hermes_cli.runtime_provider.format_runtime_provider_error", lambda exc: str(exc)) + + shell = cli.HermesCLI(model="gpt-5", compact=True, max_turns=1) + shell.provider = "openrouter" + shell.api_mode = "chat_completions" + shell.base_url = "https://same-endpoint.example/v1" + shell.api_key = "same-key" + shell.agent = object() + + assert shell._ensure_runtime_credentials() is True + assert shell.agent is None + assert shell.provider == "openai-codex" + assert shell.api_mode == "codex_responses" + + +def test_cmd_model_falls_back_to_auto_on_invalid_provider(monkeypatch, capsys): + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"default": "gpt-5", "provider": "invalid-provider"}}, + ) + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None) + monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "") + monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None) + + def _resolve_provider(requested, **kwargs): + if requested == "invalid-provider": + raise AuthError("Unknown provider 'invalid-provider'.", code="invalid_provider") + return "openrouter" + + monkeypatch.setattr("hermes_cli.auth.resolve_provider", _resolve_provider) + monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: len(choices) - 1) + + hermes_main.cmd_model(SimpleNamespace()) + output = capsys.readouterr().out + + assert "Warning:" in output + assert "falling back to auto provider detection" in output.lower() + assert "No change." in output diff --git a/tests/test_delegate.py b/tests/test_delegate.py index 811940a02..8fb16be61 100644 --- a/tests/test_delegate.py +++ b/tests/test_delegate.py @@ -33,6 +33,9 @@ def _make_mock_parent(depth=0): """Create a mock parent agent with the fields delegate_task expects.""" parent = MagicMock() parent.base_url = "https://openrouter.ai/api/v1" + parent.api_key = "parent-key" + parent.provider = "openrouter" + parent.api_mode = "chat_completions" parent.model = "anthropic/claude-sonnet-4" parent.platform = "cli" parent.providers_allowed = None @@ -221,6 +224,30 @@ class TestDelegateTask(unittest.TestCase): delegate_task(goal="Test tracking", parent_agent=parent) self.assertEqual(len(parent._active_children), 0) + def test_child_inherits_runtime_credentials(self): + parent = _make_mock_parent(depth=0) + parent.base_url = "https://chatgpt.com/backend-api/codex" + parent.api_key = "codex-token" + parent.provider = "openai-codex" + parent.api_mode = "codex_responses" + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "ok", + "completed": True, + "api_calls": 1, + } + MockAgent.return_value = mock_child + + delegate_task(goal="Test runtime inheritance", parent_agent=parent) + + _, kwargs = MockAgent.call_args + self.assertEqual(kwargs["base_url"], parent.base_url) + self.assertEqual(kwargs["api_key"], parent.api_key) + self.assertEqual(kwargs["provider"], parent.provider) + self.assertEqual(kwargs["api_mode"], parent.api_mode) + class TestBlockedTools(unittest.TestCase): def test_blocked_tools_constant(self): diff --git a/tests/test_run_agent_codex_responses.py b/tests/test_run_agent_codex_responses.py new file mode 100644 index 000000000..846d9c1c0 --- /dev/null +++ b/tests/test_run_agent_codex_responses.py @@ -0,0 +1,231 @@ +import sys +import types +from types import SimpleNamespace + + +sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) +sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) +sys.modules.setdefault("fal_client", types.SimpleNamespace()) + +import run_agent + + +def _patch_agent_bootstrap(monkeypatch): + monkeypatch.setattr( + run_agent, + "get_tool_definitions", + lambda **kwargs: [ + { + "type": "function", + "function": { + "name": "terminal", + "description": "Run shell commands.", + "parameters": {"type": "object", "properties": {}}, + }, + } + ], + ) + monkeypatch.setattr(run_agent, "check_toolset_requirements", lambda: {}) + + +def _build_agent(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + + agent = run_agent.AIAgent( + model="gpt-5-codex", + base_url="https://chatgpt.com/backend-api/codex", + api_key="codex-token", + quiet_mode=True, + max_iterations=4, + skip_context_files=True, + skip_memory=True, + ) + agent._cleanup_task_resources = lambda task_id: None + agent._persist_session = lambda messages, history=None: None + agent._save_trajectory = lambda messages, user_message, completed: None + agent._save_session_log = lambda messages: None + return agent + + +def _codex_message_response(text: str): + return SimpleNamespace( + output=[ + SimpleNamespace( + type="message", + content=[SimpleNamespace(type="output_text", text=text)], + ) + ], + usage=SimpleNamespace(input_tokens=5, output_tokens=3, total_tokens=8), + status="completed", + model="gpt-5-codex", + ) + + +def _codex_tool_call_response(): + return SimpleNamespace( + output=[ + SimpleNamespace( + type="function_call", + id="call_1", + call_id="call_1", + name="terminal", + arguments="{}", + ) + ], + usage=SimpleNamespace(input_tokens=12, output_tokens=4, total_tokens=16), + status="completed", + model="gpt-5-codex", + ) + + +def _codex_incomplete_message_response(text: str): + return SimpleNamespace( + output=[ + SimpleNamespace( + type="message", + status="in_progress", + content=[SimpleNamespace(type="output_text", text=text)], + ) + ], + usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6), + status="in_progress", + model="gpt-5-codex", + ) + + +def test_api_mode_uses_explicit_provider_when_codex(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="gpt-5-codex", + base_url="https://openrouter.ai/api/v1", + provider="openai-codex", + api_key="codex-token", + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + ) + assert agent.api_mode == "codex_responses" + assert agent.provider == "openai-codex" + + +def test_api_mode_normalizes_provider_case(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="gpt-5-codex", + base_url="https://openrouter.ai/api/v1", + provider="OpenAI-Codex", + api_key="codex-token", + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + ) + assert agent.provider == "openai-codex" + assert agent.api_mode == "codex_responses" + + +def test_api_mode_respects_explicit_openrouter_provider_over_codex_url(monkeypatch): + _patch_agent_bootstrap(monkeypatch) + agent = run_agent.AIAgent( + model="gpt-5-codex", + base_url="https://chatgpt.com/backend-api/codex", + provider="openrouter", + api_key="test-token", + quiet_mode=True, + max_iterations=1, + skip_context_files=True, + skip_memory=True, + ) + assert agent.api_mode == "chat_completions" + assert agent.provider == "openrouter" + + +def test_build_api_kwargs_codex(monkeypatch): + agent = _build_agent(monkeypatch) + kwargs = agent._build_api_kwargs( + [ + {"role": "system", "content": "You are Hermes."}, + {"role": "user", "content": "Ping"}, + ] + ) + + assert kwargs["model"] == "gpt-5-codex" + assert kwargs["instructions"] == "You are Hermes." + assert kwargs["store"] is False + assert isinstance(kwargs["input"], list) + assert kwargs["input"][0]["role"] == "user" + assert kwargs["tools"][0]["type"] == "function" + assert kwargs["tools"][0]["name"] == "terminal" + assert "function" not in kwargs["tools"][0] + + +def test_run_conversation_codex_plain_text(monkeypatch): + agent = _build_agent(monkeypatch) + monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: _codex_message_response("OK")) + + result = agent.run_conversation("Say OK") + + assert result["completed"] is True + assert result["final_response"] == "OK" + assert result["messages"][-1]["role"] == "assistant" + assert result["messages"][-1]["content"] == "OK" + + +def test_run_conversation_codex_tool_round_trip(monkeypatch): + agent = _build_agent(monkeypatch) + responses = [_codex_tool_call_response(), _codex_message_response("done")] + monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) + + def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): + for call in assistant_message.tool_calls: + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": '{"ok":true}', + } + ) + + monkeypatch.setattr(agent, "_execute_tool_calls", _fake_execute_tool_calls) + + result = agent.run_conversation("run a command") + + assert result["completed"] is True + assert result["final_response"] == "done" + assert any(msg.get("tool_calls") for msg in result["messages"] if msg.get("role") == "assistant") + assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"]) + + +def test_run_conversation_codex_continues_after_incomplete_interim_message(monkeypatch): + agent = _build_agent(monkeypatch) + responses = [ + _codex_incomplete_message_response("I'll inspect the repo structure first."), + _codex_tool_call_response(), + _codex_message_response("Architecture summary complete."), + ] + monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0)) + + def _fake_execute_tool_calls(assistant_message, messages, effective_task_id): + for call in assistant_message.tool_calls: + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": '{"ok":true}', + } + ) + + monkeypatch.setattr(agent, "_execute_tool_calls", _fake_execute_tool_calls) + + result = agent.run_conversation("analyze repo") + + assert result["completed"] is True + assert result["final_response"] == "Architecture summary complete." + assert any( + msg.get("role") == "assistant" + and msg.get("finish_reason") == "incomplete" + and "inspect the repo structure" in (msg.get("content") or "") + for msg in result["messages"] + ) + assert any(msg.get("role") == "tool" and msg.get("tool_call_id") == "call_1" for msg in result["messages"]) diff --git a/tests/test_runtime_provider_resolution.py b/tests/test_runtime_provider_resolution.py new file mode 100644 index 000000000..af6914092 --- /dev/null +++ b/tests/test_runtime_provider_resolution.py @@ -0,0 +1,95 @@ +from hermes_cli import runtime_provider as rp + + +def test_resolve_runtime_provider_codex(monkeypatch): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openai-codex") + monkeypatch.setattr( + rp, + "resolve_codex_runtime_credentials", + lambda: { + "provider": "openai-codex", + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "codex-token", + "source": "codex-auth-json", + "auth_file": "/tmp/auth.json", + "codex_home": "/tmp/codex", + "last_refresh": "2026-02-26T00:00:00Z", + }, + ) + + resolved = rp.resolve_runtime_provider(requested="openai-codex") + + assert resolved["provider"] == "openai-codex" + assert resolved["api_mode"] == "codex_responses" + assert resolved["base_url"] == "https://chatgpt.com/backend-api/codex" + assert resolved["api_key"] == "codex-token" + assert resolved["requested_provider"] == "openai-codex" + + +def test_resolve_runtime_provider_openrouter_explicit(monkeypatch): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr(rp, "_get_model_config", lambda: {}) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + resolved = rp.resolve_runtime_provider( + requested="openrouter", + explicit_api_key="test-key", + explicit_base_url="https://example.com/v1/", + ) + + assert resolved["provider"] == "openrouter" + assert resolved["api_mode"] == "chat_completions" + assert resolved["api_key"] == "test-key" + assert resolved["base_url"] == "https://example.com/v1" + assert resolved["source"] == "explicit" + + +def test_resolve_runtime_provider_openrouter_ignores_codex_config_base_url(monkeypatch): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openai-codex", + "base_url": "https://chatgpt.com/backend-api/codex", + }, + ) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + resolved = rp.resolve_runtime_provider(requested="openrouter") + + assert resolved["provider"] == "openrouter" + assert resolved["base_url"] == rp.OPENROUTER_BASE_URL + + +def test_resolve_runtime_provider_auto_uses_custom_config_base_url(monkeypatch): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "auto", + "base_url": "https://custom.example/v1/", + }, + ) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + + resolved = rp.resolve_runtime_provider(requested="auto") + + assert resolved["provider"] == "openrouter" + assert resolved["base_url"] == "https://custom.example/v1" + + +def test_resolve_requested_provider_precedence(monkeypatch): + monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "nous") + monkeypatch.setattr(rp, "_get_model_config", lambda: {"provider": "openai-codex"}) + assert rp.resolve_requested_provider("openrouter") == "openrouter" diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 111beb33a..db72a5f1a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -101,7 +101,10 @@ def _run_single_child( try: child = AIAgent( base_url=parent_agent.base_url, + api_key=getattr(parent_agent, "api_key", None), model=model or parent_agent.model, + provider=getattr(parent_agent, "provider", None), + api_mode=getattr(parent_agent, "api_mode", None), max_iterations=max_iterations, enabled_toolsets=child_toolsets, quiet_mode=True,