# Provider Interface Reference

Complete developer documentation for the Kollabor CLI provider system.

## Table of Contents

1. [Overview](#overview)
2. [Provider Interface](#provider-interface)
3. [Provider Implementation Guide](#provider-implementation-guide)
4. [Architecture Overview](#architecture-overview)
5. [Transformation Layer](#transformation-layer)
6. [Tool Schema Conversion](#tool-schema-conversion)
7. [Error Handling](#error-handling)
8. [Security Features](#security-features)
9. [Hook Compatibility](#hook-compatibility)
10. [API Reference](#api-reference)

---

## Overview

The Kollabor CLI provider system provides a unified interface for interacting with multiple LLM providers (OpenAI, Anthropic, Azure OpenAI). It uses an abstract base class pattern with response transformers to normalize provider-specific APIs into a consistent format.

### Key Features

- **Unified Interface**: Single API for all providers
- **Type Safety**: Pydantic models for configuration and responses
- **Streaming Support**: Async streaming with incremental updates
- **Tool Calling**: Unified tool/function calling interface
- **Error Handling**: Standardized error hierarchy with user-safe messages
- **Security**: 4-tier key storage with enterprise-grade encryption
- **Extensibility**: Easy to add new providers via registration pattern

### Design Patterns

- **Abstract Factory**: Provider registry with singleton instances
- **Adapter Pattern**: Transformers normalize provider responses
- **Strategy Pattern**: Different providers implement same interface differently
- **Template Method**: Base class defines lifecycle, subclasses override specifics

---

## Provider Interface

### LLMProvider Base Class

All providers inherit from `LLMProvider` in `core/llm/providers/base.py`.

```python
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any, AsyncIterator, List

class LLMProvider(ABC):
    """
    Abstract base class for LLM providers.

    Thread Safety:
        Providers use asyncio.Lock for atomic operations.
        Request tracking is protected by lock.

    Lifecycle:
        1. __init__ - Store config, initialize state
        2. initialize() - Connect to API, validate credentials
        3. call() / stream() - Make requests
        4. shutdown() - Cleanup resources, wait for active requests
    """

    def __init__(self, config: ProviderConfig):
        """Initialize provider with configuration."""

    @abstractmethod
    async def initialize(self) -> None:
        """Initialize API connections and validate credentials."""

    @abstractmethod
    async def call(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict[str, Any]]] = None,
        **kwargs: Any,
    ) -> UnifiedResponse:
        """Make non-streaming API call."""

    @abstractmethod
    async def stream(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict[str, Any]]] = None,
        **kwargs: Any,
    ) -> AsyncIterator[StreamingResponse]:
        """Make streaming API call."""

    @abstractmethod
    def validate_config(self, config: ProviderConfig) -> None:
        """Validate provider-specific configuration."""

    async def shutdown(self) -> None:
        """Shutdown provider and wait for active requests."""

    # Properties
    @property
    def is_initialized(self) -> bool: ...

    @property
    def active_requests(self) -> int: ...

    @property
    def provider_name(self) -> str: ...

    @property
    def supports_streaming(self) -> bool: ...

    @property
    def supports_tools(self) -> bool: ...
```

### Provider Configuration

All providers use Pydantic models for validated configuration.

#### Base Configuration

```python
class ProviderConfig(BaseModel):
    """Base provider configuration."""

    provider: ProviderType
    api_key: str = Field(..., min_length=1)
    base_url: Optional[str] = None
    model: str = Field(..., min_length=1)
    temperature: float = Field(default=0.7, ge=0.0, le=2.0)
    max_tokens: int = Field(default=4096, ge=1)
    top_p: Optional[float] = Field(default=None, ge=0.0, le=1.0)
    timeout: Optional[float] = Field(default=60.0, gt=0)
```

#### Provider-Specific Configuration

**OpenAI:**

```python
class OpenAIConfig(ProviderConfig):
    """OpenAI provider configuration."""

    provider: Literal[ProviderType.OPENAI] = ProviderType.OPENAI
    organization: Optional[str] = None
    api_key_format: Literal["sk-", "sk-proj-"] = "sk-"
```

**Anthropic:**

```python
class AnthropicConfig(ProviderConfig):
    """Anthropic provider configuration."""

    provider: Literal[ProviderType.ANTHROPIC] = ProviderType.ANTHROPIC
    api_version: str = Field(default="2023-06-01")
    max_retries: int = Field(default=2, ge=0, le=5)
```

**Azure OpenAI:**

```python
class AzureOpenAIConfig(ProviderConfig):
    """Azure OpenAI provider configuration."""

    provider: Literal[ProviderType.AZURE_OPENAI] = ProviderType.AZURE_OPENAI
    azure_endpoint: str = Field(..., min_length=1)
    api_version: str = Field(default="2024-02-15-preview")
    deployment_id: Optional[str] = None
```

### Response Models

#### Streaming Response

```python
class StreamingResponse(BaseModel):
    """Streaming response chunk from LLM API."""

    delta: StreamingDelta  # TextDelta, ToolCallDelta, or ThinkingDelta
    usage: Optional[UsageInfo] = None
    is_final: bool = Field(default=False)
    raw_chunk: Optional[Dict[str, Any]] = None
```

#### Unified Response

```python
class UnifiedResponse(BaseModel):
    """Unified response format from any provider."""

    content: List[ContentBlock]  # TextContent, ToolUseContent, ThinkingContent
    usage: UsageInfo
    model: str
    provider: ProviderType
    finish_reason: Optional[str] = None
    raw_response: Optional[Dict[str, Any]] = None
```

#### Content Blocks

```python
class TextContent(BaseModel):
    """Text content block."""
    type: Literal["text"] = "text"
    text: str

class ToolUseContent(BaseModel):
    """Tool use content block."""
    type: Literal["tool_use"] = "tool_use"
    id: str
    name: str
    input: Dict[str, Any]

class ThinkingContent(BaseModel):
    """Thinking content block (extended thinking)."""
    type: Literal["thinking"] = "thinking"
    thinking: str
```

---

## Provider Implementation Guide

This guide shows how to add a new provider to the Kollabor CLI.

### Step 1: Add Provider Type

Add your provider to the `ProviderType` enum in `core/llm/providers/models.py`:

```python
class ProviderType(str, Enum):
    """Supported LLM provider types."""
    OPENAI = "openai"
    ANTHROPIC = "anthropic"
    AZURE_OPENAI = "azure_openai"
    YOUR_PROVIDER = "your_provider"  # Add this
```

### Step 2: Create Configuration Model

Create a Pydantic model for your provider's configuration in `models.py`:

```python
class YourProviderConfig(ProviderConfig):
    """
    Your Provider configuration.

    Add provider-specific fields and validation.
    """

    provider: Literal[ProviderType.YOUR_PROVIDER] = ProviderType.YOUR_PROVIDER

    # Provider-specific fields
    region: Optional[str] = None
    max_retries: int = Field(default=3, ge=0, le=10)

    @field_validator("api_key")
    @classmethod
    def validate_your_provider_api_key(cls, v: str) -> str:
        """Validate Your Provider API key format."""
        if not v.startswith("ypk-"):
            raise ValueError(
                f"Your Provider API key must start with 'ypk-', got: {v[:10]}..."
            )
        return v

    @field_validator("base_url")
    @classmethod
    def validate_base_url(cls, v: Optional[str]) -> Optional[str]:
        """Validate base URL is HTTPS."""
        if v is None:
            return v

        if "localhost" in v or "127.0.0.1" in v:
            return v

        if not v.startswith("https://"):
            raise ValueError(
                f"Your Provider base URL must use HTTPS (got: {v}). "
                "Use https://api.yourprovider.com or your custom HTTPS endpoint."
            )

        return v
```

### Step 3: Create Error Mapping

Add error mapping function in `core/llm/providers/errors.py`:

```python
def map_your_provider_error(error: Exception, provider: str = "your_provider") -> ProviderError:
    """
    Map Your Provider API exceptions to unified error types.

    Args:
        error: Original exception from Your Provider SDK
        provider: Provider name (default: "your_provider")

    Returns:
        Mapped ProviderError subclass
    """
    error_message = str(error)

    # Import Your Provider SDK exceptions
    try:
        from your_provider import AuthenticationError as YourAuthError
        from your_provider import RateLimitError as YourRateLimitError
        from your_provider import APIError as YourAPIError
    except ImportError:
        YourAuthError = None
        YourRateLimitError = None
        YourAPIError = None

    # Map authentication errors
    if YourAuthError and isinstance(error, YourAuthError):
        return AuthenticationError(
            error_message,
            provider,
            error_code="authentication_error",
            original_error=error,
        )

    # Map rate limit errors
    if YourRateLimitError and isinstance(error, YourRateLimitError):
        # Extract retry-after if available
        retry_after = getattr(error, "retry_after", None)
        return RateLimitError(
            error_message,
            provider,
            retry_after=retry_after,
            error_code="rate_limit_error",
            original_error=error,
        )

    # Map API errors
    if YourAPIError and isinstance(error, YourAPIError):
        status_code = getattr(error, "status_code", None)

        if status_code and 500 <= status_code < 600:
            return ServerError(
                error_message,
                provider,
                status_code=status_code,
                error_code=f"server_error_{status_code}",
                original_error=error,
            )

        if status_code == 400:
            return InvalidRequestError(
                error_message,
                provider,
                error_code="invalid_request",
                original_error=error,
            )

    # Generic error
    return ProviderError(
        error_message,
        provider,
        original_error=error,
    )
```

### Step 4: Create Response Transformer

Create transformer class in `core/llm/providers/transformers.py`:

```python
class YourProviderResponseTransformer:
    """
    Transforms Your Provider API responses to unified format.

    Handles both streaming chunks and complete responses.
    """

    @staticmethod
    def transform_your_provider_chunk(
        chunk: Dict[str, Any], model: str
    ) -> Optional[StreamingResponse]:
        """
        Transform Your Provider streaming chunk to unified format.

        Args:
            chunk: Raw Your Provider chunk dict
            model: Model name

        Returns:
            StreamingResponse or None if chunk has no content
        """
        if not chunk or "data" not in chunk:
            return None

        data = chunk["data"]

        # Extract text content
        if "text" in data:
            return StreamingResponse(
                delta=TextDelta(content=data["text"]),
                is_final=data.get("finished", False),
                raw_chunk=chunk,
            )

        # Extract tool call
        if "tool_call" in data:
            tool_call = data["tool_call"]
            return StreamingResponse(
                delta=ToolCallDelta(
                    tool_call_id=tool_call.get("id"),
                    tool_name=tool_call.get("name"),
                    tool_arguments_delta=tool_call.get("arguments", ""),
                ),
                is_final=False,
                raw_chunk=chunk,
            )

        # Final chunk with usage
        if data.get("finished"):
            usage = data.get("usage")
            if usage:
                return StreamingResponse(
                    delta=TextDelta(content=""),
                    usage=UsageInfo(
                        prompt_tokens=usage.get("prompt_tokens", 0),
                        completion_tokens=usage.get("completion_tokens", 0),
                        total_tokens=usage.get("total_tokens", 0),
                    ),
                    is_final=True,
                    raw_chunk=chunk,
                )

        return None

    @staticmethod
    def transform_your_provider_response(
        response: Dict[str, Any], model: str
    ) -> UnifiedResponse:
        """
        Transform complete Your Provider response to unified format.

        Args:
            response: Raw Your Provider response dict
            model: Model name

        Returns:
            UnifiedResponse with all content blocks
        """
        if not response or "data" not in response:
            raise ValueError("Invalid Your Provider response: missing data")

        data = response["data"]
        content_blocks: List[ContentBlock] = []

        # Text content
        text = data.get("text")
        if text:
            content_blocks.append(TextContent(text=text))

        # Tool calls
        tool_calls = data.get("tool_calls", [])
        for tool_call in tool_calls:
            content_blocks.append(
                ToolUseContent(
                    id=tool_call.get("id", ""),
                    name=tool_call.get("name", ""),
                    input=tool_call.get("input", {}),
                )
            )

        # Usage
        usage_dict = data.get("usage", {})
        usage = UsageInfo(
            prompt_tokens=usage_dict.get("prompt_tokens", 0),
            completion_tokens=usage_dict.get("completion_tokens", 0),
            total_tokens=usage_dict.get("total_tokens", 0),
        )

        return UnifiedResponse(
            content=content_blocks,
            usage=usage,
            model=model,
            provider=ProviderType.YOUR_PROVIDER,
            finish_reason=data.get("finish_reason"),
            raw_response=response,
        )
```

### Step 5: Implement Provider Class

Create `your_provider_provider.py` in `core/llm/providers/`:

```python
"""
Your Provider implementation using official Your Provider SDK.

Implements LLMProvider interface for Your Provider API with:
- Official your_provider.AsyncYourProviderClient client
- Streaming and non-streaming completions
- Tool calling with incremental JSON accumulation
- Error mapping to unified error hierarchy
- Usage tracking
"""

import logging
from typing import Any, AsyncIterator, Dict, List, Optional

from .base import LLMProvider
from .models import (
    YourProviderConfig,
    ProviderType,
    StreamingResponse,
    UnifiedResponse,
)
from .transformers import (
    YourProviderResponseTransformer,
    ToolCallAccumulator,
    ToolSchemaTransformer,
)
from .errors import map_your_provider_error
from .registry import register_provider


logger = logging.getLogger(__name__)


@register_provider(ProviderType.YOUR_PROVIDER)
class YourProviderProvider(LLMProvider):
    """
    Your Provider using official Your Provider SDK.

    Features:
    - AsyncYourProviderClient with automatic error handling
    - Streaming with tool call accumulation
    - Custom base_url support
    - Usage tracking

    Configuration:
        api_key: Your Provider API key (ypk-*)
        base_url: Optional custom endpoint
        model: Model name (e.g., your-model-v1)
        temperature: Sampling temperature (0.0-2.0)
        max_tokens: Maximum tokens to generate
        timeout: Request timeout in seconds
    """

    def __init__(self, config: YourProviderConfig):
        """
        Initialize Your Provider.

        Args:
            config: Validated Your Provider configuration
        """
        super().__init__(config)
        self.config: YourProviderConfig = config

        # Your Provider client (initialized in initialize())
        self._client: Optional[Any] = None

        # Tool accumulator for streaming
        self._tool_accumulator: Optional[ToolCallAccumulator] = None

        logger.debug(
            f"Your Provider created (model={config.model}, "
            f"base_url={config.base_url or 'default'})"
        )

    def validate_config(self, config: YourProviderConfig) -> None:
        """
        Validate Your Provider-specific configuration.

        Args:
            config: Configuration to validate

        Raises:
            ValueError: If configuration is invalid
        """
        if not config.api_key:
            raise ValueError("Your Provider API key is required")

    async def initialize(self) -> None:
        """
        Initialize Your Provider client.

        Creates AsyncYourProviderClient with API key and configuration.

        Raises:
            ProviderError: If client initialization fails
        """
        if self._initialized:
            logger.debug("Your Provider already initialized")
            return

        try:
            # Import Your Provider SDK
            from your_provider import AsyncYourProviderClient

            # Create client
            client_kwargs = {
                "api_key": self.config.api_key,
                "timeout": self.config.timeout,
            }

            # Add optional parameters
            if self.config.base_url:
                client_kwargs["base_url"] = self.config.base_url

            self._client = AsyncYourProviderClient(**client_kwargs)

            self._initialized = True
            logger.info(f"Your Provider initialized (model={self.model})")

        except ImportError as e:
            raise ImportError(
                "Your Provider SDK not installed. "
                "Install with: pip install your-provider"
            ) from e
        except Exception as e:
            logger.error(f"Failed to initialize Your Provider client: {e}")
            raise map_your_provider_error(e, "your_provider") from e

    async def call(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict[str, Any]]] = None,
        **kwargs: Any,
    ) -> UnifiedResponse:
        """
        Make non-streaming API call to Your Provider.

        Args:
            messages: Conversation messages
            tools: Optional tool definitions (unified format, will be transformed)
            **kwargs: Additional provider-specific parameters

        Returns:
            UnifiedResponse with normalized response format

        Raises:
            ProviderError: If the API call fails
        """
        self._validate_initialized()
        self._validate_not_shutdown()
        await self._track_request_start()

        try:
            # Transform tools to Your Provider format
            provider_tools = None
            if tools:
                # Tools are in unified format, transform to Your Provider format
                provider_tools = self._transform_tools_to_your_provider(tools)

            # Build request parameters
            request_params = {
                "model": self.model,
                "messages": messages,
                "temperature": self.config.temperature,
                "max_tokens": self.config.max_tokens,
            }

            if provider_tools:
                request_params["tools"] = provider_tools

            # Add optional parameters
            if self.config.top_p is not None:
                request_params["top_p"] = self.config.top_p

            request_params.update(kwargs)

            # Make API call
            logger.debug(f"Calling Your Provider API (model={self.model})")
            response = await self._client.chat.completions.create(
                **request_params
            )

            # Transform response to unified format
            unified_response = YourProviderResponseTransformer.transform_your_provider_response(
                response.model_dump(), self.model
            )

            logger.debug(
                f"Your Provider response received: "
                f"{unified_response.usage.total_tokens} tokens"
            )

            return unified_response

        except Exception as e:
            logger.error(f"Your Provider API call failed: {e}")
            raise map_your_provider_error(e, "your_provider") from e

        finally:
            await self._track_request_end()

    async def stream(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict[str, Any]]] = None,
        **kwargs: Any,
    ) -> AsyncIterator[StreamingResponse]:
        """
        Make streaming API call to Your Provider.

        Args:
            messages: Conversation messages
            tools: Optional tool definitions (unified format)
            **kwargs: Additional provider-specific parameters

        Yields:
            StreamingResponse chunks as they arrive

        Raises:
            ProviderError: If the API call fails
        """
        self._validate_initialized()
        self._validate_not_shutdown()
        await self._track_request_start()

        # Initialize tool accumulator
        self._tool_accumulator = ToolCallAccumulator(legacy_mode=False)

        try:
            # Transform tools to Your Provider format
            provider_tools = None
            if tools:
                provider_tools = self._transform_tools_to_your_provider(tools)

            # Build request parameters
            request_params = {
                "model": self.model,
                "messages": messages,
                "temperature": self.config.temperature,
                "max_tokens": self.config.max_tokens,
                "stream": True,
            }

            if provider_tools:
                request_params["tools"] = provider_tools

            if self.config.top_p is not None:
                request_params["top_p"] = self.config.top_p

            request_params.update(kwargs)

            logger.debug(f"Streaming from Your Provider (model={self.model})")

            # Make streaming API call
            stream = await self._client.chat.completions.create(
                **request_params
            )

            async for chunk in stream:
                # Transform chunk to unified format
                streaming_response = YourProviderResponseTransformer.transform_your_provider_chunk(
                    chunk.model_dump(), self.model
                )

                if streaming_response:
                    # Handle tool call accumulation
                    if isinstance(streaming_response.delta, ToolCallDelta):
                        delta = streaming_response.delta
                        completed_tools = self._tool_accumulator.add_delta(
                            tool_call_id=delta.tool_call_id,
                            name=delta.tool_name,
                            arguments_delta=delta.tool_arguments_delta,
                        )

                        # If tools completed, yield them
                        if completed_tools:
                            for tool in completed_tools:
                                yield StreamingResponse(
                                    delta=ToolCallDelta(
                                        tool_call_id=tool.id,
                                        tool_name=tool.name,
                                        tool_arguments_delta=None,
                                    ),
                                    is_final=False,
                                )
                    else:
                        yield streaming_response

                # Check if stream is finished
                if streaming_response and streaming_response.is_final:
                    logger.debug("Your Provider stream finished")
                    break

        except Exception as e:
            logger.error(f"Your Provider stream failed: {e}")
            raise map_your_provider_error(e, "your_provider") from e

        finally:
            self._tool_accumulator = None
            await self._track_request_end()

    def _transform_tools_to_your_provider(
        self, tools: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        """
        Transform tools from unified format to Your Provider format.

        Args:
            tools: Tools in unified (Anthropic-style) format

        Returns:
            Tools in Your Provider format
        """
        # If tools are already in Your Provider format, return as-is
        if tools and "function" in tools[0]:
            return tools

        # Transform from unified format to Your Provider format
        return ToolSchemaTransformer.to_openai_format(tools)

    async def _cleanup(self) -> None:
        """Cleanup Your Provider-specific resources."""
        if self._client:
            # Close client connections if needed
            self._client = None
        logger.debug("Your Provider cleanup complete")
```

### Step 6: Register Provider

Your provider is automatically registered via the `@register_provider` decorator. The registry will discover it when imported.

To ensure your provider is imported, add it to `core/llm/providers/__init__.py`:

```python
from .your_provider_provider import YourProviderProvider

__all__ = [
    "YourProviderProvider",
    # ... other providers
]
```

### Step 7: Update Profile Detection

Update `detect_provider_from_profile()` in `registry.py` to detect your provider:

```python
def detect_provider_from_profile(profile: Dict[str, Any]) -> ProviderType:
    """
    Detect provider type from profile configuration.

    Priority:
    1. Explicit provider field
    2. API key format detection
    3. API base URL detection
    4. Default to anthropic (for legacy configs)
    """
    # 1. Explicit provider field (highest priority)
    if "provider" in profile:
        provider_str = profile["provider"]
        try:
            return ProviderType(provider_str)
        except ValueError:
            raise ValueError(
                f"Unknown provider type: '{provider_str}'. "
                f"Must be one of: {[p.value for p in ProviderType]}"
            )

    api_key = profile.get("api_key", "")
    api_base = profile.get("api_base", "")

    # 2. API key format detection (in correct order!)

    # Your Provider FIRST (most specific prefix)
    if api_key.startswith("ypk-"):
        return ProviderType.YOUR_PROVIDER

    # Anthropic (more specific prefix)
    if api_key.startswith("sk-ant-"):
        return ProviderType.ANTHROPIC

    # Azure OpenAI (specific format)
    if api_base and "azure.com" in api_base:
        return ProviderType.AZURE_OPENAI

    # OpenAI (generic sk- prefix)
    if api_key.startswith("sk-"):
        return ProviderType.OPENAI

    # 3. API base URL detection
    if "yourprovider.com" in api_base:
        return ProviderType.YOUR_PROVIDER

    if "anthropic.com" in api_base:
        return ProviderType.ANTHROPIC

    if "openai.com" in api_base or "azure.com" in api_base:
        return ProviderType.OPENAI

    # 4. Default for legacy configs
    logger.debug("Could not detect provider, defaulting to anthropic")
    return ProviderType.ANTHROPIC
```

### Step 8: Update Config Creation

Update `create_config_from_profile()` in `registry.py`:

```python
def create_config_from_profile(
    profile: Dict[str, Any],
    provider_type: Optional[ProviderType] = None
) -> ProviderConfig:
    """Create provider config from profile dict."""
    # Detect provider if not specified
    if provider_type is None:
        provider_type = detect_provider_from_profile(profile)

    # Common fields
    base_fields = {
        "provider": provider_type,
        "model": profile.get("model", "your-model-v1" if provider_type == ProviderType.YOUR_PROVIDER else "gpt-4"),
        "temperature": profile.get("temperature", 0.7),
        "max_tokens": profile.get("max_tokens", 4096),
    }

    # ... (common field handling)

    # Provider-specific fields
    if provider_type == ProviderType.YOUR_PROVIDER:
        base_fields["region"] = profile.get("region")
        base_fields["max_retries"] = profile.get("max_retries", 3)
        return YourProviderConfig(**base_fields)

    # ... (other providers)
```

### Step 9: Add Tests

Create tests for your provider in `tests/unit/test_your_provider.py`:

```python
import unittest
from unittest.mock import AsyncMock, MagicMock, patch

from core.llm.providers.your_provider_provider import YourProviderProvider
from core.llm.providers.models import YourProviderConfig, ProviderType


class TestYourProviderProvider(unittest.TestCase):
    """Test Your Provider implementation."""

    def setUp(self):
        """Set up test fixtures."""
        self.config = YourProviderConfig(
            provider=ProviderType.YOUR_PROVIDER,
            api_key="ypk-test-key-12345",
            model="your-model-v1",
        )

    @patch('core.llm.providers.your_provider_provider.AsyncYourProviderClient')
    def test_initialize(self, mock_client_class):
        """Test provider initialization."""
        mock_client = AsyncMock()
        mock_client_class.return_value = mock_client

        provider = YourProviderProvider(self.config)

        # Should not be initialized yet
        self.assertFalse(provider.is_initialized)

        # Initialize
        import asyncio
        asyncio.run(provider.initialize())

        # Should be initialized
        self.assertTrue(provider.is_initialized)

        # Client should be created
        mock_client_class.assert_called_once()

    @patch('core.llm.providers.your_provider_provider.AsyncYourProviderClient')
    def test_call(self, mock_client_class):
        """Test non-streaming API call."""
        mock_client = AsyncMock()
        mock_response = MagicMock()
        mock_response.model_dump.return_value = {
            "data": {
                "text": "Hello, world!",
                "usage": {
                    "prompt_tokens": 10,
                    "completion_tokens": 5,
                    "total_tokens": 15,
                },
                "finish_reason": "stop",
            }
        }
        mock_client.chat.completions.create.return_value = mock_response
        mock_client_class.return_value = mock_client

        provider = YourProviderProvider(self.config)
        import asyncio
        asyncio.run(provider.initialize())

        messages = [{"role": "user", "content": "Hello"}]
        response = asyncio.run(provider.call(messages))

        # Should return UnifiedResponse
        self.assertEqual(response.model, "your-model-v1")
        self.assertEqual(response.provider, ProviderType.YOUR_PROVIDER)
        self.assertEqual(response.usage.total_tokens, 15)


if __name__ == "__main__":
    unittest.main()
```

---

## Architecture Overview

### System Architecture Diagram

```
                     +---------------------------+
                     |   Application Layer       |
                     |  (core/application.py)    |
                     +------------+--------------+
                                  |
                                  v
                     +---------------------------+
                     |      LLM Service          |
                     |  (core/llm/llm_service.py)|
                     +------------+--------------+
                                  |
                                  v
                     +---------------------------+
                     |     Provider Registry     |
                     |  (providers/registry.py)  |
                     +------------+--------------+
                                  |
          +-----------------------+-----------------------+
          |                       |                       |
          v                       v                       v
+------------------+    +------------------+    +------------------+
|   OpenAI         |    |   Anthropic      |    |   Your Provider  |
|   Provider       |    |   Provider       |    |   Provider       |
+------------------+    +------------------+    +------------------+
| - AsyncOpenAI    |    | - Anthropic      |    | - AsyncClient    |
|   Client         |    |   AsyncClient    |    |                  |
+------------------+    +------------------+    +------------------+
          |                       |                       |
          v                       v                       v
+------------------+    +------------------+    +------------------+
|  OpenAI Response |    |  Anthropic       |    |  Your Provider   |
|  Transformer     |    |  Response        |    |  Response        |
|                  |    |  Transformer     |    |  Transformer     |
+------------------+    +------------------+    +------------------+
                                  |
                                  v
                     +---------------------------+
                     |   Unified Response        |
                     |  (UnifiedResponse model)  |
                     +---------------------------+
```

### Request Flow Diagram

```
User Input
    |
    v
Application Layer
    |
    | 1. Get provider from registry
    v
Provider Registry
    |
    | 2. Return singleton provider instance
    v
LLMProvider (Your Provider)
    |
    | 3. Transform request to provider format
    v
Provider SDK Client
    |
    | 4. Make API call (streaming or non-streaming)
    v
Provider API
    |
    | 5. Return provider-specific response
    v
Provider Transformer
    |
    | 6. Transform to unified format
    v
Unified Response
    |
    | 7. Return to application
    v
Application Layer
    |
    v
Display to User
```

### Tool Call Flow Diagram

```
Application with Tools
    |
    | 1. Define tools in unified format
    v
Tool Schema Transformer
    |
    | 2a. Transform to OpenAI format (if needed)
    | 2b. Transform to Anthropic format (if needed)
    v
Provider API Request
    |
    | 3. Provider executes tool call
    v
Provider Response (Streaming)
    |
    | 4. Tool arguments arrive incrementally
    v
Tool Call Accumulator
    |
    | 5. Accumulate JSON fragments
    | 6. Detect when JSON is complete
    v
ToolUseContent (when complete)
    |
    | 7. Return to application for execution
    v
Tool Executor
    |
    | 8. Execute tool function
    v
Tool Result
    |
    | 9. Send result back to provider
    v
Provider API (next turn)
```

### Event System Integration

```
Application Layer
    |
    | pre_api_request hook
    v
Provider Registry
    |
    | pre_request hook
    v
LLMProvider
    |
    | Making API request...
    v
Provider API
    |
    | Response received
    v
LLMProvider
    |
    | post_response hook
    v
Provider Transformer
    |
    | post_api_response hook
    v
Application Layer
```

---

## Transformation Layer

The transformation layer converts provider-specific requests and responses to/from a unified format.

### Request Transformation

Requests are transformed from unified format to provider-specific format before being sent to the API.

**Unified Request Format:**

```python
{
    "messages": [
        {"role": "user", "content": "Hello"}
    ],
    "tools": [
        {
            "name": "get_weather",
            "description": "Get weather for location",
            "input_schema": {
                "type": "object",
                "properties": {
                    "location": {"type": "string"}
                },
                "required": ["location"]
            }
        }
    ],
    "temperature": 0.7,
    "max_tokens": 4096
}
```

**Provider-Specific Transformations:**

OpenAI Format:

```python
{
    "messages": [
        {"role": "user", "content": "Hello"}
    ],
    "tools": [
        {
            "type": "function",
            "function": {
                "name": "get_weather",
                "description": "Get weather for location",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {"type": "string"}
                    },
                    "required": ["location"]
                }
            }
        }
    ],
    "temperature": 0.7,
    "max_tokens": 4096
}
```

Anthropic Format (same as unified):

```python
{
    "messages": [
        {"role": "user", "content": "Hello"}
    ],
    "tools": [
        {
            "name": "get_weather",
            "description": "Get weather for location",
            "input_schema": {
                "type": "object",
                "properties": {
                    "location": {"type": "string"}
                },
                "required": ["location"]
            }
        }
    ],
    "temperature": 0.7,
    "max_tokens": 4096
}
```

### Response Transformation

Responses are transformed from provider-specific format to unified format after being received from the API.

**Streaming Response Transformation:**

Provider chunk (OpenAI):

```python
{
    "id": "chatcmpl-123",
    "choices": [{
        "delta": {"content": "Hello"},
        "finish_reason": null
    }]
}
```

Transformed to unified:

```python
StreamingResponse(
    delta=TextDelta(content="Hello"),
    is_final=False,
    raw_chunk={...}
)
```

**Complete Response Transformation:**

Provider response (OpenAI):

```python
{
    "id": "chatcmpl-123",
    "choices": [{
        "message": {
            "content": "Hello, world!",
            "tool_calls": null
        },
        "finish_reason": "stop"
    }],
    "usage": {
        "prompt_tokens": 10,
        "completion_tokens": 5,
        "total_tokens": 15
    }
}
```

Transformed to unified:

```python
UnifiedResponse(
    content=[
        TextContent(text="Hello, world!")
    ],
    usage=UsageInfo(
        prompt_tokens=10,
        completion_tokens=5,
        total_tokens=15
    ),
    model="gpt-4",
    provider=ProviderType.OPENAI,
    finish_reason="stop",
    raw_response={...}
)
```

### Streaming Delta Handling

The transformation layer handles incremental updates during streaming:

1. **Text Deltas**: Accumulate text chunks into complete content
2. **Tool Call Deltas**: Accumulate JSON fragments using `ToolCallAccumulator`
3. **Thinking Deltas**: Preserve extended thinking blocks (Anthropic)
4. **Usage Updates**: Extract usage info from final chunk

### ToolCallAccumulator

The `ToolCallAccumulator` class handles incremental JSON accumulation for streaming tool calls.

**Modes:**

- **LEGACY mode** (default): Buffer deltas, retrieve completed tools via `get_completed_tools()`
- **EXPLICIT mode**: Return newly completed tools immediately from `add_delta()`

**Usage Example:**

```python
# Initialize accumulator
accumulator = ToolCallAccumulator(legacy_mode=False)

# Add deltas as they arrive
completed_tools = accumulator.add_delta(
    tool_call_id="call_123",
    name="get_weather",
    arguments_delta='{"query": "test'
)
# Returns: None (not complete yet)

completed_tools = accumulator.add_delta(
    tool_call_id="call_123",
    name=None,
    arguments_delta='", "limit": 10}'
)
# Returns: [ToolUseContent(id="call_123", name="get_weather", input={...})]
```

**Key Features:**

- Handles multiple simultaneous tool calls
- Gracefully handles malformed JSON
- Tracks returned tools (EXPLICIT mode)
- Provides buffer status for debugging

---

## Tool Schema Conversion

The `ToolSchemaTransformer` provides bidirectional conversion between OpenAI and Anthropic tool schema formats.

### OpenAI Format

```python
{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get weather for location",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "City name"
                }
            },
            "required": ["location"]
        }
    }
}
```

### Anthropic Format

```python
{
    "name": "get_weather",
    "description": "Get weather for location",
    "input_schema": {
        "type": "object",
        "properties": {
            "location": {
                "type": "string",
                "description": "City name"
            }
        },
        "required": ["location"]
    }
}
```

### Conversion Methods

**Anthropic to OpenAI:**

```python
openai_tools = ToolSchemaTransformer.to_openai_format(anthropic_tools)
```

**OpenAI to Anthropic:**

```python
anthropic_tools = ToolSchemaTransformer.to_anthropic_format(openai_tools)
```

### Key Differences

1. **Wrapper Structure**: OpenAI wraps function definitions in a `{"type": "function", "function": {...}}` structure
2. **Parameter Field Name**: OpenAI uses `parameters`, Anthropic uses `input_schema`
3. **Nesting**: Anthropic format is flatter (no `function` wrapper)

### Conversion Guarantees

- **Reversible**: Round-trip conversion preserves schema
- **Validation**: Raises `ValueError` for invalid schemas
- **Compatibility**: Handles both OpenAI function format and direct parameter format

---

## Error Handling

The provider system uses a unified error hierarchy with user-safe messages.

### Error Hierarchy

```
ProviderError (base)
├── AuthenticationError
├── RateLimitError (with retry_after)
├── InvalidRequestError
│   └── ContextLengthExceededError
├── APITimeoutError
├── APIConnectionError
└── ServerError (with status_code)
```

### Error Mapping

Each provider implements an error mapping function:

```python
def map_openai_error(error: Exception, provider: str = "openai") -> ProviderError:
    """Map OpenAI SDK exceptions to unified error types."""
```

### Error Features

1. **Safe Messages**: All errors have `safe_message` attribute with sanitized content
2. **Original Error**: Preserves original exception for debugging
3. **Context**: Includes provider name and error code
4. **Retry Info**: `RateLimitError` includes `retry_after` duration
5. **Status Code**: `ServerError` includes HTTP status code

### Usage Example

```python
try:
    response = await provider.call(messages, tools)
except AuthenticationError as e:
    # Invalid API key
    print(f"Authentication failed: {e.safe_message}")
except RateLimitError as e:
    # Rate limited, can retry after delay
    if e.retry_after:
        await asyncio.sleep(e.retry_after)
except ContextLengthExceededError as e:
    # Message too long
    print("Message exceeds context length")
except ProviderError as e:
    # Generic provider error
    print(f"Provider error: {e.safe_message}")
```

---

## Security Features

The provider system includes enterprise-grade security features.

### 4-Tier Key Storage Fallback

```
Tier 1: OS Keychain (Recommended)
    ├─ macOS: Keychain
    ├─ Windows: Credential Manager (DPAPI)
    └─ Linux: Secret Service (Gnome Keyring, KWallet)

Tier 2: Encrypted File Storage (Fallback)
    ├─ AES-256-GCM encryption
    ├─ PBKDF2 key derivation (100,000 iterations)
    └─ Secure file permissions (0o600)

Tier 3: Environment Variables (CI/CD)
    └─ Read-only, no persistence

Tier 4: Plaintext Storage (Dev Only)
    ├─ Requires explicit opt-in
    └─ KOLLABOR_ALLOW_PLAINTEXT_KEYS=true
```

### Key Loading Priority

1. **Environment variables** (highest priority, overrides all)
2. **OS keyring** (secure, recommended)
3. **Encrypted file storage** (secure fallback)
4. **Plaintext storage** (development only, requires opt-in)
5. **Profile config** (legacy, auto-migrate to secure storage)

### URL Validation

- **Allowlist-based validation** for API endpoints
- **HTTPS enforcement** (except localhost)
- **Custom host support** via `KOLLABOR_ALLOWED_API_HOSTS`

```python
# Default allowed hosts
DEFAULT_ALLOWED_HOSTS = {
    "api.openai.com",
    "api.anthropic.com",
    "localhost",
    "127.0.0.1",
}

# Add custom hosts
export KOLLABOR_ALLOWED_API_HOSTS="custom-host.com,other-host.com"
```

### Logging Security

**Deep Recursive Redaction:**

- API keys (sk-*, sk-proj-*, sk-ant-*)
- Bearer tokens
- Authorization headers
- Passwords and secrets
- URLs with embedded keys

**Redaction Patterns:**

```python
PATTERNS = [
    (re.compile(r'sk-[a-zA-Z0-9_-]{20,}'), '[REDACTED-OPENAI-KEY]'),
    (re.compile(r'sk-proj-[a-zA-Z0-9_-]{20,}'), '[REDACTED-OPENAI-PROJECT-KEY]'),
    (re.compile(r'sk-ant-[a-zA-Z0-9_-]{20,}'), '[REDACTED-ANTHROPIC-KEY]'),
    (re.compile(r'Bearer\s+[a-zA-Z0-9_\-\.]{20,}'), 'Bearer [REDACTED]'),
    # ... more patterns
]
```

**Automatic Installation:**

```python
from core.llm.providers.security import setup_secure_logging

setup_secure_logging()  # Installs RedactingLogFilter on all loggers
```

---

## Hook Compatibility

The provider system integrates with the Kollabor CLI hook system for plugin extensibility.

### Hook System Overview

The hook system allows plugins to intercept and modify provider operations at key points in the request lifecycle.

### Key Hook Events

**Pre-Request Hooks:**

- `pre_user_input`: Before user input is processed
- `pre_api_request`: Before API call is made
- `pre_stream_request`: Before streaming API call

**Post-Response Hooks:**

- `post_api_response`: After API response is received
- `post_stream_response`: After streaming chunk is received
- `post_message_display`: After message is displayed

**Provider Lifecycle Hooks:**

- `provider_initialized`: After provider is initialized
- `provider_shutdown`: Before provider is shut down

### Hook Registration

Plugins register hooks via the event bus:

```python
from core.events.bus import EventBus
from core.events.models import EventType, HookPriority

class MyPlugin:
    def __init__(self, event_bus: EventBus):
        self.event_bus = event_bus
        self.register_hooks()

    def register_hooks(self):
        # Register pre-request hook
        self.event_bus.register_hook(
            event_type=EventType.PRE_API_REQUEST,
            handler=self.on_pre_request,
            priority=HookPriority.NORMAL
        )

    async def on_pre_request(self, context: Dict[str, Any]) -> Dict[str, Any]:
        """Modify request before API call."""
        # Log request details
        logger.info(f"Making request to {context.get('provider')}")

        # Add custom headers
        headers = context.get("headers", {})
        headers["X-Custom-Header"] = "value"

        # Return modified context
        context["headers"] = headers
        return context
```

### HookAdapter for Compatibility

The `HookAdapter` provides backward compatibility for plugins using old hook formats.

```python
from core.events.hook_adapter import HookAdapter

# Adapter handles both old and new hook formats
adapter = HookAdapter(event_bus)

# Register legacy hook
adapter.register_legacy_hook(
    event_name="pre_request",
    handler=legacy_handler
)
```

### Migrating Plugins

**Old Hook Format:**

```python
async def on_request(request_data):
    # Process request
    return modified_request
```

**New Hook Format:**

```python
async def on_request(context: Dict[str, Any]) -> Dict[str, Any]:
    # Context includes:
    # - provider: Provider type
    # - messages: Conversation messages
    # - tools: Tool definitions
    # - headers: HTTP headers
    # - metadata: Additional metadata

    # Process and return modified context
    return context
```

---

## API Reference

### Provider Registry

**Class:** `ProviderRegistry`

**Methods:**

```python
@classmethod
def register(cls, provider_type: ProviderType) -> callable:
    """
    Decorator to register a provider class.

    Args:
        provider_type: Provider type enum value

    Returns:
        Decorator function

    Example:
        @ProviderRegistry.register(ProviderType.OPENAI)
        class OpenAIProvider(LLMProvider):
            ...
    """

@classmethod
async def get_provider(cls, config: ProviderConfig) -> LLMProvider:
    """
    Get or create provider instance (singleton pattern).

    Args:
        config: Provider configuration

    Returns:
        Provider instance (singleton for provider type)

    Raises:
        ValueError: If provider type not registered
        ProviderError: If provider creation fails
    """

@classmethod
async def create_provider(cls, config: ProviderConfig) -> LLMProvider:
    """
    Create a new provider instance (non-singleton).

    Args:
        config: Provider configuration

    Returns:
        New provider instance

    Raises:
        ValueError: If provider type not registered
        ProviderError: If provider creation fails
    """

@classmethod
def list_providers(cls) -> List[ProviderType]:
    """List all registered provider types."""

@classmethod
def is_registered(cls, provider_type: ProviderType) -> bool:
    """Check if provider type is registered."""

@classmethod
async def shutdown_all(cls) -> None:
    """Shutdown all provider instances."""

@classmethod
async def shutdown_provider(cls, provider_type: ProviderType) -> bool:
    """Shutdown a specific provider instance."""
```

### Provider Detection

**Function:** `detect_provider_from_profile(profile: Dict[str, Any]) -> ProviderType`

Detects provider type from profile configuration.

**Priority:**
1. Explicit provider field
2. API key format detection
3. API base URL detection
4. Default to anthropic (for legacy configs)

**Returns:** `ProviderType`

**Raises:** `ValueError` if provider cannot be determined

### Config Creation

**Function:** `create_config_from_profile(profile: Dict[str, Any], provider_type: Optional[ProviderType] = None) -> ProviderConfig`

Creates provider config from profile dict.

**Args:**
- `profile`: Profile configuration dict
- `provider_type`: Optional provider type (auto-detect if None)

**Returns:** Validated `ProviderConfig` (OpenAI, Anthropic, or Azure)

**Raises:** `ValueError` if config is invalid or provider cannot be determined

### Tool Schema Transformer

**Class:** `ToolSchemaTransformer`

**Methods:**

```python
@staticmethod
def to_openai_format(anthropic_tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """
    Convert Anthropic tool schema to OpenAI format.

    Args:
        anthropic_tools: List of Anthropic tool schemas

    Returns:
        List of OpenAI tool schemas

    Raises:
        ValueError: If tool schema is invalid
    """

@staticmethod
def to_anthropic_format(openai_tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """
    Convert OpenAI tool schema to Anthropic format.

    Args:
        openai_tools: List of OpenAI tool schemas

    Returns:
        List of Anthropic tool schemas

    Raises:
        ValueError: If tool schema is invalid
    """
```

### Tool Call Accumulator

**Class:** `ToolCallAccumulator`

**Constructor:**

```python
def __init__(self, legacy_mode: bool = True):
    """
    Initialize tool accumulator.

    Args:
        legacy_mode: If True, uses LEGACY mode (buffer + get_completed_tools).
                    If False, uses EXPLICIT mode (add_delta returns tools).
    """
```

**Methods:**

```python
def add_delta(
    self,
    tool_call_id: Optional[str],
    name: Optional[str],
    arguments_delta: Optional[str],
) -> Optional[List[ToolUseContent]]:
    """
    Add incremental tool call delta.

    LEGACY mode: Returns None. Use get_completed_tools() to retrieve tools.
    EXPLICIT mode: Returns list of newly completed tools immediately.

    Args:
        tool_call_id: Unique identifier for this tool call
        name: Tool function name (optional)
        arguments_delta: JSON fragment (partial string)

    Returns:
        LEGACY mode: None
        EXPLICIT mode: List of newly completed ToolUseContent objects

    Raises:
        ValueError: If tool_call_id is None
    """

def get_completed_tools(self) -> List[ToolUseContent]:
    """
    Get all completed tool calls.

    LEGACY mode: Returns all tools with name and valid JSON.
    EXPLICIT mode: Returns tools not yet returned via add_delta().

    Returns:
        List of completed ToolUseContent objects
    """

def reset(self) -> None:
    """Clear all accumulated tool call state."""

def get_buffer_status(self) -> Dict[str, Dict[str, Any]]:
    """
    Get status of all buffered tool calls.

    Returns:
        Dict mapping tool_call_id to status info:
        {
            "name": str or None,
            "buffer_length": int,
            "buffer_preview": str (first 100 chars),
            "parseable": bool,
            "returned": bool (EXPLICIT mode only)
        }
    """
```

### Error Mapping Functions

```python
def map_openai_error(error: Exception, provider: str = "openai") -> ProviderError:
    """Map OpenAI API exceptions to unified error types."""

def map_anthropic_error(error: Exception, provider: str = "anthropic") -> ProviderError:
    """Map Anthropic API exceptions to unified error types."""
```

### Security Functions

```python
def setup_secure_logging() -> None:
    """
    Set up secure logging with redaction on all loggers.

    Installs RedactingLogFilter on:
    - Root logger
    - Provider loggers (openai, anthropic)
    - HTTP loggers (httpx, httpcore, urllib3)
    """
```

---

## Examples

### Basic Provider Usage

```python
from core.llm.providers.registry import ProviderRegistry
from core.llm.providers.models import ProviderConfig, ProviderType

# Create configuration
config = ProviderConfig(
    provider=ProviderType.OPENAI,
    api_key="sk-...",
    model="gpt-4",
    temperature=0.7,
    max_tokens=4096
)

# Get provider (singleton)
provider = await ProviderRegistry.get_provider(config)

# Make non-streaming call
messages = [{"role": "user", "content": "Hello!"}]
response = await provider.call(messages)

print(response.get_text_content())
print(f"Tokens used: {response.usage.total_tokens}")
```

### Streaming Usage

```python
# Streaming call
async for chunk in provider.stream(messages):
    if isinstance(chunk.delta, TextDelta):
        print(chunk.delta.content, end="", flush=True)
    elif chunk.is_final:
        print(f"\n\nTokens: {chunk.usage.total_tokens}")
```

### Tool Calling

```python
# Define tools
tools = [
    {
        "name": "get_weather",
        "description": "Get weather for location",
        "input_schema": {
            "type": "object",
            "properties": {
                "location": {"type": "string"}
            },
            "required": ["location"]
        }
    }
]

# Make call with tools
response = await provider.call(messages, tools)

# Check for tool use
tool_uses = response.get_tool_uses()
if tool_uses:
    for tool_use in tool_uses:
        print(f"Tool: {tool_use.name}")
        print(f"Args: {tool_use.input}")
```

### Custom Provider Implementation

See the [Provider Implementation Guide](#provider-implementation-guide) for a complete example of implementing a custom provider.

---

## Best Practices

1. **Use Singleton Instances**: Always use `ProviderRegistry.get_provider()` for production code
2. **Handle Errors Gracefully**: Always catch `ProviderError` and its subclasses
3. **Validate Configuration**: Use Pydantic validators for provider-specific validation
4. **Transform Responses**: Always use response transformers for consistency
5. **Accumulate Tools**: Use `ToolCallAccumulator` for streaming tool calls
6. **Secure Keys**: Use OS keyring or encrypted file storage for API keys
7. **Sanitize Logs**: Always use `RedactingLogFilter` to prevent key leakage
8. **Test Mappings**: Write comprehensive tests for error mapping functions
9. **Document Formats**: Document any provider-specific request/response formats
10. **Version Pinning**: Pin SDK versions in requirements for stability

---

## Further Reading

- [Kollabor CLI Architecture](../architecture/README.md)
- [Plugin Development Guide](../plugins/README.md)
- [Hook System Reference](hook-system.md)
- [Error Handling Guide](error-handling.md)
- [Security Best Practices](security.md)
