"""
Minimal LiteLLM wrapper - thin abstraction for easy backend swaps.

Philosophy:
- LiteLLM handles ALL providers, routing, caching, retries
- This wrapper ONLY provides: sync/async normalization, structured output, Ondine response format
- ZERO provider-specific logic - just pass through to LiteLLM
- Clean interface makes future backend swaps trivial

Usage:
    # Any LiteLLM model format works (provider/model):
    spec = LLMSpec(model="openai/gpt-4o-mini", api_key="sk-...")  # pragma: allowlist secret
    spec = LLMSpec(model="groq/llama-3.3-70b-versatile", api_key="gsk_...")  # pragma: allowlist secret
    spec = LLMSpec(model="moonshot/kimi-k2-thinking-turbo", api_key="sk-...")  # pragma: allowlist secret

    # Advanced: Use extra_params for ANY LiteLLM feature
    spec = LLMSpec(
        model="openai/gpt-4o-mini",
        api_key="sk-...",  # pragma: allowlist secret
        extra_params={
            'stream': True,              # Streaming
            'caching': True,             # Enable caching
            'max_retries': 3,            # Built-in retries
            'top_p': 0.9,                # Sampling params
            'response_format': {...},    # JSON mode
            'tools': [...],              # Function calling
            # ... ANY LiteLLM param works!
        }
    )
    client = UnifiedLiteLLMClient(spec)

The extra_params pattern means:
- No code changes needed when LiteLLM adds new features
- User has full control over LiteLLM's capabilities
- Wrapper stays minimal and maintainable
"""

import asyncio
import logging
import time
import uuid
import warnings
from datetime import datetime
from decimal import Decimal
from typing import Any

import instructor
import litellm
from pydantic import BaseModel

logger = logging.getLogger(__name__)

# Import Ondine Exceptions for mapping
# Type hint for ObserverDispatcher (avoid circular import)
from typing import TYPE_CHECKING

from ondine.adapters.llm_client import LLMClient
from ondine.core.exceptions import (
    InvalidAPIKeyError,
    ModelNotFoundError,
    QuotaExceededError,
)
from ondine.core.models import LLMResponse
from ondine.core.specifications import LLMSpec
from ondine.utils.retry_handler import NetworkError
from ondine.utils.retry_handler import RateLimitError as OndineRateLimitError

if TYPE_CHECKING:
    from ondine.observability.dispatcher import ObserverDispatcher

# CRITICAL: Suppress Pydantic serialization warnings at module level
# LiteLLM's internal models trigger these harmless warnings
warnings.filterwarnings("ignore", category=UserWarning, module="pydantic")
warnings.filterwarnings("ignore", message=".*Expected.*fields but got.*")
warnings.filterwarnings("ignore", message=".*serialized value may not be as expected.*")


class UnifiedLiteLLMClient(LLMClient):
    """
    Thin wrapper around LiteLLM - minimal abstraction.

    Just provides:
    - Async/sync interface
    - Structured output via Instructor
    - Ondine response format
    - Optional Router support

    Everything else (providers, retries, caching) = LiteLLM's job.
    """

    def __init__(self, spec: LLMSpec):
        super().__init__(spec)

        # Build model identifier for LiteLLM
        # If model has "/", use as-is (e.g., "moonshot/kimi-k2")
        # Otherwise, prepend provider (e.g., "groq" + "llama-3.3" → "groq/llama-3.3")
        provider_name = (
            spec.provider.value
            if hasattr(spec.provider, "value")
            else str(spec.provider)
        )
        provider_name = (
            provider_name.replace("litellm_", "").replace("litellm", "").strip()
        )

        if "/" in spec.model:
            self.model = spec.model  # Already has provider prefix
        else:
            # Only prepend if we have a non-empty provider
            if provider_name:
                self.model = f"{provider_name}/{spec.model}"
            else:
                self.model = spec.model  # No provider, hope model is complete

        self.provider_name = (
            (provider_name if provider_name else self.model.split("/")[0])
            if "/" in self.model
            else provider_name
        )
        self.structured_model = (
            self.model.split("/", 1)[1]
            if self.provider_name == "anthropic" and "/" in self.model
            else self.model
        )
        self.api_key = spec.api_key
        self._uses_direct_anthropic_instructor = False

        # Suppress LiteLLM noise and async cleanup warnings
        litellm.suppress_debug_info = True
        litellm.drop_params = True
        litellm.set_verbose = False  # CRITICAL: Disable ALL LiteLLM internal logging
        logging.getLogger("LiteLLM").setLevel(logging.CRITICAL)
        logging.getLogger("LiteLLM Router").setLevel(logging.CRITICAL)
        logging.getLogger("LiteLLM Proxy").setLevel(logging.CRITICAL)
        logging.getLogger("httpx").setLevel(logging.ERROR)

        # Suppress harmless async cleanup warnings that occur on script exit
        # (LiteLLM spawns background tasks that may not complete before event loop closes)
        import warnings

        warnings.filterwarnings("ignore", category=RuntimeWarning)
        warnings.filterwarnings(
            "ignore", category=UserWarning
        )  # Pydantic serialization warnings
        warnings.filterwarnings("ignore", category=DeprecationWarning)
        warnings.filterwarnings("ignore", message=".*Event loop is closed.*")
        warnings.filterwarnings("ignore", message=".*coroutine.*never awaited.*")
        warnings.filterwarnings(
            "ignore", message=".*PydanticSerializationUnexpectedValue.*"
        )
        warnings.filterwarnings("ignore", message=".*Pydantic serializer warnings.*")
        warnings.filterwarnings("ignore", message=".*Expected.*fields but got.*")
        warnings.filterwarnings(
            "ignore", message=".*serialized value may not be as expected.*"
        )

        # Suppress asyncio SSL transport errors (harmless cleanup noise)
        logging.getLogger("asyncio").setLevel(logging.CRITICAL)

        # Suppress LiteLLM's internal async client cleanup warnings
        logging.getLogger("litellm.llms.custom_httpx.async_client_cleanup").setLevel(
            logging.CRITICAL
        )

        # CRITICAL: Suppress Pydantic's internal warnings logger
        logging.getLogger("pydantic").setLevel(logging.CRITICAL)
        logging.getLogger("pydantic.warnings").setLevel(logging.CRITICAL)
        logging.getLogger("pydantic._internal").setLevel(logging.CRITICAL)

        # Router support (optional)
        self.router = None
        if hasattr(spec, "router_config") and spec.router_config:
            self._init_router(spec.router_config)

        # Router initialization can rewrite self.model to the shared model group,
        # so structured_model must be derived after the router is configured.
        self.structured_model = (
            self.model.split("/", 1)[1]
            if self.provider_name == "anthropic"
            and "/" in self.model
            and not self.router
            else self.model
        )

        # Initialize Cache (optional)
        # Supports Redis, Disk, S3, etc. via LiteLLM native caching
        if hasattr(spec, "cache_config") and spec.cache_config:
            try:
                from litellm.caching import Cache

                litellm.cache = Cache(**spec.cache_config)
                logger.debug(f"Initialized LiteLLM cache: {spec.cache_config}")
            except Exception as e:
                logger.warning(f"Failed to initialize LiteLLM cache: {e}")

        # Initialize Instructor client ONCE (not per-call!)
        # Use intelligent mode detection (layered approach)
        from ondine.adapters.instructor_mode import detect_instructor_mode

        # Get router model list if available
        router_model_list = None
        if self.router and hasattr(self.router, "model_list"):
            router_model_list = self.router.model_list

        # Detect best instructor mode using layered approach:
        # 1. User override (from spec.instructor_mode if set)
        # 2. Special cases (reasoning_effort)
        # 3. LiteLLM model capabilities lookup
        # 4. Provider registry fallback
        # 5. Safe default (JSON)
        instructor_mode = detect_instructor_mode(
            model=self.model,
            extra_params=spec.extra_params,
            user_override=getattr(spec, "instructor_mode", "auto"),
            router_model_list=router_model_list,
        )

        # Anthropic structured output currently works more reliably through the
        # native Instructor adapter than through LiteLLM's OpenAI-style tool shim.
        if self.provider_name == "anthropic" and not self.router:
            from anthropic import AsyncAnthropic

            self.instructor_client = instructor.from_anthropic(
                AsyncAnthropic(
                    api_key=self.api_key,
                    base_url=getattr(spec, "base_url", None) or None,
                ),
                mode=instructor_mode,
            )
            self._uses_direct_anthropic_instructor = True
        else:
            completion_func = (
                self.router.acompletion if self.router else litellm.acompletion
            )
            self.instructor_client = instructor.from_litellm(
                completion_func, mode=instructor_mode
            )

        logger.debug(f"Initialized LiteLLM client: {self.model}")

        # Observer dispatcher for emitting LLM call events (set via set_observer_dispatcher)
        self._observer_dispatcher: ObserverDispatcher | None = None

    def set_observer_dispatcher(self, dispatcher: "ObserverDispatcher") -> None:
        """
        Set the observer dispatcher for emitting LLM call events.

        This decouples observability from LiteLLM's internal callback mechanism,
        allowing each observer to use its provider's SDK directly.

        Args:
            dispatcher: The ObserverDispatcher instance to use
        """
        self._observer_dispatcher = dispatcher

    def _emit_llm_call_event(
        self,
        prompt: str,
        completion: str,
        tokens_in: int,
        tokens_out: int,
        cost: Decimal,
        latency_ms: float,
        metadata: dict,
        system_message: str | None = None,
        error: Exception | None = None,
    ) -> None:
        """
        Emit an LLM call event to all registered observers.

        This is called after each successful LLM invocation to notify
        observers (Langfuse, OpenTelemetry, etc.) about the call.
        """
        if not self._observer_dispatcher:
            return

        try:
            from ondine.observability.events import LLMCallEvent

            # Extract provider from model string (e.g., "groq/llama-3.3" -> "groq")
            provider = self.model.split("/")[0] if "/" in self.model else "unknown"

            event = LLMCallEvent(
                # Context (use placeholder UUIDs - pipeline will set real ones)
                pipeline_id=uuid.UUID("00000000-0000-0000-0000-000000000000"),
                run_id=uuid.UUID("00000000-0000-0000-0000-000000000000"),
                stage_name="LLMInvocation",
                row_index=0,
                timestamp=datetime.now(),
                trace_id=str(uuid.uuid4()),
                span_id=str(uuid.uuid4())[:16],
                # LLM Request
                prompt=prompt,
                model=self.model,
                provider=provider,
                temperature=self.spec.temperature,
                completion=completion,
                system_message=system_message,
                max_tokens=self.spec.max_tokens,
                # Metrics
                input_tokens=tokens_in,
                output_tokens=tokens_out,
                total_tokens=tokens_in + tokens_out,
                cost=cost,
                latency_ms=latency_ms,
                # Metadata
                metadata=metadata,
            )

            self._observer_dispatcher.dispatch("llm_call", event)

        except Exception as e:
            # Never let observer errors crash the LLM call
            logger.debug(f"Failed to emit LLM call event: {e}")

    async def start(self):
        """
        Optional startup hook.

        Kept as a no-op for compatibility with `LLMInvocationStage`, which calls
        `await llm_client.start()` for any provider.

        Note: We intentionally do NOT inject custom HTTP transports here.
        LiteLLM already manages its own async transport + connection pooling.
        """
        return

    async def stop(self):
        """
        Optional shutdown hook.

        Kept as a no-op for compatibility with `LLMInvocationStage`.
        """
        return

    def _init_router(self, config: dict):
        """
        Initialize LiteLLM Router - generic wrapper behavior.

        Safety checks:
        - All deployments must share the same model_name (required for load balancing)
        - Maps 'debug' config to LiteLLM's 'set_verbose'
        - Registers cooldown callback for circuit breaker observability
        """
        from litellm import Router

        from ondine.utils.rich_utils import display_router_deployments

        try:
            # Validate all deployments share same model_name (required for Router load balancing)
            model_names = {m["model_name"] for m in config["model_list"]}
            if len(model_names) != 1:
                raise ValueError(
                    f"Router requires all deployments to share the same model_name. "
                    f"Found {len(model_names)} different names: {model_names}. "
                    f"Use unique 'model_id' for tracking instead."
                )

            # Map our 'debug' to LiteLLM's 'set_verbose'
            router_kwargs = dict(config)
            verbose_mode = router_kwargs.pop("debug", False)
            if verbose_mode:
                router_kwargs["set_verbose"] = True

            self.router = Router(**router_kwargs)
            self.model = model_names.pop()  # Use shared model_name

            # Store resilience config for observability
            self._allowed_fails = config.get("allowed_fails", 3)
            self._cooldown_time = config.get("cooldown_time", 60)
            self._wrap_router_failure_callbacks()

            # Display Router info using Rich utilities
            strategy = router_kwargs.get("routing_strategy", "simple-shuffle")
            display_router_deployments(
                model_name=self.model,
                strategy=strategy,
                deployments=config["model_list"],
                verbose=verbose_mode or router_kwargs.get("set_verbose", False),
            )

            # Log resilience config
            if self._allowed_fails > 0 and self._cooldown_time > 0:
                logger.info(
                    f"Circuit breaker enabled: "
                    f"{self._allowed_fails} fails → {self._cooldown_time}s cooldown"
                )

            # ============================================================
            # CRITICAL: Aggressively suppress Router's internal JSON logging
            # ============================================================
            router_logger = logging.getLogger("LiteLLM Router")
            router_logger.setLevel(logging.CRITICAL)
            router_logger.propagate = False

            # Suppress litellm's underlying logger
            litellm_logger = logging.getLogger("litellm")
            litellm_logger.setLevel(logging.CRITICAL)
            litellm_logger.propagate = False

            # Remove all handlers to prevent JSON dumps
            for handler in router_logger.handlers[:]:
                router_logger.removeHandler(handler)
            for handler in litellm_logger.handlers[:]:
                litellm_logger.removeHandler(handler)
            # ============================================================
        except Exception as e:
            logger.error(f"Router init failed: {e}")
            self.router = None

    def _wrap_router_failure_callbacks(self) -> None:
        """Attach Ondine cooldown observability to LiteLLM Router failure hooks."""
        if self.router is None:
            return

        original_failure_callback = getattr(
            self.router, "deployment_callback_on_failure", None
        )
        if callable(original_failure_callback):

            def wrapped_failure_callback(*args, **kwargs):
                result = original_failure_callback(*args, **kwargs)
                if result:
                    callback_kwargs = args[0] if args else kwargs.get("kwargs", {})
                    litellm_params = callback_kwargs.get("litellm_params", {}) or {}
                    model_info = litellm_params.get("model_info", {}) or {}
                    deployment = {
                        "model_id": str(model_info.get("id"))
                        if model_info.get("id") is not None
                        else None,
                        "model_name": callback_kwargs.get("model", self.model),
                        "litellm_params": {
                            "model": litellm_params.get("model", self.model)
                        },
                    }
                    exception = callback_kwargs.get(
                        "exception", Exception("Router deployment failure")
                    )
                    self._emit_cooldown_event(deployment, exception)
                return result

            self.router.deployment_callback_on_failure = wrapped_failure_callback

    def _emit_cooldown_event(
        self,
        deployment: dict,
        exception: Exception,
    ) -> None:
        """
        Emit a provider cooldown event when circuit breaker triggers.

        Called by LiteLLM Router when a deployment exceeds allowed_fails.
        """
        if not self._observer_dispatcher:
            return

        try:
            from ondine.observability.events import ProviderCooldownEvent

            # Extract deployment info
            model_info = deployment.get("litellm_params", {})
            provider = model_info.get("model", "unknown")
            deployment_id = deployment.get(
                "model_id", deployment.get("model_name", "unknown")
            )

            event = ProviderCooldownEvent(
                pipeline_id=uuid.UUID("00000000-0000-0000-0000-000000000000"),
                run_id=uuid.UUID("00000000-0000-0000-0000-000000000000"),
                timestamp=datetime.now(),
                trace_id=str(uuid.uuid4()),
                span_id=str(uuid.uuid4())[:16],
                provider=provider,
                deployment_id=deployment_id,
                reason=str(exception)[:500],  # Truncate long error messages
                cooldown_duration=getattr(self, "_cooldown_time", 60),
                fail_count=getattr(self, "_allowed_fails", 3),
            )

            self._observer_dispatcher.dispatch("provider_cooldown", event)
            logger.warning(
                f"🔴 Circuit breaker triggered: {provider} entering {event.cooldown_duration}s cooldown"
            )

        except Exception as e:
            # Never let observer errors crash the pipeline
            logger.debug(f"Failed to emit cooldown event: {e}")

    def _map_provider_error(self, error: Exception) -> Exception:
        """
        Map provider-specific exceptions to Ondine domain exceptions.

        Centralizes error classification logic to decouple stages from provider details.
        Distinguishes between Fatal (NonRetryable) and Transient (Retryable) errors.

        Args:
            error: The raw exception from LiteLLM/Provider

        Returns:
            Mapped Ondine exception or original exception if no mapping exists
        """
        error_str = str(error).lower()

        # 1. Unwrap Instructor Retry Exceptions (recursively)
        try:
            from instructor.core.exceptions import InstructorRetryException

            if isinstance(error, InstructorRetryException):
                # Try to find the underlying cause
                if hasattr(error, "last_attempt") and error.last_attempt:
                    if hasattr(error.last_attempt, "exception"):
                        inner_exc = error.last_attempt.exception()
                        if inner_exc:
                            # Recurse to map the inner exception
                            return self._map_provider_error(inner_exc)
                elif hasattr(error, "args") and error.args:
                    if isinstance(error.args[0], Exception):
                        return self._map_provider_error(error.args[0])
        except ImportError:
            pass

        # 2. Check for Network errors (Retryable) - CHECK FIRST
        if (
            "network" in error_str
            or "timeout" in error_str
            or "connection" in error_str
            or "service unavailable" in error_str
            or "503" in error_str
            or "502" in error_str
        ):
            provider_info = ""
            if hasattr(error, "model") and error.model and error.model != "mixed-llm":
                provider_info = f" [Provider: {error.model}]"
            return NetworkError(f"{str(error)}{provider_info}")

        # 3. Check for Quota/Billing errors (Fatal) - CHECK BEFORE RATE LIMIT
        # Because Providers often return 429 for BOTH Rate Limit and Quota
        quota_patterns = [
            "quota exceeded",
            "insufficient_quota",
            "billing",
            "credits exhausted",
            "account suspended",
            "payment required",
            "tokens per day limit exceeded",  # Cerebras Quota
            "tokens per hour limit exceeded",  # Cerebras Quota
            "tokens per month limit exceeded",  # Cerebras Quota
        ]
        if any(p in error_str for p in quota_patterns):
            return QuotaExceededError(f"Quota error: {error}")

        # 4. Check for Rate Limit (Retryable)
        # LiteLLM usually wraps these in its own RateLimitError, but check string too
        if (
            "rate" in error_str
            or "429" in error_str
            or isinstance(error, litellm.RateLimitError)
        ):
            return OndineRateLimitError(str(error))

        # 5. Check for Authentication errors (Fatal)
        auth_patterns = [
            "invalid api key",
            "authentication failed",
            "401",
            "403",
            "unauthorized",
            "invalid credentials",
            "permission denied",
        ]
        # Check OpenAI/Anthropic specific auth errors types if available
        try:
            from openai import AuthenticationError as OpenAIAuthError

            if isinstance(error, OpenAIAuthError):
                return InvalidAPIKeyError(f"OpenAI authentication failed: {error}")
        except ImportError:
            pass

        try:
            from anthropic import AuthenticationError as AnthropicAuthError

            if isinstance(error, AnthropicAuthError):
                return InvalidAPIKeyError(f"Anthropic authentication failed: {error}")
        except ImportError:
            pass

        if any(p in error_str for p in auth_patterns):
            return InvalidAPIKeyError(f"Authentication error: {error}")

        # 6. Check for Model Not Found (Fatal)
        model_patterns = [
            "decommissioned",
            "not found",
            "does not exist",
            "invalid model",
            "unknown model",
        ]
        if any(p in error_str for p in model_patterns):
            return ModelNotFoundError(f"Model error: {error}")

        # Return original if no mapping matches
        return error

    async def ainvoke(self, prompt: str, **kwargs: Any) -> LLMResponse:
        """Async call to LLM - pass through to LiteLLM."""
        start = time.time()

        # Build messages
        messages = [{"role": "user", "content": prompt}]
        if kwargs.get("system_message"):
            messages.insert(0, {"role": "system", "content": kwargs["system_message"]})

        # Build call kwargs
        call_kwargs = {
            "model": self.model,
            "messages": messages,
            "temperature": self.spec.temperature,
            "max_tokens": self.spec.max_tokens,
        }

        # CRITICAL: Only pass api_key if NOT using Router
        # Router has keys embedded in its model_list config!
        if (
            not self.router
            and self.api_key
            and not self._uses_direct_anthropic_instructor
        ):
            call_kwargs["api_key"] = self.api_key

        # Add api_base if custom endpoint
        if (
            hasattr(self.spec, "base_url")
            and self.spec.base_url
            and not self._uses_direct_anthropic_instructor
        ):
            call_kwargs["api_base"] = self.spec.base_url

        # CRITICAL: Pass through any extra params from spec (streaming, caching, retries, etc.)
        # This makes the wrapper truly "thin" - ANY LiteLLM param works without code changes!
        if hasattr(self.spec, "extra_params") and self.spec.extra_params:
            call_kwargs.update(self.spec.extra_params)

        # Call LiteLLM (Router or direct)
        try:
            if self.router:
                response = await self.router.acompletion(**call_kwargs)
            else:
                response = await litellm.acompletion(**call_kwargs)
        except Exception as e:
            raise self._map_provider_error(e)

        # Extract response
        text = response.choices[0].message.content
        tokens_in = response.usage.prompt_tokens if response.usage else 0
        tokens_out = response.usage.completion_tokens if response.usage else 0
        latency_ms = (time.time() - start) * 1000

        # Calculate cost (LiteLLM has pricing DB) - pass response object for accurate cost
        cost = self._calc_cost_from_response(response)

        # Check for prompt caching (OpenAI/Azure/Anthropic/Groq)
        self._check_cache_hit(response, tokens_in)

        # Extract Router deployment info (if available)
        # LiteLLM Router stores actual deployment ID in _hidden_params
        metadata = {}
        if self.router:
            # Try multiple methods to extract deployment info
            if hasattr(response, "_hidden_params") and response._hidden_params:
                hidden = response._hidden_params
                if isinstance(hidden, dict):
                    # Method 1: model_id field
                    if "model_id" in hidden:
                        metadata["model_id"] = hidden["model_id"]
                    # Method 2: model_region or custom_llm_provider
                    elif "model_region" in hidden:
                        metadata["model_id"] = hidden["model_region"]

        # Emit LLM call event to observers
        self._emit_llm_call_event(
            prompt=prompt,
            completion=text,
            tokens_in=tokens_in,
            tokens_out=tokens_out,
            cost=cost,
            latency_ms=latency_ms,
            metadata=metadata,
            system_message=kwargs.get("system_message"),
        )

        return LLMResponse(
            text=text,
            tokens_in=tokens_in,
            tokens_out=tokens_out,
            model=self.model,
            cost=cost,
            latency_ms=latency_ms,
            metadata=metadata,  # Pass deployment info to stage
        )

    def invoke(self, prompt: str, **kwargs: Any) -> LLMResponse:
        """
        Sync wrapper that works in both scripts and Jupyter notebooks.

        Automatically detects if running in an async context (Jupyter, FastAPI, etc.)
        and handles it appropriately.
        """
        try:
            # Check if we're already in an event loop (Jupyter, FastAPI, etc.)
            loop = asyncio.get_running_loop()
        except RuntimeError:
            # No loop running - safe to use asyncio.run() (script mode)
            return asyncio.run(self.ainvoke(prompt, **kwargs))
        else:
            # Already in a loop - schedule and wait (Jupyter mode)
            future = asyncio.run_coroutine_threadsafe(
                self.ainvoke(prompt, **kwargs), loop
            )
            return future.result()

    def _calc_cost_from_response(self, response: Any) -> Decimal:
        """
        Calculate cost from LiteLLM response object.

        Simple logic: LiteLLM already calculated the cost, just use it!
        """
        # SIMPLE: Check if LiteLLM already calculated cost (it's in _hidden_params)
        if hasattr(response, "_hidden_params") and response._hidden_params:
            hidden = response._hidden_params
            if isinstance(hidden, dict) and "response_cost" in hidden:
                cost = hidden["response_cost"]
                if cost and cost > 0:
                    return Decimal(str(cost))

        # Fallback: Calculate ourselves
        try:
            model_to_use = self.model
            if self.router and hasattr(response, "model") and response.model:
                model_to_use = response.model

            cost = litellm.completion_cost(
                completion_response=response,
                model=model_to_use,
            )
            if cost and cost > 0:
                return Decimal(str(cost))
        except Exception:  # nosec B110
            # Cost calculation is non-critical, fallback to manual calculation
            pass

        # Last resort: Manual calculation from spec
        if self.spec.input_cost_per_1k_tokens or self.spec.output_cost_per_1k_tokens:
            tokens_in = response.usage.prompt_tokens if response.usage else 0
            tokens_out = response.usage.completion_tokens if response.usage else 0
            return self._calc_cost(tokens_in, tokens_out)

        return Decimal("0")

    def _calc_cost(self, tokens_in: int, tokens_out: int) -> Decimal:
        """
        Calculate cost using token counts (fallback method).

        Framework-wide behavior:
        1. Falls back to manual calculation if pricing available in spec
        2. Returns $0 if pricing unavailable (prevents pipeline errors)

        Pipelines consume response.cost without needing to know calculation method.
        """
        try:
            # Fallback to manual calculation if specified in spec
            if (
                self.spec.input_cost_per_1k_tokens
                and self.spec.output_cost_per_1k_tokens
            ):
                input_cost = (
                    Decimal(tokens_in / 1000) * self.spec.input_cost_per_1k_tokens
                )
                output_cost = (
                    Decimal(tokens_out / 1000) * self.spec.output_cost_per_1k_tokens
                )
                return input_cost + output_cost
            # Return $0 if pricing unavailable (avoids breaking pipelines)
            return Decimal("0")
        except Exception:
            return Decimal("0")

    def estimate_tokens(self, text: str) -> int:
        """Estimate tokens using LiteLLM."""
        try:
            return len(litellm.encode(model=self.model, text=text))
        except Exception:
            return int(len(text.split()) * 1.3)

    def calculate_cost(self, tokens_in: int, tokens_out: int) -> Decimal:
        """
        Public cost calculation for estimates.

        Uses LiteLLM's pricing database first, falls back to spec pricing.
        For Router configs, uses the first deployment's model for pricing.
        """
        # Determine model to use for pricing lookup
        model_for_pricing = self.model

        # If using Router, get the actual model from first deployment
        if (
            self.router
            and hasattr(self.router, "model_list")
            and self.router.model_list
        ):
            first_deployment = self.router.model_list[0]
            litellm_params = first_deployment.get("litellm_params", {})
            model_for_pricing = litellm_params.get("model", self.model)

        try:
            # Use LiteLLM's cost_per_token for accurate pricing
            input_cost, output_cost = litellm.cost_per_token(
                model=model_for_pricing,
                prompt_tokens=tokens_in,
                completion_tokens=tokens_out,
            )
            # cost_per_token returns TOTAL cost, not per-token
            return Decimal(str(input_cost + output_cost))
        except Exception:  # nosec B110
            # Fallback to spec-based calculation
            return self._calc_cost(tokens_in, tokens_out)

    def structured_invoke(
        self, prompt: str, output_cls: type[BaseModel], **kwargs: Any
    ) -> LLMResponse:
        """
        Sync wrapper for structured output that works in both scripts and Jupyter.

        Automatically detects if running in an async context and handles it appropriately.
        """
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            # No loop - use asyncio.run()
            return asyncio.run(
                self.structured_invoke_async(prompt, output_cls, **kwargs)
            )
        else:
            # Loop exists - use run_coroutine_threadsafe
            future = asyncio.run_coroutine_threadsafe(
                self.structured_invoke_async(prompt, output_cls, **kwargs), loop
            )
            return future.result()

    async def structured_invoke_async(
        self, prompt: str, output_cls: type[BaseModel], **kwargs: Any
    ) -> LLMResponse:
        """Structured output via Instructor - use pre-initialized client."""
        start = time.time()

        # Build messages
        messages = [{"role": "user", "content": prompt}]
        if kwargs.get("system_message"):
            messages.insert(0, {"role": "system", "content": kwargs["system_message"]})

        # Build call kwargs
        call_kwargs = {
            "model": self.structured_model,
            "messages": messages,
            "response_model": output_cls,
            "temperature": self.spec.temperature,
            "max_tokens": self.spec.max_tokens,
        }

        # CRITICAL: Only pass api_key through LiteLLM-backed Instructor clients.
        # Anthropic's native Instructor adapter gets credentials from AsyncAnthropic.
        if (
            not self.router
            and self.api_key
            and not self._uses_direct_anthropic_instructor
        ):
            call_kwargs["api_key"] = self.api_key

        # Retry strategy: Default to 1 (Ondine handles retries at pipeline level)
        # Allow override via extra_params if needed
        call_kwargs["max_retries"] = (
            self.spec.extra_params.get("max_retries", 1)
            if hasattr(self.spec, "extra_params") and self.spec.extra_params
            else 1
        )

        if (
            hasattr(self.spec, "base_url")
            and self.spec.base_url
            and not self._uses_direct_anthropic_instructor
        ):
            call_kwargs["api_base"] = self.spec.base_url

        # CRITICAL: Pass through extra params (caching, response_format, tools, etc.)
        if hasattr(self.spec, "extra_params") and self.spec.extra_params:
            call_kwargs.update(self.spec.extra_params)

        # Prevent models from returning multiple tool calls in a single response.
        # Instructor's parse_tools() asserts exactly one tool call per response;
        # parallel tool calls (common with temperature > 0) trigger an assertion.
        if self.instructor_client.mode in (
            instructor.Mode.TOOLS,
            getattr(instructor.Mode, "TOOLS_STRICT", None),
            getattr(instructor.Mode, "PARALLEL_TOOLS", None),
        ):
            call_kwargs.setdefault("parallel_tool_calls", False)

        # Call with pre-initialized Instructor client
        raw_response = None
        try:
            if self._uses_direct_anthropic_instructor:
                # Anthropic's native Instructor adapter avoids LiteLLM's tool
                # translation layer, which is currently incompatible with batch
                # structured output. Use create() and fall back to local token
                # estimation since raw completion metadata is not returned here.
                if call_kwargs["max_tokens"] is None:
                    call_kwargs["max_tokens"] = 1024
                result = await self.instructor_client.create(**call_kwargs)
            # Try to get raw response for metadata extraction
            # Instructor >= 1.0.0 supports create_with_completion
            elif hasattr(
                self.instructor_client.chat.completions, "create_with_completion"
            ):
                (
                    result,
                    raw_response,
                ) = await self.instructor_client.chat.completions.create_with_completion(
                    **call_kwargs
                )
            else:
                # Fallback for older versions
                result = await self.instructor_client.chat.completions.create(
                    **call_kwargs
                )
        except Exception as e:
            raise self._map_provider_error(e)

        # Serialize for backward compatibility (text field)
        text = result.model_dump_json()

        # Calculate tokens and cost
        # If we have raw_response, use it for accurate usage/cost!
        if raw_response:
            tokens_in = raw_response.usage.prompt_tokens if raw_response.usage else 0
            tokens_out = (
                raw_response.usage.completion_tokens if raw_response.usage else 0
            )
            # Use LiteLLM's cost calculation if available
            cost = self._calc_cost_from_response(raw_response)

            # Check for prompt caching (OpenAI/Azure/Anthropic/Groq)
            self._check_cache_hit(raw_response, tokens_in)
        else:
            # Fallback estimation
            full_prompt = (
                f"{kwargs.get('system_message', '')}\n\n{prompt}"
                if kwargs.get("system_message")
                else prompt
            )
            tokens_in = self.estimate_tokens(full_prompt)
            tokens_out = self.estimate_tokens(text)
            cost = self._calc_cost(tokens_in, tokens_out)

        latency_ms = (time.time() - start) * 1000

        # Extract Router deployment info (Instructor path)
        metadata = {}
        if self.router:
            # Use raw_response if available
            source = raw_response if raw_response else result

            # Check for _hidden_params
            if hasattr(source, "_hidden_params") and source._hidden_params:
                hidden = source._hidden_params
                if isinstance(hidden, dict):
                    # Method 1: model_id field
                    if "model_id" in hidden:
                        metadata["model_id"] = hidden["model_id"]
                    # Method 2: model_region or custom_llm_provider
                    elif "model_region" in hidden:
                        metadata["model_id"] = hidden["model_region"]

            # Fallback: Check if result has _raw_response (some instructor versions)
            if not metadata and hasattr(result, "_raw_response"):
                raw = result._raw_response
                if hasattr(raw, "_hidden_params") and raw._hidden_params:
                    hidden = raw._hidden_params
                    if isinstance(hidden, dict) and "model_id" in hidden:
                        metadata["model_id"] = hidden["model_id"]

        # Emit LLM call event to observers
        self._emit_llm_call_event(
            prompt=prompt,
            completion=text,
            tokens_in=tokens_in,
            tokens_out=tokens_out,
            cost=cost,
            latency_ms=latency_ms,
            metadata=metadata,
            system_message=kwargs.get("system_message"),
        )

        return LLMResponse(
            text=text,
            tokens_in=tokens_in,
            tokens_out=tokens_out,
            model=self.model,
            cost=cost,
            latency_ms=latency_ms,
            structured_result=result,  # CRITICAL: Keep Pydantic object (avoids re-parsing!)
            metadata=metadata,  # Pass deployment info
        )

    def _check_cache_hit(self, response: Any, tokens_in: int) -> None:
        """
        Check for provider-side prompt caching (OpenAI/Azure/Anthropic).

        If cached tokens are found, logs a DEBUG message.
        Uses ONDINE_LOG_LEVEL environment variable (default INFO) to control visibility.
        """
        try:
            cached_tokens = 0
            # 1. Check standard OpenAI/Groq format (usage.prompt_tokens_details.cached_tokens)
            if hasattr(response, "usage") and response.usage:
                usage = response.usage
                if (
                    hasattr(usage, "prompt_tokens_details")
                    and usage.prompt_tokens_details
                ):
                    cached_tokens = getattr(
                        usage.prompt_tokens_details, "cached_tokens", 0
                    )

            # 2. Check Anthropic format (usage.cache_creation_input_tokens / cache_read_input_tokens)
            # LiteLLM normalizes this, but checking raw just in case
            if cached_tokens == 0 and hasattr(response, "usage"):
                cached_tokens = getattr(response.usage, "cache_read_input_tokens", 0)

            # Log if hit
            if cached_tokens > 0:
                # Try to get actual model name from response
                # 1. response.model (usually the human-readable model name, e.g., 'gpt-4o-mini')
                # 2. hidden params (deployment ID, sometimes a hash)
                # 3. self.model (fallback to 'mixed-llm')
                actual_model = getattr(response, "model", None)

                if not actual_model or "mixed-llm" in actual_model:
                    if hasattr(response, "_hidden_params"):
                        hidden = response._hidden_params
                        if isinstance(hidden, dict):
                            # Prefer model_region (often provider/model) over model_id (hash)
                            if "model_region" in hidden:
                                actual_model = hidden["model_region"]
                            elif "model_id" in hidden:
                                actual_model = hidden["model_id"]

                if not actual_model:
                    actual_model = self.model

                cache_pct = (cached_tokens / tokens_in * 100) if tokens_in > 0 else 0
                logger.debug(
                    f"Cache hit! ({actual_model}) {cached_tokens}/{tokens_in} tokens cached ({cache_pct:.0f}%)"
                )
        except Exception:  # nosec B110
            # Don't crash on logging errors - cache hit detection is non-critical
            pass
