Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 48 additions & 38 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ def __init__(
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)
Expand All @@ -821,26 +821,28 @@ def __init__(
# 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.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._nous_key_expires_at: Optional[str] = None
self._nous_key_source: Optional[str] = None
# Max turns priority: CLI arg > config file > env var > default
Expand Down Expand Up @@ -903,45 +905,51 @@ def __init__(

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
Expand All @@ -957,7 +965,7 @@ def _init_agent(self) -> bool:
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
Expand Down Expand Up @@ -1001,6 +1009,8 @@ def _init_agent(self) -> bool:
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,
Expand Down Expand Up @@ -1093,8 +1103,8 @@ def _show_status(self):
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}[/] "
Expand Down Expand Up @@ -1929,8 +1939,8 @@ def chat(self, message: str) -> Optional[str]:
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
Expand Down
34 changes: 17 additions & 17 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
except UnicodeDecodeError:
load_dotenv(str(_hermes_home / ".env"), override=True, encoding="latin-1")

model = os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6")
# Custom endpoint (OPENAI_*) takes precedence, matching CLI behavior
api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "")
base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"

try:
import yaml
Expand All @@ -188,24 +185,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)
# Check if provider is nous — resolve OAuth credentials
provider = _model_cfg.get("provider", "") if isinstance(_model_cfg, dict) else ""
if provider == "nous":
try:
from hermes_cli.auth import resolve_nous_runtime_credentials
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60)
api_key = creds.get("api_key", api_key)
base_url = creds.get("base_url", base_url)
except Exception as nous_err:
logging.warning("Nous Portal credential resolution failed for cron: %s", nous_err)
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')}"
)
Expand Down
58 changes: 36 additions & 22 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,28 @@
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.
Expand Down Expand Up @@ -958,14 +980,11 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
from run_agent import AIAgent
loop = asyncio.get_event_loop()
# Resolve credentials so the flush agent can reach the LLM
_flush_api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "")
_flush_base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
_flush_model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL", "anthropic/claude-opus-4.6")
_flush_model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"
def _do_flush():
tmp_agent = AIAgent(
model=_flush_model,
api_key=_flush_api_key,
base_url=_flush_base_url,
**_resolve_runtime_agent_kwargs(),
max_iterations=5,
quiet_mode=True,
enabled_toolsets=["memory"],
Expand Down Expand Up @@ -1678,7 +1697,7 @@ def run_sync():
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:
Expand All @@ -1688,9 +1707,6 @@ def run_sync():
except Exception:
pass

# Custom endpoint (OPENAI_*) takes precedence, matching CLI behavior
api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENROUTER_API_KEY", "")
base_url = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
model = os.getenv("HERMES_MODEL") or os.getenv("LLM_MODEL") or "anthropic/claude-opus-4.6"

try:
Expand All @@ -1704,24 +1720,22 @@ def run_sync():
model = _model_cfg
elif isinstance(_model_cfg, dict):
model = _model_cfg.get("default", model)
base_url = _model_cfg.get("base_url", base_url)
# Check if provider is nous — resolve OAuth credentials
provider = _model_cfg.get("provider", "") if isinstance(_model_cfg, dict) else ""
if provider == "nous":
try:
from hermes_cli.auth import resolve_nous_runtime_credentials
creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60)
api_key = creds.get("api_key", api_key)
base_url = creds.get("base_url", base_url)
except Exception as nous_err:
logger.warning("Nous Portal credential resolution failed: %s", nous_err)
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,
verbose_logging=False,
Expand Down
Loading