"""
AbstractCore Server - Universal LLM Gateway with Media Processing

A focused FastAPI server that provides OpenAI-compatible endpoints with support for
multiple agent formats, tool calling, and comprehensive media processing capabilities.

Key Features:
- Universal tool call syntax conversion (OpenAI, Codex, Qwen3, LLaMA3, custom)
- Auto-detection of target agent format
- Media processing for images, documents, and data files
- OpenAI Vision API compatible format support
- Streaming responses with media attachments
- Clean delegation to AbstractCore
- Proper ReAct loop support
- Comprehensive model listing from AbstractCore providers

Media Support:
- Images: PNG, JPEG, GIF, WEBP, BMP, TIFF
- Documents: PDF, DOCX, XLSX, PPTX
- Data: CSV, TSV, JSON, XML, TXT, MD
- Size limits: 10MB per file, 32MB total per request
- Both base64 data URLs and HTTP URLs supported
"""

import os
import json
import time
import uuid
import base64
import tempfile
import urllib.request
import urllib.parse
import argparse
import sys
import logging
import threading
import warnings
import socket
import httpx
import ipaddress
import fnmatch
import posixpath
import hmac
from pathlib import Path
from typing import List, Dict, Any, Optional, Literal, Union, Iterator, Tuple, Annotated
from enum import Enum
from fastapi import FastAPI, HTTPException, Request, Query, Body, Path as FastAPIPath
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_oauth2_redirect_html
from fastapi.openapi.utils import get_openapi
from fastapi.responses import StreamingResponse, JSONResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from pydantic import BaseModel, Field, ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException

from ..core.factory import create_llm
from ..exceptions import AuthenticationError, InvalidRequestError, ModelNotFoundError, ProviderAPIError, RateLimitError
from ..utils.structured_logging import get_logger, configure_logging
from ..utils.version import __version__
from ..utils.message_preprocessor import MessagePreprocessor
# Removed simple_model_discovery import - now using provider methods directly
from ..tools.syntax_rewriter import (
    ToolCallSyntaxRewriter,
    SyntaxFormat,
    auto_detect_format,
    create_openai_rewriter,
    create_codex_rewriter,
    create_passthrough_rewriter
)

# ============================================================================
# Configuration
# ============================================================================

# Initialize with default logging configuration (can be overridden later).
#
# IMPORTANT: default console verbosity is controlled by AbstractCore's centralized logging defaults
# (and env overrides like ABSTRACTCORE_CONSOLE_LOG_LEVEL). The server must not force INFO-level
# console logs on startup.
debug_mode = os.getenv("ABSTRACTCORE_DEBUG", "false").lower() == "true"

if debug_mode:
    configure_logging(
        console_level=logging.DEBUG,
        file_level=logging.DEBUG,
        log_dir="logs",
        verbatim_enabled=True,
        console_json=False,
        file_json=True,
    )

def _configure_warning_logging() -> None:
    """Route Python warnings through structured logging."""
    def _showwarning(message, category, filename, lineno, file=None, line=None):
        log = get_logger("server")
        log.warning(
            "Python warning",
            category=getattr(category, "__name__", str(category)),
            warning_message=str(message),
            location=f"{filename}:{lineno}",
        )

    warnings.showwarning = _showwarning


# Get initial logger
logger = get_logger("server")
_configure_warning_logging()


def _apply_centralized_config_env() -> None:
    """Load centralized config so persisted keys/server settings reach os.environ.

    The server's auth middleware runs before provider creation, so the server
    master key and auth hardening knobs must be available at module import time,
    not only when `create_llm(...)` eventually imports the provider registry.
    """
    raw_disable = str(os.getenv("ABSTRACTCORE_SERVER_DISABLE_CENTRALIZED_CONFIG") or "").strip().lower()
    if raw_disable in {"1", "true", "yes", "on"}:
        return

    try:
        from ..config import get_config_manager

        get_config_manager()
    except Exception as exc:
        logger.debug(
            "Centralized config env injection skipped",
            error_type=type(exc).__name__,
            error=str(exc),
        )


_apply_centralized_config_env()

# Log initial startup with debug mode status (may be suppressed by console level).
logger.info("🚀 AbstractCore Server Initializing", version=__version__, debug_mode=debug_mode)


_REDACTED = "[REDACTED]"
_SENSITIVE_FIELD_NAMES = {
    "api_key",
    "apikey",
    "api-key",
    "access_token",
    "auth_token",
    "authorization",
    "bearer",
    "cookie",
    "id_token",
    "key",
    "password",
    "refresh_token",
    "secret",
    "set-cookie",
    "token",
    "x-api-key",
}
_SENSITIVE_FIELD_FRAGMENTS = ("api_key", "api-key", "apikey", "authorization", "password", "secret", "token")
_PLACEHOLDER_API_KEYS = {"not-needed", "not_needed", "notneeded", "unused", "dummy", "empty", "none"}
_PROVIDER_API_KEY_HEADERS = (
    "x-abstractcore-provider-api-key",
    "x-provider-api-key",
)


def _is_sensitive_name(name: Any) -> bool:
    key = str(name or "").strip().lower()
    if not key:
        return False
    return key in _SENSITIVE_FIELD_NAMES or any(fragment in key for fragment in _SENSITIVE_FIELD_FRAGMENTS)


def _redact_sensitive_data(value: Any) -> Any:
    """Recursively redact known secret-bearing fields for logs and validation responses."""
    if isinstance(value, dict):
        return {
            k: (_REDACTED if _is_sensitive_name(k) else _redact_sensitive_data(v))
            for k, v in value.items()
        }
    if isinstance(value, list):
        return [_redact_sensitive_data(v) for v in value]
    if isinstance(value, tuple):
        return tuple(_redact_sensitive_data(v) for v in value)
    return value


def _redact_url(url: Any) -> str:
    """Redact sensitive query params and userinfo from a URL before logging."""
    raw = str(url or "")
    try:
        parsed = urllib.parse.urlsplit(raw)
    except Exception:
        return raw

    query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
    redacted_query = [
        (k, _REDACTED if _is_sensitive_name(k) else v)
        for k, v in query
    ]

    netloc = parsed.netloc
    if parsed.hostname:
        host = parsed.hostname
        if ":" in host and not host.startswith("["):
            host = f"[{host}]"
        try:
            parsed_port = parsed.port
        except Exception:
            parsed_port = None
        port = f":{parsed_port}" if parsed_port is not None else ""
        netloc = f"{host}{port}"

    return urllib.parse.urlunsplit(
        (
            parsed.scheme,
            netloc,
            parsed.path,
            urllib.parse.urlencode(redacted_query, doseq=True),
            parsed.fragment,
        )
    )


def _redact_headers(headers: Any) -> Dict[str, Any]:
    """Redact sensitive request/response headers before logging."""
    try:
        items = headers.items()
    except Exception:
        return {}
    return {
        str(k): (_REDACTED if _is_sensitive_name(k) else v)
        for k, v in items
    }


def _redact_text(text: Any) -> str:
    """Best-effort redaction for strings that may contain URLs with secret query params."""
    raw = str(text or "")
    try:
        return _redact_url(raw)
    except Exception:
        return raw


def _server_api_key() -> str:
    """Return the configured inbound server API key, if any."""
    return str(os.getenv("ABSTRACTCORE_SERVER_API_KEY") or "").strip()


def _server_auth_enabled() -> bool:
    return bool(_server_api_key())


def _server_allows_unauthenticated() -> bool:
    raw = str(os.getenv("ABSTRACTCORE_SERVER_ALLOW_UNAUTHENTICATED") or "").strip().lower()
    return raw in {"1", "true", "yes", "on"}


def _server_protect_docs() -> bool:
    raw = str(os.getenv("ABSTRACTCORE_SERVER_PROTECT_DOCS") or "").strip().lower()
    return raw in {"1", "true", "yes", "on"}


def _extract_bearer_token(auth_header: Any) -> str:
    auth = str(auth_header or "").strip()
    if not auth.lower().startswith("bearer "):
        return ""
    return auth[7:].strip()


def _extract_secret_header(header_value: Any) -> str:
    value = str(header_value or "").strip()
    if value.lower().startswith("bearer "):
        return value[7:].strip()
    return value


def _is_placeholder_api_key(token: Any) -> bool:
    t = str(token or "").strip()
    return not t or t.lower() in _PLACEHOLDER_API_KEYS


def _request_is_auth_exempt(request: Request) -> bool:
    path = str(getattr(request.url, "path", "") or "")
    if request.method.upper() == "OPTIONS":
        return True
    if path == "/health":
        return True
    if not _server_protect_docs() and path in {"/docs", "/docs/oauth2-redirect", "/openapi.json", "/redoc"}:
        return True
    return False


def _unauthorized_response(message: str) -> JSONResponse:
    return JSONResponse(
        status_code=401,
        content={"error": {"message": message, "type": "authentication_error"}},
        headers={"WWW-Authenticate": "Bearer"},
    )


async def _enforce_server_auth(request: Request, call_next):
    """Enforce the server-master auth boundary before endpoint/provider creation."""
    if _request_is_auth_exempt(request):
        return await call_next(request)

    expected = _server_api_key()
    if not expected:
        # No master server key is configured. In this mode Authorization may be
        # used as a provider key, but it does not unlock server-held provider keys.
        if (
            _server_allows_unauthenticated()
            or _provider_api_key_from_request(request)
        ):
            return await call_next(request)
        return JSONResponse(
            status_code=503,
            content={
                "error": {
                    "message": (
                        "Server authentication is required but ABSTRACTCORE_SERVER_API_KEY is not configured. "
                        "Set ABSTRACTCORE_SERVER_API_KEY, pass an explicit provider key with Authorization: "
                        "Bearer <provider-key>, or set ABSTRACTCORE_SERVER_ALLOW_UNAUTHENTICATED=1 only for "
                        "intentional local/dev use."
                    ),
                    "type": "server_auth_not_configured",
                }
            },
        )

    provided = _extract_bearer_token(request.headers.get("authorization"))
    if not provided:
        return _unauthorized_response("Missing server Authorization bearer token")
    if not hmac.compare_digest(provided, expected):
        return _unauthorized_response("Invalid server Authorization bearer token")

    request.state.abstractcore_server_authenticated = True
    return await call_next(request)


def _request_has_server_auth(http_request: Optional[Request]) -> bool:
    if http_request is None:
        return False
    return bool(getattr(http_request.state, "abstractcore_server_authenticated", False))


def _provider_api_key_from_request(http_request: Request) -> Optional[str]:
    """Resolve the explicit upstream provider key for this request.

    Contract:
    - `Authorization` is the AbstractCore server key whenever server auth is configured.
    - `X-AbstractCore-Provider-API-Key` is the per-request upstream provider override.
    - For compatibility, when server auth is not configured, `Authorization` may carry
      the upstream provider key.
    """
    for header_name in _PROVIDER_API_KEY_HEADERS:
        token = _extract_secret_header(http_request.headers.get(header_name))
        if not _is_placeholder_api_key(token):
            return token

    if _server_auth_enabled():
        return None

    token = _extract_bearer_token(http_request.headers.get("authorization"))
    if _is_placeholder_api_key(token):
        return None
    return token

def reconfigure_for_debug():
    """Reconfigure logging for debug mode when --debug flag is used."""
    global debug_mode, logger

    debug_mode = True

    # Reconfigure with debug levels
    configure_logging(
        console_level=logging.DEBUG,
        file_level=logging.DEBUG,
        log_dir="logs",
        verbatim_enabled=True,
        console_json=False,
        file_json=True
    )

    # Update logger instance
    logger = get_logger("server")
    _configure_warning_logging()

    return logger

# Create FastAPI app (will be initialized after argument parsing)
app = FastAPI(
    title="AbstractCore Server",
    description=(
        "Universal LLM Gateway with Multi-Agent Tool Call Syntax Support and Media Processing.\n\n"
        "When server authentication is enabled, click Authorize in Swagger UI and enter the "
        "`ABSTRACTCORE_SERVER_API_KEY` value. The UI sends it as `Authorization: Bearer <token>` "
        "for protected API calls."
    ),
    version=__version__,
    docs_url=None,
    redoc_url=None,
    swagger_ui_parameters={
        "persistAuthorization": True,
        "displayRequestDuration": True,
        "tryItOutEnabled": True,
    },
)


@app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html() -> HTMLResponse:
    """Serve Swagger UI with a small media-preview fix for binary audio POSTs."""

    return HTMLResponse(
        """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
<title>AbstractCore Server - Swagger UI</title>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.__abstractcoreLatestAudioPreviewUrl = null;

function abstractcoreHeader(response, name) {
  const headers = response && response.headers ? response.headers : {};
  return headers[name] || headers[name.toLowerCase()] || headers[name.toUpperCase()] || "";
}

function abstractcoreBytesFromString(value) {
  const bytes = new Uint8Array(value.length);
  for (let index = 0; index < value.length; index += 1) {
    bytes[index] = value.charCodeAt(index) & 255;
  }
  return bytes;
}

function abstractcoreBlobFromResponse(response, contentType) {
  const data = response ? response.data : null;
  if (data instanceof Blob) {
    return data;
  }
  if (data instanceof ArrayBuffer) {
    return new Blob([data], { type: contentType });
  }
  if (data && data.buffer instanceof ArrayBuffer) {
    return new Blob([data], { type: contentType });
  }
  if (typeof data === "string" && data.length > 0) {
    return new Blob([abstractcoreBytesFromString(data)], { type: contentType });
  }
  return null;
}

function abstractcorePatchAudioPlayers() {
  const previewUrl = window.__abstractcoreLatestAudioPreviewUrl;
  if (!previewUrl) {
    return;
  }
  document.querySelectorAll("audio").forEach((audio) => {
    const current = audio.getAttribute("src") || "";
    if (current.includes("/v1/audio/speech") || current === "" || current === window.location.href) {
      if (audio.src !== previewUrl) {
        audio.src = previewUrl;
        audio.load();
      }
    }
  });
}

const ui = SwaggerUIBundle({
  url: "/openapi.json",
  dom_id: "#swagger-ui",
  layout: "BaseLayout",
  deepLinking: true,
  showExtensions: true,
  showCommonExtensions: true,
  persistAuthorization: true,
  displayRequestDuration: true,
  tryItOutEnabled: true,
  oauth2RedirectUrl: window.location.origin + "/docs/oauth2-redirect",
  responseInterceptor: (response) => {
    const contentType = String(abstractcoreHeader(response, "content-type")).split(";")[0].trim().toLowerCase();
    if (contentType.startsWith("audio/")) {
      const blob = abstractcoreBlobFromResponse(response, contentType);
      if (blob && blob.size > 0) {
        if (window.__abstractcoreLatestAudioPreviewUrl) {
          URL.revokeObjectURL(window.__abstractcoreLatestAudioPreviewUrl);
        }
        const previewUrl = URL.createObjectURL(blob);
        window.__abstractcoreLatestAudioPreviewUrl = previewUrl;
        response.url = previewUrl;
        response.text = previewUrl;
        response.obj = previewUrl;
        window.setTimeout(abstractcorePatchAudioPlayers, 0);
        window.setTimeout(abstractcorePatchAudioPlayers, 100);
        window.setTimeout(abstractcorePatchAudioPlayers, 500);
      }
    }
    return response;
  },
  presets: [
    SwaggerUIBundle.presets.apis,
    SwaggerUIBundle.SwaggerUIStandalonePreset
  ],
});

const abstractcoreSwaggerRoot = document.getElementById("swagger-ui");
if (abstractcoreSwaggerRoot) {
  const abstractcoreObserver = new MutationObserver(abstractcorePatchAudioPlayers);
  abstractcoreObserver.observe(abstractcoreSwaggerRoot, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["src"]
  });
}
</script>
</body>
</html>
        """
    )


@app.get("/docs/oauth2-redirect", include_in_schema=False)
async def swagger_ui_redirect() -> HTMLResponse:
    return get_swagger_ui_oauth2_redirect_html()


@app.get("/redoc", include_in_schema=False)
async def redoc_html() -> HTMLResponse:
    return get_redoc_html(openapi_url="/openapi.json", title=f"{app.title} - ReDoc")


def _custom_openapi() -> Dict[str, Any]:
    if app.openapi_schema:
        return app.openapi_schema

    schema = get_openapi(
        title=app.title,
        version=app.version,
        description=app.description,
        routes=app.routes,
    )
    components = schema.setdefault("components", {})
    security_schemes = components.setdefault("securitySchemes", {})
    security_schemes["AbstractCoreBearerAuth"] = {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "server API key",
        "description": (
            "Inbound AbstractCore server token. Use the value configured in "
            "ABSTRACTCORE_SERVER_API_KEY. Provider-key overrides still use the "
            "X-AbstractCore-Provider-API-Key header."
        ),
    }
    schemas = components.setdefault("schemas", {})
    schemas.setdefault(
        "AbstractCoreError",
        {
            "type": "object",
            "required": ["error"],
            "properties": {
                "error": {
                    "type": "object",
                    "required": ["message", "type"],
                    "properties": {
                        "message": {"type": "string", "description": "Human-readable error message."},
                        "type": {"type": "string", "description": "Stable error category such as `authentication_error`, `invalid_request_error`, or `http_error`."},
                        "code": {"type": "string", "nullable": True, "description": "Optional provider/server error code."},
                    },
                }
            },
            "examples": [
                {
                    "error": {
                        "message": "Invalid server Authorization bearer token",
                        "type": "authentication_error",
                    }
                }
            ],
        },
    )

    def _component_examples(name: str, example: Dict[str, Any]) -> None:
        comp = schemas.get(name)
        if isinstance(comp, dict):
            comp["examples"] = [example]

    def _request_example(path: str, method: str, content_type: str, name: str, summary: str, value: Dict[str, Any]) -> None:
        operation = schema.get("paths", {}).get(path, {}).get(method.lower())
        if not isinstance(operation, dict):
            return
        content = operation.get("requestBody", {}).get("content", {})
        media = content.get(content_type)
        if isinstance(media, dict):
            media["examples"] = {name: {"summary": summary, "value": value}}

    _component_examples(
        "AudioSpeechRequest",
        {
            "model": "openai/gpt-4o-mini-tts",
            "input": "Hello from AbstractCore.",
            "text": None,
            "voice": "coral",
            "response_format": "wav",
            "format": None,
            "speed": 1.0,
            "instructions": "Speak clearly and calmly.",
            "provider": {},
            "base_url": None,
        },
    )
    _component_examples(
        "AudioMusicRequest",
        {
            "prompt": "A short calm piano loop.",
            "input": None,
            "text": None,
            "lyrics": None,
            "response_format": "wav",
            "format": None,
        },
    )
    image_generation_example = {
        "model": "openai-compatible/gpt-image-1",
        "prompt": "A precise product photo of a red ceramic mug on a white table.",
        "n": 1,
        "width": 1024,
        "height": 1024,
        "response_format": "b64_json",
        "negative_prompt": None,
        "seed": None,
        "steps": None,
        "guidance_scale": None,
        "quality": "low",
        "style": None,
        "user": "swagger-user",
        "background": "auto",
        "output_format": "png",
        "output_compression": None,
        "moderation": "auto",
        "extra": {},
    }
    _component_examples("ImageGenerationBody", image_generation_example)
    _component_examples(
        "PromptCacheSetProxyRequest",
        {"base_url": "http://localhost:8001/v1", "api_key": None, "key": "project-default", "make_default": True, "ttl_s": 3600},
    )
    _component_examples(
        "PromptCacheUpdateProxyRequest",
        {
            "base_url": "http://localhost:8001/v1",
            "api_key": None,
            "key": "project-default",
            "prompt": "You are a helpful assistant.",
            "messages": None,
            "system_prompt": None,
            "tools": None,
            "add_generation_prompt": False,
            "ttl_s": 3600,
        },
    )
    _component_examples(
        "PromptCacheForkProxyRequest",
        {"base_url": "http://localhost:8001/v1", "api_key": None, "from_key": "project-default", "to_key": "project-branch", "make_default": False, "ttl_s": 3600},
    )
    _component_examples(
        "PromptCacheClearProxyRequest",
        {"base_url": "http://localhost:8001/v1", "api_key": None, "key": "project-default"},
    )
    _component_examples(
        "PromptCachePrepareModulesProxyRequest",
        {
            "base_url": "http://localhost:8001/v1",
            "api_key": None,
            "namespace": "abstractcore.tools",
            "modules": [{"name": "calculator", "version": "1.0"}],
            "make_default": False,
            "ttl_s": 3600,
            "version": 1,
        },
    )
    _component_examples("VisionModelLoadRequest", {"model_id": "Qwen/Qwen-Image-2512", "model": None})

    _request_example(
        "/v1/audio/speech",
        "post",
        "application/json",
        "remote_openai",
        "Remote OpenAI speech",
        {
            "model": "openai/gpt-4o-mini-tts",
            "input": "Hello from AbstractCore.",
            "text": None,
            "voice": "coral",
            "response_format": "wav",
            "format": None,
            "speed": 1.0,
            "instructions": "Speak clearly and calmly.",
            "provider": {},
            "base_url": None,
        },
    )
    _request_example(
        "/v1/audio/music",
        "post",
        "application/json",
        "local_plugin",
        "Local music plugin",
        {
            "prompt": "A short calm piano loop.",
            "input": None,
            "text": None,
            "lyrics": None,
            "response_format": "wav",
            "format": None,
        },
    )
    _request_example(
        "/v1/audio/transcriptions",
        "post",
        "multipart/form-data",
        "remote_openai",
        "Remote OpenAI transcription",
        {
            "file": "<upload audio.mp3>",
            "model": "openai/gpt-4o-mini-transcribe",
            "language": "en",
            "prompt": "Technical discussion about AbstractCore endpoints.",
            "response_format": "json",
            "temperature": 0.0,
            "format": None,
            "base_url": None,
        },
    )
    _request_example("/v1/images/generations", "post", "application/json", "remote_openai_compatible", "Remote OpenAI-compatible image generation", image_generation_example)
    _request_example("/v1/vision/jobs/images/generations", "post", "application/json", "async_image_generation", "Async image generation job", image_generation_example)
    _request_example(
        "/v1/audio/translations",
        "post",
        "multipart/form-data",
        "not_implemented",
        "Compatibility placeholder",
        {"file": "<upload audio.mp3>", "model": "openai/whisper-1"},
    )
    image_edit_example = {
        "prompt": "Make the mug blue and keep the white background.",
        "image": "<upload source.png>",
        "mask": None,
        "model": "openai-compatible/gpt-image-1",
        "size": "1024x1024",
        "response_format": "b64_json",
        "negative_prompt": None,
        "seed": None,
        "steps": None,
        "guidance_scale": None,
        "extra_json": '{"quality":"low","background":"auto","output_format":"png"}',
    }
    _request_example("/v1/images/edits", "post", "multipart/form-data", "remote_openai_compatible", "Remote OpenAI-compatible image edit", image_edit_example)
    _request_example("/v1/vision/jobs/images/edits", "post", "multipart/form-data", "async_image_edit", "Async image edit job", image_edit_example)
    _request_example(
        "/v1/voice/clone",
        "post",
        "multipart/form-data",
        "compatible_clone_server",
        "AbstractVoice-compatible clone server",
        {
            "file": "<upload reference.wav>",
            "model": "openai-compatible/default",
            "name": "my_voice",
            "reference_text": "Hello from AbstractCore voice cloning.",
            "validate": True,
            "base_url": "http://127.0.0.1:5000/v1",
            "clone_path": "/voice/clone",
            "file_field": "file",
            "consent": None,
        },
    )
    _request_example("/v1/vision/model/load", "post", "application/json", "load_local_model", "Load local vision model", {"model_id": "Qwen/Qwen-Image-2512", "model": None})
    _request_example("/acore/prompt_cache/set", "post", "application/json", "set_cache_key", "Set prompt cache key", {"base_url": "http://localhost:8001/v1", "api_key": None, "key": "project-default", "make_default": True, "ttl_s": 3600})
    _request_example(
        "/acore/prompt_cache/update",
        "post",
        "application/json",
        "update_cache_key",
        "Update prompt cache",
        {
            "base_url": "http://localhost:8001/v1",
            "api_key": None,
            "key": "project-default",
            "prompt": "You are a helpful assistant.",
            "messages": None,
            "system_prompt": None,
            "tools": None,
            "add_generation_prompt": False,
            "ttl_s": 3600,
        },
    )
    _request_example("/acore/prompt_cache/fork", "post", "application/json", "fork_cache_key", "Fork prompt cache", {"base_url": "http://localhost:8001/v1", "api_key": None, "from_key": "project-default", "to_key": "project-branch", "make_default": False, "ttl_s": 3600})
    _request_example("/acore/prompt_cache/clear", "post", "application/json", "clear_cache_key", "Clear prompt cache", {"base_url": "http://localhost:8001/v1", "api_key": None, "key": "project-default"})
    _request_example(
        "/acore/prompt_cache/prepare_modules",
        "post",
        "application/json",
        "prepare_modules",
        "Prepare module cache",
        {
            "base_url": "http://localhost:8001/v1",
            "api_key": None,
            "namespace": "abstractcore.tools",
            "modules": [{"name": "calculator", "version": "1.0"}],
            "make_default": False,
            "ttl_s": 3600,
            "version": 1,
        },
    )

    error_response = {
        "description": "AbstractCore error response.",
        "content": {
            "application/json": {
                "schema": {"$ref": "#/components/schemas/AbstractCoreError"},
                "examples": {
                    "error": {
                        "summary": "Error",
                        "value": {
                            "error": {
                                "message": "Invalid server Authorization bearer token",
                                "type": "authentication_error",
                            }
                        },
                    }
                },
            }
        },
    }

    for path, path_item in schema.get("paths", {}).items():
        if not isinstance(path_item, dict):
            continue
        for method, operation in path_item.items():
            if method.lower() not in {"get", "put", "post", "delete", "patch", "options", "head"}:
                continue
            if not isinstance(operation, dict):
                continue
            operation["security"] = [] if path == "/health" else [{"AbstractCoreBearerAuth": []}]
            if path != "/health":
                responses = operation.setdefault("responses", {})
                for code, description in (
                    ("400", "Bad request."),
                    ("401", "Unauthorized."),
                    ("413", "Payload too large."),
                    ("500", "Internal server error."),
                    ("501", "Capability not implemented or not configured."),
                    ("502", "Upstream/provider error."),
                ):
                    if code not in responses:
                        item = dict(error_response)
                        item["description"] = description
                        responses[code] = item

    app.openapi_schema = schema
    return app.openapi_schema


app.openapi = _custom_openapi  # type: ignore[method-assign]

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Optional: OpenAI-compatible vision generation endpoints (/v1/images/*).
# These are safe-by-default and require explicit configuration; see `vision_endpoints.py`.
try:
    from .vision_endpoints import router as _vision_router

    app.include_router(_vision_router, prefix="/v1")
    logger.info("🖼️ Vision endpoints enabled at /v1/images/*")
except Exception as e:
    logger.debug(f"Vision endpoints not loaded: {e}")

# Optional: OpenAI-compatible audio endpoints (/v1/audio/*).
# These delegate to capability plugins (e.g. AbstractVoice) and degrade to 501 when unavailable.
try:
    from .audio_endpoints import router as _audio_router

    app.include_router(_audio_router, prefix="/v1")
    logger.info("🔊 Audio endpoints enabled at /v1/audio/*")
except Exception as e:
    logger.debug(f"Audio endpoints not loaded: {e}")

# ============================================================================
# Enhanced Error Handling and Logging Middleware
# ============================================================================

@app.middleware("http")
async def debug_logging_middleware(request: Request, call_next):
    """Enhanced logging middleware for debug mode."""
    start_time = time.time()

    # Log request details in debug mode
    if debug_mode:
        logger.debug(
            "📥 HTTP Request",
            method=request.method,
            url=_redact_url(str(request.url)),
            headers=_redact_headers(request.headers),
            client=request.client.host if request.client else "unknown"
        )

    response = await _enforce_server_auth(request, call_next)

    process_time = time.time() - start_time

    # Log response details
    log_data = {
        "method": request.method,
        "url": _redact_url(str(request.url)),
        "status_code": response.status_code,
        "process_time_ms": round(process_time * 1000, 2)
    }

    if response.status_code >= 400:
        logger.error("❌ HTTP Error Response", **log_data)
    elif debug_mode:
        logger.debug("📤 HTTP Response", **log_data)
    else:
        logger.info("✅ HTTP Request", **log_data)

    return response

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    """Enhanced handler for 422 validation errors with detailed logging."""
    error_details = []
    for error in exc.errors():
        error_details.append({
            "field": " -> ".join(str(loc) for loc in error["loc"]),
            "message": error["msg"],
            "type": error["type"],
            "input": _redact_sensitive_data(error.get("input")),
        })

    # Log detailed validation error information
    logger.error(
        "🔴 Request Validation Error (422)",
        method=request.method,
        url=_redact_url(str(request.url)),
        error_count=len(error_details),
        errors=error_details,
        client=request.client.host if request.client else "unknown"
    )

    # In debug mode, also try to log the request body if possible
    if debug_mode:
        try:
            # Try to get the request body for debugging
            body = await request.body()
            if body:
                try:
                    import json
                    body_json = json.loads(body)
                    logger.debug(
                        "📋 Request Body (Validation Error)",
                        body=_redact_sensitive_data(body_json),
                    )
                except json.JSONDecodeError:
                    raw = body.decode("utf-8", errors="replace")
                    body_text = raw
                    if len(body_text) > 1000:
                        #[WARNING:TRUNCATION] bounded request-body preview for debug logs
                        body_text = body_text[:980].rstrip() + "\n… (truncated)"
                    logger.debug(
                        "📋 Request Body (Validation Error)",
                        body_text=body_text,
                    )
        except Exception as e:
            logger.debug(f"Could not read request body for debugging: {e}")

    # Return detailed error response
    return JSONResponse(
        status_code=422,
        content={
            "error": {
                "message": "Request validation failed",
                "type": "validation_error",
                "details": error_details
            }
        }
    )

@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
    """Enhanced handler for HTTP exceptions with detailed logging."""
    safe_detail = _redact_sensitive_data(exc.detail)
    logger.error(
        "🔴 HTTP Exception",
        method=request.method,
        url=_redact_url(str(request.url)),
        status_code=exc.status_code,
        detail=safe_detail,
        client=request.client.host if request.client else "unknown"
    )

    return JSONResponse(
        status_code=exc.status_code,
        content={
            "error": {
                "message": str(safe_detail),
                "type": "http_error"
            }
        },
        headers=getattr(exc, "headers", None),
    )

@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
    """Handler for unexpected exceptions with detailed logging."""
    logger.error(
        "💥 Unexpected Server Error",
        method=request.method,
        url=_redact_url(str(request.url)),
        exception_type=type(exc).__name__,
        exception_message=_redact_text(str(exc)),
        client=request.client.host if request.client else "unknown",
        exc_info=True  # This will include the full stack trace
    )

    return JSONResponse(
        status_code=500,
        content={
            "error": {
                "message": "Internal server error",
                "type": "server_error"
            }
        }
    )

# ============================================================================
# Model Type Detection
# ============================================================================

# Import the core capability enums directly
from ..providers.model_capabilities import ModelInputCapability, ModelOutputCapability


# ============================================================================
# Provider Model Discovery (Using Centralized Registry)
# ============================================================================

def get_models_from_provider(
    provider_name: str, 
    input_capabilities=None, 
    output_capabilities=None,
    **kwargs,
) -> List[str]:
    """
    Get available models from a specific provider using the centralized provider registry.

    Args:
        provider_name: Name of the provider
        input_capabilities: Optional list of ModelInputCapability enums
        output_capabilities: Optional list of ModelOutputCapability enums

    Returns:
        List of model names from the provider, optionally filtered
    """
    try:
        from ..providers.registry import get_available_models_for_provider
        return get_available_models_for_provider(
            provider_name, 
            input_capabilities=input_capabilities,
            output_capabilities=output_capabilities,
            **kwargs,
        )
    except Exception as e:
        if bool(kwargs.get("raise_on_error", False)):
            raise
        logger.debug(f"Failed to get models from provider {provider_name}: {_redact_text(str(e))}")
        return []



# ============================================================================
# OpenAI Responses API Models (100% Compatible)
# ============================================================================

class OpenAIInputContent(BaseModel):
    """OpenAI Responses API content item"""
    type: Literal["input_text", "input_file"] = Field(
        description="Content type - 'input_text' for text or 'input_file' for files"
    )
    text: Optional[str] = Field(
        default=None,
        description="Text content (required when type='input_text')"
    )
    file_url: Optional[str] = Field(
        default=None,
        description="Direct file URL (required when type='input_file')"
    )

class OpenAIResponsesInput(BaseModel):
    """OpenAI Responses API input message"""
    role: Literal["user"] = Field(
        description="Message role (OpenAI responses only supports 'user')"
    )
    content: List[OpenAIInputContent] = Field(
        description="Array of input content items"
    )

class OpenAIResponsesRequest(BaseModel):
    """OpenAI Responses API request format (100% compatible)"""
    model: str = Field(
        description="Model identifier",
        example="gpt-4o"
    )
    input: List[OpenAIResponsesInput] = Field(
        description="Array of input messages"
    )
    max_tokens: Optional[int] = Field(
        default=None,
        description="Maximum tokens to generate"
    )
    temperature: Optional[float] = Field(
        default=None,
        description="Sampling temperature"
    )
    top_p: Optional[float] = Field(
        default=None,
        description="Top-p sampling"
    )
    stream: Optional[bool] = Field(
        default=False,
        description="Enable streaming (false by default, set to true for real-time responses)"
    )

# ============================================================================
# Models
# ============================================================================

class ContentItem(BaseModel):
    """Individual content item within a message (OpenAI Vision API format with file support)"""
    type: Literal["text", "image_url", "file"] = Field(
        description="Content type - 'text' for text content, 'image_url' for images, or 'file' for file attachments"
    )
    text: Optional[str] = Field(
        default=None,
        description="Text content (required when type='text')"
    )
    image_url: Optional[Dict[str, Any]] = Field(
        default=None,
        description="Image URL object (required when type='image_url'). Should contain 'url' field with base64 data URL or HTTP(S) URL"
    )
    file_url: Optional[Dict[str, Any]] = Field(
        default=None,
        description="File URL object (required when type='file'). Should contain 'url' field with HTTP(S) URL, local path, or base64 data URL"
    )

class ChatMessage(BaseModel):
    """Enhanced OpenAI-compatible message format with media support"""
    role: Literal["system", "user", "assistant", "tool"] = Field(
        description="The role of the message author. One of 'system', 'user', 'assistant', or 'tool'.",
        example="user"
    )
    content: Optional[Union[str, List[ContentItem]]] = Field(
        default=None,
        description="Message content - can be a string or array of content objects for multimodal messages. "
                   "For multimodal messages, use array format with text, image_url, and file objects.",
        example="What is the capital of France?"
    )
    tool_call_id: Optional[str] = Field(
        default=None,
        description="Tool call that this message is responding to (required for role='tool').",
        example="call_abc123"
    )
    tool_calls: Optional[List[Dict[str, Any]]] = Field(
        default=None,
        description="The tool calls generated by the model (only for assistant messages)."
    )
    name: Optional[str] = Field(
        default=None,
        description="An optional name for the participant. Provides the model information to differentiate between participants of the same role.",
        example="User1"
    )

class ChatCompletionRequest(BaseModel):
    """OpenAI-compatible chat completion request"""
    model: str = Field(
        description="ID of the model to use. Use provider/model format (e.g., 'openai/gpt-4', 'ollama/llama3:latest', "
                    "'anthropic/claude-3-opus-20240229'). You can use the List models API to see all available models, "
                    "or filter by input_type=text&output_type=text.",
        example="openai/gpt-4"
    )
    messages: List[ChatMessage] = Field(
        description="A list of messages comprising the conversation so far. Each message has a role (system/user/assistant/tool) "
                    "and content. System messages set the assistant's behavior, user messages are from the end user, "
                    "assistant messages are from the AI, and tool messages contain function call results."
    )

    # Core parameters
    temperature: Optional[float] = Field(
        default=0.7,
        ge=0.0,
        le=2.0,
        description="What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, "
                    "while lower values like 0.2 will make it more focused and deterministic. "
                    "We generally recommend altering this or top_p but not both.",
        example=0.7
    )
    max_tokens: Optional[int] = Field(
        default=None,
        ge=1,
        description="The maximum number of tokens that can be generated in the chat completion. "
                    "The total length of input tokens and generated tokens is limited by the model's context length. "
                    "If not specified, uses the model's default maximum output tokens.",
        example=2048
    )
    top_p: Optional[float] = Field(
        default=1.0,
        ge=0.0,
        le=1.0,
        description="An alternative to sampling with temperature, called nucleus sampling. "
                    "The model considers the results of the tokens with top_p probability mass. "
                    "For example, 0.1 means only the tokens comprising the top 10% probability mass are considered. "
                    "We generally recommend altering this or temperature but not both.",
        example=1.0
    )
    stream: Optional[bool] = Field(
        default=False,
        description="If set, partial message deltas will be sent as server-sent events. "
                    "Tokens will be sent as data-only server-sent events as they become available, "
                    "with the stream terminated by a 'data: [DONE]' message.",
        example=False
    )

    # Unified thinking/reasoning control (AbstractCore-specific feature)
    thinking: Optional[Union[bool, str]] = Field(
        default=None,
        description="Unified thinking/reasoning control (best-effort across providers/models). "
                    "Accepted values: null/'auto'/'on'/'off'/'none' or 'low'/'medium'/'high'/'xhigh' when supported. "
                    "Note: 'none' is treated as an alias for 'off'.",
        example="off",
    )

    # Tool calling
    tools: Optional[List[Dict[str, Any]]] = Field(
        default=None,
        description="A list of tools the model may call. Currently, only functions are supported as a tool. "
                    "Use this to provide a list of functions the model may generate JSON inputs for. "
                    "Maximum of 128 functions supported."
    )
    tool_choice: Optional[Union[str, Dict[str, Any]]] = Field(
        default="auto",
        description="Controls which (if any) tool is called by the model. "
                    "'none' means the model will not call any tool and instead generates a message. "
                    "'auto' means the model can pick between generating a message or calling one or more tools. "
                    "'required' means the model must call one or more tools. "
                    "Specifying a particular tool via {\"type\": \"function\", \"function\": {\"name\": \"my_function\"}} forces the model to call that tool.",
        example="auto"
    )

    # Other OpenAI parameters
    stop: Optional[List[str]] = Field(
        default=None,
        description="Up to 4 sequences where the API will stop generating further tokens. "
                    "The returned text will not contain the stop sequence.",
        example=None
    )
    seed: Optional[int] = Field(
        default=None,
        description="If specified, the system will make a best effort to sample deterministically, "
                    "such that repeated requests with the same seed and parameters should return the same result. "
                    "Determinism is not guaranteed.",
        example=None
    )
    frequency_penalty: Optional[float] = Field(
        default=0.0,
        ge=-2.0,
        le=2.0,
        description="Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, "
                    "decreasing the model's likelihood to repeat the same line verbatim.",
        example=0.0
    )
    presence_penalty: Optional[float] = Field(
        default=0.0,
        ge=-2.0,
        le=2.0,
        description="Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, "
                    "increasing the model's likelihood to talk about new topics.",
        example=0.0
    )

    # OpenAI prompt caching (2025+): forwarded best-effort by providers that support it.
    prompt_cache_key: Optional[str] = Field(
        default=None,
        description="Provider-specific prompt cache key for prefix caching (best-effort).",
        example="tenantA:session123"
    )
    prompt_cache_retention: Optional[str] = Field(
        default=None,
        description="Prompt cache retention policy (provider-specific; OpenAI: 'in_memory' or '24h').",
        example="24h",
    )

    # Agent format control (AppV2 feature)
    agent_format: Optional[str] = Field(
        default=None,
        description="Target agent format for tool call syntax conversion (AbstractCore-specific feature). "
                    "Options: 'auto' (auto-detect), 'openai', 'codex', 'qwen3', 'llama3', 'passthrough'. "
                    "Use 'auto' for automatic format detection based on model and user-agent.",
        example="auto"
    )

    # Provider-specific parameters (AbstractCore-specific feature)
    api_key: Optional[str] = Field(
        default=None,
        description="Deprecated/disabled for HTTP requests. Provider API keys must be supplied via "
                    "X-AbstractCore-Provider-API-Key, via Authorization only when server auth is not "
                    "configured, or configured on the server (e.g., OPENAI_API_KEY, ANTHROPIC_API_KEY, "
                    "OPENROUTER_API_KEY, PORTKEY_API_KEY).",
        example=None
    )
    base_url: Optional[str] = Field(
        default=None,
        description="Base URL for the provider API endpoint (AbstractCore-specific feature). "
                    "Useful for OpenAI-compatible providers (lmstudio, vllm, openrouter, portkey, openai-compatible) and custom/proxied endpoints. "
                    "Example: 'http://localhost:1234/v1' for LMStudio, 'http://localhost:8080/v1' for llama.cpp. "
                    "If not specified, uses provider's default or environment variable.",
        example="http://localhost:1234/v1"
    )

    # Runtime/orchestrator policy (AbstractCore-specific feature)
    timeout_s: Optional[float] = Field(
        default=None,
        description="Per-request provider HTTP timeout in seconds (AbstractCore-specific feature). "
                    "Intended for orchestrators (e.g. AbstractRuntime) to enforce execution policy. "
                    "If omitted, the server uses its own defaults. "
                    "Values <= 0 are treated as unlimited.",
        example=7200.0,
    )
    unload_after: bool = Field(
        default=False,
        description="If true, call `llm.unload_model(model)` after the request completes (AbstractCore-specific feature). "
                    "This is useful for explicit memory hygiene in single-tenant or batch scenarios. "
                    "WARNING: for providers that unload shared server state (e.g. Ollama), this can disrupt other "
                    "clients and is disabled by default unless explicitly enabled by the server operator.",
        example=False,
    )

    class Config:
        json_schema_extra = {
            "examples": list({
                "basic_text": {
                    "summary": "Basic Text Chat",
                    "description": "Simple text-based conversation",
                    "value": {
                        "model": "openai/gpt-4",
                        "messages": [
                            {
                                "role": "system",
                                "content": "You are a helpful assistant."
                            },
                            {
                                "role": "user",
                                "content": "What is the capital of France?"
                            }
                        ],
                        "temperature": 0.7,
                        "max_tokens": 150,
                        "stream": False
                    }
                },
                "vision_image": {
                    "summary": "Image Analysis",
                    "description": "Analyze images using vision-capable models with OpenAI Vision API format",
                    "value": {
                        "model": "ollama/qwen2.5vl:7b",
                        "messages": [
                            {
                                "role": "user",
                                "content": [
                                    {
                                        "type": "text",
                                        "text": "What's in this image?"
                                    },
                                    {
                                        "type": "image_url",
                                        "image_url": {
                                            "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
                                        }
                                    }
                                ]
                            }
                        ],
                        "temperature": 0.7,
                        "max_tokens": 200
                    }
                },
                "document_analysis": {
                    "summary": "Document Analysis",
                    "description": "Process documents (PDF, CSV, Excel, etc.) with file attachments",
                    "value": {
                        "model": "lmstudio/qwen/qwen3-next-80b",
                        "messages": [
                            {
                                "role": "user",
                                "content": [
                                    {
                                        "type": "text",
                                        "text": "Analyze this CSV file and calculate the total sales"
                                    },
                                    {
                                        "type": "image_url",
                                        "image_url": {
                                            "url": "data:text/csv;base64,RGF0ZSxQcm9kdWN0LFNhbGVzCjIwMjQtMDEtMDEsUHJvZHVjdCBBLDEwMDAwCjIwMjQtMDEtMDIsUHJvZHVjdCBCLDE1MDAwCjIwMjQtMDEtMDMsUHJvZHVjdCBDLDI1MDAw"
                                        }
                                    }
                                ]
                            }
                        ],
                        "temperature": 0.3,
                        "max_tokens": 300
                    }
                },
                "mixed_media": {
                    "summary": "Mixed Media Analysis",
                    "description": "Process multiple file types in a single request",
                    "value": {
                        "model": "ollama/qwen2.5vl:7b",
                        "messages": [
                            {
                                "role": "user",
                                "content": [
                                    {
                                        "type": "text",
                                        "text": "Compare this chart image with the data in this PDF report"
                                    },
                                    {
                                        "type": "image_url",
                                        "image_url": {
                                            "url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
                                        }
                                    },
                                    {
                                        "type": "image_url",
                                        "image_url": {
                                            "url": "data:application/pdf;base64,JVBERi0xLjQKJdPr6eEKMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovUGFnZXMgMiAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDEKPJ4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL1BhZ2UKL1BhcmVudCAyIDAgUgovTWVkaWFCb3ggWzAgMCA2MTIgNzkyXQo+PgplbmRvYmoKeHJlZgowIDQKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDQKL1Jvb3QgMSAwIFIKPj4Kc3RhcnR4cmVmCjE5NQolJUVPRgo="
                                        }
                                    }
                                ]
                            }
                        ],
                        "temperature": 0.5,
                        "max_tokens": 500,
                        "stream": False
                    }
                },
                "tools_with_media": {
                    "summary": "Tools + Media",
                    "description": "Combine tool usage with file attachments for complex workflows",
                    "value": {
                        "model": "openai/gpt-4",
                        "messages": [
                            {
                                "role": "user",
                                "content": [
                                    {
                                        "type": "text",
                                        "text": "Analyze this financial data and create a summary chart"
                                    },
                                    {
                                        "type": "image_url",
                                        "image_url": {
                                            "url": "data:text/csv;base64,Q29tcGFueSxRMSxRMixRMyxRNApBY21lIEluYywyMDAsMjUwLDMwMCwzNTAKVGVjaCBDb3JwLDE1MCwyMDAsMjUwLDMwMApCaXogTHRkLDEwMCwxMjAsMTQwLDE2MA=="
                                        }
                                    }
                                ]
                            }
                        ],
                        "temperature": 0.7,
                        "max_tokens": 2048,
                        "stream": False,
                        "tools": [
                            {
                                "type": "function",
                                "function": {
                                    "name": "create_chart",
                                    "description": "Create a chart from data",
                                    "parameters": {
                                        "type": "object",
                                        "properties": {
                                            "chart_type": {"type": "string"},
                                            "data": {"type": "array"}
                                        }
                                    }
                                }
                            }
                        ],
                        "tool_choice": "auto"
                    }
                },
                "complete_request": {
                    "summary": "Complete Request with Media",
                    "description": "Full example showing all possible fields with file attachment",
                    "value": {
                        "model": "openai/gpt-4",
                        "messages": [
                            {
                                "role": "user",
                                "content": [
                                    {
                                        "type": "text",
                                        "text": "Analyze this CSV file and provide insights"
                                    },
                                    {
                                        "type": "image_url",
                                        "image_url": {
                                            "url": "data:text/csv;base64,RGF0ZSxQcm9kdWN0LFNhbGVzCjIwMjQtMDEtMDEsUHJvZHVjdCBBLDEwMDAwCjIwMjQtMDEtMDIsUHJvZHVjdCBCLDE1MDAwCjIwMjQtMDEtMDMsUHJvZHVjdCBDLDI1MDAw"
                                        }
                                    }
                                ],
                                "tool_call_id": None,
                                "tool_calls": None,
                                "name": "DataAnalyst"
                            }
                        ],
                        "temperature": 0.7,
                        "max_tokens": 2048,
                        "top_p": 1,
                        "stream": False,
                        "tools": [
                            {
                                "type": "function",
                                "function": {
                                    "name": "analyze_data",
                                    "description": "Analyze data and generate insights",
                                    "parameters": {
                                        "type": "object",
                                        "properties": {
                                            "analysis_type": {"type": "string"},
                                            "metrics": {"type": "array"}
                                        }
                                    }
                                }
                            }
                        ],
                        "tool_choice": "auto",
                        "stop": ["END"],
                        "seed": 12345,
                        "frequency_penalty": 0.0,
                        "presence_penalty": 0.0,
                        "agent_format": "auto",
                        "base_url": None
                    }
                }
            }.values())
        }

class EmbeddingRequest(BaseModel):
    """OpenAI-compatible embedding request"""
    input: Union[str, List[str]] = Field(
        description="Input text to embed, encoded as a string or array of strings. "
                    "To embed multiple inputs in a single request, pass an array of strings. "
                    "The input must not exceed the max input tokens for the model (8192 tokens for most embedding models), "
                    "cannot be an empty string, and any array must be 2048 dimensions or less.",
        example="this is the story of starship lost in space"
    )
    model: str = Field(
        description="ID of the model to use. Use provider/model format (e.g., 'huggingface/sentence-transformers/all-MiniLM-L6-v2', "
                    "'ollama/granite-embedding:278m', 'lmstudio/text-embedding-all-minilm-l6-v2'). "
                    "You can use the List models API to see all available models, or filter by output_type=embeddings.",
        example="huggingface/sentence-transformers/all-MiniLM-L6-v2"
    )
    encoding_format: Optional[str] = Field(
        default="float",
        description="The format to return the embeddings in. Can be either 'float' or 'base64'. Defaults to 'float'.",
        example="float"
    )
    dimensions: Optional[int] = Field(
        default=None,
        description="The number of dimensions the resulting output embeddings should have. "
                    "Only supported in some models (e.g., text-embedding-3 and later models). "
                    "If specified, embeddings will be truncated to this dimension. "
                    "Set to 0 or None to use the model's default dimension.",
        example=0
    )
    user: Optional[str] = Field(
        default=None,
        description="A unique identifier representing your end-user, which can help OpenAI/providers to monitor and detect abuse. "
                    "This is optional but recommended for production applications.",
        example="user-123"
    )
    base_url: Optional[str] = Field(
        default=None,
        description=(
            "AbstractCore extension: request-level OpenAI-compatible base URL override. "
            "Loopback URLs are allowed by default; non-loopback URLs require "
            "ABSTRACTCORE_SERVER_BASE_URL_ALLOWLIST."
        ),
        example="http://127.0.0.1:1234/v1",
    )
    api_key: Optional[str] = Field(
        default=None,
        description="Deprecated/disabled for HTTP request bodies. Use X-AbstractCore-Provider-API-Key for provider overrides.",
        example=None,
    )

    class Config:
        json_schema_extra = {
            "example": {
                "input": "this is the story of starship lost in space",
                "model": "huggingface/sentence-transformers/all-MiniLM-L6-v2",
                "encoding_format": "float",
                "dimensions": 0,
                "user": "user-123"
            }
        }

# ============================================================================
# Union Request Model for /v1/responses endpoint
# ============================================================================

class ResponsesAPIRequest(BaseModel):
    """
    Union request model for /v1/responses endpoint supporting both OpenAI and legacy formats.

    The endpoint automatically detects the format based on the presence of 'input' vs 'messages' field.
    """
    class Config:
        json_schema_extra = {
            "oneOf": [
                {
                    "title": "OpenAI Responses API Format",
                    "description": "OpenAI-compatible responses format with input_file support",
                    "$ref": "#/components/schemas/OpenAIResponsesRequest"
                },
                {
                    "title": "Legacy Format (ChatCompletionRequest)",
                    "description": "Backward-compatible format using messages array",
                    "$ref": "#/components/schemas/ChatCompletionRequest"
                }
            ],
            "examples": list({
                "openai_format": {
                    "summary": "OpenAI Responses API Format",
                    "description": "Use input array with input_text and input_file objects",
                    "value": {
                        "model": "gpt-4o",
                        "input": [
                            {
                                "role": "user",
                                "content": [
                                    {"type": "input_text", "text": "Analyze this document"},
                                    {"type": "input_file", "file_url": "https://example.com/doc.pdf"}
                                ]
                            }
                        ],
                        "stream": False
                    }
                },
                "legacy_format": {
                    "summary": "Legacy Format (Backward Compatible)",
                    "description": "Use messages array like standard chat completions",
                    "value": {
                        "model": "openai/gpt-4",
                        "messages": [
                            {"role": "user", "content": "Tell me a story"}
                        ],
                        "stream": False
                    }
                }
            }.values())
        }

# ============================================================================
# OpenAI Responses API Compatibility
# ============================================================================

def convert_openai_responses_to_chat_completion(openai_request: OpenAIResponsesRequest) -> ChatCompletionRequest:
    """
    Convert OpenAI Responses API format to internal ChatCompletionRequest format.

    Transforms:
    - input -> messages
    - input_text -> text
    - input_file -> file with file_url

    Args:
        openai_request: OpenAI responses API request

    Returns:
        ChatCompletionRequest compatible with our internal processing
    """
    # Convert input messages to chat messages
    messages = []

    for input_msg in openai_request.input:
        # Build content array as list of dicts (not ContentItem objects)
        content_items = []

        for content in input_msg.content:
            if content.type == "input_text":
                content_items.append({
                    "type": "text",
                    "text": content.text
                })
            elif content.type == "input_file":
                content_items.append({
                    "type": "file",
                    "file_url": {"url": content.file_url}  # Convert to our format
                })

        # Create chat message with list content (not ContentItem objects)
        message_dict = {
            "role": input_msg.role,
            "content": content_items
        }
        messages.append(ChatMessage(**message_dict))

    # Build ChatCompletionRequest
    return ChatCompletionRequest(
        model=openai_request.model,
        messages=messages,
        max_tokens=openai_request.max_tokens,
        temperature=openai_request.temperature,
        top_p=openai_request.top_p,
        stream=openai_request.stream
    )

# ============================================================================
# Helper Functions
# ============================================================================

def _parse_bool_env(var_name: str) -> bool:
    """Parse a boolean environment variable (1/true/yes/on)."""
    val = os.getenv(var_name)
    if val is None:
        return False
    return str(val).strip().lower() in {"1", "true", "yes", "on"}


def _parse_boolish(value: Any) -> bool:
    """Parse a request-supplied bool-ish value (bool/int/str/None)."""
    if value is None:
        return False
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)):
        return bool(value)
    if isinstance(value, str):
        normalized = value.strip().lower()
        if normalized in {"1", "true", "yes", "on"}:
            return True
        if normalized in {"0", "false", "no", "off", ""}:
            return False
    raise ValueError(f"Expected boolean, got {type(value).__name__}: {value!r}")


def _csv_env(var_name: str) -> List[str]:
    """Parse a comma-separated env var into a list of non-empty items."""
    raw = os.getenv(var_name)
    if not isinstance(raw, str) or not raw.strip():
        return []
    items = [p.strip() for p in raw.split(",")]
    return [p for p in items if p]


def _resolve_host_ips(hostname: str) -> List[str]:
    """Best-effort: resolve hostname to IP strings (v4/v6)."""
    host = str(hostname or "").strip()
    if not host:
        return []
    try:
        infos = socket.getaddrinfo(host, None)
    except Exception:
        return []
    out: List[str] = []
    for _fam, _typ, _proto, _canon, sockaddr in infos:
        try:
            ip = sockaddr[0]
        except Exception:
            continue
        if isinstance(ip, str) and ip and ip not in out:
            out.append(ip)
    return out


def _is_loopback_host(hostname: str) -> bool:
    host = str(hostname or "").strip().lower()
    if not host:
        return False
    if host == "localhost":
        return True
    try:
        return bool(ipaddress.ip_address(host).is_loopback)
    except Exception:
        pass
    ips = _resolve_host_ips(host)
    if not ips:
        return False
    try:
        return all(ipaddress.ip_address(ip).is_loopback for ip in ips)
    except Exception:
        return False


def _canonical_allowlist_host(hostname: Any) -> str:
    """Canonicalize a parsed hostname for allowlist comparisons."""
    host = str(hostname or "").strip().rstrip(".").lower()
    if not host:
        return ""
    try:
        return ipaddress.ip_address(host).compressed.lower()
    except Exception:
        pass
    try:
        return host.encode("idna").decode("ascii").lower()
    except Exception:
        return host


def _effective_url_port(scheme: str, port: Optional[int]) -> Optional[int]:
    if port is not None:
        return port
    if scheme == "http":
        return 80
    if scheme == "https":
        return 443
    return None


def _safe_split_http_url(value: str) -> Optional[Tuple[Any, str, str, Optional[int]]]:
    """Parse an http(s) URL into canonical pieces, rejecting malformed ports."""
    try:
        parsed = urllib.parse.urlsplit(str(value or "").strip())
        port = parsed.port
    except Exception:
        return None

    scheme = str(parsed.scheme or "").lower()
    if scheme not in {"http", "https"}:
        return None
    if parsed.username is not None or parsed.password is not None:
        return None

    host = _canonical_allowlist_host(parsed.hostname)
    if not host:
        return None

    return parsed, scheme, host, _effective_url_port(scheme, port)


def _canonical_allowlist_path(path: Any) -> str:
    """Normalize URL paths for path-prefix allowlists.

    Repeated percent-decoding and dot-segment normalization make `/v1/../admin`
    and `/v1/%252e%252e/admin` compare as `/admin`, avoiding prefix bypasses.
    """
    p = str(path or "")
    if not p.startswith("/"):
        p = "/" + p

    for _ in range(3):
        decoded = urllib.parse.unquote(p)
        if decoded == p:
            break
        p = decoded

    p = p.replace("\\", "/")
    normalized = posixpath.normpath(p)
    if not normalized.startswith("/"):
        normalized = "/" + normalized
    if p.endswith("/") and normalized != "/" and not normalized.endswith("/"):
        normalized += "/"
    return normalized


def _allowlist_path_prefix_matches(target_path: Any, allowed_path: Any) -> bool:
    allowed = _canonical_allowlist_path(allowed_path)
    target = _canonical_allowlist_path(target_path)
    if allowed == "/":
        return True
    allowed = allowed.rstrip("/")
    return target == allowed or target.startswith(f"{allowed}/")


def _split_host_allowlist_item(item: str) -> Tuple[str, Optional[int]]:
    """Split a host/glob allowlist entry into host pattern and optional port."""
    raw = str(item or "").strip()
    if not raw:
        return "", None

    if raw.startswith("["):
        end = raw.find("]")
        if end > 0:
            host = raw[1:end]
            rest = raw[end + 1:]
            if rest.startswith(":") and rest[1:].isdigit():
                return host, int(rest[1:])
            if not rest:
                return host, None
            return raw, None

    if raw.count(":") == 1:
        host, port_s = raw.rsplit(":", 1)
        if port_s.isdigit():
            return host, int(port_s)

    return raw, None


def _allowlist_matches_url(url: str, allowlist: List[str]) -> bool:
    """Match a URL/host against an operator-provided allowlist.

    Allowlist items:
    - If item contains '://': match scheme, host, effective port, and path prefix
      with a path-segment boundary.
    - Otherwise: match a hostname glob (optionally `:port`), e.g. '*.example.com'.
    """
    target = _safe_split_http_url(url)
    if target is None or not allowlist:
        return False
    target_parsed, target_scheme, target_host, target_port = target

    for item in allowlist:
        it = str(item or "").strip()
        if not it:
            continue
        if "://" in it:
            allowed = _safe_split_http_url(it)
            if allowed is None:
                continue
            allowed_parsed, allowed_scheme, allowed_host, allowed_port = allowed

            if allowed_parsed.username is not None or allowed_parsed.password is not None:
                continue
            if allowed_parsed.fragment:
                continue

            if target_scheme != allowed_scheme:
                continue
            if target_host != allowed_host:
                continue
            if target_port != allowed_port:
                continue
            if allowed_parsed.query and target_parsed.query != allowed_parsed.query:
                continue
            if _allowlist_path_prefix_matches(target_parsed.path, allowed_parsed.path):
                return True
            continue

        host_pattern, allowed_port = _split_host_allowlist_item(it)
        if allowed_port is not None and target_port != allowed_port:
            continue
        if any(ch in host_pattern for ch in "*?["):
            pattern = host_pattern.strip().rstrip(".").lower()
        else:
            pattern = _canonical_allowlist_host(host_pattern)
        if pattern and fnmatch.fnmatchcase(target_host, pattern):
            return True
    return False


def _require_public_url(url: str, *, allowlist_env: str) -> None:
    """Enforce that a URL does not resolve to private/link-local/loopback addresses.

    Operator escape hatch: set an allowlist env var (comma-separated) to permit specific
    structured URL entries or host globs for controlled environments.
    """
    u = str(url or "").strip()
    if not u:
        raise HTTPException(status_code=400, detail={"error": {"message": "Empty URL", "type": "invalid_request"}})

    try:
        parsed = urllib.parse.urlsplit(u)
        _ = parsed.port
    except Exception:
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": "Invalid URL", "type": "invalid_request"}},
        )
    if parsed.scheme not in {"http", "https"}:
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": "Only http/https URLs are allowed", "type": "invalid_request"}},
        )
    host = parsed.hostname
    if not host:
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": "Invalid URL (missing host)", "type": "invalid_request"}},
        )

    allowlist = _csv_env(allowlist_env)
    if _allowlist_matches_url(u, allowlist):
        return

    ips = _resolve_host_ips(host)
    if not ips:
        raise HTTPException(
            status_code=403,
            detail={
                "error": {
                    "message": f"URL host did not resolve: {host}",
                    "type": "forbidden",
                }
            },
        )
    # Block anything that is not globally routable (covers loopback, RFC1918, link-local, etc).
    for ip in ips:
        try:
            if not ipaddress.ip_address(ip).is_global:
                raise HTTPException(
                    status_code=403,
                    detail={
                        "error": {
                            "message": (
                                "Refusing to fetch non-public URL target (SSRF protection). "
                                f"Host={host} resolved to {ip}. "
                                f"To allow this, set {allowlist_env} with an explicit allowlist."
                            ),
                            "type": "forbidden",
                        }
                    },
                )
        except HTTPException:
            raise
        except Exception:
            # If parsing fails, be conservative.
            raise HTTPException(
                status_code=403,
                detail={
                    "error": {
                        "message": f"Refusing to fetch URL with unparseable IP for host={host!r} (SSRF protection).",
                        "type": "forbidden",
                    }
                },
            )


def _provider_api_key_env_var(provider: str) -> Optional[str]:
    """Best-effort mapping of provider -> env var used for auth."""
    p = str(provider or "").strip().lower()
    if p == "openai":
        return "OPENAI_API_KEY"
    if p == "anthropic":
        return "ANTHROPIC_API_KEY"
    if p == "openrouter":
        return "OPENROUTER_API_KEY"
    if p == "portkey":
        return "PORTKEY_API_KEY"
    if p == "openai-compatible":
        return "OPENAI_COMPATIBLE_API_KEY"
    if p == "vllm":
        return "VLLM_API_KEY"
    return None


def _server_has_provider_api_key(provider: str) -> bool:
    p = str(provider or "").strip().lower()
    env_var = _provider_api_key_env_var(provider)
    if env_var and str(os.getenv(env_var) or "").strip():
        return True

    try:
        from ..config import manager as config_manager_module

        cfg = getattr(config_manager_module, "_config_manager", None)
        if cfg is not None:
            runtime_config = cfg.get_provider_config(p)
            for key_name in ("api_key", "provider_api_key"):
                if str(runtime_config.get(key_name) or "").strip():
                    return True
    except Exception:
        pass

    return False


def _reject_body_api_key(api_key: Any) -> None:
    if api_key is None:
        return
    raise HTTPException(
        status_code=400,
        detail={
            "error": {
                "message": (
                    "Per-request provider api_key fields are disabled for the HTTP server because they leak "
                    "through bodies, logs, and client history. Use X-AbstractCore-Provider-API-Key for a "
                    "provider override, Authorization as a provider key only when server auth is not "
                    "configured, or configure provider keys on the server."
                ),
                "type": "invalid_request",
            }
        },
    )


def _reject_query_api_key(api_key: Any) -> None:
    if api_key is None:
        return
    raise HTTPException(
        status_code=400,
        detail={
            "error": {
                "message": (
                    "Query-string api_key is disabled because URLs are routinely logged. Use Authorization "
                    "as a provider key only when server auth is not configured, "
                    "X-AbstractCore-Provider-API-Key for a provider override, or configure provider keys "
                    "on the server."
                ),
                "type": "invalid_request",
            }
        },
    )


def _guard_unauthenticated_server_provider_key_use(
    provider: str,
    *,
    explicit_provider_key: bool,
    http_request: Optional[Request] = None,
) -> None:
    """Do not let unauthenticated HTTP callers spend or exfiltrate server-held provider keys."""
    if _request_has_server_auth(http_request) or explicit_provider_key:
        return
    if not _server_has_provider_api_key(provider):
        return

    env_var = _provider_api_key_env_var(provider) or "provider API key"
    raise HTTPException(
        status_code=401,
        detail={
            "error": {
                "message": (
                    f"Server-held {env_var} is configured, but inbound server auth is not. "
                    "Set ABSTRACTCORE_SERVER_API_KEY and send Authorization: Bearer <server-key>, "
                    "or pass an explicit provider key with Authorization: Bearer <provider-key> "
                    "when server auth is not configured."
                ),
                "type": "authentication_error",
            }
        },
        headers={"WWW-Authenticate": "Bearer"},
    )


def _server_allows_request_base_url(base_url: str) -> bool:
    """Allow request-level provider base_url overrides only when explicitly safe.

    Default: allow loopback only. Operators can expand via `ABSTRACTCORE_SERVER_BASE_URL_ALLOWLIST`.
    """
    u = str(base_url or "").strip()
    if not u:
        return False
    parsed = _safe_split_http_url(u)
    if parsed is None:
        return False
    _parsed_url, _scheme, host, _port = parsed
    if _is_loopback_host(host):
        return True
    allowlist = _csv_env("ABSTRACTCORE_SERVER_BASE_URL_ALLOWLIST")
    return _allowlist_matches_url(u, allowlist)


def _server_media_root() -> Optional[Path]:
    """Optional safe-root for local file attachments in HTTP requests.

    If set, local paths are only allowed when they resolve under this directory.
    """
    raw = os.getenv("ABSTRACTCORE_SERVER_MEDIA_ROOT")
    if not isinstance(raw, str) or not raw.strip():
        return None
    try:
        return Path(raw).expanduser().resolve()
    except Exception:
        return None


def _server_allows_local_files() -> bool:
    """Dangerous: allow arbitrary local file paths from HTTP requests."""
    return _parse_bool_env("ABSTRACTCORE_SERVER_ALLOW_LOCAL_FILES")


def _resolve_local_media_path(path_value: str) -> str:
    """Resolve and validate a local file path under server policy."""
    raw = str(path_value or "").strip()
    if not raw:
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": "Empty local file path", "type": "invalid_request"}},
        )

    root = _server_media_root()
    allow_all = _server_allows_local_files()
    if root is None and not allow_all:
        raise HTTPException(
            status_code=403,
            detail={
                "error": {
                    "message": (
                        "Local file paths are disabled for HTTP requests. "
                        "Use http(s) URLs or data: base64 attachments, or configure "
                        "ABSTRACTCORE_SERVER_MEDIA_ROOT (safe) / ABSTRACTCORE_SERVER_ALLOW_LOCAL_FILES=1 (unsafe)."
                    ),
                    "type": "forbidden",
                }
            },
        )

    p = Path(raw).expanduser()
    if root is not None and not p.is_absolute():
        p = root / p

    try:
        resolved = p.resolve()
    except Exception as e:
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": f"Invalid local path: {e}", "type": "invalid_request"}},
        ) from e

    if root is not None:
        try:
            if not resolved.is_relative_to(root):
                raise HTTPException(
                    status_code=403,
                    detail={
                        "error": {
                            "message": (
                                "Local file path is outside ABSTRACTCORE_SERVER_MEDIA_ROOT. "
                                f"root={str(root)} path={str(resolved)}"
                            ),
                            "type": "forbidden",
                        }
                    },
                )
        except HTTPException:
            raise
        except Exception:
            raise HTTPException(
                status_code=403,
                detail={
                    "error": {
                        "message": "Local file path rejected by server policy.",
                        "type": "forbidden",
                    }
                },
            )

    if not resolved.exists() or not resolved.is_file():
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": f"File not found: {str(resolved)}", "type": "file_not_found"}},
        )

    return str(resolved)


_OLLAMA_INFLIGHT_LOCK = threading.Lock()
_OLLAMA_INFLIGHT_COUNTS: Dict[Tuple[str, str, str], int] = {}
_OLLAMA_UNLOAD_REQUESTED: Dict[Tuple[str, str, str], bool] = {}


def _ollama_inflight_key(provider: str, base_url: Optional[str], model: str) -> Tuple[str, str, str]:
    """Build a stable key for tracking in-flight Ollama requests."""
    return (provider.strip().lower(), (base_url or "").strip(), model)


def _ollama_inflight_enter(key: Tuple[str, str, str]) -> None:
    """Increment in-flight counter for an Ollama (provider/base_url/model) key."""
    with _OLLAMA_INFLIGHT_LOCK:
        _OLLAMA_INFLIGHT_COUNTS[key] = _OLLAMA_INFLIGHT_COUNTS.get(key, 0) + 1


def _ollama_inflight_exit(key: Tuple[str, str, str], *, unload_after_requested: bool) -> bool:
    """Decrement in-flight counter and return True if an unload should happen now."""
    with _OLLAMA_INFLIGHT_LOCK:
        if unload_after_requested:
            _OLLAMA_UNLOAD_REQUESTED[key] = True

        current = _OLLAMA_INFLIGHT_COUNTS.get(key, 0)
        if current <= 1:
            _OLLAMA_INFLIGHT_COUNTS.pop(key, None)
            return bool(_OLLAMA_UNLOAD_REQUESTED.pop(key, False))

        _OLLAMA_INFLIGHT_COUNTS[key] = current - 1
        return False


def _best_effort_unload(llm: Any, *, request_id: str, provider: str, model: str) -> None:
    """Unload provider resources without failing the request lifecycle."""
    try:
        if not hasattr(llm, "unload_model"):
            raise AttributeError("Provider does not implement unload_model(model_name)")
        llm.unload_model(model)
        logger.info("🧹 Provider Unloaded", request_id=request_id, provider=provider, model=model)
    except Exception as e:
        logger.warning(
            "⚠️ Provider unload failed",
            request_id=request_id,
            provider=provider,
            model=model,
            error=str(e),
            error_type=type(e).__name__,
        )


def parse_model_string(model_string: str) -> tuple[str, str]:
    """Parse model string to extract provider and model."""
    if not model_string:
        return "ollama", "qwen3-coder:30b"  # Default

    # Explicit provider/model format
    if '/' in model_string:
        parts = model_string.split('/', 1)
        provider = parts[0].strip()
        model = parts[1].strip()
        return provider, model

    # Auto-detect provider from model name
    model_lower = model_string.lower()

    if any(pattern in model_lower for pattern in ['gpt-', 'text-davinci', 'text-embedding']):
        return "openai", model_string
    elif any(pattern in model_lower for pattern in ['claude']):
        return "anthropic", model_string
    elif any(pattern in model_lower for pattern in ['llama', 'mistral', 'gemma', 'phi', 'qwen']):
        return "ollama", model_string
    elif any(pattern in model_lower for pattern in ['-4bit', 'mlx-community']):
        return "mlx", model_string
    else:
        return "ollama", model_string  # Default

def detect_target_format(
    model: str,
    request: ChatCompletionRequest,
    http_request: Request
) -> SyntaxFormat:
    """
    Detect the target format for tool call syntax conversion.

    Args:
        model: Model identifier
        request: Chat completion request
        http_request: HTTP request object

    Returns:
        Target syntax format
    """
    # Explicit format override
    if request.agent_format:
        try:
            return SyntaxFormat(request.agent_format.lower())
        except ValueError:
            logger.warning(f"Invalid agent_format '{request.agent_format}', using auto-detection")

    # Auto-detect from headers and model
    user_agent = http_request.headers.get("user-agent", "")
    return auto_detect_format(model, user_agent)

def convert_to_abstractcore_messages(openai_messages: List[ChatMessage]) -> List[Dict[str, Any]]:
    """Convert OpenAI messages to AbstractCore format."""
    messages = []
    for msg in openai_messages:
        message_dict = {"role": msg.role}

        if msg.content is not None:
            message_dict["content"] = msg.content
        if msg.tool_calls:
            message_dict["tool_calls"] = msg.tool_calls
        if msg.tool_call_id:
            message_dict["tool_call_id"] = msg.tool_call_id
        if msg.name:
            message_dict["name"] = msg.name

        messages.append(message_dict)

    return messages

def create_syntax_rewriter(target_format: SyntaxFormat, model_name: str) -> ToolCallSyntaxRewriter:
    """Create appropriate syntax rewriter for target format."""
    if target_format == SyntaxFormat.PASSTHROUGH:
        return create_passthrough_rewriter()
    elif target_format == SyntaxFormat.CODEX:
        return create_codex_rewriter(model_name)
    elif target_format == SyntaxFormat.OPENAI:
        return create_openai_rewriter(model_name)
    else:
        return ToolCallSyntaxRewriter(target_format, model_name=model_name)

# ============================================================================
# Endpoints
# ============================================================================

@app.get("/health", tags=["health"])
async def health_check():
    """Health check endpoint."""
    return {
        "status": "healthy",
        "version": __version__,
        "features": [
            "multi-agent-syntax-support",
            "auto-format-detection",
            "universal-tool-calls",
            "abstractcore-integration"
        ]
    }


class PromptCacheProxyBase(BaseModel):
    """Proxy configuration for forwarding AbstractCore prompt-cache control-plane calls."""

    base_url: Optional[str] = Field(
        default=None,
        description=(
            "Upstream base URL for an AbstractEndpoint instance. Can include an OpenAI-style `/v1` suffix "
            "(it will be stripped when proxying `/acore/prompt_cache/*`)."
        ),
        example="http://localhost:8001/v1",
    )
    api_key: Optional[str] = Field(
        default=None,
        description="Deprecated/disabled for HTTP request bodies. Use X-AbstractCore-Provider-API-Key for provider overrides.",
        example=None,
    )


class PromptCacheSetProxyRequest(PromptCacheProxyBase):
    key: str = Field(..., description="Prompt-cache key to select or create on the upstream AbstractEndpoint.", example="project-default")
    make_default: bool = Field(default=True, description="Whether the upstream endpoint should make this key the default cache key.")
    ttl_s: Optional[float] = Field(default=None, description="Optional upstream cache TTL in seconds.", example=3600)


class PromptCacheUpdateProxyRequest(PromptCacheProxyBase):
    key: str = Field(..., description="Prompt-cache key to update on the upstream AbstractEndpoint.", example="project-default")
    prompt: Optional[str] = Field(default=None, description="Plain prompt text to prepare/cache upstream.")
    messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="Chat messages to prepare/cache upstream.")
    system_prompt: Optional[str] = Field(default=None, description="Optional system prompt to include in the prepared cache state.")
    tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Optional tool schemas to include in the prepared cache state.")
    add_generation_prompt: bool = Field(default=False, description="Whether to ask the upstream endpoint to add a generation prompt marker.")
    ttl_s: Optional[float] = Field(default=None, description="Optional upstream cache TTL in seconds.", example=3600)


class PromptCacheForkProxyRequest(PromptCacheProxyBase):
    from_key: str = Field(..., description="Existing upstream cache key to fork from.", example="project-default")
    to_key: str = Field(..., description="New upstream cache key to create.", example="project-branch")
    make_default: bool = Field(default=False, description="Whether the forked cache key should become the upstream default.")
    ttl_s: Optional[float] = Field(default=None, description="Optional upstream cache TTL in seconds.", example=3600)


class PromptCacheClearProxyRequest(PromptCacheProxyBase):
    key: Optional[str] = Field(default=None, description="Specific upstream cache key to clear. Omit to clear upstream default/all cache state, depending on backend support.")


class PromptCachePrepareModulesProxyRequest(PromptCacheProxyBase):
    namespace: str = Field(..., description="Namespace for the prepared module cache.", example="abstractcore.tools")
    modules: List[Dict[str, Any]] = Field(..., description="Module descriptors/payloads to prepare on the upstream endpoint.")
    make_default: bool = Field(default=False, description="Whether the prepared module cache should become the upstream default.")
    ttl_s: Optional[float] = Field(default=None, description="Optional upstream cache TTL in seconds.", example=3600)
    version: int = Field(default=1, description="Payload version for forward-compatible module preparation.")


def _normalize_control_plane_base_url(base_url: str) -> str:
    u = str(base_url or "").strip().rstrip("/")
    if u.endswith("/v1"):
        u = u[:-3]
    return u.rstrip("/")


def _proxy_prompt_cache_request(
    *,
    http_request: Optional[Request] = None,
    base_url: Optional[str],
    api_key: Optional[str],
    method: str,
    path: str,
    json_body: Optional[Dict[str, Any]] = None,
    timeout_s: float = 30.0,
) -> Dict[str, Any]:
    if not isinstance(base_url, str) or not base_url.strip():
        return {
            "supported": False,
            "error": "base_url is required to proxy prompt cache control plane calls (use AbstractEndpoint)",
        }
    _reject_body_api_key(api_key)

    base_url_s = base_url.strip()
    if not _server_allows_request_base_url(base_url_s):
        return {
            "supported": False,
            "error": (
                "Request-level base_url overrides are restricted for security. "
                "By default only loopback URLs are allowed. "
                "To allow additional hosts/prefixes, set ABSTRACTCORE_SERVER_BASE_URL_ALLOWLIST."
            ),
        }

    upstream_root = _normalize_control_plane_base_url(base_url_s)
    url = f"{upstream_root}{path}"

    headers: Dict[str, str] = {}
    upstream_api_key = _provider_api_key_from_request(http_request) if http_request is not None else None
    if upstream_api_key:
        headers["Authorization"] = f"Bearer {upstream_api_key}"

    try:
        with httpx.Client(timeout=timeout_s) as client:
            if method.upper() == "GET":
                resp = client.get(url, headers=headers)
            else:
                resp = client.post(url, headers=headers, json=json_body or {})
    except Exception as e:
        return {"supported": False, "error": str(e)}

    try:
        payload = resp.json()
    except Exception:
        payload = {"error": resp.text}

    if resp.status_code >= 400:
        return {
            "supported": False,
            "status_code": int(resp.status_code),
            "error": payload,
            "upstream": url,
        }

    if isinstance(payload, dict):
        return payload
    return {"supported": True, "data": payload}


@app.get(
    "/acore/prompt_cache/stats",
    tags=["prompt-cache"],
    summary="Prompt Cache Stats",
    description="Proxy a prompt-cache stats request to an AbstractEndpoint control-plane base URL.",
)
def acore_prompt_cache_stats(
    http_request: Request,
    base_url: Optional[str] = Query(None, description="Upstream AbstractEndpoint base_url (optionally including /v1)"),
    api_key: Optional[str] = Query(None, description="Deprecated/disabled. Use X-AbstractCore-Provider-API-Key for provider overrides."),
):
    _reject_query_api_key(api_key)
    return _proxy_prompt_cache_request(
        http_request=http_request,
        base_url=base_url,
        api_key=api_key,
        method="GET",
        path="/acore/prompt_cache/stats",
        json_body=None,
    )


@app.get(
    "/acore/prompt_cache/capabilities",
    tags=["prompt-cache"],
    summary="Prompt Cache Capabilities",
    description="Proxy a prompt-cache capability discovery request to an AbstractEndpoint control-plane base URL.",
)
def acore_prompt_cache_capabilities(
    http_request: Request,
    base_url: Optional[str] = Query(None, description="Upstream AbstractEndpoint base_url (optionally including /v1)"),
    api_key: Optional[str] = Query(None, description="Deprecated/disabled. Use X-AbstractCore-Provider-API-Key for provider overrides."),
):
    _reject_query_api_key(api_key)
    return _proxy_prompt_cache_request(
        http_request=http_request,
        base_url=base_url,
        api_key=api_key,
        method="GET",
        path="/acore/prompt_cache/capabilities",
        json_body=None,
    )


@app.post(
    "/acore/prompt_cache/set",
    tags=["prompt-cache"],
    summary="Set Prompt Cache Key",
    description="Proxy a request that selects or creates a prompt-cache key on an upstream AbstractEndpoint.",
)
def acore_prompt_cache_set(req: PromptCacheSetProxyRequest, http_request: Request):
    body = req.model_dump(exclude_none=True)
    base_url = body.pop("base_url", None)
    api_key = body.pop("api_key", None)
    return _proxy_prompt_cache_request(
        http_request=http_request,
        base_url=base_url,
        api_key=api_key,
        method="POST",
        path="/acore/prompt_cache/set",
        json_body=body,
    )


@app.post(
    "/acore/prompt_cache/update",
    tags=["prompt-cache"],
    summary="Update Prompt Cache",
    description="Proxy a request that prepares prompt/messages/tools into an upstream AbstractEndpoint prompt cache.",
)
def acore_prompt_cache_update(req: PromptCacheUpdateProxyRequest, http_request: Request):
    body = req.model_dump(exclude_none=True)
    base_url = body.pop("base_url", None)
    api_key = body.pop("api_key", None)
    return _proxy_prompt_cache_request(
        http_request=http_request,
        base_url=base_url,
        api_key=api_key,
        method="POST",
        path="/acore/prompt_cache/update",
        json_body=body,
    )


@app.post(
    "/acore/prompt_cache/fork",
    tags=["prompt-cache"],
    summary="Fork Prompt Cache",
    description="Proxy a request that forks one upstream prompt-cache key into another key.",
)
def acore_prompt_cache_fork(req: PromptCacheForkProxyRequest, http_request: Request):
    body = req.model_dump(exclude_none=True)
    base_url = body.pop("base_url", None)
    api_key = body.pop("api_key", None)
    return _proxy_prompt_cache_request(
        http_request=http_request,
        base_url=base_url,
        api_key=api_key,
        method="POST",
        path="/acore/prompt_cache/fork",
        json_body=body,
    )


@app.post(
    "/acore/prompt_cache/clear",
    tags=["prompt-cache"],
    summary="Clear Prompt Cache",
    description="Proxy a request that clears upstream prompt-cache state.",
)
def acore_prompt_cache_clear(req: PromptCacheClearProxyRequest, http_request: Request):
    body = req.model_dump(exclude_none=True)
    base_url = body.pop("base_url", None)
    api_key = body.pop("api_key", None)
    return _proxy_prompt_cache_request(
        http_request=http_request,
        base_url=base_url,
        api_key=api_key,
        method="POST",
        path="/acore/prompt_cache/clear",
        json_body=body,
    )


@app.post(
    "/acore/prompt_cache/prepare_modules",
    tags=["prompt-cache"],
    summary="Prepare Prompt Cache Modules",
    description="Proxy a request that prepares module/tool context in an upstream AbstractEndpoint prompt cache.",
)
def acore_prompt_cache_prepare_modules(req: PromptCachePrepareModulesProxyRequest, http_request: Request):
    body = req.model_dump(exclude_none=True)
    base_url = body.pop("base_url", None)
    api_key = body.pop("api_key", None)
    return _proxy_prompt_cache_request(
        http_request=http_request,
        base_url=base_url,
        api_key=api_key,
        method="POST",
        path="/acore/prompt_cache/prepare_modules",
        json_body=body,
    )


@app.get("/v1/models", tags=["models"])
async def list_models(
    http_request: Request,
    provider: Optional[str] = Query(
        None,
        description="Filter by provider (e.g., 'ollama', 'openai', 'anthropic', 'lmstudio')",
    ),
    input_type: Optional[ModelInputCapability] = Query(
        None,
        description="Filter by input capability: 'text', 'image', 'audio', 'video'"
    ),
    output_type: Optional[ModelOutputCapability] = Query(
        None,
        description="Filter by output capability: 'text', 'embeddings'"
    ),
    api_key: Optional[str] = Query(
        None,
        description=(
            "Deprecated/disabled. Query-string secrets leak through URLs; use "
            "X-AbstractCore-Provider-API-Key for provider overrides, Authorization only when "
            "server auth is not configured, or configure provider keys on the server."
        ),
    ),
    base_url: Optional[str] = Query(
        None,
        description="Optional upstream base URL override (useful for OpenAI-compatible providers).",
    ),
):
    """
    List available models from AbstractCore providers.

    Returns a list of all available models, optionally filtered by provider and/or capabilities.

    **Filtering System:**
    - `input_type`: Filter by what INPUT the model can process (text, image, audio, video)
    - `output_type`: Filter by what OUTPUT the model generates (text, embeddings)

    **Examples:**
    - `/v1/models` - All models from all providers
    - `/v1/models?output_type=embeddings` - Only embedding models
    - `/v1/models?input_type=text&output_type=text` - Text-only models that generate text
    - `/v1/models?input_type=image` - Models that can analyze images
    - `/v1/models?provider=ollama&input_type=image` - Ollama vision models only
    """
    try:
        models_data = []

        # Use the capability enums directly
        input_capabilities = [input_type] if input_type else None
        output_capabilities = [output_type] if output_type else None

        _reject_query_api_key(api_key)

        if provider:
            provider_norm = provider.lower().strip()
            from ..providers.registry import is_provider_available

            # Match prior behavior: unknown providers return an empty list (200 OK).
            if not is_provider_available(provider_norm):
                return {"object": "list", "data": []}

            provider_kwargs: Dict[str, Any] = {}

            candidate_key = _provider_api_key_from_request(http_request) or ""

            if candidate_key:
                provider_kwargs["api_key"] = candidate_key
            _guard_unauthenticated_server_provider_key_use(
                provider_norm,
                explicit_provider_key=bool(candidate_key),
                http_request=http_request,
            )
            if isinstance(base_url, str) and base_url.strip():
                base_url_s = base_url.strip()
                if not _server_allows_request_base_url(base_url_s):
                    raise HTTPException(
                        status_code=403,
                        detail={
                            "error": {
                                "message": (
                                    "Request-level base_url overrides are restricted for security. "
                                    "By default only loopback URLs are allowed. "
                                    "To allow additional hosts/prefixes, set "
                                    "ABSTRACTCORE_SERVER_BASE_URL_ALLOWLIST."
                                ),
                                "type": "forbidden",
                            }
                        },
                    )

                parsed = urllib.parse.urlsplit(base_url_s)
                host = str(parsed.hostname or "").strip()
                if host and not _is_loopback_host(host):
                    env_var = _provider_api_key_env_var(provider_norm)
                    if _server_has_provider_api_key(provider_norm) and not candidate_key:
                        raise HTTPException(
                            status_code=403,
                            detail={
                                "error": {
                                    "message": (
                                        "Refusing request-level base_url override without an explicit provider key "
                                        f"because the server has {env_var} set. Provide the provider key via "
                                        "X-AbstractCore-Provider-API-Key, or via Authorization only when server auth "
                                        "is not configured."
                                    ),
                                    "type": "forbidden",
                                }
                            },
                        )

                provider_kwargs["base_url"] = base_url_s

            # Get models from specific provider with optional filtering
            try:
                models = get_models_from_provider(
                    provider_norm,
                    input_capabilities=input_capabilities,
                    output_capabilities=output_capabilities,
                    raise_on_error=True,
                    **provider_kwargs,
                )
            except Exception as e:
                raise HTTPException(
                    status_code=502,
                    detail=f"Failed to list models for provider '{provider_norm}': {e}",
                ) from e
            for model in models:
                model_id = f"{provider_norm}/{model}"
                models_data.append({
                    "id": model_id,
                    "object": "model",
                    "owned_by": provider_norm,
                    "created": int(time.time()),
                    "permission": [{"allow_create_engine": False, "allow_sampling": True}]
                })

            filter_parts = []
            if input_type:
                filter_parts.append(f"input_type={input_type.value}")
            if output_type:
                filter_parts.append(f"output_type={output_type.value}")

            filter_msg = f" ({', '.join(filter_parts)})" if filter_parts else ""
            logger.info(f"Listed {len(models_data)} models for provider {provider}{filter_msg}")
        else:
            # Get models from all providers using centralized registry
            from ..providers.registry import list_available_providers
            providers = list_available_providers()
            for prov in providers:
                if not _server_auth_enabled() and _server_has_provider_api_key(prov):
                    logger.warning(
                        "Skipping provider model discovery because server-held API key requires server auth",
                        provider=prov,
                    )
                    continue
                models = get_models_from_provider(
                    prov, 
                    input_capabilities=input_capabilities,
                    output_capabilities=output_capabilities
                )
                for model in models:
                    model_id = f"{prov}/{model}"
                    models_data.append({
                        "id": model_id,
                        "object": "model",
                        "owned_by": prov,
                        "created": int(time.time()),
                        "permission": [{"allow_create_engine": False, "allow_sampling": True}]
                    })

            filter_parts = []
            if input_type:
                filter_parts.append(f"input_type={input_type.value}")
            if output_type:
                filter_parts.append(f"output_type={output_type.value}")

            filter_msg = f" ({', '.join(filter_parts)})" if filter_parts else ""
            logger.info(f"Listed {len(models_data)} models from all providers{filter_msg}")

        return {
            "object": "list",
            "data": sorted(models_data, key=lambda x: x["id"])
        }

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Failed to list models: {_redact_text(str(e))}")
        return {
            "object": "list",
            "data": []
        }

@app.get("/providers", tags=["providers"])
async def list_providers(
    include_models: bool = Query(
        False,
        description="Include model lists for each provider. Set to true for full information (slower)."
    )
):
    """
    List all available AbstractCore providers and their capabilities.

    Returns comprehensive information about all registered LLM providers, including:
    - Provider name, display name, and type
    - Number of available models and sample models (if include_models=True)
    - Current availability status and detailed error information
    - Provider description and supported features
    - Authentication requirements and installation instructions
    - Local vs. cloud provider designation

    **Query Parameters:**
    - `include_models` (bool, default=False): Include model lists for each provider.
      Set to `true` for full information (slower).

    **Performance:**
    - `include_models=false`: Metadata only (very fast, ~15ms) - **DEFAULT**
    - `include_models=true`: Full information including model lists (slower, ~800ms)

    **Supported Providers:**
    - **OpenAI**: Commercial API with GPT-4, GPT-3.5, and embedding models
    - **Anthropic**: Commercial API with Claude 3 family models
    - **Ollama**: Local LLM server for running open-source models
    - **LMStudio**: Local model development and testing platform
    - **MLX**: Apple Silicon optimized local inference
    - **HuggingFace**: Access to HuggingFace models (transformers and embeddings)

    **Use Cases:**
    - Fast provider discovery: `GET /providers` (default, very fast)
    - Full provider information: `GET /providers?include_models=true`
    - Build dynamic provider selection UIs
    - Monitor provider status and troubleshoot issues
    - Get installation instructions for missing dependencies

    **Returns:** A list of provider objects with comprehensive metadata.
    """
    try:
        from ..providers.registry import get_all_providers_with_models, get_all_providers_status, list_available_providers

        if include_models and not _server_auth_enabled():
            keyed_providers = [p for p in list_available_providers() if _server_has_provider_api_key(p)]
            if keyed_providers:
                raise HTTPException(
                    status_code=401,
                    detail={
                        "error": {
                            "message": (
                                "Provider model discovery would use server-held API keys, but inbound server auth is "
                                "not configured. Set ABSTRACTCORE_SERVER_API_KEY or omit include_models=true."
                            ),
                            "type": "authentication_error",
                        }
                    },
                    headers={"WWW-Authenticate": "Bearer"},
                )

        # Get providers with models (available providers)
        available_providers = get_all_providers_with_models(include_models=include_models)

        # Optionally include all providers (even those with issues) for debugging
        # Uncomment the next line if you want to see providers with errors too:
        # all_providers = get_all_providers_status()

        logger.info(f"Listed {len(available_providers)} available providers with models")

        return {
            "providers": available_providers,
            "total_providers": len(available_providers),
            "registry_version": "2.0",  # Indicate this is using the new registry system
            "note": "Provider information from centralized AbstractCore registry"
        }

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Failed to list providers: {_redact_text(str(e))}")
        return {
            "providers": [],
            "total_providers": 0,
            "error": _redact_text(str(e)),
            "registry_version": "2.0"
        }

@app.post(
    "/v1/responses",
    tags=["responses"],
    summary="Create Response",
    description=(
        "Create a model response using either the OpenAI Responses API request shape "
        "or the legacy AbstractCore chat-completions request shape."
    ),
    openapi_extra={
        "requestBody": {
            "description": (
                "JSON body in either OpenAI Responses format (`model` plus `input`) "
                "or legacy chat-completions format (`model` plus `messages`)."
            )
        }
    },
)
async def create_response(
    http_request: Request,
    request_body: Annotated[
        Dict[str, Any],
        Body(
            ...,
            description=(
                "Responses request body. Accepts either the OpenAI Responses API shape "
                "with `input` content items, or the legacy chat-completions shape with "
                "`messages`. The examples below show both supported formats."
            ),
            examples={
                "openai_format": {
                    "summary": "OpenAI Responses API Format",
                    "description": "Use input array with input_text and input_file objects",
                    "value": {
                        "model": "gpt-4o",
                        "input": [
                            {
                                "role": "user",
                                "content": [
                                    {"type": "input_text", "text": "Analyze this document"},
                                    {"type": "input_file", "file_url": "https://example.com/doc.pdf"}
                                ]
                            }
                        ],
                        "stream": False
                    }
                },
                "legacy_format": {
                    "summary": "Legacy Format (Backward Compatible)",
                    "description": "Use messages array like standard chat completions",
                    "value": {
                        "model": "openai/gpt-4",
                        "messages": [
                            {"role": "user", "content": "Tell me a story"}
                        ],
                        "stream": False
                    }
                },
                "file_analysis": {
                    "summary": "Document Analysis",
                    "description": "Analyze files using OpenAI format",
                    "value": {
                        "model": "openai/gpt-4",
                        "input": [
                            {
                                "role": "user",
                                "content": [
                                    {"type": "input_text", "text": "What's the key information in this CSV?"},
                                    {"type": "input_file", "file_url": "data:text/csv;base64,RGF0ZSxQcm9kdWN0LFNhbGVzCjIwMjQtMDEtMDEsUHJvZHVjdCBBLDEwMDAwCjIwMjQtMDEtMDIsUHJvZHVjdCBCLDE1MDAwCjIwMjQtMDEtMDMsUHJvZHVjdCBDLDI1MDAw"}
                                ]
                            }
                        ]
                    }
                },
                "streaming_example": {
                    "summary": "Streaming Response",
                    "description": "Enable streaming for real-time responses",
                    "value": {
                        "model": "lmstudio/qwen/qwen3-next-80b",
                        "input": [
                            {
                                "role": "user",
                                "content": [
                                    {"type": "input_text", "text": "Analyze the letter and provide a summary of the key points."},
                                    {"type": "input_file", "file_url": "https://www.berkshirehathaway.com/letters/2024ltr.pdf"}
                                ]
                            }
                        ],
                        "stream": True
                    }
                }
            }
        )
    ]
):
    """
    OpenAI Responses API (100% Compatible) + Backward Compatibility

    Supports both OpenAI's responses format and our legacy format for seamless migration.
    Streaming can be enabled by setting "stream": true for real-time interaction.

    **OpenAI Format (input_file support):**
    ```json
    {
      "model": "gpt-4o",
      "input": [
        {
          "role": "user",
          "content": [
            {"type": "input_text", "text": "Analyze this document"},
            {"type": "input_file", "file_url": "https://example.com/doc.pdf"}
          ]
        }
      ]
    }
    ```

    **Legacy Format (backward compatibility):**
    ```json
    {
      "model": "openai/gpt-4",
      "messages": [
        {"role": "user", "content": "Tell me a story"}
      ]
    }
    ```

    **Key Features:**
    - **100% OpenAI Compatible**: Supports input_file with file_url
    - **Universal File Support**: PDF, DOCX, XLSX, CSV, images, and more
    - **Multi-Provider**: Works with all providers (OpenAI, Anthropic, Ollama, etc.)
    - **Optional Streaming**: Set "stream": true for real-time responses
    - **Backward Compatible**: Existing clients continue to work

    **Returns:** Chat completion object, or server-sent events stream if streaming is enabled.
    """
    try:
        # Use the parsed request body directly
        request_data = request_body
        _reject_body_api_key(request_data.get("api_key") if isinstance(request_data, dict) else None)

        # Detect OpenAI responses format vs legacy format
        if "input" in request_data:
            # OpenAI Responses API format
            logger.info("📡 OpenAI Responses API format detected")

            # Parse as OpenAI format
            openai_request = OpenAIResponsesRequest(**request_data)

            # Convert to internal format
            chat_request = convert_openai_responses_to_chat_completion(openai_request)

        elif "messages" in request_data:
            # Legacy format (backward compatibility)
            logger.info("📡 Legacy responses format detected")

            # Parse as ChatCompletionRequest
            chat_request = ChatCompletionRequest(**request_data)

        else:
            raise HTTPException(
                status_code=400,
                detail={"error": {"message": "Request must contain either 'input' (OpenAI format) or 'messages' (legacy format)", "type": "invalid_request"}}
            )

        # AbstractCore extension: allow opt-in unload-after-request even for OpenAI Responses format.
        if "unload_after" in request_data:
            try:
                chat_request = chat_request.model_copy(update={"unload_after": _parse_boolish(request_data.get("unload_after"))})
            except Exception as e:
                raise HTTPException(
                    status_code=422,
                    detail={"error": {"message": f"Invalid unload_after value: {e}", "type": "validation_error"}},
                )

        # Respect user's streaming preference (defaults to False)

        # Process using our standard pipeline
        provider, model = parse_model_string(chat_request.model)

        logger.info(
            "📡 Responses API Request",
            provider=provider,
            model=model,
            format="openai" if "input" in request_data else "legacy",
            messages=len(chat_request.messages)
        )

        return await process_chat_completion(provider, model, chat_request, http_request)

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Responses API error: {_redact_text(str(e))}")
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": _redact_text(str(e)), "type": "processing_error"}}
        )

_LOCAL_EMBEDDING_PROVIDERS = {"huggingface", "ollama"}
_DIRECT_EMBEDDING_PROVIDERS = {"lmstudio", "openai", "openrouter", "portkey", "openai-compatible"}
_SUPPORTED_EMBEDDING_PROVIDERS = _LOCAL_EMBEDDING_PROVIDERS | _DIRECT_EMBEDDING_PROVIDERS


def _normalize_embedding_inputs(value: Union[str, List[str]]) -> List[str]:
    if isinstance(value, str):
        inputs = [value]
    else:
        inputs = list(value)

    if not inputs:
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": "Embedding input array must not be empty.", "type": "invalid_request"}},
        )
    for idx, text in enumerate(inputs):
        if not isinstance(text, str):
            raise HTTPException(
                status_code=400,
                detail={
                    "error": {
                        "message": f"Embedding input at index {idx} must be a string.",
                        "type": "invalid_request",
                    }
                },
            )
        if not text.strip():
            raise HTTPException(
                status_code=400,
                detail={
                    "error": {
                        "message": f"Embedding input at index {idx} must not be empty.",
                        "type": "invalid_request",
                    }
                },
            )
    return inputs


def _embedding_dimensions(value: Optional[int]) -> Optional[int]:
    if value is None or value == 0:
        return None
    if value < 0:
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": "dimensions must be a positive integer, 0, or null.", "type": "invalid_request"}},
        )
    return int(value)


def _embedding_provider_kwargs(
    *,
    provider: str,
    request: EmbeddingRequest,
    http_request: Request,
) -> Dict[str, Any]:
    provider_kwargs: Dict[str, Any] = {}

    provider_api_key = _provider_api_key_from_request(http_request)
    if provider_api_key:
        provider_kwargs["api_key"] = provider_api_key

    _guard_unauthenticated_server_provider_key_use(
        provider,
        explicit_provider_key=bool(provider_api_key),
        http_request=http_request,
    )

    if isinstance(request.base_url, str) and request.base_url.strip():
        base_url = request.base_url.strip()
        if not _server_allows_request_base_url(base_url):
            raise HTTPException(
                status_code=403,
                detail={
                    "error": {
                        "message": (
                            "Request-level base_url overrides are restricted for security. "
                            "By default only loopback URLs are allowed. "
                            "To allow additional hosts/prefixes, set ABSTRACTCORE_SERVER_BASE_URL_ALLOWLIST."
                        ),
                        "type": "forbidden",
                    }
                },
            )

        parsed = urllib.parse.urlsplit(base_url)
        host = str(parsed.hostname or "").strip()
        if host and not _is_loopback_host(host):
            env_var = _provider_api_key_env_var(provider)
            if _server_has_provider_api_key(provider) and not provider_api_key:
                raise HTTPException(
                    status_code=403,
                    detail={
                        "error": {
                            "message": (
                                "Refusing request-level base_url override without an explicit provider key because "
                                f"the server has {env_var} set. Provide the provider key via "
                                "X-AbstractCore-Provider-API-Key, or via Authorization only when server auth is "
                                "not configured. "
                                f"(provider={provider})"
                            ),
                            "type": "forbidden",
                        }
                    },
                )

        provider_kwargs["base_url"] = base_url

    return provider_kwargs


def _create_direct_embedding_provider(provider: str, model: str, provider_kwargs: Dict[str, Any]):
    if provider == "openai":
        from ..providers.openai_provider import OpenAIProvider

        return OpenAIProvider(model=model, **provider_kwargs)
    if provider == "openrouter":
        from ..providers.openrouter_provider import OpenRouterProvider

        return OpenRouterProvider(model=model, validate_model=False, **provider_kwargs)
    if provider == "portkey":
        from ..providers.portkey_provider import PortkeyProvider

        return PortkeyProvider(model=model, **provider_kwargs)
    if provider == "openai-compatible":
        from ..providers.openai_compatible_provider import OpenAICompatibleProvider

        return OpenAICompatibleProvider(model=model, **provider_kwargs)
    if provider == "lmstudio":
        from ..providers.lmstudio_provider import LMStudioProvider

        return LMStudioProvider(model=model, **provider_kwargs)
    raise ValueError(f"Unsupported direct embedding provider: {provider}")


def _provider_exception_status(exc: Exception) -> int:
    if isinstance(exc, AuthenticationError):
        return 401
    if isinstance(exc, RateLimitError):
        return 429
    if isinstance(exc, ModelNotFoundError):
        return 404
    if isinstance(exc, InvalidRequestError):
        return 400
    if isinstance(exc, ProviderAPIError):
        return 502
    text = str(exc).lower()
    if "api key" in text or "authentication" in text or "unauthorized" in text:
        return 401
    return 500


@app.post("/v1/embeddings", tags=["embeddings"])
async def create_embeddings(request: EmbeddingRequest, http_request: Request):
    """
    Create embedding vectors representing the input text.

    Creates an embedding vector representing the input text. Embeddings are useful for:
    - Semantic search
    - Document similarity
    - Clustering and classification
    - Retrieval-Augmented Generation (RAG)

    **Supported Providers:**
    - **HuggingFace**: Local sentence-transformers models with ONNX acceleration
    - **Ollama**: Local embedding models via Ollama API
    - **LMStudio**: Local embedding models via LMStudio API

    **Model Format:** Use `provider/model` format:
    - `huggingface/sentence-transformers/all-MiniLM-L6-v2`
    - `ollama/granite-embedding:278m`
    - `lmstudio/text-embedding-all-minilm-l6-v2`

    **To see available embedding models:** `GET /v1/models?output_type=embeddings`

    **Returns:** A list of embedding objects containing the embedding vector and metadata.
    """
    try:
        # Parse provider and model
        provider, model = parse_model_string(request.model)

        logger.info(
            "🔢 Embedding Request",
            provider=provider,
            model=model,
            input_type=type(request.input).__name__,
            input_count=len(request.input) if isinstance(request.input, list) else 1
        )

        # Validate provider
        provider_lower = provider.lower()
        if provider_lower not in _SUPPORTED_EMBEDDING_PROVIDERS:
            raise HTTPException(
                status_code=400,
                detail={
                    "error": {
                        "message": (
                            f"Embedding provider '{provider}' not supported. Supported providers: "
                            f"{', '.join(sorted(_SUPPORTED_EMBEDDING_PROVIDERS))}. "
                            "Anthropic does not expose a native embeddings API; use another embeddings provider."
                        ),
                        "type": "unsupported_provider"
                    }
                }
            )

        _reject_body_api_key(request.api_key)
        provider_kwargs = _embedding_provider_kwargs(provider=provider_lower, request=request, http_request=http_request)
        inputs = _normalize_embedding_inputs(request.input)
        dimensions = _embedding_dimensions(request.dimensions)

        encoding_format = str(request.encoding_format or "float").strip().lower()
        if encoding_format not in {"float", "base64"}:
            raise HTTPException(
                status_code=400,
                detail={
                    "error": {
                        "message": "encoding_format must be either 'float' or 'base64'.",
                        "type": "invalid_request",
                    }
                },
            )

        if provider_lower in _DIRECT_EMBEDDING_PROVIDERS:
            request_kwargs: Dict[str, Any] = {"encoding_format": encoding_format}
            if dimensions is not None:
                request_kwargs["dimensions"] = dimensions
            if request.user:
                request_kwargs["user"] = request.user

            direct_provider = _create_direct_embedding_provider(provider_lower, model, provider_kwargs)
            result = direct_provider.embed(
                input_text=request.input if isinstance(request.input, str) else inputs,
                **request_kwargs,
            )
            if not isinstance(result, dict):
                raise ValueError(f"Invalid response from {provider_lower} embedding provider")
            result["model"] = f"{provider_lower}/{model}"

            logger.info(
                "✅ Remote embeddings generated",
                provider=provider_lower,
                count=len(result.get("data", [])) if isinstance(result.get("data"), list) else None,
                model=result.get("model"),
            )
            return result

        if encoding_format != "float":
            raise HTTPException(
                status_code=400,
                detail={
                    "error": {
                        "message": f"encoding_format='base64' is only supported for OpenAI-compatible embedding providers, not {provider_lower}.",
                        "type": "unsupported_parameter",
                    }
                },
            )

        # Route local/native providers through EmbeddingManager. strict=True is
        # important for the HTTP server: provider/model failures must surface as
        # errors, not silent zero-vector fallbacks.
        from ..embeddings.manager import EmbeddingManager

        embedder = EmbeddingManager(
            model=model,
            provider=provider_lower,
            output_dims=dimensions,
            strict=True,
            provider_kwargs=provider_kwargs,
        )

        # Generate embeddings
        embeddings = embedder.embed_batch(inputs)

        # Convert to OpenAI format
        embedding_objects = []
        for i, embedding in enumerate(embeddings):
            embedding_objects.append({
                "object": "embedding",
                "embedding": embedding,
                "index": i
            })

        # Calculate usage using centralized token utilities
        # Calculate total tokens using centralized utility
        from ..utils.token_utils import TokenUtils
        model_name = getattr(embedder, 'model_name', None)
        total_tokens = sum(TokenUtils.estimate_tokens(text, model_name) for text in inputs)

        response = {
            "object": "list",
            "data": embedding_objects,
            "model": f"{provider}/{model}",
            "usage": {
                "prompt_tokens": total_tokens,
                "total_tokens": total_tokens
            }
        }

        logger.info(
            "✅ Embeddings generated",
            provider=provider_lower,
            count=len(embedding_objects),
            dimensions=len(embeddings[0]) if embeddings else 0,
            total_tokens=total_tokens
        )

        return response

    except HTTPException:
        # Re-raise HTTP exceptions as-is
        raise
    except Exception as e:
        logger.error(f"❌ Embedding generation failed: {_redact_text(str(e))}", exc_info=True)
        raise HTTPException(
            status_code=_provider_exception_status(e),
            detail={"error": {"message": _redact_text(str(e)), "type": "embedding_error"}}
        )

# ============================================================================
# Media Processing Utilities
# ============================================================================

def handle_base64_image(data_url: str) -> str:
    """
    Process base64 data URL and save to temporary file.

    Args:
        data_url: Base64 data URL (e.g., "data:image/jpeg;base64,..." or "data:application/pdf;base64,...")

    Returns:
        Path to temporary file
    """
    try:
        # Parse data URL
        if not data_url.startswith("data:"):
            raise ValueError("Invalid data URL format")

        # Extract media type and base64 data
        header, data = data_url.split(",", 1)
        media_type = header.split(";")[0].split(":")[1]

        # Determine file extension for all supported media types
        ext_map = {
            # Images
            "image/jpeg": ".jpg",
            "image/jpg": ".jpg",
            "image/png": ".png",
            "image/gif": ".gif",
            "image/webp": ".webp",
            "image/bmp": ".bmp",
            "image/tiff": ".tiff",
            # Documents
            "application/pdf": ".pdf",
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
            "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
            # Data files
            "text/csv": ".csv",
            "text/tab-separated-values": ".tsv",
            "application/json": ".json",
            "application/xml": ".xml",
            "text/xml": ".xml",
            "text/plain": ".txt",
            "text/markdown": ".md",
            # Generic fallback
            "application/octet-stream": ".bin"
        }
        extension = ext_map.get(media_type, ".bin")

        # Decode base64 data
        file_data = base64.b64decode(data)

        # Save to temporary file with request-specific prefix for better isolation
        import hashlib
        data_hash = hashlib.md5(data[:100].encode() if len(data) > 100 else data.encode()).hexdigest()[:8]
        request_id = uuid.uuid4().hex[:8]
        prefix = f"abstractcore_b64_{data_hash}_{request_id}_"

        with tempfile.NamedTemporaryFile(delete=False, suffix=extension, prefix=prefix) as temp_file:
            temp_file.write(file_data)
            temp_file_path = temp_file.name

        # Log the temporary file creation for debugging
        logger.debug(f"Processed base64 media to temporary file: {temp_file_path} (size: {len(file_data)} bytes)")
        return temp_file_path

    except Exception as e:
        logger.error(f"Failed to process base64 media: {_redact_text(str(e))}")
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": f"Invalid base64 media data: {_redact_text(str(e))}", "type": "media_error"}}
        )

def download_file_temporarily(url: str) -> str:
    """
    Download file from URL to temporary file (supports images, documents, data files).

    Args:
        url: HTTP(S) URL to file

    Returns:
        Path to temporary file
    """
    # Security: prevent SSRF by default. Operators can allow specific hosts/prefixes
    # via ABSTRACTCORE_SERVER_URL_FETCH_ALLOWLIST.
    _require_public_url(url, allowlist_env="ABSTRACTCORE_SERVER_URL_FETCH_ALLOWLIST")

    max_bytes = 10 * 1024 * 1024  # 10MB per file
    max_redirects = 5

    # Determine extension from content-type or URL
    ext_map = {
        # Images
        "image/jpeg": ".jpg",
        "image/jpg": ".jpg",
        "image/png": ".png",
        "image/gif": ".gif",
        "image/webp": ".webp",
        "image/bmp": ".bmp",
        "image/tiff": ".tiff",
        # Documents
        "application/pdf": ".pdf",
        "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
        "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
        # Data files
        "text/csv": ".csv",
        "text/tab-separated-values": ".tsv",
        "application/json": ".json",
        "application/xml": ".xml",
        "text/xml": ".xml",
        "text/plain": ".txt",
        "text/markdown": ".md",
        # Generic fallback
        "application/octet-stream": ".bin",
    }

    def _ext_from_url(u: str) -> str:
        try:
            p = urllib.parse.urlsplit(u)
            path = str(p.path or "").lower()
        except Exception:
            path = ""
        if path.endswith(".pdf"):
            return ".pdf"
        if path.endswith(".jpg") or path.endswith(".jpeg"):
            return ".jpg"
        if path.endswith(".png"):
            return ".png"
        if path.endswith(".docx"):
            return ".docx"
        if path.endswith(".xlsx"):
            return ".xlsx"
        if path.endswith(".csv"):
            return ".csv"
        if path.endswith(".md"):
            return ".md"
        if path.endswith(".txt"):
            return ".txt"
        if path.endswith(".json"):
            return ".json"
        if path.endswith(".xml"):
            return ".xml"
        return ".bin"

    # Browser-ish headers to reduce 403s on commodity hosting.
    headers = {
        "User-Agent": "Mozilla/5.0 (AbstractCore Server)",
        "Accept": "*/*",
    }

    current_url = str(url).strip()
    try:
        with httpx.Client(timeout=30.0, follow_redirects=False) as client:
            for _ in range(max_redirects + 1):
                # Re-validate each hop (redirects are common SSRF bypasses).
                _require_public_url(current_url, allowlist_env="ABSTRACTCORE_SERVER_URL_FETCH_ALLOWLIST")

                with client.stream("GET", current_url, headers=headers) as resp:
                    status = int(getattr(resp, "status_code", 0) or 0)
                    if status in {301, 302, 303, 307, 308}:
                        loc = resp.headers.get("location")
                        if not loc:
                            raise ValueError(f"Redirect without Location header (status={status})")
                        current_url = urllib.parse.urljoin(current_url, str(loc))
                        continue

                    if status >= 400:
                        body = ""
                        try:
                            body = (resp.text or "").strip()
                        except Exception:
                            body = ""
                        raise ValueError(f"HTTP {status}: {body[:200] or '(empty)'}")

                    content_len = resp.headers.get("content-length")
                    if content_len:
                        try:
                            n = int(str(content_len).strip())
                        except Exception:
                            n = -1
                        if n > max_bytes:
                            raise ValueError("File too large (max 10MB)")

                    content_type = str(resp.headers.get("content-type", "") or "").split(";", 1)[0].strip().lower()
                    extension = ext_map.get(content_type) or _ext_from_url(current_url)

                    import hashlib

                    url_hash = hashlib.md5(current_url.encode("utf-8")).hexdigest()[:8]
                    request_id = uuid.uuid4().hex[:8]
                    prefix = f"abstractcore_file_{url_hash}_{request_id}_"

                    total = 0
                    with tempfile.NamedTemporaryFile(delete=False, suffix=extension, prefix=prefix) as temp_file:
                        for chunk in resp.iter_bytes():
                            if not chunk:
                                continue
                            total += len(chunk)
                            if total > max_bytes:
                                raise ValueError("File too large (max 10MB)")
                            temp_file.write(chunk)
                        temp_file_path = temp_file.name

                    logger.info(
                        f"Downloaded file to temporary file: {temp_file_path} "
                        f"(size: {total} bytes, type: {content_type or 'unknown'})"
                    )
                    return temp_file_path

            raise ValueError("Too many redirects")
    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Failed to download file from URL {_redact_url(url)}: {_redact_text(str(e))}")
        raise HTTPException(
            status_code=400,
            detail={"error": {"message": f"Failed to download file: {_redact_text(str(e))}", "type": "media_error"}},
        ) from e

def download_image_temporarily(url: str) -> str:
    """
    Download image from URL to temporary file (backward compatibility wrapper).

    Args:
        url: HTTP(S) URL to image

    Returns:
        Path to temporary file
    """
    return download_file_temporarily(url)

def process_image_url_object(image_url_obj: Dict[str, Any]) -> Optional[str]:
    """
    Process OpenAI image_url object and return local file path.

    Args:
        image_url_obj: Image URL object with 'url' field

    Returns:
        Local file path or None if processing failed
    """
    url = str(image_url_obj.get("url", "") or "").strip()
    if not url:
        return None

    if url.startswith("data:"):
        return handle_base64_image(url)

    if url.startswith(("http://", "https://")):
        # SSRF protection is enforced in download_file_temporarily().
        return download_image_temporarily(url)

    # Local file paths are an explicit operator choice for HTTP requests.
    return _resolve_local_media_path(url)

def process_file_url_object(file_url_obj: Dict[str, Any]) -> Optional[str]:
    """
    Process OpenAI file_url object and return local file path.

    Simplified format (consistent with image_url):
    {"url": "https://example.com/file.pdf"} or
    {"url": "/local/path/file.pdf"} or
    {"url": "data:application/pdf;base64,..."}

    Args:
        file_url_obj: File URL object with 'url' field (same as image_url)

    Returns:
        Local file path or None if processing failed
    """
    try:
        # Reuse existing image URL processing logic - works perfectly for any file type
        return process_image_url_object(file_url_obj)

    except HTTPException:
        raise
    except Exception as e:
        logger.error(f"Failed to process file URL object: {_redact_text(str(e))}")
        return None

def process_message_content(message: ChatMessage) -> Tuple[str, List[str]]:
    """
    Extract media files from message content and return clean text + media list.

    Supports both OpenAI formats:
    - content as string: "Analyze this @image.jpg"
    - content as array: [{"type": "text", "text": "..."}, {"type": "image_url", "image_url": {...}}, {"type": "file", "file_url": {...}}]

    Args:
        message: ChatMessage with content to process

    Returns:
        Tuple of (clean_text, media_file_paths)
    """
    if message.content is None:
        return "", []

    if isinstance(message.content, str):
        # Legacy format: optional @filename extraction (dangerous in HTTP server contexts).
        #
        # By default, treat "@file" as plain text. Operators can opt-in by setting:
        # - ABSTRACTCORE_SERVER_MEDIA_ROOT (safe-root), and/or
        # - ABSTRACTCORE_SERVER_ALLOW_LOCAL_FILES=1 (unsafe).
        if _server_media_root() is None and not _server_allows_local_files():
            return str(message.content or ""), []

        clean_text, candidates = MessagePreprocessor.parse_file_attachments(
            str(message.content or ""),
            validate_existence=False,
            verbose=False,
        )
        resolved_files: List[str] = []
        for f in candidates:
            resolved_files.append(_resolve_local_media_path(f))
        return clean_text, resolved_files

    elif isinstance(message.content, list):
        # OpenAI array format: extract image_url objects
        text_parts = []
        media_files = []

        for item in message.content:
            if isinstance(item, dict):
                item_type = item.get("type")
                if item_type == "text" and item.get("text"):
                    text_parts.append(item["text"])
                elif item_type == "image_url" and item.get("image_url"):
                    media_file = process_image_url_object(item["image_url"])
                    if media_file:
                        media_files.append(media_file)
                elif item_type == "file" and item.get("file_url"):
                    media_file = process_file_url_object(item["file_url"])
                    if media_file:
                        media_files.append(media_file)
            elif hasattr(item, 'type'):
                # Pydantic ContentItem object
                if item.type == "text" and item.text:
                    text_parts.append(item.text)
                elif item.type == "image_url" and item.image_url:
                    media_file = process_image_url_object(item.image_url)
                    if media_file:
                        media_files.append(media_file)
                elif item.type == "file" and item.file_url:
                    media_file = process_file_url_object(item.file_url)
                    if media_file:
                        media_files.append(media_file)

        return " ".join(text_parts), media_files

    return str(message.content), []

def adapt_prompt_for_media_types(text: str, media_files: List[str]) -> str:
    """
    Intelligently adapt prompts based on attached media file types.

    Fixes common mismatches like "What is in this image?" when sending documents.

    Args:
        text: Original text content
        media_files: List of media file paths

    Returns:
        Adapted text content
    """
    if not media_files or not text:
        return text

    # Analyze media file types
    image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'}
    document_extensions = {'.pdf', '.docx', '.xlsx', '.pptx'}
    data_extensions = {'.csv', '.tsv', '.json', '.xml'}
    text_extensions = {'.txt', '.md'}

    has_images = False
    has_documents = False
    has_data = False
    has_text = False

    for file_path in media_files:
        ext = os.path.splitext(file_path)[1].lower()
        if ext in image_extensions:
            has_images = True
        elif ext in document_extensions:
            has_documents = True
        elif ext in data_extensions:
            has_data = True
        elif ext in text_extensions:
            has_text = True

    # Common prompt adaptations
    adapted_text = text

    # Fix "What is in this image?" when not dealing with images
    if "what is in this image" in text.lower():
        if has_documents and not has_images:
            adapted_text = text.replace("What is in this image?", "What is in this document?")
            adapted_text = adapted_text.replace("what is in this image?", "what is in this document?")
            adapted_text = adapted_text.replace("What is in this image", "What is in this document")
            adapted_text = adapted_text.replace("what is in this image", "what is in this document")
        elif has_data and not has_images:
            adapted_text = text.replace("What is in this image?", "What data is in this file?")
            adapted_text = adapted_text.replace("what is in this image?", "what data is in this file?")
            adapted_text = adapted_text.replace("What is in this image", "What data is in this file")
            adapted_text = adapted_text.replace("what is in this image", "what data is in this file")
        elif has_text and not has_images:
            adapted_text = text.replace("What is in this image?", "What is in this text file?")
            adapted_text = adapted_text.replace("what is in this image?", "what is in this text file?")
            adapted_text = adapted_text.replace("What is in this image", "What is in this text file")
            adapted_text = adapted_text.replace("what is in this image", "what is in this text file")

    # Fix "What is in this document?" when dealing with images
    elif "what is in this document" in text.lower() and has_images and not (has_documents or has_data or has_text):
        adapted_text = text.replace("What is in this document?", "What is in this image?")
        adapted_text = adapted_text.replace("what is in this document?", "what is in this image?")
        adapted_text = adapted_text.replace("What is in this document", "What is in this image")
        adapted_text = adapted_text.replace("what is in this document", "what is in this image")

    # Handle mixed content with specific naming
    if adapted_text != text:
        # Count media types for better description
        total_files = len(media_files)
        if total_files > 1:
            types = []
            if has_images:
                types.append("image(s)")
            if has_documents:
                types.append("document(s)")
            if has_data:
                types.append("data file(s)")
            if has_text:
                types.append("text file(s)")

            if len(types) > 1:
                adapted_text = adapted_text.replace("this document", f"these {' and '.join(types)}")
                adapted_text = adapted_text.replace("this image", f"these {' and '.join(types)}")
                adapted_text = adapted_text.replace("this file", f"these {' and '.join(types)}")

    if adapted_text != text:
        logger.info(f"Adapted prompt for media types: '{text}' → '{adapted_text}'")

    return adapted_text

def validate_media_files(files: List[str]) -> None:
    """
    Validate media files for security and size limits.

    Args:
        files: List of file paths to validate

    Raises:
        HTTPException: If validation fails
    """
    ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff',
                         '.pdf', '.docx', '.xlsx', '.pptx', '.csv', '.tsv', '.txt', '.md',
                         '.json', '.xml'}

    total_size = 0
    max_total_size = 32 * 1024 * 1024  # 32MB total limit

    for file_path in files:
        if not os.path.exists(file_path):
            raise HTTPException(
                status_code=400,
                detail={"error": {"message": f"File not found: {file_path}", "type": "file_not_found"}}
            )

        # Check extension
        ext = os.path.splitext(file_path)[1].lower()
        if ext not in ALLOWED_EXTENSIONS:
            raise HTTPException(
                status_code=400,
                detail={"error": {"message": f"File type {ext} not allowed", "type": "invalid_file_type"}}
            )

        # Check individual file size (10MB per file)
        file_size = os.path.getsize(file_path)
        if file_size > 10 * 1024 * 1024:
            raise HTTPException(
                status_code=400,
                detail={"error": {"message": f"File too large: {file_path} (max 10MB per file)", "type": "file_too_large"}}
            )

        total_size += file_size

        # Check total size across all files
        if total_size > max_total_size:
            raise HTTPException(
                status_code=400,
                detail={"error": {"message": "Total file size exceeds 32MB limit", "type": "total_size_exceeded"}}
            )

@app.post("/v1/chat/completions", tags=["chat"])
async def chat_completions(request: ChatCompletionRequest, http_request: Request):
    """
    Create a model response for the given chat conversation with optional media attachments.

    Given a list of messages comprising a conversation, the model will return a response.
    This endpoint supports streaming, tool calling, media attachments, and multiple providers.

    **Key Features:**
    - Multi-provider support (OpenAI, Anthropic, Ollama, LMStudio, etc.)
    - Streaming responses with server-sent events
    - Tool/function calling with automatic syntax conversion
    - Media attachments (images, documents, data files)
    - OpenAI Vision API compatible format

    **Provider Format:** Use `provider/model` format in the model field:
    - `openai/gpt-4` - OpenAI GPT-4
    - `ollama/llama3:latest` - Ollama LLaMA 3
    - `anthropic/claude-3-opus-20240229` - Anthropic Claude 3 Opus

    **Media Attachments:** Support for OpenAI Vision API compatible format:
    - String content: "Analyze this @image.jpg" (AbstractCore @filename syntax)
    - Array content: [{"type": "text", "text": "..."}, {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}]
    - Supported formats: Images (PNG, JPEG, GIF, WEBP), Documents (PDF, DOCX, XLSX, PPTX), Data (CSV, TSV, TXT, MD)
    - Size limits: 10MB per file, 32MB total per request

    **To see available text models:** `GET /v1/models?input_type=text&output_type=text`

    **Returns:** A chat completion object, or a stream of chat completion chunks if streaming is enabled.
    """
    provider, model = parse_model_string(request.model)
    return await process_chat_completion(provider, model, request, http_request)

@app.post("/{provider}/v1/chat/completions", tags=["chat"])
async def provider_chat_completions(
    provider: Annotated[
        str,
        FastAPIPath(
            description="Provider route prefix, e.g. `openai`, `anthropic`, `ollama`, `openrouter`, or `openai-compatible`."
        ),
    ],
    request: ChatCompletionRequest,
    http_request: Request
):
    """
    Provider-specific chat completions endpoint.

    Same functionality as `/v1/chat/completions` but allows specifying the provider in the URL path.
    Useful when you want explicit provider routing or when the model name doesn't include the provider prefix.

    **Examples:**
    - `POST /ollama/v1/chat/completions` with `"model": "llama3:latest"`
    - `POST /openai/v1/chat/completions` with `"model": "gpt-4"`

    **Note:** Provider in the URL takes precedence over provider in the model name.
    """
    _, model = parse_model_string(request.model)
    return await process_chat_completion(provider, model, request, http_request)


def _extract_trace_metadata(http_request: Request) -> Dict[str, Any]:
    """Extract trace metadata from request headers (schema-safe)."""
    meta: Dict[str, Any] = {}

    raw = (
        http_request.headers.get("x-abstractcore-trace-metadata")
        or http_request.headers.get("x-abstract-trace-metadata")
    )
    if raw:
        try:
            parsed = json.loads(raw)
            if isinstance(parsed, dict):
                meta.update(parsed)
        except Exception:
            # Ignore invalid metadata payloads; tracing is best-effort.
            pass

    header_map = {
        "actor_id": "x-abstractcore-actor-id",
        "session_id": "x-abstractcore-session-id",
        "run_id": "x-abstractcore-run-id",
        "parent_run_id": "x-abstractcore-parent-run-id",
    }
    for key, header in header_map.items():
        val = http_request.headers.get(header)
        if val is not None and key not in meta:
            meta[key] = val

    # Never log or return these directly; they are for internal correlation only.
    return meta


async def process_chat_completion(
    provider: str,
    model: str,
    request: ChatCompletionRequest,
    http_request: Request
):
    """
    Core chat completion processing with syntax rewriting support.
    """
    request_id = uuid.uuid4().hex[:8]

    try:
        logger.info(
            "📥 Chat Completion Request",
            request_id=request_id,
            provider=provider,
            model=model,
            messages=len(request.messages),
            has_tools=bool(request.tools),
            stream=request.stream
        )

        _reject_body_api_key(request.api_key)
        provider_api_key = _provider_api_key_from_request(http_request)

        # Detect target format for tool call syntax
        target_format = detect_target_format(f"{provider}/{model}", request, http_request)
        user_agent_raw = http_request.headers.get("user-agent", "")
        user_agent = str(user_agent_raw or "")
        if len(user_agent) > 50:
            #[WARNING:TRUNCATION] bounded user-agent capture for request logs
            user_agent = user_agent[:50].rstrip() + "…"
        logger.info(
            "🎯 Target Format Detected",
            request_id=request_id,
            target_format=target_format.value,
            user_agent=user_agent,
        )

        # Process media from messages
        all_media_files = []
        processed_messages = []

        for message in request.messages:
            clean_text, media_files = process_message_content(message)
            all_media_files.extend(media_files)

            # Adapt prompt based on media file types to avoid confusion
            if media_files:
                adapted_text = adapt_prompt_for_media_types(clean_text, media_files)
            else:
                adapted_text = clean_text

            # Create processed message with adapted text
            processed_message = message.model_copy()
            processed_message.content = adapted_text
            processed_messages.append(processed_message)

        # Validate media files if any were found
        if all_media_files:
            validate_media_files(all_media_files)
            #[WARNING:TRUNCATION] bounded filename preview for request logs
            files_preview = [os.path.basename(f) for f in all_media_files[:5]]
            logger.info(
                "📎 Media Files Processed",
                request_id=request_id,
                file_count=len(all_media_files),
                files=files_preview,
                files_truncated=len(all_media_files) > 5,
            )

        # Create LLM instance
        # Prepare provider-specific kwargs
        provider_kwargs = {}
        trace_metadata = _extract_trace_metadata(http_request)
        if trace_metadata:
            # Enable trace capture (trace_id) without retaining full trace buffers by default.
            provider_kwargs["enable_tracing"] = True
            provider_kwargs.setdefault("max_traces", 0)
        if provider_api_key:
            provider_kwargs["api_key"] = provider_api_key
            logger.debug("🔑 Explicit provider API key supplied", request_id=request_id, provider=provider)
        _guard_unauthenticated_server_provider_key_use(
            provider,
            explicit_provider_key=bool(provider_api_key),
            http_request=http_request,
        )
        if isinstance(request.base_url, str) and request.base_url.strip():
            base_url = request.base_url.strip()
            if not _server_allows_request_base_url(base_url):
                raise HTTPException(
                    status_code=403,
                    detail={
                        "error": {
                            "message": (
                                "Request-level base_url overrides are restricted for security. "
                                "By default only loopback URLs are allowed. "
                                "To allow additional hosts/prefixes, set "
                                "ABSTRACTCORE_SERVER_BASE_URL_ALLOWLIST."
                            ),
                            "type": "forbidden",
                        }
                    },
                )

            # Prevent accidental provider key exfiltration when the server has an env API key.
            # If a client sets a non-loopback base_url and does not provide an explicit provider key,
            # providers may fall back to server env keys and send them to the supplied endpoint.
            parsed = urllib.parse.urlsplit(base_url)
            host = str(parsed.hostname or "").strip()
            if host and not _is_loopback_host(host):
                env_var = _provider_api_key_env_var(provider)
                explicit_key = bool(provider_api_key)
                if _server_has_provider_api_key(provider) and not explicit_key:
                    raise HTTPException(
                        status_code=403,
                        detail={
                            "error": {
                                "message": (
                                    "Refusing request-level base_url override without an explicit provider key because "
                                    f"the server has {env_var} set. Provide the provider key via "
                                    "X-AbstractCore-Provider-API-Key, or via Authorization only when server auth is "
                                    "not configured. "
                                    f"(provider={provider})"
                                ),
                                "type": "forbidden",
                            }
                        },
                    )

            provider_kwargs["base_url"] = base_url
            logger.info("🔗 Custom Base URL", request_id=request_id, base_url=_redact_url(base_url))
        if request.timeout_s is not None:
            # Orchestrator policy: allow the caller to specify the provider timeout.
            # Note: BaseProvider treats non-positive values as "unlimited".
            provider_kwargs["timeout"] = request.timeout_s

        provider_normalized = provider.strip().lower()
        unload_after_requested = bool(getattr(request, "unload_after", False))
        allow_unsafe_unload_after = _parse_bool_env("ABSTRACTCORE_ALLOW_UNSAFE_UNLOAD_AFTER")
        if unload_after_requested and provider_normalized == "ollama" and not allow_unsafe_unload_after:
            raise HTTPException(
                status_code=403,
                detail={
                    "error": {
                        "message": (
                            "unload_after=true is disabled for provider 'ollama' because it can unload shared server "
                            "state and disrupt other clients. Set ABSTRACTCORE_ALLOW_UNSAFE_UNLOAD_AFTER=1 to enable."
                        ),
                        "type": "forbidden",
                    }
                },
            )

        llm = create_llm(provider, model=model, **provider_kwargs)
        ollama_key: Optional[Tuple[str, str, str]] = None
        if provider_normalized == "ollama":
            base_url_for_key = provider_kwargs.get("base_url")
            if not isinstance(base_url_for_key, str) or not base_url_for_key.strip():
                base_url_for_key = request.base_url
            ollama_key = _ollama_inflight_key(provider, base_url_for_key, model)
            _ollama_inflight_enter(ollama_key)

        # Convert messages
        messages = convert_to_abstractcore_messages(processed_messages)

        # Create syntax rewriter
        syntax_rewriter = create_syntax_rewriter(target_format, f"{provider}/{model}")

        # Prepare generation parameters
        gen_kwargs = {
            "prompt": "",  # Empty when using messages
            "messages": messages,
            "media": all_media_files if all_media_files else None,  # Add media files
            "temperature": request.temperature,
            "max_tokens": request.max_tokens,
            "stream": request.stream,
            "tools": request.tools,
            "tool_choice": request.tool_choice if request.tools else None,
            "execute_tools": False,  # Server mode - don't execute tools
        }
        if trace_metadata:
            gen_kwargs["trace_metadata"] = trace_metadata

        # Add optional parameters
        if request.thinking is not None:
            gen_kwargs["thinking"] = request.thinking
        if request.stop:
            gen_kwargs["stop"] = request.stop
        if request.seed:
            gen_kwargs["seed"] = request.seed
        if request.frequency_penalty:
            gen_kwargs["frequency_penalty"] = request.frequency_penalty
        if request.presence_penalty:
            gen_kwargs["presence_penalty"] = request.presence_penalty
        if isinstance(request.prompt_cache_key, str) and request.prompt_cache_key.strip():
            gen_kwargs["prompt_cache_key"] = request.prompt_cache_key.strip()
        if isinstance(request.prompt_cache_retention, str) and request.prompt_cache_retention.strip():
            gen_kwargs["prompt_cache_retention"] = request.prompt_cache_retention.strip()

        # Generate response
        # Only cleanup files created by this request (with our specific prefixes)
        temp_files_to_cleanup = [
            f for f in all_media_files
            if f.startswith("/tmp/") and (
                "abstractcore_img_" in f or
                "abstractcore_file_" in f or
                "abstractcore_b64_" in f or
                "temp" in f
            )
        ]

        try:
            if request.stream:
                return StreamingResponse(
                    generate_streaming_response(
                        llm,
                        gen_kwargs,
                        provider,
                        model,
                        syntax_rewriter,
                        request_id,
                        temp_files_to_cleanup,
                        unload_after=unload_after_requested,
                        ollama_key=ollama_key,
                        allow_unsafe_unload_after=allow_unsafe_unload_after,
                    ),
                    media_type="text/event-stream",
                    headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
                )
            else:
                response = llm.generate(**gen_kwargs)
                openai_response = convert_to_openai_response(
                    response, provider, model, syntax_rewriter, request_id
                )
                trace_id = None
                if hasattr(response, "metadata") and isinstance(getattr(response, "metadata"), dict):
                    trace_id = response.metadata.get("trace_id")
                if trace_id:
                    return JSONResponse(
                        content=openai_response,
                        headers={"X-AbstractCore-Trace-Id": str(trace_id)},
                    )
                return openai_response
        finally:
            if not request.stream:
                if provider_normalized == "ollama" and ollama_key is not None:
                    should_unload = _ollama_inflight_exit(ollama_key, unload_after_requested=unload_after_requested)
                    if should_unload and allow_unsafe_unload_after:
                        _best_effort_unload(llm, request_id=request_id, provider=provider, model=model)
                    elif should_unload:
                        logger.warning(
                            "⚠️ Unload requested but disabled by server policy",
                            request_id=request_id,
                            provider=provider,
                            model=model,
                        )
                elif unload_after_requested:
                    _best_effort_unload(llm, request_id=request_id, provider=provider, model=model)

            # Cleanup temporary files (base64 and downloaded images) with delay to avoid race conditions
            def delayed_cleanup():
                """Cleanup temporary files after a short delay to avoid race conditions"""
                time.sleep(1)  # Short delay to ensure generation is complete
                for temp_file in temp_files_to_cleanup:
                    try:
                        if os.path.exists(temp_file):
                            # Additional check: only delete files created by this session
                            if ("abstractcore_img_" in temp_file or "abstractcore_file_" in temp_file or "abstractcore_b64_" in temp_file):
                                os.unlink(temp_file)
                                logger.debug(f"Cleaned up temporary file: {temp_file}")
                            else:
                                logger.debug(f"Skipped cleanup of non-AbstractCore file: {temp_file}")
                    except Exception as e:
                        logger.warning(f"Failed to cleanup temporary file {temp_file}: {e}")

            # Run cleanup in background thread to avoid blocking response
            cleanup_thread = threading.Thread(target=delayed_cleanup, daemon=True)
            cleanup_thread.start()

    except HTTPException:
        raise
    except Exception as e:
        logger.error(
            "❌ Chat completion failed",
            request_id=request_id,
            error=str(e),
            error_type=type(e).__name__
        )
        raise HTTPException(
            status_code=500,
            detail={"error": {"message": str(e), "type": "server_error"}}
        )

def generate_streaming_response(
    llm,
    gen_kwargs: Dict[str, Any],
    provider: str,
    model: str,
    syntax_rewriter: ToolCallSyntaxRewriter,
    request_id: str,
    temp_files_to_cleanup: List[str] = None,
    *,
    unload_after: bool = False,
    ollama_key: Optional[Tuple[str, str, str]] = None,
    allow_unsafe_unload_after: bool = False,
) -> Iterator[str]:
    """Generate OpenAI-compatible streaming response with syntax rewriting."""
    provider_normalized = provider.strip().lower()
    try:
        chat_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
        created_time = int(time.time())
        has_tool_calls = False

        for chunk in llm.generate(**gen_kwargs):
            # Content streaming
            if hasattr(chunk, 'content') and chunk.content:
                content = chunk.content

                # For OpenAI/Codex format: only clean if content contains tool calls
                # For other formats: apply syntax rewriting
                if syntax_rewriter.target_format in [SyntaxFormat.OPENAI, SyntaxFormat.CODEX]:
                    # Only clean content if it contains tool call patterns
                    # This prevents stripping spaces from regular text chunks
                    if any(pattern in content for pattern in ['<function_call>', '<tool_call>', '<|tool_call|>', '```tool_code']):
                        content = syntax_rewriter.remove_tool_call_patterns(content)
                elif syntax_rewriter.target_format != SyntaxFormat.PASSTHROUGH:
                    # Apply format-specific rewriting for non-OpenAI formats
                    content = syntax_rewriter.rewrite_content(content)

                # Only send content if it's meaningful (not just whitespace)
                if content.strip():
                    openai_chunk = {
                        "id": chat_id,
                        "object": "chat.completion.chunk",
                        "created": created_time,
                        "model": f"{provider}/{model}",
                        "choices": [{
                            "index": 0,
                            "delta": {"content": content},
                            "finish_reason": None
                        }]
                    }
                    yield f"data: {json.dumps(openai_chunk)}\n\n"

            # Tool calls
            if hasattr(chunk, 'tool_calls') and chunk.tool_calls:
                has_tool_calls = True
                # OpenAI/Codex clients expect structured tool_calls deltas.
                if syntax_rewriter.target_format in [SyntaxFormat.OPENAI, SyntaxFormat.CODEX]:
                    openai_tool_calls = syntax_rewriter.convert_to_openai_format(chunk.tool_calls)

                    for i, openai_tool_call in enumerate(openai_tool_calls):
                        tool_chunk = {
                            "id": chat_id,
                            "object": "chat.completion.chunk",
                            "created": created_time,
                            "model": f"{provider}/{model}",
                            "choices": [{
                                "index": 0,
                                "delta": {
                                    "tool_calls": [{
                                        "index": i,  # Proper indexing for multiple tools
                                        "id": openai_tool_call["id"],
                                        "type": "function",
                                        "function": openai_tool_call["function"]
                                    }]
                                },
                                "finish_reason": "tool_calls"  # Critical for Codex
                            }]
                        }
                        yield f"data: {json.dumps(tool_chunk)}\n\n"
                # Tag-based clients parse tool calls from assistant content.
                elif syntax_rewriter.target_format != SyntaxFormat.PASSTHROUGH:
                    tool_text = syntax_rewriter.rewrite_content("", detected_tool_calls=chunk.tool_calls)
                    if tool_text and tool_text.strip():
                        openai_chunk = {
                            "id": chat_id,
                            "object": "chat.completion.chunk",
                            "created": created_time,
                            "model": f"{provider}/{model}",
                            "choices": [{
                                "index": 0,
                                "delta": {"content": tool_text},
                                "finish_reason": None
                            }]
                        }
                        yield f"data: {json.dumps(openai_chunk)}\n\n"

        # Final chunk
        final_chunk = {
            "id": chat_id,
            "object": "chat.completion.chunk",
            "created": created_time,
            "model": f"{provider}/{model}",
            "choices": [{
                "index": 0,
                "delta": {},
                "finish_reason": "tool_calls" if has_tool_calls else "stop"
            }]
        }
        yield f"data: {json.dumps(final_chunk)}\n\n"
        yield "data: [DONE]\n\n"

        logger.info(
            "✅ Streaming completed",
            request_id=request_id,
            has_tool_calls=has_tool_calls
        )

        # Cleanup temporary files for streaming with delay to avoid race conditions
        if temp_files_to_cleanup:
            import threading

            def delayed_streaming_cleanup():
                """Cleanup temporary files after streaming completes"""
                time.sleep(2)  # Longer delay for streaming to ensure all chunks are sent
                for temp_file in temp_files_to_cleanup:
                    try:
                        if os.path.exists(temp_file):
                            # Additional check: only delete files created by this session
                            if ("abstractcore_img_" in temp_file or "abstractcore_file_" in temp_file or "abstractcore_b64_" in temp_file):
                                os.unlink(temp_file)
                                logger.debug(f"Cleaned up temporary file during streaming: {temp_file}")
                            else:
                                logger.debug(f"Skipped cleanup of non-AbstractCore streaming file: {temp_file}")
                    except Exception as cleanup_error:
                        logger.warning(f"Failed to cleanup temporary file {temp_file}: {cleanup_error}")

            # Run cleanup in background thread
            cleanup_thread = threading.Thread(target=delayed_streaming_cleanup, daemon=True)
            cleanup_thread.start()

    except Exception as e:
        logger.error(
            "❌ Streaming failed",
            request_id=request_id,
            error=str(e)
        )
        error_chunk = {"error": {"message": str(e), "type": "server_error"}}
        yield f"data: {json.dumps(error_chunk)}\n\n"
    finally:
        if provider_normalized == "ollama" and ollama_key is not None:
            try:
                should_unload = _ollama_inflight_exit(ollama_key, unload_after_requested=unload_after)
            except Exception as e:
                logger.warning(
                    "⚠️ Failed to update in-flight unload state",
                    request_id=request_id,
                    provider=provider,
                    model=model,
                    error=str(e),
                    error_type=type(e).__name__,
                )
                should_unload = False

            if should_unload and allow_unsafe_unload_after:
                _best_effort_unload(llm, request_id=request_id, provider=provider, model=model)
            elif should_unload:
                logger.warning(
                    "⚠️ Unload requested but disabled by server policy",
                    request_id=request_id,
                    provider=provider,
                    model=model,
                )
        elif unload_after:
            _best_effort_unload(llm, request_id=request_id, provider=provider, model=model)

def convert_to_openai_response(
    response,
    provider: str,
    model: str,
    syntax_rewriter: ToolCallSyntaxRewriter,
    request_id: str
) -> Dict[str, Any]:
    """Convert AbstractCore response to OpenAI format with syntax rewriting."""

    if response is None:
        logger.warning("Received None response", request_id=request_id)
        return {
            "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
            "object": "chat.completion",
            "created": int(time.time()),
            "model": f"{provider}/{model}",
            "choices": [{
                "index": 0,
                "message": {"role": "assistant", "content": "Error: No response generated"},
                "finish_reason": "error"
            }],
            "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
        }

    # Apply syntax rewriting to content
    content = response.content if hasattr(response, 'content') else str(response)
    tool_calls = getattr(response, "tool_calls", None) if hasattr(response, "tool_calls") else None

    # For OpenAI/Codex format: only clean if content contains tool calls
    # For other formats: apply syntax rewriting
    if syntax_rewriter.target_format in [SyntaxFormat.OPENAI, SyntaxFormat.CODEX]:
        # Only clean content if it contains tool call patterns
        # This prevents stripping spaces from regular text
        if any(pattern in content for pattern in ['<function_call>', '<tool_call>', '<|tool_call|>', '```tool_code']):
            content = syntax_rewriter.remove_tool_call_patterns(content)
    elif syntax_rewriter.target_format != SyntaxFormat.PASSTHROUGH:
        # Apply format-specific rewriting for non-OpenAI formats.
        # Prefer structured tool_calls when present (content may be cleaned upstream).
        if tool_calls:
            content = syntax_rewriter.rewrite_content(content, detected_tool_calls=tool_calls)
        else:
            content = syntax_rewriter.rewrite_content(content)

    response_dict = {
        "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
        "object": "chat.completion",
        "created": int(time.time()),
        "model": f"{provider}/{model}",
        "choices": [{
            "index": 0,
            "message": {"role": "assistant", "content": content},
            "finish_reason": "stop"
        }],
        "usage": {
            "prompt_tokens": getattr(response, 'usage', {}).get('prompt_tokens', 0) if hasattr(response, 'usage') else 0,
            "completion_tokens": getattr(response, 'usage', {}).get('completion_tokens', 0) if hasattr(response, 'usage') else 0,
            "total_tokens": getattr(response, 'usage', {}).get('total_tokens', 0) if hasattr(response, 'usage') else 0
        }
    }

    # Add tool calls if present
    if tool_calls:
        openai_tool_calls = syntax_rewriter.convert_to_openai_format(tool_calls)
        response_dict["choices"][0]["message"]["tool_calls"] = openai_tool_calls
        response_dict["choices"][0]["finish_reason"] = "tool_calls"

        logger.info(
            "🔧 Tool calls converted",
            request_id=request_id,
            tool_count=len(tool_calls),
            target_format=syntax_rewriter.target_format.value
        )

    return response_dict

# ============================================================================
# Server Runner
# ============================================================================

def _resolve_external_host(bind_host: str) -> str:
    """Best-effort external host for display when binding to 0.0.0.0."""
    if bind_host in {"0.0.0.0", "::", "[::]"}:
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
                sock.connect(("8.8.8.8", 80))
                return sock.getsockname()[0]
        except Exception:
            return bind_host
    return bind_host

def run_server(host: str = "0.0.0.0", port: int = 8000):
    """Run the server"""
    import uvicorn
    uvicorn.run(app, host=host, port=port, log_level="error")

# ============================================================================
# Server Runner Function
# ============================================================================

def run_server_with_args():
    """Run the server with argument parsing for CLI usage."""
    parser = argparse.ArgumentParser(
        description="AbstractCore Server - Universal LLM Gateway with Media Processing",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python -m abstractcore.server.app                    # Start server with defaults
  python -m abstractcore.server.app --debug           # Start with debug logging
  python -m abstractcore.server.app --host 127.0.0.1 --port 8080  # Custom host/port
  python -m abstractcore.server.app --debug --port 8080           # Debug on custom port

Environment Variables:
  ABSTRACTCORE_SERVER_API_KEY=...  # Server master key for non-health endpoints
  ABSTRACTCORE_DEBUG=true    # Enable debug mode (equivalent to --debug)
  HOST=127.0.0.1            # Server host (overridden by --host)
  PORT=8080                  # Server port (overridden by --port)

Debug Mode:
  The --debug flag enables verbose logging and better error reporting, including:
  - Detailed HTTP request/response logging
  - Full error traces for 422 Unprocessable Entity errors
  - Media processing diagnostics
  - Provider initialization details
        """
    )

    parser.add_argument(
        '--debug',
        action='store_true',
        help='Enable debug logging and show detailed diagnostics (overrides centralized config)'
    )
    parser.add_argument(
        '--host',
        default=os.getenv("HOST", "0.0.0.0"),
        help='Host to bind the server to (default: 0.0.0.0)'
    )
    parser.add_argument(
        '--port',
        type=int,
        default=int(os.getenv("PORT", "8000")),
        help='Port to bind the server to (default: 8000)'
    )

    args = parser.parse_args()

    # Reconfigure logging if debug mode is requested (--debug overrides config defaults)
    if args.debug:
        reconfigure_for_debug()
        print("🐛 Debug mode enabled - detailed logging active")

    logger.info(
        "🚀 Starting AbstractCore Server",
        host=args.host,
        port=args.port,
        debug=debug_mode,
        version=__version__
    )

    # Print access URLs (outside logging)
    internal_url = f"http://127.0.0.1:{args.port}"
    external_host = _resolve_external_host(args.host)
    external_url = f"http://{external_host}:{args.port}"
    print(f"Internal URL: {internal_url}")
    print(f"External URL: {external_url}")

    # Enhanced uvicorn configuration for debug mode
    uvicorn_config = {
        "app": app,
        "host": args.host,
        "port": args.port,
        "log_level": "debug" if debug_mode else "error",
    }

    # In debug mode, enable more detailed uvicorn logging
    if debug_mode:
        uvicorn_config.update({
            "access_log": True,
            "use_colors": True,
        })

    import uvicorn
    uvicorn.run(**uvicorn_config)

# ============================================================================
# Startup
# ============================================================================

if __name__ == "__main__":
    run_server_with_args()
