Загрузка данных
import asyncio
import base64
import json
import re
import time
from typing import Any, Callable, Dict, List, Optional, Union
from urllib.parse import urlsplit
import httpx
from dotenv import load_dotenv
from drivers.base_driver import BaseDriver
from drivers.providers import DriverProvider
from drivers.shared_utils import (
COMMON_REQUEST_MACRO_ACTIONS,
IncrementalTextAccumulator,
build_prompt_text_file_payload,
clear_clean_regeneration_cache,
compute_missing_suffix,
extract_macro_overrides,
find_multi_slot_cache_entry,
format_request_messages,
make_openai_delta_sse,
make_openai_usage_sse,
read_multi_slot_cache_payload,
remove_multi_slot_cache_entry,
strip_macros_from_messages,
upsert_multi_slot_cache_entry,
)
from utils.cache_manager import CacheManager
from utils.logger import Logger
from utils.model_ids import (
MODE_CHAT,
MODE_REASONER,
resolve_behavior_mode,
resolve_real_model_label_from_model_id,
)
load_dotenv()
GLM_REQUEST_MACRO_ACTIONS: Dict[str, tuple[str, Any]] = {
**COMMON_REQUEST_MACRO_ACTIONS,
"tool": ("tools_enabled", True),
"tools": ("tools_enabled", True),
"notool": ("tools_enabled", False),
"no_tool": ("tools_enabled", False),
"no-tool": ("tools_enabled", False),
"notools": ("tools_enabled", False),
"no_tools": ("tools_enabled", False),
"no-tools": ("tools_enabled", False),
}
class GLMDriver(BaseDriver):
REQUEST_CAPTURE_MODE_REPLAY = "replay"
REQUEST_CAPTURE_MODE_CDP_TEEING = "cdp_teeing"
CHAT_URL = "https://chat.z.ai/"
AUTH_URL = "https://chat.z.ai/auth"
CONVERSATION_URL_RE = re.compile(r"^https://chat\.z\.ai/c/([^/?#]+)", re.IGNORECASE)
COMPLETION_ROUTE_GLOB = "**/api/v2/chat/completions**"
COMPLETION_URL_PATHS = {"/api/v2/chat/completions"}
MODEL_CONCURRENCY_LIMIT_CODE = "MODEL_CONCURRENCY_LIMIT"
GLM_52_MODEL_FRIENDLY = "GLM-5.2"
TOOLS_SUPPORTED_MODEL_FRIENDLY = ""
DEFAULT_GLM_52_DEEPTHINK_EFFORT = "max"
MODEL_CAPACITY_TEXT_MARKERS = (
"model_concurrency_limit",
"currently at capacity",
"peak hours",
"switch to another model",
)
EMPTY_COMPLETION_STREAM_ERROR_CODE = "20001"
REFRESH_AFTER_GENERATION_DELAY_S = 2.0
COMPLETION_REQUEST_TIMEOUT_S = 150.0
INTERCEPT_FIRST_CHUNK_TIMEOUT_S = 45.0
INTERCEPT_IDLE_TIMEOUT_S = 75.0
MODEL_SELECTOR_READY_TIMEOUT_MS = 20000
CLEAN_REGEN_STATE_KEYS = (
"deepthink_enabled",
"deepthink_effort",
"search_enabled",
"advanced_search_enabled",
"tools_enabled",
"send_as_text_file",
"ui_model",
)
SIDEBAR_NEW_CHAT_BUTTON_SELECTOR = "button#sidebar-new-chat-button"
QUICK_NEW_CHAT_BUTTON_SELECTOR = "button[data-scene='chat']"
LEGACY_QUICK_NEW_CHAT_BUTTON_SELECTOR = "button#new-chat-button"
MODEL_SELECTOR_BUTTON_SELECTOR = "button.modelSelectorButton"
MODEL_DROPDOWN_ID = "f8T9iEf1QC"
MODEL_DROPDOWN_SELECTOR = f"div#{MODEL_DROPDOWN_ID}"
MODEL_OPTION_SELECTOR = "button[aria-label='model-item'][data-value], div[role='menu'] button[data-value]"
# Only these models are supported by this driver.
MODEL_DATA_VALUE_BY_FRIENDLY: Dict[str, str] = {
"GLM-5.2": "glm-5.2",
"GLM-5.3": "glm-5.3",
"GLM-5.3-Flash": "glm-5.3-flash",
}
# Models hidden behind a collapsible section in older dropdown variants.
MODELS_IN_COLLAPSIBLE: set = set()
def __init__(self, config_manager):
super().__init__(config_manager=config_manager, provider=DriverProvider.GLM_CHAT)
self.cache_manager = CacheManager()
self.current_model: Optional[str] = None
self.current_send_deepthink: Optional[bool] = None
self.thinking_active = False
self._pending_request_overrides: dict[str, Any] = {}
self.clean_regen_message_cache_key = "glm_last_message.txt"
self.clean_regen_state_cache_key = "glm_last_message_state.json"
self.multi_slot_cache_key = "glm_multi_slot_cache.json"
self.repetition_buster_cache_key = "glm_repetition_buster_prompt_cache.json"
self._refresh_after_generation = False
self._refresh_after_generation_task: asyncio.Task | None = None
self._refresh_quirks()
def _refresh_quirks(self) -> None:
"""Read quirks settings from config and cache them as instance attributes."""
try:
ui_timeout = int(self.config_manager.get_setting("glm_behavior", "ui_click_timeout") or 3000)
except Exception:
ui_timeout = 3000
try:
post_delay = int(self.config_manager.get_setting("glm_behavior", "post_action_delay") or 500)
except Exception:
post_delay = 500
try:
msg_send_timeout = int(self.config_manager.get_setting("glm_behavior", "message_send_timeout") or 5)
except Exception:
msg_send_timeout = 5
try:
completion_request_timeout = float(
self.config_manager.get_setting("glm_behavior", "completion_request_timeout")
or self.COMPLETION_REQUEST_TIMEOUT_S
)
except Exception:
completion_request_timeout = self.COMPLETION_REQUEST_TIMEOUT_S
try:
first_chunk_timeout = float(
self.config_manager.get_setting("glm_behavior", "first_chunk_timeout") or self.INTERCEPT_FIRST_CHUNK_TIMEOUT_S
)
except Exception:
first_chunk_timeout = self.INTERCEPT_FIRST_CHUNK_TIMEOUT_S
try:
refresh_after_generation = bool(
self.config_manager.get_setting("glm_behavior", "refresh_after_generation")
)
except Exception:
refresh_after_generation = False
self._ui_timeout = max(ui_timeout, 500)
self._post_delay_s = max(post_delay, 0) / 1000.0
self._msg_send_timeout = max(msg_send_timeout, 1)
self._completion_request_timeout_s = max(completion_request_timeout, 5.0)
self._first_chunk_timeout_s = max(first_chunk_timeout, 5.0)
self._refresh_after_generation = bool(refresh_after_generation)
async def _await_pending_refresh_after_generation(self, abort_event: asyncio.Event | None = None) -> None:
task = getattr(self, "_refresh_after_generation_task", None)
if not task:
return
if abort_event and abort_event.is_set():
try:
if not task.done():
task.cancel()
except Exception:
pass
self._refresh_after_generation_task = None
return
try:
await task
except asyncio.CancelledError:
pass
except Exception as e:
Logger.warning(f"GLM Chat: Refresh After Generation task failed: {e}")
finally:
self._refresh_after_generation_task = None
def _schedule_refresh_after_generation(self) -> None:
if not self.page:
return
if not getattr(self, "_refresh_after_generation", False):
return
try:
if self.page.is_closed():
return
except Exception:
pass
existing = getattr(self, "_refresh_after_generation_task", None)
if existing and (not existing.done()):
try:
existing.cancel()
except Exception:
pass
self._refresh_after_generation_task = asyncio.create_task(self._refresh_page_after_generation())
async def _refresh_page_after_generation(self) -> None:
if not self.page:
return
try:
await asyncio.sleep(self.REFRESH_AFTER_GENERATION_DELAY_S)
except asyncio.CancelledError:
return
await self._reload_chat_page("Refresh After Generation enabled")
async def _reload_chat_page(self, reason: str) -> None:
if not self.page:
return
try:
if self.page.is_closed():
return
except Exception:
pass
Logger.info(f"GLM Chat: {reason}, reloading page...")
try:
await self.page.reload(wait_until="domcontentloaded", timeout=45000)
except asyncio.CancelledError:
return
except Exception as e:
Logger.warning(f"GLM Chat: failed to reload page: {e}")
return
try:
await self._wait_for_chat_shell_ready(timeout_ms=60000)
except Exception as e:
Logger.warning(f"GLM Chat: page reload completed but chat shell was not ready: {e}")
return
# Best-effort: if the session expired, warn early so the next request isn't a surprise
try:
if await self._chat_page_contains_sign_in():
Logger.warning("GLM Chat: after reload, Sign in was detected - you may need to log in again.")
except Exception:
pass
async def _refresh_page_after_capacity_error(self) -> None:
if not self.page:
return
try:
if self.page.is_closed():
return
except Exception:
pass
Logger.info("GLM Chat: Model capacity error detected, returning to main chat page...")
try:
await self.page.goto(self.CHAT_URL, wait_until="domcontentloaded", timeout=45000)
except asyncio.CancelledError:
return
except Exception as e:
Logger.warning(f"GLM Chat: failed to return to main chat page after capacity error: {e}")
return
try:
await self._wait_for_chat_shell_ready(timeout_ms=60000)
except Exception as e:
Logger.warning(f"GLM Chat: main chat page loaded but shell was not ready after capacity error: {e}")
return
try:
if await self._chat_page_contains_sign_in():
Logger.warning("GLM Chat: after capacity-error recovery, Sign in was detected - you may need to log in again.")
except Exception:
pass
def _reset_generation_state(self) -> None:
self.current_abort_event = None
self.abort_requested = False
self.current_model = None
self.current_send_deepthink = None
self.thinking_active = False
def get_start_url(self) -> str:
return self.CHAT_URL
async def after_start(self, status_callback: Optional[Callable[[str], None]] = None) -> None:
await self.check_ui_language(status_callback=status_callback)
clear_clean_regeneration_cache(
self.cache_manager,
self.clean_regen_message_cache_key,
self.clean_regen_state_cache_key,
)
async def cleanup_background_tasks(self) -> None:
await self._cancel_task(
self._refresh_after_generation_task,
label="stopping GLM refresh-after-generation task",
)
self._refresh_after_generation_task = None
def set_request_overrides(self, overrides: dict[str, Any] | None = None) -> None:
self._pending_request_overrides = dict(overrides or {})
def api_real_model_labels(self) -> list[str]:
return list(self.MODEL_DATA_VALUE_BY_FRIENDLY.keys())
def _get_glm_model_label_for_request(self, model: Any = None) -> str:
override = resolve_real_model_label_from_model_id(
self.provider,
model,
self.api_real_model_labels(),
)
return override or self._get_configured_glm_model_friendly()
async def apply_configured_model(
self,
model: Any = None,
wait_until_ready: bool = False,
) -> None:
await self._dismiss_dialog_close_buttons()
desired_friendly = self._get_glm_model_label_for_request(model)
if not desired_friendly:
return
# GLM's model picker is unreliable outside the fresh-new-chat flow
# Keep the generic provider hook as a harmless no-op and only perform
# actual picker work when a caller explicitly opts into waiting
if not wait_until_ready:
return
try:
await self._ensure_glm_model_selected(desired_friendly, wait_until_ready=wait_until_ready)
except Exception as e:
Logger.warning(f"GLM Chat: Failed to apply model selection '{desired_friendly}': {e}")
def _get_configured_glm_model_friendly(self) -> str:
try:
value = self.config_manager.get_setting("glm_behavior", "model")
except Exception:
value = None
return str(value or "").strip()
def _get_configured_glm_deepthink_effort(self) -> str:
try:
value = self.config_manager.get_setting("glm_behavior", "deepthink_effort")
except Exception:
value = None
return self._normalize_glm_deepthink_effort(value)
@staticmethod
def _normalize_model_label(value: str) -> str:
return re.sub(r"\\s+", " ", str(value or "")).strip().lower()
@classmethod
def _glm_uses_deepthink_effort_controls(cls, model_friendly: str) -> bool:
return cls._normalize_model_label(model_friendly) == cls._normalize_model_label(
cls.GLM_52_MODEL_FRIENDLY
)
@classmethod
def _normalize_glm_deepthink_effort(cls, value: Any, default: str | None = None) -> str:
fallback = str(default or cls.DEFAULT_GLM_52_DEEPTHINK_EFFORT).strip().lower()
normalized = str(value or "").strip().lower()
normalized = re.sub(r"[\s_]+", "-", normalized)
normalized = re.sub(r"-{2,}", "-", normalized).strip("-")
if normalized in {"max", "maximum", "xhigh", "x-high", "extra-high", "extra-highest"}:
return "max"
if normalized in {"high", "medium", "med"}:
return "high"
return fallback if fallback in {"high", "max"} else cls.DEFAULT_GLM_52_DEEPTHINK_EFFORT
def _get_request_capture_mode(self) -> str:
try:
mode = str(
self.config_manager.get_setting("glm_behavior", "request_capture_mode")
or self.REQUEST_CAPTURE_MODE_REPLAY
).strip().lower()
except Exception:
mode = self.REQUEST_CAPTURE_MODE_REPLAY
if mode == self.REQUEST_CAPTURE_MODE_CDP_TEEING:
return self.REQUEST_CAPTURE_MODE_CDP_TEEING
return self.REQUEST_CAPTURE_MODE_REPLAY
@classmethod
def _is_completion_request_url(cls, url: Any) -> bool:
try:
parsed = urlsplit(str(url or ""))
except Exception:
return False
return parsed.path in cls.COMPLETION_URL_PATHS
async def _dismiss_dialog_close_buttons(self, context: str = "GLM Chat") -> int:
if not self.page:
return 0
try:
clicked = await self.page.evaluate(
"""() => {
const isVisible = (element) => {
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
rect.width > 0 &&
rect.height > 0
);
};
let clicked = 0;
for (const button of document.querySelectorAll('button[data-dialog-close]')) {
if (button.disabled || !isVisible(button)) {
continue;
}
try {
button.click();
clicked += 1;
} catch (e) {
// Ignore one bad close button; the next UI action will retry.
}
}
return clicked;
}"""
)
except Exception as e:
Logger.debug(f"{context}: failed to dismiss data-dialog-close buttons: {e}")
return 0
try:
clicked_count = int(clicked or 0)
except Exception:
clicked_count = 0
if clicked_count:
Logger.debug(f"{context}: dismissed {clicked_count} startup dialog close button(s).")
await asyncio.sleep(0.1)
return clicked_count
async def _read_glm_pointer_events_state(self) -> dict[str, Any]:
"""Return whether GLM's app shell is currently accepting pointer events."""
if not self.page:
return {"ready": True}
try:
state = await self.page.evaluate(
"""() => {
const normalize = (value) => String(value || '')
.replace(/\\s+/g, ' ')
.trim();
const visibleOpenControls = Array.from(
document.querySelectorAll('[data-state="open"], [aria-expanded="true"]')
).filter((element) => {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
rect.width > 0 &&
rect.height > 0
);
}).slice(0, 8).map((element) => ({
tag: normalize(element.tagName).toLowerCase(),
id: normalize(element.id),
text: normalize(element.textContent).slice(0, 80),
dataState: normalize(element.getAttribute('data-state')),
ariaExpanded: normalize(element.getAttribute('aria-expanded')),
}));
const bodyStyle = document.body
? window.getComputedStyle(document.body)
: null;
const htmlStyle = document.documentElement
? window.getComputedStyle(document.documentElement)
: null;
const bodyPointerEvents = normalize(bodyStyle?.pointerEvents || '');
const htmlPointerEvents = normalize(htmlStyle?.pointerEvents || '');
return {
bodyPointerEvents,
htmlPointerEvents,
openControls: visibleOpenControls,
ready: bodyPointerEvents.toLowerCase() !== 'none',
};
}"""
)
except Exception as e:
Logger.debug(f"GLM Chat: failed to read pointer-events state: {e}")
return {"ready": True}
return state if isinstance(state, dict) else {"ready": True}
async def _ensure_glm_pointer_events_ready(
self,
*,
context: str = "GLM Chat",
timeout_ms: int | None = None,
send_escape: bool = True,
) -> bool:
"""Close stale popovers until GLM restores normal click hit-testing."""
if not self.page:
return False
timeout = int(timeout_ms or self._ui_timeout)
deadline = time.time() + max(0.0, float(timeout) / 1000.0)
last_state: dict[str, Any] = {}
last_escape_at = 0.0
while True:
last_state = await self._read_glm_pointer_events_state()
if last_state.get("ready"):
return True
now = time.time()
if send_escape and (last_escape_at <= 0.0 or (now - last_escape_at) >= 0.35):
try:
await self.page.keyboard.press("Escape")
except Exception as e:
Logger.debug(f"{context}: failed to press Escape while unblocking UI: {e}")
last_escape_at = now
if now >= deadline:
Logger.debug(
f"{context}: UI still has body pointer-events='{last_state.get('bodyPointerEvents', '')}' "
f"after {timeout}ms; open controls={last_state.get('openControls', [])}"
)
return False
await asyncio.sleep(0.1)
async def _click_glm_control(
self,
target: Any,
*,
label: str,
timeout_ms: int | None = None,
ensure_unblocked: bool = True,
evaluate_fallback: bool = True,
) -> bool:
"""Click a GLM control with fallbacks for transient overlay hit-test failures."""
if not self.page or target is None:
return False
timeout = int(timeout_ms or self._ui_timeout)
if ensure_unblocked:
await self._ensure_glm_pointer_events_ready(
context=f"GLM Chat: before clicking {label}",
timeout_ms=min(timeout, 1000),
)
try:
await target.scroll_into_view_if_needed(timeout=timeout)
except Exception:
pass
try:
await target.click(timeout=timeout)
return True
except Exception as e:
Logger.debug(f"GLM Chat: {label} click failed, trying fallbacks: {e}")
try:
await target.click(timeout=timeout, force=True)
return True
except Exception as e:
Logger.debug(f"GLM Chat: {label} forced click failed: {e}")
if not evaluate_fallback:
return False
try:
return bool(
await target.evaluate(
"""(element) => {
if (!element) return false;
element.click();
return true;
}"""
)
)
except Exception as e:
Logger.debug(f"GLM Chat: {label} DOM click fallback failed: {e}")
return False
async def _read_glm_model_selector_state(self) -> dict[str, Any]:
if not self.page:
return {"exists": False, "ready": False}
expression = (
"selector => {"
" const btn = document.querySelector(selector);"
" if (!btn) return { exists: false, ready: false };"
" const style = window.getComputedStyle(btn);"
" const className = (btn.className || '').toString();"
" const classTokens = className.split(/\\s+/).filter(Boolean);"
" const rect = btn.getBoundingClientRect();"
" const visible = !!style && style.display !== 'none' && style.visibility !== 'hidden';"
" const hasSize = !!rect && rect.width > 0 && rect.height > 0;"
" const cursorDefault = classTokens.includes('cursor-default');"
" const pointerEvents = ((style && style.pointerEvents) || '').toLowerCase();"
" const disabled = !!btn.disabled;"
" return {"
" exists: true,"
" visible,"
" hasSize,"
" disabled,"
" cursorDefault,"
" className,"
" ariaDisabled: (btn.getAttribute('aria-disabled') || '').trim().toLowerCase(),"
" hasDataDisabled: btn.hasAttribute('data-disabled'),"
" pointerEvents,"
" ready: visible && hasSize && !disabled && !cursorDefault && pointerEvents !== 'none'"
" };"
"}"
)
try:
state = await self.page.evaluate(expression, self.MODEL_SELECTOR_BUTTON_SELECTOR)
except Exception as e:
Logger.debug(f"GLM Chat: failed to read model selector readiness: {e}")
return {"exists": False, "ready": False}
return state if isinstance(state, dict) else {"exists": False, "ready": False}
async def _is_glm_model_selector_ready(self) -> bool:
state = await self._read_glm_model_selector_state()
return bool(state.get("ready"))
async def _wait_for_glm_model_selector_ready(self, timeout_ms: int = 10000) -> bool:
if not self.page:
return False
deadline = time.time() + max(0.0, float(timeout_ms) / 1000.0)
while True:
state = await self._read_glm_model_selector_state()
if state.get("ready"):
return True
if time.time() >= deadline:
Logger.debug(
"GLM Chat: model selector stayed disabled "
f"(class='{state.get('className', '')}', "
f"aria-disabled='{state.get('ariaDisabled', '')}', "
f"data-disabled={bool(state.get('hasDataDisabled'))})."
)
return False
await asyncio.sleep(0.2)
async def _click_glm_model_selector_button(self) -> bool:
if not self.page:
return False
button = self.page.locator(self.MODEL_SELECTOR_BUTTON_SELECTOR)
if await button.count() == 0:
Logger.warning("GLM Chat: model selector button not found.")
return False
# GLM sometimes leaves aria-disabled/data-disabled on this button a bit longer
# than the real UI lock. The cursor-default class tracks the clickable state more reliably.
state = await self._read_glm_model_selector_state()
if not state.get("ready"):
return False
try:
await button.first.click(timeout=self._ui_timeout, force=True)
return True
except Exception as e:
Logger.warning(f"GLM Chat: failed to click model selector button: {e}")
try:
clicked = await self.page.evaluate(
"""(selector) => {
const btn = document.querySelector(selector);
if (!btn) return false;
btn.click();
return true;
}""",
self.MODEL_SELECTOR_BUTTON_SELECTOR,
)
return bool(clicked)
except Exception as e:
Logger.warning(f"GLM Chat: JS model selector click failed: {e}")
return False
async def _wait_and_open_glm_model_dropdown(self, timeout_ms: int = 10000) -> bool:
if not self.page:
return False
deadline = time.time() + max(0.0, float(timeout_ms) / 1000.0)
while True:
if not await self._wait_for_glm_model_selector_ready(timeout_ms=min(self._ui_timeout, 1000)):
if time.time() >= deadline:
return False
await asyncio.sleep(0.2)
continue
if await self._open_glm_model_dropdown(timeout_ms=min(self._ui_timeout, 1000)):
return True
if time.time() >= deadline:
return False
await asyncio.sleep(0.2)
async def _read_current_glm_model_label(self) -> str:
if not self.page:
return ""
try:
label = await self.page.evaluate(
"() => {"
" const btn = document.querySelector('button.modelSelectorButton');"
" if (!btn) return '';"
" const div = btn.querySelector('div');"
" if (!div) return '';"
" const node = div.childNodes && div.childNodes[0];"
" const text = (node && node.textContent) ? node.textContent : (div.textContent || '');"
" return (text || '').toString().trim();"
"}"
)
except Exception as e:
Logger.debug(f"GLM Chat: failed to read current model label: {e}")
return ""
return str(label or "").strip()
async def _open_glm_model_dropdown(self, timeout_ms: int = 5000) -> bool:
if not self.page:
return False
if not await self._click_glm_model_selector_button():
return False
# Wait for the dropdown content to appear. The old dropdown had a fixed
# id, but the current Bits UI generates dynamic wrapper ids.
try:
await self.page.wait_for_selector(
self.MODEL_OPTION_SELECTOR,
timeout=int(timeout_ms),
state="visible",
)
return True
except Exception:
pass
# Legacy fallback for older GLM sessions.
try:
await self.page.wait_for_selector(
f"{self.MODEL_DROPDOWN_SELECTOR} button[data-value]",
timeout=int(timeout_ms),
state="visible",
)
return True
except Exception:
pass
# Fallback if the dropdown id changes.
try:
await self.page.wait_for_selector("button[data-value]", timeout=int(timeout_ms), state="visible")
return True
except Exception:
Logger.warning("GLM Chat: model dropdown did not appear after clicking the selector (this is usually very bad).")
return False
async def _is_glm_model_dropdown_open(self) -> bool:
if not self.page:
return False
try:
return bool(
await self.page.evaluate(
"""(optionSelector) => {
const isVisible = (element) => {
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
rect.width > 0 &&
rect.height > 0
);
};
const selector = document.querySelector('button.modelSelectorButton');
const expanded = String(selector?.getAttribute('aria-expanded') || '')
.trim()
.toLowerCase();
if (expanded === 'true') {
return true;
}
return Array.from(document.querySelectorAll(optionSelector)).some(isVisible);
}""",
self.MODEL_OPTION_SELECTOR,
)
)
except Exception:
pass
try:
options = self.page.locator(self.MODEL_OPTION_SELECTOR)
count = await options.count()
for idx in range(min(count, 10)):
try:
if await options.nth(idx).is_visible():
return True
except Exception:
continue
except Exception:
pass
return False
async def _wait_for_glm_model_dropdown_closed(self, timeout_ms: int | None = None) -> bool:
timeout = int(timeout_ms or self._ui_timeout)
deadline = time.time() + max(0.0, float(timeout) / 1000.0)
while True:
if not await self._is_glm_model_dropdown_open():
return True
if time.time() >= deadline:
return False
await asyncio.sleep(0.1)
async def _close_glm_model_dropdown(self) -> None:
if not self.page:
return
if not await self._is_glm_model_dropdown_open():
await self._ensure_glm_pointer_events_ready(
context="GLM Chat: after model dropdown check",
timeout_ms=min(self._ui_timeout, 1000),
)
return
try:
await self.page.keyboard.press("Escape")
if await self._wait_for_glm_model_dropdown_closed(timeout_ms=self._ui_timeout):
await self._ensure_glm_pointer_events_ready(
context="GLM Chat: after closing model dropdown",
timeout_ms=self._ui_timeout,
)
return
except Exception:
pass
button = self.page.locator(self.MODEL_SELECTOR_BUTTON_SELECTOR)
if await button.count() == 0:
return
try:
await button.first.click(timeout=self._ui_timeout, force=True)
except Exception:
try:
await self.page.evaluate(
"""(selector) => {
const btn = document.querySelector(selector);
if (btn) btn.click();
}""",
self.MODEL_SELECTOR_BUTTON_SELECTOR,
)
except Exception:
return
try:
await self._wait_for_glm_model_dropdown_closed(timeout_ms=self._ui_timeout)
except Exception:
return
await self._ensure_glm_pointer_events_ready(
context="GLM Chat: after closing model dropdown",
timeout_ms=self._ui_timeout,
)
async def _expand_collapsible_section(self) -> bool:
"""Expand the model dropdown's More Models section when it is collapsed."""
if not self.page:
return False
try:
content = self.page.locator("div[data-melt-collapsible-content]")
if await content.count() > 0:
try:
if await content.first.is_visible():
return True
except Exception:
pass
trigger = self.page.locator("button[data-melt-collapsible-trigger]")
if await trigger.count() == 0:
return False
await trigger.first.click(timeout=self._ui_timeout)
# Wait for the content to appear
await content.first.wait_for(state="visible", timeout=self._ui_timeout)
return True
except Exception as e:
Logger.warning(f"GLM Chat: failed to expand collapsible section: {e}")
return False
async def _click_glm_model_option(self, data_value: str, friendly_name: str = "") -> bool:
if not self.page:
return False
safe_value = str(data_value or "").strip()
if not safe_value:
return False
if friendly_name in self.MODELS_IN_COLLAPSIBLE:
await self._expand_collapsible_section()
option = self.page.locator(
f"button[aria-label='model-item'][data-value='{safe_value}'], "
f"div[role='menu'] button[data-value='{safe_value}']"
)
if await option.count() == 0:
option = self.page.locator(f"button[data-value='{safe_value}']")
if await option.count() == 0 and friendly_name not in self.MODELS_IN_COLLAPSIBLE:
await self._expand_collapsible_section()
option = self.page.locator(
f"button[aria-label='model-item'][data-value='{safe_value}'], "
f"div[role='menu'] button[data-value='{safe_value}']"
)
if await option.count() == 0:
option = self.page.locator(f"button[data-value='{safe_value}']")
count = await option.count()
if count == 0:
return False
for idx in range(min(count, 10)):
cand = option.nth(idx)
try:
if not await cand.is_visible():
continue
await cand.click(timeout=self._ui_timeout)
return True
except Exception:
continue
try:
await option.first.click(timeout=self._ui_timeout)
return True
except Exception:
return False
async def _click_first_glm_model_option(self) -> Optional[str]:
if not self.page:
return None
options = self.page.locator(self.MODEL_OPTION_SELECTOR)
if await options.count() == 0:
options = self.page.locator("button[data-value]")
count = await options.count()
if count == 0:
return None
for idx in range(min(count, 25)):
cand = options.nth(idx)
try:
if not await cand.is_visible():
continue
except Exception:
pass
try:
data_value = (await cand.get_attribute("data-value")) or ""
except Exception:
data_value = ""
try:
await cand.click(timeout=self._ui_timeout)
except Exception:
continue
return str(data_value or "").strip() or None
return None
async def _ensure_glm_model_selected(self, desired_friendly: str, wait_until_ready: bool = False) -> None:
if not self.page:
return
desired = str(desired_friendly or "").strip()
if not desired:
return
desired_key = next(
(name for name in self.MODEL_DATA_VALUE_BY_FRIENDLY
if self._normalize_model_label(name) == self._normalize_model_label(desired)),
None,
)
desired_data_value = self.MODEL_DATA_VALUE_BY_FRIENDLY.get(desired_key) if desired_key else None
if not desired_data_value:
Logger.warning(f"GLM Chat: unknown configured model '{desired}'.")
return
current_label = await self._read_current_glm_model_label()
if self._normalize_model_label(current_label) == self._normalize_model_label(desired):
return
if wait_until_ready:
if not await self._wait_and_open_glm_model_dropdown(timeout_ms=self.MODEL_SELECTOR_READY_TIMEOUT_MS):
Logger.warning("GLM Chat: model selector did not become openable in time.")
return
else:
if not await self._is_glm_model_selector_ready():
Logger.debug("GLM Chat: model selector is not ready for interaction.")
return
if not await self._open_glm_model_dropdown(timeout_ms=5000):
return
try:
clicked = await self._click_glm_model_option(desired_data_value, friendly_name=desired)
if not clicked:
Logger.error(
f"GLM Chat: supported model '{desired}' was not found in the dropdown "
f"(data-value '{desired_data_value}'). No fallback model will be selected."
)
finally:
await self._close_glm_model_dropdown()
async def _chat_page_contains_sign_in(self) -> bool:
if not self.page:
return False
# Prefer a real element match over brittle body-text scans
# (Body innerText may not be fully populated during loading screens)
try:
sign_in_controls = self.page.locator(
"button:has-text('Sign in'), a:has-text('Sign in'), [role='button']:has-text('Sign in')"
)
count = await sign_in_controls.count()
if count > 0:
# Only treat as auth-needed if at least one is actually visible.
for idx in range(min(count, 10)):
try:
if await sign_in_controls.nth(idx).is_visible():
return True
except Exception:
continue
except Exception:
pass
try:
# js fallback 1
return bool(
await self.page.evaluate(
"() => {"
" const el = document.body;"
" if (!el) return false;"
" const text = (el.innerText || '');"
" return text.includes('Sign in');"
"}"
)
)
except Exception:
return False
async def _wait_for_chat_ready(self, timeout_ms: int | None = None) -> None:
if not self.page:
return
timeout = 0 if timeout_ms is None else int(timeout_ms)
await self.page.wait_for_selector("textarea#chat-input, #chat-input", timeout=timeout, state="visible")
await self._dismiss_dialog_close_buttons()
async def _wait_for_chat_shell_ready(self, timeout_ms: int | None = None) -> None:
"""
Wait for GLM's initial loading screen to resolve into a stable UI.
GLM can show a splash/loading view where auth text isn't present yet.
We treat the page as "ready for auth detection" once either:
- the chat composer appears, or
- a visible Sign in control suddenly appears, confirming all your fears.
"""
if not self.page:
return
shell_selector = (
"textarea#chat-input, #chat-input, "
"button:has-text('Sign in'), a:has-text('Sign in'), [role='button']:has-text('Sign in')"
)
combined_selector = f"{shell_selector}, button[data-dialog-close]"
deadline = None if timeout_ms is None else time.time() + max(0.0, int(timeout_ms) / 1000.0)
while True:
timeout = 0
if deadline is not None:
remaining_ms = int(max(1.0, (deadline - time.time()) * 1000.0))
timeout = remaining_ms
await self.page.wait_for_selector(
combined_selector,
timeout=timeout,
state="visible",
)
await self._dismiss_dialog_close_buttons()
shell_controls = self.page.locator(shell_selector)
count = await shell_controls.count()
for idx in range(min(count, 10)):
try:
if await shell_controls.nth(idx).is_visible():
return
except Exception:
continue
if deadline is not None and time.time() >= deadline:
raise TimeoutError("GLM Chat shell did not become ready before timeout.")
async def login(self) -> None:
"""
GLM Chat does not auto-redirect to the auth page, so we must detect auth state.
"""
if not self.page:
return
try:
await self.page.wait_for_load_state("domcontentloaded")
except Exception:
pass
await self._dismiss_dialog_close_buttons()
# GLM shows an initial loading screen; auth UI is unreliable to detect until the
# app transitions into its stable shell. Wait for composer/sign-in UI before checking auth
try:
await self._wait_for_chat_shell_ready(timeout_ms=60000)
except Exception as e:
# If the composer never appears (UI change / slow load), fall back to best-effort auth detection
Logger.debug(f"GLM Chat: chat composer not detected before auth check: {e}")
await self._dismiss_dialog_close_buttons()
needs_auth = await self._chat_page_contains_sign_in()
if not needs_auth:
Logger.info("GLM Chat: already signed in (or Sign in not detected).")
self._mark_active_ece_pair_used()
return
auto_login = bool(self.config_manager.get_setting("providers_credentials", "auto_login"))
if not auto_login:
Logger.info("GLM Chat: Auto-login disabled. Waiting for manual login...")
await self.page.goto(self.AUTH_URL)
await self._wait_for_chat_ready(timeout_ms=None)
Logger.success("GLM Chat: manual login detected.")
return
email = ""
password = ""
pair = self.ece_active_pair()
if not pair:
Logger.warning(
"GLM Chat: Auto-login is enabled but no accounts are configured in Credential Manager. "
"Waiting for manual login..."
)
await self.page.goto(self.AUTH_URL)
await self._wait_for_chat_ready(timeout_ms=None)
Logger.success("GLM Chat: manual login detected.")
return
email = pair.email
password = pair.password
if not email or not password:
Logger.error("GLM Chat account is missing an email or password.")
return
Logger.info("GLM Chat: Auto-login enabled. Attempting login...")
await self.page.goto(self.AUTH_URL)
try:
await self.page.wait_for_selector(".loginFormUni", timeout=30000)
except Exception as e:
Logger.error(f"GLM Chat: login form not found: {e}")
return
form_root = self.page.locator(".loginFormUni")
if await form_root.count() == 0:
form_root = self.page.locator("body")
# Select the email login button: second <button> under loginFormUni
try:
email_button = form_root.first.locator("button").nth(1)
if await email_button.count() > 0:
await email_button.click()
except Exception as e:
Logger.warning(f"GLM Chat: failed to click email login button: {e}")
# Fill credentials
try:
email_input = self.page.locator("input[type='email']")
password_input = self.page.locator("input[type='password']")
await email_input.first.fill(str(email))
await password_input.first.fill(str(password))
except Exception as e:
Logger.error(f"GLM Chat: failed to fill credentials: {e}")
return
# Submit
try:
submit_button = self.page.locator("button[type='submit']")
await submit_button.first.click()
except Exception as e:
Logger.error(f"GLM Chat: failed to click submit: {e}")
return
# CAPTCHA is required and must be solved manually.
self.notify_user(
"GLM Chat Login",
"Please solve the CAPTCHA in the GLM Chat browser window, then click Sign in.",
level="warning",
)
Logger.warning("GLM Chat: waiting for CAPTCHA / manual confirmation...")
await self._wait_for_chat_ready(timeout_ms=None)
Logger.success("GLM Chat: chat ready.")
self.ece_mark_used(email)
def _resolve_deepthink_flags(self, model: str) -> tuple[bool, bool]:
enable_deepthink = bool(self.config_manager.get_setting("glm_behavior", "enable_deepthink"))
send_deepthink = bool(self.config_manager.get_setting("glm_behavior", "send_deepthink"))
mode = resolve_behavior_mode(
model,
self.provider,
real_model_labels=self.api_real_model_labels(),
)
if mode == MODE_CHAT:
return False, False
if mode == MODE_REASONER:
return True, send_deepthink
return enable_deepthink, send_deepthink
def _glm_tools_supported_for_model(self, model_friendly: str) -> bool:
return self._normalize_model_label(model_friendly) == self._normalize_model_label(
self.TOOLS_SUPPORTED_MODEL_FRIENDLY
)
def _resolve_glm_deepthink_effort(
self,
ui_model_label: str,
*,
deepthink_enabled: bool,
overrides: Optional[Dict[str, Any]] = None,
) -> str:
if not (deepthink_enabled and self._glm_uses_deepthink_effort_controls(ui_model_label)):
return ""
override = (overrides or {}).get("deepthink_effort")
value = override if override is not None else self._get_configured_glm_deepthink_effort()
return self._normalize_glm_deepthink_effort(value)
def _resolve_glm_request_settings(self, model: str, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
resolved_model = (model or "").strip() or "GLM-5.2"
ui_model_label = self._get_glm_model_label_for_request(resolved_model)
deepthink_enabled, send_deepthink = self._resolve_deepthink_flags(resolved_model)
deepthink_effort = self._resolve_glm_deepthink_effort(
ui_model_label,
deepthink_enabled=deepthink_enabled,
overrides=overrides,
)
enable_search = bool(self.config_manager.get_setting("glm_behavior", "enable_search"))
enable_advanced_search = bool(
self.config_manager.get_setting("glm_behavior", "enable_advanced_search")
)
enable_tools = bool(self.config_manager.get_setting("glm_behavior", "enable_tools"))
send_as_text_file = bool(self.config_manager.get_setting("glm_behavior", "send_as_text_file"))
tools_supported = self._glm_tools_supported_for_model(ui_model_label)
settings = {
"model_label": ui_model_label,
"deepthink_enabled": bool(deepthink_enabled),
"deepthink_effort": deepthink_effort,
"send_deepthink": bool(send_deepthink),
"search_enabled": bool(enable_search),
"advanced_search_enabled": bool(enable_advanced_search),
"tools_enabled": bool(enable_tools),
"send_as_text_file": bool(send_as_text_file),
}
if overrides:
for key in (
"deepthink_enabled",
"send_deepthink",
"deepthink_effort",
"search_enabled",
"advanced_search_enabled",
"tools_enabled",
"send_as_text_file",
):
if key == "deepthink_effort":
if key in overrides:
settings[key] = self._resolve_glm_deepthink_effort(
ui_model_label,
deepthink_enabled=bool(settings["deepthink_enabled"]),
overrides=overrides,
)
continue
if key in overrides:
settings[key] = bool(overrides[key])
if not tools_supported:
settings["tools_enabled"] = False
if settings["deepthink_enabled"] and self._glm_uses_deepthink_effort_controls(ui_model_label):
if not settings["deepthink_effort"]:
settings["deepthink_effort"] = self._get_configured_glm_deepthink_effort()
else:
settings["deepthink_effort"] = ""
if settings["advanced_search_enabled"] and not (
settings["deepthink_enabled"] and settings["search_enabled"]
):
Logger.warning(
"GLM Chat: Advanced Search requires Deep Think and Search. "
"Ignoring Advanced Search for this request."
)
settings["advanced_search_enabled"] = False
return settings
def _extract_glm_macros_from_text(self, text: str) -> tuple[str, Dict[str, bool]]:
return extract_macro_overrides(text, macro_actions=GLM_REQUEST_MACRO_ACTIONS)
def _strip_glm_macros_from_messages(self, messages: List[Any]) -> tuple[List[Any], Dict[str, bool]]:
return strip_macros_from_messages(messages, macro_actions=GLM_REQUEST_MACRO_ACTIONS)
def _read_clean_regeneration_state(self) -> Optional[Dict[str, Any]]:
raw = self.cache_manager.read_cache(self.clean_regen_state_cache_key)
if raw is None:
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
Logger.warning("Clean Regeneration (GLM): Cached state is invalid JSON, ignoring.")
return None
return self._normalize_clean_regeneration_state(data)
def _write_clean_regeneration_state(self, state: Dict[str, Any]) -> None:
payload = self._normalize_clean_regeneration_state(state)
if payload is None:
return
self.cache_manager.write_cache(
self.clean_regen_state_cache_key,
json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True),
)
def _normalize_clean_regeneration_state(self, state: Any) -> Optional[Dict[str, Any]]:
if not isinstance(state, dict):
return None
optional_keys = {"advanced_search_enabled", "deepthink_effort"}
required_keys = [key for key in self.CLEAN_REGEN_STATE_KEYS if key not in optional_keys]
if not all(key in state for key in required_keys):
return None
ui_model = str(state.get("ui_model") or "").strip()
deepthink_enabled = bool(state.get("deepthink_enabled"))
deepthink_effort = ""
if deepthink_enabled and self._glm_uses_deepthink_effort_controls(ui_model):
deepthink_effort = self._normalize_glm_deepthink_effort(
state.get("deepthink_effort"),
default=self.DEFAULT_GLM_52_DEEPTHINK_EFFORT,
)
return {
"deepthink_enabled": deepthink_enabled,
"deepthink_effort": deepthink_effort,
"search_enabled": bool(state.get("search_enabled")),
"advanced_search_enabled": bool(state.get("advanced_search_enabled", False)),
"tools_enabled": bool(state.get("tools_enabled")),
"send_as_text_file": bool(state.get("send_as_text_file")),
"ui_model": ui_model,
}
def _build_multi_slot_cache_state(
self,
*,
effective_deepthink: bool,
deepthink_effort: str = "",
enable_search: bool,
enable_advanced_search: bool,
enable_tools: bool,
send_as_text_file: bool,
ui_model_label: str | None = None,
) -> Dict[str, Any]:
normalized_ui_model = str(ui_model_label or self._get_glm_model_label_for_request(self.current_model))
normalized_effort = ""
if effective_deepthink and self._glm_uses_deepthink_effort_controls(normalized_ui_model):
normalized_effort = self._normalize_glm_deepthink_effort(deepthink_effort)
return {
"deepthink_enabled": bool(effective_deepthink),
"deepthink_effort": normalized_effort,
"search_enabled": bool(enable_search),
"advanced_search_enabled": bool(enable_advanced_search),
"tools_enabled": bool(enable_tools),
"send_as_text_file": bool(send_as_text_file),
"ui_model": normalized_ui_model,
}
async def _prepare_new_chat_request_ui(
self,
*,
effective_deepthink: bool,
deepthink_effort: str = "",
enable_search: bool,
enable_advanced_search: bool,
enable_tools: bool,
ui_model_label: str | None = None,
log_label: str = "GLM Chat: preparing new chat session...",
) -> None:
Logger.info(log_label)
await self._dismiss_dialog_close_buttons()
await self.click_new_chat(source="auto")
await asyncio.sleep(self._post_delay_s)
await self.apply_configured_model(model=self.current_model, wait_until_ready=True)
await self.set_tools_state(bool(enable_tools), model_label=ui_model_label)
await self.set_deepthink_state(
bool(effective_deepthink),
effort=deepthink_effort,
model_label=ui_model_label,
)
await self.set_search_state(bool(enable_search))
await self.set_advanced_search_state(bool(enable_advanced_search))
await asyncio.sleep(self._post_delay_s)
async def _send_text_request(
self,
message: str,
*,
send_timeout: int | None,
arm_event: asyncio.Event | None = None,
log_label: str = "GLM Chat: sending request...",
) -> None:
await self._enter_message(message)
await asyncio.sleep(self._post_delay_s)
Logger.info(log_label)
await self._send_message(timeout=send_timeout, arm_event=arm_event)
async def _send_text_request_with_capacity_guard(
self,
message: str,
*,
send_timeout: int | None,
log_label: str,
) -> str | None:
if not self.page:
await self._send_text_request(
message,
send_timeout=send_timeout,
log_label=log_label,
)
return None
def response_matches(response: Any) -> bool:
try:
url = str(response.url or "")
except Exception:
url = ""
if "/api/v2/chat/completions" not in url:
return False
try:
method = str(response.request.method or "").upper()
except Exception:
method = ""
return method == "POST"
try:
completion_timeout_ms = int(self._completion_request_timeout_s * 1000)
async with self.page.expect_response(
response_matches,
timeout=completion_timeout_ms,
) as response_info:
await self._send_text_request(
message,
send_timeout=send_timeout,
log_label=log_label,
)
response = await response_info.value
except Exception as e:
Logger.debug(f"GLM Chat: no completion response observed during guarded send: {e}")
return None
try:
await asyncio.wait_for(response.finished(), timeout=3.0)
except asyncio.TimeoutError:
return None
except Exception as e:
Logger.debug(f"GLM Chat: failed waiting for guarded completion response to finish: {e}")
return None
try:
response_text = await asyncio.wait_for(response.text(), timeout=1.0)
except asyncio.TimeoutError:
return None
except Exception as e:
Logger.debug(f"GLM Chat: failed reading guarded completion response body: {e}")
return None
capacity_error_message = self._extract_model_capacity_error_from_text(response_text)
if not capacity_error_message:
if self._glm_frontend_would_see_sse_data_event(response_text):
return None
empty_stream_message = self._build_empty_completion_stream_error_message()
Logger.warning(empty_stream_message)
await self._reload_chat_page(
f"empty completion stream (GLM Error code: {self.EMPTY_COMPLETION_STREAM_ERROR_CODE})"
)
return empty_stream_message
Logger.warning(capacity_error_message)
await self._refresh_page_after_capacity_error()
return capacity_error_message
async def _run_repetition_buster(
self,
*,
effective_deepthink: bool,
deepthink_effort: str = "",
enable_search: bool,
enable_advanced_search: bool,
enable_tools: bool,
ui_model_label: str | None = None,
auto_delete_after_send: bool = False,
) -> str | None:
Logger.info(
"Repetition Buster (GLM): duplicate prompt detected. Sending a 128-character "
"cache-buster prompt in a throwaway chat first..."
)
await self._prepare_new_chat_request_ui(
effective_deepthink=effective_deepthink,
deepthink_effort=deepthink_effort,
enable_search=enable_search,
enable_advanced_search=enable_advanced_search,
enable_tools=enable_tools,
ui_model_label=ui_model_label,
log_label="Repetition Buster (GLM): opening throwaway chat...",
)
capacity_error_message = await self._send_text_request_with_capacity_guard(
self._generate_repetition_buster_text(128),
send_timeout=self._msg_send_timeout,
log_label="Repetition Buster (GLM): sending cache-buster prompt...",
)
if capacity_error_message:
return capacity_error_message
await asyncio.sleep(self._post_delay_s)
if auto_delete_after_send:
await self._auto_delete_current_chat(log_context="Repetition Buster (GLM)")
return None
def _parse_conversation_info_from_url(self, url: str) -> Optional[Dict[str, str]]:
normalized_url = str(url or "").strip()
if not normalized_url:
return None
match = self.CONVERSATION_URL_RE.match(normalized_url)
if not match:
return None
conversation_id = str(match.group(1) or "").strip()
if not conversation_id:
return None
return {
"conversation_id": conversation_id,
"conversation_url": f"https://chat.z.ai/c/{conversation_id}",
}
async def _get_current_conversation_info(self) -> Optional[Dict[str, str]]:
if not self.page:
return None
try:
current_url = str(self.page.url or "")
except Exception:
current_url = ""
return self._parse_conversation_info_from_url(current_url)
async def _wait_for_current_conversation_info(
self,
timeout_ms: int = 6000,
poll_interval_s: float = 0.2,
) -> Optional[Dict[str, str]]:
deadline = time.time() + max(0.0, float(timeout_ms) / 1000.0)
while True:
info = await self._get_current_conversation_info()
if info is not None:
return info
if time.time() >= deadline:
return None
await asyncio.sleep(max(0.05, float(poll_interval_s)))
async def _delete_conversation_by_id(self, conversation_id: str) -> bool:
normalized_id = str(conversation_id or "").strip()
if not normalized_id:
return False
result = await self._run_browser_request(
method="DELETE",
url=f"https://chat.z.ai/api/v1/chats/{normalized_id}",
referrer="https://chat.z.ai/",
)
if bool(result.get("ok")):
return True
detail = str(result.get("error") or result.get("text") or "").strip()
status = int(result.get("status") or 0)
suffix = f" ({detail[:180]})" if detail else ""
Logger.warning(
f"GLM Chat: failed to auto-delete chat {normalized_id} (status={status}){suffix}"
)
return False
async def _auto_delete_current_chat(self, *, log_context: str = "GLM Chat") -> bool:
current_info = await self._get_current_conversation_info()
if current_info is None:
Logger.debug(f"{log_context}: auto-delete skipped because the current chat ID was not available.")
return False
conversation_id = str(current_info.get("conversation_id") or "").strip()
if not conversation_id:
Logger.debug(f"{log_context}: auto-delete skipped because the current chat ID was empty.")
return False
try:
await self.click_new_chat(source="auto")
await asyncio.sleep(self._post_delay_s)
except Exception as e:
Logger.warning(
f"{log_context}: auto-delete skipped because a replacement chat could not be prepared: {e}"
)
return False
if await self._delete_conversation_by_id(conversation_id):
Logger.info(f"{log_context}: auto-deleted the completed chat.")
return True
return False
async def _open_cached_conversation(self, conversation_url: str) -> bool:
if not self.page:
return False
target_url = str(conversation_url or "").strip()
if not target_url:
return False
try:
await self.page.goto(target_url, wait_until="domcontentloaded", timeout=45000)
except Exception as e:
Logger.warning(f"Multi-Slot Cache (GLM): failed to open cached chat URL: {e}")
return False
try:
await self._wait_for_chat_shell_ready(timeout_ms=60000)
except Exception as e:
Logger.warning(f"Multi-Slot Cache (GLM): chat shell did not become ready: {e}")
return False
try:
if await self._chat_page_contains_sign_in():
Logger.warning("Multi-Slot Cache (GLM): cached chat URL requires sign-in.")
return False
except Exception:
pass
return True
async def _try_multi_slot_regeneration(
self,
*,
formatted_message: str,
multi_slot_state: Dict[str, Any],
completion_armed: asyncio.Event,
completion_started: asyncio.Event,
) -> bool:
account_key = self._get_multi_slot_cache_account_key()
payload = read_multi_slot_cache_payload(
self.cache_manager,
self.multi_slot_cache_key,
log_label="Multi-Slot Cache (GLM)",
)
entry = find_multi_slot_cache_entry(payload, account_key, formatted_message, multi_slot_state)
if entry is None:
return False
current_info = await self._get_current_conversation_info()
if current_info is None or current_info["conversation_id"] != entry["conversation_id"]:
Logger.info("Multi-Slot Cache (GLM): opening cached conversation for regeneration...")
opened = await self._open_cached_conversation(entry["conversation_url"])
if not opened:
return False
current_info = await self._get_current_conversation_info()
if current_info is None or current_info["conversation_id"] != entry["conversation_id"]:
Logger.warning(
"Multi-Slot Cache (GLM): cached conversation URL opened, but the expected "
"chat ID was not available. Falling back to a new chat."
)
return False
try:
await self.set_tools_state(
bool(multi_slot_state.get("tools_enabled")),
model_label=str(multi_slot_state.get("ui_model") or ""),
)
await self.set_deepthink_state(
bool(multi_slot_state.get("deepthink_enabled")),
effort=str(multi_slot_state.get("deepthink_effort") or ""),
model_label=str(multi_slot_state.get("ui_model") or ""),
)
await self.set_search_state(bool(multi_slot_state.get("search_enabled")))
await self.set_advanced_search_state(bool(multi_slot_state.get("advanced_search_enabled")))
await asyncio.sleep(self._post_delay_s)
except Exception:
pass
regen_timeout = max(int(getattr(self, "_ui_timeout", 3000)), 5000)
Logger.info("Multi-Slot Cache (GLM): cached prompt match found. Attempting to regenerate...")
if not await self._click_regenerate(timeout_ms=regen_timeout, arm_event=completion_armed):
Logger.warning(
"Multi-Slot Cache (GLM): regenerate button unavailable. Removing cached entry."
)
remove_multi_slot_cache_entry(
self.cache_manager,
self.multi_slot_cache_key,
account_key,
entry["conversation_id"],
log_label="Multi-Slot Cache (GLM)",
)
return False
try:
await asyncio.wait_for(
completion_started.wait(),
timeout=self._completion_request_timeout_s,
)
except asyncio.TimeoutError:
Logger.warning(
"Multi-Slot Cache (GLM): completion request not observed after clicking "
"Regenerate. Removing cached entry."
)
remove_multi_slot_cache_entry(
self.cache_manager,
self.multi_slot_cache_key,
account_key,
entry["conversation_id"],
log_label="Multi-Slot Cache (GLM)",
)
return False
return True
def _format_messages(self, messages: Union[str, List[Any]]) -> str:
return format_request_messages(self.config_manager, messages)
async def set_sidebar_status(self, open: bool) -> None:
if not self.page:
return
sidebar = self.page.locator("#sidebar")
if await sidebar.count() == 0:
Logger.warning("GLM Chat: sidebar container not found.")
return
state = (await sidebar.first.get_attribute("data-state")) or ""
is_open = state.strip().lower() == "true"
if is_open == open:
return
if open:
toggle = self.page.locator("button#sidebar-toggle-button")
else:
toggle = (
sidebar.first.locator("div").nth(0).locator("div").nth(0).locator("button").nth(0)
)
if await toggle.count() == 0:
Logger.warning("GLM Chat: sidebar toggle button not found.")
return
try:
await toggle.first.click(timeout=self._ui_timeout)
except Exception as e:
Logger.warning(f"GLM Chat: failed to click sidebar toggle: {e}")
return
# Wait for state to flip
deadline = time.time() + 5.0
while time.time() < deadline:
try:
state_now = (await sidebar.first.get_attribute("data-state")) or ""
open_now = state_now.strip().lower() == "true"
if open_now == open:
break
except Exception:
pass
await asyncio.sleep(0.1)
async def click_new_chat(self, source: str = "auto") -> None:
if not self.page:
return
await self._dismiss_dialog_close_buttons()
sidebar = self.page.locator("#sidebar")
is_open = False
try:
state = (await sidebar.first.get_attribute("data-state")) or ""
is_open = state.strip().lower() == "true"
except Exception:
is_open = False
if source == "sidebar":
is_open = True
elif source == "simple":
is_open = False
elif source != "auto":
Logger.warning(f"GLM Chat: unknown new chat source '{source}'.")
selectors = (
[
self.SIDEBAR_NEW_CHAT_BUTTON_SELECTOR,
self.QUICK_NEW_CHAT_BUTTON_SELECTOR,
self.LEGACY_QUICK_NEW_CHAT_BUTTON_SELECTOR,
]
if is_open
else [
self.QUICK_NEW_CHAT_BUTTON_SELECTOR,
self.LEGACY_QUICK_NEW_CHAT_BUTTON_SELECTOR,
self.SIDEBAR_NEW_CHAT_BUTTON_SELECTOR,
]
)
btn = None
for selector in selectors:
locator = self.page.locator(selector)
count = await locator.count()
for idx in range(min(count, 5)):
candidate = locator.nth(idx)
try:
if await candidate.is_visible():
btn = candidate
break
except Exception:
continue
if btn is not None:
break
if btn is None:
Logger.warning("GLM Chat: New Chat button not found.")
return
try:
await btn.click(timeout=self._ui_timeout)
except Exception as e:
Logger.warning(f"GLM Chat: failed to click New Chat: {e}")
async def _find_composer_toggle_button(self, label_prefix: str, *, state_attr: str | None = None):
"""Find a visible composer toggle by its aria-label wrapper.
GLM's footer toggles now live inside wrappers like
``div[aria-label="Web search enabled"]``. Some models also insert a
separate Tools button between Search and Deep Think, so position-based
lookup is no longer reliable.
"""
if not self.page:
return None
try:
selector = f"div[aria-label^='{label_prefix}'] button"
if state_attr:
selector = f"{selector}[{state_attr}]"
candidates = self.page.locator(selector)
count = await candidates.count()
except Exception:
return None
for idx in range(min(count, 25)):
cand = candidates.nth(idx)
try:
if await cand.is_visible():
return cand
except Exception:
pass
return candidates.first if count > 0 else None
async def _find_compact_search_button(self):
"""Find Search in GLM's compact non-Tools composer layout.
Non-5V models no longer expose a Web Search aria-label wrapper. In that
layout, Search is the unlabeled globe button immediately before the Deep
Think toggle, while GLM-5V still has a separate Tools button in between.
GLM-5.2 can replace the old Deep Think wrapper with an effort menu, so
keep a composer-local ``button[data-active]`` fallback for that layout.
"""
if not self.page:
return None
try:
handle = await self.page.evaluate_handle(
"""() => {
const isVisible = (element) => {
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
rect.width > 0 &&
rect.height > 0
);
};
const skipSibling = (element) => (
element?.classList?.contains('flagsContainer') ||
element?.getAttribute?.('aria-hidden') === 'true'
);
const deepThinkWrappers = Array.from(
document.querySelectorAll('div[aria-label^="Deep think"]')
);
for (const wrapper of deepThinkWrappers) {
const deepThinkButton = wrapper.querySelector('button[data-autothink]');
if (!isVisible(deepThinkButton)) continue;
let searchRoot = wrapper.previousElementSibling;
while (searchRoot && skipSibling(searchRoot)) {
searchRoot = searchRoot.previousElementSibling;
}
if (!searchRoot) continue;
const candidates = [];
if (searchRoot.matches?.('button')) {
candidates.push(searchRoot);
}
candidates.push(...searchRoot.querySelectorAll('button'));
for (const button of candidates.reverse()) {
if (
button.id === 'upload-file-button' ||
button.id === 'send-message-button' ||
button.hasAttribute('data-autothink') ||
button.closest('div[aria-label^="Deep think"]')
) {
continue;
}
if (isVisible(button)) {
return button;
}
}
}
return null;
}"""
)
button = handle.as_element()
if button:
return button
except Exception as e:
Logger.debug(f"GLM Chat: compact Search button lookup failed: {e}")
try:
handle = await self.page.evaluate_handle(
"""() => {
const isVisible = (element) => {
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
rect.width > 0 &&
rect.height > 0
);
};
const isExcludedButton = (button) => (
button.id === 'upload-file-button' ||
button.id === 'send-message-button' ||
button.hasAttribute('data-autothink') ||
button.closest('div[aria-label^="Deep think"]') ||
button.closest('[aria-label="Send Message"]') ||
button.closest('[aria-label^="Up to "]')
);
const inputs = Array.from(
document.querySelectorAll('textarea#chat-input, #chat-input, textarea')
).filter(isVisible);
for (const input of inputs) {
const roots = [];
const addRoot = (node) => {
if (node && !roots.includes(node)) {
roots.push(node);
}
};
addRoot(input.closest('form'));
let parent = input.parentElement;
for (let depth = 0; parent && depth < 5; depth += 1) {
addRoot(parent);
parent = parent.parentElement;
}
const inputRect = input.getBoundingClientRect();
for (const root of roots) {
const candidates = Array.from(root.querySelectorAll('button[data-active]'))
.filter((button) => {
if (!isVisible(button) || isExcludedButton(button)) {
return false;
}
const rect = button.getBoundingClientRect();
return rect.top >= inputRect.bottom - 16;
})
.sort((left, right) => {
const leftRect = left.getBoundingClientRect();
const rightRect = right.getBoundingClientRect();
return leftRect.left - rightRect.left;
});
if (candidates.length > 0) {
return candidates[0];
}
}
}
return null;
}"""
)
button = handle.as_element()
if button:
return button
except Exception as e:
Logger.debug(f"GLM Chat: composer Search data-active lookup failed: {e}")
return None
async def _read_composer_toggle_enabled(
self,
button: Any,
*,
state_attr: str,
fallback_state_attrs: tuple[str, ...] = (),
enabled_label_prefix: str,
enabled_class_markers: tuple[str, ...] = (),
) -> bool:
"""Read a composer toggle's enabled state across old and compact DOMs."""
state_attrs = tuple(dict.fromkeys((state_attr, *fallback_state_attrs)))
try:
return bool(
await button.evaluate(
"""(button, options) => {
const nodes = [];
const addNode = (node) => {
if (node && !nodes.includes(node)) {
nodes.push(node);
}
};
addNode(button);
addNode(button.closest?.('button'));
addNode(button.closest?.('[aria-label]'));
let parent = button.parentElement;
for (let depth = 0; parent && depth < 3; depth += 1) {
addNode(parent);
parent = parent.parentElement;
}
for (const node of nodes) {
for (const attr of options.stateAttrs || []) {
if (node.hasAttribute?.(attr)) {
return String(node.getAttribute(attr) || '')
.trim()
.toLowerCase() === 'true';
}
}
}
const enabledPrefix = String(options.enabledLabelPrefix || '')
.trim()
.toLowerCase();
if (enabledPrefix) {
for (const node of nodes) {
const label = String(node.getAttribute?.('aria-label') || '')
.trim()
.toLowerCase();
if (label.startsWith(enabledPrefix)) {
return true;
}
}
}
const classText = nodes
.map((node) => {
if (typeof node.className === 'string') {
return node.className;
}
if (node.className?.baseVal) {
return node.className.baseVal;
}
return '';
})
.join(' ');
return (options.enabledClassMarkers || []).some(
(marker) => classText.includes(marker)
);
}""",
{
"stateAttrs": list(state_attrs),
"enabledLabelPrefix": enabled_label_prefix,
"enabledClassMarkers": list(enabled_class_markers),
},
)
)
except Exception:
pass
for attr_name in state_attrs:
try:
attr = await button.get_attribute(attr_name)
if attr is not None:
return str(attr or "").strip().lower() == "true"
except Exception:
continue
return False
async def _find_deepthink_button(self):
"""Find the Deep Think button by its aria-label wrapper."""
return await self._find_composer_toggle_button("Deep think", state_attr="data-autothink")
async def _find_glm_52_deepthink_trigger(self):
"""Find GLM-5.2's combined Deep Think effort menu trigger."""
if not self.page:
return None
try:
candidates = self.page.locator(
"div[aria-expanded][data-state][type='button']"
).filter(has_text="Deep Think")
count = await candidates.count()
except Exception:
return None
for idx in range(min(count, 10)):
cand = candidates.nth(idx)
try:
if await cand.is_visible():
return cand
except Exception:
pass
return candidates.first if count > 0 else None
async def _read_glm_52_deepthink_trigger_state(self, trigger: Any | None = None) -> dict[str, Any]:
button = trigger or await self._find_glm_52_deepthink_trigger()
if not button:
return {"exists": False, "enabled": False, "effort": ""}
try:
text = await button.inner_text()
except Exception:
text = ""
normalized = self._normalize_model_label(text)
enabled = "off" not in normalized
effort = ""
if enabled:
if "high" in normalized:
effort = "high"
elif "max" in normalized:
effort = "max"
return {
"exists": True,
"enabled": enabled,
"effort": effort,
"text": str(text or "").strip(),
}
async def _open_glm_52_deepthink_menu(self) -> bool:
if not self.page:
return False
trigger = await self._find_glm_52_deepthink_trigger()
if not trigger:
Logger.warning("GLM Chat: GLM-5.2 Deep Think effort menu not found.")
return False
try:
expanded = str((await trigger.get_attribute("aria-expanded")) or "").strip().lower()
except Exception:
expanded = ""
if expanded != "true":
await self._ensure_glm_pointer_events_ready(
context="GLM Chat: before opening GLM-5.2 Deep Think menu",
timeout_ms=min(self._ui_timeout, 1000),
)
try:
await trigger.click(timeout=self._ui_timeout)
except Exception as e:
Logger.warning(f"GLM Chat: failed to open GLM-5.2 Deep Think menu: {e}")
return False
try:
await self.page.wait_for_selector(
"div[role='menu'][data-state='open'] button[role='switch'][aria-checked]",
timeout=self._ui_timeout,
state="visible",
)
return True
except Exception:
Logger.warning("GLM Chat: GLM-5.2 Deep Think menu did not appear.")
return False
async def _close_glm_52_deepthink_menu(self) -> None:
if not self.page:
return
try:
await self.page.keyboard.press("Escape")
except Exception:
pass
if await self._ensure_glm_pointer_events_ready(
context="GLM Chat: closing GLM-5.2 Deep Think menu",
timeout_ms=self._ui_timeout,
):
return
trigger = await self._find_glm_52_deepthink_trigger()
if not trigger:
return
try:
await trigger.evaluate(
"""(element) => {
if (!element) return;
if (String(element.getAttribute('aria-expanded') || '').toLowerCase() === 'true') {
element.click();
}
}"""
)
except Exception as e:
Logger.debug(f"GLM Chat: DOM close for GLM-5.2 Deep Think menu failed: {e}")
await self._ensure_glm_pointer_events_ready(
context="GLM Chat: after DOM-closing GLM-5.2 Deep Think menu",
timeout_ms=self._ui_timeout,
)
async def _read_glm_52_deepthink_switch_enabled(self) -> bool:
if not self.page:
return False
switch = self.page.locator(
"div[role='menu'][data-state='open'] button[role='switch'][aria-checked]"
)
if await switch.count() == 0:
return False
try:
checked = await switch.first.get_attribute("aria-checked")
return str(checked or "").strip().lower() == "true"
except Exception:
return False
async def _click_glm_52_deepthink_switch(self) -> bool:
if not self.page:
return False
switch = self.page.locator(
"div[role='menu'][data-state='open'] button[role='switch'][aria-checked]"
)
if await switch.count() == 0:
return False
try:
await switch.first.click(timeout=self._ui_timeout)
return True
except Exception as e:
Logger.warning(f"GLM Chat: failed to toggle GLM-5.2 Deep Think switch: {e}")
return False
async def _select_glm_52_deepthink_effort(self, effort: str) -> bool:
if not self.page:
return False
desired = self._normalize_glm_deepthink_effort(effort)
label = "Max" if desired == "max" else "High"
menu = self.page.locator("div[role='menu'][data-state='open']")
option = menu.locator("button[type='button'][data-selected]").filter(has_text=label)
count = await option.count()
if count == 0:
Logger.warning(f"GLM Chat: GLM-5.2 Deep Think effort option '{label}' not found.")
return False
for idx in range(min(count, 5)):
cand = option.nth(idx)
try:
if await cand.is_visible():
selected = str((await cand.get_attribute("data-selected")) or "").strip().lower()
if selected == "true":
return True
await cand.click(timeout=self._ui_timeout)
await asyncio.sleep(0.1)
return True
except Exception:
continue
return False
async def _set_glm_52_deepthink_state(self, state: bool, effort: str | None = None) -> None:
desired_enabled = bool(state)
desired_effort = self._normalize_glm_deepthink_effort(
effort or self._get_configured_glm_deepthink_effort()
)
trigger = await self._find_glm_52_deepthink_trigger()
current = await self._read_glm_52_deepthink_trigger_state(trigger)
if not current.get("exists"):
Logger.warning("GLM Chat: GLM-5.2 Deep Think control not found.")
return
if not desired_enabled:
if not current.get("enabled"):
return
elif current.get("enabled") and current.get("effort") == desired_effort:
return
if not await self._open_glm_52_deepthink_menu():
return
try:
if desired_enabled:
if current.get("effort") != desired_effort:
await self._select_glm_52_deepthink_effort(desired_effort)
if not await self._open_glm_52_deepthink_menu():
return
if not await self._read_glm_52_deepthink_switch_enabled():
await self._click_glm_52_deepthink_switch()
else:
if await self._read_glm_52_deepthink_switch_enabled():
await self._click_glm_52_deepthink_switch()
finally:
await self._close_glm_52_deepthink_menu()
async def _find_search_button(self):
"""Find the Web Search button in the active GLM composer layout."""
if self._glm_tools_supported_for_model(self._get_configured_glm_model_friendly()):
button = await self._find_composer_toggle_button("Web search", state_attr="data-active")
if button:
return button
button = await self._find_composer_toggle_button("Web search", state_attr="data-selected")
if button:
return button
return await self._find_compact_search_button()
async def _find_tools_button(self):
"""Find the Tools button by its aria-label wrapper."""
return await self._find_composer_toggle_button("Tools", state_attr="data-selected")
async def set_deepthink_state(
self,
state: bool,
*,
effort: str | None = None,
model_label: str | None = None,
) -> None:
if not self.page:
return
await self._dismiss_dialog_close_buttons()
await self._close_glm_model_dropdown()
effective_model_label = str(model_label or "").strip()
if not effective_model_label:
effective_model_label = await self._read_current_glm_model_label()
if not effective_model_label:
effective_model_label = self._get_configured_glm_model_friendly()
if self._glm_uses_deepthink_effort_controls(effective_model_label):
await self._set_glm_52_deepthink_state(state, effort=effort)
return
button = await self._find_deepthink_button()
if not button:
Logger.warning("GLM Chat: Deep Think button not found.")
return
try:
attr = await button.get_attribute("data-autothink")
is_enabled = str(attr or "").strip().lower() == "true"
except Exception:
is_enabled = False
if is_enabled == state:
return
if not await self._click_glm_control(button, label="Deep Think"):
Logger.warning("GLM Chat: failed to toggle Deep Think.")
async def set_search_state(self, state: bool) -> None:
if not self.page:
return
await self._dismiss_dialog_close_buttons()
await self._close_glm_model_dropdown()
button = await self._find_search_button()
if not button:
Logger.warning("GLM Chat: Search button not found.")
return
is_enabled = await self._read_composer_toggle_enabled(
button,
state_attr="data-active",
fallback_state_attrs=("data-selected",),
enabled_label_prefix="web search enabled",
enabled_class_markers=("text-[#0881F0]", "bg-[#F0F7FE]"),
)
if is_enabled == state:
return
if not await self._click_glm_control(button, label="Search"):
Logger.warning("GLM Chat: failed to toggle Search.")
async def _find_advanced_search_switch(self, search_button: Any | None = None):
"""Find the Advanced Search switch that appears while Search is hovered."""
if not self.page:
return None
await self._close_glm_model_dropdown()
await self._ensure_glm_pointer_events_ready(
context="GLM Chat: before hovering Search for Advanced Search",
timeout_ms=min(self._ui_timeout, 1000),
)
button = search_button or await self._find_search_button()
if not button:
return None
try:
await button.hover(timeout=self._ui_timeout)
except Exception as e:
Logger.debug(f"GLM Chat: failed to hover Search button for Advanced Search: {e}")
return None
for _attempt in range(4):
try:
handle = await self.page.evaluate_handle(
"""() => {
const requiredClasses = [
'font-medium',
'leading-6',
'flex',
'justify-between',
'items-center',
];
const isVisible = (element) => {
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
rect.width > 0 &&
rect.height > 0
);
};
const normalize = (value) => String(value || '')
.replace(/\\s+/g, ' ')
.trim()
.toLowerCase();
const rows = Array.from(
document.querySelectorAll(
'div.font-medium.leading-6.flex.justify-between.items-center'
)
);
for (const row of rows) {
if (!requiredClasses.every((cls) => row.classList.contains(cls))) {
continue;
}
if (!isVisible(row)) {
continue;
}
const span = Array.from(row.children).find(
(child) => child.tagName === 'SPAN'
) || row.querySelector('span');
if (!span || normalize(span.textContent) !== 'advanced search') {
continue;
}
const switchButton = Array.from(row.children).find(
(child) => child.tagName === 'BUTTON'
) || row.querySelector('button[aria-checked]');
if (isVisible(switchButton)) {
return switchButton;
}
}
const findSwitchNear = (label) => {
let node = label;
for (let depth = 0; node && depth < 6; depth += 1) {
if (node.matches?.('button[aria-checked]') && isVisible(node)) {
return node;
}
const switchButton = Array.from(
node.querySelectorAll?.('button[aria-checked]') || []
).find(isVisible);
if (switchButton) {
return switchButton;
}
node = node.parentElement;
}
return null;
};
const labels = Array.from(
document.querySelectorAll('span, div, p, button')
).filter((node) => (
isVisible(node) &&
normalize(node.textContent) === 'advanced search'
));
for (const label of labels) {
const switchButton = findSwitchNear(label);
if (switchButton) {
return switchButton;
}
}
return null;
}"""
)
switch_button = handle.as_element()
if switch_button:
return switch_button
except Exception as e:
Logger.debug(f"GLM Chat: Advanced Search switch lookup failed: {e}")
await asyncio.sleep(0.15)
return None
async def set_advanced_search_state(self, state: bool) -> None:
if not self.page:
return
await self._dismiss_dialog_close_buttons()
switch_button = await self._find_advanced_search_switch()
if not switch_button:
if state:
Logger.warning("GLM Chat: Advanced Search switch not found.")
return
try:
attr = await switch_button.get_attribute("aria-checked")
is_enabled = str(attr or "").strip().lower() == "true"
except Exception:
is_enabled = False
if is_enabled == state:
return
try:
await switch_button.evaluate("(button) => button.click()")
except Exception as e:
Logger.warning(f"GLM Chat: failed to toggle Advanced Search: {e}")
return
try:
await asyncio.sleep(0.1)
attr = await switch_button.get_attribute("aria-checked")
checked_after = str(attr or "").strip().lower() == "true"
if checked_after != state:
Logger.warning("GLM Chat: Advanced Search did not settle to the requested state.")
except Exception as e:
Logger.debug(f"GLM Chat: failed to verify Advanced Search state: {e}")
async def set_tools_state(self, state: bool, *, model_label: str | None = None) -> None:
if not self.page:
return
await self._dismiss_dialog_close_buttons()
await self._close_glm_model_dropdown()
effective_model_label = str(model_label or "").strip() or self._get_configured_glm_model_friendly()
supported = self._glm_tools_supported_for_model(effective_model_label)
wanted = bool(state) and supported
button = await self._find_tools_button()
if not button:
if wanted:
Logger.warning("GLM Chat: Tools button not found.")
return
is_enabled = await self._read_composer_toggle_enabled(
button,
state_attr="data-selected",
enabled_label_prefix="tools enabled",
)
if is_enabled == wanted:
return
if not await self._click_glm_control(button, label="Tools"):
Logger.warning("GLM Chat: failed to toggle Tools.")
async def upload_file(self, file_spec: Any) -> None:
await self._upload_file(file_spec)
async def _upload_file(self, file_spec: Any) -> None:
if not self.page:
return
file_input = self.page.locator("input[type='file']")
if await file_input.count() == 0:
Logger.warning("GLM Chat: file input not found.")
return
await file_input.first.set_input_files(file_spec)
await asyncio.sleep(0.5)
async def enter_message(self, message: str) -> None:
await self._enter_message(message)
async def _enter_message(self, message: str) -> None:
if not self.page:
return
textarea = self.page.locator("textarea#chat-input")
if await textarea.count() == 0:
Logger.warning("GLM Chat: message textarea not found.")
return
# Use JS to set the value through the native property setter and dispatch
# events so that Svelte's reactive bindings pick up the change. This is
# more reliable than Playwright's .fill() for large inputs on slow machines.
try:
await self.page.evaluate(
"""(msg) => {
const ta = document.querySelector('textarea#chat-input');
if (!ta) return;
ta.focus();
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype, 'value'
).set;
setter.call(ta, msg);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
}""",
message,
)
except Exception as e:
Logger.debug(f"GLM Chat: JS text entry failed ({e}), falling back to .fill()")
await textarea.first.fill(message)
async def send_message(self, timeout: int | None = None) -> None:
await self._send_message(timeout=timeout)
async def _verify_send_clicked(self, poll_timeout: float = 1.5) -> bool:
"""
Verify that the send button click actually registered.
After a successful send, the send button's parent container changes its
aria-label from "Send Message" to "Stop" (and the send button itself
disappears). Poll for this state change to confirm the click went through.
"""
if not self.page:
return False
deadline = time.time() + poll_timeout
while time.time() < deadline:
try:
result = await self.page.evaluate(
"() => {"
" const btn = document.querySelector('button#send-message-button');"
" if (!btn) return 'gone';"
" const parent = btn.parentElement;"
" if (!parent) return 'unknown';"
" return (parent.getAttribute('aria-label') || '').trim().toLowerCase();"
"}"
)
if result == "gone" or result == "stop":
return True
except Exception:
pass
await asyncio.sleep(0.15)
return False
async def _click_stop_button(self) -> bool:
if not self.page:
return False
stop_targets = (
self.page.locator("div[aria-label='Stop']")
)
for target in stop_targets:
try:
if await target.count() <= 0:
continue
button = target.first
if not await button.is_visible():
continue
try:
await button.scroll_into_view_if_needed(timeout=self._ui_timeout)
except Exception:
pass
await button.click(timeout=self._ui_timeout)
Logger.debug("GLM Chat: Stop button clicked.")
return True
except Exception:
continue
try:
clicked = await self.page.evaluate(
"""() => {
const match = document.querySelector("div[aria-label='Stop']");
if (!match) return false;
match.click();
return true;
}"""
)
if clicked:
Logger.debug("GLM Chat: Stop button clicked via JS fallback.")
return True
except Exception as e:
Logger.debug(f"GLM Chat: JS stop click failed: {e}")
Logger.debug("GLM Chat: Stop button not found.")
return False
@classmethod
def _build_model_capacity_error_message(cls) -> str:
return (
"GLM Chat is currently at capacity. "
"Please try again later or switch to another model."
)
@classmethod
def _build_empty_completion_stream_error_message(cls) -> str:
return (
"GLM Chat returned an empty completion stream "
f"(the condition GLM shows as Error code: {cls.EMPTY_COMPLETION_STREAM_ERROR_CODE}). "
"IntenseRP attempted to refresh the page; please retry the request."
)
@staticmethod
def _glm_frontend_would_see_sse_data_event(body: bytes | bytearray | str | None) -> bool:
"""
Mirror GLM's own fallback check for Error code 20001.
GLM's frontend splits the decoded stream on LF/LF and only clears its
empty-stream fallback when a complete event block starts with "data:".
A trailing partial event is ignored by that code path, so we ignore it too.
"""
if body is None:
return False
if isinstance(body, str):
raw = body.encode("utf-8", errors="ignore")
else:
raw = bytes(body)
if not raw:
return False
complete_events = raw.split(b"\n\n")[:-1]
return any(event.startswith(b"data:") for event in complete_events)
@classmethod
def _extract_model_capacity_error_from_data(cls, data: Any) -> str | None:
if not isinstance(data, dict):
return None
raw_error = data.get("error")
code = ""
detail = ""
if isinstance(raw_error, dict):
code = str(raw_error.get("code") or "").strip().upper()
detail = str(raw_error.get("detail") or raw_error.get("message") or "").strip()
else:
detail = str(raw_error or "").strip()
normalized_detail = re.sub(r"\s+", " ", detail).strip().lower()
if code == cls.MODEL_CONCURRENCY_LIMIT_CODE:
return cls._build_model_capacity_error_message()
if normalized_detail and any(marker in normalized_detail for marker in cls.MODEL_CAPACITY_TEXT_MARKERS):
return cls._build_model_capacity_error_message()
return None
@classmethod
def _extract_model_capacity_error_from_text(cls, text: str | None) -> str | None:
normalized = re.sub(r"\s+", " ", str(text or "")).strip().lower()
if not normalized:
return None
if any(marker in normalized for marker in cls.MODEL_CAPACITY_TEXT_MARKERS):
return cls._build_model_capacity_error_message()
return None
async def _send_message(
self, timeout: int | None = None, arm_event: asyncio.Event | None = None
) -> None:
if not self.page:
return
await self._close_glm_model_dropdown()
send_button = self.page.locator("button#send-message-button")
if await send_button.count() == 0:
Logger.warning("GLM Chat: send button not found.")
return
if timeout and timeout > 0:
start = time.time()
while time.time() - start < timeout:
try:
if not await send_button.first.is_disabled():
break
except Exception:
pass
await asyncio.sleep(0.25)
try:
if await send_button.first.is_disabled():
Logger.warning("GLM Chat: send button is disabled. Cannot send message.")
return
except Exception:
pass
# Brief stabilization delay to let the UI settle before clicking,
# helps with long RPs where the button may not be fully interactive yet
await asyncio.sleep(0.3)
toaster = self.page.locator("ol[data-sonner-toaster]")
max_retries = 5
if arm_event:
try:
arm_event.set()
except Exception:
pass
for attempt in range(max_retries):
clicked = await self._click_glm_control(
send_button.first,
label="send button",
timeout_ms=max(int(getattr(self, "_ui_timeout", 3000)), 3000),
)
if not clicked:
Logger.debug("GLM Chat: send button click failed.")
await asyncio.sleep(0.4)
continue
# Check if a "still uploading" toast appeared
await asyncio.sleep(0.8)
try:
if await toaster.count() > 0:
toast_text = (await toaster.first.inner_text() or "").lower()
if "still uploading" in toast_text:
Logger.info(
f"GLM Chat: file still uploading (attempt {attempt + 1}/{max_retries}), "
f"waiting 3s before retry..."
)
await asyncio.sleep(3)
continue
except Exception:
pass
# Verify the click actually registered (parent aria-label changes to "Stop")
if await self._verify_send_clicked(poll_timeout=1.5):
return
# Playwright click didn't register -> try a direct JS click as fallback
Logger.debug(
f"GLM Chat: send click may not have registered (attempt {attempt + 1}/{max_retries}), "
f"retrying with JS click..."
)
try:
await self.page.evaluate(
"() => {"
" const btn = document.querySelector('button#send-message-button');"
" if (btn) btn.click();"
"}"
)
except Exception as e:
Logger.debug(f"GLM Chat: JS click fallback failed: {e}")
await asyncio.sleep(0.5)
if await self._verify_send_clicked(poll_timeout=1.5):
return
Logger.debug(f"GLM Chat: send click not confirmed (attempt {attempt + 1}/{max_retries}).")
await asyncio.sleep(0.3)
Logger.warning("GLM Chat: send button click did not register after all retry attempts, giving up.")
async def _click_regenerate(
self,
timeout_ms: int | None = None,
arm_event: asyncio.Event | None = None,
) -> bool:
if not self.page:
return False
timeout = self._ui_timeout if timeout_ms is None else max(int(timeout_ms), 0)
deadline = time.time() + (float(timeout) / 1000.0 if timeout > 0 else 0.0)
regen = self.page.locator("div[aria-label='Regenerate'] button")
if await regen.count() == 0:
regen = self.page.locator("[aria-label='Regenerate'] button")
last_error: Exception | None = None
while True:
try:
if await regen.count() > 0 and await regen.first.is_visible():
try:
aria_disabled = (await regen.first.get_attribute("aria-disabled") or "").strip().lower()
if aria_disabled == "true":
return False
except Exception:
pass
if arm_event:
try:
arm_event.set()
except Exception:
pass
try:
await regen.first.scroll_into_view_if_needed(timeout=self._ui_timeout)
except Exception:
pass
try:
await regen.first.click(timeout=self._ui_timeout)
return True
except Exception as e:
last_error = e
# Fallback: JS click (Playwright click can be swallowed by GLM's UI)
try:
clicked = await self.page.evaluate(
"() => {"
" const btn = document.querySelector(\"div[aria-label='Regenerate'] button, [aria-label='Regenerate'] button\");"
" if (!btn) return false;"
" btn.click();"
" return true;"
"}"
)
if clicked:
return True
except Exception as e:
last_error = e
except Exception as e:
last_error = e
if timeout <= 0 or time.time() >= deadline:
if last_error:
Logger.debug(f"GLM Chat: regenerate click failed: {last_error}")
return False
await asyncio.sleep(0.25)
@staticmethod
def _strip_details_tags(text: str) -> str:
if not text:
return ""
# Remove opening <details ...> and closing </details> tags if present
text = re.sub(r"<details[^>]*>", "", text, flags=re.IGNORECASE)
text = re.sub(r"</details>", "", text, flags=re.IGNORECASE)
return text
@staticmethod
def _strip_glm_blocks_from_stream_chunk(text: str, in_glm_block: bool) -> tuple[str, bool]:
"""
Strip GLM's internal <glm_block ...>...</glm_block> tool payloads from the stream.
These blocks contain tool/search metadata and results and should never be forwarded to the client.
Returns (cleaned_text, new_in_glm_block_state).
"""
if not text:
return "", bool(in_glm_block)
start_token = "<glm_block"
end_token = "</glm_block>"
out: list[str] = []
i = 0
active = bool(in_glm_block)
while i < len(text):
if active:
end = text.find(end_token, i)
if end == -1:
return "".join(out), True
i = end + len(end_token)
active = False
continue
start = text.find(start_token, i)
if start == -1:
# If we see a closing tag without a visible opening tag in this chunk,
# assume we're still inside a block (e.g. the opening tag was split across
# chunks) and drop everything up to and including the closing tag
end = text.find(end_token, i)
if end != -1:
i = end + len(end_token)
active = False
continue
out.append(text[i:])
return "".join(out), False
out.append(text[i:start])
end = text.find(end_token, start)
if end == -1:
return "".join(out), True
i = end + len(end_token)
active = False
return "".join(out), active
@staticmethod
def _strip_partial_details_opening_tail(text: str) -> str:
"""
GLM sometimes emits an edit_content patch that starts mid-way through the opening
<details ...> tag (e.g. starts with: 'true" duration="5" ...>').
In that case, strip the remaining tag attributes up to and including the first
'>' so we don't leak raw HTML attributes into <think> output.
"""
if not text:
return ""
preview = text[:200].lower()
if "<details" in preview:
return text
# Heuristic: only strip when it looks like a details tag attribute tail
if any(token in preview for token in ("done=", "duration=", "last_tool_call", "view=")):
gt_idx = text.find(">")
if 0 <= gt_idx <= 200:
return text[gt_idx + 1 :].lstrip("\n\r")
return text
@classmethod
def _extract_reasoning_from_edit_content(cls, text: str) -> str:
if not text:
return ""
marker = "</details>"
idx = text.find(marker)
if idx == -1:
return ""
reasoning = text[:idx]
reasoning = cls._strip_details_tags(reasoning)
reasoning = cls._strip_partial_details_opening_tail(reasoning)
return reasoning
@staticmethod
def _extract_answer_tail_from_edit_content(text: str) -> str:
if not text:
return ""
marker = "</details>"
idx = text.rfind(marker)
if idx == -1:
return ""
return text[idx + len(marker) :].lstrip("\n\r")
@staticmethod
def _compute_missing_suffix(emitted: str, candidate: str) -> str:
"""
Compute the text suffix that exists in candidate but has not yet been emitted.
GLM can resend (or patch) the full reasoning/answer content via edit_content. We
want to forward only the newly-added tail to avoid duplicate chunks.
"""
return compute_missing_suffix(emitted, candidate)
async def generate_response(
self,
message: Union[str, List[Any]],
model: str = "GLM-5.2",
stream: bool = False,
temperature: float | None = None,
top_p: float | None = None,
max_tokens: int | None = None,
abort_event: asyncio.Event | None = None,
):
_ = max_tokens
response_queue: asyncio.Queue = asyncio.Queue()
completion_armed = asyncio.Event()
completion_started = asyncio.Event()
completion_claim_lock = asyncio.Lock()
completion_claimed = False
intercepted_activity_count = 0
intercepted_response: httpx.Response | None = None
intercepted_request_abort = asyncio.Event()
intercepted_request_finished = asyncio.Event()
request_capture_mode = self._get_request_capture_mode()
use_cdp_teeing = request_capture_mode == self.REQUEST_CAPTURE_MODE_CDP_TEEING
route_handlers_registered = False
cdp_session: Any = None
cdp_listeners_registered = False
cdp_tasks: set[asyncio.Task] = set()
request_methods: dict[str, str] = {}
cdp_pending_response_urls: dict[str, str] = {}
cdp_active_request_id: Optional[str] = None
cdp_stream_started = False
cdp_stream_finished = False
cdp_had_data = False
def get_intercepted_activity_count() -> int:
return intercepted_activity_count
async def abort_intercepted_request() -> None:
intercepted_request_abort.set()
response = intercepted_response
if response is not None:
try:
await response.aclose()
except Exception as e:
Logger.debug(f"GLM Chat: failed to close intercepted response: {e}")
try:
await self._click_stop_button()
except Exception as e:
Logger.debug(f"GLM Chat: failed to click Stop during timeout handling: {e}")
await self._await_pending_refresh_after_generation(abort_event=abort_event)
await self.require_english_ui()
self._refresh_quirks()
# Reset state for new generation
self.thinking_active = False
self.abort_requested = False
self.current_abort_event = abort_event
resolved_model = (model or "").strip() or "GLM-5.2"
self.current_model = resolved_model
macros_overrides: Dict[str, bool] = {}
message_for_formatting = message
if isinstance(message, list):
message_for_formatting, macros_overrides = self._strip_glm_macros_from_messages(message)
elif isinstance(message, str):
message_for_formatting, macros_overrides = self._extract_glm_macros_from_text(message)
if macros_overrides:
Logger.debug(f"GLM macros applied: {macros_overrides}")
request_overrides = dict(getattr(self, "_pending_request_overrides", {}) or {})
request_overrides.update(macros_overrides)
effective_settings = self._resolve_glm_request_settings(resolved_model, overrides=request_overrides)
effective_deepthink = effective_settings["deepthink_enabled"]
deepthink_effort = str(effective_settings.get("deepthink_effort") or "").strip()
effective_send_deepthink = effective_settings["send_deepthink"]
enable_search = effective_settings["search_enabled"]
enable_advanced_search = effective_settings["advanced_search_enabled"]
enable_tools = effective_settings["tools_enabled"]
send_as_text_file = effective_settings["send_as_text_file"]
ui_model_label = str(
effective_settings.get("model_label") or self._get_glm_model_label_for_request(resolved_model)
)
self.current_send_deepthink = effective_send_deepthink
formatted_message = self._format_messages(message_for_formatting)
glm_extra_prompt_texts: Dict[str, str] = {}
if send_as_text_file:
try:
glm_text_file_filler = str(
self.config_manager.get_setting("glm_behavior", "text_file_filler") or "."
)
except Exception:
glm_text_file_filler = "."
if glm_text_file_filler.strip():
glm_extra_prompt_texts["text_file_filler"] = glm_text_file_filler
self._capture_diagnostics_prompt_snapshot(
formatted_message,
extra_prompt_texts=glm_extra_prompt_texts or None,
metadata={
"model": resolved_model,
"ui_model": ui_model_label,
"deepthink_enabled": bool(effective_deepthink),
"deepthink_effort": deepthink_effort,
"send_deepthink": bool(effective_send_deepthink),
"search_enabled": bool(enable_search),
"advanced_search_enabled": bool(enable_advanced_search),
"tools_enabled": bool(enable_tools),
"send_as_text_file": bool(send_as_text_file),
},
)
try:
repetition_buster_enabled = bool(
self.config_manager.get_setting("glm_behavior", "repetition_buster")
)
except Exception:
repetition_buster_enabled = False
try:
clean_regeneration_requested = bool(
self.config_manager.get_setting("glm_behavior", "clean_regeneration")
)
except Exception:
clean_regeneration_requested = False
try:
multi_slot_cache_requested = bool(
self.config_manager.get_setting("glm_behavior", "multi_slot_cache")
)
except Exception:
multi_slot_cache_requested = False
try:
auto_delete_requested = bool(
self.config_manager.get_setting("glm_behavior", "auto_delete_chats")
)
except Exception:
auto_delete_requested = False
if repetition_buster_enabled and clean_regeneration_requested:
Logger.debug(
"Repetition Buster (GLM): Clean Regeneration is also enabled in config, "
"but Repetition Buster takes priority."
)
clean_regeneration = bool((not repetition_buster_enabled) and clean_regeneration_requested)
multi_slot_cache_enabled = bool(clean_regeneration and multi_slot_cache_requested)
auto_delete_enabled = bool(auto_delete_requested and (not clean_regeneration))
if auto_delete_requested and clean_regeneration:
Logger.warning(
"GLM Chat: Delete Chat After Reply is skipped for this request because "
"Reuse Matching Chat is enabled."
)
prompt_matches_last = False
if repetition_buster_enabled:
prompt_matches_last = self._account_scoped_cached_prompt_matches(
self.cache_manager,
self.repetition_buster_cache_key,
formatted_message,
)
elif clean_regeneration:
prompt_matches_last = self._cached_prompt_matches(
self.cache_manager,
self.clean_regen_message_cache_key,
formatted_message,
)
if repetition_buster_enabled and prompt_matches_last:
repetition_buster_error = await self._run_repetition_buster(
effective_deepthink=bool(effective_deepthink),
deepthink_effort=deepthink_effort,
enable_search=bool(enable_search),
enable_advanced_search=bool(enable_advanced_search),
enable_tools=bool(enable_tools),
ui_model_label=ui_model_label,
auto_delete_after_send=auto_delete_enabled,
)
if repetition_buster_error:
self._reset_generation_state()
self._pending_request_overrides = {}
yield f"data: {json.dumps({'error': repetition_buster_error})}\n\n"
return
full_response_body = bytearray()
text_buffer = bytearray()
text_buffer_pos = 0
thinking_emitted = IncrementalTextAccumulator()
answer_emitted = False
glm_block_active = False
emitted_openai_chunk = False
openai_usage: dict[str, Any] | None = None
openai_usage_emitted = False
openai_finish_emitted = False
capacity_error_message: str | None = None
try:
count_tokens_setting = self.config_manager.get_setting("glm_behavior", "count_tokens")
except Exception:
count_tokens_setting = None
# Default to enabled, even if the setting isn't present (older configs).
count_tokens_enabled = True if count_tokens_setting is None else bool(count_tokens_setting)
def request_aborted() -> bool:
return bool(
intercepted_request_abort.is_set()
or self.abort_requested
or (abort_event and abort_event.is_set())
)
def _normalize_openai_usage(raw: Any) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
def _to_int(value: Any) -> int | None:
try:
return int(value)
except Exception:
return None
prompt_tokens = _to_int(raw.get("prompt_tokens"))
completion_tokens = _to_int(raw.get("completion_tokens"))
total_tokens = _to_int(raw.get("total_tokens"))
if prompt_tokens is None and completion_tokens is None and total_tokens is None:
return None
prompt_tokens = 0 if prompt_tokens is None else max(prompt_tokens, 0)
completion_tokens = 0 if completion_tokens is None else max(completion_tokens, 0)
if total_tokens is None:
total_tokens = prompt_tokens + completion_tokens
else:
total_tokens = max(total_tokens, 0)
usage: dict[str, Any] = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
prompt_details = raw.get("prompt_tokens_details")
if isinstance(prompt_details, dict):
usage["prompt_tokens_details"] = prompt_details
completion_details = raw.get("completion_tokens_details")
if isinstance(completion_details, dict):
usage["completion_tokens_details"] = completion_details
return usage
def enqueue_openai_delta(content: str, finish_reason: str | None = None) -> None:
nonlocal emitted_openai_chunk
if (not content) and (not finish_reason):
return
model_name = self.current_model or "GLM-5.2"
response_queue.put_nowait(
make_openai_delta_sse(
model_name,
content,
finish_reason=finish_reason,
)
)
emitted_openai_chunk = True
def enqueue_openai_usage(usage: dict[str, Any]) -> None:
nonlocal emitted_openai_chunk, openai_usage_emitted
if openai_usage_emitted:
return
model_name = self.current_model or "GLM-5.2"
response_queue.put_nowait(make_openai_usage_sse(model_name, usage))
emitted_openai_chunk = True
openai_usage_emitted = True
def process_sse_line(line: str) -> None:
nonlocal thinking_emitted, answer_emitted, glm_block_active, openai_usage
nonlocal openai_finish_emitted, capacity_error_message
line = line.strip()
if not line.startswith("data:"):
return
data_str = line[len("data:") :].strip()
if not data_str or data_str == "[DONE]":
return
try:
payload = json.loads(data_str)
except Exception:
return
if not isinstance(payload, dict):
return
payload_type = str(payload.get("type") or "").strip().lower()
# Regeneration can use the same endpoint with slightly different type labels.
if not payload_type.startswith("chat:completion"):
return
data = payload.get("data")
if not isinstance(data, dict):
return
capacity_error_message = self._extract_model_capacity_error_from_data(data)
if capacity_error_message:
return
phase = str(data.get("phase") or "").strip().lower()
if count_tokens_enabled:
normalized_usage = _normalize_openai_usage(data.get("usage"))
if normalized_usage:
openai_usage = normalized_usage
if phase == "done" and bool(data.get("done")):
if not openai_finish_emitted:
if self.thinking_active and self.current_send_deepthink:
enqueue_openai_delta("</think>")
self.thinking_active = False
enqueue_openai_delta("", finish_reason="stop")
openai_finish_emitted = True
return
delta_content = data.get("delta_content")
edit_content = data.get("edit_content")
if isinstance(delta_content, str):
if phase == "thinking":
if not self.current_send_deepthink:
return
if not self.thinking_active:
enqueue_openai_delta("<think>")
self.thinking_active = True
stripped = self._strip_details_tags(delta_content)
if stripped:
enqueue_openai_delta(stripped)
thinking_emitted.append(stripped)
return
if phase == "answer":
if self.thinking_active and self.current_send_deepthink:
enqueue_openai_delta("</think>")
self.thinking_active = False
enqueue_openai_delta(delta_content)
answer_emitted = True
return
return
if isinstance(edit_content, str):
edit_content, glm_block_active = self._strip_glm_blocks_from_stream_chunk(
edit_content, glm_block_active
)
# When thinking is enabled, GLM often sends a huge edit_content that includes the full
# <details> reasoning plus the first token(s) of the answer. Extract only the answer tail.
if phase == "answer":
if self.current_send_deepthink and (self.thinking_active or not answer_emitted):
reasoning = self._extract_reasoning_from_edit_content(edit_content)
if reasoning:
missing = thinking_emitted.missing_suffix(reasoning)
if missing:
if not self.thinking_active:
enqueue_openai_delta("<think>")
self.thinking_active = True
enqueue_openai_delta(missing)
thinking_emitted.append(missing)
if self.thinking_active and self.current_send_deepthink:
enqueue_openai_delta("</think>")
self.thinking_active = False
tail = self._extract_answer_tail_from_edit_content(edit_content)
if tail:
enqueue_openai_delta(tail)
answer_emitted = True
return
# GLM sometimes finalizes the answer with an "other" edit_content frame (tail append).
if phase == "other":
if self.thinking_active and self.current_send_deepthink:
enqueue_openai_delta("</think>")
self.thinking_active = False
if edit_content:
enqueue_openai_delta(edit_content)
answer_emitted = True
return
# At this point answer content may already be streaming via delta_content; only
# treat tool_call edit_content as answer tail if we've already emitted answer.
if phase == "tool_call":
if self.thinking_active and self.current_send_deepthink:
enqueue_openai_delta("</think>")
self.thinking_active = False
if answer_emitted and edit_content:
enqueue_openai_delta(edit_content)
answer_emitted = True
return
def process_glm_stream_chunk(chunk: bytes) -> None:
nonlocal intercepted_activity_count, text_buffer_pos
if not chunk:
return
intercepted_activity_count += 1
full_response_body.extend(chunk)
if capacity_error_message:
return
text_buffer.extend(chunk)
while True:
newline_idx = text_buffer.find(b"\n", text_buffer_pos)
if newline_idx == -1:
break
line_bytes = text_buffer[text_buffer_pos:newline_idx]
text_buffer_pos = newline_idx + 1
try:
process_sse_line(bytes(line_bytes).decode("utf-8", errors="ignore"))
except Exception:
continue
if capacity_error_message:
break
if text_buffer_pos > 8192:
del text_buffer[:text_buffer_pos]
text_buffer_pos = 0
def finalize_glm_stream_processing(*, aborted: bool = False) -> None:
nonlocal text_buffer_pos
if not capacity_error_message:
tail = bytes(text_buffer[text_buffer_pos:])
if tail.strip():
try:
process_sse_line(tail.decode("utf-8", errors="ignore"))
except Exception:
pass
text_buffer.clear()
text_buffer_pos = 0
if (
(not aborted)
and (not request_aborted())
and (not capacity_error_message)
and count_tokens_enabled
and (openai_usage is not None)
and (not openai_usage_emitted)
):
enqueue_openai_usage(openai_usage)
async def finish_glm_stream_result(
*,
aborted: bool = False,
encountered_error: bool = False,
) -> None:
if aborted or request_aborted():
Logger.warning("GLM Chat generation was aborted before completion.")
should_report_empty_stream_error = bool(
(not aborted)
and (not encountered_error)
and (not request_aborted())
and (not capacity_error_message)
and (not emitted_openai_chunk)
)
if capacity_error_message:
Logger.warning(capacity_error_message)
await self._refresh_page_after_capacity_error()
response_queue.put_nowait(
f"data: {json.dumps({'error': capacity_error_message})}\n\n"
)
elif should_report_empty_stream_error:
msg = self._extract_model_capacity_error_from_text(
full_response_body.decode("utf-8", errors="ignore")
)
if msg:
await self._refresh_page_after_capacity_error()
if not msg:
if self._glm_frontend_would_see_sse_data_event(full_response_body):
# Surface a helpful error instead of silently returning an empty stream.
msg = (
"GLM Chat: intercepted completion produced no streamable output. "
"This may indicate a GLM API / frontend change."
)
else:
msg = self._build_empty_completion_stream_error_message()
await self._reload_chat_page(
"empty completion stream "
f"(GLM Error code: {self.EMPTY_COMPLETION_STREAM_ERROR_CODE})"
)
Logger.warning(msg)
response_queue.put_nowait(f"data: {json.dumps({'error': msg})}\n\n")
await response_queue.put(None)
intercepted_request_finished.set()
if (
not aborted
and (not encountered_error)
and (not request_aborted())
and (not capacity_error_message)
):
Logger.success("GLM Chat response streaming completed.")
async def handle_route(route):
nonlocal completion_claimed, intercepted_response
request = route.request
# Ignore preflight and any requests we aren't actively expecting to stream
# GLM can sometimes fire background requests to the same endpoint
try:
method = str(request.method or "").upper()
except Exception:
method = ""
if method == "OPTIONS":
await route.continue_()
return
if not completion_armed.is_set():
await route.continue_()
return
async with completion_claim_lock:
if completion_claimed:
await route.continue_()
return
completion_claimed = True
completion_started.set()
Logger.info("Intercepting GLM Chat API request...")
Logger.debug(f"Intercepted request to: {request.url}")
headers = await request.all_headers()
headers.pop("content-length", None)
headers.pop("host", None)
cookies = await self.context.cookies()
cookie_dict = {c["name"]: c["value"] for c in cookies}
response_headers: Dict[str, str] = {}
aborted = False
encountered_error = False
try:
json_body = None
raw_post_data = request.post_data
if raw_post_data:
try:
json_body = request.post_data_json
except Exception:
json_body = None
client = await self._get_http_client()
try:
request_kwargs: Dict[str, Any] = {
"headers": headers,
"cookies": cookie_dict,
"timeout": 60.0,
}
if json_body is not None:
request_kwargs["json"] = json_body
elif raw_post_data:
request_kwargs["content"] = str(raw_post_data).encode("utf-8")
async with client.stream(
request.method,
request.url,
**request_kwargs,
) as response:
intercepted_response = response
for k, v in response.headers.items():
response_headers[k] = v
async for chunk in response.aiter_bytes():
if request_aborted():
Logger.debug("Abort detected during GLM streaming, stopping...")
aborted = True
break
process_glm_stream_chunk(chunk)
if capacity_error_message:
break
if not capacity_error_message:
finalize_glm_stream_processing(aborted=aborted)
except httpx.ReadError as e:
if not aborted and not request_aborted():
encountered_error = True
Logger.error(f"Read error during GLM intercepted request: {e}")
response_queue.put_nowait(f"data: {json.dumps({'error': str(e)})}\n\n")
except Exception as e:
if not aborted and not request_aborted():
encountered_error = True
Logger.error(f"Error during GLM intercepted request: {e}")
response_queue.put_nowait(f"data: {json.dumps({'error': str(e)})}\n\n")
except RuntimeError as e:
if "async generator" in str(e) or "cancel scope" in str(e):
Logger.debug(f"Ignored expected error during abort: {e}")
else:
raise
finally:
intercepted_response = None
if aborted or request_aborted():
try:
await route.abort()
except Exception as e:
Logger.error(f"GLM Chat: error finalizing route: {e}")
else:
try:
await route.fulfill(
body=bytes(full_response_body),
status=200,
headers=response_headers,
)
except Exception as e:
Logger.error(f"GLM Chat: error finalizing route: {e}")
await finish_glm_stream_result(
aborted=aborted,
encountered_error=encountered_error,
)
def _schedule_cdp_task(coro: Any, label: str) -> None:
try:
task = asyncio.create_task(coro)
except Exception as exc:
Logger.debug(f"GLM Chat: failed to schedule CDP handler for {label}: {exc}")
return
cdp_tasks.add(task)
def _on_done(done_task: asyncio.Task) -> None:
cdp_tasks.discard(done_task)
try:
done_task.exception()
except asyncio.CancelledError:
pass
except Exception as exc:
Logger.debug(f"GLM Chat: CDP handler for {label} failed: {exc}")
task.add_done_callback(_on_done)
async def finish_cdp_stream(
request_id: str,
*,
aborted: bool = False,
encountered_error: bool = False,
) -> None:
nonlocal cdp_stream_finished
if request_id != cdp_active_request_id or cdp_stream_finished:
return
cdp_stream_finished = True
if not aborted and not encountered_error:
finalize_glm_stream_processing(aborted=False)
await finish_glm_stream_result(
aborted=aborted,
encountered_error=encountered_error,
)
request_methods.pop(request_id, None)
cdp_pending_response_urls.pop(request_id, None)
async def feed_cdp_stream_chunk(request_id: str, data: bytes) -> None:
nonlocal cdp_had_data
if request_id != cdp_active_request_id or not data or cdp_stream_finished:
return
cdp_had_data = True
if request_aborted():
await finish_cdp_stream(request_id, aborted=True)
return
process_glm_stream_chunk(data)
if request_aborted():
await finish_cdp_stream(request_id, aborted=True)
async def feed_base64_cdp_stream_chunk(request_id: str, encoded_data: Any) -> None:
if not encoded_data:
return
encoded_text = str(encoded_data)
try:
data = base64.b64decode(encoded_text, validate=True)
except Exception:
data = encoded_text.encode("utf-8", errors="ignore")
await feed_cdp_stream_chunk(request_id, data)
async def start_cdp_stream(request_id: str, url: str) -> None:
nonlocal cdp_stream_started
if (
request_id != cdp_active_request_id
or not cdp_session
or cdp_stream_started
or cdp_stream_finished
):
return
cdp_stream_started = True
Logger.info("Teeing GLM Chat API response via CDP...")
Logger.debug(f"Teeing request to: {url}")
try:
result = await cdp_session.send(
"Network.streamResourceContent",
{"requestId": request_id},
)
except Exception as exc:
message_text = f"GLM Chat CDP response streaming failed: {exc}"
Logger.error(message_text)
await response_queue.put({"error": message_text})
await finish_cdp_stream(request_id, encountered_error=True)
return
if isinstance(result, dict):
await feed_base64_cdp_stream_chunk(request_id, result.get("bufferedData"))
async def handle_cdp_request_will_be_sent(params: Any) -> None:
nonlocal completion_claimed, cdp_active_request_id
if not isinstance(params, dict):
return
request_id = str(params.get("requestId") or "").strip()
request = params.get("request")
if not request_id or not isinstance(request, dict):
return
method = str(request.get("method") or "").upper()
request_methods[request_id] = method
url = str(request.get("url") or "")
if method != "POST" or not self._is_completion_request_url(url):
return
if not completion_armed.is_set():
return
async with completion_claim_lock:
if completion_claimed:
return
completion_claimed = True
cdp_active_request_id = request_id
completion_started.set()
Logger.info("Observing GLM Chat API request via CDP...")
Logger.debug(f"Observed request to: {url}")
pending_url = cdp_pending_response_urls.get(request_id)
if pending_url:
await start_cdp_stream(request_id, pending_url)
async def handle_cdp_response_received(params: Any) -> None:
if not isinstance(params, dict):
return
request_id = str(params.get("requestId") or "").strip()
response = params.get("response")
if not request_id or not isinstance(response, dict):
return
url = str(response.get("url") or "")
if not self._is_completion_request_url(url):
return
method = request_methods.get(request_id, "").upper()
if method and method != "POST":
return
cdp_pending_response_urls[request_id] = url
if request_id != cdp_active_request_id:
return
await start_cdp_stream(request_id, url)
async def handle_cdp_data_received(params: Any) -> None:
if not isinstance(params, dict):
return
request_id = str(params.get("requestId") or "").strip()
if request_id == cdp_active_request_id:
await feed_base64_cdp_stream_chunk(request_id, params.get("data"))
async def handle_cdp_loading_finished(params: Any) -> None:
if not isinstance(params, dict):
return
request_id = str(params.get("requestId") or "").strip()
if request_id == cdp_active_request_id:
await finish_cdp_stream(request_id)
else:
request_methods.pop(request_id, None)
cdp_pending_response_urls.pop(request_id, None)
async def handle_cdp_loading_failed(params: Any) -> None:
if not isinstance(params, dict):
return
request_id = str(params.get("requestId") or "").strip()
if request_id != cdp_active_request_id:
request_methods.pop(request_id, None)
cdp_pending_response_urls.pop(request_id, None)
return
if request_aborted():
await finish_cdp_stream(request_id, aborted=True)
return
error_text = str(params.get("errorText") or "network loading failed").strip()
if "ERR_ABORTED" in error_text.upper() and cdp_had_data:
Logger.debug(
"GLM Chat CDP stream ended with net::ERR_ABORTED after data arrived; "
"treating it as complete."
)
await finish_cdp_stream(request_id)
return
message_text = f"GLM Chat CDP stream failed: {error_text}"
Logger.error(message_text)
await response_queue.put({"error": message_text})
await finish_cdp_stream(request_id, encountered_error=True)
def on_cdp_request_will_be_sent(params: Any) -> None:
_schedule_cdp_task(handle_cdp_request_will_be_sent(params), "requestWillBeSent")
def on_cdp_response_received(params: Any) -> None:
_schedule_cdp_task(handle_cdp_response_received(params), "responseReceived")
def on_cdp_data_received(params: Any) -> None:
_schedule_cdp_task(handle_cdp_data_received(params), "dataReceived")
def on_cdp_loading_finished(params: Any) -> None:
_schedule_cdp_task(handle_cdp_loading_finished(params), "loadingFinished")
def on_cdp_loading_failed(params: Any) -> None:
_schedule_cdp_task(handle_cdp_loading_failed(params), "loadingFailed")
route_owner = self.context or self.page
if not route_owner:
raise RuntimeError("GLM Chat: browser context is not available.")
try:
if use_cdp_teeing:
if not self.context or not self.page:
message_text = "GLM Chat CDP setup failed: browser context is not available."
Logger.error(message_text)
yield f"data: {json.dumps({'error': message_text})}\n\n"
return
try:
cdp_session = await self.context.new_cdp_session(self.page)
await cdp_session.send("Network.enable", {})
cdp_session.on("Network.requestWillBeSent", on_cdp_request_will_be_sent)
cdp_session.on("Network.responseReceived", on_cdp_response_received)
cdp_session.on("Network.dataReceived", on_cdp_data_received)
cdp_session.on("Network.loadingFinished", on_cdp_loading_finished)
cdp_session.on("Network.loadingFailed", on_cdp_loading_failed)
cdp_listeners_registered = True
Logger.info("GLM Chat Request Capture Mode: CDP Teeing.")
except Exception as exc:
message_text = f"GLM Chat CDP setup failed: {exc}"
Logger.error(message_text)
yield f"data: {json.dumps({'error': message_text})}\n\n"
return
else:
await route_owner.route(self.COMPLETION_ROUTE_GLOB, handle_route)
route_handlers_registered = True
Logger.info("GLM Chat Request Capture Mode: Replay.")
regenerated = False
clean_regen_state: Dict[str, Any] | None = None
multi_slot_state: Dict[str, Any] | None = None
current_cache_matched = False
should_record_multi_slot = False
if clean_regeneration:
clean_regen_state = {
"deepthink_enabled": bool(effective_deepthink),
"deepthink_effort": deepthink_effort,
"search_enabled": bool(enable_search),
"advanced_search_enabled": bool(enable_advanced_search),
"tools_enabled": bool(enable_tools),
"send_as_text_file": bool(send_as_text_file),
"ui_model": ui_model_label,
}
multi_slot_state = self._build_multi_slot_cache_state(
effective_deepthink=bool(effective_deepthink),
deepthink_effort=deepthink_effort,
enable_search=bool(enable_search),
enable_advanced_search=bool(enable_advanced_search),
enable_tools=bool(enable_tools),
send_as_text_file=bool(send_as_text_file),
ui_model_label=ui_model_label,
)
last_state = self._read_clean_regeneration_state()
message_matches = prompt_matches_last
state_matches = last_state == clean_regen_state
if message_matches and state_matches:
current_cache_matched = True
Logger.info(
"Clean Regeneration (GLM): Message and settings match cache. Attempting to regenerate..."
)
# toggles must match before regenerating (GLM UI can reset them on refresh)
try:
await self.set_tools_state(enable_tools, model_label=ui_model_label)
await self.set_deepthink_state(
effective_deepthink,
effort=deepthink_effort,
model_label=ui_model_label,
)
await self.set_search_state(enable_search)
await self.set_advanced_search_state(enable_advanced_search)
await asyncio.sleep(self._post_delay_s)
except Exception:
pass
regen_timeout = max(int(getattr(self, "_ui_timeout", 3000)), 5000)
if await self._click_regenerate(timeout_ms=regen_timeout, arm_event=completion_armed):
Logger.info("Clean Regeneration (GLM): Regenerate clicked. Regenerating...")
try:
await asyncio.wait_for(
completion_started.wait(),
timeout=self._completion_request_timeout_s,
)
except asyncio.TimeoutError:
Logger.warning(
"Clean Regeneration (GLM): completion request not observed after clicking "
"Regenerate. Falling back to new chat."
)
else:
regenerated = True
self._write_cached_prompt(
self.cache_manager,
self.clean_regen_message_cache_key,
formatted_message,
)
self._write_clean_regeneration_state(clean_regen_state)
else:
Logger.warning(
"Clean Regeneration (GLM): Regenerate button not found/visible. Falling back to new chat."
)
elif message_matches and not state_matches:
Logger.info(
"Clean Regeneration (GLM): Message matches cache but settings changed. Creating new chat."
)
else:
Logger.debug("Clean Regeneration (GLM): Message differs from cache. Creating new chat.")
if (
(not regenerated)
and multi_slot_cache_enabled
and multi_slot_state
and (not current_cache_matched)
):
regenerated = await self._try_multi_slot_regeneration(
formatted_message=formatted_message,
multi_slot_state=multi_slot_state,
completion_armed=completion_armed,
completion_started=completion_started,
)
if regenerated and clean_regen_state:
self._write_cached_prompt(
self.cache_manager,
self.clean_regen_message_cache_key,
formatted_message,
)
self._write_clean_regeneration_state(clean_regen_state)
if not regenerated:
await self._prepare_new_chat_request_ui(
effective_deepthink=bool(effective_deepthink),
deepthink_effort=deepthink_effort,
enable_search=bool(enable_search),
enable_advanced_search=bool(enable_advanced_search),
enable_tools=bool(enable_tools),
ui_model_label=ui_model_label,
)
if send_as_text_file:
Logger.info("GLM Chat: sending message as text file...")
file_payload = build_prompt_text_file_payload(formatted_message)
await self._upload_file(file_payload)
# GLM requires some text alongside the file to enable the send button
filler = self.config_manager.get_setting("glm_behavior", "text_file_filler") or "."
await self._enter_message(str(filler))
# It Just Works™
# Copyright © ONCE IN A LIFETIME Bethesda Softworks LLC
upload_timeout = int(self.config_manager.get_setting("glm_behavior", "file_upload_timeout") or 15)
Logger.info("GLM Chat: sending request...")
await self._send_message(timeout=upload_timeout, arm_event=completion_armed)
else:
await self._send_text_request(
formatted_message,
send_timeout=self._msg_send_timeout,
arm_event=completion_armed,
)
if clean_regeneration and clean_regen_state:
self._write_cached_prompt(
self.cache_manager,
self.clean_regen_message_cache_key,
formatted_message,
)
self._write_clean_regeneration_state(clean_regen_state)
should_record_multi_slot = bool(multi_slot_cache_enabled and multi_slot_state)
elif repetition_buster_enabled:
self._write_account_scoped_cached_prompt(
self.cache_manager,
self.repetition_buster_cache_key,
formatted_message,
)
if not completion_started.is_set():
try:
await asyncio.wait_for(
completion_started.wait(),
timeout=self._completion_request_timeout_s,
)
except asyncio.TimeoutError:
Logger.error(
"GLM Chat: completion request was not observed. "
"The UI may have swallowed the click or the endpoint changed."
)
yield f"data: {json.dumps({'error': 'GLM: completion request not observed'})}\n\n"
return
stream_completed = True
async for item in self._iterate_response_queue(
response_queue,
abort_event=abort_event,
first_chunk_timeout_s=self._first_chunk_timeout_s,
idle_timeout_s=self.INTERCEPT_IDLE_TIMEOUT_S,
on_timeout=abort_intercepted_request,
activity_counter=get_intercepted_activity_count,
):
if isinstance(item, dict) and "error" in item:
stream_completed = False
yield f"data: {json.dumps(item)}\n\n"
break
yield item
if should_record_multi_slot and stream_completed and not self.abort_requested and not (abort_event and abort_event.is_set()):
conversation_info = await self._wait_for_current_conversation_info(timeout_ms=6000)
if conversation_info is None:
Logger.debug(
"Multi-Slot Cache (GLM): could not resolve conversation URL after generation; "
"skipping cache save."
)
else:
upsert_multi_slot_cache_entry(
self.cache_manager,
self.multi_slot_cache_key,
self._get_multi_slot_cache_account_key(),
{
"conversation_id": conversation_info["conversation_id"],
"conversation_url": conversation_info["conversation_url"],
"prompt": formatted_message,
"state": multi_slot_state,
},
log_label="Multi-Slot Cache (GLM)",
)
if stream_completed and not self.abort_requested and not (abort_event and abort_event.is_set()):
self._schedule_refresh_after_generation()
if (
auto_delete_enabled
and stream_completed
and not self.abort_requested
and not (abort_event and abort_event.is_set())
):
await self._auto_delete_current_chat()
finally:
if completion_started.is_set() and not intercepted_request_finished.is_set():
try:
await asyncio.wait_for(intercepted_request_finished.wait(), timeout=5.0)
except asyncio.TimeoutError:
Logger.debug("GLM Chat: timed out waiting for intercepted request cleanup.")
self._reset_generation_state()
self._pending_request_overrides = {}
if route_handlers_registered:
try:
await route_owner.unroute(self.COMPLETION_ROUTE_GLOB, handle_route)
except Exception:
try:
await route_owner.unroute(self.COMPLETION_ROUTE_GLOB)
except Exception:
pass
if cdp_session and cdp_listeners_registered:
for event_name, listener in (
("Network.requestWillBeSent", on_cdp_request_will_be_sent),
("Network.responseReceived", on_cdp_response_received),
("Network.dataReceived", on_cdp_data_received),
("Network.loadingFinished", on_cdp_loading_finished),
("Network.loadingFailed", on_cdp_loading_failed),
):
try:
cdp_session.remove_listener(event_name, listener)
except Exception:
pass
for task in list(cdp_tasks):
if not task.done():
task.cancel()
tasks_to_wait = set(cdp_tasks)
if tasks_to_wait:
try:
await asyncio.wait(tasks_to_wait, timeout=1.0)
except Exception:
pass
if cdp_session:
try:
await cdp_session.detach()
except Exception as exc:
Logger.debug(f"GLM Chat: CDP detach failed: {exc}")