mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Add OpenAI Codex provider runtime and responses integration (without .agent/PLANS.md)
This commit is contained in:
parent
e3cb957a10
commit
609b19b630
19 changed files with 1713 additions and 145 deletions
15
.env.example
15
.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
|
||||
# =============================================================================
|
||||
|
|
|
|||
16
README.md
16
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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
86
cli.py
86
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
|
||||
|
|
|
|||
|
|
@ -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')}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
149
hermes_cli/runtime_provider.py
Normal file
149
hermes_cli/runtime_provider.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
483
run_agent.py
483
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 "<think>" in final_response:
|
||||
final_response = re.sub(r'<think>.*?</think>\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 "<think>" in final_response:
|
||||
final_response = re.sub(r'<think>.*?</think>\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:
|
||||
|
|
|
|||
114
tests/test_auth_codex_provider.py
Normal file
114
tests/test_auth_codex_provider.py
Normal file
|
|
@ -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
|
||||
187
tests/test_cli_provider_resolution.py
Normal file
187
tests/test_cli_provider_resolution.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
231
tests/test_run_agent_codex_responses.py
Normal file
231
tests/test_run_agent_codex_responses.py
Normal file
|
|
@ -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"])
|
||||
95
tests/test_runtime_provider_resolution.py
Normal file
95
tests/test_runtime_provider_resolution.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue