From ee1ffee151212705792c5e16cc336ad42594354e Mon Sep 17 00:00:00 2001 From: Darko Luketic Date: Wed, 24 Jun 2026 10:35:37 +0200 Subject: [PATCH 1/2] fix: use opencode identity for zai clients --- acp_adapter/server.py | 5 +- agent/agent_init.py | 71 ++-- agent/anthropic_adapter.py | 20 ++ agent/auxiliary_client.py | 88 +++-- agent/provider_client_identity.py | 135 +++++++ gateway/run.py | 1 + run_agent.py | 58 +-- .../test_auxiliary_user_default_headers.py | 9 + tests/agent/test_zai_opencode_identity.py | 335 ++++++++++++++++++ tests/gateway/test_session_env.py | 5 +- tests/test_tui_gateway_server.py | 6 + tui_gateway/server.py | 7 +- 12 files changed, 660 insertions(+), 80 deletions(-) create mode 100644 agent/provider_client_identity.py create mode 100644 tests/agent/test_zai_opencode_identity.py diff --git a/acp_adapter/server.py b/acp_adapter/server.py index a51db91d4e820..192f62498bc21 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -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] diff --git a/agent/agent_init.py b/agent/agent_init.py index e7f2ed9eac33d..dadc966d858b9 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -614,6 +614,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. @@ -775,22 +794,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 @@ -1031,17 +1055,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 @@ -1097,7 +1113,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(). diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index c63c71da7bcac..83f8400bd4290 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -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 @@ -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, @@ -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) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 0afb0add20bf6..744b8c91c5b74 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars @@ -414,12 +421,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. @@ -1591,7 +1629,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 @@ -1631,7 +1669,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 @@ -1651,16 +1689,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: @@ -1752,7 +1790,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, ), @@ -2029,7 +2067,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, @@ -2043,14 +2081,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, ) @@ -2079,7 +2117,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 @@ -2116,7 +2154,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), @@ -2216,7 +2254,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 @@ -3477,8 +3515,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): @@ -3531,7 +3567,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]: @@ -3741,7 +3777,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), @@ -3822,7 +3858,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)) @@ -3926,7 +3962,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( @@ -3935,7 +3971,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 @@ -4077,8 +4113,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 @@ -4613,7 +4649,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 diff --git a/agent/provider_client_identity.py b/agent/provider_client_identity.py new file mode 100644 index 0000000000000..d7c287b730180 --- /dev/null +++ b/agent/provider_client_identity.py @@ -0,0 +1,135 @@ +"""Provider-specific HTTP client identity helpers. + +Keep this module narrow: it exists for provider fingerprints that must be +applied consistently across main, auxiliary, and adapter clients without +expanding the model tool surface. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +from utils import base_url_hostname + + +ZAI_OPENCODE_USER_AGENT = "opencode/1.17.9" +_ZAI_HOSTS = frozenset({"api.z.ai", "open.bigmodel.cn"}) + + +def canonical_header_name(existing_names: Any, header_name: str) -> str: + """Return the casing to use when replacing a header value.""" + lower = header_name.lower() + if lower == "user-agent": + # The OpenAI SDK's built-in User-Agent suppression is sensitive to + # this canonical spelling, so preserve it even if config says + # ``user-agent``. + return "User-Agent" + for existing in existing_names: + existing_name = str(existing) + if existing_name.lower() == lower: + return existing_name + return header_name + + +def set_merged_header(headers: dict[str, str], key: Any, value: Any) -> None: + """Set a header case-insensitively while preserving useful casing.""" + if value is None: + return + header_name = str(key) + target_name = canonical_header_name(headers.keys(), header_name) + target_lower = target_name.lower() + for existing in list(headers.keys()): + if str(existing).lower() == target_lower and existing != target_name: + headers.pop(existing, None) + headers[target_name] = str(value) + + +def is_zai_endpoint(base_url: str | None) -> bool: + """Return True for Z.ai/Zhipu hosts that need OpenCode-shaped identity.""" + return base_url_hostname(base_url or "") in _ZAI_HOSTS + + +def zai_opencode_headers() -> dict[str, str]: + """Headers matching OpenCode's non-hosted-provider LLM request identity.""" + # Do not send Hermes session ids or parent ids here. Those are stable + # third-party identifiers and require a broader user-facing opt-in policy. + return {"User-Agent": ZAI_OPENCODE_USER_AGENT} + + +def merge_zai_opencode_headers( + base_url: str | None, + headers: Mapping[str, Any] | None = None, +) -> dict[str, str] | None: + """Apply Z.ai OpenCode defaults while preserving caller override order.""" + if not is_zai_endpoint(base_url): + return dict(headers) if headers else None + merged = zai_opencode_headers() + for key, value in (headers or {}).items(): + set_merged_header(merged, key, value) + return merged + + +def strip_stainless_headers(request: Any) -> None: + """Remove OpenAI/Anthropic SDK fingerprint headers from an httpx request.""" + headers = getattr(request, "headers", None) + if headers is None: + return + for name in list(headers.keys()): + if str(name).lower().startswith("x-stainless-"): + try: + del headers[name] + except KeyError: + pass + + +def apply_zai_request_identity(request: Any) -> None: + """Apply per-request Z.ai identity cleanup that must run last.""" + strip_stainless_headers(request) + + +def make_zai_request_hook(): + def _hook(request: Any) -> None: + apply_zai_request_identity(request) + + return _hook + + +def make_async_zai_request_hook(): + async def _hook(request: Any) -> None: + apply_zai_request_identity(request) + + return _hook + + +def sync_zai_event_hooks( + event_hooks: Mapping[str, list[Any]] | None = None, +) -> dict[str, list[Any]]: + hooks = {str(name): list(values) for name, values in (event_hooks or {}).items()} + hooks.setdefault("request", []).append(make_zai_request_hook()) + return hooks + + +def async_zai_event_hooks( + event_hooks: Mapping[str, list[Any]] | None = None, +) -> dict[str, list[Any]]: + hooks = {str(name): list(values) for name, values in (event_hooks or {}).items()} + hooks.setdefault("request", []).append(make_async_zai_request_hook()) + return hooks + + +def build_zai_sync_http_client(**kwargs: Any) -> Any: + """Build an ``httpx.Client`` with the Z.ai Stainless-strip request hook.""" + import httpx + + kwargs = dict(kwargs) + kwargs["event_hooks"] = sync_zai_event_hooks(kwargs.get("event_hooks")) + return httpx.Client(**kwargs) + + +def build_zai_async_http_client(**kwargs: Any) -> Any: + """Build an ``httpx.AsyncClient`` with the Z.ai Stainless-strip hook.""" + import httpx + + kwargs = dict(kwargs) + kwargs["event_hooks"] = async_zai_event_hooks(kwargs.get("event_hooks")) + return httpx.AsyncClient(**kwargs) diff --git a/gateway/run.py b/gateway/run.py index 4f3b12375d66f..5baf9da11b29d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -12739,6 +12739,7 @@ def _set_session_env(self, context: SessionContext) -> list: user_id=str(context.source.user_id) if context.source.user_id else "", user_name=str(context.source.user_name) if context.source.user_name else "", session_key=context.session_key, + session_id=context.session_id, message_id=str(context.source.message_id) if context.source.message_id else "", async_delivery=_async_delivery, ) diff --git a/run_agent.py b/run_agent.py index 63050980934b2..6b195e8d51519 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3572,6 +3572,10 @@ def _build_keepalive_http_client(base_url: str = "") -> Any: try: import httpx as _httpx import socket as _socket + from agent.provider_client_identity import ( + build_zai_sync_http_client, + is_zai_endpoint, + ) if "api.githubcopilot.com" in str(base_url or "").lower(): return _httpx.Client() @@ -3588,10 +3592,13 @@ def _build_keepalive_http_client(base_url: str = "") -> Any: # Explicitly read proxy settings while still honoring NO_PROXY for # loopback / local endpoints such as a locally hosted sub2api. _proxy = _get_proxy_for_base_url(base_url) - return _httpx.Client( - transport=_httpx.HTTPTransport(socket_options=_sock_opts), - proxy=_proxy, - ) + _client_kwargs = { + "transport": _httpx.HTTPTransport(socket_options=_sock_opts), + "proxy": _proxy, + } + if is_zai_endpoint(base_url): + return build_zai_sync_http_client(**_client_kwargs) + return _httpx.Client(**_client_kwargs) except Exception: return None @@ -3988,27 +3995,32 @@ def _apply_client_headers_for_base_url(self, base_url: str) -> None: self._client_kwargs["default_headers"] = copilot_default_headers() elif base_url_host_matches(base_url, "api.kimi.com"): self._client_kwargs["default_headers"] = {"User-Agent": "claude-code/0.1.0"} - elif base_url_host_matches(base_url, "portal.qwen.ai"): - self._client_kwargs["default_headers"] = _qwen_portal_headers() - elif base_url_host_matches(base_url, "chatgpt.com"): - from agent.auxiliary_client import _codex_cloudflare_headers - self._client_kwargs["default_headers"] = _codex_cloudflare_headers( - self._client_kwargs.get("api_key", "") - ) else: - # No URL-specific headers — check profile.default_headers before clearing. - _ph_headers = None - try: - from providers import get_provider_profile as _gpf2 - _ph2 = _gpf2(self.provider) - if _ph2 and _ph2.default_headers: - _ph_headers = dict(_ph2.default_headers) - except Exception: - pass - if _ph_headers: - self._client_kwargs["default_headers"] = _ph_headers + from agent.provider_client_identity import is_zai_endpoint, zai_opencode_headers + + if is_zai_endpoint(base_url): + self._client_kwargs["default_headers"] = zai_opencode_headers() + elif base_url_host_matches(base_url, "portal.qwen.ai"): + self._client_kwargs["default_headers"] = _qwen_portal_headers() + elif base_url_host_matches(base_url, "chatgpt.com"): + from agent.auxiliary_client import _codex_cloudflare_headers + self._client_kwargs["default_headers"] = _codex_cloudflare_headers( + self._client_kwargs.get("api_key", "") + ) else: - self._client_kwargs.pop("default_headers", None) + # No URL-specific headers — check profile.default_headers before clearing. + _ph_headers = None + try: + from providers import get_provider_profile as _gpf2 + _ph2 = _gpf2(self.provider) + if _ph2 and _ph2.default_headers: + _ph_headers = dict(_ph2.default_headers) + except Exception: + pass + if _ph_headers: + self._client_kwargs["default_headers"] = _ph_headers + else: + self._client_kwargs.pop("default_headers", None) # User-configured overrides win over URL/profile defaults — keep them # applied across credential swaps and client rebuilds, not just at diff --git a/tests/agent/test_auxiliary_user_default_headers.py b/tests/agent/test_auxiliary_user_default_headers.py index c2038e5476f76..64d4afdc0bf9b 100644 --- a/tests/agent/test_auxiliary_user_default_headers.py +++ b/tests/agent/test_auxiliary_user_default_headers.py @@ -40,6 +40,15 @@ def test_user_headers_merged_and_win(self, tmp_path): assert merged["User-Agent"] == "curl/8.7.1" # user wins assert merged["X-Extra"] == "1" + def test_lowercase_user_agent_override_keeps_canonical_key(self, tmp_path): + _write_config(tmp_path, { + "model": {"default": "m", "default_headers": {"user-agent": "curl/8.7.1"}}, + }) + from agent.auxiliary_client import _apply_user_default_headers + merged = _apply_user_default_headers({"User-Agent": "OpenAI/Python 2.24.0"}) + assert merged["User-Agent"] == "curl/8.7.1" + assert "user-agent" not in merged + def test_no_config_is_noop_returns_original(self, tmp_path): _write_config(tmp_path, {"model": {"default": "m"}}) from agent.auxiliary_client import _apply_user_default_headers diff --git a/tests/agent/test_zai_opencode_identity.py b/tests/agent/test_zai_opencode_identity.py new file mode 100644 index 0000000000000..c14131c752d16 --- /dev/null +++ b/tests/agent/test_zai_opencode_identity.py @@ -0,0 +1,335 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from agent.provider_client_identity import ( + ZAI_OPENCODE_USER_AGENT, + is_zai_endpoint, + strip_stainless_headers, +) +from run_agent import AIAgent + +_ZAI_SESSION_HEADERS = ("x-session-affinity", "X-Session-Id", "x-parent-session-id") + + +@pytest.fixture(autouse=True) +def _isolate_hermes_home(tmp_path, monkeypatch): + from gateway.session_context import _UNSET, _VAR_MAP + + for var in _VAR_MAP.values(): + var.set(_UNSET) + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text("model:\n default: glm-5.2\n") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + yield + monkeypatch.delenv("HERMES_SESSION_ID", raising=False) + for var in _VAR_MAP.values(): + var.set(_UNSET) + + +def _close_forwarded_http_client(mock_openai) -> None: + http_client = (mock_openai.call_args.kwargs or {}).get("http_client") + if http_client is not None: + http_client.close() + + +def _assert_no_zai_session_headers(headers) -> None: + for name in _ZAI_SESSION_HEADERS: + assert name not in headers + + +def _make_agent(base_url: str, **kwargs) -> AIAgent: + return AIAgent( + api_key="zai-test-key", + base_url=base_url, + provider="zai", + model="glm-5.2", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + **kwargs, + ) + + +@patch("run_agent.OpenAI") +def test_api_zai_coding_endpoint_gets_opencode_identity(mock_openai): + mock_openai.return_value = MagicMock() + + agent = _make_agent( + "https://api.z.ai/api/coding/paas/v4", + session_id="sess-main", + parent_session_id="sess-parent", + ) + + headers = agent._client_kwargs["default_headers"] + assert headers["User-Agent"] == ZAI_OPENCODE_USER_AGENT + _assert_no_zai_session_headers(headers) + assert isinstance(mock_openai.call_args.kwargs["http_client"], httpx.Client) + _close_forwarded_http_client(mock_openai) + + +@patch("run_agent.OpenAI") +def test_open_bigmodel_coding_endpoint_gets_opencode_identity(mock_openai): + mock_openai.return_value = MagicMock() + + agent = _make_agent( + "https://open.bigmodel.cn/api/coding/paas/v4", + session_id="sess-cn", + ) + + headers = agent._client_kwargs["default_headers"] + assert headers["User-Agent"] == ZAI_OPENCODE_USER_AGENT + _assert_no_zai_session_headers(headers) + _close_forwarded_http_client(mock_openai) + + +@patch("run_agent.OpenAI") +def test_non_zai_provider_headers_remain_unchanged(mock_openai): + mock_openai.return_value = MagicMock() + + agent = AIAgent( + api_key="test-key", + base_url="https://api.example.com/v1", + provider="custom", + model="custom-model", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert "default_headers" not in agent._client_kwargs + assert "http_client" in mock_openai.call_args.kwargs + _close_forwarded_http_client(mock_openai) + + +@patch("run_agent.OpenAI") +def test_user_default_headers_override_zai_opencode_user_agent(mock_openai): + mock_openai.return_value = MagicMock() + + with patch("hermes_cli.config.load_config", return_value={ + "model": {"default_headers": {"user-agent": "curl/8.7.1", "X-Extra": "1"}}, + }): + agent = _make_agent("https://api.z.ai/api/coding/paas/v4") + + headers = agent._client_kwargs["default_headers"] + assert headers["User-Agent"] == "curl/8.7.1" + assert "user-agent" not in headers + assert headers["X-Extra"] == "1" + _assert_no_zai_session_headers(headers) + _close_forwarded_http_client(mock_openai) + + +def test_is_zai_endpoint_is_exact_host_scoped(): + assert is_zai_endpoint("https://api.z.ai/api/coding/paas/v4") is True + assert is_zai_endpoint("https://open.bigmodel.cn/api/coding/paas/v4") is True + assert is_zai_endpoint("https://proxy.example.test/api.z.ai/api/paas/v4") is False + assert is_zai_endpoint("https://api.z.ai.example.test/api/paas/v4") is False + + +def test_stainless_strip_hook_removes_all_sdk_fingerprint_headers(): + request = httpx.Request( + "POST", + "https://api.z.ai/api/coding/paas/v4/chat/completions", + headers={ + "X-Stainless-Lang": "python", + "x-stainless-package-version": "2.24.0", + "X-Stainless-Runtime": "CPython", + "X-Other": "keep", + }, + ) + + strip_stainless_headers(request) + + assert "X-Stainless-Lang" not in request.headers + assert "x-stainless-package-version" not in request.headers + assert "X-Stainless-Runtime" not in request.headers + assert request.headers["X-Other"] == "keep" + + +def test_auxiliary_openai_client_applies_zai_identity(monkeypatch): + from gateway.session_context import set_session_vars + + monkeypatch.setenv("HERMES_SESSION_ID", "stale-env-session") + set_session_vars(session_id="sess-aux") + monkeypatch.setenv("ZAI_API_KEY", "zai-key") + + with patch("hermes_cli.auth.detect_zai_endpoint", return_value=None), \ + patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = SimpleNamespace( + api_key="zai-key", + base_url="https://api.z.ai/api/coding/paas/v4", + ) + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client( + "zai", + "glm-5.2", + explicit_api_key="zai-key", + explicit_base_url="https://api.z.ai/api/coding/paas/v4", + ) + + assert client is mock_openai.return_value + assert model == "glm-5.2" + kwargs = mock_openai.call_args.kwargs + assert kwargs["default_headers"]["User-Agent"] == ZAI_OPENCODE_USER_AGENT + _assert_no_zai_session_headers(kwargs["default_headers"]) + assert isinstance(kwargs["http_client"], httpx.Client) + request = httpx.Request( + "POST", + "https://api.z.ai/api/coding/paas/v4/chat/completions", + headers={"X-Stainless-Lang": "python"}, + ) + kwargs["http_client"].event_hooks["request"][0](request) + _assert_no_zai_session_headers(request.headers) + assert "X-Stainless-Lang" not in request.headers + kwargs["http_client"].close() + + +def test_cached_zai_http_client_does_not_emit_task_session(monkeypatch): + from gateway.session_context import set_session_vars + + monkeypatch.setenv("HERMES_SESSION_ID", "stale-env-session") + set_session_vars(session_id="sess-one") + monkeypatch.setenv("ZAI_API_KEY", "zai-key") + + with patch("hermes_cli.auth.detect_zai_endpoint", return_value=None), \ + patch("agent.auxiliary_client.OpenAI") as mock_openai: + mock_openai.return_value = SimpleNamespace( + api_key="zai-key", + base_url="https://api.z.ai/api/coding/paas/v4", + ) + from agent.auxiliary_client import resolve_provider_client + + resolve_provider_client( + "zai", + "glm-5.2", + explicit_api_key="zai-key", + explicit_base_url="https://api.z.ai/api/coding/paas/v4", + ) + + http_client = mock_openai.call_args.kwargs["http_client"] + hook = http_client.event_hooks["request"][0] + request_one = httpx.Request( + "POST", + "https://api.z.ai/api/coding/paas/v4/chat/completions", + ) + hook(request_one) + _assert_no_zai_session_headers(request_one.headers) + + set_session_vars(session_id="sess-two") + request_two = httpx.Request( + "POST", + "https://api.z.ai/api/coding/paas/v4/chat/completions", + ) + hook(request_two) + _assert_no_zai_session_headers(request_two.headers) + http_client.close() + + +def test_zai_request_hook_strips_stainless_without_adding_session_headers(monkeypatch): + from agent.provider_client_identity import build_zai_sync_http_client + from gateway.session_context import set_session_vars + + monkeypatch.setenv("HERMES_SESSION_ID", "stale-env-session") + set_session_vars(session_id="fresh-session") + + http_client = build_zai_sync_http_client() + request = httpx.Request( + "POST", + "https://api.z.ai/api/coding/paas/v4/chat/completions", + headers={"X-Stainless-Lang": "python"}, + ) + + http_client.event_hooks["request"][0](request) + + _assert_no_zai_session_headers(request.headers) + assert "X-Stainless-Lang" not in request.headers + http_client.close() + + +def test_auxiliary_async_openai_client_applies_zai_identity(monkeypatch): + from gateway.session_context import set_session_vars + + monkeypatch.setenv("HERMES_SESSION_ID", "stale-env-session") + set_session_vars(session_id="sess-async") + monkeypatch.setenv("ZAI_API_KEY", "zai-key") + + sync_client = SimpleNamespace( + api_key="zai-key", + base_url="https://open.bigmodel.cn/api/coding/paas/v4", + ) + async_client = MagicMock() + with patch("hermes_cli.auth.detect_zai_endpoint", return_value=None), \ + patch("agent.auxiliary_client.OpenAI", return_value=sync_client), \ + patch("openai.AsyncOpenAI", return_value=async_client) as mock_async_openai: + from agent.auxiliary_client import resolve_provider_client + + client, model = resolve_provider_client( + "zai", + "glm-5.2", + async_mode=True, + explicit_api_key="zai-key", + explicit_base_url="https://open.bigmodel.cn/api/coding/paas/v4", + ) + + assert client is async_client + assert model == "glm-5.2" + kwargs = mock_async_openai.call_args.kwargs + assert kwargs["default_headers"]["User-Agent"] == ZAI_OPENCODE_USER_AGENT + _assert_no_zai_session_headers(kwargs["default_headers"]) + assert isinstance(kwargs["http_client"], httpx.AsyncClient) + request = httpx.Request( + "POST", + "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", + headers={"X-Stainless-Runtime": "CPython", "X-Other": "keep"}, + ) + asyncio.run(kwargs["http_client"].event_hooks["request"][0](request)) + assert "X-Stainless-Runtime" not in request.headers + assert request.headers["X-Other"] == "keep" + _assert_no_zai_session_headers(request.headers) + asyncio.run(kwargs["http_client"].aclose()) + + +@pytest.mark.parametrize( + "base_url", + [ + "https://api.z.ai/api/anthropic", + "https://open.bigmodel.cn/api/anthropic", + ], +) +def test_zai_anthropic_client_preserves_betas_and_strips_stainless(base_url): + from agent.anthropic_adapter import build_anthropic_client + from gateway.session_context import set_session_vars + + with patch("agent.anthropic_adapter._anthropic_sdk") as mock_sdk: + build_anthropic_client( + "zai-key", + base_url=base_url, + ) + + kwargs = mock_sdk.Anthropic.call_args.kwargs + assert kwargs["api_key"] == "zai-key" + assert "auth_token" not in kwargs + headers = kwargs["default_headers"] + assert headers["User-Agent"] == ZAI_OPENCODE_USER_AGENT + _assert_no_zai_session_headers(headers) + assert "interleaved-thinking-2025-05-14" in headers["anthropic-beta"] + assert "fine-grained-tool-streaming-2025-05-14" in headers["anthropic-beta"] + + set_session_vars(session_id="sess-anthropic") + request = httpx.Request( + "POST", + "https://api.z.ai/api/anthropic/v1/messages", + headers={"X-Stainless-Arch": "arm64", "anthropic-beta": headers["anthropic-beta"]}, + ) + hook = kwargs["http_client"].event_hooks["request"][0] + hook(request) + assert "X-Stainless-Arch" not in request.headers + _assert_no_zai_session_headers(request.headers) + assert request.headers["anthropic-beta"] == headers["anthropic-beta"] + kwargs["http_client"].close() diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index b0797467d452c..84a7a086e5c9d 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -217,7 +217,7 @@ def test_session_id_set_via_contextvars(monkeypatch): def test_set_session_env_includes_session_key(): - """_set_session_env should propagate session_key from SessionContext.""" + """_set_session_env should propagate session identifiers from SessionContext.""" runner = object.__new__(GatewayRunner) source = SessionSource( platform=Platform.TELEGRAM, @@ -231,17 +231,20 @@ def test_set_session_env_includes_session_key(): connected_platforms=[], home_channels={}, session_key="tg:-1001:17585", + session_id="session-abc123", ) # Capture baseline value before setting (may be non-empty from another # test in the same pytest-xdist worker sharing the context). tokens = runner._set_session_env(context) assert get_session_env("HERMES_SESSION_KEY") == "tg:-1001:17585" + assert get_session_env("HERMES_SESSION_ID") == "session-abc123" runner._clear_session_env(tokens) # After clearing, the session key must not retain the value we just set. # The exact post-clear value depends on context propagation from other # tests, so only check that our value was removed, not what replaced it. assert get_session_env("HERMES_SESSION_KEY") != "tg:-1001:17585" + assert get_session_env("HERMES_SESSION_ID") != "session-abc123" def test_session_key_no_race_condition_with_contextvars(monkeypatch): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 0c70557ce3a29..fc5628d68b9b2 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -80,6 +80,9 @@ def test_session_context_uses_session_cwd(monkeypatch, tmp_path): tokens = server._set_session_context(session_key) try: + from gateway.session_context import get_session_env + + assert get_session_env("HERMES_SESSION_ID") == session_key assert resolve_agent_cwd() == project finally: server._clear_session_context(tokens) @@ -143,6 +146,9 @@ def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path): tokens = server._set_session_context("bg_deadbe", cwd=str(project)) try: + from gateway.session_context import get_session_env + + assert get_session_env("HERMES_SESSION_ID") == "bg_deadbe" assert resolve_agent_cwd() == project finally: server._clear_session_context(tokens) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6bb4743dc9fdb..c5d9d9c6686a9 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1497,7 +1497,12 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list: if sess.get("session_key") == session_key: source = _session_source(sess) break - return set_session_vars(session_key=session_key, source=source, cwd=resolved) + return set_session_vars( + session_key=session_key, + session_id=session_key, + source=source, + cwd=resolved, + ) except Exception: return [] From b04fb42bc17e989d61344526e54749987f657c44 Mon Sep 17 00:00:00 2001 From: Darko Luketic Date: Fri, 26 Jun 2026 16:24:11 +0200 Subject: [PATCH 2/2] Update provider_client_identity.py opencode/1.17.11 --- agent/provider_client_identity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/provider_client_identity.py b/agent/provider_client_identity.py index d7c287b730180..a93bbd42d0de3 100644 --- a/agent/provider_client_identity.py +++ b/agent/provider_client_identity.py @@ -12,7 +12,7 @@ from utils import base_url_hostname -ZAI_OPENCODE_USER_AGENT = "opencode/1.17.9" +ZAI_OPENCODE_USER_AGENT = "opencode/1.17.11" _ZAI_HOSTS = frozenset({"api.z.ai", "open.bigmodel.cn"})