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
5 changes: 4 additions & 1 deletion acp_adapter/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1471,7 +1471,10 @@ def _run_agent() -> dict:
clear_session_vars,
set_session_vars,
)
session_tokens = set_session_vars(session_key=session_id)
session_tokens = set_session_vars(
session_key=session_id,
session_id=session_id,
)
except Exception:
session_tokens = None
clear_session_vars = None # type: ignore[assignment]
Expand Down
71 changes: 43 additions & 28 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,25 @@ def init_agent(
# same image history.
agent._anthropic_image_fallback_cache: Dict[str, str] = {}

# Session identity is initialized before LLM client construction so setup
# hooks see a consistent local session context. Provider identity helpers
# must not send this value to third parties unless a user explicitly opts in.
agent.session_start = datetime.now()
if session_id:
agent.session_id = session_id
else:
timestamp_str = agent.session_start.strftime("%Y%m%d_%H%M%S")
short_uuid = uuid.uuid4().hex[:6]
agent.session_id = f"{timestamp_str}_{short_uuid}"
agent._parent_session_id = parent_session_id

try:
from gateway.session_context import set_current_session_id

set_current_session_id(agent.session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = agent.session_id

# Initialize LLM client via centralized provider router.
# The router handles auth resolution, base URL, headers, and
# Codex/Anthropic wrapping for all known providers.
Expand Down Expand Up @@ -796,22 +815,27 @@ def init_agent(
client_kwargs["default_headers"] = {
"User-Agent": "claude-code/0.1.0",
}
elif base_url_host_matches(effective_base, "portal.qwen.ai"):
client_kwargs["default_headers"] = _ra()._qwen_portal_headers()
elif base_url_host_matches(effective_base, "chatgpt.com"):
from agent.auxiliary_client import _codex_cloudflare_headers
client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key)
elif "default_headers" not in client_kwargs:
# Fall back to profile.default_headers for providers that
# declare custom headers (e.g. Kimi User-Agent on non-kimi.com
# endpoints).
try:
from providers import get_provider_profile as _gpf
_ph = _gpf(agent.provider)
if _ph and _ph.default_headers:
client_kwargs["default_headers"] = dict(_ph.default_headers)
except Exception:
pass
else:
from agent.provider_client_identity import is_zai_endpoint, zai_opencode_headers

if is_zai_endpoint(effective_base):
client_kwargs["default_headers"] = zai_opencode_headers()
elif base_url_host_matches(effective_base, "portal.qwen.ai"):
client_kwargs["default_headers"] = _ra()._qwen_portal_headers()
elif base_url_host_matches(effective_base, "chatgpt.com"):
from agent.auxiliary_client import _codex_cloudflare_headers
client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key)
elif "default_headers" not in client_kwargs:
# Fall back to profile.default_headers for providers that
# declare custom headers (e.g. Kimi User-Agent on non-kimi.com
# endpoints).
try:
from providers import get_provider_profile as _gpf
_ph = _gpf(agent.provider)
if _ph and _ph.default_headers:
client_kwargs["default_headers"] = dict(_ph.default_headers)
except Exception:
pass
else:
# No explicit creds — use the centralized provider router
from agent.auxiliary_client import resolve_provider_client
Expand Down Expand Up @@ -1052,17 +1076,9 @@ def init_agent(
source = "Claude via OpenRouter"
print(f"💾 Prompt caching: ENABLED ({source}, {agent._cache_ttl} TTL)")

# Session logging setup - auto-save conversation trajectories for debugging
agent.session_start = datetime.now()
if session_id:
# Use provided session ID (e.g., from CLI)
agent.session_id = session_id
else:
# Generate a new session ID
timestamp_str = agent.session_start.strftime("%Y%m%d_%H%M%S")
short_uuid = uuid.uuid4().hex[:6]
agent.session_id = f"{timestamp_str}_{short_uuid}"

# Session logging setup - auto-save conversation trajectories for debugging.
# The id is initialized before LLM client construction; keep the existing
# exposure paths synchronized for tools and local session bookkeeping.
# Expose session ID to tools (terminal, execute_code) so agents can
# reference their own session for --resume commands, cross-session
# coordination, and logging. Keep the ContextVar and os.environ
Expand Down Expand Up @@ -1118,7 +1134,6 @@ def init_agent(

# SQLite session store (optional -- provided by CLI or gateway)
agent._session_db = session_db
agent._parent_session_id = parent_session_id
agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes
agent._session_db_created = False # DB row deferred to run_conversation()
# Most agents own their session row and should finalize it on close().
Expand Down
20 changes: 20 additions & 0 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
from pathlib import Path
from urllib.parse import urlparse

from agent.provider_client_identity import (
build_zai_sync_http_client,
is_zai_endpoint,
merge_zai_opencode_headers,
)
from hermes_constants import get_hermes_home
from typing import Any, Dict, List, Optional, Tuple
from utils import base_url_host_matches, normalize_proxy_env_vars
Expand Down Expand Up @@ -697,6 +702,20 @@ def _build_anthropic_client_with_bearer_hook(
return _anthropic_sdk.Anthropic(**kwargs)


def _apply_zai_anthropic_identity(kwargs: Dict[str, Any], base_url: str | None) -> None:
"""Layer Z.ai OpenCode identity onto an Anthropic-compatible client."""
if not is_zai_endpoint(base_url):
return
headers = merge_zai_opencode_headers(base_url, kwargs.get("default_headers"))
if headers:
kwargs["default_headers"] = headers
if "http_client" not in kwargs:
client_kwargs: Dict[str, Any] = {}
if "timeout" in kwargs:
client_kwargs["timeout"] = kwargs["timeout"]
kwargs["http_client"] = build_zai_sync_http_client(**client_kwargs)


def build_anthropic_client(
api_key,
base_url: str = None,
Expand Down Expand Up @@ -817,6 +836,7 @@ def build_anthropic_client(
if common_betas:
kwargs["default_headers"] = {"anthropic-beta": ",".join(common_betas)}

_apply_zai_anthropic_identity(kwargs, normalized_base_url)
return _anthropic_sdk.Anthropic(**kwargs)


Expand Down
88 changes: 62 additions & 26 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ def __repr__(self):
OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance

from agent.credential_pool import load_pool
from agent.provider_client_identity import (
build_zai_async_http_client,
build_zai_sync_http_client,
is_zai_endpoint,
merge_zai_opencode_headers,
set_merged_header,
)
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
Expand Down Expand Up @@ -415,12 +422,43 @@ def _apply_user_default_headers(headers: dict | None) -> dict | None:
return headers
merged = dict(headers or {})
for key, value in user_headers.items():
if value is None:
continue
merged[str(key)] = str(value)
set_merged_header(merged, key, value)
return merged or headers


def _with_zai_openai_identity(
kwargs: Dict[str, Any],
*,
async_mode: bool = False,
) -> Dict[str, Any]:
"""Add Z.ai OpenCode identity and Stainless stripping to OpenAI clients."""
base_url = str(kwargs.get("base_url") or "")
if not is_zai_endpoint(base_url):
return kwargs
prepared = dict(kwargs)
headers = merge_zai_opencode_headers(
base_url,
prepared.get("default_headers"),
)
if headers:
prepared["default_headers"] = headers
if "http_client" not in prepared:
prepared["http_client"] = (
build_zai_async_http_client() if async_mode else build_zai_sync_http_client()
)
return prepared


def _new_openai_client(**kwargs):
return OpenAI(**_with_zai_openai_identity(kwargs))


def _new_async_openai_client(**kwargs):
from openai import AsyncOpenAI

return AsyncOpenAI(**_with_zai_openai_identity(kwargs, async_mode=True))


def build_or_headers(or_config: dict | None = None) -> dict:
"""Build OpenRouter headers, optionally including response-cache headers.

Expand Down Expand Up @@ -1614,7 +1652,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
_merged_aux = _apply_user_default_headers(extra.get("default_headers"))
if _merged_aux:
extra["default_headers"] = _merged_aux
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _new_openai_client(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model

Expand Down Expand Up @@ -1654,7 +1692,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
_merged_aux2 = _apply_user_default_headers(extra.get("default_headers"))
if _merged_aux2:
extra["default_headers"] = _merged_aux2
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _new_openai_client(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model

Expand All @@ -1674,16 +1712,16 @@ def _try_openrouter(explicit_api_key: str = None, model: str = None) -> Tuple[Op
return None, None
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()), model or _OPENROUTER_MODEL
return _new_openai_client(api_key=or_key, base_url=base_url,
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()), model or _OPENROUTER_MODEL
return _new_openai_client(api_key=or_key, base_url=OPENROUTER_BASE_URL,
default_headers=build_or_headers()), model or _OPENROUTER_MODEL


def _describe_openrouter_unavailable() -> str:
Expand Down Expand Up @@ -1775,7 +1813,7 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]:
return None, None
base_url = str((nous or {}).get("inference_base_url") or _nous_base_url()).rstrip("/")
return (
OpenAI(
_new_openai_client(
api_key=api_key,
base_url=base_url,
),
Expand Down Expand Up @@ -2052,7 +2090,7 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
if _custom_headers:
_extra["default_headers"] = _custom_headers
if custom_mode == "codex_responses":
real_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
real_client = _new_openai_client(api_key=custom_key, base_url=_clean_base, **_extra)
return CodexAuxiliaryClient(real_client, model), model
if custom_mode == "anthropic_messages":
# Third-party Anthropic-compatible gateway (MiniMax, Zhipu GLM,
Expand All @@ -2066,14 +2104,14 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
"Custom endpoint declares api_mode=anthropic_messages but the "
"anthropic SDK is not installed — falling back to OpenAI-wire."
)
return OpenAI(api_key=custom_key, base_url=_clean_base, **_extra), model
return _new_openai_client(api_key=custom_key, base_url=_clean_base, **_extra), model
return (
AnthropicAuxiliaryClient(real_client, model, custom_key, custom_base, is_oauth=False),
model,
)
# URL-based anthropic detection for custom endpoints that didn't set
# api_mode explicitly (e.g. kimi.com/coding reached via custom config).
_fallback_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
_fallback_client = _new_openai_client(api_key=custom_key, base_url=_clean_base, **_extra)
_fallback_client = _maybe_wrap_anthropic(
_fallback_client, model, custom_key, custom_base, custom_mode,
)
Expand Down Expand Up @@ -2102,7 +2140,7 @@ def _build_xai_oauth_aux_client(model: str) -> Tuple[Optional[Any], Optional[str
return None, None
api_key, base_url = resolved
logger.debug("Auxiliary client: xAI OAuth (%s via Responses API)", model)
real_client = OpenAI(api_key=api_key, base_url=base_url)
real_client = _new_openai_client(api_key=api_key, base_url=base_url)
return CodexAuxiliaryClient(real_client, model), model


Expand Down Expand Up @@ -2139,7 +2177,7 @@ def _build_codex_client(model: str) -> Tuple[Optional[Any], Optional[str]]:
return None, None
base_url = _CODEX_AUX_BASE_URL
logger.debug("Auxiliary client: Codex OAuth (%s via Responses API)", model)
real_client = OpenAI(
real_client = _new_openai_client(
api_key=codex_token,
base_url=base_url,
default_headers=_codex_cloudflare_headers(codex_token),
Expand Down Expand Up @@ -2239,7 +2277,7 @@ def _try_azure_foundry(
if _dq:
extra["default_query"] = _dq

client = OpenAI(api_key=api_key, base_url=_clean_base, **extra)
client = _new_openai_client(api_key=api_key, base_url=_clean_base, **extra)

if runtime_api_mode == "codex_responses":
# GPT-5.x / o-series / codex models on Azure Foundry are
Expand Down Expand Up @@ -3716,8 +3754,6 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
header so the request is routed to Copilot's vision-capable
infrastructure (otherwise vision payloads silently time out).
"""
from openai import AsyncOpenAI

if isinstance(sync_client, CodexAuxiliaryClient):
return AsyncCodexAuxiliaryClient(sync_client), model
if isinstance(sync_client, AnthropicAuxiliaryClient):
Expand Down Expand Up @@ -3770,7 +3806,7 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
_merged_async = _apply_user_default_headers(async_kwargs.get("default_headers"))
if _merged_async:
async_kwargs["default_headers"] = _merged_async
return AsyncOpenAI(**async_kwargs), model
return _new_async_openai_client(**async_kwargs), model


def _normalize_resolved_model(model_name: Optional[str], provider: str) -> Optional[str]:
Expand Down Expand Up @@ -3980,7 +4016,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
"but no Codex OAuth token found (run: hermes model)")
return None, None
final_model = _normalize_resolved_model(model, provider)
raw_client = OpenAI(
raw_client = _new_openai_client(
api_key=codex_token,
base_url=_CODEX_AUX_BASE_URL,
default_headers=_codex_cloudflare_headers(codex_token),
Expand Down Expand Up @@ -4061,7 +4097,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
_merged_custom = _apply_user_default_headers(extra.get("default_headers"))
if _merged_custom:
extra["default_headers"] = _merged_custom
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
client = _new_openai_client(api_key=custom_key, base_url=_clean_base, **extra)
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
Expand Down Expand Up @@ -4165,7 +4201,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
_fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers"))
if _fb_headers:
_fb_extra["default_headers"] = _fb_headers
client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
client = _new_openai_client(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
sync_anthropic = AnthropicAuxiliaryClient(
Expand All @@ -4174,7 +4210,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
if async_mode:
return AsyncAnthropicAuxiliaryClient(sync_anthropic), final_model
return sync_anthropic, final_model
client = OpenAI(api_key=custom_key, base_url=_clean_base2, **_extra2)
client = _new_openai_client(api_key=custom_key, base_url=_clean_base2, **_extra2)
# codex_responses or inherited auto-detect (via _wrap_if_needed).
# _wrap_if_needed reads the closed-over `api_mode` (the task-level
# override). Named-provider entry api_mode=codex_responses also
Expand Down Expand Up @@ -4316,8 +4352,8 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
_merged_main = _apply_user_default_headers(headers)
if _merged_main:
headers = _merged_main
client = OpenAI(api_key=api_key, base_url=base_url,
**({"default_headers": headers} if headers else {}))
client = _new_openai_client(api_key=api_key, base_url=base_url,
**({"default_headers": headers} if headers else {}))

# Copilot GPT-5+ models (except gpt-5-mini) require the Responses
# API — they are not accessible via /chat/completions. Wrap the
Expand Down Expand Up @@ -4852,7 +4888,7 @@ def _refresh_nous_auxiliary_client(
return None, model

fresh_key, fresh_base_url = runtime
sync_client = OpenAI(api_key=fresh_key, base_url=fresh_base_url)
sync_client = _new_openai_client(api_key=fresh_key, base_url=fresh_base_url)
final_model = model

current_loop = None
Expand Down
Loading