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
19 changes: 13 additions & 6 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ def _nous_extra_body() -> dict:
auxiliary_is_nous: bool = False

# Default auxiliary models per provider
_OPENROUTER_MODEL = "google/gemini-3-flash-preview"
_OPENROUTER_MODEL = "google/gemini-2.5-flash"
_NOUS_MODEL = "google/gemini-3-flash-preview"
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
_ANTHROPIC_DEFAULT_BASE_URL = "https://api.anthropic.com"
Expand Down Expand Up @@ -1473,7 +1473,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:



def _try_openrouter(explicit_api_key: str = None) -> Tuple[Optional[OpenAI], Optional[str]]:
def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Optional[OpenAI], Optional[str]]:
pool_present, entry = _select_pool_entry("openrouter")
if pool_present:
or_key = explicit_api_key or _pool_runtime_api_key(entry)
Expand All @@ -1483,15 +1483,15 @@ def _try_openrouter(explicit_api_key: str = None) -> Tuple[Optional[OpenAI], Opt
base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
logger.debug("Auxiliary client: OpenRouter via pool")
return OpenAI(api_key=or_key, base_url=base_url,
default_headers=build_or_headers()), _OPENROUTER_MODEL
default_headers=build_or_headers()), model or _OPENROUTER_MODEL

or_key = explicit_api_key or os.getenv("OPENROUTER_API_KEY")
if not or_key:
_mark_provider_unhealthy("openrouter", ttl=60)
return None, None
logger.debug("Auxiliary client: OpenRouter")
return OpenAI(api_key=or_key, base_url=OPENROUTER_BASE_URL,
default_headers=build_or_headers()), _OPENROUTER_MODEL
default_headers=build_or_headers()), model or _OPENROUTER_MODEL


def _describe_openrouter_unavailable() -> str:
Expand Down Expand Up @@ -3049,10 +3049,17 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
if custom_entry:
custom_base = custom_entry.get("base_url", "").strip()
custom_key = custom_entry.get("api_key", "").strip()
custom_key_env = custom_entry.get("key_env", "").strip()
custom_key_env = (custom_entry.get("key_env") or custom_entry.get("api_key_env") or "").strip()
if not custom_key and custom_key_env:
custom_key = os.getenv(custom_key_env, "").strip()
custom_key = custom_key or "no-key-required"
if custom_key == "no-key-required":
logger.warning(
"resolve_provider_client: named custom provider %r has no resolvable "
"api_key — request will be sent with placeholder no-key-required "
"and will 401 on auth-required endpoints",
custom_entry.get("name") or provider,
)
# An explicit per-task api_mode override (from _resolve_task_provider_model)
# wins; otherwise fall back to what the provider entry declared.
entry_api_mode = (api_mode or custom_entry.get("api_mode") or "").strip()
Expand Down Expand Up @@ -3400,7 +3407,7 @@ def _resolve_strict_vision_backend(
if provider == "copilot":
return resolve_provider_client("copilot", model, is_vision=True)
if provider == "openrouter":
return _try_openrouter()
return _try_openrouter(model=model)
if provider == "nous":
return _try_nous(vision=True)
if provider == "openai-codex":
Expand Down
5 changes: 5 additions & 0 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,12 @@ async def body_limit_middleware(request, handler):
body_limit_middleware = None # type: ignore[assignment]

_SECURITY_HEADERS = {
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "0",
"Referrer-Policy": "no-referrer",
}

Expand Down
9 changes: 8 additions & 1 deletion gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2014,6 +2014,13 @@ async def send_voice(
text = f"{caption}\n{text}"
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)

def prepare_tts_text(self, text: str) -> str:
"""Prepare text for TTS. Override to filter tool output, code, etc.

Default strips markdown formatting and truncates to 4000 chars.
"""
return re.sub(r'[*_`#\[\]()]', '', text)[:4000].strip()

async def play_tts(
self,
chat_id: str,
Expand Down Expand Up @@ -3144,7 +3151,7 @@ async def _stop_typing_task() -> None:
from tools.tts_tool import text_to_speech_tool, check_tts_requirements
if check_tts_requirements():
import json as _json
speech_text = re.sub(r'[*_`#\[\]()]', '', text_content)[:4000].strip()
speech_text = self.prepare_tts_text(text_content)
if not speech_text:
raise ValueError("Empty text after markdown cleanup")
tts_result_str = await asyncio.to_thread(
Expand Down
4 changes: 2 additions & 2 deletions gateway/platforms/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ def cancel_all(self) -> None:
# Pre-compiled regexes for performance
_RE_BOLD = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
_RE_ITALIC_STAR = re.compile(r"\*(.+?)\*", re.DOTALL)
_RE_BOLD_UNDER = re.compile(r"__(.+?)__", re.DOTALL)
_RE_ITALIC_UNDER = re.compile(r"_(.+?)_", re.DOTALL)
_RE_BOLD_UNDER = re.compile(r"\b__(?![\s_])(.+?)(?<![\s_])__\b", re.DOTALL)
_RE_ITALIC_UNDER = re.compile(r"\b_(?![\s_])(.+?)(?<![\s_])_\b", re.DOTALL)
_RE_CODE_BLOCK = re.compile(r"```[a-zA-Z0-9_+-]*\n?")
_RE_INLINE_CODE = re.compile(r"`(.+?)`")
_RE_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
Expand Down
2 changes: 1 addition & 1 deletion gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ async def _send_slash_ephemeral(
"text": text,
}
try:
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
ctx["response_url"],
json=payload,
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/sms.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ async def connect(self) -> bool:
await site.start()
self._http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
trust_env=True,
)
self._running = True

Expand Down Expand Up @@ -169,6 +170,7 @@ async def send(

session = self._http_session or aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30),
trust_env=True,
)
try:
for chunk in chunks:
Expand Down
6 changes: 5 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12837,7 +12837,11 @@ async def _handle_update_command(self, event: MessageEvent) -> str:
update_cmd = (
f"PYTHONUNBUFFERED=1 {hermes_cmd_str} update --gateway"
f" > {shlex.quote(str(output_path))} 2>&1; "
f"status=$?; printf '%s' \"$status\" > {shlex.quote(str(exit_code_path))}"
# Avoid `status=$?`: `status` is a read-only special parameter
# in zsh, and this command string is copied/reused in macOS/zsh
# operator wrappers. Keep the template zsh-safe even though this
# specific subprocess currently runs under bash.
f"rc=$?; printf '%s' \"$rc\" > {shlex.quote(str(exit_code_path))}"
)
setsid_bin = shutil.which("setsid")
if setsid_bin:
Expand Down
9 changes: 9 additions & 0 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,15 @@ def _probe_apikey_provider(pname, env_vars, default_url, base_env,
}
if base_url_host_matches(base, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
# Google's Generative Language API (generativelanguage.googleapis.com)
# rejects ``Authorization: Bearer <api-key>`` with 401
# ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for
# OAuth 2 access tokens, not plain API keys. Plain keys use
# ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid
# GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.
if url and base_url_host_matches(url, "generativelanguage.googleapis.com"):
headers.pop("Authorization", None)
headers["x-goog-api-key"] = key
r = httpx.get(url, headers=headers, timeout=10)
if (
pname == "Alibaba/DashScope"
Expand Down
2 changes: 1 addition & 1 deletion plugins/platforms/google_chat/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3246,7 +3246,7 @@ async def _standalone_send(
return {"error": "Google Chat standalone send: aiohttp not installed"}

try:
async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0)) as session:
async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0), trust_env=True) as session:
async with session.post(
url,
json=body,
Expand Down
10 changes: 5 additions & 5 deletions plugins/platforms/line/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ def __init__(self, channel_access_token: str, *, timeout: float = 15.0) -> None:
async def reply(self, reply_token: str, messages: List[Dict[str, Any]]) -> None:
import aiohttp
timeout = aiohttp.ClientTimeout(total=self._timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.post(
LINE_REPLY_URL,
headers=self._headers,
Expand All @@ -460,7 +460,7 @@ async def reply(self, reply_token: str, messages: List[Dict[str, Any]]) -> None:
async def push(self, chat_id: str, messages: List[Dict[str, Any]]) -> None:
import aiohttp
timeout = aiohttp.ClientTimeout(total=self._timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.post(
LINE_PUSH_URL,
headers=self._headers,
Expand All @@ -479,7 +479,7 @@ async def loading(self, chat_id: str, seconds: int = 60) -> None:
clamped = max(5, min(60, (seconds // 5) * 5 or 5))
try:
timeout = aiohttp.ClientTimeout(total=5.0)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
await session.post(
LINE_LOADING_URL,
headers=self._headers,
Expand All @@ -493,7 +493,7 @@ async def fetch_content(self, message_id: str) -> bytes:
import aiohttp
url = LINE_CONTENT_URL_FMT.format(message_id=message_id)
timeout = aiohttp.ClientTimeout(total=30.0)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(url, headers={"Authorization": f"Bearer {self._token}"}) as resp:
if resp.status >= 400:
raise RuntimeError(f"LINE content {resp.status}")
Expand All @@ -504,7 +504,7 @@ async def get_bot_user_id(self) -> Optional[str]:
import aiohttp
timeout = aiohttp.ClientTimeout(total=10.0)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
async with session.get(LINE_BOT_INFO_URL, headers=self._headers) as resp:
if resp.status >= 400:
return None
Expand Down
2 changes: 1 addition & 1 deletion plugins/platforms/teams/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ async def _standalone_send(
# Per-request timeouts so a slow STS endpoint cannot starve the
# subsequent activity POST of its budget.
per_request_timeout = _aiohttp.ClientTimeout(total=15.0)
async with _aiohttp.ClientSession() as session:
async with _aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
token_url,
data={
Expand Down
11 changes: 11 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,17 @@
"pr7426@users.noreply.github.com": "pr7426", # PR #27048 (cron parallel job loss)
"rahulnilvan43@gmail.com": "therahul-yo", # PR #26215 (mock keychain in tests)
"kingsleyemeka117@gmail.com": "flamiinngo", # PR #27205 (UnicodeEncodeError footgun checker)
# batch salvage (May 2026 LHF run, group 4)
"283442588+EloquentBrush0x@users.noreply.github.com": "EloquentBrush0x", # PR #26657 (trust_env aiohttp)
"205509009+subtract0@users.noreply.github.com": "subtract0", # PR #25658 (zsh $status -> $rc)
"patryk@jarmakowicz.me": "zwolniony", # PR #26961 (gemini x-goog-api-key)
"12735938+zwolniony@users.noreply.github.com": "zwolniony",
"ambuj@dodopayments.com": "that-ambuj", # PR #26582 (preserve underscores)
"zccyman@163.com": "zccyman", # PR #25294 (custom provider api_key_env alias)
"bitkyc08@gmail.com": "lidge-jun", # PR #26814 (api server browser security headers)
"sp_ps@Mac-mini.lan": "phoenixshen", # PR #26768 (respect user-configured vision model)
"1594534+phoenixshen@users.noreply.github.com": "phoenixshen",
"147827411+AhmetArif0@users.noreply.github.com": "AhmetArif0", # PR #26635 (line proxy env vars)
}


Expand Down
5 changes: 5 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,12 @@ async def test_security_headers_present(self, adapter):
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/health")
assert resp.status == 200
assert resp.headers.get("Content-Security-Policy") == "default-src 'none'; frame-ancestors 'none'"
assert resp.headers.get("Permissions-Policy") == "camera=(), microphone=(), geolocation=()"
assert resp.headers.get("Strict-Transport-Security") == "max-age=31536000; includeSubDomains"
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
assert resp.headers.get("X-Frame-Options") == "DENY"
assert resp.headers.get("X-XSS-Protection") == "0"
assert resp.headers.get("Referrer-Policy") == "no-referrer"

@pytest.mark.asyncio
Expand Down
5 changes: 5 additions & 0 deletions tests/gateway/test_bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ def test_format_message_strips_markdown(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("**Hello** `world`") == "Hello world"

def test_format_message_preserves_underscores_in_identifiers(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
text = "Use /api_v2 with FEATURE_FLAG_NAME and config_file.json"
assert adapter.format_message(text) == text

def test_strip_markdown_headers(self, monkeypatch):
adapter = _make_adapter(monkeypatch)
assert adapter.format_message("## Heading\ntext") == "Heading\ntext"
Expand Down
2 changes: 2 additions & 0 deletions tests/gateway/test_update_streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@ async def test_spawns_with_gateway_flag(self, tmp_path):
cmd_string = call_args[-1] if isinstance(call_args, list) else str(call_args)
assert "--gateway" in cmd_string
assert "PYTHONUNBUFFERED" in cmd_string
assert "rc=$?" in cmd_string
assert "status=$?" not in cmd_string
assert "stream progress" in result


Expand Down
Loading