"""Model executor for EB1-GNN server.

This module handles executing the routed model via the Ember gateway.
All format translation (OpenAI -> Anthropic, Google, etc.) is handled by
the gateway's provider adapters, ensuring a single source of truth.

Design:
    - Uses aiohttp for async HTTP calls to the gateway
    - Gateway handles all provider-specific format translation
    - Streaming passthrough for SSE responses
    - Typed exceptions for precise error handling (no silent fallbacks)
    - Shared session support for connection pooling
"""

from __future__ import annotations

import asyncio
import json
import logging
import time
import uuid
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from typing import Any, AsyncIterator

import aiohttp

from ember.eb1_gnn.errors import (
    GatewayConnectionError,
    GatewayError,
    GatewayRateLimitError,
    GatewayTimeoutError,
)
from ember.models.catalog.parameters import _is_reasoning_model
from ember.takumi.model_pool import CLAUDE_OPUS, GNN_LABEL_TO_MODEL_ID

# Ensemble sentinel IDs that must NOT be forwarded to the gateway.
# These represent ensemble orchestrations, not concrete models.
_ENSEMBLE_SENTINELS: frozenset[str] = frozenset(
    {"eb1", "eb1-pro", "eb1-pro-high", "eb1-codex", "ensemble"}
)

logger = logging.getLogger(__name__)

# Reasoning effort defaults for models that support it.
# Values: low, medium, high, xhigh
# The provider layer translates effort to native API params:
#   - Anthropic: output_config.effort (native, SDK >= 0.75.0)
#   - OpenAI: reasoning_effort directly
#   - Bedrock: thinking.budget_tokens (synthetic mapping)
# Applied only when the caller has NOT already set "reasoning" in the payload.
MODEL_REASONING_EFFORT: dict[str, str] = {
    "gpt-5.2": "high",
    "gpt-5.2-codex": "xhigh",
    "gpt-5.1": "high",
    "gpt-5.1-codex": "high",
    "gpt-5": "high",
    CLAUDE_OPUS: "high",
    "claude-sonnet-4-5-20250514": "high",
    "claude-haiku-4-5-20251001": "high",
}

# Re-export: canonical GNN label→model ID mapping lives in model_pool.py.
MODEL_ID_MAPPING = GNN_LABEL_TO_MODEL_ID

# Per-model-class timeout constants (seconds).
# Conservative initial floors; to be refined from baseline latency data.
DEFAULT_TIMEOUT_SECONDS = 300
TIMEOUT_PREVIEW_SECONDS = 120  # Single model (preview variants)
TIMEOUT_ENSEMBLE_SECONDS = 600  # Ensemble (eb1-pro, eb1-codex)
TIMEOUT_CONDUCTOR_SECONDS = 900  # Conductor (eb1-delta-preview)


def _parse_retry_after(retry_after_raw: str | None) -> float | None:
    """Parse Retry-After header as seconds or HTTP-date.

    Returns:
        Seconds until retry, or None when header is missing/invalid.
    """
    if retry_after_raw is None:
        return None

    normalized = retry_after_raw.strip()
    if not normalized:
        return None

    try:
        return max(float(normalized), 0.0)
    except ValueError:
        pass

    try:
        retry_at = parsedate_to_datetime(normalized)
    except (TypeError, ValueError):
        return None

    if retry_at.tzinfo is None:
        retry_at = retry_at.replace(tzinfo=UTC)

    seconds_until_retry = (retry_at - datetime.now(UTC)).total_seconds()
    return max(seconds_until_retry, 0.0)


async def execute_model(
    model: str,
    messages: list[dict],
    request_body: dict,
    use_ensemble: bool = False,
    ensemble_plan: str | None = None,
    gateway_url: str = "http://localhost:8080",
    *,
    session: aiohttp.ClientSession | None = None,
    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
    request_id: str | None = None,
) -> dict[str, Any]:
    """Execute a model via the Ember gateway.

    The gateway handles all provider-specific format translation (OpenAI,
    Anthropic Messages, Google Gemini, etc.), so this function only needs
    to send OpenAI-compatible payloads.

    Args:
        model: Model ID from routing decision.
        messages: Chat messages to send.
        request_body: Original request body for passthrough parameters.
        use_ensemble: Whether to use EB1 ensemble instead.
        ensemble_plan: If use_ensemble, which plan to use.
        gateway_url: URL of the Ember gateway.
        session: Shared aiohttp session for connection pooling. When None,
            creates a per-request session (backward compat).
        timeout_seconds: Per-request timeout in seconds.
        request_id: Correlation ID for tracing.

    Returns:
        OpenAI-compatible chat completion response.

    Raises:
        GatewayError: If the gateway returns an error status.
        GatewayTimeoutError: If the request times out.
        GatewayConnectionError: If TCP connection fails.
        GatewayRateLimitError: If the gateway returns 429.
    """
    # Ensemble requests run inline to avoid circular routing
    # (GNN server -> gateway -> ???). run_eb1() dispatches directly
    # to model provider APIs, bypassing the gateway entirely.
    if use_ensemble:
        return await _execute_ensemble_inline(
            messages=messages,
            ensemble_plan=ensemble_plan,
            timeout_seconds=timeout_seconds,
            request_id=request_id,
        )

    actual_model = MODEL_ID_MAPPING.get(model, model)

    logger.debug("Executing model: %s (original: %s)", actual_model, model)

    payload: dict[str, Any] = {
        "model": actual_model,
        "messages": messages,
    }

    # Copy standard parameters, but skip temperature for reasoning models
    # (reasoning models like gpt-5.x, o1, o3 only support temperature=1)
    is_reasoning = _is_reasoning_model(actual_model)
    for key in (
        "temperature",
        "max_tokens",
        "top_p",
        "stop",
        "tools",
        "tool_choice",
        "parallel_tool_calls",
        "reasoning",
    ):
        if key in request_body:
            # Skip temperature for reasoning models - they don't support it
            if key == "temperature" and is_reasoning:
                logger.debug("Skipping temperature for reasoning model %s", actual_model)
                continue
            payload[key] = request_body[key]

    # Apply default reasoning for models that support it, only when the
    # caller has not already provided reasoning config (user params win).
    if actual_model in MODEL_REASONING_EFFORT and "reasoning" not in payload:
        payload["reasoning"] = {"effort": MODEL_REASONING_EFFORT[actual_model]}
        logger.debug(
            "Applied default reasoning.effort=%s for %s",
            MODEL_REASONING_EFFORT[actual_model],
            actual_model,
        )

    return await _call_gateway(
        gateway_url,
        payload,
        session=session,
        timeout_seconds=timeout_seconds,
        request_id=request_id,
    )


async def _execute_ensemble_inline(
    messages: list[dict],
    ensemble_plan: str | None,
    *,
    timeout_seconds: float = TIMEOUT_ENSEMBLE_SECONDS,
    request_id: str | None = None,
) -> dict[str, Any]:
    """Execute EB1 ensemble inline instead of forwarding to the gateway.

    This eliminates circular routing (GNN server -> gateway -> ???) by
    running the ensemble directly. ``run_eb1()`` dispatches candidate
    calls to model provider APIs (OpenAI, Anthropic, Google) without
    touching the gateway.

    Args:
        messages: Chat messages from the request.
        ensemble_plan: Which ensemble plan to use (DEFAULT_PLAN or
            DEFAULT_PRO_PLAN).
        timeout_seconds: Per-request timeout in seconds.
        request_id: Correlation ID for tracing.

    Returns:
        OpenAI-compatible chat completion response dict.

    Raises:
        GatewayTimeoutError: If ensemble execution exceeds the timeout.
        GatewayError: If the ensemble raises an unexpected error.
    """
    from ember.takumi.eb1 import run_eb1, run_eb1_pro

    # Extract prompt (last user message) and system message from messages.
    prompt = ""
    system: str | None = None
    for msg in messages:
        role = msg.get("role", "")
        content = msg.get("content")
        if role == "system" and isinstance(content, str):
            system = content
        elif role == "user" and content:
            # Keep overwriting so we get the *last* user message.
            prompt = content if isinstance(content, str) else str(content)

    if not prompt:
        raise GatewayError(
            status=400,
            detail="No user message found in messages for ensemble execution",
            request_id=request_id,
        )

    is_pro = ensemble_plan != "DEFAULT_PLAN"
    plan_label = "eb1-pro" if is_pro else "eb1"

    logger.info(
        "Request %s: executing %s ensemble inline (prompt=%d chars)",
        request_id,
        plan_label,
        len(prompt),
    )

    loop = asyncio.get_running_loop()
    ensemble_fn = run_eb1_pro if is_pro else run_eb1

    try:
        result = await asyncio.wait_for(
            loop.run_in_executor(
                None, lambda: ensemble_fn(prompt, system=system)
            ),
            timeout=timeout_seconds,
        )
    except asyncio.TimeoutError as exc:
        raise GatewayTimeoutError(
            detail=(
                f"Ensemble ({plan_label}) timed out after "
                f"{timeout_seconds}s"
            ),
            request_id=request_id,
        ) from exc
    except Exception as exc:
        raise GatewayError(
            status=502,
            detail=f"Ensemble ({plan_label}) failed: {exc}",
            request_id=request_id,
        ) from exc

    # Convert ChatResponse to OpenAI chat completion format.
    text = result.data or ""
    usage = result.usage
    completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"

    response: dict[str, Any] = {
        "id": completion_id,
        "object": "chat.completion",
        "created": int(time.time()),
        "model": plan_label,
        "choices": [
            {
                "index": 0,
                "message": {"role": "assistant", "content": text},
                "finish_reason": "stop",
            }
        ],
        "usage": {
            "prompt_tokens": usage.prompt_tokens if usage else 0,
            "completion_tokens": usage.completion_tokens if usage else 0,
            "total_tokens": usage.total_tokens if usage else 0,
        },
    }

    logger.info(
        "Request %s: %s ensemble completed (tokens=%d)",
        request_id,
        plan_label,
        (usage.total_tokens if usage else 0),
    )

    return response


async def _call_gateway(
    gateway_url: str,
    payload: dict,
    *,
    session: aiohttp.ClientSession | None = None,
    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
    request_id: str | None = None,
) -> dict[str, Any]:
    """Call the Ember gateway's chat completions endpoint.

    Args:
        gateway_url: Base URL of the gateway.
        payload: Request payload in OpenAI format.
        session: Shared aiohttp session. When None, creates a per-request
            session (backward compat, logs a warning).
        timeout_seconds: Per-request timeout.
        request_id: Correlation ID for tracing.

    Returns:
        Response from the gateway.

    Raises:
        GatewayError: On non-200 responses.
        GatewayTimeoutError: On timeout.
        GatewayConnectionError: On connection failure.
        GatewayRateLimitError: On 429 responses.
    """
    url = f"{gateway_url.rstrip('/')}/v1/chat/completions"
    timeout = aiohttp.ClientTimeout(total=timeout_seconds)
    headers: dict[str, str] = {"Content-Type": "application/json"}
    if request_id:
        headers["X-Request-Id"] = request_id

    if session is not None:
        return await _do_gateway_post(session, url, payload, headers, timeout, request_id)

    # Fallback: per-request session (backward compat)
    logger.debug("Creating per-request session (no shared session provided)")
    async with aiohttp.ClientSession(timeout=timeout) as ephemeral:
        return await _do_gateway_post(ephemeral, url, payload, headers, timeout, request_id)


async def _do_gateway_post(
    session: aiohttp.ClientSession,
    url: str,
    payload: dict,
    headers: dict[str, str],
    timeout: aiohttp.ClientTimeout,
    request_id: str | None,
) -> dict[str, Any]:
    """Execute the HTTP POST and translate errors to typed exceptions.

    This is the single error-handling path for both shared and ephemeral
    sessions, ensuring consistent exception semantics.
    """
    try:
        async with session.post(
            url,
            json=payload,
            headers=headers,
            timeout=timeout,
        ) as resp:
            if resp.status == 200:
                return await resp.json()

            error_text = await resp.text()
            truncated = error_text[:500]

            if resp.status == 429:
                retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
                raise GatewayRateLimitError(
                    detail=truncated,
                    retry_after=retry_after,
                    request_id=request_id,
                )

            if resp.status >= 500:
                raise GatewayError(
                    status=resp.status,
                    detail=truncated,
                    request_id=request_id,
                )

            # 4xx (not 429)
            raise GatewayError(
                status=resp.status,
                detail=truncated,
                request_id=request_id,
            )

    except asyncio.TimeoutError as exc:
        raise GatewayTimeoutError(
            detail=f"request timed out after {timeout.total}s",
            request_id=request_id,
        ) from exc
    except aiohttp.ClientConnectorError as exc:
        raise GatewayConnectionError(
            detail=str(exc),
            request_id=request_id,
        ) from exc
    except aiohttp.ClientError as exc:
        raise GatewayConnectionError(
            detail=str(exc),
            request_id=request_id,
        ) from exc


def _normalize_to_responses_format(
    raw: dict[str, Any],
) -> dict[str, Any]:
    """Normalize gateway response to Responses API format.

    The gateway returns provider-native format (Anthropic Messages, Chat
    Completions, or Responses API). This function ensures the response
    always contains ``output`` and ``output_text`` fields so callers of
    ``/v1/responses`` receive a consistent schema.

    Args:
        raw: Raw response dict from the gateway.

    Returns:
        The dict unchanged when it already has an ``output`` key,
        otherwise a copy with ``output`` and ``output_text`` added.
    """
    if "output" in raw:
        return raw

    text = ""
    tool_calls: list[dict[str, Any]] = []

    # Chat Completions format: choices[0].message.content
    choices = raw.get("choices")
    if isinstance(choices, list) and choices:
        message = choices[0].get("message", {})
        text = message.get("content", "") or ""
        for tc in message.get("tool_calls", []):
            func = tc.get("function", {})
            tool_calls.append(
                {
                    "type": "function_call",
                    "id": tc.get("id", ""),
                    "call_id": tc.get("id", ""),
                    "name": func.get("name", ""),
                    "arguments": func.get("arguments", "{}"),
                }
            )

    # Anthropic Messages format: content[].text / content[].tool_use
    elif isinstance(raw.get("content"), list):
        parts: list[str] = []
        for item in raw["content"]:
            if not isinstance(item, dict):
                continue
            if item.get("type") == "text":
                parts.append(item.get("text", ""))
            elif item.get("type") == "tool_use":
                tool_calls.append(
                    {
                        "type": "function_call",
                        "id": item.get("id", ""),
                        "call_id": item.get("id", ""),
                        "name": item.get("name", ""),
                        "arguments": json.dumps(item.get("input", {})),
                    }
                )
        text = "".join(parts)

    # Build Responses API output list.
    output: list[dict[str, Any]] = []
    if text:
        output.append(
            {
                "type": "message",
                "role": "assistant",
                "content": [{"type": "output_text", "text": text}],
            }
        )
    output.extend(tool_calls)

    result = dict(raw)
    result["output"] = output
    result["output_text"] = text
    return result


async def execute_responses(
    model: str,
    payload: dict[str, Any],
    gateway_url: str = "http://localhost:8080",
    *,
    session: aiohttp.ClientSession | None = None,
    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
    request_id: str | None = None,
) -> dict[str, Any]:
    """Execute a model via the Ember gateway using Chat Completions API.

    Converts Responses API payload to Chat Completions format, executes via
    /v1/chat/completions, then normalizes the response back to Responses format.
    This is necessary because the gateway's /v1/responses endpoint only accepts
    eb1 models, not underlying models like gpt-5.1-codex.

    Args:
        model: Model ID to use.
        payload: Full Responses API payload (input, tools, etc.).
        gateway_url: URL of the Ember gateway.
        session: Shared aiohttp session for connection pooling.
        timeout_seconds: Per-request timeout in seconds.
        request_id: Correlation ID for tracing.

    Returns:
        Responses API response with tool calls properly formatted.

    Raises:
        GatewayError: If the gateway returns an error status.
        GatewayTimeoutError: If the request times out.
    """
    actual_model = MODEL_ID_MAPPING.get(model, model)

    # Guard: ensemble sentinels must never reach the gateway.
    # This is a defense-in-depth check; callers should already resolve
    # ensemble IDs to concrete models before calling this function.
    if actual_model in _ENSEMBLE_SENTINELS:
        logger.warning(
            "Request %s: ensemble sentinel %r reached execute_responses; "
            "falling back to %s",
            request_id,
            actual_model,
            CLAUDE_OPUS,
        )
        actual_model = CLAUDE_OPUS

    # Convert Responses API payload to Chat Completions format
    chat_payload: dict[str, Any] = {"model": actual_model}

    # Convert input to messages
    input_content = payload.get("input")
    if input_content:
        raw_messages = _responses_input_to_messages(input_content)
    elif payload.get("messages"):
        raw_messages = payload["messages"]
    else:
        raw_messages = []

    # CRITICAL: Always sanitize messages to ensure valid content fields.
    # Gateway accepts null content for assistant messages with tool_calls (OpenAI spec).
    # Other roles require content; _sanitize_messages normalizes appropriately.
    if raw_messages:
        chat_payload["messages"] = _sanitize_messages(raw_messages)

    # Pass through compatible parameters
    for key in (
        "tools", "tool_choice", "parallel_tool_calls",
        "temperature", "top_p", "stop", "reasoning",
    ):
        if key in payload:
            chat_payload[key] = payload[key]

    # Convert max_output_tokens to max_tokens
    if "max_output_tokens" in payload:
        chat_payload["max_tokens"] = payload["max_output_tokens"]
    elif "max_tokens" in payload:
        chat_payload["max_tokens"] = payload["max_tokens"]

    # Apply default reasoning when caller has not provided it
    if actual_model in MODEL_REASONING_EFFORT and "reasoning" not in chat_payload:
        chat_payload["reasoning"] = {"effort": MODEL_REASONING_EFFORT[actual_model]}
        logger.debug(
            "Applied default reasoning.effort=%s for %s",
            MODEL_REASONING_EFFORT[actual_model],
            actual_model,
        )

    logger.debug("Executing via chat completions: model=%s", actual_model)

    raw = await _call_gateway(
        gateway_url,
        chat_payload,
        session=session,
        timeout_seconds=timeout_seconds,
        request_id=request_id,
    )
    return _normalize_to_responses_format(raw)


def _responses_input_to_messages(input_content: Any) -> list[dict[str, Any]]:
    """Convert Responses API input to Chat Completions messages.

    Handles both simple string input and structured input items.

    Args:
        input_content: Responses API input (string or list of items).

    Returns:
        List of Chat Completions message dicts.
    """
    if isinstance(input_content, str):
        return [{"role": "user", "content": input_content}]

    if not isinstance(input_content, list):
        return [{"role": "user", "content": str(input_content)}]

    messages: list[dict[str, Any]] = []
    for item in input_content:
        if isinstance(item, str):
            messages.append({"role": "user", "content": item})
        elif isinstance(item, dict):
            item_type = item.get("type", "")
            role = item.get("role", "user")

            if item_type == "message":
                # Responses API message item
                content = item.get("content", [])
                tool_calls = []
                text_parts = []

                if isinstance(content, list):
                    # Extract text and tool_calls from content items
                    for c in content:
                        if isinstance(c, dict):
                            c_type = c.get("type", "")
                            if c_type == "function_call":
                                # Assistant tool call - ensure non-empty id
                                raw_tc_id = c.get("call_id") or c.get("id")
                                tc_id = raw_tc_id or ""
                                if not tc_id:
                                    tc_id = f"call_{uuid.uuid4().hex[:24]}"
                                    logger.warning("Generated UUID for empty tc_id: %s", tc_id)
                                tool_calls.append(
                                    {
                                        "id": tc_id,
                                        "type": "function",
                                        "function": {
                                            "name": c.get("name", ""),
                                            "arguments": c.get("arguments", "{}"),
                                        },
                                    }
                                )
                            else:
                                text = (
                                    c.get("text") or c.get("input_text") or c.get("output_text", "")
                                )
                                if text:
                                    text_parts.append(text)
                        elif isinstance(c, str):
                            text_parts.append(c)

                    # Build message with proper content handling
                    msg: dict[str, Any] = {"role": role}
                    if text_parts:
                        msg["content"] = " ".join(text_parts)
                    elif role == "assistant" and tool_calls:
                        # Gateway accepts null content for assistant+tool_calls (OpenAI spec)
                        msg["content"] = None
                    else:
                        # Non-assistant roles require content
                        msg["content"] = ""

                    if tool_calls:
                        msg["tool_calls"] = tool_calls
                    messages.append(msg)
                else:
                    messages.append({"role": role, "content": str(content) if content else ""})

            elif item_type == "function_call_output":
                # Tool result from previous call
                raw_call_id = item.get("call_id")
                call_id = raw_call_id or ""
                # OpenAI Responses API requires non-empty call_id
                # Generate a placeholder if missing (should rarely happen)
                if not call_id:
                    call_id = f"call_{uuid.uuid4().hex[:24]}"
                    logger.warning("Generated UUID for empty call_id: %s", call_id)
                messages.append(
                    {
                        "role": "tool",
                        "tool_call_id": call_id,
                        "content": str(item.get("output", "")),
                    }
                )
            elif item_type == "function_call":
                # Standalone function call (assistant message)
                call_id = item.get("call_id") or item.get("id") or ""
                if not call_id:
                    call_id = f"call_{uuid.uuid4().hex[:24]}"
                messages.append(
                    {
                        "role": "assistant",
                        "content": None,  # Gateway accepts null for assistant+tool_calls
                        "tool_calls": [
                            {
                                "id": call_id,
                                "type": "function",
                                "function": {
                                    "name": item.get("name", ""),
                                    "arguments": item.get("arguments", "{}"),
                                },
                            }
                        ],
                    }
                )
            else:
                # Fallback: treat as message with content
                content = item.get("content") or item.get("text", "")
                if isinstance(content, list):
                    text_parts = [
                        c.get("text", "") if isinstance(c, dict) else str(c) for c in content
                    ]
                    content = " ".join(text_parts)
                messages.append({"role": role, "content": str(content) if content else ""})

    return messages if messages else [{"role": "user", "content": ""}]


def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Sanitize messages to ensure valid content fields for Chat Completions API.

    Handles common issues:
    - Assistant messages with null content but tool_calls (valid)
    - Messages with empty content (convert to minimal valid content)
    - Content that is a list (convert to string)

    Args:
        messages: List of messages to sanitize.

    Returns:
        Sanitized messages safe for Chat Completions API.
    """
    sanitized = []
    for msg in messages:
        role = msg.get("role", "user")
        content = msg.get("content")
        tool_calls = msg.get("tool_calls")

        # Build sanitized message
        new_msg: dict[str, Any] = {"role": role}

        # Handle content based on role and presence of tool_calls
        # Gateway accepts null content for assistant+tool_calls (OpenAI spec)
        if content is None or content == "":
            if role == "assistant" and tool_calls:
                new_msg["content"] = None
            else:
                new_msg["content"] = ""
        elif isinstance(content, list):
            # Convert list content to string
            text_parts = []
            for c in content:
                if isinstance(c, dict):
                    text = c.get("text") or c.get("input_text") or c.get("output_text", "")
                    if text:
                        text_parts.append(text)
                elif isinstance(c, str):
                    text_parts.append(c)
            if text_parts:
                new_msg["content"] = " ".join(text_parts)
            elif role == "assistant" and tool_calls:
                new_msg["content"] = None
            else:
                new_msg["content"] = ""
        else:
            new_msg["content"] = content

        # Preserve other fields
        if tool_calls:
            new_msg["tool_calls"] = tool_calls
        if "tool_call_id" in msg:
            new_msg["tool_call_id"] = msg["tool_call_id"]
        if "name" in msg:
            new_msg["name"] = msg["name"]

        sanitized.append(new_msg)

    return sanitized


async def execute_streaming(
    model: str,
    messages: list[dict],
    request_body: dict,
    gateway_url: str = "http://localhost:8080",
    *,
    session: aiohttp.ClientSession | None = None,
    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
    request_id: str | None = None,
) -> AsyncIterator[bytes]:
    """Execute a model with streaming response.

    Args:
        model: Model ID to call.
        messages: Chat messages.
        request_body: Original request body.
        gateway_url: URL of the Ember gateway.
        session: Shared aiohttp session for connection pooling.
        timeout_seconds: Per-request timeout in seconds.
        request_id: Correlation ID for tracing.

    Yields:
        SSE data chunks as bytes.

    Raises:
        GatewayError: If the gateway returns an error.
        GatewayTimeoutError: If the request times out.
    """
    actual_model = MODEL_ID_MAPPING.get(model, model)

    payload: dict[str, Any] = {
        "model": actual_model,
        "messages": messages,
        "stream": True,
    }

    # Copy standard parameters, but skip temperature for reasoning models
    is_reasoning = _is_reasoning_model(actual_model)
    for key in (
        "temperature",
        "max_tokens",
        "top_p",
        "stop",
        "tools",
        "tool_choice",
        "parallel_tool_calls",
        "reasoning",
    ):
        if key in request_body:
            # Skip temperature for reasoning models - they don't support it
            if key == "temperature" and is_reasoning:
                continue
            payload[key] = request_body[key]

    # Apply default reasoning when caller has not provided it
    if actual_model in MODEL_REASONING_EFFORT and "reasoning" not in payload:
        payload["reasoning"] = {"effort": MODEL_REASONING_EFFORT[actual_model]}

    url = f"{gateway_url.rstrip('/')}/v1/chat/completions"
    timeout = aiohttp.ClientTimeout(total=timeout_seconds)
    headers: dict[str, str] = {"Content-Type": "application/json"}
    if request_id:
        headers["X-Request-Id"] = request_id

    async def _stream_from(sess: aiohttp.ClientSession) -> AsyncIterator[bytes]:
        try:
            async with sess.post(
                url,
                json=payload,
                headers=headers,
                timeout=timeout,
            ) as resp:
                if resp.status != 200:
                    error_text = await resp.text()
                    truncated = error_text[:500]
                    if resp.status == 429:
                        retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
                        raise GatewayRateLimitError(
                            detail=truncated,
                            retry_after=retry_after,
                            request_id=request_id,
                        )
                    raise GatewayError(
                        status=resp.status,
                        detail=truncated,
                        request_id=request_id,
                    )
                async for line in resp.content:
                    yield line
        except asyncio.TimeoutError as exc:
            raise GatewayTimeoutError(
                detail=f"streaming request timed out after {timeout.total}s",
                request_id=request_id,
            ) from exc
        except aiohttp.ClientConnectorError as exc:
            raise GatewayConnectionError(
                detail=str(exc),
                request_id=request_id,
            ) from exc
        except aiohttp.ClientError as exc:
            raise GatewayConnectionError(
                detail=str(exc),
                request_id=request_id,
            ) from exc

    if session is not None:
        async for chunk in _stream_from(session):
            yield chunk
    else:
        logger.debug("Creating per-request session for streaming")
        async with aiohttp.ClientSession(timeout=timeout) as ephemeral:
            async for chunk in _stream_from(ephemeral):
                yield chunk


async def execute_via_gateway(
    model: str,
    messages: list[dict],
    request_body: dict,
    gateway_url: str = "http://localhost:8080",
    *,
    session: aiohttp.ClientSession | None = None,
    timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
    request_id: str | None = None,
) -> dict[str, Any]:
    """Execute model via the gateway.

    This function routes all requests through the gateway, which is the single
    source of truth for provider adapters, format translation, and credential
    management. Errors are propagated to the caller for proper handling.

    Args:
        model: Model ID to execute.
        messages: Chat messages.
        request_body: Original request body.
        gateway_url: Gateway URL.
        session: Shared aiohttp session for connection pooling.
        timeout_seconds: Per-request timeout in seconds.
        request_id: Correlation ID for tracing.

    Returns:
        OpenAI-compatible response dict.

    Raises:
        GatewayError: If the gateway returns an error.
    """
    return await execute_model(
        model=model,
        messages=messages,
        request_body=request_body,
        gateway_url=gateway_url,
        session=session,
        timeout_seconds=timeout_seconds,
        request_id=request_id,
    )


# Deprecated alias for backwards compatibility
execute_model_with_fallback = execute_via_gateway
