Skip to content
Closed
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
115 changes: 113 additions & 2 deletions agent/account_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Optional
from typing import Any, Dict, Optional

import httpx

Expand Down Expand Up @@ -321,6 +321,117 @@ def fetch_account_usage(
return _fetch_anthropic_account_usage()
if normalized == "openrouter":
return _fetch_openrouter_account_usage(base_url, api_key)
if normalized == "nous":
return _fetch_nous_account_usage()
except Exception:
return None


def _fetch_nous_account_usage() -> Optional[AccountUsageSnapshot]:
"""Fetch Nous Portal usage snapshot.

Uses the Nous OAuth credential resolution path (same as inference) to
obtain a valid access_token, then queries the inference API. The
inference API is OpenAI-compatible so /credits or usage endpoints are
tried first; if unavailable, falls back to a connectivity-check
snapshot via /models (#33376).
"""
creds: Dict[str, Any] = {}
try:
from hermes_cli.auth import resolve_nous_runtime_credentials

creds = resolve_nous_runtime_credentials() or {}
except Exception:
return None

api_key = str(creds.get("api_key", "") or "").strip()
base_url = str(creds.get("base_url", "") or "").strip()
if not api_key:
return None
if not base_url:
base_url = "https://inference.nousresearch.com/v1"

normalized_url = base_url.rstrip("/")
headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"}

# Try credits/usage-style endpoints first.
for usage_path in ("/credits", "/usage", "/account/usage"):
try:
resp = httpx.get(
f"{normalized_url}{usage_path}",
headers=headers,
timeout=8.0,
)
if resp.status_code == 200:
data = resp.json()
return _parse_nous_usage_payload(data, normalized_url, headers)
except Exception:
continue

# Fallback: connectivity check — count available models.
return _nous_connectivity_snapshot(normalized_url, headers)


def _parse_nous_usage_payload(
data: Any, base_url: str, headers: dict
) -> Optional[AccountUsageSnapshot]:
"""Parse a Nous usage JSON payload into an AccountUsageSnapshot."""
if not isinstance(data, dict):
return None
payload = data.get("data") or data

details: list[str] = []
plan = payload.get("plan") or payload.get("tier") or payload.get("subscription")
if plan:
details.append(f"Plan: {plan}")

windows: list[AccountUsageWindow] = []
# Try several common Nous credit shapes.
total_credits = payload.get("total_credits") or payload.get("credits_total")
used_credits = payload.get("used_credits") or payload.get("credits_used") or payload.get("total_usage")
credit_limit = payload.get("limit") or payload.get("credit_limit") or payload.get("monthly_limit")
if isinstance(used_credits, (int, float)) and isinstance(credit_limit, (int, float)) and float(credit_limit) > 0:
used_pct = (float(used_credits) / float(credit_limit)) * 100
remaining = max(0.0, float(credit_limit) - float(used_credits))
windows.append(
AccountUsageWindow(
label="Credits",
used_percent=round(min(used_pct, 100.0), 1),
detail=f"${remaining:.2f} of ${float(credit_limit):.2f} remaining",
)
)
elif isinstance(used_credits, (int, float)):
details.append(f"Usage: ${float(used_credits):.2f}")

if not windows and not details:
return _nous_connectivity_snapshot(base_url, headers)

return AccountUsageSnapshot(
provider="nous",
source="usage_api",
fetched_at=_utc_now(),
title="Nous Research",
plan=str(plan) if plan else None,
windows=tuple(windows),
details=tuple(details),
)


def _nous_connectivity_snapshot(base_url: str, headers: dict) -> Optional[AccountUsageSnapshot]:
"""Fallback: confirm the token works by listing models."""
try:
resp = httpx.get(f"{base_url.rstrip('/')}/models", headers=headers, timeout=5.0)
models_count = 0
if resp.status_code == 200:
md = resp.json()
models_list = md.get("data") if isinstance(md, dict) else md
models_count = len(models_list) if isinstance(models_list, list) else 0
return AccountUsageSnapshot(
provider="nous",
source="connectivity_check",
fetched_at=_utc_now(),
title="Nous Research",
details=(f"{models_count} models available" if models_count else "Connected",),
)
except Exception:
return None
return None
99 changes: 99 additions & 0 deletions agent/codex_responses_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1048,3 +1048,102 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]:
else:
finish_reason = "stop"
return assistant_message, finish_reason


def _normalize_responses_to_chat(raw_response: Any) -> Any:
"""Normalize an OpenAI Responses API response to chat.completions shape.

The Responses API returns a different object shape than chat.completions.
This converts it so the agent loop can treat it uniformly.

Used by the generic ``api_mode='responses'`` path for custom providers
that expose a ``/v1/responses`` endpoint (#33600).
"""
from types import SimpleNamespace

# Extract text content — Responses API stores output in .output items
text_parts: list[str] = []
tool_calls: list[Any] = []

output = getattr(raw_response, "output", None) or []
if isinstance(output, list):
for item in output:
item_type = getattr(item, "type", "") or ""
if item_type == "message":
content = getattr(item, "content", None) or []
for block in (content if isinstance(content, list) else []):
block_type = getattr(block, "type", "") or ""
if block_type == "output_text":
txt = getattr(block, "text", "") or ""
if txt:
text_parts.append(txt)
elif item_type == "function_call":
arguments = getattr(item, "arguments", "{}") or "{}"
if isinstance(arguments, dict):
arguments = json.dumps(arguments)
tool_calls.append(
SimpleNamespace(
id=getattr(item, "call_id", None) or getattr(item, "id", "") or "fc_0",
type="function",
function=SimpleNamespace(
name=getattr(item, "name", "tool") or "tool",
arguments=arguments,
),
)
)
elif item_type == "tool_call":
arguments = getattr(item, "arguments", "{}") or "{}"
if isinstance(arguments, dict):
arguments = json.dumps(arguments)
tool_calls.append(
SimpleNamespace(
id=getattr(item, "call_id", None) or getattr(item, "id", "") or "tc_0",
type="function",
function=SimpleNamespace(
name=getattr(item, "name", "tool") or "tool",
arguments=arguments,
),
)
)

# Fallback: some Responses APIs expose .output_text directly
if not text_parts and not tool_calls:
output_text = getattr(raw_response, "output_text", None)
if isinstance(output_text, str) and output_text:
text_parts = [output_text]

content = "\n".join(text_parts) if text_parts else None

# Build the chat.completions-style response
usage = getattr(raw_response, "usage", None)
prompt_tokens = getattr(usage, "input_tokens", 0) or 0 if usage else 0
completion_tokens = getattr(usage, "output_tokens", 0) or 0 if usage else 0

message = SimpleNamespace(
content=content,
tool_calls=tool_calls if tool_calls else [],
role="assistant",
reasoning=None,
)

finish_reason = "stop"
if tool_calls:
finish_reason = "tool_calls"

return SimpleNamespace(
id=getattr(raw_response, "id", "") or "",
model=getattr(raw_response, "model", "") or "",
choices=[
SimpleNamespace(
finish_reason=finish_reason,
index=0,
message=message,
)
],
usage=SimpleNamespace(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=prompt_tokens + completion_tokens,
),
created=int(getattr(raw_response, "created", 0) or 0),
)
6 changes: 6 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,12 @@ class MessageEvent:
# from ``text`` so the sender-prefix logic in run.py can operate on the
# trigger message alone, then prepend this context afterward.
channel_context: Optional[str] = None

# Target Hermes profile override — set by the gateway when profile_routing
# config maps this user to a specific profile. When set, the gateway
# switches to this profile for the duration of the message processing,
# then restores the original profile afterward.
target_profile: Optional[str] = None

# Internal flag — set for synthetic events (e.g. background process
# completion notifications) that must bypass user authorization checks.
Expand Down
Loading