"""Async HTTP client for Alloy Runtime server."""

import asyncio
import json
import logging
import time
from collections.abc import AsyncGenerator
from importlib.metadata import PackageNotFoundError, version
from types import TracebackType
from typing import Any, BinaryIO, Literal, TypeVar, cast, overload
from uuid import UUID

import httpx
import structlog
from pydantic import BaseModel
from alloy_runtime_sdk.logging.protocol import (
    ClientLogger,
    generate_trace_id,
    get_null_logger,
)
from alloy_runtime_sdk.pipeline.types import ApprovalCheckpoint

from alloy_runtime_sdk.exceptions.errors import (
    AuthenticationError,
    CollectionClusteringError,
    CollectionClusteringTimeout,
    ConflictError,
    DocumentProcessingError,
    DocumentProcessingTimeout,
    ForbiddenError,
    GenerationFailedError,
    GenerationTimeoutError,
    NotFoundError,
    RateLimitError,
    RequestError,
    ServerError,
    ValidationError,
)
from alloy_runtime_types.dtos.admin import SyncModelsResponse
from alloy_runtime_types.dtos.agents import (
    AgentSummary,
    CreateAgentRequest,
    CreateAgentResponse,
    DeleteAgentResponse,
    GetAgentResponse,
    ListAgentsResponse,
    UpdateAgentRequest,
    UpdateAgentResponse,
)
from alloy_runtime_types.dtos.api_keys import (
    CreateApiKeyRequest,
    CreateApiKeyResponse,
)
from alloy_runtime_types.dtos.content import (
    ContentPartItemResponse,
    DeleteContentPartResponse,
    ListContentPartsResponse,
    UpdateContentPartRequest,
    UpdateContentPartResponse,
)
from alloy_runtime_types.dtos.credential_list import (
    ListOrganizationCredentialsResponse,
    ListSystemCredentialsResponse,
)
from alloy_runtime_types.dtos.credentials import (
    CreateSystemCredentialRequest,
    GrantSystemCredentialRequest,
    GrantSystemCredentialResponse,
    SystemCredentialResponse,
    UpdateCredentialRequest,
)
from alloy_runtime_types.dtos.organization_credentials import (
    CreateOrganizationCredentialRequest,
    OrganizationCredentialResponse,
)
from alloy_runtime_types.dtos.generation import (
    AsyncGenerationCancelResponse,
    AsyncGenerationStatusResponse,
    AsyncGenerationSubmitResponse,
    GenerateTextRequest,
    GenerateTextResponse,
    GenerateTextStreamChunk,
)
from alloy_runtime_types.dtos.executions import GetExecutionResponse
from alloy_runtime_types.dtos.models import ListModelsResponse
from alloy_runtime_types.dtos.organizations import (
    CreateOrganizationRequest,
    OrganizationResponse,
)
from alloy_runtime_types.dtos.structured_filter import ConditionSpec, LogicalSpec
from alloy_runtime_types.enums.content_enums import (
    ContentSortField,
    ContentStorageType,
    SortOrder,
)
from alloy_runtime_types.dtos.schemas import (
    CreateSchemaRequest,
    CreateSchemaResponse,
    DeleteSchemaResponse,
    GetSchemaResponse,
    ListSchemasResponse,
    SchemaSummary,
    UpdateSchemaRequest,
    UpdateSchemaResponse,
)
from alloy_runtime_types.dtos.templates import (
    CreateTemplateRequest,
    CreateTemplateResponse,
    CreateTemplateVersionRequest,
    CreateTemplateVersionResponse,
    DeleteTemplateResponse,
    GetTemplateResponse,
    ListTemplatesResponse,
    RenderTemplateRequest,
    RenderTemplateResponse,
    TemplateListItemResponse,
    UpdateTemplateRequest,
    UpdateTemplateResponse,
    ValidateTemplateRequest,
    ValidateTemplateResponse,
)
from alloy_runtime_types.enums.template_enums import (
    TemplateContentType,
    TemplateVisibility,
)
from alloy_runtime_types.dtos.users import (
    CreateUserRequest,
    CreateUserResponse,
    MeResponse,
    PingResponse,
)
from alloy_runtime_types.dtos.auth import (
    BootstrapAdminRequest,
    BootstrapAdminResponse,
    LoginRequest,
    LoginResponse,
    SignupRequest,
    SignupResponse,
)
from alloy_runtime_types.dtos.messages import (
    DeleteMessageResponse,
    MessageDetailResponse,
    UpdateMessageRequest,
)
from alloy_runtime_types.dtos.sessions import (
    CreateSessionRequest,
    DeleteSessionResponse,
    ListSessionMessagesResponse,
    ListSessionsResponse,
    MessageResponse,
    SessionDetailResponse,
    SessionSummary,
    UpdateSessionRequest,
)
from alloy_runtime_types.dtos.pipeline import (
    CreatePipelineRequest,
    CreatePipelineResponse,
    ExecutePipelineRequest,
    ExecutePipelineResponse,
    GetPipelineExecutionResponse,
    GetPipelineResponse,
    ListPipelineExecutionsResponse,
    ListPipelinesResponse,
    PipelineExecutionSummary,
    PipelineSummary,
    RegisterLocalPipelineExecutionRequest,
    RegisterLocalPipelineExecutionResponse,
    SetPipelineEnvVarsRequest,
    SetPipelineEnvVarsResponse,
    UpdateLocalPipelineExecutionRequest,
    UpdateLocalPipelineExecutionResponse,
    UpdatePipelineRequest,
    UpdatePipelineResponse,
    UpdatePipelineExecutionRequest,
    UpdatePipelineExecutionResponse,
)
from alloy_runtime_types.dtos.pipeline_costs import (
    DailyCostEntry,
    ModelCostBreakdown,
    PipelineDefinitionCostSummary,
    PipelineExecutionCostSummary,
)
from alloy_runtime_types.dtos.schedule import (
    CreateScheduleRequest,
    CreateScheduleResponse,
    DeleteScheduleResponse,
    GetScheduleResponse,
    ListSchedulesResponse,
    ScheduleOnceRequest,
    ScheduleOnceResponse,
    ScheduleSummary,
    SetScheduleEnvVarsRequest,
    SetScheduleEnvVarsResponse,
    UpdateScheduleRequest,
    UpdateScheduleResponse,
)
from alloy_runtime_types.dtos.billing import (
    CreateProjectRequest,
    CreateProjectResponse,
    ProjectCostByAgent,
    ProjectCostByModel,
    ProjectCostSummary,
    ProjectDailyCostEntry,
    ProjectListItem,
    ProjectListResponse,
    ProjectResponse,
)
from alloy_runtime_types.dtos.tags import (
    CreateTagRequest,
    CreateTagResponse,
    DeleteTagResponse,
    GetTagResponse,
    ListTagsResponse,
    TagResponse,
    UpdateTagRequest,
    UpdateTagResponse,
)
from alloy_runtime_types.dtos.render import (
    RenderHtmlToImageRequest,
    RenderHtmlToImageResponse,
)
from alloy_runtime_types.dtos.audio import (
    AudioTranscriptionBackend,
    AudioTranscriptionJSONOutput,
    AudioTranscriptionOutputFormat,
    AudioTranscriptionStatusResponse,
    AudioTranscriptionSubmitRequest,
    AudioTranscriptionSubmitResponse,
)
from alloy_runtime_types.dtos.knowledge import (
    BulkReingestFailedDocumentsRequest,
    BulkReingestFailedDocumentsResponse,
    CreateCollectionRequest,
    CreateCollectionResponse,
    CreateSynthesisRequest,
    CreateSynthesisResponse,
    DeleteCollectionResponse,
    BulkUpdateDocumentMetadataRequest,
    BulkUpdateDocumentMetadataResponse,
    DeleteDocumentResponse,
    DeleteSynthesisResponse,
    GetCollectionClusteringStatusResponse,
    GetCollectionResponse,
    GetDocumentResponse,
    GetSynthesisResponse,
    IngestDocumentRequest,
    IngestDocumentResponse,
    is_terminal_status,
    ListCollectionDocumentStatusesResponse,
    ListCollectionsResponse,
    ListDocumentsResponse,
    ListSynthesesResponse,
    ProcessingStatus,
    RecoverDocumentsRequest,
    RecoverDocumentsResponse,
    RefreshSynthesisResponse,
    ReingestDocumentRequest,
    ReingestDocumentResponse,
    UpdateDocumentMetadataRequest,
    UpdateDocumentMetadataResponse,
    SearchRequest,
    SearchResponse,
    TriggerCollectionClusteringResponse,
    UpdateCollectionRequest,
    UpdateCollectionResponse,
    UpdateSynthesisRequest,
    UpdateSynthesisResponse,
    is_terminal_clustering_status,
)
from alloy_runtime_types.dtos.tool_configs import (
    CreateToolConfigRequest,
    CreateToolConfigResponse,
    DeleteToolConfigResponse,
    GetToolConfigResponse,
    ListToolConfigsResponse,
    UpdateToolConfigRequest,
    UpdateToolConfigResponse,
)
from alloy_runtime_types.dtos.tools import (
    ListToolsResponse,
    ToolResponse,
)
from alloy_runtime_sdk.api_client.pagination import (
    AsyncCursorPaginator,
    AsyncOffsetPaginator,
)

T = TypeVar("T", bound=BaseModel)


# Silence httpx's internal logging to avoid duplicate logs
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)


def _installed_package_version(package_name: str) -> str | None:
    try:
        return version(package_name)
    except PackageNotFoundError:
        return None


def _ping_dependency_versions() -> dict[str, str | None]:
    return {
        "sdk_version": _installed_package_version("alloy-runtime-sdk"),
        "types_version": _installed_package_version("alloy-runtime-types"),
    }


def _extract_resource_info(
    path: str,
    method: str,
    json_data: dict[str, Any] | None,
    params: dict[str, Any] | None,
) -> dict[str, Any]:
    """Extract meaningful resource identifiers from request for logging.

    Returns a dict with resource-specific fields like:
    - resource: the type of resource (agents, templates, etc.)
    - name: resource name if available
    - id: resource ID if in path
    - search: search term if querying
    - model/provider: for generation requests
    """
    info: dict[str, Any] = {}

    # Extract resource type from path (e.g., /api/v1/agents -> agents)
    path_parts = [
        p for p in path.split("/") if p and p != "api" and not p.startswith("v")
    ]
    if path_parts:
        info["resource"] = path_parts[0]

        # If there's a second part, it's likely an ID or identifier
        if len(path_parts) > 1 and path_parts[1] not in (
            "versions",
            "render",
            "execute",
            "messages",
            "text",  # /generate/text
        ):
            info["resource_id"] = path_parts[1][:12]  # Truncate long UUIDs

    # Extract name from request body for create/update operations
    if json_data and method in ("POST", "PUT", "PATCH"):
        if "name" in json_data:
            info["name"] = json_data["name"]

        # Handle model_source for generation requests
        model_source = json_data.get("model_source")
        if model_source and isinstance(model_source, dict):
            # AgentSource: has "agent" field
            if "agent" in model_source:
                info["agent"] = model_source["agent"]
            # DirectModelSource: has "provider_key" and "provider_model_name"
            if "provider_key" in model_source:
                info["provider"] = model_source["provider_key"]
            if "provider_model_name" in model_source:
                info["model"] = model_source["provider_model_name"]

        # User message preview
        if "user_message" in json_data:
            msg = json_data["user_message"]
            if isinstance(msg, str):
                info["message_preview"] = msg[:50] + "..." if len(msg) > 50 else msg

    # Extract search/filter params for list operations
    if params:
        if "search" in params:
            info["search"] = params["search"]
        if "limit" in params:
            info["limit"] = params["limit"]

    return info


def _extract_response_info(
    response_data: dict[str, Any] | BaseModel | None,
    resource: str | None = None,
) -> dict[str, Any]:
    """Extract meaningful info from response for logging.

    Returns fields like count for lists, id/name for single resources,
    and generation-specific info (tokens, cost, model) for generate responses.

    Args:
        response_data: The response data to extract info from.
        resource: Resource type hint (e.g., "generate", "agents").
    """
    info: dict[str, Any] = {}

    if response_data is None:
        return info

    # Convert Pydantic model to dict if needed
    data: dict[str, Any]
    if isinstance(response_data, BaseModel):
        data = response_data.model_dump()
    else:
        data = response_data

    # Generation response - extract model, tokens, and cost
    if resource == "generate" or "output_text" in data:
        if "provider_key" in data:
            info["provider"] = data["provider_key"]
        if "provider_model_name" in data:
            info["model"] = data["provider_model_name"]
        if "finish_reason" in data:
            info["finish_reason"] = data["finish_reason"]

        # Token usage
        usage = data.get("usage")
        if usage and isinstance(usage, dict):
            usage_dict = cast(dict[str, Any], usage)
            info["input_tokens"] = usage_dict.get("input_tokens")
            info["output_tokens"] = usage_dict.get("output_tokens")

        # Cost
        cost = data.get("cost")
        if cost and isinstance(cost, dict):
            cost_dict = cast(dict[str, Any], cost)
            total_cost: Any = cost_dict.get("total_cost_usd")
            if total_cost is not None:
                # Format as string to preserve decimal precision
                info["cost_usd"] = str(total_cost)

        # Execution/session IDs
        if "execution_id" in data:
            info["execution_id"] = str(data["execution_id"])
        if "chat_session_id" in data:
            info["session_id"] = str(data["chat_session_id"])[:12]

        return info

    # List responses - extract count
    for list_key in (
        "agents",
        "templates",
        "schemas",
        "models",
        "sessions",
        "pipelines",
        "content_parts",
        "messages",
    ):
        if list_key in data and isinstance(data[list_key], list):
            info["count"] = len(data[list_key])
            if "total" in data:
                info["total"] = data["total"]
            break

    # Single resource responses - extract id and name
    if "id" in data:
        info["id"] = str(data["id"])[:12]  # Truncate UUID
    if "name" in data:
        info["name"] = data["name"]
    if "agent_id" in data:
        info["agent_id"] = str(data["agent_id"])[:12]
    if "execution_id" in data:
        info["execution_id"] = str(data["execution_id"])
    if "chat_session_id" in data:
        info["session_id"] = str(data["chat_session_id"])[:12]

    return info


def _round_ms(value: float | None) -> float | None:
    if value is None:
        return None
    return round(value, 1)


def _decode_http_version(value: object) -> str | None:
    if isinstance(value, bytes):
        return value.decode("utf-8")
    if isinstance(value, str):
        return value
    return None


def _extract_tls_version(response: httpx.Response) -> str | None:
    network_stream = response.extensions.get("network_stream")
    get_extra_info = getattr(network_stream, "get_extra_info", None)
    if not callable(get_extra_info):
        return None

    ssl_object = get_extra_info("ssl_object")
    version = getattr(ssl_object, "version", None)
    if not callable(version):
        return None

    tls_version = version()
    if isinstance(tls_version, str):
        return tls_version
    return None


def _mask_api_key_prefix(api_key: str) -> str | None:
    normalized = api_key.strip()
    if not normalized:
        return None
    visible_chars = min(20, max(4, len(normalized) - 4))
    return f"{normalized[:visible_chars]}..."


class _PingTraceCollector:
    def __init__(self, request_started_at: float):
        self._request_started_at = request_started_at
        self._events: dict[str, dict[str, float]] = {}

    async def on_event(self, name: str, info: dict[str, object]) -> None:
        del info
        event_name, _, stage = name.rpartition(".")
        if not event_name or not stage:
            return
        event = self._events.setdefault(event_name, {})
        event[stage] = time.perf_counter()

    def duration_ms(self, *event_names: str) -> float | None:
        for event_name in event_names:
            event = self._events.get(event_name)
            if event is None:
                continue
            started = event.get("started")
            completed = event.get("complete")
            if started is None or completed is None:
                continue
            return _round_ms((completed - started) * 1000)
        return None

    def ttfb_ms(self) -> float | None:
        for event_name in (
            "http11.receive_response_headers",
            "http2.receive_response_headers",
        ):
            event = self._events.get(event_name)
            if event is None:
                continue
            completed = event.get("complete")
            if completed is None:
                continue
            return _round_ms((completed - self._request_started_at) * 1000)
        return None


class ApiClient:
    """Type-safe async HTTP client for Alloy Runtime server.

    This client provides strictly typed methods for all server endpoints.
    Direct HTTP access is not exposed - all requests must go through typed endpoint methods.

    Usage:
        async with ApiClient(base_url="https://api.example.com", api_key="sk_...") as client:
            user = await client.create_user(CreateUserRequest(...))

        # With logging (JSON format depends on logger configuration)
        from alloy_runtime_sdk.logging.config import configure, get_logger, LogConfig, LogFormat
        configure(LogConfig(format=LogFormat.JSON, level=LogLevel.DEBUG))
        logger = get_logger()

        async with ApiClient(base_url="...", api_key="...", logger=logger) as client:
            user = await client.create_user(CreateUserRequest(...))

    Note:
        The client intentionally does not expose generic HTTP methods (get, post, put, etc).
        All API calls must use the typed endpoint methods to ensure type safety and consistency.
    """

    __slots__ = ("_base_url", "_api_key", "_timeout", "_api_base", "_client", "_logger")

    def __init__(
        self,
        base_url: str,
        api_key: str,
        timeout: float | None = None,
        api_version: str = "v1",
        logger: ClientLogger | None = None,
    ):
        """Initialize the server client.

        Args:
            base_url: Base URL of the server (e.g., "https://api.example.com")
            api_key: API key for authentication (Bearer token)
            timeout: Request timeout in seconds (default: None - no timeout)
            api_version: API version string (default: "v1")
            logger: Optional logger for request/response logging. If not provided,
                    logging is disabled. Pass any logger with info/debug/warning/error
                    methods (e.g., structlog, stdlib logging.Logger).
                    The logger's format (JSON, console) is determined by its configuration.
        """
        self._base_url = base_url.rstrip("/")
        self._api_key = api_key
        self._timeout = timeout
        self._api_base = f"/api/{api_version}"
        self._client: httpx.AsyncClient | None = None
        self._logger: ClientLogger = logger if logger is not None else get_null_logger()

    async def __aenter__(self) -> "ApiClient":
        """Async context manager entry."""
        # Only add Authorization header if API key is provided
        headers: dict[str, str] = {}
        if self._api_key and self._api_key.strip():
            headers["Authorization"] = f"Bearer {self._api_key}"

        self._client = httpx.AsyncClient(
            base_url=self._base_url,
            headers=headers,
            timeout=self._timeout,
        )
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: TracebackType | None,
    ) -> None:
        """Async context manager exit."""
        _, _, _ = exc_type, exc_val, exc_tb  # Required by __aexit__ protocol
        await self.close()

    async def close(self) -> None:
        """Close the HTTP client.

        Call this method when using the client without a context manager.
        If using 'async with', this is called automatically.
        """
        if self._client:
            await self._client.aclose()
            self._client = None

    def _ensure_client(self) -> httpx.AsyncClient:
        """Ensure client is initialized.

        Lazily creates the httpx client if not already initialized.
        This allows the client to be used without a context manager,
        but close() must be called manually in that case.
        """
        if not self._client:
            # Only add Authorization header if API key is provided
            headers: dict[str, str] = {}
            if self._api_key and self._api_key.strip():
                headers["Authorization"] = f"Bearer {self._api_key}"

            self._client = httpx.AsyncClient(
                base_url=self._base_url,
                headers=headers,
                timeout=self._timeout,
            )
        return self._client

    @overload
    async def _request(
        self,
        method: str,
        path: str,
        *,
        json_data: dict[str, Any] | None = None,
        data: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        headers: dict[str, str] | None = None,
        timeout: float | None = None,
        response_model: type[T],
    ) -> T: ...

    @overload
    async def _request(
        self,
        method: str,
        path: str,
        *,
        json_data: dict[str, Any] | None = None,
        data: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        headers: dict[str, str] | None = None,
        timeout: float | None = None,
        response_model: None = None,
    ) -> dict[str, Any]: ...

    async def _request(
        self,
        method: str,
        path: str,
        *,
        json_data: dict[str, Any] | None = None,
        data: dict[str, Any] | None = None,
        params: dict[str, Any] | None = None,
        headers: dict[str, str] | None = None,
        timeout: float | None = None,
        response_model: type[T] | None = None,
    ) -> T | dict[str, Any]:
        """Internal HTTP request method - not part of public API.

        This method is intentionally private to enforce usage of typed endpoint methods.
        Direct HTTP access is not exposed to prevent untyped API calls.

        Args:
            method: HTTP method (GET, POST, PUT, PATCH, DELETE)
            path: API path (e.g., "/users")
            json_data: JSON request body
            params: Query parameters
            response_model: Pydantic model for response validation

        Returns:
            Parsed response (Pydantic model or dict)

        Raises:
            AuthenticationError: 401 response
            ForbiddenError: 403 response
            NotFoundError: 404 response
            ValidationError: 422 response
            ConflictError: 409 response
            ServerError: 5xx response
            RequestError: Network/timeout errors
        """
        client = self._ensure_client()

        trace_id = generate_trace_id()
        start_time = time.perf_counter()
        structlog.contextvars.bind_contextvars(trace_id=trace_id)

        # Extract resource info for logging
        resource_info = _extract_resource_info(path, method, json_data or data, params)

        # Log request (INFO: basic info, DEBUG: full body)
        self._logger.info(
            "http_request",
            trace_id=trace_id,
            method=method,
            path=self._api_base + path,
            **resource_info,
        )
        self._logger.debug(
            "http_request_detail",
            trace_id=trace_id,
            params=params,
            body=json_data,
        )

        try:
            request_headers = {**(headers or {}), "X-Trace-ID": trace_id}
            response = await client.request(
                method=method,
                url=self._api_base + path,
                json=json_data,
                data=data,
                params=params,
                headers=request_headers,
                timeout=timeout,
            )

            duration_ms = int((time.perf_counter() - start_time) * 1000)

            # Handle error responses
            if response.status_code >= 400:
                # Parse error response once (body can only be read once)
                error_detail, error_data = self._parse_error_response(response)
                self._logger.error(
                    "http_response_error",
                    trace_id=trace_id,
                    method=method,
                    path=self._api_base + path,
                    status=response.status_code,
                    duration_ms=duration_ms,
                    error=error_detail,
                    **resource_info,
                )
                self._raise_for_status(response.status_code, error_detail, error_data)

            # Parse successful response
            content_type = response.headers.get("content-type", "").lower()
            if "text/event-stream" in content_type:
                response_data = self._parse_sse_terminal_payload(response.text)
            else:
                response_data = (
                    cast(dict[str, Any], response.json()) if response.content else {}
                )

            result: T | dict[str, Any]
            if response_model:
                result = response_model.model_validate(response_data)
            else:
                result = response_data

            # Extract response info for logging
            response_info = _extract_response_info(
                result, resource_info.get("resource")
            )

            # Log response (INFO: status/duration/count, DEBUG: full response)
            self._logger.info(
                "http_response",
                trace_id=trace_id,
                method=method,
                path=self._api_base + path,
                status=response.status_code,
                duration_ms=duration_ms,
                **response_info,
            )
            self._logger.debug(
                "http_response_detail",
                trace_id=trace_id,
                body=response_data,
            )

            return result

        except httpx.RequestError as e:
            duration_ms = int((time.perf_counter() - start_time) * 1000)
            self._logger.error(
                "http_request_failed",
                trace_id=trace_id,
                duration_ms=duration_ms,
                error=str(e),
                error_type=type(e).__name__,
                **resource_info,
            )
            raise RequestError(f"Request failed: {e}") from e

    def _parse_error_response(
        self, response: httpx.Response
    ) -> tuple[str, dict[str, Any] | None]:
        """Parse error response body once and return detail + raw data.

        Returns:
            Tuple of (error_detail for logging, error_data dict or None)
        """
        error_data: dict[str, Any] | None = None
        detail: str = "Unknown error"
        try:
            parsed = response.json()
            if isinstance(parsed, dict):
                error_data = cast(dict[str, Any], parsed)
                detail_value: Any = error_data.get("detail", "Unknown error")
                # Handle nested error structures
                if isinstance(detail_value, dict):
                    detail_dict = cast(dict[str, Any], detail_value)
                    detail = str(detail_dict.get("message", detail_dict))
                elif isinstance(detail_value, str):
                    detail = detail_value
                else:
                    detail = str(detail_value)
            else:
                detail = str(parsed) if parsed else "Unknown error"
        except Exception:
            detail = response.text or "Unknown error"

        # Truncate very long error messages for logging
        if len(detail) > 500:
            detail = detail[:500] + "..."

        return detail, error_data

    def _parse_sse_terminal_payload(self, sse_body: str) -> dict[str, Any]:
        """Parse an SSE response body and return the terminal event payload.

        Scans SSE lines for ``event:`` / ``data:`` field pairs.  Returns the
        payload from the first ``done`` event, or falls back to the latest
        successfully parsed data payload.  ``error`` events are raised as the
        appropriate SDK exception (mapped by ``status_code`` when present,
        otherwise ``ServerError``).

        Limitations:
            Each ``data:`` line is parsed independently as single-line JSON.
            Multi-line ``data:`` fields (consecutive ``data:`` lines per the
            WHATWG SSE spec) are not concatenated.  This is safe because the
            server always emits single-line JSON payloads.
        """
        current_event_type: str | None = None
        latest_payload: dict[str, Any] | None = None

        for raw_line in sse_body.splitlines():
            line = raw_line.strip()
            if not line:
                continue

            if line.startswith(":"):
                continue

            if line.startswith("event:"):
                current_event_type = line[6:].strip()
                continue

            if not line.startswith("data:"):
                continue

            payload_text = line[5:].strip()
            try:
                parsed = json.loads(payload_text)
            except json.JSONDecodeError:
                current_event_type = None
                continue

            if not isinstance(parsed, dict):
                current_event_type = None
                continue

            parsed_dict = cast(dict[str, Any], parsed)

            event_type = current_event_type or "message"
            current_event_type = None

            if event_type == "error":
                error_message = parsed_dict.get("error", str(parsed_dict))
                status_code = parsed_dict.get("status_code")
                if isinstance(status_code, int):
                    self._raise_for_status(status_code, error_message, parsed_dict)
                raise ServerError(f"Stream error: {error_message}")

            if event_type == "done":
                return parsed_dict

            latest_payload = parsed_dict

        if latest_payload is not None:
            return latest_payload

        raise ServerError("Stream response ended without terminal payload")

    def _raise_for_status(
        self,
        status_code: int,
        detail: str,
        error_data: dict[str, Any] | None,
    ) -> None:
        """Raise appropriate exception based on status code."""
        if status_code == 401:
            raise AuthenticationError(detail, error_data=error_data)
        elif status_code == 403:
            raise ForbiddenError(detail, error_data=error_data)
        elif status_code == 404:
            raise NotFoundError(detail, error_data=error_data)
        elif status_code == 409:
            raise ConflictError(detail, error_data=error_data)
        elif status_code == 429:
            raise RateLimitError(
                f"Rate limit exceeded: {detail}", error_data=error_data
            )
        elif status_code == 422:
            errors: list[dict[str, Any]] = []
            if isinstance(error_data, dict):
                # FastAPI returns validation errors in "detail" as a list
                detail_field = error_data.get("detail")
                if isinstance(detail_field, list):
                    errors = cast(list[dict[str, Any]], detail_field)
                else:
                    # Fallback to legacy "errors" field
                    legacy_errors = error_data.get("errors")
                    if isinstance(legacy_errors, list):
                        errors = cast(list[dict[str, Any]], legacy_errors)
            raise ValidationError(detail, errors=errors, error_data=error_data)
        elif status_code >= 500:
            raise ServerError(
                f"Server error ({status_code}): {detail}", error_data=error_data
            )
        else:
            raise ServerError(f"HTTP {status_code}: {detail}", error_data=error_data)

    async def _upload(
        self,
        method: str,
        path: str,
        file: BinaryIO | bytes,
        field_name: str,
        filename: str | None,
        content_type: str,
        response_model: type[T],
        headers: dict[str, str] | None = None,
        data: dict[str, str] | None = None,
    ) -> T:
        client = self._ensure_client()

        trace_id = generate_trace_id()
        start_time = time.perf_counter()
        structlog.contextvars.bind_contextvars(trace_id=trace_id)

        self._logger.info(
            "http_upload_request",
            trace_id=trace_id,
            method=method,
            path=self._api_base + path,
            filename=filename,
            content_type=content_type,
        )

        try:
            upload_headers = {**(headers or {}), "X-Trace-ID": trace_id}
            response = await client.request(
                method=method,
                url=self._api_base + path,
                files={field_name: (filename, file, content_type)},
                headers=upload_headers,
                data=data,
            )

            duration_ms = int((time.perf_counter() - start_time) * 1000)

            if response.status_code >= 400:
                error_detail, error_data = self._parse_error_response(response)
                self._logger.error(
                    "http_upload_error",
                    trace_id=trace_id,
                    method=method,
                    path=self._api_base + path,
                    status=response.status_code,
                    duration_ms=duration_ms,
                    error=error_detail,
                )
                self._raise_for_status(response.status_code, error_detail, error_data)

            response_content_type = response.headers.get("content-type", "").lower()
            if "text/event-stream" in response_content_type:
                response_data = self._parse_sse_terminal_payload(response.text)
            else:
                response_data = cast(
                    dict[str, Any], response.json() if response.content else {}
                )
            result = response_model.model_validate(response_data)

            self._logger.info(
                "http_upload_response",
                trace_id=trace_id,
                method=method,
                path=self._api_base + path,
                status=response.status_code,
                duration_ms=duration_ms,
            )

            return result

        except httpx.RequestError as e:
            duration_ms = int((time.perf_counter() - start_time) * 1000)
            self._logger.error(
                "http_upload_failed",
                trace_id=trace_id,
                duration_ms=duration_ms,
                error=str(e),
                error_type=type(e).__name__,
            )
            raise RequestError(f"Upload failed: {e}") from e

    # ========================================
    # User Endpoints
    # ========================================

    async def get_me(self) -> "MeResponse":
        """Get current authenticated user information.

        Returns basic information about the user associated with the API key,
        including their organization (if any).

        Returns:
            Current user information

        Raises:
            AuthenticationError: Invalid or missing API key
            ServerError: Server error
        """
        return await self._request(
            "GET",
            "/users/me",
            response_model=MeResponse,
        )

    async def get_ping(self) -> PingResponse:
        client = self._ensure_client()
        trace_id = generate_trace_id()
        start_time = time.perf_counter()
        trace = _PingTraceCollector(start_time)

        try:
            response = await client.request(
                method="GET",
                url=self._api_base + "/users/ping",
                headers={"X-Trace-ID": trace_id},
                extensions={"trace": trace.on_event},
            )
        except httpx.RequestError as e:
            raise RequestError(f"Request failed: {e}") from e

        duration_ms = _round_ms((time.perf_counter() - start_time) * 1000)

        if response.status_code >= 400:
            error_detail, error_data = self._parse_error_response(response)
            self._raise_for_status(response.status_code, error_detail, error_data)

        response_data = (
            cast(dict[str, Any], response.json()) if response.content else {}
        )

        connect_ms = trace.duration_ms("connection.connect_tcp")
        tls_ms = trace.duration_ms("connection.start_tls")
        ttfb_ms = trace.ttfb_ms()
        wait_ms = None
        if ttfb_ms is not None:
            wait_ms = _round_ms(
                max(ttfb_ms - (connect_ms or 0.0) - (tls_ms or 0.0), 0.0)
            )
        transfer_ms = None
        if duration_ms is not None and ttfb_ms is not None:
            transfer_ms = _round_ms(max(duration_ms - ttfb_ms, 0.0))

        response_data.update(
            api_key_prefix=_mask_api_key_prefix(self._api_key),
            api_host=httpx.URL(self._base_url).host,
            http_status=response.status_code,
            http_version=_decode_http_version(response.extensions.get("http_version")),
            tls_version=_extract_tls_version(response),
            **_ping_dependency_versions(),
            connect_ms=connect_ms,
            tls_ms=tls_ms,
            ttfb_ms=ttfb_ms,
            wait_ms=wait_ms,
            transfer_ms=transfer_ms,
            total_ms=duration_ms,
        )

        return PingResponse.model_validate(response_data)

    async def create_user(self, request: CreateUserRequest) -> CreateUserResponse:
        """Create a new user.

        Args:
            request: User creation request

        Returns:
            Created user
        """
        return await self._request(
            "POST",
            "/users",
            json_data=request.model_dump(mode="json"),
            response_model=CreateUserResponse,
        )

    # ========================================
    # Organization Endpoints
    # ========================================

    async def create_organization(
        self, request: CreateOrganizationRequest
    ) -> OrganizationResponse:
        """Create a new organization.

        Args:
            request: Organization creation request

        Returns:
            Created organization
        """
        return await self._request(
            "POST",
            "/organizations",
            json_data=request.model_dump(mode="json"),
            response_model=OrganizationResponse,
        )

    # ========================================
    # Agent Endpoints
    # ========================================

    async def create_agent(self, request: CreateAgentRequest) -> CreateAgentResponse:
        """Create a new agent.

        Args:
            request: Agent creation request

        Returns:
            Created agent with models and tools
        """
        return await self._request(
            "POST",
            "/agents",
            json_data=request.model_dump(mode="json"),
            response_model=CreateAgentResponse,
        )

    async def update_agent(
        self, agent_id: str | UUID, request: UpdateAgentRequest
    ) -> UpdateAgentResponse:
        """Update an existing agent.

        All fields in the request are optional. Only provided fields will be updated.
        To clear an optional field, explicitly set it to None in the request.

        Args:
            agent_id: UUID or name of the agent to update
            request: Agent update request with optional fields

        Returns:
            Updated agent with fresh state from database

        Raises:
            NotFoundError: Agent not found or user lacks access
            ValidationError: Invalid request parameters
            ConflictError: Name conflicts with existing agent in scope
        """
        return await self._request(
            "PUT",
            f"/agents/{agent_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateAgentResponse,
        )

    async def get_agent(self, agent_identifier: str) -> GetAgentResponse:
        """Get a single agent by ID or name.

        Retrieves complete agent details including models and tools.
        Accessible if the agent is:
        - Owned by the user
        - Owned by the user's organization
        - Global (where both organization_id and user_id are NULL)

        Args:
            agent_identifier: Agent UUID string or name

        Returns:
            Agent with full details including models and tools

        Raises:
            NotFoundError: Agent not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                # Get by UUID
                agent = await client.get_agent("123e4567-e89b-12d3-a456-426614174000")

                # Get by name
                agent = await client.get_agent("my-agent")
        """
        return await self._request(
            "GET",
            f"/agents/{agent_identifier}",
            response_model=GetAgentResponse,
        )

    async def list_agents(
        self,
        *,
        search: str | None = None,
        sort_by: str | None = None,
        usage_days: int | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> ListAgentsResponse:
        """List agents accessible to the authenticated user.

        Returns agents that are:
        - Owned by the user
        - Owned by the user's organization
        - Global/public (where both organization_id and user_id are NULL)

        Args:
            search: Optional fuzzy search string (space-separated tokens, all must match)
                    across agent name and description
            sort_by: Sort order - "updated_at" (default), "created_at", "name", or "usage"
                    (by named session count, most used first)
            usage_days: When sort_by="usage", only count sessions from last N days
            limit: Results per page (1-100, default 50)
            offset: Number of results to skip (default 0)

        Returns:
            List of agents with summary information and total count.
            When sort_by="usage", includes usage_count for each agent.

        Example:
            # Get most frequently used agents from last 90 days
            response = await client.list_agents(sort_by="usage", usage_days=90, limit=7)
            for agent in response.agents:
                print(f"{agent.name}: {agent.usage_count} chats")
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if search:
            params["search"] = search
        if sort_by:
            params["sort_by"] = sort_by
        if usage_days is not None:
            params["usage_days"] = usage_days

        return await self._request(
            "GET",
            "/agents",
            params=params,
            response_model=ListAgentsResponse,
        )

    async def delete_agent(self, agent_identifier: str) -> DeleteAgentResponse:
        """Delete an agent by ID or name.

        Permanently removes the agent and all associated data:
        - Agent models (CASCADE)
        - Agent tools (CASCADE)
        - Agent tags (CASCADE)

        Related records are updated but not deleted:
        - Child agents (base_agent_id set to NULL)
        - Agent executions (agent_id set to NULL, history preserved)

        Cannot delete agents referenced by pipeline steps (RESTRICT).

        Args:
            agent_identifier: Agent UUID string or name

        Returns:
            DeleteAgentResponse with the deleted agent's final state

        Raises:
            NotFoundError: Agent not found or user lacks access
            ConflictError: Agent is referenced by pipeline steps
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                # Delete by name
                response = await client.delete_agent("my-agent")
                print(f"Deleted: {response.deleted_agent.name}")

                # Delete by UUID
                response = await client.delete_agent("123e4567-e89b-12d3-a456-426614174000")
        """
        return await self._request(
            "DELETE",
            f"/agents/{agent_identifier}",
            response_model=DeleteAgentResponse,
        )

    # ========================================
    # Model Endpoints
    # ========================================

    async def list_models(
        self,
        *,
        search: str | None = None,
        provider_filter: str | None = None,
        configured_only: bool = False,
        limit: int = 100,
        offset: int = 0,
    ) -> ListModelsResponse:
        """List available AI models with provider information.

        Args:
            search: Optional fuzzy search string (space-separated tokens, all must match)
                    across provider key and model name
            provider_filter: Optional provider key filter (case-insensitive partial match)
            configured_only: If True, only return models where provider has credentials configured
            limit: Maximum number of models to return (1-10000, default 100)
            offset: Number of matching models to skip before returning results

        Returns:
            List of models with provider and modality information
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if search:
            params["search"] = search
        if provider_filter:
            params["provider_filter"] = provider_filter
        if configured_only:
            params["configured_only"] = "true"

        return await self._request(
            "GET",
            "/models",
            params=params,
            response_model=ListModelsResponse,
        )

    # ========================================
    # Template Endpoints
    # ========================================

    async def create_api_key(
        self, request: CreateApiKeyRequest
    ) -> CreateApiKeyResponse:
        """Create a new scoped API key.

        Args:
            request: API key creation request with explicit scopes and optional metadata

        Returns:
            Created API key response including the one-time plaintext key
        """
        return await self._request(
            "POST",
            "/api-keys",
            json_data=request.model_dump(mode="json"),
            response_model=CreateApiKeyResponse,
        )

    async def create_template(
        self, request: CreateTemplateRequest
    ) -> CreateTemplateResponse:
        """Create a new template.

        Args:
            request: Template creation request

        Returns:
            Created template with version and dependencies
        """
        return await self._request(
            "POST",
            "/templates",
            json_data=request.model_dump(mode="json"),
            response_model=CreateTemplateResponse,
        )

    async def list_templates(
        self,
        *,
        content_type: TemplateContentType | None = None,
        visibility: TemplateVisibility | None = None,
        tag_path: str | None = None,
        limit: int = 50,
        cursor: str | None = None,
        search: str | None = None,
        include_content: bool = False,
    ) -> ListTemplatesResponse:
        """List templates accessible to the authenticated user.

        Args:
            content_type: Optional filter by content type (system_instruction, user_message, fragment)
            visibility: Optional filter by visibility level (private, organization, public)
            tag_path: Optional filter by tag ltree pattern (e.g., '*.email.*', 'email.newsletter')
            limit: Results per page (1-100, default 50)
            cursor: Pagination cursor from previous response
            search: Optional full-text search on name and content (case-insensitive partial match)
            include_content: If True, include template content in response (for interactive browsers)

        Returns:
            Paginated list of templates with cursor and total count
        """
        params: dict[str, Any] = {"limit": limit}
        if content_type:
            params["content_type"] = content_type.value
        if visibility:
            params["visibility"] = visibility.value
        if tag_path:
            params["tag_path"] = tag_path
        if cursor:
            params["cursor"] = cursor
        if search:
            params["search"] = search
        if include_content:
            params["include_content"] = "true"

        return await self._request(
            "GET",
            "/templates",
            params=params,
            response_model=ListTemplatesResponse,
        )

    async def get_template(
        self, template_identifier: str, version: int | None = None
    ) -> GetTemplateResponse:
        """Get a template by ID or name.

        Args:
            template_identifier: Template UUID string or name (e.g., "my-template", "email.welcome")
            version: Optional specific version number (must be >= 1)

        Returns:
            Template with metadata, versions, dependencies, and tags

        Raises:
            NotFoundError: Template not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                # Get by UUID
                template = await client.get_template("123e4567-e89b-12d3-a456-426614174000")

                # Get by name
                template = await client.get_template("my-template")
                template = await client.get_template("email.welcome")
        """
        params = {"version": version} if version is not None else None
        return await self._request(
            "GET",
            f"/templates/{template_identifier}",
            params=params,
            response_model=GetTemplateResponse,
        )

    async def get_template_by_version(self, version_id: UUID) -> GetTemplateResponse:
        """Get a template by its version ID.

        Args:
            version_id: Template version UUID

        Returns:
            Template with metadata, versions, dependencies, and tags

        Raises:
            NotFoundError: Version not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                template = await client.get_template_by_version(version_id)
        """
        return await self._request(
            "GET",
            f"/templates/versions/{version_id}",
            response_model=GetTemplateResponse,
        )

    async def create_template_version(
        self, template_id: UUID, request: CreateTemplateVersionRequest
    ) -> CreateTemplateVersionResponse:
        """Create a new version of an existing template.

        Args:
            template_id: Template ID
            request: New content and optional tag updates

        Returns:
            Created template version with dependencies
        """
        return await self._request(
            "POST",
            f"/templates/{template_id}/versions",
            json_data=request.model_dump(mode="json"),
            response_model=CreateTemplateVersionResponse,
        )

    async def render_template(
        self, template_id: UUID, request: RenderTemplateRequest
    ) -> RenderTemplateResponse:
        """Render a template with variables.

        Args:
            template_id: Template ID
            request: Render request with variables

        Returns:
            Rendered template
        """
        return await self._request(
            "POST",
            f"/templates/{template_id}/render",
            json_data=request.model_dump(mode="json"),
            response_model=RenderTemplateResponse,
        )

    async def validate_template(
        self, request: ValidateTemplateRequest
    ) -> ValidateTemplateResponse:
        return await self._request(
            "POST",
            "/templates/validate",
            json_data=request.model_dump(mode="json"),
            response_model=ValidateTemplateResponse,
        )

    async def delete_template(self, template_identifier: str) -> DeleteTemplateResponse:
        """Delete a template by ID or name.

        Permanently removes the template and all its versions.
        Requires ownership or 'write' permission on the template.

        Args:
            template_identifier: Template UUID string or name

        Returns:
            DeleteTemplateResponse with deleted template info

        Raises:
            NotFoundError: Template not found or user lacks access
            ConflictError: Template is referenced by other templates
            AuthenticationError: Invalid or missing API key
        """
        return await self._request(
            "DELETE",
            f"/templates/{template_identifier}",
            response_model=DeleteTemplateResponse,
        )

    async def update_template(
        self, template_identifier: str, request: UpdateTemplateRequest
    ) -> UpdateTemplateResponse:
        """Update an existing template's metadata and optionally content.

        All fields in the request are optional. Only provided fields will be updated.
        Content updates are only allowed for templates with a single version.

        Args:
            template_identifier: Template UUID string or name
            request: Template update request with optional fields

        Returns:
            Updated template with fresh state from database

        Raises:
            NotFoundError: Template not found or user lacks access
            ValidationError: Invalid request parameters
            ConflictError: Name conflicts with existing template, or
                          content update rejected (template has multiple versions)

        Example:
            async with client:
                # Update by name
                response = await client.update_template(
                    "my-template",
                    UpdateTemplateRequest(description="New description")
                )

                # Update by UUID
                response = await client.update_template(
                    "123e4567-89ab-cdef-0123-456789abcdef",
                    UpdateTemplateRequest(
                        name="new-name",
                        visibility=TemplateVisibility.PUBLIC
                    )
                )
        """
        return await self._request(
            "PUT",
            f"/templates/{template_identifier}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateTemplateResponse,
        )

    # ========================================
    # Admin Endpoints (System Credentials)
    # ========================================

    async def create_system_credential(
        self, request: CreateSystemCredentialRequest
    ) -> SystemCredentialResponse:
        """Create a system credential (requires system admin).

        Args:
            request: Credential creation request

        Returns:
            Created credential
        """
        return await self._request(
            "POST",
            "/admin/credentials/system",
            json_data=request.model_dump(mode="json"),
            response_model=SystemCredentialResponse,
        )

    async def list_system_credentials(
        self,
        *,
        limit: int = 50,
        offset: int = 0,
    ) -> ListSystemCredentialsResponse:
        params: dict[str, Any] = {"limit": limit, "offset": offset}

        return await self._request(
            "GET",
            "/admin/credentials/system",
            params=params,
            response_model=ListSystemCredentialsResponse,
        )

    async def update_credential(
        self,
        credential_id: UUID,
        request: UpdateCredentialRequest,
    ) -> OrganizationCredentialResponse:
        return await self._request(
            "PATCH",
            f"/credentials/{credential_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=OrganizationCredentialResponse,
        )

    async def grant_system_credential(
        self, request: GrantSystemCredentialRequest
    ) -> GrantSystemCredentialResponse:
        """Grant system credential to user or org (requires system admin).

        Args:
            request: Grant request

        Returns:
            Grant record
        """
        return await self._request(
            "POST",
            "/admin/credentials/system/grant",
            json_data=request.model_dump(mode="json"),
            response_model=GrantSystemCredentialResponse,
        )

    async def sync_models(self) -> SyncModelsResponse:
        """Sync AI models from providers (requires system admin).

        Returns:
            Sync results
        """
        return await self._request(
            "POST",
            "/admin/sync/models",
            response_model=SyncModelsResponse,
        )

    # ========================================
    # Schema Endpoints
    # ========================================

    async def create_schema(self, request: CreateSchemaRequest) -> CreateSchemaResponse:
        """Create a new schema for input/output validation.

        Schemas can be JSON Schema (for structured validation) or regex patterns
        (for string validation).

        Args:
            request: Schema creation request with name, format, definition, and scope

        Returns:
            Created schema with version information

        Raises:
            ValidationError: Invalid schema definition or name format
            ConflictError: Schema with same name already exists in scope
            AuthenticationError: Invalid or missing API key
            ServerError: Server error
        """
        return await self._request(
            "POST",
            "/schemas",
            json_data=request.model_dump(mode="json"),
            response_model=CreateSchemaResponse,
        )

    async def list_schemas(
        self,
        *,
        search: str | None = None,
        schema_format: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> ListSchemasResponse:
        """List schemas accessible to the authenticated user.

        Returns schemas that are:
        - Owned by the user
        - Owned by the user's organization
        - Global/public (where both organization_id and user_id are NULL)

        Args:
            search: Optional fuzzy search string (space-separated tokens, all must match)
                    across schema name and description
            schema_format: Optional filter by schema format (e.g., 'json_schema', 'regex')
            limit: Results per page (1-100, default 50)
            offset: Number of results to skip (default 0)

        Returns:
            List of schemas with summary information and total count

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid query parameters
            ServerError: Server error
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if search:
            params["search"] = search
        if schema_format:
            params["schema_format"] = schema_format

        return await self._request(
            "GET",
            "/schemas",
            params=params,
            response_model=ListSchemasResponse,
        )

    async def get_schema(self, schema_identifier: str) -> GetSchemaResponse:
        """Get a single schema by ID or name.

        Retrieves complete schema details including the full schema definition.
        Accessible if the schema is:
        - Owned by the user
        - Owned by the user's organization
        - Global (where both organization_id and user_id are NULL)

        When looking up by name, returns the latest version of the schema.

        Args:
            schema_identifier: Schema UUID string or name

        Returns:
            Schema with metadata and full definition

        Raises:
            NotFoundError: Schema not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                # Get by UUID
                schema = await client.get_schema("123e4567-e89b-12d3-a456-426614174000")

                # Get by name (returns latest version)
                schema = await client.get_schema("my-schema")
                print(schema.schema_definition)
        """
        return await self._request(
            "GET",
            f"/schemas/{schema_identifier}",
            response_model=GetSchemaResponse,
        )

    async def update_schema(
        self, schema_id: UUID, request: UpdateSchemaRequest
    ) -> UpdateSchemaResponse:
        """Update an existing schema.

        All fields in the request are optional. Providing schema_definition creates
        a new version. Providing only description updates metadata on the current
        latest version.

        Note: Schema name and format cannot be changed (would break existing references).
        The server only accepts UUIDs for update operations, not names.

        Args:
            schema_id: UUID of the schema to update
            request: Schema update request with optional description and/or schema_definition

        Returns:
            Updated schema with fresh state from database

        Raises:
            NotFoundError: Schema not found or user lacks access
            ValidationError: Invalid schema definition for the format
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                # Update description only (no new version)
                response = await client.update_schema(
                    UUID("123e4567-89ab-cdef-0123-456789abcdef"),
                    UpdateSchemaRequest(description="New description")
                )

                # Update definition (creates new version)
                response = await client.update_schema(
                    UUID("123e4567-89ab-cdef-0123-456789abcdef"),
                    UpdateSchemaRequest(
                        schema_definition='{"type": "object", "properties": {...}}'
                    )
                )
        """
        return await self._request(
            "PUT",
            f"/schemas/{schema_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateSchemaResponse,
        )

    async def delete_schema(self, schema_identifier: str) -> DeleteSchemaResponse:
        """Delete a schema.

        Deletes a schema and all its versions. Cannot delete schemas that are
        currently referenced by agents or pipelines.

        Args:
            schema_identifier: UUID or name of the schema to delete

        Returns:
            Deleted schema's final state

        Raises:
            NotFoundError: Schema not found or user lacks access
            ConflictError: Schema is in use by agents/pipelines
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                result = await client.delete_schema("my-schema")
                print(f"Deleted: {result.deleted_schema.name}")
        """
        return await self._request(
            "DELETE",
            f"/schemas/{schema_identifier}",
            response_model=DeleteSchemaResponse,
        )

    # ========================================
    # Credentials Endpoints (Organization Credentials)
    # ========================================

    async def create_organization_credential(
        self, request: CreateOrganizationCredentialRequest
    ) -> OrganizationCredentialResponse:
        """Create an organization credential (BYOK - Bring Your Own Key).

        Organization credentials enable multi-tenant isolation where each organization
        manages their own AI provider API keys or tool credentials.

        Requires organization owner or admin role.

        Args:
            request: Credential creation request with provider_key or tool_id

        Returns:
            Created organization credential without sensitive data

        Raises:
            AuthenticationError: Invalid or missing API key
            ForbiddenError: User lacks permission (not owner/admin)
            ValidationError: Invalid request parameters
            ConflictError: Duplicate credential for provider or tool
            ServerError: Server error
        """
        return await self._request(
            "POST",
            "/credentials",
            json_data=request.model_dump(mode="json"),
            response_model=OrganizationCredentialResponse,
        )

    async def list_organization_credentials(
        self,
        *,
        limit: int = 50,
        offset: int = 0,
    ) -> ListOrganizationCredentialsResponse:
        params: dict[str, Any] = {"limit": limit, "offset": offset}

        return await self._request(
            "GET",
            "/credentials",
            params=params,
            response_model=ListOrganizationCredentialsResponse,
        )

    # ========================================
    # Content Endpoints
    # ========================================

    async def list_content_parts(
        self,
        *,
        search: str | None = None,
        tag_path: str | None = None,
        exclude_tags: list[str] | None = None,
        structured_filter: ConditionSpec | LogicalSpec | None = None,
        metadata_filter: ConditionSpec | LogicalSpec | None = None,
        agent_id: UUID | None = None,
        session_id: UUID | None = None,
        external_id: str | None = None,
        content_type_id: str | None = None,
        storage_type: ContentStorageType | None = None,
        start_date: str | None = None,
        end_date: str | None = None,
        sort_by: ContentSortField | None = None,
        sort_order: SortOrder | None = None,
        limit: int = 50,
        cursor: str | None = None,
    ) -> ListContentPartsResponse:
        """List AI-generated content parts with enriched metadata.

        Returns content parts from AI executions, enriched with:
        - Full execution metadata (tokens, cost breakdown, timing)
        - Agent information (name, description)
        - Chat session context (name, last activity)
        - Media content URLs and metadata
        - Associated tags

        Perfect for CLI users who want to browse and explore AI outputs.
        Results are sorted by creation time (most recent first).

        Args:
            search: Optional fuzzy search string (fzf-style tokenized) across
                    content text, agent name, and session name
            tag_path: Optional tag ltree pattern to include (e.g., '*.email.*', 'code.python')
            exclude_tags: Optional list of tag patterns to exclude from results.
                    Content with ANY of these tags will be filtered out.
            structured_filter: Optional advanced JSONB filter specification on content_structured
            metadata_filter: Optional advanced JSONB filter specification on part_metadata
            agent_id: Optional filter by agent ID
            session_id: Optional filter by chat session ID
            external_id: Optional filter by execution external_id (exact match).
                    Returns all content parts from the execution with this external_id.
            content_type_id: Optional filter by content type (text, code, structured_output, etc.)
            storage_type: Optional filter by storage type (text or structured)
            start_date: Optional start date for created_at range (ISO 8601 string)
            end_date: Optional end date for created_at range (ISO 8601 string)
            sort_by: Optional field to sort by (created_at or updated_at, default: created_at)
            sort_order: Optional sort direction (asc or desc, default: desc)
            limit: Results per page (1-100, default 50)
            cursor: Pagination cursor from previous response

        Returns:
            Paginated list of content parts with enriched metadata

        Example:
            async with client:
                # Fuzzy search content
                response = await client.list_content_parts(search="python code", limit=20)
                for item in response.content_parts:
                    print(f"{item.execution.agent_name}: {item.content_type_id}")
                    print(f"  Cost: ${item.execution.total_cost_usd}")

                # Filter by agent and tags
                response = await client.list_content_parts(
                    agent_id=UUID("..."),
                    tag_path="code.python",
                    limit=10
                )
        """
        params: dict[str, Any] = {"limit": limit}
        if search:
            params["search"] = search
        if tag_path:
            params["tag_path"] = tag_path
        if exclude_tags:
            params["exclude_tags"] = exclude_tags
        if structured_filter:
            # Serialize filter spec to JSON
            params["structured_filter"] = structured_filter.model_dump_json()
        if metadata_filter:
            # Serialize filter spec to JSON
            params["metadata_filter"] = metadata_filter.model_dump_json()
        if agent_id:
            params["agent_id"] = str(agent_id)
        if session_id:
            params["session_id"] = str(session_id)
        if external_id:
            params["external_id"] = external_id
        if content_type_id:
            params["content_type_id"] = content_type_id
        if storage_type:
            params["storage_type"] = storage_type.value
        if start_date:
            params["start_date"] = start_date
        if end_date:
            params["end_date"] = end_date
        if sort_by:
            params["sort_by"] = sort_by.value
        if sort_order:
            params["sort_order"] = sort_order.value
        if cursor:
            params["cursor"] = cursor

        return await self._request(
            "GET",
            "/content",
            params=params,
            response_model=ListContentPartsResponse,
        )

    async def get_content_part(self, content_part_id: UUID) -> ContentPartItemResponse:
        """Get a single content part by ID.

        Args:
            content_part_id: UUID of content part to retrieve

        Returns:
            Content part with tags and execution metadata

        Raises:
            NotFoundError: Content part not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                content_part = await client.get_content_part(
                    UUID("123e4567-e89b-12d3-a456-426614174000")
                )
                print(content_part.content_text)
        """
        return await self._request(
            "GET",
            f"/content/{content_part_id}",
            response_model=ContentPartItemResponse,
        )

    async def update_content_part(
        self, content_part_id: UUID, request: UpdateContentPartRequest
    ) -> UpdateContentPartResponse:
        """Update mutable content fields of a content part.

        Allows updating the text or structured content of AI-generated outputs.
        Users must update the same field type that's currently populated
        (matching the workflow: list -> edit -> update).

        Args:
            content_part_id: UUID of content part to update
            request: Update request with either content_text or content_structured

        Returns:
            Updated content part with fresh state from database

        Raises:
            NotFoundError: Content part not found or user lacks access
            ValidationError: No fields provided or invalid request
            RequestError: Wrong field type for current storage

        Example:
            # Update text content
            response = await client.update_content_part(
                content_part_id=UUID("..."),
                request=UpdateContentPartRequest(content_text="Updated text")
            )

            # Update structured content
            response = await client.update_content_part(
                content_part_id=UUID("..."),
                request=UpdateContentPartRequest(
                    content_structured={"status": "complete", "count": 42}
                )
            )
        """
        return await self._request(
            "PUT",
            f"/content/{content_part_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateContentPartResponse,
        )

    async def delete_content_part(
        self, content_part_id: UUID
    ) -> DeleteContentPartResponse:
        """Delete a content part by ID.

        Permanently removes the content part. Associated tags are automatically
        deleted (CASCADE). Content objects are NOT deleted (may be shared).

        Args:
            content_part_id: UUID of content part to delete

        Returns:
            DeleteContentPartResponse with the deleted content part's final state

        Raises:
            NotFoundError: Content part not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                response = await client.delete_content_part(
                    UUID("123e4567-e89b-12d3-a456-426614174000")
                )
                print(f"Deleted: {response.deleted_content_part.id}")
        """
        return await self._request(
            "DELETE",
            f"/content/{content_part_id}",
            response_model=DeleteContentPartResponse,
        )

    # ========================================
    # Text Generation Endpoints
    # ========================================

    async def generate_text(self, request: GenerateTextRequest) -> GenerateTextResponse:
        """Generate text (non-streaming mode).

        Args:
            request: Text generation request with stream=False

        Returns:
            Complete generation response with text, usage, and cost

        Raises:
            NotFoundError: Agent or model not found
            ValidationError: Invalid request parameters
            ServerError: Generation failed
        """
        # Ensure stream is False for non-streaming
        request_data = request.model_dump(mode="json", exclude_none=True)
        request_data["stream"] = False

        return await self._request(
            "POST",
            "/generate/text",
            json_data=request_data,
            headers={"Accept": "text/event-stream"},
            response_model=GenerateTextResponse,
        )

    async def generate_text_async(
        self,
        request: GenerateTextRequest,
        *,
        webhook_url: str | None = None,
        user_metadata: dict[str, Any] | None = None,
    ) -> AsyncGenerationSubmitResponse:
        """Submit text generation for async background processing.

        Queues the generation request and returns immediately with an execution ID.
        Poll with get_generation() to check status and retrieve results.

        Args:
            request: Text generation request (stream must be False)
            webhook_url: Optional URL to receive completion notification via POST.
                The delivered body is a signed webhook event envelope whose
                `data` field matches the terminal async generation status shape.
            user_metadata: Optional metadata attached to the execution, returned
                in status polling responses and webhook payloads

        Returns:
            Submission response with execution_id for polling

        Raises:
            NotFoundError: Agent or model not found
            ValidationError: Invalid request parameters or stream=True
            ServerError: Generation submission failed
        """
        request_data = request.model_dump(mode="json", exclude_none=True, by_alias=True)
        request_data["stream"] = False
        request_data["async"] = True
        if webhook_url is not None:
            request_data["webhook_url"] = webhook_url
        if user_metadata is not None:
            request_data["user_metadata"] = user_metadata

        return await self._request(
            "POST",
            "/generate/text",
            json_data=request_data,
            response_model=AsyncGenerationSubmitResponse,
        )

    async def generate_text_stream(
        self, request: GenerateTextRequest
    ) -> AsyncGenerator[GenerateTextStreamChunk, None]:
        """Generate text (streaming mode).

        Streams chunks via Server-Sent Events as they are generated.

        Args:
            request: Text generation request

        Yields:
            GenerateTextStreamChunk: Individual chunks of generated text

        Raises:
            NotFoundError: Agent or model not found
            ValidationError: Invalid request parameters
            ServerError: Generation failed

        Example:
            async with client:
                request = GenerateTextRequest(
                    model_canonical_name="mistral-nemo",
                    provider_key=Provider.OPENROUTER,
                    user_message="Write a story",
                    stream=True,
                )
                async for chunk in client.generate_text_stream(request):
                    print(chunk.content, end="", flush=True)
        """
        client = self._ensure_client()

        # Ensure stream is True for streaming
        request_data = request.model_dump(mode="json", exclude_none=True)
        request_data["stream"] = True

        trace_id = generate_trace_id()
        start_time = time.perf_counter()
        structlog.contextvars.bind_contextvars(trace_id=trace_id)
        path = self._api_base + "/generate/text"

        # Extract resource info for logging
        resource_info: dict[str, Any] = {"resource": "generate", "stream": True}

        # Extract model/provider from model_source
        model_source = request_data.get("model_source")
        if model_source and isinstance(model_source, dict):
            if "agent" in model_source:
                resource_info["agent"] = model_source["agent"]
            if "provider_key" in model_source:
                resource_info["provider"] = model_source["provider_key"]
            if "provider_model_name" in model_source:
                resource_info["model"] = model_source["provider_model_name"]

        # User message preview
        if "user_message" in request_data:
            msg = request_data["user_message"]
            if isinstance(msg, str):
                resource_info["message_preview"] = (
                    msg[:50] + "..." if len(msg) > 50 else msg
                )

        # Log stream request start
        self._logger.info(
            "http_stream_start",
            trace_id=trace_id,
            method="POST",
            path=path,
            **resource_info,
        )
        self._logger.debug(
            "http_stream_request_detail",
            trace_id=trace_id,
            body=request_data,
        )

        chunk_count = 0
        execution_id: str | None = None

        try:
            async with client.stream(
                "POST",
                path,
                json=request_data,
                headers={"X-Trace-ID": trace_id},
            ) as response:
                # Handle error responses before streaming
                if response.status_code >= 400:
                    # Must read body before accessing .json()/.text in streaming mode
                    await response.aread()
                    duration_ms = int((time.perf_counter() - start_time) * 1000)
                    error_detail, error_data = self._parse_error_response(response)
                    self._logger.error(
                        "http_stream_error",
                        trace_id=trace_id,
                        method="POST",
                        path=path,
                        status=response.status_code,
                        duration_ms=duration_ms,
                        error=error_detail,
                        **resource_info,
                    )
                    self._raise_for_status(
                        response.status_code, error_detail, error_data
                    )

                # Parse SSE stream
                current_event_type = None
                async for line in response.aiter_lines():
                    if not line.strip():
                        continue

                    # Parse SSE event and data
                    if line.startswith("event: "):
                        # Track event type for the next data line
                        current_event_type = line[7:]  # Remove "event: " prefix
                        continue
                    elif line.startswith("data: "):
                        data_json = line[6:]  # Remove "data: " prefix
                        try:
                            chunk_data = json.loads(data_json)

                            if current_event_type == "chunk":
                                # Regular chunk data
                                if "sequence" in chunk_data:
                                    chunk_count += 1
                                    if (
                                        execution_id is None
                                        and "execution_id" in chunk_data
                                    ):
                                        execution_id = str(chunk_data["execution_id"])
                                    yield GenerateTextStreamChunk.model_validate(
                                        chunk_data
                                    )
                            elif current_event_type == "done":
                                # Done event - yield final metadata chunk with session info
                                duration_ms = int(
                                    (time.perf_counter() - start_time) * 1000
                                )
                                session_id = chunk_data.get("chat_session_id")
                                logged_execution_id = str(
                                    chunk_data.get("execution_id", execution_id)
                                )

                                self._logger.info(
                                    "http_stream_complete",
                                    trace_id=trace_id,
                                    method="POST",
                                    path=path,
                                    duration_ms=duration_ms,
                                    chunk_count=chunk_count,
                                    execution_id=logged_execution_id,
                                    session_id=str(session_id)[:12]
                                    if session_id
                                    else None,
                                )

                                # Create a metadata chunk containing session_id from done event
                                final_chunk = GenerateTextStreamChunk(
                                    execution_id=chunk_data["execution_id"],
                                    sequence=-1,  # Special sequence for metadata
                                    content="",
                                    chunk_type="metadata",
                                    metadata={
                                        "chat_session_id": session_id,
                                        "done": True,
                                    },
                                )
                                yield final_chunk
                                break
                            elif current_event_type == "error":
                                # Error event during streaming
                                duration_ms = int(
                                    (time.perf_counter() - start_time) * 1000
                                )
                                error_msg = chunk_data.get("error", "Unknown error")
                                self._logger.error(
                                    "http_stream_error",
                                    trace_id=trace_id,
                                    method="POST",
                                    path=path,
                                    duration_ms=duration_ms,
                                    chunk_count=chunk_count,
                                    error=error_msg,
                                )
                                raise ServerError(f"Stream error: {error_msg}")
                        except json.JSONDecodeError:
                            # Skip malformed JSON
                            continue
                        finally:
                            current_event_type = None  # Reset after processing data

        except httpx.RequestError as e:
            duration_ms = int((time.perf_counter() - start_time) * 1000)
            self._logger.error(
                "http_stream_failed",
                trace_id=trace_id,
                method="POST",
                path=path,
                duration_ms=duration_ms,
                chunk_count=chunk_count,
                error=str(e),
                error_type=type(e).__name__,
                **resource_info,
            )
            raise RequestError(f"Stream request failed: {e}") from e

    async def get_generation(
        self,
        execution_id: str | UUID,
        *,
        include_tool_call_details: bool = False,
    ) -> AsyncGenerationStatusResponse:
        """Get the status and result of an async generation.

        Poll this endpoint after submitting an async generation request.
        When status is terminal (completed, failed, cancelled, timeout),
        polling should stop.

        Args:
            execution_id: UUID of the execution returned by generate_text_async()
            include_tool_call_details: If True, include tool call details in response

        Returns:
            Status response with result data when completed

        Raises:
            NotFoundError: Execution not found or not accessible
            AuthenticationError: Invalid or missing API key
        """
        params = (
            {"include_tool_call_details": True} if include_tool_call_details else None
        )
        return await self._request(
            "GET",
            f"/generate/{execution_id}",
            params=params,
            response_model=AsyncGenerationStatusResponse,
        )

    async def get_execution(self, execution_id: str | UUID) -> GetExecutionResponse:
        return await self._request(
            "GET",
            f"/executions/{execution_id}",
            response_model=GetExecutionResponse,
        )

    async def wait_for_generation(
        self,
        execution_id: str | UUID,
        *,
        timeout: float = 300.0,
        poll_interval: float = 2.0,
        include_tool_call_details: bool = False,
    ) -> AsyncGenerationStatusResponse:
        """Wait for async generation to reach a terminal state.

        This method polls the generation status until it reaches a terminal state
        (completed, failed, cancelled, or timeout). It correctly handles all terminal
        states, preventing the common bug of polling indefinitely when generation fails.

        Args:
            execution_id: UUID of the execution returned by generate_text_async()
            timeout: Maximum time to wait in seconds (default: 300)
            poll_interval: Time between polls in seconds (default: 2.0)
            include_tool_call_details: If True, include tool call details in response

        Returns:
            Final status response when generation completes successfully

        Raises:
            GenerationFailedError: If generation fails
            GenerationTimeoutError: If timeout exceeded before reaching terminal state
            AuthenticationError: Invalid or missing API key
            NotFoundError: Execution not found

        Example:
            # Submit and wait for generation
            submit = await client.generate_text_async(request)
            result = await client.wait_for_generation(
                submit.execution_id,
                timeout=120.0,
            )
            print(f"Generated: {result.output_text}")

        Example with error handling:
            try:
                result = await client.wait_for_generation(execution_id)
            except GenerationFailedError as e:
                print(f"Generation failed: {e.error_message}")
            except GenerationTimeoutError as e:
                print(f"Timed out after {e.timeout}s, last status: {e.last_status}")
        """
        start_time = time.monotonic()
        last_status: str = "pending"
        execution_id_str = str(execution_id)

        while True:
            elapsed = time.monotonic() - start_time
            if elapsed >= timeout:
                break

            status_response = await self.get_generation(
                execution_id,
                include_tool_call_details=include_tool_call_details,
            )
            last_status = status_response.status

            if status_response.is_terminal:
                if status_response.status == "failed":
                    raise GenerationFailedError(
                        execution_id=execution_id_str,
                        error_message=status_response.error_message,
                        error_type=status_response.error_type,
                    )
                return status_response

            remaining = timeout - (time.monotonic() - start_time)
            if remaining <= 0:
                break

            sleep_seconds = poll_interval if poll_interval > 0 else 0.0
            await asyncio.sleep(min(sleep_seconds, remaining))

        raise GenerationTimeoutError(
            execution_id=execution_id_str,
            last_status=last_status,
            timeout=timeout,
        )

    async def cancel_generation(
        self, execution_id: str | UUID
    ) -> AsyncGenerationCancelResponse:
        """Cancel a pending or running async generation.

        Only generations in non-terminal states (pending, running) can be cancelled.
        Cancelling an already-cancelled generation is idempotent.

        Args:
            execution_id: UUID of the execution to cancel

        Returns:
            Cancel response with status and was_running flag

        Raises:
            NotFoundError: Execution not found or not accessible
            ConflictError: Generation already in terminal state (completed, failed, timeout)
            AuthenticationError: Invalid or missing API key
        """
        return await self._request(
            "DELETE",
            f"/generate/{execution_id}",
            response_model=AsyncGenerationCancelResponse,
        )

    # ========================================
    # Authentication Endpoints (No Auth Required)
    # ========================================

    async def signup(self, request: SignupRequest) -> SignupResponse:
        """Create a new user account.

        This endpoint does not require authentication. The returned API key
        is only provided once and should be saved securely.

        Args:
            request: Signup request with email, password, name, and optional org name

        Returns:
            SignupResponse with user details and plaintext API key

        Raises:
            ConflictError: Email already registered
            ValidationError: Invalid request parameters
            ServerError: Server error

        Example:
            async with ApiClient(base_url="https://api.example.com", api_key="") as client:
                response = await client.signup(SignupRequest(
                    email="user@example.com",
                    password="securepassword123",
                    name="John Doe"
                ))
                # Save response.api_key securely
        """
        return await self._request(
            "POST",
            "/auth/signup",
            json_data=request.model_dump(mode="json"),
            response_model=SignupResponse,
        )

    async def login(self, request: LoginRequest) -> LoginResponse:
        """Authenticate and get API key.

        This endpoint does not require authentication. The returned API key
        is newly generated and should be saved securely.

        Args:
            request: Login request with email and password

        Returns:
            LoginResponse with user details and plaintext API key

        Raises:
            AuthenticationError: Invalid credentials
            ServerError: Server error

        Example:
            async with ApiClient(base_url="https://api.example.com", api_key="") as client:
                response = await client.login(LoginRequest(
                    email="user@example.com",
                    password="securepassword123"
                ))
                # Save response.api_key securely
                # Check response.is_new_user for onboarding prompts
        """
        return await self._request(
            "POST",
            "/auth/login",
            json_data=request.model_dump(mode="json"),
            response_model=LoginResponse,
        )

    async def bootstrap_admin(
        self, request: BootstrapAdminRequest
    ) -> BootstrapAdminResponse:
        """Bootstrap the first system administrator.

        This endpoint creates the initial system admin in a fresh database.
        Can ONLY be called when zero users exist (one-time operation).
        Does not require authentication (security via empty database check + token).

        Args:
            request: Bootstrap request with admin credentials and security token

        Returns:
            BootstrapAdminResponse with admin details and plaintext API key

        Raises:
            AuthenticationError: Invalid bootstrap token
            ForbiddenError: Bootstrap is disabled on server
            ConflictError: Users already exist in database
            ValidationError: Invalid request parameters
            ServerError: Server error

        Example:
            async with ApiClient(base_url="https://api.example.com", api_key="") as client:
                response = await client.bootstrap_admin(BootstrapAdminRequest(
                    email="admin@example.com",
                    password="SecurePass123",
                    name="System Admin",
                    organization_name="Platform Team",
                    bootstrap_token="your_secret_token"
                ))
                # Save response.api_key securely
        """
        return await self._request(
            "POST",
            "/auth/bootstrap",
            json_data=request.model_dump(mode="json"),
            response_model=BootstrapAdminResponse,
        )

    # ========================================
    # Session Endpoints
    # ========================================

    async def create_session(
        self, request: CreateSessionRequest
    ) -> SessionDetailResponse:
        """Create a new chat session.

        Args:
            request: Session creation request with optional name and agent_id

        Returns:
            Created session with empty message list

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Agent not found (if agent_id provided)
            ValidationError: Invalid request parameters
            ServerError: Server error

        Example:
            async with client:
                session = await client.create_session(CreateSessionRequest(
                    name="My Chat",
                    agent_id=UUID("...")
                ))
        """
        return await self._request(
            "POST",
            "/sessions",
            json_data=request.model_dump(mode="json"),
            response_model=SessionDetailResponse,
        )

    async def get_session(
        self,
        session_id: UUID,
        *,
        limit: int = 50,
        offset: int = 0,
        before_sequence: int | None = None,
    ) -> SessionDetailResponse:
        """Get a chat session by ID with messages.

        Args:
            session_id: UUID of the session to retrieve
            limit: Maximum number of messages to return (1-100, default 50)
            offset: Number of messages to skip (default 0)
            before_sequence: Return messages with sequence < this value (for pagination)

        Returns:
            Session with metadata and paginated messages

        Raises:
            NotFoundError: Session not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                session = await client.get_session(
                    UUID("..."),
                    limit=20,
                    before_sequence=100
                )
                for msg in session.messages:
                    print(f"{msg.role}: {msg.content_parts[0].content_text}")
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if before_sequence is not None:
            params["before_sequence"] = before_sequence

        return await self._request(
            "GET",
            f"/sessions/{session_id}",
            params=params,
            response_model=SessionDetailResponse,
        )

    async def list_sessions(
        self,
        *,
        search: str | None = None,
        named_only: bool = False,
        limit: int = 50,
        offset: int = 0,
    ) -> ListSessionsResponse:
        """List chat sessions accessible to the authenticated user.

        Args:
            search: Optional fuzzy search on session name
            named_only: If True, only return sessions with a name (excludes untitled)
            limit: Results per page (1-100, default 50)
            offset: Number of results to skip (default 0)

        Returns:
            List of session summaries with total count

        Raises:
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                response = await client.list_sessions(search="project", limit=10)
                for session in response.sessions:
                    print(f"{session.name}: {session.message_count} messages")
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if search:
            params["search"] = search
        if named_only:
            params["named_only"] = "true"

        return await self._request(
            "GET",
            "/sessions",
            params=params,
            response_model=ListSessionsResponse,
        )

    async def update_session(
        self, session_id: UUID, request: UpdateSessionRequest
    ) -> SessionDetailResponse:
        """Update a chat session.

        Args:
            session_id: UUID of the session to update
            request: Update request with optional name

        Returns:
            Updated session

        Raises:
            NotFoundError: Session not found or user lacks access
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid request parameters

        Example:
            async with client:
                session = await client.update_session(
                    UUID("..."),
                    UpdateSessionRequest(name="Renamed Chat")
                )
        """
        return await self._request(
            "PUT",
            f"/sessions/{session_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=SessionDetailResponse,
        )

    async def delete_session(self, session_id: UUID) -> DeleteSessionResponse:
        """Delete a chat session and all its messages.

        Args:
            session_id: UUID of the session to delete

        Returns:
            Deleted session's final state

        Raises:
            NotFoundError: Session not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                result = await client.delete_session(UUID("..."))
                print(f"Deleted session {result.deleted_session.id}")
        """
        return await self._request(
            "DELETE",
            f"/sessions/{session_id}",
            response_model=DeleteSessionResponse,
        )

    async def list_session_messages(
        self,
        session_id: UUID,
        *,
        role: str | None = None,
        limit: int = 50,
        offset: int = 0,
        order: str = "asc",
    ) -> ListSessionMessagesResponse:
        """List messages within a chat session with optional filtering.

        Args:
            session_id: UUID of the session to list messages from
            role: Optional filter by message role (user, assistant, system, tool)
            limit: Maximum number of messages to return (1-100, default 50)
            offset: Number of messages to skip (default 0)
            order: Sort order for sequence_number ('asc' or 'desc', default 'asc')

        Returns:
            List of messages with pagination info

        Raises:
            NotFoundError: Session not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                # Get the last assistant message
                response = await client.list_session_messages(
                    session_id,
                    role="assistant",
                    limit=1,
                    order="desc"
                )
                if response.messages:
                    last_msg = response.messages[0]
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset, "order": order}
        if role is not None:
            params["role"] = role

        return await self._request(
            "GET",
            f"/sessions/{session_id}/messages",
            params=params,
            response_model=ListSessionMessagesResponse,
        )

    # ========================================
    # Message Endpoints
    # ========================================

    async def update_message(
        self, message_id: UUID, request: UpdateMessageRequest
    ) -> MessageDetailResponse:
        """Update a chat message.

        Allows updating message properties like include_in_context.
        Used for undo functionality to exclude messages from conversation context.

        Args:
            message_id: UUID of the message to update
            request: Update request with include_in_context field

        Returns:
            Updated message with fresh state from database

        Raises:
            NotFoundError: Message not found or user lacks access
            AuthenticationError: Invalid or missing API key
            ValidationError: No fields provided

        Example:
            async with client:
                # Exclude message from context (undo)
                message = await client.update_message(
                    UUID("..."),
                    UpdateMessageRequest(include_in_context=False)
                )
        """
        return await self._request(
            "PUT",
            f"/messages/{message_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=MessageDetailResponse,
        )

    async def get_message(self, message_id: UUID) -> MessageDetailResponse:
        """Get a chat message by ID.

        Args:
            message_id: UUID of the message

        Returns:
            Message details

        Raises:
            NotFoundError: Message not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                message = await client.get_message(UUID("..."))
                print(f"Role: {message.role}")
        """
        return await self._request(
            "GET",
            f"/messages/{message_id}",
            response_model=MessageDetailResponse,
        )

    async def delete_message(self, message_id: UUID) -> DeleteMessageResponse:
        """Delete a chat message.

        Args:
            message_id: UUID of the message

        Returns:
            Deleted message's final state

        Raises:
            NotFoundError: Message not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                result = await client.delete_message(UUID("..."))
                print(f"Deleted message {result.deleted_message.id}")
        """
        return await self._request(
            "DELETE",
            f"/messages/{message_id}",
            response_model=DeleteMessageResponse,
        )

    # ========================================
    # Pipeline Endpoints
    # ========================================

    async def create_pipeline(
        self, request: CreatePipelineRequest
    ) -> CreatePipelineResponse:
        """Create a new pipeline definition.

                Args:
                    request: Pipeline creation request with name and module_content

                Returns:
                    Created pipeline with summary information

                Raises:
                    AuthenticationError: Invalid or missing API key
                    ValidationError: Invalid request parameters
                    ConflictError: Pipeline with same name already exists in scope
                    ServerError: Server error

                Example:
                    async with client:
                        pipeline = await client.create_pipeline(CreatePipelineRequest(
                            name="content-generator",
                            description="Generate content with research step",
                            module_content='''
        from alloy_runtime_sdk.pipeline.decorators import pipeline

        @pipeline
        async def content_generator(ctx):
            result = await ctx.generate_text(
                step_id="research",
                agent="researcher",
                user_message="Research the topic",
            )
            return result
        ''',
                        ))
        """
        return await self._request(
            "POST",
            "/pipelines",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=CreatePipelineResponse,
        )

    async def list_pipelines(
        self,
        *,
        search: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> ListPipelinesResponse:
        """List pipelines accessible to the authenticated user.

        Returns pipelines that are:
        - Owned by the user
        - Owned by the user's organization
        - Public (is_public=True)

        Args:
            search: Optional fuzzy search on pipeline name and description
            limit: Results per page (1-100, default 50)
            offset: Number of results to skip (default 0)

        Returns:
            List of pipelines with summary information and total count

        Raises:
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                response = await client.list_pipelines(search="content", limit=10)
                for pipeline in response.pipelines:
                    print(f"{pipeline.name}: {pipeline.step_count} steps")
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if search:
            params["search"] = search

        return await self._request(
            "GET",
            "/pipelines",
            params=params,
            response_model=ListPipelinesResponse,
        )

    async def get_pipeline(self, pipeline_id: UUID) -> GetPipelineResponse:
        """Get a pipeline by ID.

        Retrieves complete pipeline details including all step configurations.
        Accessible if the pipeline is:
        - Owned by the user
        - Owned by the user's organization
        - Public (is_public=True)

        Args:
            pipeline_id: UUID of the pipeline to retrieve

        Returns:
            Pipeline with full details including steps

        Raises:
            NotFoundError: Pipeline not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                pipeline = await client.get_pipeline(UUID("..."))
                for step in pipeline.steps:
                    print(f"Step {step.step_order}: {step.step_name}")
        """
        return await self._request(
            "GET",
            f"/pipelines/{pipeline_id}",
            response_model=GetPipelineResponse,
        )

    async def update_pipeline(
        self, pipeline_id: UUID, request: UpdatePipelineRequest
    ) -> UpdatePipelineResponse:
        return await self._request(
            "PATCH",
            f"/pipelines/{pipeline_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdatePipelineResponse,
        )

    async def execute_pipeline(
        self, pipeline_id: UUID, request: ExecutePipelineRequest
    ) -> ExecutePipelineResponse:
        """Execute a pipeline with runtime config.

        Hosted SDK pipelines read request data from ``request.config`` as
        ``ctx.config`` inside the pipeline runtime.

        Args:
            pipeline_id: UUID of the pipeline to execute
            request: Execution request with runtime config and execution options

        Returns:
            Execution results with status, step summaries, and result data

        Raises:
            NotFoundError: Pipeline not found or user lacks access
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid runtime config or request fields
            ServerError: Execution failed

        Example:
            async with client:
                result = await client.execute_pipeline(
                    UUID("..."),
                    ExecutePipelineRequest(
                        config={"topic": "AI agents", "word_count": 500}
                    )
                )
                print(f"Status: {result.status}")
                print(f"Duration: {result.total_duration_ms}ms")
        """
        request_data = request.model_dump(mode="json")
        headers = {
            "Accept": (
                "text/event-stream"
                if request_data.get("wait_for_completion") is True
                else "application/json"
            )
        }

        return await self._request(
            "POST",
            f"/pipelines/{pipeline_id}/execute",
            json_data=request_data,
            headers=headers,
            response_model=ExecutePipelineResponse,
        )

    async def set_pipeline_env_vars(
        self, pipeline_id: UUID, request: SetPipelineEnvVarsRequest
    ) -> SetPipelineEnvVarsResponse:
        """Set environment variables on a pipeline definition.

        Environment variables are encrypted at rest and injected into pipeline
        executions. They can be overridden at execution time by passing env_vars
        in the execute request.

        Args:
            pipeline_id: UUID of the pipeline
            request: Request with env_vars dict to set (empty dict clears all)

        Returns:
            Response with env_var_keys and masked_values

        Raises:
            NotFoundError: Pipeline not found or user lacks access
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                result = await client.set_pipeline_env_vars(
                    UUID("..."),
                    SetPipelineEnvVarsRequest(env_vars={"API_KEY": "secret123"})
                )
                print(f"Set keys: {result.env_var_keys}")
        """
        return await self._request(
            "PUT",
            f"/pipelines/{pipeline_id}/env-vars",
            json_data=request.model_dump(mode="json"),
            response_model=SetPipelineEnvVarsResponse,
        )

    async def register_local_pipeline_execution(
        self,
        request: RegisterLocalPipelineExecutionRequest,
    ) -> RegisterLocalPipelineExecutionResponse:
        return await self._request(
            "POST",
            "/pipelines/local-executions",
            json_data=request.model_dump(mode="json"),
            response_model=RegisterLocalPipelineExecutionResponse,
        )

    async def update_local_pipeline_execution(
        self,
        execution_id: UUID,
        request: UpdateLocalPipelineExecutionRequest,
    ) -> UpdateLocalPipelineExecutionResponse:
        return await self._request(
            "PATCH",
            f"/pipelines/local-executions/{execution_id}",
            json_data=request.model_dump(mode="json", exclude_none=True),
            response_model=UpdateLocalPipelineExecutionResponse,
        )

    async def finalize_hosted_pipeline_execution(
        self,
        execution_id: UUID,
        *,
        status: Literal["running", "waiting_for_approval", "completed", "failed"],
        result_data: dict[str, Any] | None = None,
        error_message: str | None = None,
        error_stack_trace: str | None = None,
        execution_stdout: str | None = None,
        execution_stderr: str | None = None,
        hosted_runtime_provider: str | None = None,
        hosted_runtime_job_id: str | None = None,
        orchestration_state: str | None = None,
    ) -> dict[str, Any]:
        json_data: dict[str, Any] = {
            "status": status,
            "result_data": result_data,
            "error_message": error_message,
            "error_stack_trace": error_stack_trace,
            "execution_stdout": execution_stdout,
            "execution_stderr": execution_stderr,
            "hosted_runtime_provider": hosted_runtime_provider,
            "hosted_runtime_job_id": hosted_runtime_job_id,
            "orchestration_state": orchestration_state,
        }
        if status == "running":
            json_data = {
                key: value for key, value in json_data.items() if value is not None
            }
        return await self._request(
            "PATCH",
            f"/pipelines/hosted-executions/{execution_id}",
            json_data=json_data,
            response_model=None,
        )

    async def list_local_pipeline_executions(
        self,
        *,
        status: str | None = None,
        external_id: str | None = None,
        search: str | None = None,
        tag_paths: list[str] | None = None,
        metadata_filter: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> ListPipelineExecutionsResponse:
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if status:
            params["status"] = status
        if external_id:
            params["external_id"] = external_id
        if search:
            params["search"] = search
        if tag_paths:
            params["tag_paths"] = tag_paths
        if metadata_filter:
            params["metadata_filter"] = metadata_filter

        return await self._request(
            "GET",
            "/pipelines/local-executions",
            params=params,
            response_model=ListPipelineExecutionsResponse,
        )

    async def get_local_pipeline_execution(
        self,
        execution_id: UUID,
    ) -> GetPipelineExecutionResponse:
        return await self._request(
            "GET",
            f"/pipelines/local-executions/{execution_id}",
            response_model=GetPipelineExecutionResponse,
        )

    # ========================================
    # Pipeline Execution Endpoints
    # ========================================

    async def list_pipeline_executions(
        self,
        pipeline_id: UUID,
        *,
        status: str | None = None,
        external_id: str | None = None,
        search: str | None = None,
        tag_paths: list[str] | None = None,
        metadata_filter: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> ListPipelineExecutionsResponse:
        """List executions for a pipeline.

        Args:
            pipeline_id: UUID of the pipeline
            status: Filter by status ('pending', 'running', 'completed', 'failed')
            external_id: Exact match filter on external_id
            search: Fuzzy search on external_id
            tag_paths: Filter by tag paths (e.g., ['pipeline.test'])
            metadata_filter: JSON string for JSONB containment filter
            limit: Results per page (1-100, default 50)
            offset: Number of results to skip (default 0)

        Returns:
            List of execution summaries with total count

        Raises:
            NotFoundError: Pipeline not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                response = await client.list_pipeline_executions(
                    UUID("..."),
                    status="completed",
                    limit=10
                )
                for execution in response.executions:
                    print(f"{execution.id}: {execution.status}")
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if status:
            params["status"] = status
        if external_id:
            params["external_id"] = external_id
        if search:
            params["search"] = search
        if tag_paths:
            params["tag_paths"] = tag_paths
        if metadata_filter:
            params["metadata_filter"] = metadata_filter

        return await self._request(
            "GET",
            f"/pipelines/{pipeline_id}/executions",
            params=params,
            response_model=ListPipelineExecutionsResponse,
        )

    async def get_pipeline_execution(
        self, pipeline_id: UUID, execution_id: UUID
    ) -> GetPipelineExecutionResponse:
        """Get a specific pipeline execution.

        Args:
            pipeline_id: UUID of the pipeline
            execution_id: UUID of the execution

        Returns:
            Full execution details including stdout/stderr

        Raises:
            NotFoundError: Pipeline or execution not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                response = await client.get_pipeline_execution(
                    UUID("..."), UUID("...")
                )
                print(f"Status: {response.execution.status}")
                print(f"Result: {response.execution.result_data}")
        """
        return await self._request(
            "GET",
            f"/pipelines/{pipeline_id}/executions/{execution_id}",
            response_model=GetPipelineExecutionResponse,
        )

    async def update_pipeline_execution(
        self,
        pipeline_id: UUID,
        execution_id: UUID,
        request: UpdatePipelineExecutionRequest,
    ) -> UpdatePipelineExecutionResponse:
        """Update a pipeline execution's metadata or tags.

        Args:
            pipeline_id: UUID of the pipeline
            execution_id: UUID of the execution
            request: Update request with metadata and/or tags

        Returns:
            Updated execution details

        Raises:
            NotFoundError: Pipeline or execution not found
            AuthenticationError: Invalid or missing API key
            ValidationError: Both metadata and metadata_merge provided

        Example:
            async with client:
                response = await client.update_pipeline_execution(
                    UUID("..."),
                    UUID("..."),
                    UpdatePipelineExecutionRequest(
                        metadata_merge={"processed": True},
                        tags=["pipeline.reviewed"]
                    )
                )
        """
        return await self._request(
            "PATCH",
            f"/pipelines/{pipeline_id}/executions/{execution_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdatePipelineExecutionResponse,
        )

    def iter_pipeline_executions(
        self,
        pipeline_id: UUID,
        *,
        status: str | None = None,
        external_id: str | None = None,
        search: str | None = None,
        tag_paths: list[str] | None = None,
        metadata_filter: str | None = None,
        page_size: int = 500,
    ) -> AsyncOffsetPaginator[PipelineExecutionSummary, ListPipelineExecutionsResponse]:
        """Iterate over all pipeline executions with automatic pagination.

        Args:
            pipeline_id: UUID of the pipeline
            status: Filter by status
            external_id: Exact match filter on external_id
            search: Fuzzy search on external_id
            tag_paths: Filter by tag paths
            metadata_filter: JSON string for JSONB containment filter
            page_size: Number of items per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields PipelineExecutionSummary items

        Example:
            async with client:
                async for execution in client.iter_pipeline_executions(UUID("...")):
                    print(execution.status)
        """

        async def fetch_page(limit: int, offset: int) -> ListPipelineExecutionsResponse:
            return await self.list_pipeline_executions(
                pipeline_id,
                status=status,
                external_id=external_id,
                search=search,
                tag_paths=tag_paths,
                metadata_filter=metadata_filter,
                limit=limit,
                offset=offset,
            )

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.executions,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    # ========================================
    # Pipeline Schedule Endpoints
    # ========================================

    async def list_pipeline_schedules(self, pipeline_id: UUID) -> ListSchedulesResponse:
        """List all schedules for a pipeline.

        Args:
            pipeline_id: UUID of the pipeline

        Returns:
            List of schedule summaries with total count

        Raises:
            NotFoundError: Pipeline not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                response = await client.list_pipeline_schedules(UUID("..."))
                for schedule in response.schedules:
                    print(f"{schedule.name}: next run at {schedule.next_run_at}")
        """
        return await self._request(
            "GET",
            f"/pipelines/{pipeline_id}/schedules",
            response_model=ListSchedulesResponse,
        )

    async def create_pipeline_schedule(
        self, pipeline_id: UUID, request: CreateScheduleRequest
    ) -> CreateScheduleResponse:
        """Create a recurring schedule for a pipeline.

        Args:
            pipeline_id: UUID of the pipeline
            request: Schedule creation request with name and schedule expression

        Returns:
            Created schedule with next_run_at

        Raises:
            NotFoundError: Pipeline not found
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid schedule expression or timezone

        Example:
            async with client:
                # Daily at 9am Chicago time
                schedule = await client.create_pipeline_schedule(
                    UUID("..."),
                    CreateScheduleRequest(
                        name="Daily Report",
                        schedule=CronSchedule(
                            expression="0 9 * * *",
                            timezone="America/Chicago"
                        ),
                        input_parameters={"report_type": "daily"}
                    )
                )
        """
        return await self._request(
            "POST",
            f"/pipelines/{pipeline_id}/schedules",
            json_data=request.model_dump(mode="json"),
            response_model=CreateScheduleResponse,
        )

    async def get_pipeline_schedule(
        self, pipeline_id: UUID, schedule_id: UUID
    ) -> GetScheduleResponse:
        """Get a specific pipeline schedule.

        Args:
            pipeline_id: UUID of the pipeline
            schedule_id: UUID of the schedule

        Returns:
            Full schedule details including RRULE/cron fields

        Raises:
            NotFoundError: Pipeline or schedule not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                schedule = await client.get_pipeline_schedule(
                    UUID("..."), UUID("...")
                )
                print(f"Next run: {schedule.next_run_at}")
        """
        return await self._request(
            "GET",
            f"/pipelines/{pipeline_id}/schedules/{schedule_id}",
            response_model=GetScheduleResponse,
        )

    async def update_pipeline_schedule(
        self, pipeline_id: UUID, schedule_id: UUID, request: UpdateScheduleRequest
    ) -> UpdateScheduleResponse:
        """Update a pipeline schedule.

        Args:
            pipeline_id: UUID of the pipeline
            schedule_id: UUID of the schedule
            request: Update request with fields to change

        Returns:
            Updated schedule summary

        Raises:
            NotFoundError: Pipeline or schedule not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                result = await client.update_pipeline_schedule(
                    UUID("..."),
                    UUID("..."),
                    UpdateScheduleRequest(is_enabled=False)
                )
        """
        return await self._request(
            "PATCH",
            f"/pipelines/{pipeline_id}/schedules/{schedule_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateScheduleResponse,
        )

    async def delete_pipeline_schedule(
        self, pipeline_id: UUID, schedule_id: UUID
    ) -> DeleteScheduleResponse:
        """Delete a pipeline schedule.

        Args:
            pipeline_id: UUID of the pipeline
            schedule_id: UUID of the schedule

        Returns:
            Confirmation with deleted schedule ID

        Raises:
            NotFoundError: Pipeline or schedule not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                result = await client.delete_pipeline_schedule(
                    UUID("..."), UUID("...")
                )
                print(f"Deleted: {result.deleted}")
        """
        return await self._request(
            "DELETE",
            f"/pipelines/{pipeline_id}/schedules/{schedule_id}",
            response_model=DeleteScheduleResponse,
        )

    async def set_schedule_env_vars(
        self, pipeline_id: UUID, schedule_id: UUID, request: SetScheduleEnvVarsRequest
    ) -> SetScheduleEnvVarsResponse:
        """Set environment variables on a pipeline schedule.

        Schedule-level env vars override pipeline-level env vars when the
        schedule triggers execution.

        Args:
            pipeline_id: UUID of the pipeline
            schedule_id: UUID of the schedule
            request: Request with env_vars dict to set (empty dict clears all)

        Returns:
            Response with env_var_keys and masked_values

        Raises:
            NotFoundError: Pipeline or schedule not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                result = await client.set_schedule_env_vars(
                    UUID("..."),
                    UUID("..."),
                    SetScheduleEnvVarsRequest(env_vars={"SCHEDULE_KEY": "value"})
                )
        """
        return await self._request(
            "PUT",
            f"/pipelines/{pipeline_id}/schedules/{schedule_id}/env-vars",
            json_data=request.model_dump(mode="json"),
            response_model=SetScheduleEnvVarsResponse,
        )

    async def schedule_pipeline_once(
        self, pipeline_id: UUID, request: ScheduleOnceRequest
    ) -> ScheduleOnceResponse:
        """Schedule a one-time pipeline execution.

        Args:
            pipeline_id: UUID of the pipeline
            request: Request with scheduled_for time and parameters

        Returns:
            Scheduled execution details

        Raises:
            NotFoundError: Pipeline not found
            AuthenticationError: Invalid or missing API key
            ValidationError: scheduled_for must be in the future

        Example:
            async with client:
                from datetime import datetime, timedelta

                result = await client.schedule_pipeline_once(
                    UUID("..."),
                    ScheduleOnceRequest(
                        scheduled_for=datetime.now() + timedelta(hours=1),
                        timezone="America/New_York",
                        input_parameters={"type": "special"}
                    )
                )
        """
        return await self._request(
            "POST",
            f"/pipelines/{pipeline_id}/schedule-once",
            json_data=request.model_dump(mode="json"),
            response_model=ScheduleOnceResponse,
        )

    def iter_pipeline_schedules(
        self, pipeline_id: UUID, *, page_size: int = 500
    ) -> AsyncOffsetPaginator[ScheduleSummary, ListSchedulesResponse]:
        """Iterate over all pipeline schedules with automatic pagination.

        Args:
            pipeline_id: UUID of the pipeline
            page_size: Number of items per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields ScheduleSummary items

        Example:
            async with client:
                async for schedule in client.iter_pipeline_schedules(UUID("...")):
                    print(f"{schedule.name}: {schedule.is_enabled}")
        """

        async def fetch_page(limit: int, offset: int) -> ListSchedulesResponse:
            # Note: schedule list endpoint doesn't support pagination yet
            # so we return the full list on first call
            return await self.list_pipeline_schedules(pipeline_id)

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.schedules,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    # ========================================
    # Approval Checkpoint Endpoints
    # ========================================

    async def get_approval_checkpoint(
        self, execution_id: UUID, approval_key: str
    ) -> ApprovalCheckpoint | None:
        """Get an approval checkpoint. Returns None if not found."""
        try:
            return await self._request(
                "GET",
                f"/pipeline-executions/{execution_id}/approvals/{approval_key}",
                response_model=ApprovalCheckpoint,
            )
        except NotFoundError:
            return None

    async def create_approval_checkpoint(
        self, execution_id: UUID, approval_key: str
    ) -> ApprovalCheckpoint:
        """Create a pending approval checkpoint (idempotent)."""
        return await self._request(
            "PUT",
            f"/pipeline-executions/{execution_id}/approvals/{approval_key}",
            response_model=ApprovalCheckpoint,
        )

    async def decide_approval(
        self,
        execution_id: UUID,
        approval_key: str,
        approved: bool,
        decision_data: dict[str, Any] | None = None,
    ) -> ApprovalCheckpoint:
        """Submit a decision on an approval checkpoint."""
        json_data: dict[str, Any] = {"approved": approved}
        if decision_data is not None:
            json_data["decision_data"] = decision_data
        return await self._request(
            "POST",
            f"/pipeline-executions/{execution_id}/approvals/{approval_key}/decide",
            json_data=json_data,
            response_model=ApprovalCheckpoint,
        )

    async def rerun_pipeline_execution(
        self, pipeline_id: UUID, execution_id: UUID
    ) -> ExecutePipelineResponse:
        """Re-run a failed or paused pipeline execution.

        Uses the same execution_id so completed steps are automatically
        skipped via idempotent cache-hits.

        Args:
            pipeline_id: UUID of the pipeline
            execution_id: UUID of the execution to rerun

        Returns:
            Execution response with updated status

        Raises:
            NotFoundError: Pipeline or execution not found
            ValueError: Execution not in a rerunnable state
        """
        return await self._request(
            "POST",
            f"/pipelines/{pipeline_id}/executions/{execution_id}/rerun",
            response_model=ExecutePipelineResponse,
        )

    # ========================================
    # Pipeline Cost Endpoints
    # ========================================

    async def get_execution_costs(
        self, pipeline_id: UUID, execution_id: UUID
    ) -> PipelineExecutionCostSummary:
        """Get cost summary for a specific pipeline execution.

        Args:
            pipeline_id: UUID of the pipeline
            execution_id: UUID of the execution

        Returns:
            Cost summary with LLM and RAG breakdowns

        Raises:
            NotFoundError: Pipeline or execution not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                costs = await client.get_execution_costs(UUID("..."), UUID("..."))
                print(f"Total: ${costs.total_cost_usd}")
        """
        return await self._request(
            "GET",
            f"/pipelines/{pipeline_id}/executions/{execution_id}/costs",
            response_model=PipelineExecutionCostSummary,
        )

    async def get_execution_costs_by_model(
        self, pipeline_id: UUID, execution_id: UUID
    ) -> list[ModelCostBreakdown]:
        """Get cost breakdown by model for a pipeline execution.

        Args:
            pipeline_id: UUID of the pipeline
            execution_id: UUID of the execution

        Returns:
            List of cost breakdowns per provider/model

        Raises:
            NotFoundError: Pipeline or execution not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                breakdown = await client.get_execution_costs_by_model(
                    UUID("..."), UUID("...")
                )
                for model in breakdown:
                    print(f"{model.provider_key}/{model.model_name}: ${model.total_cost_usd}")
        """
        data = await self._request(
            "GET",
            f"/pipelines/{pipeline_id}/executions/{execution_id}/costs/by-model",
        )
        return [ModelCostBreakdown.model_validate(item) for item in data]

    async def get_pipeline_costs(
        self,
        pipeline_id: UUID,
        *,
        start_date: str | None = None,
        end_date: str | None = None,
    ) -> PipelineDefinitionCostSummary:
        """Get aggregated cost summary for a pipeline definition.

        Args:
            pipeline_id: UUID of the pipeline
            start_date: Start of period (ISO 8601), defaults to 30 days ago
            end_date: End of period (ISO 8601), defaults to now

        Returns:
            Cost summary with execution statistics

        Raises:
            NotFoundError: Pipeline not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                costs = await client.get_pipeline_costs(
                    UUID("..."),
                    start_date="2026-01-01T00:00:00Z",
                    end_date="2026-01-31T23:59:59Z"
                )
                print(f"Total: ${costs.total_cost_usd} over {costs.execution_count} runs")
        """
        params: dict[str, Any] = {}
        if start_date:
            params["start_date"] = start_date
        if end_date:
            params["end_date"] = end_date

        return await self._request(
            "GET",
            f"/pipelines/{pipeline_id}/costs",
            params=params if params else None,
            response_model=PipelineDefinitionCostSummary,
        )

    async def get_pipeline_costs_daily(
        self,
        pipeline_id: UUID,
        *,
        start_date: str,
        end_date: str,
    ) -> list[DailyCostEntry]:
        """Get daily cost time series for a pipeline.

        Args:
            pipeline_id: UUID of the pipeline
            start_date: Start of period (ISO 8601)
            end_date: End of period (ISO 8601)

        Returns:
            List of daily cost entries

        Raises:
            NotFoundError: Pipeline not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                daily = await client.get_pipeline_costs_daily(
                    UUID("..."),
                    start_date="2026-01-01",
                    end_date="2026-01-31"
                )
                for day in daily:
                    print(f"{day.date}: ${day.total_cost_usd}")
        """
        data = await self._request(
            "GET",
            f"/pipelines/{pipeline_id}/costs/daily",
            params={"start_date": start_date, "end_date": end_date},
        )
        return [DailyCostEntry.model_validate(item) for item in data]

    # ========================================
    # Billing Project Endpoints
    # ========================================

    async def create_billing_project(
        self, request: CreateProjectRequest
    ) -> CreateProjectResponse:
        """Create a new billing project.

        Args:
            request: Project creation request with name

        Returns:
            Created project details

        Raises:
            ConflictError: Project with same name already exists
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                project = await client.create_billing_project(
                    CreateProjectRequest(
                        name="Q1 Campaign",
                        description="Marketing campaign costs"
                    )
                )
        """
        return await self._request(
            "POST",
            "/billing/projects",
            json_data=request.model_dump(mode="json"),
            response_model=CreateProjectResponse,
        )

    async def list_billing_projects(
        self, *, include_inactive: bool = False
    ) -> ProjectListResponse:
        """List billing projects in the organization.

        Args:
            include_inactive: Include inactive projects (default False)

        Returns:
            List of projects with total count

        Raises:
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                response = await client.list_billing_projects()
                for project in response.projects:
                    print(f"{project.name}: active={project.is_active}")
        """
        params: dict[str, Any] = {}
        if include_inactive:
            params["include_inactive"] = include_inactive

        return await self._request(
            "GET",
            "/billing/projects",
            params=params if params else None,
            response_model=ProjectListResponse,
        )

    async def get_billing_project(self, project_id: str | UUID) -> ProjectResponse:
        """Get a billing project by identifier.

        Args:
            project_id: UUID or name of the project

        Returns:
            Full project details

        Raises:
            NotFoundError: Project not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                project = await client.get_billing_project(UUID("..."))
                print(f"{project.name}: {project.description}")
        """
        return await self._request(
            "GET",
            f"/billing/projects/{project_id}",
            response_model=ProjectResponse,
        )

    async def get_billing_project_costs(
        self,
        project_id: str | UUID,
        *,
        start_date: str | None = None,
        end_date: str | None = None,
    ) -> ProjectCostSummary:
        """Get aggregated cost summary for a billing project.

        Args:
            project_id: UUID or name of the project
            start_date: Start of period (ISO 8601), defaults to 30 days ago
            end_date: End of period (ISO 8601), defaults to now

        Returns:
            Cost summary with LLM/RAG breakdown

        Raises:
            NotFoundError: Project not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                costs = await client.get_billing_project_costs(UUID("..."))
                print(f"Total: ${costs.total_cost_usd}")
        """
        params: dict[str, Any] = {}
        if start_date:
            params["start_date"] = start_date
        if end_date:
            params["end_date"] = end_date

        return await self._request(
            "GET",
            f"/billing/projects/{project_id}/costs",
            params=params if params else None,
            response_model=ProjectCostSummary,
        )

    async def get_billing_project_costs_daily(
        self,
        project_id: str | UUID,
        *,
        start_date: str,
        end_date: str,
    ) -> list[ProjectDailyCostEntry]:
        """Get daily cost time series for a billing project.

        Args:
            project_id: UUID or name of the project
            start_date: Start of period (ISO 8601)
            end_date: End of period (ISO 8601)

        Returns:
            List of daily cost entries

        Raises:
            NotFoundError: Project not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                daily = await client.get_billing_project_costs_daily(
                    UUID("..."),
                    start_date="2026-01-01",
                    end_date="2026-01-31"
                )
                for day in daily:
                    print(f"{day.date}: ${day.total_cost_usd}")
        """
        data = await self._request(
            "GET",
            f"/billing/projects/{project_id}/costs/daily",
            params={"start_date": start_date, "end_date": end_date},
        )
        return [ProjectDailyCostEntry.model_validate(item) for item in data]

    async def get_billing_project_costs_by_agent(
        self,
        project_id: str | UUID,
        *,
        start_date: str | None = None,
        end_date: str | None = None,
    ) -> list[ProjectCostByAgent]:
        """Get cost breakdown by agent for a billing project.

        Args:
            project_id: UUID or name of the project
            start_date: Start of period (ISO 8601)
            end_date: End of period (ISO 8601)

        Returns:
            List of cost breakdowns per agent

        Raises:
            NotFoundError: Project not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                breakdown = await client.get_billing_project_costs_by_agent(UUID("..."))
                for agent in breakdown:
                    print(f"{agent.agent_name}: ${agent.total_cost_usd}")
        """
        params: dict[str, Any] = {}
        if start_date:
            params["start_date"] = start_date
        if end_date:
            params["end_date"] = end_date

        data = await self._request(
            "GET",
            f"/billing/projects/{project_id}/costs/by-agent",
            params=params if params else None,
        )
        return [ProjectCostByAgent.model_validate(item) for item in data]

    async def get_billing_project_costs_by_model(
        self,
        project_id: str | UUID,
        *,
        start_date: str | None = None,
        end_date: str | None = None,
    ) -> list[ProjectCostByModel]:
        """Get cost breakdown by model for a billing project.

        Args:
            project_id: UUID or name of the project
            start_date: Start of period (ISO 8601)
            end_date: End of period (ISO 8601)

        Returns:
            List of cost breakdowns per provider/model

        Raises:
            NotFoundError: Project not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                breakdown = await client.get_billing_project_costs_by_model(UUID("..."))
                for model in breakdown:
                    print(f"{model.provider_key}/{model.model_name}: ${model.total_cost_usd}")
        """
        params: dict[str, Any] = {}
        if start_date:
            params["start_date"] = start_date
        if end_date:
            params["end_date"] = end_date

        data = await self._request(
            "GET",
            f"/billing/projects/{project_id}/costs/by-model",
            params=params if params else None,
        )
        return [ProjectCostByModel.model_validate(item) for item in data]

    def iter_billing_projects(
        self,
        *,
        include_inactive: bool = False,
        page_size: int = 500,
    ) -> AsyncOffsetPaginator[ProjectListItem, ProjectListResponse]:
        """Iterate over all billing projects with automatic pagination.

        Args:
            include_inactive: Include inactive projects
            page_size: Number of items per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields ProjectListItem items

        Example:
            async with client:
                async for project in client.iter_billing_projects():
                    print(f"{project.name}: {project.is_active}")
        """

        async def fetch_page(limit: int, offset: int) -> ProjectListResponse:
            # Note: billing projects list doesn't support pagination yet
            return await self.list_billing_projects(include_inactive=include_inactive)

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.projects,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    # ========================================
    # Tag Endpoints
    # ========================================

    async def list_tags(
        self,
        *,
        search: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> ListTagsResponse:
        """List tags accessible to the authenticated user.

        Returns tags that are in the user's organization scope.
        Results are ordered alphabetically by tag_path.

        Args:
            search: Optional fuzzy search string (space-separated tokens, all must match)
                    across tag_path, display_name, and description
            limit: Results per page (1-100, default 50)
            offset: Number of results to skip (default 0)

        Returns:
            List of tags with total count

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid query parameters

        Example:
            async with client:
                response = await client.list_tags(search="email", limit=20)
                for tag in response.tags:
                    print(f"{tag.tag_path}: {tag.display_name}")
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if search:
            params["search"] = search

        return await self._request(
            "GET",
            "/tags",
            params=params,
            response_model=ListTagsResponse,
        )

    async def create_tag(self, request: CreateTagRequest) -> CreateTagResponse:
        """Create a new tag in the organization.

        Tags are organization-scoped and use hierarchical paths with ltree.
        Only organization owners and admins can create tags.

        Args:
            request: Tag creation request with tag_path and optional display_name

        Returns:
            Created tag details

        Raises:
            ConflictError: Tag with same path already exists
            AuthenticationError: Invalid or missing API key
            ForbiddenError: User is not org owner/admin

        Example:
            async with client:
                tag = await client.create_tag(
                    CreateTagRequest(
                        tag_path="marketing.email.weekly",
                        display_name="Email Marketing Weekly"
                    )
                )
        """
        return await self._request(
            "POST",
            "/tags",
            json_data=request.model_dump(mode="json"),
            response_model=CreateTagResponse,
        )

    async def get_tag(self, tag_id: UUID) -> GetTagResponse:
        """Get a tag by ID.

        Args:
            tag_id: UUID of the tag

        Returns:
            Tag details

        Raises:
            NotFoundError: Tag not found
            AuthenticationError: Invalid or missing API key

        Example:
            async with client:
                tag = await client.get_tag(UUID("..."))
                print(f"{tag.tag.tag_path}: {tag.tag.display_name}")
        """
        return await self._request(
            "GET",
            f"/tags/{tag_id}",
            response_model=GetTagResponse,
        )

    async def update_tag(
        self, tag_id: UUID, request: UpdateTagRequest
    ) -> UpdateTagResponse:
        """Update a tag's display_name or description.

        The tag_path is immutable and cannot be changed.

        Args:
            tag_id: UUID of the tag
            request: Update request with fields to change

        Returns:
            Updated tag details

        Raises:
            NotFoundError: Tag not found
            AuthenticationError: Invalid or missing API key
            ForbiddenError: User is not org owner/admin

        Example:
            async with client:
                tag = await client.update_tag(
                    UUID("..."),
                    UpdateTagRequest(display_name="New Display Name")
                )
        """
        return await self._request(
            "PUT",
            f"/tags/{tag_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateTagResponse,
        )

    async def delete_tag(self, tag_id: UUID) -> DeleteTagResponse:
        """Delete a tag.

        Args:
            tag_id: UUID of the tag

        Returns:
            Deleted tag's final state

        Raises:
            NotFoundError: Tag not found
            AuthenticationError: Invalid or missing API key
            ForbiddenError: User is not org owner/admin

        Example:
            async with client:
                result = await client.delete_tag(UUID("..."))
                print(f"Deleted: {result.deleted_tag.tag_path}")
        """
        return await self._request(
            "DELETE",
            f"/tags/{tag_id}",
            response_model=DeleteTagResponse,
        )

    # ========================================
    # Pagination Iterator Methods
    # ========================================

    def iter_content_parts(
        self,
        *,
        search: str | None = None,
        tag_path: str | None = None,
        exclude_tags: list[str] | None = None,
        structured_filter: ConditionSpec | LogicalSpec | None = None,
        metadata_filter: ConditionSpec | LogicalSpec | None = None,
        agent_id: UUID | None = None,
        session_id: UUID | None = None,
        content_type_id: str | None = None,
        storage_type: ContentStorageType | None = None,
        start_date: str | None = None,
        end_date: str | None = None,
        sort_by: ContentSortField | None = None,
        sort_order: SortOrder | None = None,
        page_size: int = 500,
    ) -> AsyncCursorPaginator[ContentPartItemResponse, ListContentPartsResponse]:
        """Iterate over all content parts with automatic pagination.

        Returns an async iterator that automatically fetches pages as needed.
        Use `async for` to iterate item-by-item, or `.collect()` to get all items.

        Args:
            search: Optional fuzzy search string across content text, agent name, and session name
            tag_path: Optional tag ltree pattern to include
            exclude_tags: Optional list of tag patterns to exclude
            structured_filter: Optional advanced JSONB filter specification on content_structured
            metadata_filter: Optional advanced JSONB filter specification on part_metadata
            agent_id: Optional filter by agent ID
            session_id: Optional filter by chat session ID
            content_type_id: Optional filter by content type
            storage_type: Optional filter by storage type (text or structured)
            start_date: Optional start date for created_at range (ISO 8601 string)
            end_date: Optional end date for created_at range (ISO 8601 string)
            sort_by: Optional field to sort by (created_at or updated_at)
            sort_order: Optional sort direction (asc or desc)
            page_size: Number of items to fetch per page (default 500)

        Returns:
            AsyncCursorPaginator that yields ContentPartItemResponse items

        Example:
            async with client:
                # Iterate item-by-item
                async for item in client.iter_content_parts(agent_id=some_id):
                    print(item.content_text)

                # Collect all items (with 10k safety limit)
                all_items = await client.iter_content_parts().collect()

                # Collect with explicit max
                items = await client.iter_content_parts().collect(max_items=500)

                # Disable safety limit
                all_items = await client.iter_content_parts().collect(max_items=None)
        """

        async def fetch_page(cursor: str | None) -> ListContentPartsResponse:
            return await self.list_content_parts(
                search=search,
                tag_path=tag_path,
                exclude_tags=exclude_tags,
                structured_filter=structured_filter,
                metadata_filter=metadata_filter,
                agent_id=agent_id,
                session_id=session_id,
                content_type_id=content_type_id,
                storage_type=storage_type,
                start_date=start_date,
                end_date=end_date,
                sort_by=sort_by,
                sort_order=sort_order,
                limit=page_size,
                cursor=cursor,
            )

        return AsyncCursorPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.content_parts,
            extract_cursor=lambda r: r.cursor,
            page_size=page_size,
        )

    def iter_templates(
        self,
        *,
        content_type: TemplateContentType | None = None,
        visibility: TemplateVisibility | None = None,
        tag_path: str | None = None,
        search: str | None = None,
        include_content: bool = False,
        page_size: int = 500,
    ) -> AsyncCursorPaginator[TemplateListItemResponse, ListTemplatesResponse]:
        """Iterate over all templates with automatic pagination.

        Returns an async iterator that automatically fetches pages as needed.
        Use `async for` to iterate item-by-item, or `.collect()` to get all items.

        Args:
            content_type: Optional filter by content type
            visibility: Optional filter by visibility level
            tag_path: Optional filter by tag ltree pattern
            search: Optional full-text search on name and content
            include_content: If True, include template content in response
            page_size: Number of items to fetch per page (default 500)

        Returns:
            AsyncCursorPaginator that yields TemplateListItemResponse items

        Example:
            async with client:
                # Iterate item-by-item
                async for template in client.iter_templates(visibility=TemplateVisibility.PUBLIC):
                    print(template.name)

                # Collect all items
                all_templates = await client.iter_templates().collect()
        """

        async def fetch_page(cursor: str | None) -> ListTemplatesResponse:
            return await self.list_templates(
                content_type=content_type,
                visibility=visibility,
                tag_path=tag_path,
                search=search,
                include_content=include_content,
                limit=page_size,
                cursor=cursor,
            )

        return AsyncCursorPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.templates,
            extract_cursor=lambda r: r.cursor,
            page_size=page_size,
        )

    def iter_agents(
        self,
        *,
        search: str | None = None,
        page_size: int = 500,
    ) -> AsyncOffsetPaginator[AgentSummary, ListAgentsResponse]:
        """Iterate over all agents with automatic pagination.

        Returns an async iterator that automatically fetches pages as needed.
        Use `async for` to iterate item-by-item, or `.collect()` to get all items.

        Args:
            search: Optional fuzzy search string across agent name and description
            page_size: Number of items to fetch per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields AgentSummary items

        Example:
            async with client:
                # Iterate item-by-item
                async for agent in client.iter_agents(search="assistant"):
                    print(agent.name)

                # Collect all items
                all_agents = await client.iter_agents().collect()
        """

        async def fetch_page(limit: int, offset: int) -> ListAgentsResponse:
            return await self.list_agents(search=search, limit=limit, offset=offset)

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.agents,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    def iter_schemas(
        self,
        *,
        search: str | None = None,
        schema_format: str | None = None,
        page_size: int = 500,
    ) -> AsyncOffsetPaginator[SchemaSummary, ListSchemasResponse]:
        """Iterate over all schemas with automatic pagination.

        Returns an async iterator that automatically fetches pages as needed.
        Use `async for` to iterate item-by-item, or `.collect()` to get all items.

        Args:
            search: Optional fuzzy search string across schema name and description
            schema_format: Optional filter by schema format (e.g., 'json_schema', 'regex')
            page_size: Number of items to fetch per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields SchemaSummary items

        Example:
            async with client:
                # Iterate item-by-item
                async for schema in client.iter_schemas(schema_format="json_schema"):
                    print(schema.name)

                # Collect all items
                all_schemas = await client.iter_schemas().collect()
        """

        async def fetch_page(limit: int, offset: int) -> ListSchemasResponse:
            return await self.list_schemas(
                search=search, schema_format=schema_format, limit=limit, offset=offset
            )

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.schemas,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    def iter_sessions(
        self,
        *,
        search: str | None = None,
        page_size: int = 500,
    ) -> AsyncOffsetPaginator[SessionSummary, ListSessionsResponse]:
        """Iterate over all sessions with automatic pagination.

        Returns an async iterator that automatically fetches pages as needed.
        Use `async for` to iterate item-by-item, or `.collect()` to get all items.

        Args:
            search: Optional fuzzy search on session name
            page_size: Number of items to fetch per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields SessionSummary items

        Example:
            async with client:
                # Iterate item-by-item
                async for session in client.iter_sessions(search="project"):
                    print(session.name)

                # Collect all items
                all_sessions = await client.iter_sessions().collect()
        """

        async def fetch_page(limit: int, offset: int) -> ListSessionsResponse:
            return await self.list_sessions(search=search, limit=limit, offset=offset)

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.sessions,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    def iter_session_messages(
        self,
        session_id: UUID,
        *,
        role: str | None = None,
        order: str = "asc",
        page_size: int = 500,
    ) -> AsyncOffsetPaginator[MessageResponse, ListSessionMessagesResponse]:
        """Iterate over all messages in a session with automatic pagination.

        Returns an async iterator that automatically fetches pages as needed.
        Use `async for` to iterate item-by-item, or `.collect()` to get all items.

        Args:
            session_id: UUID of the session to list messages from
            role: Optional filter by message role (user, assistant, system, tool)
            order: Sort order for sequence_number ('asc' or 'desc', default 'asc')
            page_size: Number of items to fetch per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields MessageResponse items

        Example:
            async with client:
                # Iterate item-by-item
                async for message in client.iter_session_messages(session_id, role="assistant"):
                    print(message.content_parts)

                # Collect all items
                all_messages = await client.iter_session_messages(session_id).collect()
        """

        async def fetch_page(limit: int, offset: int) -> ListSessionMessagesResponse:
            return await self.list_session_messages(
                session_id, role=role, limit=limit, offset=offset, order=order
            )

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.messages,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    def iter_pipelines(
        self,
        *,
        search: str | None = None,
        page_size: int = 500,
    ) -> AsyncOffsetPaginator[PipelineSummary, ListPipelinesResponse]:
        """Iterate over all pipelines with automatic pagination.

        Returns an async iterator that automatically fetches pages as needed.
        Use `async for` to iterate item-by-item, or `.collect()` to get all items.

        Args:
            search: Optional fuzzy search on pipeline name and description
            page_size: Number of items to fetch per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields PipelineSummary items

        Example:
            async with client:
                # Iterate item-by-item
                async for pipeline in client.iter_pipelines(search="content"):
                    print(pipeline.name)

                # Collect all items
                all_pipelines = await client.iter_pipelines().collect()
        """

        async def fetch_page(limit: int, offset: int) -> ListPipelinesResponse:
            return await self.list_pipelines(search=search, limit=limit, offset=offset)

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.pipelines,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    def iter_tags(
        self,
        *,
        search: str | None = None,
        page_size: int = 500,
    ) -> AsyncOffsetPaginator[TagResponse, ListTagsResponse]:
        """Iterate over all tags with automatic pagination.

        Returns an async iterator that automatically fetches pages as needed.
        Use `async for` to iterate item-by-item, or `.collect()` to get all items.

        Args:
            search: Optional fuzzy search string across tag_path, display_name, and description
            page_size: Number of items to fetch per page (default 500)

        Returns:
            AsyncOffsetPaginator that yields TagResponse items

        Example:
            async with client:
                # Iterate item-by-item
                async for tag in client.iter_tags(search="email"):
                    print(tag.tag_path)

                # Collect all items
                all_tags = await client.iter_tags().collect()
        """

        async def fetch_page(limit: int, offset: int) -> ListTagsResponse:
            return await self.list_tags(search=search, limit=limit, offset=offset)

        return AsyncOffsetPaginator(
            fetch_page=fetch_page,
            extract_items=lambda r: r.tags,
            extract_total=lambda r: r.total,
            page_size=page_size,
        )

    # ========================================
    # Render Endpoints
    # ========================================

    async def render_html_to_image(
        self, request: RenderHtmlToImageRequest
    ) -> RenderHtmlToImageResponse:
        """Render HTML text to a PNG image.

        Converts HTML text with styling options into a base64-encoded PNG image.
        Uses Playwright for headless browser rendering.

        Args:
            request: Render request with HTML text and optional styling parameters
                - html_text: HTML content to render (can include tags like <strong>, <em>)
                - bg_color: Background color in hex format (default: #ffffff)
                - text_color: Text color in hex format (default: #000000)
                - font_family: CSS font-family value (default: Georgia, serif)
                - font_url: URL to web font stylesheet, e.g., Google Fonts (optional)
                - width: Image width in pixels (100-4096, default: 1080)
                - height: Image height in pixels (100-4096, default: 1080)

        Returns:
            RenderHtmlToImageResponse with base64-encoded PNG and metadata

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid request parameters
            ServerError: Render service unavailable (503)

        Example:
            async with client:
                response = await client.render_html_to_image(
                    RenderHtmlToImageRequest(
                        html_text="<strong>Hello</strong>, World!",
                        bg_color="#2d2d2d",
                        text_color="#00ff00",
                        width=800,
                        height=600,
                    )
                )
                # response.image_base64 contains the PNG data
                # response.render_time_ms contains rendering duration
        """
        return await self._request(
            "POST",
            "/render/html-to-image",
            json_data=request.model_dump(mode="json"),
            response_model=RenderHtmlToImageResponse,
        )

    # ========================================
    # Knowledge Endpoints
    # ========================================

    async def create_collection(
        self, request: CreateCollectionRequest
    ) -> CreateCollectionResponse:
        """Create a new document collection.

        Args:
            request: Collection creation request with name, description, and chunking settings

        Returns:
            Created collection with metadata

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid request parameters
            ConflictError: Collection with same name already exists
        """
        return await self._request(
            "POST",
            "/knowledge/collections",
            json_data=request.model_dump(mode="json"),
            response_model=CreateCollectionResponse,
        )

    async def list_collections(
        self, limit: int = 50, offset: int = 0
    ) -> ListCollectionsResponse:
        """List document collections.

        Args:
            limit: Maximum number of collections to return (default 50)
            offset: Number of collections to skip (default 0)

        Returns:
            List of collections with total count

        Raises:
            AuthenticationError: Invalid or missing API key
        """
        return await self._request(
            "GET",
            "/knowledge/collections",
            params={"limit": limit, "offset": offset},
            response_model=ListCollectionsResponse,
        )

    async def list_collection_statuses(
        self,
        collection_id: str | None = None,
    ) -> ListCollectionDocumentStatusesResponse:
        """List per-collection document status counts for a collection UUID or name.

        Args:
            collection_id: Optional collection UUID or name to filter the summary list

        Returns:
            ListCollectionDocumentStatusesResponse

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Collection not found
        """
        params: dict[str, str] = {}
        if collection_id:
            params["collection_id"] = collection_id

        return await self._request(
            "GET",
            "/knowledge/collection-statuses",
            params=params or None,
            response_model=ListCollectionDocumentStatusesResponse,
        )

    async def get_collection(self, collection_id: str) -> GetCollectionResponse:
        """Get collection details by ID.

        Args:
            collection_id: UUID or name of the collection

        Returns:
            Collection details including document count

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Collection not found
        """
        return await self._request(
            "GET",
            f"/knowledge/collections/{collection_id}",
            response_model=GetCollectionResponse,
        )

    async def delete_collection(self, collection_id: str) -> DeleteCollectionResponse:
        """Delete a collection and all its documents.

        Args:
            collection_id: UUID or name of the collection to delete

        Returns:
            Deleted collection info including document count deleted

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Collection not found
        """
        return await self._request(
            "DELETE",
            f"/knowledge/collections/{collection_id}",
            response_model=DeleteCollectionResponse,
        )

    async def ingest_document(
        self, request: IngestDocumentRequest
    ) -> IngestDocumentResponse:
        """Ingest a document into a collection.

        The document is queued for background processing via Celery.
        Poll GET /knowledge/documents/{document_id} for status updates.

        Args:
            request: Document ingestion request with collection_id, title,
                content, question generation configuration, and optional
                per-document chunking overrides

        Returns:
            Response with document_id and queued status

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid request parameters
            NotFoundError: Collection not found
            ConflictError: Document with identical content already exists
        """
        return await self._request(
            "POST",
            "/knowledge/documents",
            json_data=request.model_dump(mode="json"),
            response_model=IngestDocumentResponse,
        )

    async def list_documents(
        self,
        limit: int = 50,
        offset: int = 0,
        collection_id: str | None = None,
        processing_status: str | None = None,
    ) -> ListDocumentsResponse:
        """List documents with optional filtering.

        Args:
            limit: Maximum number of documents to return (default 50)
            offset: Number of documents to skip (default 0)
            collection_id: Filter by collection UUID or name
            processing_status: Filter by status (queued|processing|completed|failed|duplicate|filtered)

        Returns:
            List of documents with total count

        Raises:
            AuthenticationError: Invalid or missing API key
        """
        params: dict[str, str | int] = {"limit": limit, "offset": offset}
        if collection_id:
            params["collection_id"] = collection_id
        if processing_status:
            params["processing_status"] = processing_status

        return await self._request(
            "GET",
            "/knowledge/documents",
            params=params,
            response_model=ListDocumentsResponse,
        )

    async def get_document(
        self,
        document_id: str,
        include_content: bool = False,
    ) -> GetDocumentResponse:
        """Get document details by ID.

        Args:
            document_id: UUID of the document
            include_content: Include original document content in response

        Returns:
            Document details including processing status and chunk counts

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Document not found
        """
        params: dict[str, str | bool] = {}
        if include_content:
            params["include_content"] = include_content

        return await self._request(
            "GET",
            f"/knowledge/documents/{document_id}",
            params=params if params else None,
            response_model=GetDocumentResponse,
        )

    async def wait_for_document_processing(
        self,
        document_id: str,
        *,
        timeout: float = 60.0,
        poll_interval: float = 1.0,
    ) -> GetDocumentResponse:
        """Wait for document processing to complete (success or failure).

        This method polls the document status until it reaches a terminal state
        (completed or failed). It correctly handles all terminal states, preventing
        the common bug of polling indefinitely when processing fails.

        Args:
            document_id: UUID of the document to wait for
            timeout: Maximum time to wait in seconds (default: 60)
            poll_interval: Time between polls in seconds (default: 1.0)

        Returns:
            Final document response when processing completes successfully

        Raises:
            DocumentProcessingError: If document processing fails
            DocumentProcessingTimeout: If timeout exceeded before reaching terminal state
            AuthenticationError: Invalid or missing API key
            NotFoundError: Document not found

        Example:
            # Ingest and wait for processing
            ingest_response = await client.ingest_document(request)
            doc = await client.wait_for_document_processing(
                ingest_response.document_id,
                timeout=120.0,
            )
            print(f"Document processed: {doc.chunks_count} chunks created")

        Example with error handling:
            try:
                doc = await client.wait_for_document_processing(doc_id)
            except DocumentProcessingError as e:
                print(f"Processing failed: {e.processing_error}")
            except DocumentProcessingTimeout as e:
                print(f"Timed out after {e.timeout}s, last status: {e.last_status}")
        """
        elapsed = 0.0
        last_status: ProcessingStatus = "queued"

        while elapsed < timeout:
            doc = await self.get_document(document_id)
            last_status = doc.processing_status

            # Check if processing has reached a terminal state
            # Use the API's is_processing_terminal field if available,
            # otherwise fall back to checking known terminal states
            is_terminal = getattr(doc, "is_processing_terminal", None)
            if is_terminal is None:
                is_terminal = is_terminal_status(last_status)

            if is_terminal:
                if last_status == "failed":
                    raise DocumentProcessingError(
                        document_id=document_id,
                        processing_error=doc.processing_error,
                    )
                return doc

            await asyncio.sleep(poll_interval)
            elapsed += poll_interval

        raise DocumentProcessingTimeout(
            document_id=document_id,
            last_status=last_status,
            timeout=timeout,
        )

    async def delete_document(self, document_id: str) -> DeleteDocumentResponse:
        """Delete a document and all its chunks.

        Args:
            document_id: UUID of the document to delete

        Returns:
            Deleted document info including chunk count deleted

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Document not found
        """
        return await self._request(
            "DELETE",
            f"/knowledge/documents/{document_id}",
            response_model=DeleteDocumentResponse,
        )

    async def reingest_document(
        self, document_id: str, request: ReingestDocumentRequest | None = None
    ) -> ReingestDocumentResponse:
        """Reingest a document with new processing parameters.

        Re-processes an existing document, optionally with updated chunking
        parameters or content type detection. Useful when:
        - Chunking strategy has changed
        - Document processing failed and needs retry
        - Content type was misdetected

        Args:
            document_id: UUID of the document to reingest
            request: Optional reingest parameters (agent/template,
                document type, and per-document chunking overrides)

        Returns:
            Document status showing it's queued for reprocessing

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Document not found
            ConflictError: Document is currently processing

        Example:
            async with client:
                result = await client.reingest_document(
                    "doc-123",
                    ReingestDocumentRequest(
                        chunk_size_tokens=768,
                        chunk_overlap_tokens=64,
                    )
                )
                print(f"Status: {result.status}")
        """
        json_data = request.model_dump(mode="json") if request else None
        return await self._request(
            "POST",
            f"/knowledge/documents/{document_id}/reingest",
            json_data=json_data,
            response_model=ReingestDocumentResponse,
        )

    async def bulk_reingest_failed_documents(
        self, request: BulkReingestFailedDocumentsRequest
    ) -> BulkReingestFailedDocumentsResponse:
        return await self._request(
            "POST",
            "/knowledge/documents/reingest-failed",
            json_data=request.model_dump(mode="json"),
            response_model=BulkReingestFailedDocumentsResponse,
        )

    async def update_document_metadata(
        self, document_id: str, request: UpdateDocumentMetadataRequest
    ) -> UpdateDocumentMetadataResponse:
        """Update a document's metadata.

        Args:
            document_id: UUID of the document to update
            request: Metadata update request with metadata dict and mode (merge/replace)

        Returns:
            Updated document with fresh metadata state

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Document not found
        """
        return await self._request(
            "PATCH",
            f"/knowledge/documents/{document_id}",
            json_data=request.model_dump(mode="json"),
            response_model=UpdateDocumentMetadataResponse,
        )

    async def bulk_update_document_metadata(
        self, request: BulkUpdateDocumentMetadataRequest
    ) -> BulkUpdateDocumentMetadataResponse:
        """Bulk-update metadata across documents matching a filter.

        Args:
            request: Bulk update request with JSONB filter, patch, and optional collection scope

        Returns:
            Response with matched and updated counts

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid request parameters
        """
        return await self._request(
            "PATCH",
            "/knowledge/documents/metadata",
            json_data=request.model_dump(mode="json"),
            response_model=BulkUpdateDocumentMetadataResponse,
        )

    async def search_knowledge(self, request: SearchRequest) -> SearchResponse:
        """Search the knowledge base.

        Performs semantic search across document collections using hybrid retrieval
        (vector + full-text search) with optional HyDE and query decomposition.

        Args:
            request: Search request with query, collection filters, and execution options

        Returns:
            Search results with relevance scores, timing, and transformation metadata

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid request parameters
            NotFoundError: Specified collections not found
        """
        return await self._request(
            "POST",
            "/knowledge/search",
            json_data=request.model_dump(mode="json"),
            response_model=SearchResponse,
        )

    async def recover_documents(
        self, request: RecoverDocumentsRequest | None = None
    ) -> RecoverDocumentsResponse:
        """Trigger document recovery for stuck documents.

        Finds documents stuck in 'processing' state (worker crashed), retries or
        fails those stale claimed documents, and can optionally restart queued
        backlog documents from scratch.

        Args:
            request: Optional request with max_retries override and queued restart mode

        Returns:
            Recovery result with counts and document IDs

        Raises:
            AuthenticationError: Invalid or missing API key
        """
        json_data = request.model_dump(mode="json") if request else None
        return await self._request(
            "POST",
            "/knowledge/recovery",
            json_data=json_data,
            response_model=RecoverDocumentsResponse,
        )

    async def update_collection(
        self,
        collection_id: str,
        request: UpdateCollectionRequest,
    ) -> UpdateCollectionResponse:
        """Update a collection's name, description, or rerank instruction.

        Args:
            collection_id: UUID or name of the collection to update
            request: Fields to update (all optional - only provided fields are changed)

        Returns:
            Updated collection details

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Collection not found
            ConflictError: Name already exists in organization
        """
        return await self._request(
            "PATCH",
            f"/knowledge/collections/{collection_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateCollectionResponse,
        )

    async def trigger_collection_clustering(
        self,
        collection_id: str,
    ) -> TriggerCollectionClusteringResponse:
        """Trigger asynchronous clustering for a collection.

        Queues a background clustering run and returns immediately with pending status.

        Args:
            collection_id: UUID of the collection to cluster

        Returns:
            Trigger response with collection ID and pending status

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Collection not found
        """
        return await self._request(
            "POST",
            f"/knowledge/collections/{collection_id}/cluster",
            response_model=TriggerCollectionClusteringResponse,
        )

    async def get_collection_clustering_status(
        self,
        collection_id: str,
        *,
        task_id: str,
    ) -> GetCollectionClusteringStatusResponse:
        """Get clustering status and coverage metrics for a collection.

        Args:
            collection_id: UUID of the collection
            task_id: Optional Celery task ID for run-scoped status polling

        Returns:
            Current clustering status and assignment coverage stats

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Collection not found
        """
        params = {"task_id": task_id}

        return await self._request(
            "GET",
            f"/knowledge/collections/{collection_id}/cluster/status",
            params=params,
            response_model=GetCollectionClusteringStatusResponse,
        )

    async def wait_for_collection_clustering(
        self,
        collection_id: str,
        *,
        task_id: str,
        timeout: float = 300.0,
        poll_interval: float = 2.0,
    ) -> GetCollectionClusteringStatusResponse:
        """Wait until collection clustering reaches a terminal status.

        Args:
            collection_id: UUID of the collection
            task_id: Optional Celery task ID returned by trigger endpoint
            timeout: Maximum seconds to wait before raising timeout
            poll_interval: Seconds between status polls

        Returns:
            Final clustering status response in a terminal state

        Raises:
            CollectionClusteringError: Clustering reached failed state
            CollectionClusteringTimeout: Timeout exceeded before terminal status
            AuthenticationError: Invalid or missing API key
            NotFoundError: Collection not found
        """
        start_time = time.monotonic()
        last_status: str = "pending"

        while True:
            elapsed = time.monotonic() - start_time
            if elapsed >= timeout:
                break

            status_response = await self.get_collection_clustering_status(
                collection_id,
                task_id=task_id,
            )
            last_status = status_response.status

            is_terminal = status_response.is_terminal
            if not is_terminal:
                is_terminal = is_terminal_clustering_status(status_response.status)

            if is_terminal:
                if status_response.status == "failed":
                    raise CollectionClusteringError(
                        collection_id=collection_id,
                        error_message=status_response.error_message,
                    )
                return status_response

            remaining = timeout - (time.monotonic() - start_time)
            if remaining <= 0:
                break

            sleep_seconds = poll_interval if poll_interval > 0 else 0.0
            await asyncio.sleep(min(sleep_seconds, remaining))

        raise CollectionClusteringTimeout(
            collection_id=collection_id,
            last_status=last_status,
            timeout=timeout,
        )

    async def list_syntheses(
        self,
        *,
        collection_id: str | None = None,
        status: str | None = None,
        include_stale: bool = True,
        limit: int = 50,
        offset: int = 0,
    ) -> ListSynthesesResponse:
        """List syntheses with optional filtering.

        Args:
            collection_id: Filter by collection UUID
            status: Filter by status (accepted|queued|worker_assigned|processing|completed|failed)
            include_stale: Include stale syntheses (default True)
            limit: Maximum number of results (default 50)
            offset: Pagination offset (default 0)

        Returns:
            List of synthesis summaries with total count

        Raises:
            AuthenticationError: Invalid or missing API key
        """
        params: dict[str, str | int | bool] = {
            "include_stale": include_stale,
            "limit": limit,
            "offset": offset,
        }
        if collection_id:
            params["collection_id"] = collection_id
        if status:
            params["status"] = status

        return await self._request(
            "GET",
            "/knowledge/synthesis",
            params=params,
            response_model=ListSynthesesResponse,
        )

    async def create_synthesis(
        self,
        request: CreateSynthesisRequest,
    ) -> CreateSynthesisResponse:
        """Create a corpus-level synthesis.

        The request is accepted after validation and persistence.
        Identical payloads deduplicate automatically to the same synthesis row.
        Poll GET /knowledge/synthesis/{synthesis_id} for status updates.

        Args:
            request: Synthesis creation request with topic, collection_ids,
                synthesizer_agent_id, and synthesis_template_id

        Returns:
            Response with synthesis_id and accepted/active status metadata

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid request parameters
            NotFoundError: Agent, template, or collections not found
        """
        return await self._request(
            "POST",
            "/knowledge/synthesis",
            json_data=request.model_dump(mode="json"),
            response_model=CreateSynthesisResponse,
        )

    async def get_synthesis(
        self,
        synthesis_id: str,
    ) -> GetSynthesisResponse:
        """Get synthesis details by ID.

        Args:
            synthesis_id: UUID of the synthesis

        Returns:
            Synthesis details including content (if completed), status,
            staleness information, and source tracking

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Synthesis not found
        """
        return await self._request(
            "GET",
            f"/knowledge/synthesis/{synthesis_id}",
            response_model=GetSynthesisResponse,
        )

    async def delete_synthesis(
        self,
        synthesis_id: str,
    ) -> DeleteSynthesisResponse:
        """Delete a synthesis.

        This permanently removes the synthesis, its collection associations,
        and its source tracking records.

        Args:
            synthesis_id: UUID of the synthesis to delete

        Returns:
            Deleted synthesis info (id, topic, title)

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Synthesis not found
        """
        return await self._request(
            "DELETE",
            f"/knowledge/synthesis/{synthesis_id}",
            response_model=DeleteSynthesisResponse,
        )

    async def update_synthesis(
        self,
        synthesis_id: str,
        request: UpdateSynthesisRequest,
    ) -> UpdateSynthesisResponse:
        """Update a synthesis.

        Update synthesis properties like title or topic.

        Args:
            synthesis_id: UUID of the synthesis to update
            request: Update request with fields to change

        Returns:
            Updated synthesis details

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Synthesis not found

        Example:
            async with client:
                result = await client.update_synthesis(
                    "synth-123",
                    UpdateSynthesisRequest(title="Updated Title")
                )
        """
        return await self._request(
            "PATCH",
            f"/knowledge/synthesis/{synthesis_id}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateSynthesisResponse,
        )

    async def refresh_synthesis(
        self,
        synthesis_id: str,
    ) -> RefreshSynthesisResponse:
        """Refresh a synthesis.

        Regenerates the synthesis content using the same topic, agent, and template.
        Use this to update a stale synthesis or to get updated results.

        Cannot refresh a synthesis that is currently processing.

        Args:
            synthesis_id: UUID of the synthesis to refresh

        Returns:
            Response with synthesis_id and new pending status

        Raises:
            AuthenticationError: Invalid or missing API key
            NotFoundError: Synthesis not found
            ConflictError: Synthesis is currently processing
        """
        return await self._request(
            "POST",
            f"/knowledge/synthesis/{synthesis_id}/refresh",
            response_model=RefreshSynthesisResponse,
        )

    # ========================================
    # Tool Config Endpoints
    # ========================================

    async def create_tool_config(
        self, request: CreateToolConfigRequest
    ) -> CreateToolConfigResponse:
        """Create a reusable tool configuration.

        Tool configs store validated configurations for tools (e.g., rag_search, web_search)
        that can be referenced by multiple agents, eliminating duplication.

        Args:
            request: Tool config creation request with name, tool_id, config, and scope

        Returns:
            Created tool config with auto-generated slug

        Raises:
            ValidationError: Invalid config for the tool's parameter_schema
            NotFoundError: Tool ID does not exist
            ConflictError: Slug already exists in scope
            AuthenticationError: Invalid or missing API key
        """
        return await self._request(
            "POST",
            "/tool-configs",
            json_data=request.model_dump(mode="json"),
            response_model=CreateToolConfigResponse,
        )

    async def list_tool_configs(
        self,
        *,
        search: str | None = None,
        tool_id: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> ListToolConfigsResponse:
        """List tool configs accessible to the authenticated user.

        Returns tool configs that are:
        - Owned by the user
        - Owned by the user's organization
        - Global (where both organization_id and user_id are NULL)

        Args:
            search: Optional fuzzy search string across name and description
            tool_id: Optional filter by tool ID (e.g., 'rag_search')
            limit: Results per page (1-100, default 50)
            offset: Number of results to skip (default 0)

        Returns:
            List of tool configs with summary information and total count

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid query parameters
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if search:
            params["search"] = search
        if tool_id:
            params["tool_id"] = tool_id

        return await self._request(
            "GET",
            "/tool-configs",
            params=params,
            response_model=ListToolConfigsResponse,
        )

    async def get_tool_config(self, identifier: str) -> GetToolConfigResponse:
        """Get a single tool config by UUID or slug.

        Accessible if the tool config is:
        - Owned by the user
        - Owned by the user's organization
        - Global (where both organization_id and user_id are NULL)

        Args:
            identifier: Tool config UUID string or slug

        Returns:
            Tool config with full configuration details

        Raises:
            NotFoundError: Tool config not found or user lacks access
            AuthenticationError: Invalid or missing API key
        """
        return await self._request(
            "GET",
            f"/tool-configs/{identifier}",
            response_model=GetToolConfigResponse,
        )

    async def update_tool_config(
        self, identifier: str, request: UpdateToolConfigRequest
    ) -> UpdateToolConfigResponse:
        """Update an existing tool config.

        All fields in the request are optional - only provided fields are updated.
        Changing the name will auto-generate a new slug.

        Args:
            identifier: Tool config UUID string or slug
            request: Tool config update request with optional name, config, description

        Returns:
            Updated tool config with fresh state from database

        Raises:
            NotFoundError: Tool config not found or user lacks access
            ValidationError: Invalid config for the tool's parameter_schema
            ForbiddenError: User lacks permission to modify
            AuthenticationError: Invalid or missing API key
        """
        return await self._request(
            "PUT",
            f"/tool-configs/{identifier}",
            json_data=request.model_dump(mode="json", exclude_unset=True),
            response_model=UpdateToolConfigResponse,
        )

    async def delete_tool_config(self, identifier: str) -> DeleteToolConfigResponse:
        """Delete a tool config.

        Deletion fails if the tool config is currently referenced by any agent.

        Args:
            identifier: Tool config UUID string or slug

        Returns:
            Deleted tool config's final state

        Raises:
            NotFoundError: Tool config not found or user lacks access
            ConflictError: Tool config is in use by agents
            ForbiddenError: User lacks permission to delete
            AuthenticationError: Invalid or missing API key
        """
        return await self._request(
            "DELETE",
            f"/tool-configs/{identifier}",
            response_model=DeleteToolConfigResponse,
        )

    # ========================================
    # Tools Endpoints
    # ========================================

    async def list_tools(
        self,
        *,
        search: str | None = None,
        limit: int = 50,
        offset: int = 0,
    ) -> ListToolsResponse:
        """List all available tools.

        Tools are global resources defining agent capabilities (web_search, rag_search, etc.).
        Results are ordered alphabetically by tool ID.

        Args:
            search: Optional fuzzy search string across tool ID and description
            limit: Results per page (1-100, default 50)
            offset: Number of results to skip (default 0)

        Returns:
            List of tools with summary information and total count

        Raises:
            AuthenticationError: Invalid or missing API key
            ValidationError: Invalid query parameters
        """
        params: dict[str, Any] = {"limit": limit, "offset": offset}
        if search:
            params["search"] = search

        return await self._request(
            "GET",
            "/tools",
            params=params,
            response_model=ListToolsResponse,
        )

    async def get_tool(self, tool_id: str) -> ToolResponse:
        """Get a single tool by ID.

        Returns the complete tool definition including the parameter_schema
        which defines what configuration options the tool accepts.

        Args:
            tool_id: Tool identifier (e.g., 'rag_search', 'web_search')

        Returns:
            Tool with full details including parameter_schema

        Raises:
            NotFoundError: Tool not found
            AuthenticationError: Invalid or missing API key
        """
        return await self._request(
            "GET",
            f"/tools/{tool_id}",
            response_model=ToolResponse,
        )

    # ========================================
    # Audio Endpoints
    # ========================================

    async def submit_audio_transcription(
        self,
        audio_bytes: bytes | None = None,
        *,
        backend: AudioTranscriptionBackend,
        diarize: bool,
        filename: str | None = None,
        content_type: str | None = None,
        audio_url: str | None = None,
        external_id: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> AudioTranscriptionSubmitResponse:
        try:
            AudioTranscriptionSubmitRequest(
                backend=backend,
                diarize=diarize,
                audio_bytes=audio_bytes,
                filename=filename,
                content_type=content_type,
                audio_url=audio_url,
                external_id=external_id,
                metadata=metadata,
            )
        except Exception as exc:
            raise ValidationError(str(exc), errors=[]) from exc

        data: dict[str, str] = {
            "backend": backend,
            "diarize": "true" if diarize else "false",
        }
        if external_id is not None:
            data["external_id"] = external_id
        if metadata is not None:
            data["metadata"] = json.dumps(metadata)
        if audio_url is not None:
            data["audio_url"] = audio_url
            return await self._request(
                "POST",
                "/audio/transcriptions",
                data=data,
                response_model=AudioTranscriptionSubmitResponse,
            )

        assert audio_bytes is not None
        assert filename is not None
        assert content_type is not None
        return await self._upload(
            "POST",
            "/audio/transcriptions",
            audio_bytes,
            "file",
            filename,
            content_type,
            AudioTranscriptionSubmitResponse,
            data=data,
        )

    async def get_audio_transcription(
        self,
        transcription_id: str | UUID,
        *,
        request_timeout_seconds: float | None = None,
    ) -> AudioTranscriptionStatusResponse:
        return await self._request(
            "GET",
            f"/audio/transcriptions/{transcription_id}",
            timeout=request_timeout_seconds,
            response_model=AudioTranscriptionStatusResponse,
        )

    async def get_audio_transcription_output(
        self,
        transcription_id: str | UUID,
        *,
        format: AudioTranscriptionOutputFormat,
    ) -> AudioTranscriptionJSONOutput | str:
        client = self._ensure_client()

        try:
            response = await client.request(
                method="GET",
                url=self._api_base + f"/audio/transcriptions/{transcription_id}/output",
                params={"format": format},
            )

            if response.status_code >= 400:
                error_detail, error_data = self._parse_error_response(response)
                self._raise_for_status(response.status_code, error_detail, error_data)

            if format == "json":
                return AudioTranscriptionJSONOutput.model_validate_json(response.text)

            return response.text
        except httpx.RequestError as e:
            raise RequestError(f"Request failed: {e}") from e

    async def wait_for_audio_transcription(
        self,
        transcription_id: str | UUID,
        *,
        poll_interval_seconds: float = 2.0,
        timeout_seconds: float | None = None,
    ) -> AudioTranscriptionStatusResponse:
        start_time = time.monotonic()

        while True:
            elapsed = time.monotonic() - start_time
            if timeout_seconds is not None:
                if elapsed >= timeout_seconds:
                    raise TimeoutError(
                        f"Timed out waiting for audio transcription {transcription_id}"
                    )

            request_timeout_seconds: float | None = None
            if timeout_seconds is not None:
                request_timeout_seconds = timeout_seconds - elapsed
                if request_timeout_seconds <= 0:
                    raise TimeoutError(
                        f"Timed out waiting for audio transcription {transcription_id}"
                    )

            status_response = await self.get_audio_transcription(
                transcription_id,
                request_timeout_seconds=request_timeout_seconds,
            )
            if status_response.is_terminal:
                return status_response

            sleep_seconds = poll_interval_seconds if poll_interval_seconds > 0 else 0.0
            if timeout_seconds is None:
                await asyncio.sleep(sleep_seconds)
                continue

            remaining = timeout_seconds - (time.monotonic() - start_time)
            if remaining <= 0:
                raise TimeoutError(
                    f"Timed out waiting for audio transcription {transcription_id}"
                )

            await asyncio.sleep(min(sleep_seconds, remaining))
