Files
microsoft--webwright/src/webwright/models/base.py
T
2026-07-13 12:28:42 +08:00

588 lines
22 KiB
Python

from __future__ import annotations
import asyncio
import base64
import json
import mimetypes
import os
import subprocess
from pathlib import Path
from typing import Annotated, Any
import httpx
from jinja2 import StrictUndefined, Template
from pydantic import BaseModel as PydanticBaseModel, BeforeValidator, field_validator
from webwright.exceptions import FormatError
from webwright.utils.logging import append_runtime_log
from webwright.utils.runtime import run_async
def _none_to_str(value: Any) -> str:
return "" if value is None else str(value)
# String field that coerces None -> "" and any value -> str via pydantic.
OptStr = Annotated[str, BeforeValidator(_none_to_str)]
MAX_JSON_PARSE_RETRIES = 3
DEFAULT_OBSERVATION_TEMPLATE = """Observation:
Status: {{ 'ok' if observation.success else 'error' }}
URL: {{ observation.url }}
Title: {{ observation.title }}
{% if observation.exception %}Exception:
{{ observation.exception }}
{% endif %}{% if observation.console_output %}Console output:
{{ observation.console_output }}
{% endif %}{% if observation.aria_snapshot %}ARIA snapshot:
{{ observation.aria_snapshot }}
{% endif %}{% if observation.screenshot_path %}Screenshot path: {{ observation.screenshot_path }}
{% endif %}"""
DEFAULT_FORMAT_ERROR_TEMPLATE = """Format error:
{{ error }}
Please respond with strict JSON using exactly these fields:
- thought: short reasoning about the next step
- bash_command: exactly one shell command for local-workspace tasks
- python_code: exactly one async Python browser step for local-browser tasks
- done: boolean indicating whether the task is complete
- final_response: final natural-language answer when done, otherwise empty
"""
ACTION_FIELDS = {"bash_command", "python_code"}
def _is_rate_limit_error(exc: BaseException | None) -> bool:
current: BaseException | None = exc
while current is not None:
status_code = getattr(current, "status_code", None)
if status_code == 429:
return True
response = getattr(current, "response", None)
if getattr(response, "status_code", None) == 429:
return True
text = str(current).lower()
if "rate limit" in text or "ratelimit" in text or "too many requests" in text:
return True
current = current.__cause__ if isinstance(current.__cause__, BaseException) else None
return False
def _is_transient_http_error(exc: BaseException | None) -> bool:
"""True for retryable transient HTTP failures (timeouts, 5xx, conn resets, ...).
Applies to any HTTP backend, not just gateway/proxy setups.
"""
current: BaseException | None = exc
while current is not None:
if isinstance(current, (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)):
return True
status_code = getattr(current, "status_code", None)
if isinstance(status_code, int) and status_code in {408, 409, 425, 500, 502, 503, 504}:
return True
response = getattr(current, "response", None)
response_status = getattr(response, "status_code", None)
if isinstance(response_status, int) and response_status in {408, 409, 425, 500, 502, 503, 504}:
return True
text = str(current).lower()
if any(
needle in text
for needle in (
"bad gateway",
"gateway timeout",
"server disconnected",
"temporary failure",
"temporarily unavailable",
"connection reset",
"connection aborted",
"timed out",
)
):
return True
current = current.__cause__ if isinstance(current.__cause__, BaseException) else None
return False
def parse_json_output(raw: str, *, action_field: str = "bash_command") -> dict[str, Any]:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Unable to parse JSON output: {exc}") from exc
if not isinstance(parsed, dict):
raise ValueError("Model output was JSON but not a JSON object.")
# Strict-schema responses cannot have done=true with a non-empty action;
# tolerate it from non-strict callers by demoting `done`.
action_text = str(parsed.get(action_field, "") or "").strip()
if action_text and bool(parsed.get("done", False)):
parsed = dict(parsed)
parsed["done"] = False
return parsed
def _validate_bash_command(command: str) -> None:
result = subprocess.run(
["/bin/bash", "-n"],
input=command,
text=True,
capture_output=True,
encoding="utf-8",
errors="replace",
check=False,
)
if result.returncode == 0:
return
error = (result.stderr or result.stdout or "bash syntax check failed").strip()
raise ValueError(f"Invalid bash_command syntax: {error}")
def text_part(text: str) -> dict[str, Any]:
return {"type": "input_text", "text": text}
def image_part_from_path(path: Path) -> dict[str, Any]:
mime_type, _ = mimetypes.guess_type(str(path))
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return {
"type": "input_image",
"image_url": f"data:{mime_type or 'image/png'};base64,{encoded}",
"detail": "high",
}
def _safe_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _request_metrics_from_serialized_input(serialized_input: list[dict[str, Any]]) -> dict[str, int]:
message_count = len(serialized_input)
text_part_count = 0
image_part_count = 0
text_chars = 0
for item in serialized_input:
for content in item.get("content") or []:
if not isinstance(content, dict):
continue
part_type = content.get("type")
if part_type in {"input_text", "output_text"}:
text_part_count += 1
text_chars += len(str(content.get("text", "") or ""))
elif part_type == "input_image":
image_part_count += 1
serialized_chars = len(json.dumps(serialized_input, ensure_ascii=False))
return {
"message_count": message_count,
"text_part_count": text_part_count,
"image_part_count": image_part_count,
"text_chars": text_chars,
"serialized_chars": serialized_chars,
}
_REQUEST_METRIC_KEYS = (
"message_count",
"text_part_count",
"image_part_count",
"text_chars",
"serialized_chars",
)
_USAGE_METRIC_KEYS = (
"input_tokens",
"output_tokens",
"total_tokens",
"cached_input_tokens",
"reasoning_output_tokens",
)
class BaseModelConfig(PydanticBaseModel):
"""Fields common to every model backend (OpenAI, Anthropic, ...)."""
model_name: OptStr = ""
max_output_tokens: int = 4000
request_timeout_seconds: int = 120
error_log_path: Path | None = None
observation_template: OptStr = DEFAULT_OBSERVATION_TEMPLATE
format_error_template: OptStr = DEFAULT_FORMAT_ERROR_TEMPLATE
attach_observation_screenshot: bool = True
action_field: str = "bash_command"
@field_validator("action_field")
@classmethod
def validate_action_field(cls, value: str) -> str:
normalized = value.strip()
if normalized not in ACTION_FIELDS:
raise ValueError(f"action_field must be one of: {', '.join(sorted(ACTION_FIELDS))}")
return normalized
class BaseModel:
"""Provider-agnostic model backend.
Subclasses must override:
- class constants ``_API_KEY_FIELD``, ``_ENV_VAR``, ``_LOG_SOURCE``,
``_DEFAULT_CONFIG_CLASS`` (and optionally ``_MAX_RATE_LIMIT_RETRIES``,
``_MAX_TRANSIENT_RETRIES``)
- ``_request_headers``, ``_post_url``
- ``_build_payload``, ``_request_metrics_input``
- ``_extract_text``, ``_usage_metrics_from_payload``
Optionally override ``_rate_limit_backoff`` / ``_transient_backoff`` for
custom retry timing.
"""
_API_KEY_FIELD: str = ""
_ENV_VAR: str = ""
_LOG_SOURCE: str = ""
_MAX_RATE_LIMIT_RETRIES: int = 5
_MAX_TRANSIENT_RETRIES: int = 5
_DEFAULT_CONFIG_CLASS: type = BaseModelConfig
def __init__(self, *, config_class: type | None = None, **kwargs):
self.config = (config_class or self._DEFAULT_CONFIG_CLASS)(**kwargs)
self._last_request_metrics: dict[str, int] = {k: 0 for k in _REQUEST_METRIC_KEYS}
self._last_usage_metrics: dict[str, int] = {k: 0 for k in _USAGE_METRIC_KEYS}
self._cumulative_request_metrics: dict[str, int] = dict(self._last_request_metrics)
self._cumulative_usage_metrics: dict[str, int] = dict(self._last_usage_metrics)
if self._API_KEY_FIELD:
if not getattr(self.config, self._API_KEY_FIELD, ""):
setattr(self.config, self._API_KEY_FIELD, os.environ.get(self._ENV_VAR, ""))
if not getattr(self.config, self._API_KEY_FIELD, ""):
raise RuntimeError(f"Missing {self._ENV_VAR}.")
# ---- subclass extension points ------------------------------------------------
def _request_headers(self) -> dict[str, str]:
raise NotImplementedError
def _post_url(self) -> str:
raise NotImplementedError
def _build_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
raise NotImplementedError
def _build_text_payload(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
return self._build_payload(messages)
def _request_metrics_input(self, payload: dict[str, Any]) -> list[dict[str, Any]]:
raise NotImplementedError
def _extract_text(self, payload: dict[str, Any]) -> str:
raise NotImplementedError
def _usage_metrics_from_payload(self, payload: dict[str, Any]) -> dict[str, int]:
raise NotImplementedError
async def _rate_limit_backoff(self, attempt: int, exc: BaseException) -> None:
await asyncio.sleep(min(5 * (attempt + 1), 30))
async def _transient_backoff(self, attempt: int, exc: BaseException) -> None:
await asyncio.sleep(min(2 * (attempt + 1), 10))
# ---- shared infrastructure ----------------------------------------------------
def get_template_vars(self, **kwargs) -> dict[str, Any]:
vars: dict[str, Any] = {
"action_field": self.config.action_field,
"model_name": self.config.model_name,
}
for k, v in self._last_request_metrics.items():
vars[f"last_request_{k}"] = v
for k, v in self._last_usage_metrics.items():
vars[f"last_request_{k}"] = v
for k, v in self._cumulative_request_metrics.items():
vars[f"cumulative_request_{k}"] = v
for k, v in self._cumulative_usage_metrics.items():
vars[f"cumulative_{k}"] = v
vars.update(kwargs)
return vars
def _response_schema(self) -> dict[str, Any]:
action_field = self.config.action_field
return {
"type": "object",
"additionalProperties": False,
"properties": {
"thought": {"type": "string"},
action_field: {"type": "string"},
"done": {"type": "boolean"},
"final_response": {"type": "string"},
},
"required": ["thought", action_field, "done", "final_response"],
}
def _usage_snapshot(self) -> dict[str, dict[str, int]]:
return {
"last_request": {
"message_count": self._last_request_metrics["message_count"],
"text_part_count": self._last_request_metrics["text_part_count"],
"image_part_count": self._last_request_metrics["image_part_count"],
"input_tokens": self._last_usage_metrics["input_tokens"],
"cached_input_tokens": self._last_usage_metrics["cached_input_tokens"],
},
"last_response": dict(self._last_usage_metrics),
"cumulative_request": {
"message_count": self._cumulative_request_metrics["message_count"],
"text_part_count": self._cumulative_request_metrics["text_part_count"],
"image_part_count": self._cumulative_request_metrics["image_part_count"],
"input_tokens": self._cumulative_usage_metrics["input_tokens"],
"cached_input_tokens": self._cumulative_usage_metrics["cached_input_tokens"],
},
"cumulative_response": dict(self._cumulative_usage_metrics),
}
def _log_gateway_error(self, *, event: str, attempt: int, error: BaseException) -> None:
response = getattr(error, "response", None)
response_text = ""
if response is not None:
try:
response_text = str(getattr(response, "text", "") or "")
except Exception:
response_text = ""
if len(response_text) > 4000:
response_text = response_text[:4000]
append_runtime_log(
self.config.error_log_path,
source=self._LOG_SOURCE,
event=event,
model_name=self.config.model_name,
endpoint=self._post_url(),
attempt=attempt,
error_type=type(error).__name__,
error=str(error),
status_code=getattr(response, "status_code", None),
response_text=response_text,
)
def _raw_response_log_path(self) -> Path | None:
if self.config.error_log_path is None:
return None
return self.config.error_log_path.parent / "raw_responses.jsonl"
def format_message(self, **kwargs) -> dict[str, Any]:
role = kwargs["role"]
content = kwargs.get("content", "")
extra = kwargs.get("extra", {})
return {"role": role, "content": content, "extra": extra}
def format_observation_messages(
self,
message: dict[str, Any],
outputs: list[dict[str, Any]],
template_vars: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
observation_messages: list[dict[str, Any]] = []
for output in outputs:
observation = output.get("observation", {})
content = Template(self.config.observation_template, undefined=StrictUndefined).render(
output=output,
observation=observation,
**(template_vars or {}),
)
parts: list[dict[str, Any]] = [text_part(content)]
screenshot_path = observation.get("screenshot_path")
if self.config.attach_observation_screenshot and screenshot_path:
parts.append(image_part_from_path(Path(screenshot_path)))
observation_messages.append(
self.format_message(role="user", content=parts, extra={"observation": observation})
)
return observation_messages
def _format_error(self, *, raw_text: str, error: str) -> FormatError:
return FormatError(
self.format_message(
role="user",
content=Template(self.config.format_error_template, undefined=StrictUndefined).render(
error=error,
model_response=raw_text,
**self.get_template_vars(),
),
extra={
"interrupt_type": "FormatError",
"model_response": raw_text,
},
)
)
def _format_repair_message(self, *, raw_text: str, error: str) -> dict[str, Any]:
return self.format_message(
role="user",
content=Template(self.config.format_error_template, undefined=StrictUndefined).render(
error=error,
model_response=raw_text,
**self.get_template_vars(),
),
extra={
"interrupt_type": "FormatErrorRetry",
"model_response": raw_text,
},
)
async def _post_with_retries(self, payload: dict[str, Any]) -> dict[str, Any]:
headers = self._request_headers()
url = self._post_url()
for attempt in range(max(self._MAX_RATE_LIMIT_RETRIES, self._MAX_TRANSIENT_RETRIES) + 1):
try:
async with httpx.AsyncClient(timeout=self.config.request_timeout_seconds) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
return response.json()
except Exception as exc:
if _is_rate_limit_error(exc):
self._log_gateway_error(
event="rate_limit_error", attempt=attempt + 1, error=exc
)
if attempt >= self._MAX_RATE_LIMIT_RETRIES:
raise
await self._rate_limit_backoff(attempt, exc)
continue
if _is_transient_http_error(exc):
self._log_gateway_error(
event="transient_http_error", attempt=attempt + 1, error=exc
)
if attempt >= self._MAX_TRANSIENT_RETRIES:
raise
await self._transient_backoff(attempt, exc)
continue
self._log_gateway_error(
event="fatal_gateway_error", attempt=attempt + 1, error=exc
)
raise
raise RuntimeError("Exceeded retry budget without exception or success.")
async def _query_async(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
last_error: ValueError | None = None
raw_text = ""
request_messages = list(messages)
for attempt_index in range(MAX_JSON_PARSE_RETRIES + 1):
payload = self._build_payload(request_messages)
request_metrics = _request_metrics_from_serialized_input(self._request_metrics_input(payload))
self._last_request_metrics = dict(request_metrics)
for key, value in request_metrics.items():
self._cumulative_request_metrics[key] += value
response_payload = await self._post_with_retries(payload)
usage_metrics = self._usage_metrics_from_payload(response_payload)
self._last_usage_metrics = dict(usage_metrics)
for key, value in usage_metrics.items():
self._cumulative_usage_metrics[key] += value
raw_text = self._extract_text(response_payload)
append_runtime_log(
self._raw_response_log_path(),
source="model",
event="raw_text",
attempt=attempt_index + 1,
raw_text=raw_text,
)
try:
parsed = parse_json_output(raw_text, action_field=self.config.action_field)
break
except ValueError as exc:
last_error = exc
if attempt_index < MAX_JSON_PARSE_RETRIES:
request_messages.append(
self._format_repair_message(raw_text=raw_text, error=str(exc))
)
else:
raise self._format_error(
raw_text=raw_text,
error=str(last_error or ValueError("Unable to parse model output.")),
)
actions: list[dict[str, Any]] = []
action_field = self.config.action_field
action_text = str(parsed.get(action_field, "") or "").strip()
if action_text:
action = {action_field: action_text, "command": action_text}
if action_field == "bash_command":
action["bash_command"] = action_text
try:
_validate_bash_command(action_text)
except ValueError as exc:
raise self._format_error(raw_text=raw_text, error=str(exc))
else:
action["python_code"] = action_text
actions.append(action)
return self.format_message(
role="assistant",
content=parsed.get("thought", ""),
extra={
"actions": actions,
"done": bool(parsed.get("done", False)),
"final_response": parsed.get("final_response", ""),
"raw_response": parsed,
"usage": self._usage_snapshot(),
},
)
async def _complete_text_async(
self,
messages: list[dict[str, Any]],
*,
max_output_tokens: int | None = None,
) -> str:
original_max_output_tokens = self.config.max_output_tokens
if max_output_tokens is not None:
self.config.max_output_tokens = max_output_tokens
try:
payload = self._build_text_payload(messages)
request_metrics = _request_metrics_from_serialized_input(self._request_metrics_input(payload))
self._last_request_metrics = dict(request_metrics)
for key, value in request_metrics.items():
self._cumulative_request_metrics[key] += value
response_payload = await self._post_with_retries(payload)
usage_metrics = self._usage_metrics_from_payload(response_payload)
self._last_usage_metrics = dict(usage_metrics)
for key, value in usage_metrics.items():
self._cumulative_usage_metrics[key] += value
raw_text = self._extract_text(response_payload)
append_runtime_log(
self._raw_response_log_path(),
source="model",
event="raw_text",
raw_text=raw_text,
)
return raw_text
finally:
self.config.max_output_tokens = original_max_output_tokens
def __call__(
self,
messages: list[dict[str, Any]],
**kwargs: Any,
) -> str:
return run_async(self._complete_text_async(messages, **kwargs))
def query(self, messages: list[dict[str, Any]], **kwargs) -> dict[str, Any]:
return run_async(self._query_async(messages))
def serialize(self) -> dict[str, Any]:
config_dump = self.config.model_dump(mode="json")
if self._API_KEY_FIELD:
config_dump[self._API_KEY_FIELD] = "<redacted>"
return {
"model": {
"config": config_dump,
"usage": {
**self._usage_snapshot(),
},
"model_type": f"{self.__class__.__module__}.{self.__class__.__name__}",
}
}